Compare commits

..
Author SHA1 Message Date
SoftFever 638dd9f100 Merge branch 'main' into feature/chromastack 2026-10-16 20:29:45 +08:00
SoftFever 82aee1abdd Cache compiles in the macOS build script, including Xcode builds (#16553) 2026-10-16 19:36:50 +08:00
Ian Chua cefdd9b609 fix: max filepath length on windows (#16552)
# Description

<!--
> Please provide a summary of the changes made in this PR. Include
details such as:
  > * What issue does this PR address or fix?
  > * What new features or enhancements does this PR introduce?
> * Are there any breaking changes or dependencies that need to be
considered?
-->

Windows traditionally limits many file operations to `MAX_PATH` (260
characters). Deeply nested plugin packages can exceed this limit during
`.whl` extraction, leaving files missing and causing the plugin to fail
to load.

This change adds a shared extended_path helper that prepares paths for
Windows file operations using the extended-length prefixes `\\?\` or
`\\?\UNC\`. The helper is used for ZIP archive I/O and extraction
destinations, including plugin wheel extraction. This avoids relying on
the Windows long-path setting, which changes machine-wide settings.

Paths remain unprefixed outside file operations. Code that bypasses the
shared ZIP helpers may still require separate long-path handling.

See [Microsoft's documentation on path length
limitation](https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=registry)

# Screenshots/Recordings/Graphs

<!--
> Please attach relevant screenshots to showcase the UI changes.
> Please attach images that can help explain the changes.
-->

## Tests

<!--
> Please describe the tests that you have conducted to verify the
changes made in this PR.
-->
Tested on Windows 11 by using a portable installation of OrcaSlicer and
placing it in some really long filepath length e.g.
`C:/somename/<240 characters long folder path>/OrcaSlicer` and then
running and trying to install any plugin locally or on OrcaCloud.

<!--
> A guide for users on how to download the artifacts from this PR.
-->

[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
2026-10-16 18:47:49 +08:00
SoftFever caade80624 Keep the texture picker from stalling on open (#16547)
# Description

Opening the texture picker decoded every texture in the expanded
sections on the GUI thread in a single frame, each one twice at full
size. This stalled the UI on large libraries, and the full-size decodes
stayed in memory afterwards.

- Picker thumbnails are loaded lazily: only tiles inside the visible
scroll area are considered, at most 2 uncached decodes per frame, and
the rest fill in over the following frames. Deferred tiles draw as a
placeholder.
- Those follow-up frames are requested with `request_extra_frame()`.
Marking the canvas dirty from inside the render did not schedule another
idle pass, so thumbnails waited for the next mouse event.
- The selected texture is still centred on open, even when it is off
screen. The scroll reveal runs before the visibility check so that tile
is loaded on the next frame.
- Library validation no longer decodes pixels. The new
`png::is_decodable_8bit_png()` reads only the PNG header and accepts
8-bit grey or RGB/RGBA, which is what `decode_png()` and
`decode_colored_png()` take.
- Picker thumbnails are decoded without entering the decode cache
(`decode_height_texture(layer, keep_decoded = false)`). Only the 128 px
thumbnail is kept per library entry; the full-size decode is made
transiently and freed.

Tradeoff: the header-only check does not read pixel data, so a PNG with
a valid header but corrupt or truncated pixel data now passes validation
and fails when the texture is loaded. The previous full decode caught
that case.

Each thumbnail decode still runs at full size on the GUI thread, one at
a time. Peak memory per decode is unchanged; a streaming downscale would
be needed to reduce it.

# Screenshots/Recordings/Graphs

Old:


https://github.com/user-attachments/assets/03038532-396f-48c2-898c-aa06ef83e5f5


New:


https://github.com/user-attachments/assets/49fdc31a-ce1b-470a-88a7-4c6319f03b81



## Tests

Not yet run. Suggested checks:
- Open the texture picker with a library of many textures, and do not
move the mouse: tiles should fill in over a few frames without a visible
stall.
- Open the picker with a layer whose texture is far down the list: it
should be centred and loaded.
- Import a valid 8-bit grey and an RGB PNG, plus an unsupported one
(16-bit, palette, or grey-alpha): only the supported ones should be
accepted as-is, and the rest converted on load.
- Watch memory while scrolling through a large colour library: it should
not grow by the full decoded size of every texture shown.

<!--
> A guide for users on how to download the artifacts from this PR.
-->

[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
2026-10-16 18:11:46 +08:00
Lam Wei Lun cbd8ce98d5 Keep the texture picker redrawing while thumbnails load
Marking the canvas dirty from inside the render does not schedule another
idle pass, so deferred thumbnails waited for the next mouse event. Asking
for an extra frame does.
2026-10-16 17:55:37 +08:00
Lam Wei Lun 99714afc4c Keep picker thumbnails from retaining full-size texture decodes
The thumbnail is downscaled on upload, but the full decode it was made
from stayed in the decode cache for as long as the library entry lived.
Thumbnail decodes now skip the cache, so only the 128 px thumbnail is
kept per library texture.
2026-10-16 17:49:35 +08:00
ExPikaPaka a72aa236c4 ChromaStack: design doc
docs/HLSD/chromastack.md: what the colour model is and why it is
Beer-Lambert, what the solve does, how a stack attaches to a texture
displacement layer, why the swaps are height ranges, what the preview
shader draws, and the geometry the whole thing assumes.
2026-10-16 11:44:33 +02:00
ExPikaPaka 8baeba1341 ChromaStack: the layer card, the swaps and the preview shader
Add layer asks which kind of layer it is making - a relief layer, a height
map pushed into the surface, or a ChromaStack picture - and the layer
keeps the answer, so a card never shows a control that cannot do anything
on it. Depth, Midlevel, Smoothing, Invert, Edge fade, Tile and Blend are
absent from a picture card: each would move the relief's steps off the
print's layer boundaries, or has nothing to act on. Tile size becomes
Picture size. A picture cannot be reordered, since anything printed over
it buries the colours, and an object can hold only one, refused when the
layer is added rather than when its swaps would overwrite the first one's.

The card is the feature in one block: the picture and the predicted print
side by side, how far the prediction lands from the picture, the base
measured from the painted face, a row per band with the filament's name
and an editable TD, and the colours the stack reaches as a ramp - computed
from the stack alone, so it answers whether these filaments can print this
picture before a solve is spent finding out they cannot.

The swaps go onto the object as height ranges with extruder and
layer_height set, so they are object-relative, saved in the project and
visible in the object list; ObjectList::rebuild_layer_ranges() mirrors
them in the tree without touching the selection, which inside a gizmo
would tear the gizmo down.

The colours exist nowhere else - they are blends of translucent filaments,
not filaments, so the scene's per-triangle filament assignment cannot
express them - so the gizmo draws them itself, with a shader that reads
each fragment's own print Z. A swap happens at one Z across the whole
plate, so that is what decides a surface's colour, a band edge lands on
the layer boundary however coarse the mesh is, and it still works after a
bake when the height map is attached to nothing on screen. On top of the
colour it shades like printed plastic: a wrapped terminator and a grazing
glow scaled by the top filament's TD, and layer lines on the walls, all
fading out through fwidth once a pixel covers more than half a layer.
2026-10-16 11:44:33 +02:00
ExPikaPaka e0a6000d50 ChromaStack: the colour model, and transmission distance per filament
A picture is reproduced on one face by stacking thin layers of a few
filaments, swapping filament at fixed layer boundaries, and varying how
high each pixel is built up. Filament is translucent, so a column that
stops part-way through a run shows a blend of that run's filament and what
is under it - which is where the colours come from, far more of them than
there are filaments loaded.

libslic3r/ChromaStack.hpp holds the whole model, free of the GUI. A stack
is a list of bands (a filament and its layer count, bottom-up) on a base
of solid backing. chroma_palette() turns it into the colour shown at every
column height, by Beer-Lambert absorption with each filament's
transmission distance; chroma_solve() maps every pixel to the height whose
colour is closest in CIELAB, optionally diffusing the rounding error, and
returns the 8-bit height map and the colours it predicts. Beer-Lambert is
the only model that stays consistent applied per layer, so the palette
does not depend on how the stack was cut into bands, and a test asserts
it.

chroma_layers_to_opaque() and chroma_distinct_levels() are what make a
stack's behaviour legible: how many layers a filament needs to hide what
is under it, and how many of a picture's layers change the colour at all.
chroma_ranges() turns the bands into object height ranges, which is how
the slicer is told to print a Z band in another filament.

Transmission distance is a property of the material, so it is a filament
preset option: filament_transmission_distance, with no effect on slicing.
2026-10-16 11:44:33 +02:00
Lam Wei Lun 3d07663ab2 Merge branch 'main' into fix/texture-picker-lazy-png-header 2026-10-16 15:41:04 +08:00
HanifKoh 29b6bbb8c9 Keep Slice and Print Visible When Plugin Page Tabs Overflow (#16541) 2026-10-16 15:39:02 +08:00
Lam Wei Lun 8b6c074c69 Fix publish dialog tab clipping and extruder switch distortion (#16543)
* Fix TabCtrl clipping titles and indicator dots

Button measures a label plus icon without the icon gap that render draws,
so icon tabs were ellipsized and the indicator dot was cut off. Widen the
horizontal padding so the measured width covers the gap.

* Re-layout publish dialog when extruder switch labels change

Setting the indicator bullet on a switch option changed its width without
laying out the page, leaving the switch distorted until the next layout.
2026-10-16 15:37:17 +08:00
Error404JoyNotFound 109d09e0b8 Fix multi-tool standby heaters by turning off unused extruders (#15849)
With ooze prevention on, an extruder that has no layers left is set to 0 °C when it is parked, instead of its standby temperature. On IMEX printers where several filaments share one heater, the heater is only turned off once every filament on it is done.
2026-10-15 16:35:53 -05:00
Matias Alejandro Yocca b00237807d Fix crash when dragging objects in sequential printing mode (#16029)
The clearance preview shown while dragging sorted instances with a comparator that is not a strict weak ordering, which aborts with libc++ debug hardening. The preview now uses object-list order, with each object's copies in their last validated print order, which is the order print validation uses. The height check is now in SequentialPrintClearance.hpp, with unit tests.
2026-10-15 16:13:28 -05:00
Lam Wei Lun 4149401c53 Keep the texture picker from stalling on open
Opening the texture picker decoded every texture in the expanded sections
on the GUI thread in one frame, each one twice at full size. Only the
visible tiles now load, at most a couple per frame, and the rest fill in
over the following frames.

The library validation no longer decodes the pixels: a header-only check,
is_decodable_8bit_png(), accepts exactly what the two decoders take.
2026-10-16 02:41:12 +08:00
mosfet80 f6cf18fb96 Update libjpeg-turbo to 3.2.0 (#16098)
Also removes the Linux-only JPEG_VERSION block, so Linux builds libjpeg with the default 6.2 ABI like Windows and macOS.
2026-10-15 12:47:21 -05:00
SoftFever 4ee1b9c6e9 Add a ResourceTexture plugin capability for height-map texture packs (#16473)
# Description

Adds a fifth Python plugin capability type, `ResourceTexture`, which
contributes
height-map textures to the Texture Displacement picker. A plugin returns
a
directory of images; they appear as selectable textures in the picker,
grouped
under one collapsible section per pack. Disabling or uninstalling a pack
removes
them again, and projects that already use one of its textures keep
working,
because a layer holds its own image bytes.

**The plugin-facing contract** is one method:

```python
class WoodPack(orca.resource_texture.ResourceTexturePluginCapabilityBase):
    def get_name(self):
        return "Wood Pack"
    def get_texture_dir(self):
        return os.path.join(os.path.dirname(__file__), "textures")
```

The host scans one non-recursive level of that directory, accepting the
same
formats a hand-picked image already accepts (`.png/.jpg/.jpeg/.bmp`),
and decodes
them through the same `load_texture_image_data()` the picker uses for
the user's
own images, so a pack texture and a hand-picked one behave identically.

**Distribution is `.whl` only for v1.** `install_plugin` copies exactly
one
file, so a `.py` plugin cannot ship sibling images; a wheel is a single
file and
the loader already extracts it on demand.

**Files of interest**

- `src/slic3r/plugin/pluginTypes/resourceTexture/` — the capability, its
  trampoline and its pybind base, mirroring the Pages type.
- `src/slic3r/plugin/host/PluginResourceTextures.{hpp,cpp}` — host
registry,
  free of wx and GUI includes so it can sit beside `PluginManager`.
- `src/slic3r/GUI/TextureLibrary.{hpp,cpp}` — the third texture source,
the
  `pack_name` provenance on an entry, and a generation counter.
- `src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp` — the picker.
- `src/slic3r/plugin/PluginFsUtils.{hpp,cpp}` —
`collect_resource_texture_files()`.

**Behaviour change worth a reviewer's attention:** the picker popup is
now a
fixed size that scrolls rather than sizing itself to its content, and it
gains
one collapsible section per pack. The sizing applies to every user, not
only
those with packs installed. With no packs installed the picker otherwise
behaves
exactly as before.

**Not in scope for v1:** folder or `.zip` install for non-programmers,
and
preset-referenceable pack textures (which would need a plugin-backed
`ConfigOptionDef::plugin_type`).

# Screenshots/Recordings/Graphs


https://github.com/user-attachments/assets/b96787cf-b475-4698-826d-5db8b9f386f1

## Tests

New suite `tests/slic3rutils/test_plugin_resource_texture.cpp`, 19
cases:

- Validator: a file inside the root is listed; a parent-directory escape
and an
absolute directory outside the root are refused; a symlink pointing
outside is
skipped; files in a subdirectory are not listed; non-regular entries are
skipped; the extension whitelist is case-insensitive and rejects
near-misses;
  the listing stops at 64 files; a missing directory yields an error.
- Listing and lifecycle: a pack's images appear and clear on disable and
unload;
an empty pack contributes nothing; two distinctly named packs each stay
one
contiguous run; a second pack declaring a loaded capability name is
refused;
  reinstalling with different bytes at the same path changes the decoded
  content.
- Hostile overrides: `get_texture_dir()` returning `data_dir()` or
`resources_dir()` is refused and the pack loads with no textures; a
raising
  override still loads the pack.
- Decoding: PNG and BMP from a pack decode through the same path as the
same
  files picked by hand.
- Persistence: a project keeps a pack texture after the pack is
uninstalled,
  bytes unchanged across a re-save.
- Wheel metadata: a minimal resource-texture wheel is accepted.

Known gaps, for transparency: the wheel path is covered only at the
metadata
gate, not through a full extraction-and-import lifecycle; there is no
test for a
registry callback after interpreter shutdown, as there is no seam for it
without
reaching into `src/`; and `.jpg` decode parity is covered by the listing
rather
than by a real fixture, since no JPEG encoder is available to synthesise
one.

Manually exercised in-app on Linux with a locally built pack wheel (five
colour
PNGs): install, enable, textures listed under the pack's own section,
then
disable and uninstall remove them. The new suite itself has not been run
locally — CI is the first execution.
2026-10-16 00:35:30 +08:00
SoftFever bc8d0a7858 Wait for the cloud preset sync before offering to migrate filament presets (#16523) 2026-10-16 00:27:33 +08:00
SoftFever e0651ec31d Let ccache cache precompiled-header compiles in Linux builds (#16519) 2026-10-16 00:09:08 +08:00
Lam Wei LunandSoftFever 0caefcf4aa Linux: only disable WebKit compositing for WebKitGTK older than 2.42 (#16385)
* Linux: only disable WebKit compositing for WebKitGTK older than 2.42

The default Wayland path set WEBKIT_DISABLE_COMPOSITING_MODE=1 whenever the
session had both DISPLAY and WAYLAND_DISPLAY, i.e. any Wayland session with
XWayland. That forces WebKitGTK accelerated compositing off and also disables
threaded scrolling, so the Device tab web view (Mainsail/Fluidd) lagged unless
the app was launched with GDK_BACKEND=x11, the only path that skipped the
variable. Disabling compositing also turns off threaded scrolling.

The XWayland compositing bug the fallback worked around is fixed in WebKitGTK
>= 2.42, so apply it only for older WebKit. WebView::NeedsCompositingWorkaround()
reads the runtime WebKitGTK version; the X11 opt-in path is unchanged.

Behavior is unchanged on WebKit < 2.42, on pure X11, and on native Wayland
without XWayland. The wxWidgets skill reference is updated to match.

* Linux: use WebKit's shared-memory transport for the NVIDIA DMABUF renderer

The Device tab web view is slow on NVIDIA proprietary drivers even with
accelerated compositing left on: since the DMABUF renderer became the
default in WebKitGTK 2.42, the driver cannot back it, and web views fall
back to a slow path. Gating the old WEBKIT_DISABLE_COMPOSITING_MODE
fallback on WebKit < 2.42 is the wrong axis, because the failure is
version-independent from 2.42 on.

Force the shared-memory transport with WEBKIT_DMABUF_RENDERER_FORCE_SHM=1.
That keeps compositing and threaded scrolling and skips only the hardware
path. Also set WEBKIT_FORCE_DMABUF_RENDERER=1, which clears the isNVIDIA()
early return that Debian/Ubuntu/openSUSE patch into
rendererBufferTransportMode() and which otherwise returns before the
FORCE_SHM check is reached.

Both are set before the X11-vs-Wayland split so the workaround also covers
XWayland, and only when WebKitGTK is new enough to honour FORCE_SHM
(>= 2.44, WebView::SupportsForceSharedMemory()).

The AppImage launcher exported the superseded
WEBKIT_DISABLE_DMABUF_RENDERER=1, which is read first, leaves the transport
set empty and so disables compositing outright; replace it with the same
pair. The wxWidgets skill reference is updated to match.

* Linux: drop the AppImage WebKit DMABUF exports; correct the skill notes

The launcher cannot gate on the runtime WebKitGTK version, so exporting
WEBKIT_DMABUF_RENDERER_FORCE_SHM/WEBKIT_FORCE_DMABUF_RENDERER there would
clear a distro isNVIDIA() guard on hosts older than 2.44, where FORCE_SHM
is ignored, re-exposing the broken hardware path. The binary already sets
both with the correct gate, so remove them and say who owns the workaround.

Also correct two claims in the skill reference. An empty transport set only
means "no accelerated compositing" from 2.44 on; 2.42/2.43 still fall back
to the Wayland/X11 renderers, removed in the same release that added
FORCE_SHM. And threaded scrolling is off by default in 2.44/2.45 (on from
2.46), so FORCE_SHM keeps compositing rather than scrolling. Document the
module-level detection cost and the WEBKIT_DMABUF_RENDERER_FORCE_SHM=0
opt-out.

* Flatpak: force WebKit's shared-memory transport instead of disabling DMABUF

The entrypoint exported WEBKIT_DISABLE_DMABUF_RENDERER=1 for NVIDIA. WebKit
reads that variable first and leaves the transport set empty, which turns
accelerated compositing off, so the Device tab still lagged for NVIDIA
Flatpak users, and the binary could not override it because its setenv is
non-replacing.

The runtime's WebKitGTK is vanilla, so there is no distro isNVIDIA() guard
to clear and WEBKIT_DMABUF_RENDERER_FORCE_SHM=1 alone is the right switch:
compositing stays on and only the hardware DMABUF path is skipped.

* Flatpak: drop the WebKit NVIDIA export; the binary owns the workaround

/proc/driver/nvidia/version is readable inside the Flatpak sandbox, so the
binary's NVIDIA branch already sets WEBKIT_DMABUF_RENDERER_FORCE_SHM with
the correct runtime gate and the entrypoint's own export is redundant.

Verified with:
  flatpak run --command=cat com.orcaslicer.OrcaSlicer /proc/driver/nvidia/version
which prints the NVRM version (driver 615.71.09) from inside the sandbox.

* Linux: say what the XWayland compositing gate actually tests

DISPLAY and WAYLAND_DISPLAY are both set in a native Wayland session as
well, because GNOME and KDE run Xwayland as part of the session and export
DISPLAY for it whether or not this process is an X client. The pair means
XWayland is available in the session, not that GTK picked X11 for this
process, so "(an XWayland session)" reads as more than the condition
establishes.

Say what the pair means, and name the case the fallback actually covers:
the X11 backend inside a Wayland session, which is what the forced
GDK_BACKEND=x11 above produces in a build without EGL.

Comment only; behaviour is unchanged.

* Linux: apply the XWayland compositing fallback only where GTK picks X11

The default path set WEBKIT_DISABLE_COMPOSITING_MODE whenever DISPLAY and
WAYLAND_DISPLAY were both set, but a native Wayland app inherits both, so a
build with EGL disabled accelerated compositing — and with it threaded
scrolling — while running on Wayland, where the XWayland bug does not apply.

The only default-path case that really runs through XWayland is the forced
GDK_BACKEND=x11 used when wx lacks EGL, so set the variable there. The
backend cannot be re-checked later: that branch exports GDK_BACKEND itself,
so the force and a user's GDK_BACKEND=x11 opt-in, which deliberately keeps
compositing, would be indistinguishable.

Behaviour is unchanged for WebKitGTK >= 2.42, where the gate already skipped
the variable, and for builds without EGL. A build with EGL on WebKitGTK <
2.42 in a Wayland session no longer loses compositing and threaded scrolling.

The wxWidgets skill reference is updated to match.

---------

Co-authored-by: SoftFever <103989404+SoftFever@users.noreply.github.com>
2026-10-15 22:35:56 +08:00
SoftFever 86905a8a20 Use a keyboard icon for the shortcut button and fix unreadable guide tooltips (#16515)
* Fix unreadable text in wiki and video guide tooltips

* Use a keyboard icon for the gizmo shortcut button

* Fix blank tooltips on the Move and Scale gizmo combo boxes
2026-10-15 20:28:13 +08:00
Noisyfox d4fabbf7c8 Fix AMS gradient rendering, color presets and printer writeback (#16252)
* Fix external spool gradient and multicolor rendering

* Display AMS gradients and multicolors in color popup

* Preserve AMS gradient selection through color popup

* Match popup selections by full filament color data

* Align AMS Lite gradient geometry with BambuStudio

* Align AMS color palette with BambuStudio presets

* Match Bambu circular color rendering without background masks

* Preserve AMS gradient colors when applying filament settings

* Match AMS preview gradient swatches to the solid color shape
2026-10-15 19:43:07 +08:00
SoftFever 51efb24752 Complete traditional Chinese translations (#16513) 2026-10-15 19:32:54 +08:00
SoftFever cf9ec18df5 Complete the Simplified Chinese translations (#16512) 2026-10-15 19:32:30 +08:00
Lam Wei Lun 99de46b9d0 Rename the texture picker's pack section and OrcaCloud shortcut
Use "Texture libraries" for the plugin pack group and "Explore more
texture libraries" for the button that opens the OrcaCloud plugin hub.
2026-10-15 19:19:28 +08:00
Tobias Glothandyw4z 258f927a0f Fix: Crash by null mainframe in BackgroundSlicingProcess for CLI (#15367)
* add nullptr check

* use plater from app instead mainframe

---------

Co-authored-by: yw4z <ywsyildiz@gmail.com>
2026-10-15 14:14:26 +03:00
Lam Wei Lun b8d40ed61f Load the OrcaCloud plugin page directly when the pane is already open
The texture picker's Download from OrcaCloud shortcut asks for a specific page. Re-running the
session handoff to reach it flashed the sign-in interstitial every time; instead an already-open
pane is navigated straight to the page URL, keeping its session. The handoff is still used on the
first open, where a session has to be established.

set_cloud_mode() gains an optional page_url for this. The homepage nav passes none, so its ordinary
re-open keeps the page the pane already holds (guide §8/§10).
2026-10-15 18:46:30 +08:00
Lam Wei Lun 54e6df0235 Group texture packs under one picker section and add a Download from OrcaCloud shortcut
The texture picker lists every plugin pack as an indented, collapsible child of one
"Resource Texture" section, which now always renders so the new button is reachable even
before any pack is installed. A pack whose name carries a " by ..." suffix shows that part
on a smaller, dimmed second line, keeping the name line short.

The button switches to the Home tab and opens the OrcaCloud pane on the plugin hub filtered
to resource textures. A native open now also drives the homepage shell (nav highlight and the
cloud-mode layout), and the requested page is remembered so a sign-in re-seed keeps it.

Also fixes the PluginsDialog fuzzy-search script path, which pointed at a directory the file
is not in.
2026-10-15 18:10:33 +08:00
SoftFever 79ed246d26 Refresh the texture picker's look and scrolling 2026-10-15 15:34:05 +08:00
SoftFever 92b5301bf6 Fix trackpad scroll bounce in ImGui panels 2026-10-15 15:34:05 +08:00
SoftFever 20c157b0f4 Merge branch 'main' into feature/resource-texture-pack-plugins 2026-10-15 13:57:38 +08:00
Lam Wei Lun 4cf16febf6 Fix plugin unit test 2026-10-14 18:23:32 +08:00
Lam Wei Lun 70e52a105f Fix clang tidy 2026-10-14 17:38:52 +08:00
Lam Wei Lun b525cb79e1 Merge main 2026-10-14 17:31:35 +08:00
Lam Wei Lun 86ab598899 Add a ResourceTexture plugin capability for height-map texture packs
A fifth Python plugin capability type, ResourceTexture, contributes
height-map textures to the Texture Displacement picker. A pack returns a
directory of images; the host validates it once at load and lists the
images under a section of their own.

- New capability type ResourceTexture, type string "resource-texture",
  exposed to plugin authors as
  orca.resource_texture.ResourceTexturePluginCapabilityBase with one
  required method, get_texture_dir().
- The directory is resolved once on the load worker, under the GIL, and
  cached as a plain string: the picker runs inside the ImGui frame, where
  a trampoline call would block on the GIL behind a load/unload worker, or
  throw past EndPopup.
- Validation resolves the directory and requires containment in the plugin
  root, re-checked per file so a symlink inside the pack cannot resolve
  outside it. Containment is the only security control here and it guards
  exfiltration: layer bytes are embedded in every saved .3mf, so a pack
  that could return ~/.ssh/id_rsa would leak it through any shared project.
- New host registry PluginResourceTextures, filled from both the plugin
  and the capability lifecycle callbacks plus a startup sweep. It holds
  strings only and never a capability reference, so nothing outlives
  Py_Finalize.
- The picker lists pack textures read-only, one collapsible section per
  pack, in a fixed-size scrolling popup whose height follows the window. A
  generation counter invalidates the cached listing, and each gizmo's
  cached thumbnails, when the set of packs changes.
- Tests in tests/slic3rutils/test_plugin_resource_texture.cpp cover the
  validator (parent-directory and absolute escapes, an escaping symlink,
  the extension whitelist, the 64-file cap), the listing and its
  load/disable/re-enable/unload lifecycle, hostile get_texture_dir()
  overrides, decode parity with hand-picked files, and a .3mf round trip
  with the pack uninstalled.

No persistence work is needed: a layer carries its own image bytes and 3MF
writes them into the archive, so uninstalling a pack leaves existing
projects intact.

Behaviour change worth noting for review: the picker popup is now a fixed
size that scrolls, rather than sizing itself to its content, and it gains
one collapsible section per pack. That sizing applies to every user, not
only those with packs installed.
2026-10-14 17:15:39 +08:00
102 changed files with 15944 additions and 7513 deletions
@@ -569,12 +569,18 @@ on the **first** call. So:
#endif
```
**Before GTK starts**, only the environment exists. `CLI::run` (`src/OrcaSlicer.cpp`) branches on
`GDK_BACKEND` (an `x11` prefix means the X11 opt-in). On the default path it forces `GDK_BACKEND=x11`
when wx lacks EGL and `WAYLAND_DISPLAY` is set. It sets `WEBKIT_DISABLE_COMPOSITING_MODE=1`
(non-replacing) only when both `DISPLAY` and `WAYLAND_DISPLAY` are set, and calls `XInitThreads()`
**Before GTK starts**, only the environment exists. `CLI::run` (`src/OrcaSlicer.cpp`) sets the NVIDIA
DMABUF workaround and then branches on `GDK_BACKEND` (an `x11` prefix means the X11 opt-in). On both
paths, when `/proc/driver/nvidia/version` exists and the linked WebKitGTK honours the switch (>= 2.44,
`WebView::SupportsForceSharedMemory()`), it sets `WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` and
`WEBKIT_FORCE_DMABUF_RENDERER=1` (non-replacing): the proprietary driver cannot back WebKit's hardware
DMABUF renderer. On the default path it forces `GDK_BACKEND=x11` when wx lacks EGL and `WAYLAND_DISPLAY`
is set, and sets `WEBKIT_DISABLE_COMPOSITING_MODE=1` (non-replacing) inside that branch when the linked
WebKitGTK is older than 2.42: a forced X11 backend in a Wayland session is the only default-path case
that runs through XWayland, which `DISPLAY` and `WAYLAND_DISPLAY` alone do not tell you (a native
Wayland app inherits both). It calls `XInitThreads()`
only when `DISPLAY` is set. With neither variable set, the GUI refuses to start ("Neither DISPLAY nor
WAYLAND_DISPLAY set"). The details and the WebKit rule (c12912e0df) are in
WAYLAND_DISPLAY set"). The details and the WebKit rules (c12912e0df) are in
`references/webview-gl-aui-media.md` §WebKitGTK on Linux sessions.
**wx's own detectors**, for reference:
@@ -61,7 +61,8 @@ available; GTK2/WebKit1 is an opt-out configuration the GUI does not support.
11. A web host that re-themes in place handles `EVT_WEBVIEW_RECREATED` without `Skip()`; one that
needs a reload lets it through. §[Orca wrapper](#orca-webview-wrapper-widgetswebview)
12. Keep `WebViewWebKit`'s destructor removing the `"wx"` handler. §[Orca wrapper](#orca-webview-wrapper-widgetswebview)
13. Do not widen `WEBKIT_DISABLE_COMPOSITING_MODE` beyond Orca's default-path XWayland case, and never
13. Do not widen `WEBKIT_DISABLE_COMPOSITING_MODE` beyond the no-EGL default path where Orca forces
`GDK_BACKEND=x11`, for WebKitGTK older than 2.42, and never
make a page's progress depend only on a C++→JS callback. §[WebKitGTK on Linux](#webkitgtk-on-linux-sessions)
14. GL attribute lists: legacy `int[]` lists end with `0` and spell out `WX_GL_RGBA` and
`WX_GL_DOUBLEBUFFER`; `wxGLAttributes`/`wxGLContextAttrs` end with `EndList()`; MSAA is requested
@@ -567,33 +568,69 @@ vue-resize workaround script and sets a persistent SQLite cookie store on the vi
## WebKitGTK on Linux sessions
**Mechanism.** `CLI::run` (`src/OrcaSlicer.cpp`, under `#ifdef __WXGTK__`, before GTK initialises)
branches on `GDK_BACKEND`:
sets the NVIDIA DMABUF workaround and then branches on `GDK_BACKEND`:
- **Both paths**, before the split: when `/proc/driver/nvidia/version` exists and the linked WebKitGTK
honours the switch (>= 2.44, `WebView::SupportsForceSharedMemory()`), it sets
`WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` and `WEBKIT_FORCE_DMABUF_RENDERER=1` non-replacing. The
proprietary driver cannot back WebKit's hardware DMABUF renderer, so the shared-memory transport keeps
accelerated compositing instead of dropping it; `WEBKIT_FORCE_DMABUF_RENDERER` clears the
Debian/Ubuntu/openSUSE `isNVIDIA()` early return, which otherwise returns before the FORCE_SHM check.
- **Default path** (`GDK_BACKEND` unset or not starting with `x11`): if wx lacks EGL (`!wxHAS_EGL`) and
`WAYLAND_DISPLAY` is set, it forces `GDK_BACKEND=x11`; it sets `WEBKIT_DISABLE_COMPOSITING_MODE=1`
non-replacing **only** when both `DISPLAY` and `WAYLAND_DISPLAY` are non-empty (a Wayland session with
XWayland available — the session, not the backend GTK will pick); `XInitThreads()` runs only when
`DISPLAY` is set.
`WAYLAND_DISPLAY` is set, it forces `GDK_BACKEND=x11`, and in that same branch it sets
`WEBKIT_DISABLE_COMPOSITING_MODE=1` non-replacing when the linked WebKitGTK predates 2.42
(`WebView::NeedsCompositingWorkaround()`). That forced X11 backend in a Wayland session is the only
default-path case that really runs through XWayland; `DISPLAY` and `WAYLAND_DISPLAY` alone do not
identify it, because a native Wayland app inherits both. `XInitThreads()` runs only when `DISPLAY` is
set.
- **`GDK_BACKEND=x11…` opt-in path**: sets `DRI_PRIME=1` (and the NVIDIA PRIME variables when
`/proc/driver/nvidia/version` exists), calls `XInitThreads()`, and deliberately does **not** disable
compositing (the comment: the WebKit2GTK XWayland bug appears fixed in WebKit2GTK ≥ 2.42, and leaving
compositing on keeps hardware acceleration for Device / Setup Wizard / login pages).
compositing, which keeps hardware acceleration for Device / Setup Wizard / login pages.
Runtime backend detection elsewhere uses Orca's `is_running_on_wayland()` / `is_running_on_x11()`
(see `references/platforms.md`), not these environment variables.
**Pitfall.**
- **Rule:** Set `WEBKIT_DISABLE_COMPOSITING_MODE` only on the default path in Wayland sessions,
non-replacing (`setenv(..., false)`, so a value the user already exported wins), before GTK
initialises; never unconditionally. Keep JS-side fallbacks so a page never
- **Rule:** On NVIDIA reach for `WEBKIT_DMABUF_RENDERER_FORCE_SHM` before
`WEBKIT_DISABLE_COMPOSITING_MODE`. **Why:** the proprietary driver cannot back WebKit's hardware
DMABUF renderer, leaving web views blank or slow; FORCE_SHM keeps accelerated compositing and only
skips the hardware path, while disabling compositing drops it. Threaded scrolling is a separate,
release-dependent default (off in 2.44/2.45, on from 2.46). `WEBKIT_DISABLE_DMABUF_RENDERER` is not a
substitute — it is read first, leaves the transport set empty (no compositing from 2.44 on, where the
X11/Wayland fallback renderers were removed) and has a reported crash path. On Debian/Ubuntu and
openSUSE the `isNVIDIA()` patch returns before the FORCE_SHM check, so `WEBKIT_FORCE_DMABUF_RENDERER=1`
must clear it first; that variable exists only in those patches. WebKit reads all of these once per
process, before the first WebView.
The test is `/proc/driver/nvidia/version`, so a hybrid laptop that renders on its iGPU also takes the
shared-memory path, which is a per-frame GPU readback rather than zero-copy; exporting
`WEBKIT_DMABUF_RENDERER_FORCE_SHM=0` in the environment restores the hardware path.
```cpp
// Wrong: kills compositing and threaded scrolling (and can crash on modern WebKit)
::setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1", false);
// Right: keep compositing, skip only the hardware DMABUF path
if (::access("/proc/driver/nvidia/version", F_OK) == 0 && WebView::SupportsForceSharedMemory()) {
::setenv("WEBKIT_DMABUF_RENDERER_FORCE_SHM", "1", /*replace*/ false);
::setenv("WEBKIT_FORCE_DMABUF_RENDERER", "1", /*replace*/ false);
}
```
Cite: WebKit 2.52.6 `Source/WebKit/UIProcess/gtk/AcceleratedBackingStore.cpp`
(`rendererBufferTransportMode()`), Debian `disable-nvidia-dmabuf.patch`, WebKit bug 262607.
- **Rule:** Set `WEBKIT_DISABLE_COMPOSITING_MODE` only on the default path, in the branch where wx
lacks EGL and Orca forces `GDK_BACKEND=x11`, non-replacing (`setenv(..., false)`, so a value the user
already exported wins), before GTK
initialises; never unconditionally, and only for the WebKitGTK releases that need it (older than
2.42, `WebView::NeedsCompositingWorkaround()`). Keep JS-side fallbacks so a page never
waits forever for a C++→JS call.
**Why:** WebKit2GTK accelerated compositing could fail silently under XWayland, leaving web views
blank (Setup Wizard stuck on "Loading..."); disabling it everywhere needlessly drops hardware
acceleration; without the JS timeout the user is stuck when the signal is lost.
**Why:** WebKit2GTK accelerated compositing could fail silently under XWayland before 2.42, leaving
web views blank (Setup Wizard stuck on "Loading..."); the variable also turns off threaded scrolling,
so applying it on a newer WebKit needlessly drops both GPU compositing and async scrolling (the
Device tab loses its smoothness); without the JS timeout the user is stuck when the signal is lost.
```cpp
// Wrong: unconditional, kills HW acceleration everywhere
::setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1", false);
// Right (default path only): non-replacing, before GTK init, Wayland session with XWayland
if (display && *display && wayland && *wayland)
// Right (default path only): non-replacing, before GTK init, WebKitGTK < 2.42,
// and only in the branch that forces GDK_BACKEND=x11 (wx without EGL), the
// case that runs through XWayland
if (WebView::NeedsCompositingWorkaround() && wayland && *wayland)
::setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1", /*replace*/ false);
```
Cite: c12912e0df (`src/OrcaSlicer.cpp`; `resources/web/guide/0/load.js` `OnInit`
+13
View File
@@ -288,6 +288,19 @@ if (APPLE)
# Type"); Ninja/-Wall does not, and under -Werror it fails Xcode builds on code CI accepts.
set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_64_TO_32_BIT_CONVERSION "NO")
# The Xcode generator ignores CMAKE_<LANG>_COMPILER_LAUNCHER, so hand the launcher to
# Xcode's own Compiler Launcher setting, which wraps every clang compile (C, C++,
# Objective-C++ and the precompiled header) with one program: the C++ launcher wins when
# both are set. The setting takes a single program path, not a CMake launcher list.
# Xcode versions without it ignore it and build uncached.
if (CMAKE_GENERATOR STREQUAL "Xcode")
if (CMAKE_CXX_COMPILER_LAUNCHER)
set(CMAKE_XCODE_ATTRIBUTE_C_COMPILER_LAUNCHER "${CMAKE_CXX_COMPILER_LAUNCHER}")
elseif (CMAKE_C_COMPILER_LAUNCHER)
set(CMAKE_XCODE_ATTRIBUTE_C_COMPILER_LAUNCHER "${CMAKE_C_COMPILER_LAUNCHER}")
endif ()
endif ()
message(STATUS "Orca: IS_CROSS_COMPILE: ${IS_CROSS_COMPILE}")
elseif (CMAKE_SYSTEM_NAME STREQUAL "Linux")
set(CMAKE_INSTALL_RPATH "$ORIGIN")
+15 -1
View File
@@ -22,7 +22,7 @@ function usage() {
echo " -g: run the requested build steps inside a Docker/Podman Ubuntu 24.04 container similar to the GitHub Actions Linux runner"
echo " -h: prints this help text"
echo " -i: build the Orca Slicer AppImage (optional)"
echo " -p: boost ccache hit rate by disabling precompiled headers (default: ON)"
echo " -p: disable precompiled headers (on by default): more ccache hits after header changes, slower misses"
echo " -r: skip RAM and disk checks (low RAM compiling)"
echo " -s: build the Orca Slicer (optional)"
echo " -t: build tests (optional), requires -s flag"
@@ -549,6 +549,20 @@ fi
if [ -n "${CMAKE_CCACHE}" ] ; then
echo "${CMAKE_CCACHE} found, enabling compiler caching..."
export CMAKE_CCACHE_ARGS=(-DCMAKE_C_COMPILER_LAUNCHER="${CMAKE_CCACHE}" -DCMAKE_CXX_COMPILER_LAUNCHER="${CMAKE_CCACHE}")
# The same ccache settings as the CI build. Without this sloppiness ccache refuses every
# compile that uses the precompiled header, which is most of OrcaSlicer, and skips headers
# a checkout has just written. It is added to the sloppiness already configured, and
# CCACHE_SLOPPINESS or CCACHE_(NO)DEPEND set in the environment win.
if [[ "$(basename "${CMAKE_CCACHE}")" == ccache ]] ; then
if [[ -z "${CCACHE_SLOPPINESS+x}" ]] ; then
CCACHE_SLOPPINESS=$("${CMAKE_CCACHE}" --get-config sloppiness 2>/dev/null || true)
export CCACHE_SLOPPINESS="${CCACHE_SLOPPINESS:+${CCACHE_SLOPPINESS},}pch_defines,time_macros,include_file_mtime,include_file_ctime"
fi
# Hash the includes the compiler reports instead of preprocessing every miss.
if [[ -z "${CCACHE_DEPEND+x}${CCACHE_NODEPEND+x}" ]] ; then
export CCACHE_DEPEND=1
fi
fi
else
echo "Note: ccache or sccache are not found. Install either of them for faster rebuilds."
fi
+50
View File
@@ -111,6 +111,54 @@ else
export CMAKE_POLICY_COMPAT=""
fi
# Compiler cache, as in build_linux.sh. The program comes from CMAKE_CCACHE, else from a
# launcher already set in the environment (the CI build sets ccache there), else sccache or
# ccache from PATH; one program serves both languages. Each run applies its own choice to the
# build directories, replacing a launcher an earlier run or a hand-run configure recorded.
CMAKE_CCACHE_ARGS=()
CCACHE_VAR=""
for var in CMAKE_CCACHE CMAKE_CXX_COMPILER_LAUNCHER CMAKE_C_COMPILER_LAUNCHER; do
if [ -n "${!var:-}" ]; then
CCACHE_VAR=$var
break
fi
done
if [ -n "$CCACHE_VAR" ]; then
CCACHE_PROGRAM=${!CCACHE_VAR}
echo "Checking ${CCACHE_VAR} environment variable for compiler cache program..."
CMAKE_CCACHE=$(command -v "${CCACHE_PROGRAM}") || {
echo "${CCACHE_VAR} environment variable is set to '${CCACHE_PROGRAM}' but it was not found in PATH."
CMAKE_CCACHE=""
}
elif command -v sccache >/dev/null 2>&1 ; then
CMAKE_CCACHE=$(command -v sccache)
elif command -v ccache >/dev/null 2>&1 ; then
CMAKE_CCACHE=$(command -v ccache)
fi
if [ -n "${CMAKE_CCACHE}" ] ; then
echo "${CMAKE_CCACHE} found, enabling compiler caching..."
CMAKE_CCACHE_ARGS=(-DCMAKE_C_COMPILER_LAUNCHER="${CMAKE_CCACHE}" -DCMAKE_CXX_COMPILER_LAUNCHER="${CMAKE_CCACHE}")
# The same ccache settings as the CI build. Without this sloppiness ccache refuses every
# compile that uses the precompiled header, which is most of OrcaSlicer, and skips headers
# a checkout has just written. It is added to the sloppiness already configured, and
# CCACHE_SLOPPINESS or CCACHE_(NO)DEPEND set in the environment win.
if [[ "$(basename "${CMAKE_CCACHE}")" == ccache ]] ; then
if [[ -z "${CCACHE_SLOPPINESS+x}" ]] ; then
CCACHE_SLOPPINESS=$("${CMAKE_CCACHE}" --get-config sloppiness 2>/dev/null || true)
export CCACHE_SLOPPINESS="${CCACHE_SLOPPINESS:+${CCACHE_SLOPPINESS},}pch_defines,time_macros,include_file_mtime,include_file_ctime"
fi
# Hash the includes the compiler reports instead of preprocessing every miss.
if [[ -z "${CCACHE_DEPEND+x}${CCACHE_NODEPEND+x}" ]] ; then
export CCACHE_DEPEND=1
fi
fi
else
echo "Note: ccache or sccache are not found. Install either of them for faster rebuilds."
# Clear a launcher recorded by an earlier configure. The entries must be set empty rather
# than removed: CMake fills a missing launcher entry from the environment.
CMAKE_CCACHE_ARGS=(-DCMAKE_C_COMPILER_LAUNCHER:STRING= -DCMAKE_CXX_COMPILER_LAUNCHER:STRING=)
fi
echo "Build params:"
echo " - ARCH: $ARCH"
echo " - BUILD_CONFIG: $BUILD_CONFIG"
@@ -162,6 +210,7 @@ function build_deps() {
-DCMAKE_OSX_ARCHITECTURES:STRING="${_ARCH}" \
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
"${CMAKE_CCACHE_ARGS[@]}" \
${CMAKE_POLICY_COMPAT}
fi
cmake --build . --config "$BUILD_CONFIG" --target deps
@@ -264,6 +313,7 @@ function build_slicer() {
-DCMAKE_OSX_ARCHITECTURES="${_ARCH}" \
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
"${CMAKE_CCACHE_ARGS[@]}" \
${CMAKE_POLICY_COMPAT}
fi
cmake --build . --config "$BUILD_CONFIG" --target "$SLICER_BUILD_TARGET"
+2 -17
View File
@@ -1,22 +1,7 @@
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
if (JPEG_VERSION STREQUAL "6")
message("Using Jpeg Lib 62")
set(jpeg_flag "")
elseif (JPEG_VERSION STREQUAL "7")
message("Using Jpeg Lib 70")
set(jpeg_flag "-DWITH_JPEG7=ON")
else ()
message("Using Jpeg Lib 80")
set(jpeg_flag "-DWITH_JPEG8=ON")
endif ()
endif()
orcaslicer_add_cmake_project(JPEG
URL https://github.com/libjpeg-turbo/libjpeg-turbo/archive/refs/tags/3.0.1.zip
URL_HASH SHA256=d6d99e693366bc03897677650e8b2dfa76b5d6c54e2c9e70c03f0af821b0a52f
DEPENDS ${ZLIB_PKG}
URL https://github.com/libjpeg-turbo/libjpeg-turbo/archive/refs/tags/3.2.0.zip
URL_HASH SHA256=1b4c2cb6cba1ca5e53b9e85eeeab02154cab4b70894c96e97a9c747a26d19d63
CMAKE_ARGS
-DENABLE_SHARED=OFF
-DENABLE_STATIC=ON
${jpeg_flag}
)
+239
View File
@@ -0,0 +1,239 @@
# ChromaStack - High Level Design
## Purpose and scope
ChromaStack prints a colour picture on a single face of a model: a few filaments are printed one on
top of another, the printer swaps filament at fixed layer boundaries, and the *height* of every
pixel decides how many of those layers reach it. Filament is translucent, so a pixel that stops
part-way through a run shows a blend of that run's filament and whatever is under it, which is
where the colours come from - far more of them than there are filaments loaded.
This is that model inside OrcaSlicer, as a mode of a texture displacement layer. The picture and
the filament stack are the input; the output is a grayscale height map and a set of filament swaps.
Nothing about it is a new pipeline: the height map is handed to the layer the user painted, so the
existing projection, preview and bake turn it into real geometry, and the swaps are handed to the
object's height ranges, which is the slicer's own way of printing a Z band in another filament.
It does not import other filament-painting tools' project files, and it does not choose the filaments or
their order - that is the user's artistic decision, and the panel exists to make its result
visible before the print.
## The colour model
`libslic3r/ChromaStack.hpp` holds the whole of it, free of any GUI dependency, which is what lets the
solve run against plain image bytes and be unit tested.
A **stack** (`ChromaStack`) is a list of **bands** (`ChromaBand`), bottom-up: a filament and
how many layers it gets. Alongside them it carries the print's layer height, which the colours
depend on directly, and the **base**: how many layers of solid backing the picture sits on.
The base is not a band. It is a measurement of the model - everything below the painted face, often
a hundred layers or more on a tall part - while a band is a handful of layers the user chooses. It
prints in the bottom band's filament, because that is what is loaded when the print starts, so it
extends that band's height range down to the first layer instead of being a swap of its own. Only
the bands are quantized into the height map, which is why the 255-layer limit
(`CHROMA_MAX_RELIEF_LAYERS`) bounds the picture and not the stack.
Each filament carries its **transmission distance** (TD), the usual measure for how deep
light gets into it: about 1 for an opaque filament, 4 to 7 for a usefully translucent one, 8 and
above for a nearly clear one. TD lives in the filament preset, as
`filament_transmission_distance` - it is a property of the material, so it belongs with the
material and ships with a profile rather than being retyped per project. It has no effect on
slicing.
`chroma_palette()` turns a stack into the colour it shows at every possible column height, which
is all the solver ever needs. The model is Beer-Lambert absorption, layer by layer: a layer of
thickness `h` in a filament of transmission distance `TD` passes
T = exp(-h / (CHROMA_TD_OPAQUE_FRACTION * TD / 3))
of the light from under it and shows its own colour with the rest, so walking up from an opaque
base,
C_k = (1 - T_k) * c_k + T_k * C_(k-1), C_0 = c_1
The constants say what TD means in physical terms: a filament stops showing what is behind it at
about a tenth of its TD number (`CHROMA_TD_OPAQUE_FRACTION`), and three e-folding lengths of
absorption pass 5% of the light, which is where the eye stops seeing through. Those are calibrated
against how the community's TD step prints behave, not derived from first principles - TD is an
empirical scale.
How fast that happens is the single most useful fact about a stack, and `chroma_layers_to_opaque()`
is it: a filament hides what is under it at about a tenth of its TD, so how many *layers* that takes
depends entirely on how thick they are. TD 4 covers in two layers at 0.2 mm and in five at 0.08 mm.
Below about two layers a band has no intermediate colours at all - the picture can only use the
filament or not use it - which is why an otherwise sound stack prints as flat steps at a coarse
layer height. The panel reports the number under the palette ramp, and warns when it falls below
two, because nothing else makes it visible: the filaments look fine, the bands look long enough, and
the result looks like the colour model is wrong when it is the layer height.
How many of a picture's layers actually earn their place is a second question, and
`chroma_distinct_levels()` answers it: a band eventually hides what is under it, and another layer of
it then shows the colour the one below already showed. It is measured against the palette rather than
against TD, because how long a band keeps paying depends on what is under it as much as on the
filament - a light band over a dark one is finished in four or five layers, while a dark one over a
light one goes on moving the colour perceptibly well past the depth at which it is nominally opaque,
since the last few percent of light coming through still shifts a near-black colour. `Trim` shortens
each band to its last layer that still changes anything.
Beer-Lambert is also the only model that stays self-consistent applied per layer: transmittance
multiplies, so a run of n layers of one filament passes `exp(-n h / d)` whether it is composited in
one step or n, and the palette therefore does not depend on how the stack happens to be cut into
bands. A unit test asserts exactly that. Compositing is done on linear light, because the quantity
being attenuated is light rather than an sRGB code.
The base is assumed opaque. That is what makes a column's colour depend only on the layers above it,
and it is why the panel warns when the base is thin.
## The solve
`chroma_solve()` maps each pixel of the picture to the column height whose palette colour is
closest to it, perceptually: both sides go to CIELAB and the match is a CIE76 distance. A 1-D
palette of a few dozen entries makes the extra accuracy of CIEDE2000 hard to see and the cost of it
easy to feel - several hundred million comparisons for a large picture - so the cheaper metric is
the deliberate choice here, unlike the texture colour quantizer, which matches against a
two-dimensional palette of filament mixes and uses a precomputed CIEDE2000 cube.
Optional Floyd-Steinberg error diffusion, serpentine, trades the bands a photograph shows across a
sky or a cheek for fine stipple. Off by default, since stipple is the artifact rather than the cure
for flat-coloured artwork.
Three things come back: the per-pixel layer count, the same thing as an 8-bit grayscale height map
(0 at the base, 255 at the top of the stack), and the palette colours chosen, as an RGB image. The
quantization belongs to the solve rather than to the displacement because the relief's steps have
to land on layer boundaries, and a grey level is the only channel the displacement reads.
The solve also reports the mean and worst colour difference it had to accept. That is the only
honest answer to "will this stack reproduce my picture": under about 5 is a good match, over about
15 means the filaments cannot reach the picture's colours and no number of layers will help.
## How it attaches to a texture displacement layer
A ChromaStack layer is an ordinary `TextureDisplacementLayer` whose height map is computed rather than
imported:
- `chroma_source` keeps the picture the user chose, so the solve can be redone whenever the stack,
the layer height or the picture changes;
- `chroma` is the stack;
- `image_data` - the same field every other layer uses - holds whatever the last solve produced.
So everything downstream needs no changes at all and knows nothing about colour stacks. It also
means the solve writes `depth_mm` (the relief is exactly as tall as the layers above the base) and
pins `invert`, `midlevel` and `smoothing`: blurring a solved height map moves its steps off the
layer boundaries they were solved for.
Colour by filament swap and the layer's own `color_enabled` (one filament per triangle, via
`mmu_segmentation_facets`) are mutually exclusive, and the panel enforces it. They are two
different ways of colouring the same surface, and painting the relief per triangle would hide the
very stack the heights were solved against.
## The filament swaps
`chroma_ranges()` turns the bands into Z bands in the object's own coordinates, merging any two
adjacent bands that share a filament, since nothing is swapped between them. The panel writes them
onto the object as `layer_config_ranges`, each range carrying `extruder` - which
`apply_to_print_region_config()` spreads over every feature filament the range does not otherwise
override, i.e. the whole band prints in that filament - and `layer_height`, both because the solve
is only valid at the height it was run at and because `layer_height_profile_from_ranges()` reads
that option without checking it is there.
Height ranges rather than colour-change custom G-code: they are object-relative, so they survive
the object being moved, they are saved in the project file, and they appear in the object list where
the user can see and edit them. The cost is that an object can only have one set of them, so
applying a stack replaces whatever ranges the object had, with a confirmation when it had any.
`ObjectList::rebuild_layer_ranges()` mirrors them in the object tree without touching the
selection - changing the selection while a gizmo is open tears the gizmo down under the user.
## Geometry the model assumes
A filament swap happens at one Z across the whole plate, and the relief grows along the painted
surface's normal. The two agree only where that surface is horizontal, so:
- the painted patch's height above the bottom of the object is what the base layer count has to
match, and the panel measures it from the patch and offers the number rather than leaving the user
to work it out;
- a patch that is not flat in Z gets a warning, because its colours will only be right where it is
level. The relief itself is still computed, since a gently domed plaque is a legitimate thing to
print and only the user can judge how much error is acceptable.
The model itself is the user's: the stack needs a slab of the base thickness with a face to paint,
and nothing here creates or thickens geometry.
## Seeing the colours
The colours are blends of translucent filaments. No single filament makes them, so they cannot be
expressed as the per-triangle filament assignment the 3D scene paints with, and there is nothing for
the ordinary view to draw. They exist in two places: the sliced preview, which shows the filament
bands as they print, and an overlay the gizmo draws over whatever surface is on screen.
That overlay is a shader, `resources/shaders/*/chromastack.*`, and it reads the colour from each
fragment's own **print Z** rather than from the height map or from anything in the mesh. A filament
swap happens at one Z across the whole plate, so the layer a point sits in is what decides its
colour whatever the picture says. Two things follow. A band edge lands exactly on the layer boundary
however coarse the triangles are - a per-triangle colouring puts it wherever the mesh happens to
have an edge. And it keeps working after a bake, when the relief is real geometry, the paint has
been cleared and the height map is attached to nothing on screen; the overlay is built from the live
preview's displaced mesh before a bake and from the volume's own mesh after one.
The stack's colours reach the shader as a texture of one texel per printed layer, because a base can
be several hundred layers and no uniform array holds that. The mesh carries nothing about the stack,
so editing the stack costs a palette upload rather than a mesh rebuild.
On top of the colour the shader tries to look like printed plastic, because a flat shaded colour
reads as a rendering and the whole point of the overlay is judging a print:
- the terminator is **wrapped** and the grazing angles **glow**, both scaled by the transmission
distance of the filament on top - filament translucent enough to be worth stacking scatters light
rather than reflecting it, and an opaque stack correctly gets neither;
- the walls carry **layer lines**, a groove at every layer boundary that also modulates the
specular, so a wall glints in stripes as a printed one does. Terraces get none: a terrace is one
layer's top surface and has no lines to show.
Both effects are driven by the fragment's height, so they are free of any texture or UV, and both
fade out through `fwidth` once a pixel covers more than about half a layer - lines finer than a
pixel are noise, and a colour read from one layer out of the dozen under a pixel flickers as the
camera moves. The palette lookup slides off the texel centres over the same range so the linear
filter averages the bands a pixel covers instead of picking one of them.
The overlay is drawn pulled forward and without depth writes, the same pass the painted-colour
overlay uses, since it is the same surface as whatever is underneath it.
## What the panel shows
The kind of a layer is the layer's own, chosen when it is added and fixed from then on: **Add layer**
offers *Relief layer* or *ChromaStack picture*, and the card carries its kind as a chip. There is no
panel-wide mode, so one object can hold a picture on its face and a knurl on its rim, and a card
never shows a control that cannot do anything on it.
What the two cards do not share:
| Control | Relief card | ChromaStack card |
| --- | --- | --- |
| Depth | slider | the solve writes it; the card prints the result |
| Tile size | repeats the pattern | **Picture size**, the same number named for what it does |
| Midlevel, Smoothing, Invert, Edge fade | present | absent: each would move the steps off the layer boundaries |
| Colors, Mix filaments, Denoise | one filament per triangle | absent: swaps are how this card colours |
| Tile, tile method | present | absent: one copy, never tiled |
| Blend | four modes | absent: it is the top layer and replaces what is under it |
| Picture, prediction, stack, palette, match, Dither, Solve, Apply swaps | absent | present |
A ChromaStack card has no grip and cannot be reordered: its colours come from swapping filament at a
height, so anything printed above it would bury them. An object may hold only one, refused when the
layer is added rather than when the swaps are applied, because the swaps are the object's own height
ranges and there is one set of those.
The card itself, top to bottom: what it is in one line, the picture and the predicted print side by
side at the same size, the match figures, the base with the measurement from the painted face offered
beside it, a row per band - swatch, filament, layer count, TD - the colours the stack reaches as a
ramp, the totals, dither with the layer height it is solved against, and the two actions. Base and
band counts are typed fields rather than sliders: a base is measured in hundreds of layers and a band
in single digits, and dragging past the end of a slider's range is how a stack silently loses its
backing.
The ramp is computed from the stack alone, before any solve. It is what answers "can these filaments
print this picture" without spending a solve to find out they cannot: a short or flat ramp means the
filaments are too alike, or too opaque, to give the picture anything to work with.
A per-slot flag records that the stack has been edited since the last solve, because a stale height
map looks exactly like a good one.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15"><rect x="7" y="12" width="1" height="1" style="fill:#fefefe;"/><path d="M4.5,5.5c0-1.71,1.43-3.09,3.16-3,1.62.08,2.84,1.54,2.84,3.17h0c0,.61-.25,1.2-.69,1.62l-2.31,2.24v.97" style="fill:none; stroke:#fefefe; stroke-linecap:round; stroke-linejoin:round;"/></svg>
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15"><rect x="1.5" y="3.5" width="12" height="8" rx="1" ry="1" style="fill:none; stroke:#fefefe; stroke-linecap:round; stroke-linejoin:round;"/><path d="M3,5h1v1h-1Z M5,5h1v1h-1Z M7,5h1v1h-1Z M9,5h1v1h-1Z M11,5h1v1h-1Z M4,7h1v1h-1Z M6,7h1v1h-1Z M8,7h1v1h-1Z M10,7h1v1h-1Z M5,9h5v1h-5Z" style="fill:#fefefe;"/></svg>

Before

Width:  |  Height:  |  Size: 366 B

After

Width:  |  Height:  |  Size: 415 B

+92
View File
@@ -0,0 +1,92 @@
#version 110
// What a ChromaStack print looks like, shaded per fragment.
//
// The colour is not a vertex attribute and not a texture on the surface: it is read from the
// fragment's own height. A filament swap happens at one print Z across the whole plate, so the layer
// a point sits in is what decides its colour - which means a band edge lands exactly on the layer
// boundary however coarse the mesh is, and the same shader is right before a bake (on the preview's
// displaced mesh) and after one (on the volume's own).
//
// On top of that it tries to look like printed plastic rather than like flat shaded colour: the
// terminator is wrapped and the grazing angles glow, because filament translucent enough to be worth
// stacking scatters light rather than reflecting it; and the walls carry layer lines, because that is
// the first thing the eye uses to read a print as a print.
#define INTENSITY_CORRECTION 0.6
const vec3 LIGHT_TOP_DIR = vec3(-0.4574957, 0.4574957, 0.7624929);
#define LIGHT_TOP_DIFFUSE (0.8 * INTENSITY_CORRECTION)
#define LIGHT_TOP_SPECULAR (0.125 * INTENSITY_CORRECTION)
#define LIGHT_TOP_SHININESS 20.0
const vec3 LIGHT_FRONT_DIR = vec3(0.6985074, 0.1397015, 0.6985074);
#define LIGHT_FRONT_DIFFUSE (0.3 * INTENSITY_CORRECTION)
#define INTENSITY_AMBIENT 0.3
// The stack's colours, one texel per printed layer, bottom to top. A texture rather than a uniform
// array because a base can be several hundred layers and no uniform array survives that.
uniform sampler2D palette;
uniform float palette_size; // texels in `palette`, i.e. the stack's total printed layers
uniform float base_z; // world Z of the bottom of the object
uniform float layer_height; // mm
// How translucent the stack reads, 0 to 1, from the transmission distance of the filament on top.
// It drives the wrap and the rim: an opaque stack should not glow.
uniform float translucency;
uniform float alpha;
varying vec3 clipping_planes_dots;
varying vec3 eye_normal;
varying vec3 eye_position;
varying vec3 world_normal;
varying float world_z;
void main()
{
if (any(lessThan(clipping_planes_dots, vec3(0.0))))
discard;
float z_rel = world_z - base_z;
float layers = max(z_rel / layer_height, 0.0);
// How much of a layer one pixel covers. Everything below is faded out as this grows: layer lines
// finer than a pixel are noise, and a colour read from one layer out of the dozen under a pixel
// flickers as the camera moves.
float px = fwidth(layers);
// The layer this fragment sits in, 1-based. Up close that is an exact texel of the palette; once a
// pixel spans a layer or more it slides to a continuous coordinate so the linear filter averages
// the bands it covers instead of picking one of them at random.
float stepped = clamp(ceil(layers - 1.0e-4), 1.0, palette_size) - 0.5;
float smooth_ = clamp(layers, 0.5, palette_size - 0.5);
float u = mix(stepped, smooth_, clamp(px - 0.5, 0.0, 1.0)) / palette_size;
vec3 albedo = texture2D(palette, vec2(u, 0.5)).rgb;
vec3 N = normalize(eye_normal);
vec3 V = normalize(-eye_position);
// Layer lines, on the walls only: a terrace is one layer's top surface and has none to show. The
// groove runs round the part at every layer boundary, so it is a function of height alone.
float wall = 1.0 - abs(world_normal.z);
float phase = fract(layers);
float groove = smoothstep(0.0, 0.16, phase) * smoothstep(1.0, 0.84, phase);
groove = mix(groove, 0.65, clamp(px * 1.5, 0.0, 1.0)); // fade to flat when too fine to resolve
float lines = mix(1.0, 0.84 + 0.16 * groove, wall * 0.9);
albedo *= lines;
// Diffuse, with a wrapped terminator: light entering translucent filament comes back out a little
// way round the curve, so the shaded side never goes as dark as an opaque surface's would.
float wrap = 0.25 + 0.45 * translucency;
float ndotl = dot(N, LIGHT_TOP_DIR);
float diffuse = max((ndotl + wrap) / (1.0 + wrap), 0.0) * LIGHT_TOP_DIFFUSE;
diffuse += max((dot(N, LIGHT_FRONT_DIR) + wrap) / (1.0 + wrap), 0.0) * LIGHT_FRONT_DIFFUSE;
// Specular, strongest along a layer line, which is what makes a printed wall glint in stripes
// rather than evenly.
float spec = LIGHT_TOP_SPECULAR * pow(max(dot(V, reflect(-LIGHT_TOP_DIR, N)), 0.0), LIGHT_TOP_SHININESS);
spec *= mix(1.0, 0.35 + 0.65 * groove, wall * 0.9);
// Scattering out of the edges: a translucent part lights up where it is seen at a grazing angle
// and where it is thin. Tinted by the filament, because what comes back out has been through it.
float rim = pow(1.0 - clamp(dot(N, V), 0.0, 1.0), 4.0);
vec3 color = albedo * (INTENSITY_AMBIENT + diffuse) + vec3(spec) + albedo * rim * translucency * 0.55;
gl_FragColor = vec4(color, alpha);
}
+40
View File
@@ -0,0 +1,40 @@
#version 110
uniform mat4 view_model_matrix;
uniform mat4 projection_matrix;
uniform mat3 view_normal_matrix;
uniform mat4 volume_world_matrix;
// The volume's normal matrix in world space, so the fragment shader can tell a wall from a terrace -
// which is what decides whether layer lines show at all.
uniform mat3 world_normal_matrix;
// Clipping plane, x = min z, y = max z (the FFF/SLA preview's top/bottom cut).
uniform vec2 z_range;
// Clipping plane, general orientation (the section view).
uniform vec4 clipping_plane;
// GLModel's P3N3 layout, which is what init_from(indexed_triangle_set) produces - this shader needs
// nothing per vertex that the mesh does not already carry, because the colour comes from the
// fragment's own height rather than from anything baked into the vertices.
attribute vec3 v_position;
attribute vec3 v_normal;
varying vec3 clipping_planes_dots;
varying vec3 eye_normal;
varying vec3 eye_position;
varying vec3 world_normal;
varying float world_z;
void main()
{
vec4 model_pos = vec4(v_position, 1.0);
vec4 world_pos = volume_world_matrix * model_pos;
world_z = world_pos.z;
world_normal = normalize(world_normal_matrix * v_normal);
eye_normal = normalize(view_normal_matrix * v_normal);
vec4 eye_pos = view_model_matrix * model_pos;
eye_position = eye_pos.xyz;
gl_Position = projection_matrix * eye_pos;
clipping_planes_dots = vec3(dot(world_pos, clipping_plane), world_pos.z - z_range.x, z_range.y - world_pos.z);
}
@@ -36,11 +36,6 @@ uniform int palette_b[64];
uniform float prefer_pure_de; // PREFER_PURE_DE: how much better than a single filament a mix must be
uniform sampler2D color_tex; // the layer's colour image, sampled at the same uv as the height
uniform bool has_color_tex;
// False means the texture is a single placement, not a repeat. The sampler's wrap mode can express
// that for the *height* - a zero border reads as no displacement - but not for colour, because a
// zero border is indistinguishable from the image genuinely being black there. So the uv is tested
// instead, which is what sample_layer_color() does on the CPU side.
uniform bool tile_enabled;
uniform bool volume_mirrored;
uniform mat4 view_model_matrix;
@@ -50,14 +45,6 @@ uniform sampler2D height_tex;
uniform vec2 height_tex_texel;
uniform float depth_mm;
uniform float tiling_scale;
// Tile extent along v, in mm. Zero or negative means "the same as tiling_scale" - the square tile
// every layer had before the two axes could differ - which is also what layer_tile_mm() does on the
// CPU side, so an unset uniform behaves exactly as this shader always did.
uniform float tiling_scale_v;
// Mirror the texture along one axis of the tile. Both default to false, which GLSL guarantees for an
// uninitialised bool uniform, so a shader whose caller has not set them behaves exactly as before.
uniform bool mirror_u;
uniform bool mirror_v;
// Height map width / height. Scales the v axis so a non-square image keeps its proportions
// instead of being squeezed into a square tile - mirrors libslic3r's apply_uv_transform().
uniform float tex_aspect;
@@ -67,12 +54,6 @@ uniform bool invert;
uniform float midlevel; // the height that means "don't move"; needed by the parallax step
uniform vec3 eye_model_pos; // camera position in the texture frame (world minus tex_anchor)
uniform vec3 tex_anchor; // the volume's origin in world space: the texture frame's origin
// The placement's rotation and its inverse, so this shader turns a world position into the texture
// frame exactly as texture_displacement_bake_frame() does. Both are passed rather than one
// transposed here, because GLSL 110 has no transpose(). Identity leaves the old world-aligned
// behaviour, which is what an unset uniform gives.
uniform mat3 tex_rot; // world -> texture frame
uniform mat3 tex_rot_inv; // texture frame -> world
uniform bool use_vertex_uv;
// 2x3 affine (lin = (m00, m01, m10, m11), tr = (m02, m12)) applied to the dragged island's uv; see the
// 140 variant. Identity when nothing is dragged.
@@ -181,29 +162,13 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_
}
}
// Tile extents in mm, u then v, with the v fallback layer_tile_mm() applies.
vec2 tile_mm()
{
float su = (tiling_scale > 1e-6) ? tiling_scale : 1.0;
return vec2(su, (tiling_scale_v > 1e-6) ? tiling_scale_v : su);
}
// +1 or -1 per axis, matching layer_mirror_sign() on the CPU side.
vec2 mirror_sign()
{
return vec2(mirror_u ? -1.0 : 1.0, mirror_v ? -1.0 : 1.0);
}
vec2 project_uv(vec3 p, vec3 n)
{
vec2 planar = projection_raw(p, n);
planar *= (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0;
float cs = cos(rotation_rad);
float sn = sin(rotation_rad);
vec2 r = vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs);
// Extents divide after the rotation, so a tile whose two sides differ stays a rotated rectangle
// instead of shearing. With a square tile the two orders agree exactly. The mirror is a sign on
// the same division, in the tile's own frame.
r *= mirror_sign() / tile_mm();
// After the rotation, so the rotation stays a rotation rather than becoming a shear.
r.y *= tex_aspect;
return r + uv_offset;
@@ -272,12 +237,9 @@ void main()
// World millimetres throughout, like the bake - see the 140 variant.
vec3 triangle_normal = normalize(cross(dFdx(world_pos.xyz), dFdy(world_pos.xyz)));
vec3 tex_pos = tex_rot * (world_pos.xyz - tex_anchor); // the frame the texture is projected in, as the bake does
vec3 tex_pos = world_pos.xyz - tex_anchor; // the frame the texture is projected in, as the bake does
if (volume_mirrored)
triangle_normal = -triangle_normal;
// The same normal in that frame. triangle_normal itself stays in world, because the lighting at
// the end of this shader needs it there.
vec3 tex_normal = tex_rot * triangle_normal;
// Where the colour is read from. Both branches below already compute the uv this fragment's
// *height* came from - including the parallax-marched one on the triplanar path - and the colour
@@ -308,14 +270,14 @@ void main()
} else if (weight > 0.0) {
vec3 t, b;
vec2 units_per_mm;
projection_axes(tex_pos, tex_normal, t, b, units_per_mm);
projection_axes(tex_pos, triangle_normal, t, b, units_per_mm);
// Parallax occlusion mapping: march the view ray through the height shell and shade at the
// first point where it drops below the displaced surface (see header).
float amp = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0);
vec3 view_dir = normalize(tex_rot * eye_model_pos - tex_pos);
float v_dot_n = dot(view_dir, tex_normal);
vec2 uv = project_uv(tex_pos, tex_normal);
vec3 view_dir = normalize(eye_model_pos - tex_pos);
float v_dot_n = dot(view_dir, triangle_normal);
vec2 uv = project_uv(tex_pos, triangle_normal);
// The shell the displaced surface lives inside, as signed heights along the normal. Taken from
// both ends of h in [0, 1] so it stays correct for an inverted layer or a raised midlevel,
@@ -327,18 +289,18 @@ void main()
// How far, in mm, sweeping the ray across the shell slides the sample point sideways. Below half
// a texel there is no parallax to find and the march would be pure cost - which is the common
// case of looking straight down at a surface.
float sweep = length(view_dir - tex_normal * v_dot_n) * (h_hi - h_lo) / max(v_dot_n, 1e-4);
if (v_dot_n > 0.05 && sweep > 0.5 * min(tile_mm().x, tile_mm().y) * height_tex_texel.x) {
float sweep = length(view_dir - triangle_normal * v_dot_n) * (h_hi - h_lo) / max(v_dot_n, 1e-4);
if (v_dot_n > 0.05 && sweep > 0.5 * tiling_scale * height_tex_texel.x) {
// A point at ray parameter s (model_pos + view_dir * s) sits at height s * v_dot_n above the
// undisplaced surface. Start at the top of the shell, where the ray is outside the surface
// by construction, and step inward; the crossing is what this pixel actually sees.
float s = h_hi / v_dot_n;
float ds = (h_hi - h_lo) / (v_dot_n * float(PARALLAX_STEPS));
vec2 prev_uv = project_uv(tex_pos + view_dir * s, tex_normal);
vec2 prev_uv = project_uv(tex_pos + view_dir * s, triangle_normal);
float prev_gap = h_hi - amp * (H_AT(prev_uv) - midlevel); // >= 0 by construction
for (int i = 0; i < PARALLAX_STEPS; ++i) {
s -= ds;
vec2 cur_uv = project_uv(tex_pos + view_dir * s, tex_normal);
vec2 cur_uv = project_uv(tex_pos + view_dir * s, triangle_normal);
float gap = s * v_dot_n - amp * (H_AT(cur_uv) - midlevel);
if (gap <= 0.0) {
// Crossed between the last two samples - interpolating the hit is what stops it
@@ -360,23 +322,18 @@ void main()
float hU = texture2D(height_tex, uv + vec2(0.0, height_tex_texel.y)).r;
vec2 dh_duv = vec2((hR - hL) / (2.0 * height_tex_texel.x), (hU - hD) / (2.0 * height_tex_texel.y));
vec2 tile = tile_mm();
float amplitude = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0);
float inv_tiling = (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0;
float amplitude = (invert ? -1.0 : 1.0) * depth_mm * inv_tiling * clamp(weight, 0.0, 1.0);
float cs = cos(rotation_rad);
float sn = sin(rotation_rad);
// One uv unit is tile.x mm along u but tile.y / tex_aspect mm along v, so each component of
// the uv-space gradient carries its own factor before being rotated back into t/b.
// Each component carries its own extent and its own mirror sign, both of which project_uv()
// applied on the way in; without the sign a mirrored layer would light as though it were not.
vec2 ms = mirror_sign();
vec2 g = vec2(dh_duv.x * ms.x / tile.x, dh_duv.y * tex_aspect * ms.y / tile.y);
// One uv unit is tiling_scale mm along u but tiling_scale / tex_aspect mm along v, so the v
// component of the gradient carries the extra factor before being rotated back into t/b.
vec2 g = vec2(dh_duv.x, dh_duv.y * tex_aspect);
// ...and back out of raw-projection units into millimetres along t / b; see the 140 variant.
vec2 slope = amplitude * vec2(g.x * cs + g.y * sn, -g.x * sn + g.y * cs) * units_per_mm;
// t and b came out of the texture frame, so the gradient is in it too and has to come back
// to world before it perturbs the normal the lighting uses.
vec3 gradient = tex_rot_inv * (slope.x * t + slope.y * b);
vec3 gradient = slope.x * t + slope.y * b;
gradient -= triangle_normal * dot(triangle_normal, gradient);
triangle_normal = normalize(triangle_normal - gradient);
}
@@ -396,9 +353,7 @@ void main()
// Only the albedo - the specular term (intensity.y) stays white - so a coloured fragment reads as
// the same material under the same light, and the relief this preview exists to show is unaffected.
vec3 albedo = uniform_color.rgb;
bool uv_covered = tile_enabled ||
(all(greaterThanEqual(color_uv, vec2(0.0))) && all(lessThan(color_uv, vec2(1.0))));
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0 && uv_covered)
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0)
albedo = palette_rgb[nearest_palette_entry(texture2D(color_tex, color_uv).rgb)];
gl_FragColor = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a);
}
@@ -16,16 +16,7 @@ uniform bool volume_mirrored;
uniform int mode;
uniform float checker_freq;
uniform float tiling_scale;
// Tile extent along v, in mm. Zero or negative means "the same as tiling_scale" - see
// layer_tile_mm() on the CPU side, so an unset uniform behaves exactly as this shader always did.
uniform float tiling_scale_v;
// Mirror along one tile axis; false by default, so an unset uniform is the old behaviour.
uniform bool mirror_u;
uniform bool mirror_v;
uniform vec3 tex_anchor; // the volume's origin in world space
// The placement's rotation, so this overlay projects the same frame the bake does. Identity, which
// is what an unset uniform gives, leaves the old world-aligned behaviour.
uniform mat3 tex_rot; // world -> texture frame
uniform float rotation_rad;
uniform vec2 uv_offset;
uniform bool use_vertex_uv;
@@ -88,30 +79,13 @@ vec2 projection_raw(vec3 p, vec3 n)
return vec2(box_face_sign(n.z) * p.x, -p.y);
}
// Tile extents in mm, u then v, with the v fallback layer_tile_mm() applies.
vec2 tile_mm()
{
float su = (tiling_scale > 1e-6) ? tiling_scale : 1.0;
return vec2(su, (tiling_scale_v > 1e-6) ? tiling_scale_v : su);
}
// +1 or -1 per axis, matching layer_mirror_sign() on the CPU side.
vec2 mirror_sign()
{
return vec2(mirror_u ? -1.0 : 1.0, mirror_v ? -1.0 : 1.0);
}
vec2 project_uv(vec3 p, vec3 n)
{
vec2 planar = projection_raw(p, n);
planar *= (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0;
float cs = cos(rotation_rad);
float sn = sin(rotation_rad);
vec2 r = vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs);
// Extents divide after the rotation, so a tile whose two sides differ stays a rotated rectangle
// instead of shearing. With a square tile the two orders agree exactly. The mirror is a sign on
// the same division, in the tile's own frame.
r *= mirror_sign() / tile_mm();
r.y *= tex_aspect; // after the rotation, so the rotation stays a rotation rather than a shear
return r + uv_offset;
}
@@ -131,7 +105,7 @@ void main()
// World space anchored at the volume's origin, like the bake and the shaded preview.
vec3 triangle_normal = normalize(cross(dFdx(world_pos.xyz), dFdy(world_pos.xyz)));
vec3 tex_pos = tex_rot * (world_pos.xyz - tex_anchor);
vec3 tex_pos = world_pos.xyz - tex_anchor;
if (volume_mirrored)
triangle_normal = -triangle_normal;
@@ -139,7 +113,7 @@ void main()
if (mode == 1) {
base = heatmap(distortion);
} else {
vec2 uv = use_vertex_uv ? vertex_uv : project_uv(tex_pos, tex_rot * triangle_normal);
vec2 uv = use_vertex_uv ? vertex_uv : project_uv(tex_pos, triangle_normal);
vec2 c = floor(uv * checker_freq);
float check = mod(c.x + c.y, 2.0);
base = (check < 0.5) ? vec3(0.22, 0.23, 0.26) : vec3(0.82, 0.83, 0.86);
+94
View File
@@ -0,0 +1,94 @@
#version 140
// What a ChromaStack print looks like, shaded per fragment.
//
// The colour is not a vertex attribute and not a texture on the surface: it is read from the
// fragment's own height. A filament swap happens at one print Z across the whole plate, so the layer
// a point sits in is what decides its colour - which means a band edge lands exactly on the layer
// boundary however coarse the mesh is, and the same shader is right before a bake (on the preview's
// displaced mesh) and after one (on the volume's own).
//
// On top of that it tries to look like printed plastic rather than like flat shaded colour: the
// terminator is wrapped and the grazing angles glow, because filament translucent enough to be worth
// stacking scatters light rather than reflecting it; and the walls carry layer lines, because that is
// the first thing the eye uses to read a print as a print.
#define INTENSITY_CORRECTION 0.6
const vec3 LIGHT_TOP_DIR = vec3(-0.4574957, 0.4574957, 0.7624929);
#define LIGHT_TOP_DIFFUSE (0.8 * INTENSITY_CORRECTION)
#define LIGHT_TOP_SPECULAR (0.125 * INTENSITY_CORRECTION)
#define LIGHT_TOP_SHININESS 20.0
const vec3 LIGHT_FRONT_DIR = vec3(0.6985074, 0.1397015, 0.6985074);
#define LIGHT_FRONT_DIFFUSE (0.3 * INTENSITY_CORRECTION)
#define INTENSITY_AMBIENT 0.3
// The stack's colours, one texel per printed layer, bottom to top. A texture rather than a uniform
// array because a base can be several hundred layers and no uniform array survives that.
uniform sampler2D palette;
uniform float palette_size; // texels in `palette`, i.e. the stack's total printed layers
uniform float base_z; // world Z of the bottom of the object
uniform float layer_height; // mm
// How translucent the stack reads, 0 to 1, from the transmission distance of the filament on top.
// It drives the wrap and the rim: an opaque stack should not glow.
uniform float translucency;
uniform float alpha;
in vec3 clipping_planes_dots;
in vec3 eye_normal;
in vec3 eye_position;
in vec3 world_normal;
in float world_z;
out vec4 out_color;
void main()
{
if (any(lessThan(clipping_planes_dots, vec3(0.0))))
discard;
float z_rel = world_z - base_z;
float layers = max(z_rel / layer_height, 0.0);
// How much of a layer one pixel covers. Everything below is faded out as this grows: layer lines
// finer than a pixel are noise, and a colour read from one layer out of the dozen under a pixel
// flickers as the camera moves.
float px = fwidth(layers);
// The layer this fragment sits in, 1-based. Up close that is an exact texel of the palette; once a
// pixel spans a layer or more it slides to a continuous coordinate so the linear filter averages
// the bands it covers instead of picking one of them at random.
float stepped = clamp(ceil(layers - 1.0e-4), 1.0, palette_size) - 0.5;
float smooth_ = clamp(layers, 0.5, palette_size - 0.5);
float u = mix(stepped, smooth_, clamp(px - 0.5, 0.0, 1.0)) / palette_size;
vec3 albedo = texture(palette, vec2(u, 0.5)).rgb;
vec3 N = normalize(eye_normal);
vec3 V = normalize(-eye_position);
// Layer lines, on the walls only: a terrace is one layer's top surface and has none to show. The
// groove runs round the part at every layer boundary, so it is a function of height alone.
float wall = 1.0 - abs(world_normal.z);
float phase = fract(layers);
float groove = smoothstep(0.0, 0.16, phase) * smoothstep(1.0, 0.84, phase);
groove = mix(groove, 0.65, clamp(px * 1.5, 0.0, 1.0)); // fade to flat when too fine to resolve
float lines = mix(1.0, 0.84 + 0.16 * groove, wall * 0.9);
albedo *= lines;
// Diffuse, with a wrapped terminator: light entering translucent filament comes back out a little
// way round the curve, so the shaded side never goes as dark as an opaque surface's would.
float wrap = 0.25 + 0.45 * translucency;
float ndotl = dot(N, LIGHT_TOP_DIR);
float diffuse = max((ndotl + wrap) / (1.0 + wrap), 0.0) * LIGHT_TOP_DIFFUSE;
diffuse += max((dot(N, LIGHT_FRONT_DIR) + wrap) / (1.0 + wrap), 0.0) * LIGHT_FRONT_DIFFUSE;
// Specular, strongest along a layer line, which is what makes a printed wall glint in stripes
// rather than evenly.
float spec = LIGHT_TOP_SPECULAR * pow(max(dot(V, reflect(-LIGHT_TOP_DIR, N)), 0.0), LIGHT_TOP_SHININESS);
spec *= mix(1.0, 0.35 + 0.65 * groove, wall * 0.9);
// Scattering out of the edges: a translucent part lights up where it is seen at a grazing angle
// and where it is thin. Tinted by the filament, because what comes back out has been through it.
float rim = pow(1.0 - clamp(dot(N, V), 0.0, 1.0), 4.0);
vec3 color = albedo * (INTENSITY_AMBIENT + diffuse) + vec3(spec) + albedo * rim * translucency * 0.55;
out_color = vec4(color, alpha);
}
+40
View File
@@ -0,0 +1,40 @@
#version 140
uniform mat4 view_model_matrix;
uniform mat4 projection_matrix;
uniform mat3 view_normal_matrix;
uniform mat4 volume_world_matrix;
// The volume's normal matrix in world space, so the fragment shader can tell a wall from a terrace -
// which is what decides whether layer lines show at all.
uniform mat3 world_normal_matrix;
// Clipping plane, x = min z, y = max z (the FFF/SLA preview's top/bottom cut).
uniform vec2 z_range;
// Clipping plane, general orientation (the section view).
uniform vec4 clipping_plane;
// GLModel's P3N3 layout, which is what init_from(indexed_triangle_set) produces - this shader needs
// nothing per vertex that the mesh does not already carry, because the colour comes from the
// fragment's own height rather than from anything baked into the vertices.
in vec3 v_position;
in vec3 v_normal;
out vec3 clipping_planes_dots;
out vec3 eye_normal;
out vec3 eye_position;
out vec3 world_normal;
out float world_z;
void main()
{
vec4 model_pos = vec4(v_position, 1.0);
vec4 world_pos = volume_world_matrix * model_pos;
world_z = world_pos.z;
world_normal = normalize(world_normal_matrix * v_normal);
eye_normal = normalize(view_normal_matrix * v_normal);
vec4 eye_pos = view_model_matrix * model_pos;
eye_position = eye_pos.xyz;
gl_Position = projection_matrix * eye_pos;
clipping_planes_dots = vec3(dot(world_pos, clipping_plane), world_pos.z - z_range.x, z_range.y - world_pos.z);
}
@@ -95,11 +95,6 @@ uniform int palette_b[64];
uniform float prefer_pure_de; // PREFER_PURE_DE: how much better than a single filament a mix must be
uniform sampler2D color_tex; // the layer's colour image, sampled at the same uv as the height
uniform bool has_color_tex;
// False means the texture is a single placement, not a repeat. The sampler's wrap mode can express
// that for the *height* - a zero border reads as no displacement - but not for colour, because a
// zero border is indistinguishable from the image genuinely being black there. So the uv is tested
// instead, which is what sample_layer_color() does on the CPU side.
uniform bool tile_enabled;
uniform bool volume_mirrored;
uniform mat4 view_model_matrix;
@@ -109,14 +104,6 @@ uniform sampler2D height_tex;
uniform vec2 height_tex_texel; // (1/width, 1/height) of height_tex
uniform float depth_mm;
uniform float tiling_scale;
// Tile extent along v, in mm. Zero or negative means "the same as tiling_scale" - the square tile
// every layer had before the two axes could differ - which is also what layer_tile_mm() does on the
// CPU side, so an unset uniform behaves exactly as this shader always did.
uniform float tiling_scale_v;
// Mirror the texture along one axis of the tile. Both default to false, which GLSL guarantees for an
// uninitialised bool uniform, so a shader whose caller has not set them behaves exactly as before.
uniform bool mirror_u;
uniform bool mirror_v;
// Height map width / height. Scales the v axis so a non-square image keeps its proportions
// instead of being squeezed into a square tile - mirrors libslic3r's apply_uv_transform().
uniform float tex_aspect;
@@ -126,12 +113,6 @@ uniform bool invert;
uniform float midlevel; // the height that means "don't move"; needed by the parallax step
uniform vec3 eye_model_pos; // camera position in the texture frame (world minus tex_anchor)
uniform vec3 tex_anchor; // the volume's origin in world space: the texture frame's origin
// The placement's rotation and its inverse, so this shader turns a world position into the texture
// frame exactly as texture_displacement_bake_frame() does. Both are passed rather than one
// transposed here, because GLSL 110 has no transpose(). Identity leaves the old world-aligned
// behaviour, which is what an unset uniform gives.
uniform mat3 tex_rot; // world -> texture frame
uniform mat3 tex_rot_inv; // texture frame -> world
uniform bool use_vertex_uv; // true: sample at vertex_uv with a derived tangent frame (LSCM)
// A 2x3 affine (columns packed as lin = (m00, m01, m10, m11), tr = (m02, m12)) applied to the uv of
// the island currently being dragged in the UV editor (island_active > 0.5). Identity when nothing is
@@ -247,29 +228,13 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_
}
}
// Tile extents in mm, u then v, with the v fallback layer_tile_mm() applies.
vec2 tile_mm()
{
float su = (tiling_scale > 1e-6) ? tiling_scale : 1.0;
return vec2(su, (tiling_scale_v > 1e-6) ? tiling_scale_v : su);
}
// +1 or -1 per axis, matching layer_mirror_sign() on the CPU side.
vec2 mirror_sign()
{
return vec2(mirror_u ? -1.0 : 1.0, mirror_v ? -1.0 : 1.0);
}
vec2 project_uv(vec3 p, vec3 n)
{
vec2 planar = projection_raw(p, n);
planar *= (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0;
float cs = cos(rotation_rad);
float sn = sin(rotation_rad);
vec2 r = vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs);
// Extents divide after the rotation, so a tile whose two sides differ stays a rotated rectangle
// instead of shearing. With a square tile the two orders agree exactly. The mirror is a sign on
// the same division, in the tile's own frame.
r *= mirror_sign() / tile_mm();
// After the rotation, so the rotation stays a rotation rather than becoming a shear.
r.y *= tex_aspect;
return r + uv_offset;
@@ -340,12 +305,9 @@ void main()
// printed part whatever the instance's scale or rotation, so the preview has to project from the
// world position and perturb the world normal.
vec3 triangle_normal = normalize(cross(dFdx(world_pos.xyz), dFdy(world_pos.xyz)));
vec3 tex_pos = tex_rot * (world_pos.xyz - tex_anchor); // the frame the texture is projected in, as the bake does
vec3 tex_pos = world_pos.xyz - tex_anchor; // the frame the texture is projected in, as the bake does
if (volume_mirrored)
triangle_normal = -triangle_normal;
// The same normal in that frame. triangle_normal itself stays in world, because the lighting at
// the end of this shader needs it there.
vec3 tex_normal = tex_rot * triangle_normal;
// Where the colour is read from. Both branches below already compute the uv this fragment's
// *height* came from - including the parallax-marched one on the triplanar path - and the colour
@@ -389,14 +351,14 @@ void main()
// closed-form uv here, unlike the LSCM case.
vec3 t, b;
vec2 units_per_mm;
projection_axes(tex_pos, tex_normal, t, b, units_per_mm);
projection_axes(tex_pos, triangle_normal, t, b, units_per_mm);
// Parallax occlusion mapping: march the view ray through the height shell and shade at the
// first point where it drops below the displaced surface (see header).
float amp = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0);
vec3 view_dir = normalize(tex_rot * eye_model_pos - tex_pos);
float v_dot_n = dot(view_dir, tex_normal);
vec2 uv = project_uv(tex_pos, tex_normal);
vec3 view_dir = normalize(eye_model_pos - tex_pos);
float v_dot_n = dot(view_dir, triangle_normal);
vec2 uv = project_uv(tex_pos, triangle_normal);
// The shell the displaced surface lives inside, as signed heights along the normal. Taken from
// both ends of h in [0, 1] so it stays correct for an inverted layer or a raised midlevel,
@@ -408,18 +370,18 @@ void main()
// How far, in mm, sweeping the ray across the shell slides the sample point sideways. Below half
// a texel there is no parallax to find and the march would be pure cost - which is the common
// case of looking straight down at a surface.
float sweep = length(view_dir - tex_normal * v_dot_n) * (h_hi - h_lo) / max(v_dot_n, 1e-4);
if (v_dot_n > 0.05 && sweep > 0.5 * min(tile_mm().x, tile_mm().y) * height_tex_texel.x) {
float sweep = length(view_dir - triangle_normal * v_dot_n) * (h_hi - h_lo) / max(v_dot_n, 1e-4);
if (v_dot_n > 0.05 && sweep > 0.5 * tiling_scale * height_tex_texel.x) {
// A point at ray parameter s (model_pos + view_dir * s) sits at height s * v_dot_n above the
// undisplaced surface. Start at the top of the shell, where the ray is outside the surface
// by construction, and step inward; the crossing is what this pixel actually sees.
float s = h_hi / v_dot_n;
float ds = (h_hi - h_lo) / (v_dot_n * float(PARALLAX_STEPS));
vec2 prev_uv = project_uv(tex_pos + view_dir * s, tex_normal);
vec2 prev_uv = project_uv(tex_pos + view_dir * s, triangle_normal);
float prev_gap = h_hi - amp * (H_AT(prev_uv) - midlevel); // >= 0 by construction
for (int i = 0; i < PARALLAX_STEPS; ++i) {
s -= ds;
vec2 cur_uv = project_uv(tex_pos + view_dir * s, tex_normal);
vec2 cur_uv = project_uv(tex_pos + view_dir * s, triangle_normal);
float gap = s * v_dot_n - amp * (H_AT(cur_uv) - midlevel);
if (gap <= 0.0) {
// Crossed between the last two samples - interpolating the hit is what stops it
@@ -443,28 +405,23 @@ void main()
// Central difference, per uv unit (not per texel).
vec2 dh_duv = vec2((hR - hL) / (2.0 * height_tex_texel.x), (hU - hD) / (2.0 * height_tex_texel.y));
// uv -> mm is the reciprocal of the tile extents, applied per component below, which is what
// turns the uv-space gradient into a real surface slope.
vec2 tile = tile_mm();
float amplitude = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0);
// uv -> mm is 1/tiling_scale for the triplanar projection, so this turns the uv-space
// gradient into a real surface slope.
float inv_tiling = (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0;
float amplitude = (invert ? -1.0 : 1.0) * depth_mm * inv_tiling * clamp(weight, 0.0, 1.0);
// uv was rotated by project_uv() while t/b are the unrotated model axes, so rotate the
// gradient back into the axes' frame.
float cs = cos(rotation_rad);
float sn = sin(rotation_rad);
// One uv unit is tile.x mm along u but tile.y / tex_aspect mm along v, so each component of
// the uv-space gradient carries its own factor before being rotated back into t/b.
// Each component carries its own extent and its own mirror sign, both of which project_uv()
// applied on the way in; without the sign a mirrored layer would light as though it were not.
vec2 ms = mirror_sign();
vec2 g = vec2(dh_duv.x * ms.x / tile.x, dh_duv.y * tex_aspect * ms.y / tile.y);
// One uv unit is tiling_scale mm along u but tiling_scale / tex_aspect mm along v, so the v
// component of the gradient carries the extra factor before being rotated back into t/b.
vec2 g = vec2(dh_duv.x, dh_duv.y * tex_aspect);
// ...and back out of raw-projection units into millimetres of travel along t / b, which is a
// no-op except for the spherical longitude (see projection_axes()). After the inverse rotation,
// because units_per_mm is expressed in the t/b frame rather than in uv.
vec2 slope = amplitude * vec2(g.x * cs + g.y * sn, -g.x * sn + g.y * cs) * units_per_mm;
// t and b came out of the texture frame, so the gradient is in it too and has to come back
// to world before it perturbs the normal the lighting uses.
vec3 gradient = tex_rot_inv * (slope.x * t + slope.y * b);
vec3 gradient = slope.x * t + slope.y * b;
gradient -= triangle_normal * dot(triangle_normal, gradient);
triangle_normal = normalize(triangle_normal - gradient);
}
@@ -484,9 +441,7 @@ void main()
// Only the albedo - the specular term (intensity.y) stays white - so a coloured fragment reads as
// the same material under the same light, and the relief this preview exists to show is unaffected.
vec3 albedo = uniform_color.rgb;
bool uv_covered = tile_enabled ||
(all(greaterThanEqual(color_uv, vec2(0.0))) && all(lessThan(color_uv, vec2(1.0))));
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0 && uv_covered)
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0)
albedo = palette_rgb[nearest_palette_entry(texture(color_tex, color_uv).rgb)];
out_color = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a);
}
@@ -25,16 +25,7 @@ uniform bool volume_mirrored;
uniform int mode; // 0 checker, 1 distortion
uniform float checker_freq; // checker squares per uv unit (one uv unit == one texture tile)
uniform float tiling_scale;
// Tile extent along v, in mm. Zero or negative means "the same as tiling_scale" - see
// layer_tile_mm() on the CPU side, so an unset uniform behaves exactly as this shader always did.
uniform float tiling_scale_v;
// Mirror along one tile axis; false by default, so an unset uniform is the old behaviour.
uniform bool mirror_u;
uniform bool mirror_v;
uniform vec3 tex_anchor; // the volume's origin in world space
// The placement's rotation, so this overlay projects the same frame the bake does. Identity, which
// is what an unset uniform gives, leaves the old world-aligned behaviour.
uniform mat3 tex_rot; // world -> texture frame
uniform float rotation_rad;
uniform vec2 uv_offset;
uniform bool use_vertex_uv;
@@ -99,30 +90,13 @@ vec2 projection_raw(vec3 p, vec3 n)
return vec2(box_face_sign(n.z) * p.x, -p.y);
}
// Tile extents in mm, u then v, with the v fallback layer_tile_mm() applies.
vec2 tile_mm()
{
float su = (tiling_scale > 1e-6) ? tiling_scale : 1.0;
return vec2(su, (tiling_scale_v > 1e-6) ? tiling_scale_v : su);
}
// +1 or -1 per axis, matching layer_mirror_sign() on the CPU side.
vec2 mirror_sign()
{
return vec2(mirror_u ? -1.0 : 1.0, mirror_v ? -1.0 : 1.0);
}
vec2 project_uv(vec3 p, vec3 n)
{
vec2 planar = projection_raw(p, n);
planar *= (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0;
float cs = cos(rotation_rad);
float sn = sin(rotation_rad);
vec2 r = vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs);
// Extents divide after the rotation, so a tile whose two sides differ stays a rotated rectangle
// instead of shearing. With a square tile the two orders agree exactly. The mirror is a sign on
// the same division, in the tile's own frame.
r *= mirror_sign() / tile_mm();
r.y *= tex_aspect; // after the rotation, so the rotation stays a rotation rather than a shear
return r + uv_offset;
}
@@ -143,7 +117,7 @@ void main()
// World space anchored at the volume's origin, like the bake and the shaded preview.
vec3 triangle_normal = normalize(cross(dFdx(world_pos.xyz), dFdy(world_pos.xyz)));
vec3 tex_pos = tex_rot * (world_pos.xyz - tex_anchor);
vec3 tex_pos = world_pos.xyz - tex_anchor;
if (volume_mirrored)
triangle_normal = -triangle_normal;
@@ -151,7 +125,7 @@ void main()
if (mode == 1) {
base = heatmap(distortion);
} else {
vec2 uv = use_vertex_uv ? vertex_uv : project_uv(tex_pos, tex_rot * triangle_normal);
vec2 uv = use_vertex_uv ? vertex_uv : project_uv(tex_pos, triangle_normal);
vec2 c = floor(uv * checker_freq);
float check = mod(c.x + c.y, 2.0);
// Two distinct greys, plus a faint tint on one set so orientation is readable at a glance.
@@ -18,7 +18,7 @@
<script src="../js/plugin-config-ui.js"></script>
<script src="./index.js"></script>
<script src="./plugin-sort.js"></script>
<script src="../js/fuzzy-search.js"></script>
<script src="../../js/fuzzy-search.js"></script>
<script src="./plugin-search.js"></script>
</head>
<body onLoad="OnInit()">
+6 -2
View File
@@ -156,12 +156,16 @@ function SetCloudMode( bOn )
m_CloudMode=bOn;
$("body").toggleClass("cloud-mode", bOn);
if( bOn )
{
$(".BtnItem").removeClass("BtnItemSelected");
$(".BtnShortcut").addClass("BtnItemSelected");
}
}
function GotoCloud()
{
$(".BtnItem").removeClass("BtnItemSelected");
$(".BtnShortcut").addClass("BtnItemSelected");
SetCloudMode(true);
SendSimpleCommand("homepage_open_cloud");
}
@@ -336,10 +336,10 @@ modules:
sha256: 3d22d46c566b1761a0e15ea397589b3a5f36ac09b7c785382e6470156c04247f
dest: external-packages/PNG
# libjpeg-turbo 3.0.1
# libjpeg-turbo 3.2.0
- type: file
url: https://github.com/libjpeg-turbo/libjpeg-turbo/archive/refs/tags/3.0.1.zip
sha256: d6d99e693366bc03897677650e8b2dfa76b5d6c54e2c9e70c03f0af821b0a52f
url: https://github.com/libjpeg-turbo/libjpeg-turbo/archive/refs/tags/3.2.0.zip
sha256: 1b4c2cb6cba1ca5e53b9e85eeeab02154cab4b70894c96e97a9c747a26d19d63
dest: external-packages/JPEG
# Freetype 2.12.1
+2 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env sh
# Work-around https://gitlab.gnome.org/GNOME/gnome-build-meta/-/issues/754
grep -q org.freedesktop.Platform.GL.nvidia /.flatpak-info && export WEBKIT_DISABLE_DMABUF_RENDERER=1
# WebKit's NVIDIA DMABUF workaround (gnome-build-meta#754) is applied by the
# binary itself, gated on the runtime WebKitGTK version (src/OrcaSlicer.cpp).
# Work-around https://github.com/bambulab/BambuStudio/issues/3440
# Use LC_NUMERIC instead of LC_ALL to prevent decimal separator issues
+21 -15
View File
@@ -163,6 +163,7 @@ using json = nlohmann::json;
#include <X11/Xlib.h>
#endif
#include <unistd.h>
#include "slic3r/GUI/Widgets/WebView.hpp"
#endif
#ifdef SLIC3R_GUI
@@ -1156,8 +1157,10 @@ int CLI::run(int argc, char **argv)
// opt-in path. The ~2020-era WebKit2GTK XWayland compositor bug it
// worked around appears fixed in WebKit2GTK >= 2.42; leaving it
// unset preserves WebKit hardware acceleration on Device / Setup
// Wizard / login / store. The default path still applies it on
// XWayland sessions as a conservative fallback for older WebKit.
// Wizard / login / store. The default path applies it only where it
// forces GDK_BACKEND=x11 below (a build without EGL, so the app really
// runs through XWayland), and only when the linked WebKitGTK predates
// the fix (< 2.42), as a conservative fallback for older WebKit.
// ------------------------------------------------------------------
{
const char* gdk_backend = ::getenv("GDK_BACKEND");
@@ -1165,6 +1168,14 @@ int CLI::run(int argc, char **argv)
// honours the first backend in the comma-separated list).
const bool x11_opt_in = gdk_backend && boost::starts_with(gdk_backend, "x11");
// NVIDIA's proprietary driver cannot back WebKitGTK's DMABUF renderer.
// Force the shared-memory transport, which keeps compositing and threaded
// scrolling; the second variable clears Debian/Ubuntu/openSUSE's isNVIDIA().
if (::access("/proc/driver/nvidia/version", F_OK) == 0 && WebView::SupportsForceSharedMemory()) {
::setenv("WEBKIT_DMABUF_RENDERER_FORCE_SHM", "1", /* replace */ false);
::setenv("WEBKIT_FORCE_DMABUF_RENDERER", "1", /* replace */ false);
}
if (x11_opt_in) {
// ===== X11 / XWayland (opted in via GDK_BACKEND=x11) =====
BOOST_LOG_TRIVIAL(info) << "GDK_BACKEND=x11 detected; applying v2.3.2 X11/XWayland workarounds.";
@@ -1204,23 +1215,18 @@ int CLI::run(int argc, char **argv)
if (wayland_env && *wayland_env) {
BOOST_LOG_TRIVIAL(warning) << "Wayland detected but wxWidgets has no EGL support (wxHAS_EGL is OFF). Forcing X11 backend.";
::setenv("GDK_BACKEND", "x11", true);
// GTK will use X11 in a Wayland session here, which is the
// one default-path case that really runs through XWayland.
// WebKit2GTK accelerated compositing could fail silently
// there on releases before 2.42, so force it off. Disabling
// it also turns off threaded scrolling, hence the gate.
if (WebView::NeedsCompositingWorkaround())
::setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1", /* replace */ false);
}
}
#endif
// WebKit2GTK compositing can fail under XWayland on older
// WebKit releases. Disable it only when both DISPLAY and
// WAYLAND_DISPLAY are set (i.e. an XWayland session is in
// use). On pure X11 or native Wayland, compositing is left
// enabled so WebKit retains HW acceleration.
{
const char* display_env_wk = ::getenv("DISPLAY");
const char* wayland_env_wk = ::getenv("WAYLAND_DISPLAY");
if (display_env_wk && *display_env_wk && wayland_env_wk && *wayland_env_wk) {
::setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1", /* replace */ false);
}
}
// XInitThreads is needed before GStreamer may use Xlib. On
// native Wayland without DISPLAY, GStreamer uses waylandsink
// (no Xlib involved), so the call is skipped.
@@ -429,7 +429,8 @@ if [ "\$XDG_SESSION_TYPE" = "wayland" ] && [ "\$ZINK_DISABLE_OVERRIDE" != "1" ];
export __EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/50_mesa.json
export MESA_LOADER_DRIVER_OVERRIDE=zink
export GALLIUM_DRIVER=zink
export WEBKIT_DISABLE_DMABUF_RENDERER=1
# WebKit's NVIDIA DMABUF workaround is applied by the binary itself,
# gated on the runtime WebKitGTK version (src/OrcaSlicer.cpp).
fi
fi
fi
+2
View File
@@ -327,6 +327,8 @@ set(lisbslic3r_sources
Geometry/VoronoiUtils.cpp
Geometry/VoronoiUtils.hpp
Geometry/VoronoiVisualUtils.hpp
ChromaStack.cpp
ChromaStack.hpp
IMEXArrange.cpp
IMEXArrange.hpp
IMEXHelpers.cpp
+394
View File
@@ -0,0 +1,394 @@
#include "ChromaStack.hpp"
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
#include <cstdlib>
#include <numeric>
#include <tbb/blocked_range.h>
#include <tbb/parallel_for.h>
#include <miniz.h>
namespace Slic3r {
// How many e-folding lengths of absorption count as "the colour beneath no longer shows". Three
// passes 5% of the light, which is about where the eye stops seeing what is under a layer.
static constexpr float OPAQUE_EFOLDS = 3.f;
// TD is a filament property, so a zero or negative value in a preset is a data error rather than a
// request for an infinitely opaque filament. Clamp instead: below this the filament is opaque at a
// single layer anyway.
static constexpr float TD_MIN_MM = 0.05f;
static constexpr float TD_MAX_MM = 100.f;
static float srgb_to_linear(float c)
{
return c <= 0.04045f ? c / 12.92f : std::pow((c + 0.055f) / 1.055f, 2.4f);
}
static float linear_to_srgb(float c)
{
return c <= 0.0031308f ? c * 12.92f : 1.055f * std::pow(c, 1.f / 2.4f) - 0.055f;
}
// CIELAB under D65, from non-linear sRGB in [0, 1]. Only used for nearest-colour search, so the
// absolute white point matters far less than both sides using the same one.
struct Lab
{
float l = 0.f, a = 0.f, b = 0.f;
};
static Lab srgb_to_lab(float r, float g, float b)
{
const float rl = srgb_to_linear(r), gl = srgb_to_linear(g), bl = srgb_to_linear(b);
const float x = (0.4124f * rl + 0.3576f * gl + 0.1805f * bl) / 0.95047f;
const float y = (0.2126f * rl + 0.7152f * gl + 0.0722f * bl);
const float z = (0.0193f * rl + 0.1192f * gl + 0.9505f * bl) / 1.08883f;
auto f = [](float t) {
return t > 0.008856f ? std::cbrt(t) : (7.787f * t + 16.f / 116.f);
};
const float fx = f(x), fy = f(y), fz = f(z);
return Lab{ 116.f * fy - 16.f, 500.f * (fx - fy), 200.f * (fy - fz) };
}
static float delta_e(const Lab &p, const Lab &q)
{
const float dl = p.l - q.l, da = p.a - q.a, db = p.b - q.b;
return std::sqrt(dl * dl + da * da + db * db);
}
int ChromaStack::relief_layers() const
{
int n = 0;
for (const ChromaBand &band : bands)
n += std::max(0, band.layers);
return n;
}
int ChromaStack::total_layers() const { return std::max(0, base_layers) + relief_layers(); }
float ChromaStack::depth_mm() const { return float(relief_layers()) * layer_height_mm; }
std::vector<ColorRGB> chroma_palette(const ChromaStack &stack)
{
const int total = stack.total_layers();
// No bands means no picture and no filament to print the base in, whatever base_layers says.
if (total <= 0 || stack.bands.empty() || stack.layer_height_mm <= 0.f)
return {};
// The filament each printed layer is in, bottom-up: the base first, in the bottom band's filament,
// then the bands themselves.
std::vector<const ChromaFilament *> per_layer;
per_layer.reserve(size_t(total));
for (int i = 0; i < std::max(0, stack.base_layers); ++i)
per_layer.push_back(&stack.bands.front().filament);
for (const ChromaBand &band : stack.bands)
for (int i = 0; i < band.layers; ++i)
per_layer.push_back(&band.filament);
std::vector<ColorRGB> palette(size_t(total) + 1);
// Linear light, walking up from an opaque base: C_0 is the bottom layer's own colour, so a
// column one layer tall comes out exactly that colour however translucent the filament is.
std::array<float, 3> acc{ srgb_to_linear(per_layer.front()->color.r()), srgb_to_linear(per_layer.front()->color.g()),
srgb_to_linear(per_layer.front()->color.b()) };
auto store = [&palette](size_t k, const std::array<float, 3> &lin) {
palette[k] = ColorRGB(std::clamp(linear_to_srgb(lin[0]), 0.f, 1.f), std::clamp(linear_to_srgb(lin[1]), 0.f, 1.f),
std::clamp(linear_to_srgb(lin[2]), 0.f, 1.f));
};
store(0, acc);
for (int k = 1; k <= total; ++k) {
const ChromaFilament &f = *per_layer[size_t(k - 1)];
const float td = std::clamp(f.td_mm, TD_MIN_MM, TD_MAX_MM);
// The depth at which this filament hides what is behind it, turned into the e-folding length
// of its absorption; see CHROMA_TD_OPAQUE_FRACTION.
const float efold = CHROMA_TD_OPAQUE_FRACTION * td / OPAQUE_EFOLDS;
const float t = std::exp(-stack.layer_height_mm / efold);
const std::array<float, 3> own{ srgb_to_linear(f.color.r()), srgb_to_linear(f.color.g()),
srgb_to_linear(f.color.b()) };
for (int c = 0; c < 3; ++c)
acc[size_t(c)] = (1.f - t) * own[size_t(c)] + t * acc[size_t(c)];
store(size_t(k), acc);
}
return palette;
}
ChromaSolve chroma_solve(const uint8_t *rgb, int width, int height, const ChromaStack &stack)
{
ChromaSolve solve;
const int total = stack.total_layers();
const int base = std::max(0, stack.base_layers);
const int relief = stack.relief_layers();
if (rgb == nullptr || width <= 0 || height <= 0 || relief <= 0 || relief > CHROMA_MAX_RELIEF_LAYERS)
return solve;
const std::vector<ColorRGB> palette = chroma_palette(stack);
if (int(palette.size()) != total + 1)
return solve;
// Only the heights the solver may choose: base (nothing of the relief printed) up to total.
const int choices = relief + 1;
const size_t choice_count = size_t(choices);
std::vector<Lab> choice_lab(choice_count);
std::vector<std::array<uint8_t, 3>> choice_rgb(choice_count);
for (int i = 0; i < choices; ++i) {
const ColorRGB &c = palette[size_t(base + i)];
choice_lab[size_t(i)] = srgb_to_lab(c.r(), c.g(), c.b());
choice_rgb[size_t(i)] = { uint8_t(std::lround(c.r() * 255.f)), uint8_t(std::lround(c.g() * 255.f)),
uint8_t(std::lround(c.b() * 255.f)) };
}
const size_t count = size_t(width) * size_t(height);
solve.width = width;
solve.height = height;
solve.levels.resize(count);
solve.grey.resize(count);
solve.preview.resize(count * 3);
std::vector<float> errors(count, 0.f);
auto pick = [&](float r, float g, float b, float &out_error) {
const Lab target = srgb_to_lab(std::clamp(r, 0.f, 1.f), std::clamp(g, 0.f, 1.f), std::clamp(b, 0.f, 1.f));
int best = 0;
float best_d = std::numeric_limits<float>::max();
for (int i = 0; i < choices; ++i) {
const float d = delta_e(target, choice_lab[size_t(i)]);
if (d < best_d) {
best_d = d;
best = i;
}
}
out_error = best_d;
return best;
};
// The grey level a chosen height is written as. Rounded to the nearest of 256 levels, which for
// any practical layer budget (a few dozen) maps every height to its own level exactly - the
// relief's steps stay on layer boundaries, which is the whole point of quantizing here.
auto grey_of = [relief](int choice) {
return uint8_t(std::lround(255.0 * double(choice) / double(relief)));
};
auto write = [&](size_t px, int choice, float err) {
solve.levels[px] = uint16_t(base + choice);
solve.grey[px] = grey_of(choice);
solve.preview[px * 3] = choice_rgb[size_t(choice)][0];
solve.preview[px * 3 + 1] = choice_rgb[size_t(choice)][1];
solve.preview[px * 3 + 2] = choice_rgb[size_t(choice)][2];
errors[px] = err;
};
if (stack.dither) {
// Floyd-Steinberg, serpentine so the error does not drift consistently to one side. The
// diffusion buffer holds the two rows the kernel reaches, in sRGB - which is where the
// artifact being traded away lives, and what every reference implementation diffuses in.
const size_t row_len = size_t(width);
std::vector<std::array<float, 3>> row(row_len), next(row_len);
for (int x = 0; x < width; ++x) {
const uint8_t *src = rgb + size_t(x) * 3;
row[size_t(x)] = { src[0] / 255.f, src[1] / 255.f, src[2] / 255.f };
}
for (int y = 0; y < height; ++y) {
if (y + 1 < height)
for (int x = 0; x < width; ++x) {
const uint8_t *src = rgb + (size_t(y + 1) * size_t(width) + size_t(x)) * 3;
next[size_t(x)] = { src[0] / 255.f, src[1] / 255.f, src[2] / 255.f };
}
const bool ltr = (y % 2) == 0;
for (int i = 0; i < width; ++i) {
const int x = ltr ? i : width - 1 - i;
const size_t px = size_t(y) * size_t(width) + size_t(x);
float err = 0.f;
const int choice = pick(row[size_t(x)][0], row[size_t(x)][1], row[size_t(x)][2], err);
write(px, choice, err);
std::array<float, 3> residual{};
for (int c = 0; c < 3; ++c)
residual[size_t(c)] = row[size_t(x)][size_t(c)] - choice_rgb[size_t(choice)][size_t(c)] / 255.f;
const int ahead = ltr ? x + 1 : x - 1;
const int back = ltr ? x - 1 : x + 1;
auto spread = [&](std::vector<std::array<float, 3>> &dst, int at, float w) {
if (at < 0 || at >= width)
return;
for (int c = 0; c < 3; ++c)
dst[size_t(at)][size_t(c)] += residual[size_t(c)] * w;
};
spread(row, ahead, 7.f / 16.f);
if (y + 1 < height) {
spread(next, back, 3.f / 16.f);
spread(next, x, 5.f / 16.f);
spread(next, ahead, 1.f / 16.f);
}
}
row.swap(next);
}
} else {
tbb::parallel_for(tbb::blocked_range<int>(0, height), [&](const tbb::blocked_range<int> &range) {
for (int y = range.begin(); y < range.end(); ++y)
for (int x = 0; x < width; ++x) {
const size_t px = size_t(y) * size_t(width) + size_t(x);
const uint8_t *src = rgb + px * 3;
float err = 0.f;
write(px, pick(src[0] / 255.f, src[1] / 255.f, src[2] / 255.f, err), err);
}
});
}
solve.mean_delta_e = count == 0 ? 0. : std::accumulate(errors.begin(), errors.end(), 0.) / double(count);
solve.max_delta_e = errors.empty() ? 0. : double(*std::max_element(errors.begin(), errors.end()));
return solve;
}
float chroma_layers_to_opaque(float td_mm, float layer_height_mm)
{
if (layer_height_mm <= 0.f)
return 0.f;
return CHROMA_TD_OPAQUE_FRACTION * std::clamp(td_mm, TD_MIN_MM, TD_MAX_MM) / layer_height_mm;
}
// Below about one dE two colours are the same colour to the eye. Half of that is the threshold for
// "this layer changed something", so a step has to be comfortably invisible before a layer is called
// wasted.
static constexpr float VISIBLE_DE = 0.5f;
static float step_delta_e(const ColorRGB &a, const ColorRGB &b)
{
return delta_e(srgb_to_lab(a.r(), a.g(), a.b()), srgb_to_lab(b.r(), b.g(), b.b()));
}
int chroma_distinct_levels(const ChromaStack &stack)
{
const std::vector<ColorRGB> palette = chroma_palette(stack);
const int base = std::max(0, stack.base_layers);
if (int(palette.size()) <= base + 1)
return 0;
int n = 0;
for (size_t i = size_t(base) + 1; i < palette.size(); ++i)
if (step_delta_e(palette[i - 1], palette[i]) > VISIBLE_DE)
++n;
return n;
}
void chroma_trim_bands(ChromaStack &stack)
{
// Walked over the stack's own palette rather than over the filaments' TD, because how fast a band
// runs out of anything to say depends on what is under it as much as on the filament. A light band
// over a dark one is finished in four or five layers; a dark one over a light one goes on paying
// for many more, long past the depth at which it is nominally opaque, because the last few percent
// of light coming through still moves a near-black colour perceptibly.
const std::vector<ColorRGB> palette = chroma_palette(stack);
if (palette.empty())
return;
int layer = std::max(0, stack.base_layers); // palette index of the layer under the band being looked at
for (ChromaBand &band : stack.bands) {
int keep = 1;
for (int i = 1; i <= band.layers; ++i) {
const size_t cur = size_t(layer) + size_t(i);
if (cur >= palette.size())
break;
if (step_delta_e(palette[cur - 1], palette[cur]) > VISIBLE_DE)
keep = i; // the last layer of this band that still changes the colour
}
layer += band.layers;
band.layers = std::clamp(keep, 1, band.layers);
}
}
int chroma_level_at(const ChromaStack &stack, double z_mm)
{
const int total = stack.total_layers();
if (total <= 0 || stack.layer_height_mm <= 0.f)
return 0;
// Layer k occupies ((k-1)h, k h], so a point inside it rounds up. The epsilon keeps a surface
// sitting exactly on a layer boundary in the layer that printed it rather than in the one above,
// which is the difference between a terrace showing its own colour and showing the next band's.
const int k = int(std::ceil(z_mm / double(stack.layer_height_mm) - 1e-6));
return std::clamp(k, 1, total);
}
std::vector<unsigned char> chroma_encode_height_png(const ChromaSolve &solve)
{
if (solve.empty())
return {};
size_t size = 0;
void *png = tdefl_write_image_to_png_file_in_memory_ex(solve.grey.data(), solve.width, solve.height, 1, &size,
MZ_DEFAULT_LEVEL, MZ_FALSE);
if (png == nullptr || size == 0)
return {};
const unsigned char *bytes = static_cast<const unsigned char *>(png);
std::vector<unsigned char> out(bytes, bytes + size);
mz_free(png);
return out;
}
std::vector<ChromaRange> chroma_ranges(const ChromaStack &stack, double z_bottom)
{
std::vector<ChromaRange> out;
if (stack.layer_height_mm <= 0.f)
return out;
// The layer height is stored as a float, like every other dimension of a texture displacement
// layer, so multiplying it up leaves noise in the last few digits - 5 layers of 0.1 mm come out as
// 0.5000000074. The slicer derives its own layer boundaries from the print config in double
// precision and lands on 0.5 exactly, and a boundary that misses it by any amount is a boundary a
// layer can fall on the wrong side of. Rounding to the nanometre removes the noise without
// disturbing any layer height a profile can express.
const auto snap = [](double z) { return std::round(z * 1e6) / 1e6; };
// The base is printed in the bottom band's filament - it is what is loaded when the print starts -
// so it extends that band's range down to the first layer instead of being a swap of its own.
int layer = std::max(0, stack.base_layers);
bool first = true;
for (const ChromaBand &band : stack.bands) {
if (band.layers <= 0)
continue;
const double z_min = snap(z_bottom + (first ? 0. : double(layer) * double(stack.layer_height_mm)));
first = false;
layer += band.layers;
const double z_max = snap(z_bottom + double(layer) * double(stack.layer_height_mm));
if (!out.empty() && out.back().filament == band.filament.filament)
out.back().z_max = z_max; // no swap happens here, so it is one range
else
out.push_back(ChromaRange{ z_min, z_max, band.filament.filament });
}
return out;
}
ChromaStack chroma_default_stack(const std::vector<ChromaFilament> &filaments, float layer_height_mm,
int base_layers, int relief_layers)
{
ChromaStack stack;
stack.layer_height_mm = layer_height_mm > 0.f ? layer_height_mm : 0.08f;
stack.base_layers = std::max(0, base_layers);
if (filaments.empty() || relief_layers <= 0)
return stack;
std::vector<ChromaFilament> ordered = filaments;
std::stable_sort(ordered.begin(), ordered.end(), [](const ChromaFilament &a, const ChromaFilament &b) {
// Relative luminance of the linear colour: the perceptual order, so a saturated yellow
// counts as light and a saturated blue as dark, which a plain r+g+b sum gets wrong.
auto lum = [](const ColorRGB &c) {
return 0.2126f * srgb_to_linear(c.r()) + 0.7152f * srgb_to_linear(c.g()) + 0.0722f * srgb_to_linear(c.b());
};
return lum(a.color) > lum(b.color);
});
const int count = int(ordered.size());
int left = relief_layers;
for (int i = 0; i < count; ++i) {
// Spread the remainder over the first bands rather than piling it onto the last one.
const int share = left / (count - i) + ((left % (count - i)) > 0 ? 1 : 0);
ChromaBand band;
band.filament = ordered[size_t(i)];
band.layers = std::max(1, share);
left -= share;
stack.bands.push_back(band);
}
return stack;
}
} // namespace Slic3r
+286
View File
@@ -0,0 +1,286 @@
#ifndef slic3r_ChromaStack_hpp_
#define slic3r_ChromaStack_hpp_
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
#include <cereal/cereal.hpp>
#include <cereal/types/vector.hpp>
#include "Color.hpp"
namespace Slic3r {
// ChromaStack-style colour printing: a picture is reproduced on a single face by stacking thin layers
// of a few filaments, swapping filament at fixed layer boundaries, and varying the *height* of each
// pixel's column. Where the column is low, the layers above it are missing and the filament below
// shows; where it is high, the topmost filament covers everything beneath it. Because filament is
// translucent, a column that stops part-way through a run shows a blend of that run's filament and
// what is under it, which is what gives far more colours than there are filaments loaded.
//
// So there are only two things to compute, and they are both here:
// - the *palette*: the colour the stack shows at every possible column height (chroma_palette());
// - the *height map*: per pixel, the column height whose palette colour is closest to the picture
// (chroma_solve()).
//
// Everything else is machinery that already exists. The height map is handed to a texture
// displacement layer as its height texture, so the existing projection/preview/bake pipeline turns
// it into real geometry (see TextureDisplacement.hpp), and the filament swaps are handed to the
// object's height ranges (ModelObject::layer_config_ranges), which is the slicer's own way of
// saying "print this Z band with that filament".
// The depth of filament that blocks essentially all the light behind it, as a fraction of the
// filament's transmission distance (TD).
//
// TD's scale is empirical, not physical: its values run from about 1
// for an opaque filament to 10 and above for a nearly clear one, and the community's calibration
// prints (the "seashell" step test) read a TD off the step where the colour beneath stops showing.
// That step is about a tenth of the TD number, which is the only thing this constant encodes - a
// filament of TD 2 stops hiding what is under it at roughly 0.2 mm, i.e. after two or three layers,
// while one of TD 8 needs most of a millimetre. The model below turns that into a per-layer
// transmittance; see chroma_palette().
static constexpr float CHROMA_TD_OPAQUE_FRACTION = 0.1f;
// What a fresh stack gets above the base. Twenty-four layers is about two millimetres at a fine layer
// height: enough steps for a continuous tonal ramp, and still a print that finishes in an evening.
static constexpr int CHROMA_DEFAULT_RELIEF_LAYERS = 24;
// The most picture layers the solve can express. A column's height above the base is stored as one
// byte per pixel in the 8-bit grayscale height map the displacement reads, so beyond this two
// different heights would have to share a grey level and the relief's steps would stop landing on
// layer boundaries - which is the one thing the quantization exists to guarantee.
//
// It bounds the bands, not the stack: the base is whatever the model already is under the painted
// face, often hundreds of layers on a tall part, and nothing about it is quantized into the height
// map. As a limit on the picture it is not one in practice - 255 layers is 20 mm of relief at
// 0.08 mm, far past where a picture gains anything.
static constexpr int CHROMA_MAX_RELIEF_LAYERS = 255;
// The most filament changes one stack may hold. Every band is a swap the printer has to make, so this is
// a limit on patience rather than on the model; the palette itself is bounded by the layer count.
static constexpr size_t CHROMA_MAX_BANDS = 16;
// One filament as the colour model sees it: what colour it is, and how far light gets into it.
struct ChromaFilament
{
// 0-based index into the project's filaments, which is what a height range's `extruder` option
// (1-based) is written from. -1 for a filament that is not loaded, which the GUI refuses to
// solve with rather than silently printing in the wrong colour.
int filament = -1;
ColorRGB color;
// The filament preset's own name, so the panel can say which filament this is. Six similar greys
// are six identical swatches and six identical numbers otherwise. Stored rather than looked up on
// demand because a stack outlives the project's filament list - a preset can be swapped out from
// under a saved stack, and then the name is the only record of what it was solved against.
std::string name;
// Transmission distance, mm, as the filament preset reports it
// (`filament_transmission_distance`). Clamped to a sane range when the palette is built: zero
// would make a layer infinitely opaque and a negative value is meaningless.
float td_mm = 4.f;
template<class Archive> void serialize(Archive &ar)
{
float r = color.r(), g = color.g(), b = color.b();
ar(filament, r, g, b, td_mm, name);
color = ColorRGB(r, g, b);
}
};
// A run of consecutive layers printed in one filament. The stack is a list of these, bottom-up, so
// a band boundary is exactly where the print pauses and the filament is changed.
struct ChromaBand
{
ChromaFilament filament;
int layers = 1;
template<class Archive> void serialize(Archive &ar) { ar(filament, layers); }
};
// The whole recipe: which filaments, in which order, how thick a layer is, and how much of the
// bottom is solid backing rather than picture.
struct ChromaStack
{
std::vector<ChromaBand> bands; // bottom-up; bands.front() is the one the base is printed in
// The print's layer height, mm. Taken from the print profile rather than chosen here - a stack
// solved against 0.08 mm layers prints as the wrong picture at 0.2 mm, so the GUI re-solves when
// the profile's layer height changes.
float layer_height_mm = 0.08f;
// Layers of solid backing under the picture: everything the model already is below the painted
// face. Not part of any band - the bands are the picture - but printed in the bottom band's
// filament, since that is what is loaded when the print starts.
//
// Keeping it out of the bands is what lets it be what it really is: a measurement of the model,
// often a hundred layers or more, while a band is a handful. Folding the two together put a
// three-digit number on a slider meant for single digits and made "how thick is the backing" and
// "how many layers does the first filament get" the same control, which they are not.
//
// The colour model treats the base as opaque. That is what makes a column's colour depend only on
// the layers above it, and why the panel warns about a base thin enough to let the plate show
// through.
int base_layers = 4;
// Whether `base_layers` follows the model or was typed in. It follows by default, and it should:
// the painted face is wherever the geometry puts it, and a base that disagrees with it shifts
// every colour in the picture - the stack ends up anchored at the bottom of the part instead of
// under the face, so the picture's whole range is squeezed into the first few millimetres and
// everything above it clamps to the top band.
//
// It cannot be measured once and left: a layer is created, and its picture chosen, before there
// is any paint to measure, and the face moves again whenever the part is scaled or the layer
// height changes. So the panel re-measures it every frame while this is set.
bool base_auto = true;
// Spread the quantization error into neighbouring pixels (Floyd-Steinberg, serpentine) instead
// of rounding each pixel to its nearest palette colour on its own. A palette of twenty or thirty
// colours has to cover a photograph's whole range, so plain rounding leaves visible bands across
// a sky or a cheek; diffusing the error trades them for fine stipple, which at a tenth of a
// millimetre per pixel reads as the intermediate colour. Off for flat-coloured artwork, where
// stipple is the artifact rather than the cure.
bool dither = false;
// Total printed layers, base included: what the tallest column is.
int total_layers() const;
// Layers the picture is built from, i.e. the bands' own total above the base.
int relief_layers() const;
// The relief's height in mm, which is the `depth_mm` of the texture displacement layer the
// height map is handed to.
float depth_mm() const;
bool empty() const { return bands.empty(); }
template<class Archive> void serialize(Archive &ar) { ar(bands, layer_height_mm, base_layers, dither, base_auto); }
};
// The colour the stack shows at every column height: entry k is a column of exactly k printed
// layers, so the vector has total_layers() + 1 entries.
//
// The model is Beer-Lambert absorption, layer by layer. A layer of thickness h in a filament of
// transmission distance TD passes
//
// T = exp(-h / (CHROMA_TD_OPAQUE_FRACTION * TD / 3))
//
// of the light coming from under it and shows its own colour with the rest, so
//
// C_k = (1 - T_k) * c_k + T_k * C_(k-1)
//
// walking up from an opaque base, C_0 = c_1. The divisor is what makes "opaque at
// CHROMA_TD_OPAQUE_FRACTION * TD" true: three e-folding lengths pass 5% of the light, which is
// where the colour beneath stops being visible to the eye.
//
// Beer-Lambert is not just the physically honest choice here, it is the only one that stays
// consistent when applied per layer: transmittance multiplies, so a run of n layers of one filament
// passes exp(-n h / d) whether it is composited in one step or n, and the palette does not depend on
// how the stack happens to be cut into bands.
//
// Compositing is done on linear light and the entries are converted back to sRGB, because the
// quantity being attenuated is light, not an sRGB code.
std::vector<ColorRGB> chroma_palette(const ChromaStack &stack);
// What chroma_solve() produced.
struct ChromaSolve
{
int width = 0;
int height = 0;
// Per pixel, row-major and top-to-bottom like DecodedHeightTexture: the column's printed layer
// count, between base_layers and total_layers(). Sixteen bits because it counts from the build
// plate, and the base alone can be several hundred layers on a tall part.
std::vector<uint16_t> levels;
// The same thing as the 8-bit grayscale height map the displacement samples: 0 at base_layers,
// 255 at total_layers(). This is what gets encoded as a PNG into the layer's `image_data`, which
// is why the solve has to be the thing that owns the quantization - the relief's steps have to
// land on layer boundaries, and a grey level is the only channel the displacement reads.
std::vector<uint8_t> grey;
// Per pixel RGB: the palette colour the solve chose, i.e. what the print is predicted to look
// like. Shown in the panel next to the source picture, since the 3D view can only show the
// relief - the colours do not exist until the print swaps filament.
std::vector<uint8_t> preview;
// How far the prediction landed from the picture, as a mean and worst-case CIE76 dE over all
// pixels. The panel reports it because it is the only honest answer to "will this stack
// reproduce my image": under about 5 is a good match, over about 15 means the filaments cannot
// reach the picture's colours and no amount of layers will fix it.
double mean_delta_e = 0.;
double max_delta_e = 0.;
bool empty() const { return width <= 0 || height <= 0 || levels.empty(); }
};
// Solves `rgb` (width * height * 3 bytes, row-major, top-to-bottom) against the stack's palette.
// An empty stack, a zero-sized image or a stack with no relief layers gives an empty result.
ChromaSolve chroma_solve(const uint8_t *rgb, int width, int height, const ChromaStack &stack);
// How many layers of this filament it takes to hide what is under it, at this layer height. The
// single most useful number about a stack and the one nothing else makes visible: it is what decides
// whether a band is a gradient or a step.
//
// A filament hides what is behind it at about CHROMA_TD_OPAQUE_FRACTION of its TD (see
// chroma_palette()), so this is that depth in layers. Below two the band has no intermediate colours
// at all - the picture can only use the filament or not use it - which is what a coarse layer height
// does to an otherwise good stack: TD 4 at 0.2 mm covers in two layers, the same filament at 0.08 mm
// in five.
float chroma_layers_to_opaque(float td_mm, float layer_height_mm);
// How many of the picture's layers actually change the colour.
//
// A band eventually stops saying anything: once it hides what is under it, another layer of it shows
// the colour the one below already showed. Those layers still cost print time and still take up
// relief height, and nothing about the stack makes them visible - the band looks like ten layers of
// gradient and is, say, five of gradient and five of flat.
//
// Measured against the palette rather than against TD, because how long a band keeps paying depends
// on what is under it as much as on the filament: a light band over a dark one is finished in four or
// five layers, while a dark one over a light one goes on moving the colour perceptibly well past the
// depth at which it is nominally opaque.
int chroma_distinct_levels(const ChromaStack &stack);
// Shortens every band to its last layer that still changes the colour. Bands already shorter than
// that are left alone: they are still inside their fade, where every layer counts.
void chroma_trim_bands(ChromaStack &stack);
// The printed layer a point `z_mm` above the bottom of the object falls in: 1-based, clamped to the
// stack, 0 for a stack that prints nothing. Pair it with chroma_palette() to get the colour shown
// there.
//
// This, rather than the height map, is what decides the colour a surface shows. A filament swap
// happens at one print Z across the whole plate, so every surface crossing that height changes colour
// whatever the picture says - the terraces of the relief, the walls between them, and the sides of the
// model alike. Colouring a preview this way is therefore not an approximation of the print; it is what
// the print does, and it keeps working after the relief has been baked into the mesh and the height
// map is no longer attached to anything on screen.
int chroma_level_at(const ChromaStack &stack, double z_mm);
// The solve's height map as an 8-bit grayscale PNG, ready to be stored in a
// TextureDisplacementLayer's `image_data`.
std::vector<unsigned char> chroma_encode_height_png(const ChromaSolve &solve);
// One filament swap as an object height range: the Z band, in the object's own coordinates (0 at its
// bottom), that prints in `filament` (0-based).
struct ChromaRange
{
double z_min = 0.;
double z_max = 0.;
int filament = -1;
};
// The stack's bands as height ranges, in object coordinates, starting at `z_bottom` (which is 0 for
// a stack whose base starts at the object's first layer). Bands printed in the same filament as the
// band below are merged, so a stack that repeats a filament does not produce a pointless swap.
std::vector<ChromaRange> chroma_ranges(const ChromaStack &stack, double z_bottom = 0.);
// A starting point for a stack: the given filaments, lightest at the bottom, each with an equal
// share of `relief_layers` on top of `base_layers`.
//
// Light-to-dark is the convention filament-painting guides teach, and it is not arbitrary: a dark
// filament over a light one reads as a midtone at one or two layers and as the dark colour at four,
// so putting the dark filaments last is what buys the long tonal ramp. The reverse order gives a few
// washed-out steps and then nothing.
ChromaStack chroma_default_stack(const std::vector<ChromaFilament> &filaments, float layer_height_mm,
int base_layers, int relief_layers);
} // namespace Slic3r
#endif // slic3r_ChromaStack_hpp_
+27 -18
View File
@@ -432,27 +432,36 @@ static std::vector<Vec2d> get_path_of_change_filament(const Print& print)
std::string OozePrevention::pre_toolchange(GCode& gcodegen)
{
std::string gcode;
unsigned int extruder_id = gcodegen.writer().filament()->id();
const auto& filament_idle_temp = gcodegen.config().idle_temperature;
if (filament_idle_temp.get_at(extruder_id) == 0) {
// There is no idle temperature defined in filament settings.
// Use the delta value from print config.
if (gcodegen.config().standby_temperature_delta.value != 0) {
// we assume that heating is always slower than cooling, so no need to block
gcode += gcodegen.writer().set_temperature
(this->_get_temp(gcodegen) + gcodegen.config().standby_temperature_delta.value, false, extruder_id);
gcode.pop_back();
gcode += " ;cooldown\n"; // this is a marker for GCodeProcessor, so it can supress the commands when needed
}
} else {
// Use the value from filament settings. That one is absolute, not delta.
gcode += gcodegen.writer().set_temperature(filament_idle_temp.get_at(extruder_id), false, extruder_id);
gcode.pop_back();
gcode += " ;cooldown\n"; // this is a marker for GCodeProcessor, so it can supress the commands when needed
// Check if this tool will ever be used again in this print.
bool is_last_use = false;
if (gcodegen.m_print != nullptr && !gcodegen.m_print->tool_ordering().empty()) {
coordf_t current_z = gcodegen.layer() ? gcodegen.layer()->print_z : gcodegen.writer().get_position().z();
const auto& tool_ordering = gcodegen.m_print->tool_ordering();
const auto& cfg = gcodegen.config();
const int heater = imex_physical_heater_for(cfg.is_imex.value, cfg.physical_extruder_map, int(extruder_id));
is_last_use = tool_ordering.is_last_extrusion_layer(current_z, extruder_id);
for (unsigned int f : tool_ordering.all_extruders())
if (is_last_use && imex_physical_heater_for(cfg.is_imex.value, cfg.physical_extruder_map, int(f)) == heater)
is_last_use = tool_ordering.is_last_extrusion_layer(current_z, f);
}
int temp = 0;
if (!is_last_use) {
const auto& filament_idle_temp = gcodegen.config().idle_temperature;
if (filament_idle_temp.get_at(extruder_id) == 0) {
if (gcodegen.config().standby_temperature_delta.value == 0)
return std::string();
temp = this->_get_temp(gcodegen) + gcodegen.config().standby_temperature_delta.value;
} else {
temp = filament_idle_temp.get_at(extruder_id);
}
}
std::string gcode = gcodegen.writer().set_temperature(temp, false, extruder_id);
gcode.pop_back();
gcode += " ;cooldown\n"; // this is a marker for GCodeProcessor, so it can supress the commands when needed
return gcode;
}
+1
View File
@@ -1004,6 +1004,7 @@ protected:
size_t num_objects,
size_t num_islands);
friend class OozePrevention;
friend class Wipe;
friend class WipeTowerIntegration;
friend class PressureEqualizer;
+14 -1
View File
@@ -1276,6 +1276,13 @@ void ToolOrdering::collect_extruder_statistics(bool prime_multi_material)
sort_remove_duplicates(m_all_printing_extruders);
}
m_last_layer_per_extruder.clear();
for (const LayerTools& lt : m_layer_tools) {
for (unsigned int ext : lt.extruders) {
m_last_layer_per_extruder[ext] = lt.print_z;
}
}
if (prime_multi_material && ! m_all_printing_extruders.empty()) {
// Reorder m_all_printing_extruders in the sequence they will be primed, the last one will be m_first_printing_extruder.
// Then set m_first_printing_extruder to the 1st extruder primed.
@@ -3664,5 +3671,11 @@ int WipingExtrusions::get_support_interface_extruder_overrides(const PrintObject
return -1;
}
bool ToolOrdering::is_last_extrusion_layer(coordf_t print_z, unsigned int extruder_id) const
{
auto it = m_last_layer_per_extruder.find(extruder_id);
if (it == m_last_layer_per_extruder.end())
return true;
return print_z >= it->second - EPSILON;
}
} // namespace Slic3r
+3
View File
@@ -370,6 +370,7 @@ public:
std::vector<int> & initial_filaments);
bool has_non_support_filament(const PrintConfig &config);
bool is_last_extrusion_layer(coordf_t print_z, unsigned int extruder_id) const;
private:
void initialize_layers(std::vector<coordf_t> &zs);
@@ -413,6 +414,8 @@ private:
// which keeps every legacy per-object code path bit-identical (loops over an empty map are
// no-ops; downstream emission falls through to the per-object branch).
std::map<unsigned int, std::map<LayerTools::MixedSubLayerGroup::VolumeKey, std::vector<size_t>>> m_gradient_volume_layers;
// Map: extruder_id -> print_z (z coordinate) of the last layer it is used on
std::map<unsigned int, coordf_t> m_last_layer_per_extruder;
const PrintObject* m_print_object_ptr = nullptr;
Print* m_print;
bool m_sorted = false;
+29
View File
@@ -211,6 +211,35 @@ bool decode_colored_png(const ReadBuf &in_buf, ImageColorscale &out_img)
return decode_colored_png(stream, out_img);
}
bool is_decodable_8bit_png(const ReadBuf &pngbuf)
{
static const constexpr int PNG_SIG_BYTES = 8;
struct ReadBufStream stream{pngbuf};
std::vector<png_byte> sig(PNG_SIG_BYTES, 0);
stream.read(sig.data(), PNG_SIG_BYTES);
if (!png_check_sig(sig.data(), PNG_SIG_BYTES))
return false;
PNGDescr dsc;
dsc.png = png_create_read_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr);
if (!dsc.png)
return false;
dsc.info = png_create_info_struct(dsc.png);
if (!dsc.info)
return false;
if (!png_read_header_guarded(dsc.png, dsc.info, &stream, PNG_SIG_BYTES))
return false;
const size_t color_type = png_get_color_type(dsc.png, dsc.info);
const size_t bit_depth = png_get_bit_depth(dsc.png, dsc.info);
const bool grey = color_type == PNG_COLOR_TYPE_GRAY;
const bool colour = color_type == PNG_COLOR_TYPE_RGB || color_type == PNG_COLOR_TYPE_RGB_ALPHA;
return png_get_image_width(dsc.png, dsc.info) > 0 && png_get_image_height(dsc.png, dsc.info) > 0 &&
bit_depth == 8 && (grey || colour);
}
// Down to earth function to store a packed RGB image to file. Mostly useful for debugging purposes.
// Based on https://www.lemoda.net/c/write-png/
+3
View File
@@ -50,6 +50,9 @@ struct ReadBuf { const void *buf = nullptr; const size_t sz = 0; };
bool is_png(const ReadBuf &pngbuf);
// True if decode_png() or decode_colored_png() would accept these bytes, judged from the header alone.
bool is_decodable_8bit_png(const ReadBuf &pngbuf);
struct ReadBufStream: public IStream {
const ReadBuf &rbuf_ref;
size_t pos = 0;
+1 -1
View File
@@ -1489,7 +1489,7 @@ static std::vector<std::string> s_Preset_print_options{
static std::vector<std::string> s_Preset_filament_options {/*"filament_colour", */ "default_filament_colour", "required_nozzle_HRC", "filament_diameter", "pellet_flow_coefficient", "volumetric_speed_coefficients", "filament_type",
"filament_soluble", "filament_is_support", "filament_printable", "filament_extruder_compatibility",
"filament_max_volumetric_speed", "filament_adaptive_volumetric_speed",
"filament_flow_ratio", "filament_density", "filament_adhesiveness_category", "filament_cost", "filament_minimal_purge_on_wipe_tower",
"filament_flow_ratio", "filament_density", "filament_transmission_distance", "filament_adhesiveness_category", "filament_cost", "filament_minimal_purge_on_wipe_tower",
"filament_tower_interface_pre_extrusion_dist", "filament_tower_interface_pre_extrusion_length", "filament_tower_ironing_area", "filament_tower_interface_purge_volume",
"filament_tower_interface_print_temp",
"nozzle_temperature", "nozzle_temperature_initial_layer",
+1
View File
@@ -228,6 +228,7 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n
"filament_diameter",
"volumetric_speed_coefficients",
"filament_density",
"filament_transmission_distance",
"filament_cost",
"filament_notes",
"outer_wall_acceleration",
+14
View File
@@ -3440,6 +3440,20 @@ void PrintConfigDef::init_fff_params()
def->mode = comAdvanced;
def->set_default_value(new ConfigOptionFloats { 0. });
def = this->add("filament_transmission_distance", coFloats);
def->label = L("Transmission distance");
def->tooltip = L("How deep light gets into this filament, in millimetres, also called its "
"transmission distance or TD. About 1 for an opaque filament, 4 to 7 for a usefully "
"translucent one, 8 and above for a nearly clear one. Used only to predict the "
"colours of a ChromaStack in the texture displacement tool; it has no effect on "
"slicing. Measure it with a step test print, or take a published value for the "
"filament.");
def->sidetext = L("mm");
def->min = 0;
def->max = 100;
def->mode = comAdvanced;
def->set_default_value(new ConfigOptionFloats { 4. });
def = this->add("filament_type", coStrings);
def->label = L("Type");
def->tooltip = L("Filament material type");
+3
View File
@@ -1709,6 +1709,9 @@ PRINT_CONFIG_CLASS_DEFINE(
((ConfigOptionStrings, volumetric_speed_coefficients))
((ConfigOptionInts, filament_adhesiveness_category))
((ConfigOptionFloats, filament_density))
// Transmission distance: how deep light gets into this filament. Not used by
// slicing - it is the colour model of a ChromaStack stack (see ChromaStack.hpp).
((ConfigOptionFloats, filament_transmission_distance))
((ConfigOptionStrings, filament_type))
((ConfigOptionBools, filament_soluble))
((ConfigOptionStrings, filament_ids))
+36 -103
View File
@@ -1,7 +1,6 @@
#include "TextureDisplacement.hpp"
#include <Eigen/Core>
#include <Eigen/SVD>
#include <Eigen/Geometry>
#include <algorithm>
#include <array>
@@ -284,7 +283,7 @@ bool height_texture_has_color(const TextureDisplacementLayer &layer)
return decode_height_texture(layer).has_color();
}
DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer)
DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer, bool keep_decoded)
{
DecodedHeightTexture result;
if (layer.empty())
@@ -352,13 +351,15 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer
}
}
std::lock_guard<std::mutex> lock(g_decoded_texture_cache.mutex);
// Opportunistically drop entries for image_data that no longer exists anywhere, so the
// cache doesn't grow without bound across many add/remove-texture cycles in a long session.
auto &entries = g_decoded_texture_cache.entries;
for (auto it = entries.begin(); it != entries.end();)
it = it->second.first.expired() ? entries.erase(it) : std::next(it);
entries[key] = { std::weak_ptr<const std::vector<unsigned char>>(layer.image_data), result };
if (keep_decoded) {
std::lock_guard<std::mutex> lock(g_decoded_texture_cache.mutex);
// Opportunistically drop entries for image_data that no longer exists anywhere, so the
// cache doesn't grow without bound across many add/remove-texture cycles in a long session.
auto &entries = g_decoded_texture_cache.entries;
for (auto it = entries.begin(); it != entries.end();)
it = it->second.first.expired() ? entries.erase(it) : std::next(it);
entries[key] = { std::weak_ptr<const std::vector<unsigned char>>(layer.image_data), result };
}
}
// The image-adjustment panel's live edits, on top of the decode and before the smoothing. Before,
@@ -723,10 +724,7 @@ V2Resolution recommend_v2_resolution(const indexed_triangle_set
const DecodedHeightTexture &tex = decode_height_texture(layer);
if (tex.width <= 0)
continue;
// Texels are square in both axes (the aspect correction is what keeps them so), so the finer
// of the two tile extents sets the finest texel the refinement has to resolve.
const Vec2f tile = layer_tile_mm(layer);
const float texel = std::min(tile.x(), tile.y()) / float(tex.width);
const float texel = layer.tiling_scale / float(tex.width);
if (out.texel_mm <= 0.f || texel < out.texel_mm) {
out.texel_mm = texel;
out.pixels_per_edge = analyze_texture_detail(layer).pixels_per_edge;
@@ -2168,7 +2166,7 @@ namespace {
// of them was most of the per-sample arithmetic.
struct UVTransform
{
float inv_u, inv_v, cs, sn, aspect;
float scale, cs, sn, aspect;
Vec2f offset;
// `normalized_uv` drops the two steps that only mean something for a coordinate measured on the
@@ -2176,18 +2174,7 @@ struct UVTransform
// rotation and offset. See apply_uv_transform(); TextureProjectionMethod::ModelUV is what needs it.
UVTransform(const TextureDisplacementLayer &layer, float aspect_, bool normalized_uv = false)
{
// The mirror folds into the reciprocal: it is a negation of the same coordinate, so there is
// nothing to do per sample beyond carrying the sign here. A normalized uv has no millimetre
// tiling to divide by, but a mirror is still a mirror, so the sign applies either way.
const Vec2f sign = layer_mirror_sign(layer);
if (normalized_uv) {
inv_u = sign.x();
inv_v = sign.y();
} else {
const Vec2f tile = layer_tile_mm(layer);
inv_u = sign.x() / tile.x();
inv_v = sign.y() / tile.y();
}
scale = (normalized_uv || layer.tiling_scale <= 1e-6f) ? 1.f : (1.f / layer.tiling_scale);
const float rad = layer.rotation_deg * float(M_PI) / 180.f;
cs = std::cos(rad);
sn = std::sin(rad);
@@ -2196,11 +2183,9 @@ struct UVTransform
}
Vec2f operator()(const Vec2f &planar) const
{
// See apply_uv_transform() for why both the tile extents and the aspect correction follow
// the rotation rather than preceding it.
Vec2f rotated(planar.x() * cs - planar.y() * sn, planar.x() * sn + planar.y() * cs);
rotated.x() *= inv_u;
rotated.y() *= inv_v;
const Vec2f scaled = planar * scale;
Vec2f rotated(scaled.x() * cs - scaled.y() * sn, scaled.x() * sn + scaled.y() * cs);
// See apply_uv_transform() for why the aspect correction follows the rotation.
if (aspect > 0.f && aspect != 1.f)
rotated.y() *= aspect;
return rotated + offset;
@@ -2220,43 +2205,26 @@ inline Vec3f triplanar_weights(const Vec3f &normal)
Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect, bool normalized_uv)
{
// A uv that is already normalized has no millimetre scale to divide by and no aspect to correct -
// see the header. UVTransform does the same, and the two must stay in step.
const float scale = (normalized_uv || layer.tiling_scale <= 1e-6f) ? 1.f : (1.f / layer.tiling_scale);
const Vec2f scaled = planar * scale;
const float rad = layer.rotation_deg * float(M_PI) / 180.f;
const float cs = std::cos(rad);
const float sn = std::sin(rad);
Vec2f rotated(planar.x() * cs - planar.y() * sn, planar.x() * sn + planar.y() * cs);
// The tile extents. Dividing here rather than before the rotation is what makes a non-square
// tile a rotated *rectangle* on the surface: scaling the two axes of an already-rotated
// coordinate by different amounts would be a shear, so the rotation has to come first and the
// extents divide in the tile's own frame. With a square tile the two orders agree exactly,
// R being linear - which is why this is not a change in behaviour for any existing layer.
//
// The mirror rides along as a sign on the same division. It is in the tile's own frame too, so
// it mirrors along the tile's axes rather than the world's, and because the offset is what
// positions the tile, mirroring about the tile's middle is a matter of the offset the gizmo
// writes alongside it, not of anything here.
// A uv that is already normalized has no millimetre scale to divide by - see the header - but it
// can still be mirrored, so the sign is applied in both cases and only the extents are skipped.
// UVTransform does the same, and the two must stay in step.
const Vec2f sign = layer_mirror_sign(layer);
if (normalized_uv) {
rotated.x() *= sign.x();
rotated.y() *= sign.y();
} else {
const Vec2f tile = layer_tile_mm(layer);
rotated.x() *= sign.x() / tile.x();
rotated.y() *= sign.y() / tile.y();
}
Vec2f rotated(scaled.x() * cs - scaled.y() * sn, scaled.x() * sn + scaled.y() * cs);
// Non-square textures. Without this the [0,1] square of uv covers the whole image whatever its
// proportions, so a 2:1 image is squeezed into one tile and every feature in it comes out half
// as wide as it should be. The tile is `layer_tile_mm().y() * height / width` mm along v, which
// is exactly what keeps texels square - so dividing v by that extent is the same as multiplying
// it by width / height. A square texture has aspect 1 and is untouched, which is why this
// changes nothing for the shipped library.
// proportions, so a 2:1 image is squeezed into a square tile and every feature in it comes out
// half as wide as it should be. `tiling_scale` is the tile's size along u; the tile is
// `tiling_scale * height / width` mm along v, which is exactly what keeps texels square - so
// dividing v by that extent is the same as multiplying it by width / height. A square texture has
// aspect 1 and is untouched, which is why this changes nothing for the shipped library.
//
// Applied after the rotation, like the extents, and for the same reason: scaling one axis of an
// already-rotated coordinate before turning it would be a shear.
// Applied after the rotation, not before: scaling one axis of an already-rotated coordinate is a
// shear, and doing it the other way round would make "Rotation" skew the pattern instead of
// turning it.
if (!normalized_uv && aspect > 0.f && aspect != 1.f)
rotated.y() *= aspect;
@@ -3600,36 +3568,13 @@ void texture_displacement_patch_frame(const indexed_triangle_set &patch, const s
(an.y() <= an.x() && an.y() <= an.z()) ? Vec3f::UnitY() : Vec3f::UnitZ();
}
Matrix3d texture_displacement_frame_rotation(const Transform3d &volume_to_world)
{
// Separated out by polar decomposition rather than by assuming the linear part is a clean
// rotation times a scale: a sheared or mirrored placement is legal here. A reflection is left
// behind with the scale on purpose, so a mirrored instance still makes the bake frame's
// determinant negative and the bake still knows to flip the normals it derives.
const Matrix3d linear = volume_to_world.linear();
Eigen::JacobiSVD<Matrix3d> svd(linear, Eigen::ComputeFullU | Eigen::ComputeFullV);
Matrix3d rotation = svd.matrixU() * svd.matrixV().transpose();
if (rotation.determinant() < 0.0) {
Matrix3d u = svd.matrixU();
u.col(2) *= -1.0;
rotation = u * svd.matrixV().transpose();
}
return rotation.transpose(); // world -> texture frame
}
Transform3d texture_displacement_bake_frame(const Transform3d &volume_to_world)
{
// The model's own orientation, world scale, and the origin moved to where the volume's origin
// sits. So the texture rides with the model however it is moved *and* however it is turned,
// while a tile is still `tiling_scale` printed millimetres whatever the instance's scale, and a
// single untiled stamp starts on the model rather than at the plate's corner.
//
// Orientation used to come from the world too, which meant turning a part on the plate slid the
// decoration across its surface. Turning a part is a print-time decision - clearance, supports,
// warping - and it should not be a design one.
Transform3d unrotate = Transform3d::Identity();
unrotate.linear() = texture_displacement_frame_rotation(volume_to_world);
return unrotate * Eigen::Translation3d(-volume_to_world.translation()) * volume_to_world;
// World orientation and scale, but the origin moved to where the volume's own origin sits: the
// texture then rides with the model when it is moved about the plate (and a single, untiled stamp
// starts on the model rather than at the plate's corner), while a tile is still `tiling_scale`
// printed millimetres whatever the instance's scale.
return Eigen::Translation3d(-volume_to_world.translation()) * volume_to_world;
}
Transform3d texture_displacement_volume_to_world(const ModelVolume &volume)
@@ -5840,13 +5785,7 @@ std::string texture_displacement_layers_to_json(const std::vector<TextureDisplac
j["depth_mm"] = l.depth_mm;
j["tiling_scale"] = l.tiling_scale;
// The v extent and the mirroring. Written unconditionally, including the <= 0 "same as u"
// sentinel, so a project says plainly that the tile was square rather than leaving a reader
// to infer it from a missing key.
j["tiling_scale_v"] = l.tiling_scale_v;
j["mirror_u"] = l.mirror_u;
j["mirror_v"] = l.mirror_v;
j["rotation_deg"] = l.rotation_deg;
j["rotation_deg"] = l.rotation_deg;
j["offset"] = vec2_to_json(l.offset);
j["invert"] = l.invert;
j["midlevel"] = l.midlevel;
@@ -5954,12 +5893,6 @@ bool texture_displacement_layers_from_json(const std::string
read(j, "depth_mm", l.depth_mm);
read(j, "tiling_scale", l.tiling_scale);
// Absent in a project written before the tile could be rectangular or mirrored, and
// then each keeps its default: -1 for the v extent, which layer_tile_mm() reads as
// "square", and false for the mirrors. So an older project loads as what it was.
read(j, "tiling_scale_v", l.tiling_scale_v);
read(j, "mirror_u", l.mirror_u);
read(j, "mirror_v", l.mirror_v);
read(j, "rotation_deg", l.rotation_deg);
if (const auto o = j.find("offset"); o != j.end())
l.offset = vec2_from_json(*o, l.offset);
+31 -49
View File
@@ -18,6 +18,7 @@
#include <array>
#include "ChromaStack.hpp"
#include "Point.hpp"
#include "TriangleSelector.hpp"
@@ -225,17 +226,7 @@ struct TextureDisplacementLayer
std::shared_ptr<std::vector<unsigned char>> image_data;
float depth_mm = 0.4f; // maximum displacement along the surface normal, in mm
float tiling_scale = 12.5f; // size of one texture tile along u, in mm
// Size of one tile along v, in mm. Anything <= 0 means "whatever tiling_scale is", which is both
// the default and what a layer created before the tile could be non-square meant - so an older
// undo snapshot still comes back as the square tile it was.
float tiling_scale_v = -1.f;
// Mirror the texture along one axis of the tile. Set by dragging a width or height handle back
// through the tile's middle, which is how a side handle behaves in an image editor: the extent
// cannot go negative, so the sign lives here instead. Both default to off, which is what every
// layer written before this meant.
bool mirror_u = false;
bool mirror_v = false;
float tiling_scale = 12.5f; // size of one texture tile, in mm
float rotation_deg = 0.f;
Vec2f offset = Vec2f::Zero();
bool invert = false;
@@ -363,17 +354,33 @@ struct TextureDisplacementLayer
// for the boundary to be drawn on.
bool color_enabled = false;
// ChromaStack (see ChromaStack.hpp): when set, `image_data` is not a texture the user picked but the
// height map chroma_solve() computed, so that the whole displacement pipeline - preview, UV
// editor, bake - goes on reading one grayscale height map and needs to know nothing about colour
// stacks. The picture it was solved from is kept here instead, because the solve has to be redone
// whenever the stack, the print's layer height or the picture itself changes.
//
// A ChromaStack layer gets its colour from filament swaps at fixed Z, which is what `chroma`
// records and what the object's height ranges carry out. That is a different mechanism from
// `color_enabled` (per-triangle filament assignment), and the two are mutually exclusive: the
// panel turns colour off on a ChromaStack layer, since painting the relief in one filament per
// triangle would hide the very stack the heights were solved against.
bool chroma_enabled = false;
ChromaStack chroma;
std::shared_ptr<std::vector<unsigned char>> chroma_source;
bool empty() const { return !image_data || image_data->empty(); }
template<class Archive> void save(Archive &ar) const
{
std::string blob = image_data ? std::string(image_data->begin(), image_data->end()) : std::string();
std::string source = chroma_source ? std::string(chroma_source->begin(), chroma_source->end()) : std::string();
ar(slot, name, path, path_in_3mf, blob, depth_mm, tiling_scale, rotation_deg, offset, invert, tile_enabled,
static_cast<int>(tile_method), static_cast<int>(projection_method), lscm_seam_angle_deg, islands,
static_cast<int>(blend_mode), midlevel, island_padding_mm, lscm_seam_edges, view_project_right,
view_project_up, smoothing, edge_smoothing, edge_smoothing_amount, auto_connect_islands, island_groups,
lscm_uv_overrides, view_project_projective, view_project_matrix, color_enabled, tiling_scale_v,
mirror_u, mirror_v);
lscm_uv_overrides, view_project_projective, view_project_matrix, color_enabled, chroma_enabled, chroma,
source);
}
template<class Archive> void load(Archive &ar)
{
@@ -381,12 +388,14 @@ struct TextureDisplacementLayer
int tile_method_int = 0;
int projection_method_int = 0;
int blend_mode_int = 0;
std::string source;
ar(slot, name, path, path_in_3mf, blob, depth_mm, tiling_scale, rotation_deg, offset, invert, tile_enabled,
tile_method_int, projection_method_int, lscm_seam_angle_deg, islands, blend_mode_int, midlevel,
island_padding_mm, lscm_seam_edges, view_project_right, view_project_up, smoothing, edge_smoothing,
edge_smoothing_amount, auto_connect_islands, island_groups, lscm_uv_overrides, view_project_projective,
view_project_matrix, color_enabled, tiling_scale_v, mirror_u, mirror_v);
view_project_matrix, color_enabled, chroma_enabled, chroma, source);
image_data = blob.empty() ? nullptr : std::make_shared<std::vector<unsigned char>>(blob.begin(), blob.end());
chroma_source = source.empty() ? nullptr : std::make_shared<std::vector<unsigned char>>(source.begin(), source.end());
tile_method = static_cast<TextureTileMethod>(tile_method_int);
projection_method = static_cast<TextureProjectionMethod>(projection_method_int);
blend_mode = static_cast<TextureBlendMode>(blend_mode_int);
@@ -394,25 +403,6 @@ struct TextureDisplacementLayer
};
// The layer's tile extents in mm, u first. The v extent falls back to the u one whenever
// tiling_scale_v is unset, so "unset means square" is written down once and every consumer - the
// bake, the preview shaders, the UV pane and the placement gizmo - agrees on what a layer with no
// v extent means.
inline Vec2f layer_tile_mm(const TextureDisplacementLayer &layer)
{
const float su = (layer.tiling_scale > 1e-6f) ? layer.tiling_scale : 1.f;
return Vec2f(su, (layer.tiling_scale_v > 1e-6f) ? layer.tiling_scale_v : su);
}
// +1 or -1 per axis, for the layer's mirroring. A separate factor from the extents rather than a
// signed extent: the extents are a size the panel shows and the refinement measures texels against,
// and letting one of them go negative would mean every one of those places had to remember to take
// the magnitude.
inline Vec2f layer_mirror_sign(const TextureDisplacementLayer &layer)
{
return Vec2f(layer.mirror_u ? -1.f : 1.f, layer.mirror_v ? -1.f : 1.f);
}
// Settings that apply to the whole layer stack rather than to one layer, held per ModelVolume next
// to texture_displacement_layers and consumed by build_texture_displacement().
struct TextureDisplacementOptions
@@ -629,8 +619,9 @@ DecodedHeightTexture downscale_height_texture(const DecodedHeightTexture &src, i
// colour PNGs are accepted; for a colour source the height is its luminance, using the same
// coefficients wxImage::ConvertToGreyscale() uses, so a texture imported as colour displaces exactly
// as it did when the importer flattened it to grey on the way in. Returns an empty
// DecodedHeightTexture if image_data is empty or is not a PNG at all.
DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer);
// DecodedHeightTexture if image_data is empty or is not a PNG at all. keep_decoded = false leaves the
// result out of the decode cache, for one-off reads such as a picker thumbnail that must not hold full-size pixels.
DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer, bool keep_decoded = true);
// decode_height_texture(layer).has_color(), answered from the decode cache rather than from a copy of the
// texture - cheap enough to ask every frame. Smoothing does not change it, so the raw decode is what is read.
@@ -698,19 +689,14 @@ Vec2f project_planar(const Vec3f &position, const Vec3f &normal);
// projection method, without going through project_texture_displacement_uv()'s own dispatch
// (which only knows how to compute the *analytic* methods from a single vertex + normal).
// `aspect` is the height map's width / height. It scales the v axis so a non-square image is not
// squeezed into a square tile: the tile is `layer_tile_mm().y() * height / width` mm along v, which
// keeps texels square. 1 (the default) is the square case and leaves the coordinate exactly as it
// always was.
//
// The tile extents divide *after* the rotation, not before. With one extent that made no difference
// (R is linear, so R(p / s) == R(p) / s), but with two it is what keeps a non-square tile a rotated
// rectangle on the surface instead of a sheared parallelogram. The layer's mirroring is a sign on
// that same division.
// squeezed into a square tile: `tiling_scale` is the tile's size along u, and the tile is
// `tiling_scale * height / width` mm along v, which keeps texels square. 1 (the default) is the
// square case and leaves the coordinate exactly as it always was.
//
// `normalized_uv` says the coordinate is already a uv rather than a millimetre measurement on the
// model - which is what TextureProjectionMethod::ModelUV hands in. Then neither the tile extents (mm
// model - which is what TextureProjectionMethod::ModelUV hands in. Then neither the tiling scale (mm
// per tile is meaningless for it) nor the aspect correction (the artist's layout already accounts for
// the image's proportions) applies, and only the rotation, the mirroring and the offset are left.
// the image's proportions) applies, and only the rotation and offset are left.
Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect = 1.f,
bool normalized_uv = false);
@@ -1082,10 +1068,6 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
Transform3d texture_displacement_volume_to_world(const ModelVolume &volume);
// The frame the bake and the previews project the texture in: `volume_to_world` with its translation
// removed, i.e. world orientation and scale about the volume's own origin. See build_texture_displacement().
// The rotation that carries a world direction into the texture's frame, i.e. the inverse of the
// placement's own rotation. Exposed because the preview shaders rebuild the frame themselves from a
// world position and have to turn it the same way the bake does, or the two disagree.
Matrix3d texture_displacement_frame_rotation(const Transform3d &volume_to_world);
Transform3d texture_displacement_bake_frame(const Transform3d &volume_to_world);
// Area-weighted vertex normals of `its` - the directions the bake both projects and displaces along.
+7
View File
@@ -842,6 +842,13 @@ void load_string_file(const boost::filesystem::path& p, std::string& str);
bool check_layer_id_pattern(const std::string& pattern, int layer_id);
// On Windows, return an equivalent path carrying the extended-length ("\\?\" / "\\?\UNC\")
// prefix so the Win32 APIs accept it past MAX_PATH even when LongPathsEnabled is off. The path
// is made absolute and lexically normalized first, because the prefix disables normalisation.
// A path that is already extended (or a device path) is returned unchanged; on other platforms,
// or when an absolute path cannot be formed, the input is returned unchanged.
boost::filesystem::path extended_path(const boost::filesystem::path& path);
} // namespace Slic3r
#if WIN32
+53 -8
View File
@@ -17,6 +17,7 @@
#if defined(_MSC_VER) || defined(__MINGW64__)
#include "boost/nowide/cstdio.hpp"
#include "boost/nowide/convert.hpp"
#endif
#include "I18N.hpp"
@@ -62,7 +63,11 @@ bool open_zip(mz_zip_archive *zip, const char *fname, bool isread)
FILE *f = nullptr;
#if defined(_MSC_VER) || defined(__MINGW64__)
f = boost::nowide::fopen(fname, mode);
// fname is UTF-8 (the nowide contract). Route it through the shared extended-path helper so an
// archive stored past MAX_PATH opens even with LongPathsEnabled off.
const std::wstring wide_path = boost::nowide::widen(fname);
const std::string extended = boost::nowide::narrow(Slic3r::extended_path(boost::filesystem::path(wide_path)).wstring());
f = boost::nowide::fopen(extended.c_str(), mode);
#elif defined(__GNUC__) && defined(_LARGEFILE64_SOURCE)
f = fopen64(fname, mode);
#else
@@ -101,6 +106,14 @@ bool close_zip(mz_zip_archive *zip, bool isread)
}
return ret;
}
#ifdef WIN32
// mz_file_write_func writing an extracted entry into an already-open FILE*.
size_t mz_write_to_cfile(void *opaque, mz_uint64 /*file_ofs*/, const void *buffer, size_t size)
{
return std::fwrite(buffer, 1, size, static_cast<FILE *>(opaque));
}
#endif
}
bool open_zip_reader(mz_zip_archive *zip, const std::string &fname)
@@ -116,6 +129,36 @@ bool open_zip_writer(mz_zip_archive *zip, const std::string &fname)
bool close_zip_reader(mz_zip_archive *zip) { return close_zip(zip, true); }
bool close_zip_writer(mz_zip_archive *zip) { return close_zip(zip, false); }
bool extract_zip_entry_to_file(mz_zip_archive *zip, mz_uint file_index, const boost::filesystem::path &dest_path)
{
if (!zip)
return false;
#ifdef WIN32
// Open the destination ourselves with a Windows extended-length path: miniz's narrow
// extract_to_file cannot be relied on past MAX_PATH with LongPathsEnabled off, and its wide
// variant takes a plain (unprefixed) path and a text-mode stream. This keeps the write binary.
FILE *file = ::_wfopen(Slic3r::extended_path(dest_path).wstring().c_str(), L"wb");
if (!file) {
zip->m_last_error = MZ_ZIP_FILE_OPEN_FAILED;
return false;
}
const bool written = mz_zip_reader_extract_to_callback(zip, file_index, mz_write_to_cfile, file, 0) != 0;
const bool closed = std::fclose(file) == 0;
if (!written || !closed) {
if (written && !closed)
zip->m_last_error = MZ_ZIP_FILE_CLOSE_FAILED;
// Drop the partial entry rather than leave a truncated file behind.
boost::system::error_code ec;
boost::filesystem::remove(Slic3r::extended_path(dest_path), ec);
return false;
}
return true;
#else
return mz_zip_reader_extract_to_file(zip, file_index, dest_path.string().c_str(), 0) != 0;
#endif
}
std::string decode_archive_entry_path(mz_zip_archive *zip, const mz_zip_archive_file_stat &stat)
{
if (stat.m_is_utf8)
@@ -156,10 +199,13 @@ bool extract_archive_confined(const std::string &zip_path_utf8, const std::strin
}
const std::string dest_file = dest_dir + "/" + stat.m_filename;
try {
// Use the extended-length form for every filesystem call so entries under a destination
// past MAX_PATH are created even with LongPathsEnabled off.
const boost::filesystem::path dest_path(dest_file);
const boost::filesystem::path io_dest = extended_path(dest_path);
if (stat.m_is_directory) {
const boost::filesystem::path dest_path(dest_file);
if (!boost::filesystem::exists(dest_path))
boost::filesystem::create_directories(dest_path);
if (!boost::filesystem::exists(io_dest))
boost::filesystem::create_directories(io_dest);
continue;
}
if (stat.m_uncomp_size == 0) {
@@ -167,10 +213,9 @@ bool extract_archive_confined(const std::string &zip_path_utf8, const std::strin
continue;
}
// Replace a symlink at the destination rather than writing through it.
const boost::filesystem::path dest_path(dest_file);
if (boost::filesystem::is_symlink(boost::filesystem::symlink_status(dest_path)))
boost::filesystem::remove(dest_path);
if (!mz_zip_reader_extract_to_file(&archive, stat.m_file_index, dest_file.c_str(), 0)) {
if (boost::filesystem::is_symlink(boost::filesystem::symlink_status(io_dest)))
boost::filesystem::remove(io_dest);
if (!extract_zip_entry_to_file(&archive, stat.m_file_index, dest_path)) {
BOOST_LOG_TRIVIAL(error) << "Unzip: extract file " << stat.m_filename << " to dest " << dest_file << " failed";
close_zip_reader(&archive);
return false;
+5
View File
@@ -1,6 +1,7 @@
#ifndef MINIZ_EXTENSION_HPP
#define MINIZ_EXTENSION_HPP
#include <boost/filesystem/path.hpp>
#include <string>
#include <miniz.h>
@@ -13,6 +14,10 @@ bool close_zip_writer(mz_zip_archive *zip);
std::string decode_archive_entry_path(mz_zip_archive *zip, const mz_zip_archive_file_stat &stat);
// Extracts every entry of the archive under dest_dir. Nothing is written if any entry would resolve outside dest_dir.
bool extract_archive_confined(const std::string &zip_path_utf8, const std::string &dest_dir);
// Extracts one entry into dest_path, binary-safe and overwriting. On Windows the destination is
// opened through an extended-length path so it works past MAX_PATH even with LongPathsEnabled off;
// a partial file is removed if the write fails. False for a directory/unsupported entry or on error.
bool extract_zip_entry_to_file(mz_zip_archive *zip, mz_uint file_index, const boost::filesystem::path &dest_path);
class MZ_Archive {
public:
+40
View File
@@ -2254,5 +2254,45 @@ bool check_layer_id_pattern(const std::string& pattern, int layer_id){
return mod >= 0 && mod < count;
}
boost::filesystem::path extended_path(const boost::filesystem::path& path) {
#ifdef WIN32
namespace fs = boost::filesystem;
// Already extended, or an explicit device path (which is not subject to MAX_PATH and must not
// be absolutized or normalized): leave it alone.
{
fs::path prefix_candidate = path;
const std::wstring native = prefix_candidate.make_preferred().wstring();
if (native.rfind(L"\\\\?\\", 0) == 0 || native.rfind(L"\\\\.\\", 0) == 0)
return fs::path(native);
}
fs::path absolute = path;
if (!absolute.is_absolute()) {
boost::system::error_code ec;
absolute = fs::absolute(absolute, ec);
if (ec)
return path; // can't form a valid prefixed path from a relative one
}
// A trailing separator is redundant to the Win32 APIs, but lexically_normal() would rewrite it
// as a "." element, which the "\\?\" prefix (normalisation off) takes literally. Drop it first.
while (absolute.has_relative_path() && absolute.filename() == ".")
absolute = absolute.parent_path();
// The "\\?\" prefix turns off Win32 normalisation, so do it here first.
absolute = absolute.lexically_normal();
// Extended paths only recognize backslash separators.
const std::wstring native = absolute.make_preferred().wstring();
if (native.empty())
return path;
if (native.rfind(L"\\\\", 0) == 0)
return fs::path(L"\\\\?\\UNC\\" + native.substr(2)); // \\server\share\... -> \\?\UNC\server\share\...
return fs::path(L"\\\\?\\" + native); // C:\dir\... -> \\?\C:\dir\...
#else
return path;
#endif
}
}; // namespace Slic3r
+6
View File
@@ -222,6 +222,7 @@ set(SLIC3R_GUI_SOURCES
GUI/Gizmos/GLGizmoUtils.hpp
GUI/GLCanvas3D.cpp
GUI/GLCanvas3D.hpp
GUI/SequentialPrintClearance.hpp
GUI/GLModel.cpp
GUI/GLModel.hpp
GUI/GLSelectionRectangle.cpp
@@ -689,6 +690,8 @@ set(SLIC3R_GUI_SOURCES
plugin/host/PluginHostUi.hpp
plugin/host/PluginPages.cpp
plugin/host/PluginPages.hpp
plugin/host/PluginResourceTextures.cpp
plugin/host/PluginResourceTextures.hpp
plugin/CloudPluginService.cpp
plugin/CloudPluginService.hpp
plugin/PluginFsUtils.cpp
@@ -712,6 +715,9 @@ set(SLIC3R_GUI_SOURCES
plugin/pluginTypes/pages/PagesPluginCapability.hpp
plugin/pluginTypes/pages/PagesPluginCapability.cpp
plugin/pluginTypes/pages/PagesPluginCapabilityTrampoline.hpp
plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp
plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.cpp
plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapabilityTrampoline.hpp
plugin/pluginTypes/script/ScriptPluginCapability.hpp
plugin/pluginTypes/script/ScriptPluginCapability.cpp
plugin/pluginTypes/script/ScriptPluginCapabilityTrampoline.hpp
+260 -72
View File
@@ -85,6 +85,96 @@ static std::string float_to_string_with_precision(float value, int precision = 3
return stream.str();
}
static std::string colour_to_ams_string(const wxColour& color)
{
char col_buf[10];
sprintf(col_buf, "%02X%02X%02X%02X", (int)color.Red(), (int)color.Green(), (int)color.Blue(), (int)color.Alpha());
return col_buf;
}
static wxColour mix_colour(const wxColour& left, const wxColour& right, double ratio)
{
ratio = std::max(0.0, std::min(1.0, ratio));
auto mix_channel = [ratio](unsigned char l, unsigned char r) {
return static_cast<unsigned char>(std::round(l + (r - l) * ratio));
};
return wxColour(mix_channel(left.Red(), right.Red()),
mix_channel(left.Green(), right.Green()),
mix_channel(left.Blue(), right.Blue()),
mix_channel(left.Alpha(), right.Alpha()));
}
static std::vector<ColorPickerPopup::ColorItem> collect_ams_color_items(DevFilaSystem* fila_system)
{
std::vector<ColorPickerPopup::ColorItem> items;
if (!fila_system) return items;
for (const auto& ams_pair : fila_system->GetAmsList()) {
DevAms* ams = ams_pair.second;
if (!ams) continue;
for (const auto& tray_pair : ams->GetTrays()) {
DevAmsTray* tray = tray_pair.second;
if (!tray || !tray->is_tray_info_ready()) continue;
ColorPickerPopup::ColorItem item;
for (const std::string& col : tray->cols) {
item.colors.emplace_back(DevAmsTray::decode_color(col));
}
if (item.colors.empty() && !tray->color.empty()) {
item.colors.emplace_back(DevAmsTray::decode_color(tray->color));
}
if (item.colors.empty()) continue;
item.ctype = item.colors.size() > 1 ? tray->ctype : 2;
items.emplace_back(std::move(item));
}
}
return items;
}
static std::vector<ColorPickerPopup::ColorItem> collect_preset_color_items(const std::string& filament_id)
{
std::vector<ColorPickerPopup::ColorItem> items;
if (filament_id.empty()) return items;
auto* clr_query = GUI::wxGetApp().get_filament_color_code_query();
if (!clr_query) return items;
auto* agent = wxGetApp().getAgent();
const std::string catalog_id = agent ? agent->from_orca_filament_id(filament_id) : filament_id;
FilamentColorCodes* color_codes = clr_query->GetFilaInfoMap(wxString::FromUTF8(catalog_id));
if (!color_codes || !color_codes->GetFilamentColor2CodeMap()) return items;
std::set<std::string> seen;
for (const auto& color_pair : *color_codes->GetFilamentColor2CodeMap()) {
const FilamentColor& fila_color = color_pair.first;
FilamentColorCode* color_code = color_pair.second;
if (!color_code || fila_color.GetColors().empty()) continue;
ColorPickerPopup::ColorItem item;
item.ctype = fila_color.ColorCount() < 2 ? 2 :
(fila_color.m_color_type == FilamentColor::ColorType::GRADIENT_CLR ? 0 : 1);
item.name = color_code->GetFilaColorName();
std::string key = std::to_string(item.ctype);
for (const wxColour& color : fila_color.GetColors()) {
item.colors.emplace_back(color);
char rgba[9];
std::snprintf(rgba, sizeof(rgba), "%02X%02X%02X%02X",
static_cast<unsigned>(color.Red()), static_cast<unsigned>(color.Green()),
static_cast<unsigned>(color.Blue()), static_cast<unsigned>(color.Alpha()));
key += "|" + std::string(rgba);
}
if (item.colors.empty() || !seen.insert(key).second) continue;
items.emplace_back(std::move(item));
}
return items;
}
AMSMaterialsSetting::AMSMaterialsSetting(wxWindow *parent, wxWindowID id)
: DPIDialog(parent, id, _L("AMS Materials Setting"), wxDefaultPosition, wxDefaultSize, wxCAPTION | wxCLOSE_BOX)
, m_color_picker_popup(ColorPickerPopup(this))
@@ -740,8 +830,19 @@ void AMSMaterialsSetting::on_select_ok(wxCommandEvent &event)
nozzle_temp_min.ToLong(&nozzle_temp_min_int);
nozzle_temp_max.ToLong(&nozzle_temp_max_int);
wxColour color = m_clr_picker->m_colour;
char col_buf[10];
sprintf(col_buf, "%02X%02X%02X%02X", (int)color.Red(), (int)color.Green(), (int)color.Blue(), (int)color.Alpha());
std::string tray_color = colour_to_ams_string(color);
std::vector<std::string> tray_colors;
int tray_ctype = 2;
// Only firmware advertising fun2[23] receives the manual multi-color fields.
if (obj->is_support_filament_manual_multi_color) {
for (const wxColour& selected_color : m_clr_picker->m_cols) {
tray_colors.emplace_back(colour_to_ams_string(selected_color));
}
if (tray_colors.empty()) {
tray_colors.emplace_back(tray_color);
}
tray_ctype = tray_colors.size() > 1 ? m_clr_picker->ctype : 2;
}
if (ams_filament_id.empty() || nozzle_temp_min.empty() || nozzle_temp_max.empty() || m_filament_type.empty()) {
BOOST_LOG_TRIVIAL(trace) << "Invalid Setting id";
@@ -760,13 +861,33 @@ void AMSMaterialsSetting::on_select_ok(wxCommandEvent &event)
<< ", tray_info_idx (filament_id) = " << ams_filament_id
<< ", setting_id = " << ams_setting_id
<< ", tray_type = " << m_filament_type
<< ", tray_color = " << col_buf
<< ", tray_color = " << tray_color
<< ", nozzle_temp_min = " << nozzle_temp_min_int
<< ", nozzle_temp_max = " << nozzle_temp_max_int;
// set filament
if (m_is_third) {
obj->command_ams_filament_settings(ams_id, slot_id, ams_filament_id, ams_setting_id, std::string(col_buf), m_filament_type, nozzle_temp_min_int, nozzle_temp_max_int);
obj->command_ams_filament_settings(ams_id, slot_id, ams_filament_id, ams_setting_id, tray_color, m_filament_type, nozzle_temp_min_int, nozzle_temp_max_int,
tray_colors, tray_ctype);
// Refresh the edited tray immediately while its MQTT acknowledgement is pending.
DevAmsTray* tray = nullptr;
if (is_virtual_tray()) {
for (auto& virtual_tray : obj->vt_slot) {
if (virtual_tray.id == std::to_string(ams_id)) {
tray = &virtual_tray;
break;
}
}
} else {
tray = obj->GetFilaSystem()->GetAmsTray(std::to_string(ams_id), std::to_string(slot_id));
}
if (tray) {
tray->color = tray_color;
tray->cols = tray_colors;
tray->ctype = tray_ctype;
tray->set_hold_count();
}
}
//reset param
@@ -923,8 +1044,15 @@ void AMSMaterialsSetting::set_ctype(int ctype)
void AMSMaterialsSetting::on_picker_color(wxCommandEvent& event)
{
unsigned int color_num = event.GetInt();
set_color(wxColour(color_num>>24&0xFF, color_num>>16&0xFF, color_num>>8&0xFF, color_num&0xFF));
std::vector<wxColour> colors = m_color_picker_popup.get_selected_colours();
if (colors.empty()) {
unsigned int color_num = event.GetInt();
colors.emplace_back(color_num >> 24 & 0xFF, color_num >> 16 & 0xFF, color_num >> 8 & 0xFF, color_num & 0xFF);
}
set_ctype(m_color_picker_popup.get_selected_ctype());
set_color(colors.front());
set_colors(colors);
}
void AMSMaterialsSetting::on_clr_picker(wxMouseEvent &event)
@@ -939,14 +1067,12 @@ void AMSMaterialsSetting::on_clr_picker(wxMouseEvent &event)
}
}
std::vector<wxColour> ams_colors;
obj->GetFilaSystem()->CollectAmsColors(ams_colors);
m_color_picker_popup.set_ams_colours(collect_ams_color_items(obj->GetFilaSystem().get()));
m_color_picker_popup.set_preset_colours(collect_preset_color_items(ams_filament_id));
m_color_picker_popup.set_def_colour(m_clr_picker->m_colour, m_clr_picker->m_cols, m_clr_picker->ctype);
wxPoint img_pos = m_clr_picker->ClientToScreen(wxPoint(0, 0));
wxPoint popup_pos(img_pos.x - m_color_picker_popup.GetSize().x - FromDIP(95), img_pos.y - FromDIP(65));
m_color_picker_popup.Position(popup_pos, wxSize(0, 0));
m_color_picker_popup.set_ams_colours(ams_colors);
m_color_picker_popup.set_def_colour(m_clr_picker->m_colour);
m_color_picker_popup.Popup();
}
@@ -1735,7 +1861,46 @@ void ColorPicker::doRender(wxDC& dc)
auto radius = m_show_full ? size.x / 2 - FromDIP(1) : size.x / 2;
if (m_selected) radius -= FromDIP(1);
if (alpha == 0) {
if (m_show_full && m_cols.size() > 1) {
if (ctype == 0) {
const double center_x = size.x / 2.0;
const double center_y = size.y / 2.0;
const double draw_radius = radius;
dc.SetPen(*wxTRANSPARENT_PEN);
for (int x = 0; x < size.x; ++x) {
const double dx = x + 0.5 - center_x;
if (std::abs(dx) > draw_radius) continue;
const double half_height = std::sqrt(std::max(0.0, draw_radius * draw_radius - dx * dx));
const int top = std::max(0, static_cast<int>(std::ceil(center_y - half_height)));
const int bottom = std::min(size.y - 1, static_cast<int>(std::floor(center_y + half_height)));
const double pos = size.x > 1 ? static_cast<double>(x) / (size.x - 1) : 0.0;
const double scaled = pos * (m_cols.size() - 1);
const size_t idx = std::min(static_cast<size_t>(scaled), m_cols.size() - 2);
const double ratio = scaled - idx;
if (top <= bottom) {
dc.SetBrush(wxBrush(mix_colour(m_cols[idx], m_cols[idx + 1], ratio)));
dc.DrawRectangle(x, top, 1, bottom - top + 1);
}
}
}
else {
float ev_angle = 360.0 / m_cols.size();
const float overlap = 2.0f;
wxPoint center(size.x / 2, size.y / 2);
dc.SetPen(*wxTRANSPARENT_PEN);
dc.SetBrush(wxBrush(m_cols.front()));
dc.DrawCircle(center.x, center.y, radius);
for (int i = 0; i < m_cols.size(); i++) {
dc.SetBrush(m_cols[i]);
float startAngle = 270.0f + i * ev_angle;
float endAngle = startAngle + ev_angle;
dc.DrawEllipticArc(center.x - radius, center.y - radius, 2 * radius, 2 * radius, startAngle - overlap, endAngle + overlap);
}
}
}
else if (alpha == 0) {
wxSize bmp_size = m_bitmap_transparent_def.GetBmpSize();
int center_x = (size.x - bmp_size.x) / 2;
int center_y = (size.y - bmp_size.y) / 2;
@@ -1777,52 +1942,6 @@ void ColorPicker::doRender(wxDC& dc)
dc.SetPen(wxPen(wxColour("#6B6B6B")));
dc.SetBrush(*wxTRANSPARENT_BRUSH);
dc.DrawCircle(size.x / 2, size.y / 2, radius);
if (m_cols.size() > 1) {
if (ctype == 0) {
int left = FromDIP(0);
float total_width = size.x;
int gwidth = std::round(total_width / (m_cols.size() - 1));
for (int i = 0; i < m_cols.size() - 1; i++) {
if ((left + gwidth) > (size.x)) {
gwidth = size.x - left;
}
auto rect = wxRect(left, 0, gwidth, size.y);
dc.GradientFillLinear(rect, m_cols[i], m_cols[i + 1], wxEAST);
left += gwidth;
}
if (wxGetApp().dark_mode()) {
dc.DrawBitmap(m_bitmap_border_dark, wxPoint(0, 0));
}
else {
dc.DrawBitmap(m_bitmap_border, wxPoint(0, 0));
}
}
else {
float ev_angle = 360.0 / m_cols.size();
float startAngle = 270.0;
float endAngle = 270.0;
dc.SetPen(*wxTRANSPARENT_PEN);
for (int i = 0; i < m_cols.size(); i++) {
dc.SetBrush(m_cols[i]);
endAngle += ev_angle;
endAngle = endAngle > 360.0 ? endAngle - 360.0 : endAngle;
wxPoint center(size.x / 2, size.y / 2);
dc.DrawEllipticArc(center.x - radius, center.y - radius, 2 * radius, 2 * radius, startAngle, endAngle);
startAngle += ev_angle;
startAngle = startAngle > 360.0 ? startAngle - 360.0 : startAngle;
}
if (wxGetApp().dark_mode()) {
dc.DrawBitmap(m_bitmap_border_dark, wxPoint(0, 0));
}
else {
dc.DrawBitmap(m_bitmap_border, wxPoint(0, 0));
}
}
}
}
if (m_is_empty) {
@@ -1891,19 +2010,21 @@ ColorPickerPopup::ColorPickerPopup(wxWindow* parent)
m_ams_fg_sizer->SetNonFlexibleGrowMode(wxFLEX_GROWMODE_SPECIFIED);
//other
wxFlexGridSizer* fg_sizer;
fg_sizer = new wxFlexGridSizer(0, 8, 0, 0);
fg_sizer->SetFlexibleDirection(wxBOTH);
fg_sizer->SetNonFlexibleGrowMode(wxFLEX_GROWMODE_SPECIFIED);
m_other_fg_sizer = new wxFlexGridSizer(0, 8, 0, 0);
m_other_fg_sizer->SetFlexibleDirection(wxBOTH);
m_other_fg_sizer->SetNonFlexibleGrowMode(wxFLEX_GROWMODE_SPECIFIED);
for (wxColour col : m_def_colors) {
auto cp = new ColorPicker(m_def_color_box, wxID_ANY, wxDefaultPosition, wxDefaultSize);
cp->set_color(col);
cp->set_colors({col});
cp->ctype = 2;
cp->set_selected(false);
cp->SetBackgroundColour(StateColor::darkModeColorFor(wxColour(238,238,238)));
m_color_pickers.push_back(cp);
fg_sizer->Add(cp, 0, wxALL, FromDIP(3));
m_default_color_pickers.push_back(cp);
m_other_fg_sizer->Add(cp, 0, wxALL, FromDIP(3));
cp->Bind(wxEVT_LEFT_DOWN, [this, cp](auto& e) {
set_def_colour(cp->m_colour);
@@ -1970,7 +2091,7 @@ ColorPickerPopup::ColorPickerPopup(wxWindow* parent)
m_sizer_box->Add(m_sizer_ams, 1, wxEXPAND|wxLEFT|wxRIGHT, FromDIP(10));
m_sizer_box->Add(m_ams_fg_sizer, 0, wxEXPAND|wxLEFT|wxRIGHT, FromDIP(10));
m_sizer_box->Add(m_sizer_other, 1, wxEXPAND|wxLEFT|wxRIGHT, FromDIP(10));
m_sizer_box->Add(fg_sizer, 0, wxEXPAND|wxLEFT|wxRIGHT, FromDIP(10));
m_sizer_box->Add(m_other_fg_sizer, 0, wxEXPAND|wxLEFT|wxRIGHT, FromDIP(10));
m_sizer_box->Add(m_sizer_custom, 0, wxEXPAND|wxLEFT|wxRIGHT, FromDIP(10));
m_sizer_box->Add(m_custom_cp, 0, wxEXPAND|wxLEFT|wxRIGHT, FromDIP(16));
m_sizer_box->Add(0, 0, 0, wxTOP, FromDIP(10));
@@ -1981,7 +2102,7 @@ ColorPickerPopup::ColorPickerPopup(wxWindow* parent)
m_def_color_box->Fit();
m_sizer_main->Add(m_def_color_box, 0, wxALL | wxEXPAND, 10);
SetSizer(m_sizer_main);
SetSizerAndFit(m_sizer_main);
Layout();
Fit();
@@ -2019,13 +2140,15 @@ void ColorPickerPopup::on_custom_clr_picker(wxMouseEvent& /*event*/)
wxPostEvent(GetParent(), evt);
}
void ColorPickerPopup::set_ams_colours(std::vector<wxColour> ams)
void ColorPickerPopup::set_ams_colours(const std::vector<ColorItem>& ams)
{
m_ams_fg_sizer->Clear(false);
if (m_ams_color_pickers.size() > 0) {
for (ColorPicker* col_pick:m_ams_color_pickers) {
std::vector<ColorPicker*>::iterator iter = find(m_color_pickers.begin(), m_color_pickers.end(), col_pick);
if (iter != m_color_pickers.end()) {
col_pick->Hide();
col_pick->Destroy();
m_color_pickers.erase(iter);
}
@@ -2035,17 +2158,22 @@ void ColorPickerPopup::set_ams_colours(std::vector<wxColour> ams)
}
m_ams_colors = ams;
for (wxColour col : m_ams_colors) {
m_ams_color_items = ams;
for (const ColorItem& item : m_ams_color_items) {
if (item.colors.empty()) continue;
auto cp = new ColorPicker(m_def_color_box, wxID_ANY, wxDefaultPosition, wxDefaultSize);
cp->set_color(col);
cp->set_color(item.colors.front());
cp->set_colors(item.colors);
cp->ctype = item.colors.size() > 1 ? item.ctype : 2;
cp->set_show_full(true);
cp->set_selected(false);
cp->SetBackgroundColour(StateColor::darkModeColorFor(wxColour(238,238,238)));
m_color_pickers.push_back(cp);
m_ams_color_pickers.push_back(cp);
m_ams_fg_sizer->Add(cp, 0, wxALL, FromDIP(3));
cp->Bind(wxEVT_LEFT_DOWN, [this, cp](auto& e) {
set_def_colour(cp->m_colour);
set_def_colour(cp->m_colour, cp->m_cols, cp->ctype);
wxCommandEvent evt(EVT_SELECTED_COLOR);
unsigned long g_col = ((cp->m_colour.Red() & 0xff) << 24) + ((cp->m_colour.Green() & 0xff) << 16) + ((cp->m_colour.Blue() & 0xff) << 8) + (cp->m_colour.Alpha() & 0xff);
@@ -2058,9 +2186,68 @@ void ColorPickerPopup::set_ams_colours(std::vector<wxColour> ams)
Fit();
}
void ColorPickerPopup::set_def_colour(wxColour col)
void ColorPickerPopup::set_preset_colours(const std::vector<ColorItem>& preset_colors)
{
m_other_fg_sizer->Clear(false);
if (!m_preset_color_pickers.empty()) {
for (ColorPicker* col_pick : m_preset_color_pickers) {
auto iter = std::find(m_color_pickers.begin(), m_color_pickers.end(), col_pick);
if (iter != m_color_pickers.end()) {
col_pick->Hide();
col_pick->Destroy();
m_color_pickers.erase(iter);
}
}
m_preset_color_pickers.clear();
}
for (const ColorItem& item : preset_colors) {
if (item.colors.empty()) continue;
auto cp = new ColorPicker(m_def_color_box, wxID_ANY, wxDefaultPosition, wxDefaultSize);
cp->set_color(item.colors.front());
cp->set_colors(item.colors);
cp->ctype = item.colors.size() > 1 ? item.ctype : 2;
cp->set_show_full(true);
cp->set_selected(false);
cp->SetBackgroundColour(StateColor::darkModeColorFor(wxColour(238,238,238)));
if (!item.name.empty()) {
cp->SetToolTip(item.name);
}
m_color_pickers.push_back(cp);
m_preset_color_pickers.push_back(cp);
cp->Bind(wxEVT_LEFT_DOWN, [this, cp](auto& e) {
set_def_colour(cp->m_colour, cp->m_cols, cp->ctype);
wxCommandEvent evt(EVT_SELECTED_COLOR);
unsigned long g_col = ((cp->m_colour.Red() & 0xff) << 24) + ((cp->m_colour.Green() & 0xff) << 16) + ((cp->m_colour.Blue() & 0xff) << 8) + (cp->m_colour.Alpha() & 0xff);
evt.SetInt(g_col);
wxPostEvent(GetParent(), evt);
});
}
const bool show_default_colours = m_preset_color_pickers.empty();
for (ColorPicker* cp : m_default_color_pickers) {
cp->Show(show_default_colours);
}
for (ColorPicker* cp : (show_default_colours ? m_default_color_pickers : m_preset_color_pickers)) {
m_other_fg_sizer->Add(cp, 0, wxALL, FromDIP(3));
}
m_other_fg_sizer->Layout();
Layout();
Fit();
}
void ColorPickerPopup::set_def_colour(wxColour col, std::vector<wxColour> cols, int ctype)
{
m_def_col = col;
if (cols.empty()) {
cols.push_back(col);
}
m_def_cols = cols;
m_def_ctype = m_def_cols.size() > 1 ? ctype : 2;
for (ColorPicker* cp : m_color_pickers) {
if (cp->m_selected) {
@@ -2069,7 +2256,8 @@ void ColorPickerPopup::set_def_colour(wxColour col)
}
for (ColorPicker* cp : m_color_pickers) {
if (cp->m_colour == m_def_col) {
if (!cp->IsShown()) continue;
if (cp->ctype == m_def_ctype && cp->m_cols == m_def_cols) {
cp->set_selected(true);
break;
}
+19 -4
View File
@@ -82,14 +82,22 @@ public:
class ColorPickerPopup : public PopupWindow
{
public:
struct ColorItem
{
std::vector<wxColour> colors;
int ctype = 2;
wxString name;
};
ScalableBitmap m_ts_bitmap_custom;
wxStaticBitmap* m_ts_stbitmap_custom;
StaticBox* m_custom_cp;
StaticBox* m_def_color_box;
wxFlexGridSizer* m_ams_fg_sizer;
wxFlexGridSizer* m_other_fg_sizer;
wxColour m_def_col;
std::vector<wxColour> m_def_colors;
std::vector<wxColour> m_ams_colors;
std::vector<ColorItem> m_ams_color_items;
std::vector<ColorPicker*> m_color_pickers;
std::vector<ColorPicker*> m_ams_color_pickers;
@@ -97,13 +105,20 @@ public:
ColorPickerPopup(wxWindow* parent);
~ColorPickerPopup() {};
void on_custom_clr_picker(wxMouseEvent& event);
void set_ams_colours(std::vector<wxColour> ams);
void set_def_colour(wxColour col);
void set_ams_colours(const std::vector<ColorItem>& ams);
void set_preset_colours(const std::vector<ColorItem>& preset_colors);
void set_def_colour(wxColour col, std::vector<wxColour> cols = {}, int ctype = 2);
const std::vector<wxColour>& get_selected_colours() const { return m_def_cols; }
int get_selected_ctype() const { return m_def_ctype; }
void paintEvent(wxPaintEvent& evt);
virtual void OnDismiss() wxOVERRIDE;
virtual bool ProcessLeftDown(wxMouseEvent& event) wxOVERRIDE;
public:
private:
std::vector<ColorPicker*> m_default_color_pickers;
std::vector<ColorPicker*> m_preset_color_pickers;
std::vector<wxColour> m_def_cols;
int m_def_ctype = 2;
};
+3 -1
View File
@@ -747,7 +747,9 @@ Print::ApplyStatus BackgroundSlicingProcess::apply(const Model& model, const Dyn
// Orca: prevent resetting under gcode viewer mode
if (invalidated != PrintBase::APPLY_STATUS_UNCHANGED) {
const auto plater = GUI::wxGetApp().mainframe->m_plater;
// Use the app’s plater accessor instead of going through mainframe, otherwise anything during startup that reaches apply()
// (loading presets, config or input files, e.g. from the CLI) therefore dereferences a null `mainframe` and crashes.
const auto plater = GUI::wxGetApp().plater();
if (plater && plater->only_gcode_mode()) {
invalidated = PrintBase::APPLY_STATUS_UNCHANGED;
}
+7 -1
View File
@@ -1798,7 +1798,8 @@ int MachineObject::command_ams_calibrate(int ams_id)
return command_with_dialog(m_agent->command_ams_calibrate(get_dev_id(), ams_id, MachineObject::m_sequence_id++, is_lan_mode_printer()));
}
int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::string filament_id, std::string setting_id, std::string tray_color, std::string tray_type, int nozzle_temp_min, int nozzle_temp_max)
int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::string filament_id, std::string setting_id, std::string tray_color, std::string tray_type,
int nozzle_temp_min, int nozzle_temp_max, const std::vector<std::string>& tray_colors, int tray_ctype)
{
int tag_tray_id = 0;
int tag_ams_id = ams_id;
@@ -1827,6 +1828,10 @@ int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::s
j["print"]["nozzle_temp_min"] = nozzle_temp_min;
j["print"]["nozzle_temp_max"] = nozzle_temp_max;
j["print"]["tray_type"] = tray_type;
if (!tray_colors.empty()) {
j["print"]["cols"] = tray_colors;
j["print"]["ctype"] = tray_ctype;
}
return this->publish_json(j);
}
@@ -5478,6 +5483,7 @@ void MachineObject::parse_new_info(json print)
is_support_pa_mode = (get_flag_bits_no_border(fun2, 3) == 1);
is_support_remote_dry = (get_flag_bits_no_border(fun2, 5) == 1);
is_support_check_track_switch_match_slice_printer = get_flag_bits_no_border(fun2, 19) == 1;
is_support_filament_manual_multi_color = (get_flag_bits_no_border(fun2, 23) == 1);
if (DevPrinterConfigUtil::support_print_check_firmware_for_tpu_left(printer_type)) {
m_firmware_support_print_tpu_left = get_flag_bits_no_border(fun2, 7) == 1;
+2 -1
View File
@@ -674,6 +674,7 @@ public:
bool is_support_remote_dry = false;
bool is_support_check_track_switch_match_slice_printer{false};
bool is_support_pa_mode{false};
bool is_support_filament_manual_multi_color{false};
std::optional<bool> m_firmware_support_print_tpu_left;
bool installed_upgrade_kit{false};
@@ -804,7 +805,7 @@ public:
int command_ams_switch_filament(bool switch_filament);
int command_ams_air_print_detect(bool air_print_detect);
int command_ams_calibrate(int ams_id);
int command_ams_filament_settings(int ams_id, int slot_id, std::string filament_id, std::string setting_id, std::string tray_color, std::string tray_type, int nozzle_temp_min, int nozzle_temp_max);
int command_ams_filament_settings(int ams_id, int slot_id, std::string filament_id, std::string setting_id, std::string tray_color, std::string tray_type, int nozzle_temp_min, int nozzle_temp_max, const std::vector<std::string>& tray_colors = {}, int tray_ctype = 2);
int command_ams_select_tray(std::string tray_id);
int command_ams_refresh_rfid(int ams_id, int slot_id);
int command_ams_control(std::string action);
+1 -1
View File
@@ -96,7 +96,7 @@ void FilamentColorCodeQuery::LoadFromLocal()
const auto& fila_color_strs = json_data_item["fila_color"].get<std::vector<wxString>>();
for (const auto& color_str : fila_color_strs) {
if (color_str.size() > 3) /* Skip the value like "#0"*/{
fila_color.m_colors.emplace(wxColour(color_str));
fila_color.AddColor(wxColour(color_str));
}
}
}
+11
View File
@@ -87,6 +87,17 @@ struct FilamentColor
public:
size_t ColorCount() const noexcept { return m_colors.size(); }
// Preserve catalog endpoint order for display while retaining set-based lookup identity.
const std::vector<wxColour>& GetColors() const noexcept { return m_color_list; }
void AddColor(const wxColour& color)
{
if (m_colors.insert(color).second) m_color_list.push_back(color);
}
private:
std::vector<wxColour> m_color_list;
public:
void EndSet(int ctype)
{
+15 -91
View File
@@ -65,6 +65,7 @@
#include <string_view>
#include "slic3r/GUI/Selection.hpp"
#include "GLCanvas3D.hpp"
#include "SequentialPrintClearance.hpp"
#ifdef SLIC3R_CAD
#include "slic3r/GUI/CAD/DesignSketchTool.hpp" // Design tab: interactive 2D sketch tool
#endif
@@ -6397,17 +6398,16 @@ void GLCanvas3D::update_sequential_clearance()
//BBS: add the height logic
PartPlate* plate = wxGetApp().plater()->get_partplate_list().get_curr_plate();
Polygons polygons;
std::vector<std::pair<Polygon, float>> height_polygons;
polygons.reserve(instances_count);
height_polygons.reserve(instances_count);
std::vector<struct height_info> convex_and_bounding_boxes;
struct height_info
{
double instance_height;
BoundingBox bounding_box;
Polygon hull_polygon;
};
std::vector<SequentialClearanceInstance> convex_and_bounding_boxes;
convex_and_bounding_boxes.reserve(instances_count);
std::map<ObjectID, int> print_order;
for (const PrintObject* print_object : fff_print()->objects())
for (const PrintInstance& instance : print_object->instances())
print_order.emplace(instance.model_instance->id(), instance.model_instance->arrange_order);
for (size_t i = 0; i < instance_transforms.size(); ++i) {
const size_t first_instance = convex_and_bounding_boxes.size();
const auto& instances = instance_transforms[i];
double rotation_z0 = instances.front()->get_rotation().z();
int index = 0;
@@ -6427,96 +6427,20 @@ void GLCanvas3D::update_sequential_clearance()
Polygon convex_hull(std::move(inst_pts));
BoundingBox bouding_box = convex_hull.bounding_box();
BoundingBox plate_bb = plate->get_bounding_box_crd();
const ObjectID instance_id = m_model->objects[i]->instances[index]->id();
double instance_height = m_model->objects[i]->get_instance_max_z(index++);
//skip the object for not current plate
if (!plate_bb.overlap(bouding_box))
continue;
convex_and_bounding_boxes.push_back({instance_height, bouding_box, convex_hull});
convex_and_bounding_boxes.push_back({instance_height, bouding_box, convex_hull, instance_id});
polygons.emplace_back(std::move(convex_hull));
}
sort_sequential_clearance_instances(convex_and_bounding_boxes.begin() + first_instance, convex_and_bounding_boxes.end(), print_order);
}
//sort the print instance
std::sort(convex_and_bounding_boxes.begin(), convex_and_bounding_boxes.end(),
[](auto &l, auto &r) {
auto ly1 = l.bounding_box.min.y();
auto ly2 = l.bounding_box.max.y();
auto ry1 = r.bounding_box.min.y();
auto ry2 = r.bounding_box.max.y();
auto inter_min = std::max(ly1, ry1);
auto inter_max = std::min(ly2, ry2);
auto lx = l.bounding_box.min.x();
auto rx = r.bounding_box.min.x();
if (inter_max - inter_min > 0)
return (lx < rx) || ((lx == rx)&&(ly1 < ry1));
else
return (ly1 < ry1);
});
/*bool has_interlaced_objects = false;
for (int k = 0; k < bounding_box_count; k++)
{
Polygon& convex = convex_and_bounding_boxes[k].hull_polygon;
BoundingBox& bbox = convex_and_bounding_boxes[k].bounding_box;
auto iy1 = bbox.min.y();
auto iy2 = bbox.max.y();
for (int i = k+1; i < bounding_box_count; i++)
{
Polygon& next_convex = convex_and_bounding_boxes[i].hull_polygon;
BoundingBox& next_bbox = convex_and_bounding_boxes[i].bounding_box;
auto py1 = next_bbox.min.y();
auto py2 = next_bbox.max.y();
auto inter_min = std::max(iy1, py1); // min y of intersection
auto inter_max = std::min(iy2, py2); // max y of intersection. length=max_y-min_y>0 means intersection exists
if (inter_max - inter_min > 0) {
has_interlaced_objects = true;
break;
}
}
if (has_interlaced_objects)
break;
}*/
int bounding_box_count = convex_and_bounding_boxes.size();
double printable_height = fff_print()->config().printable_height;
double hc1 = fff_print()->config().extruder_clearance_height_to_lid;
double hc2 = fff_print()->config().extruder_clearance_height_to_rod;
for (int k = 0; k < bounding_box_count; k++)
{
Polygon& convex = convex_and_bounding_boxes[k].hull_polygon;
BoundingBox& bbox = convex_and_bounding_boxes[k].bounding_box;
auto iy1 = bbox.min.y();
auto iy2 = bbox.max.y();
double height = (k == (bounding_box_count - 1))?printable_height:hc1;
/*if (has_interlaced_objects) {
if ((k < (bounding_box_count - 1)) && (convex_and_bounding_boxes[k].instance_height > hc2)) {
height_polygons.emplace_back(std::make_pair(convex, hc2));
}
}
else {
if ((k < (bounding_box_count - 1)) && (convex_and_bounding_boxes[k].instance_height > hc1)) {
height_polygons.emplace_back(std::make_pair(convex, hc1));
}
}*/
for (int i = k+1; i < bounding_box_count; i++)
{
Polygon& next_convex = convex_and_bounding_boxes[i].hull_polygon;
BoundingBox& next_bbox = convex_and_bounding_boxes[i].bounding_box;
auto py1 = next_bbox.min.y();
auto py2 = next_bbox.max.y();
auto inter_min = std::max(iy1, py1); // min y of intersection
auto inter_max = std::min(iy2, py2); // max y of intersection. length=max_y-min_y>0 means intersection exists
if (inter_max - inter_min > 0) {
height = hc2;
break;
}
}
if (height < convex_and_bounding_boxes[k].instance_height)
height_polygons.emplace_back(std::make_pair(convex, height));
}
const auto& config = fff_print()->config();
const auto height_polygons = sequential_clearance_height_polygons(convex_and_bounding_boxes, config.printable_height,
config.extruder_clearance_height_to_lid, config.extruder_clearance_height_to_rod);
// sends instances 2d hulls to be rendered
set_sequential_print_clearance_visible(true);
+2
View File
@@ -114,6 +114,8 @@ std::pair<bool, std::string> GLShadersManager::init()
valid &= append_shader("mm_gouraud", { prefix + "mm_gouraud.vs", prefix + "mm_gouraud.fs" });
// Fast shaded preview for the texture displacement gizmo (see libslic3r/TextureDisplacement.hpp).
valid &= append_shader("texture_displacement_shaded", { prefix + "texture_displacement_shaded.vs", prefix + "texture_displacement_shaded.fs" });
// ChromaStack: the printed colours on the model, read per fragment from its own height.
valid &= append_shader("chromastack", { prefix + "chromastack.vs", prefix + "chromastack.fs" });
// UV-check overlay for the same gizmo: a procedural checker or a distortion heatmap over the
// painted patch, to sanity-check the unwrap.
valid &= append_shader("texture_displacement_uvcheck", { prefix + "texture_displacement_uvcheck.vs", prefix + "texture_displacement_uvcheck.fs" });
+108 -15
View File
@@ -252,9 +252,11 @@
#include "slic3r/GUI/ParamsDialog.hpp"
#include "slic3r/GUI/ParamsPanel.hpp"
#include "slic3r/GUI/PartPlate.hpp"
#include "slic3r/GUI/TextureLibrary.hpp"
#include "slic3r/GUI/Widgets/Button.hpp"
#include "slic3r/GUI/wxExtensions.hpp"
#include "slic3r/plugin/host/PluginPages.hpp"
#include "slic3r/plugin/host/PluginResourceTextures.hpp"
#include <wx/defs.h>
#include "slic3r/GUI/Widgets/WebView.hpp"
#include <cwchar>
@@ -333,6 +335,23 @@ static std::shared_ptr<OrcaCloudServiceAgent> get_orca_cloud_agent(NetworkAgent*
return agent ? std::dynamic_pointer_cast<OrcaCloudServiceAgent>(agent->get_cloud_agent()) : nullptr;
}
// Full {web} page URL for a same-site OrcaCloud path, or empty when it cannot be built. An already
// open cloud pane is navigated straight to it, without re-running the session handoff.
static wxString cloud_page_url(NetworkAgent* agent, const std::string& path)
{
if (path.empty())
return {};
auto orca = get_orca_cloud_agent(agent);
if (!orca)
return {};
std::string base = orca->get_cloud_base_url();
while (!base.empty() && base.back() == '/')
base.pop_back();
if (base.empty())
return {};
return from_u8(base + path);
}
void start_ping_test()
{
return;
@@ -3063,6 +3082,37 @@ void GUI_App::init_plugin_gui_wiring()
refresh_plugins_dialog();
switch_printer_agent_after_unload(capability.plugin_key);
});
plugin_mgr.subscribe_on_load_callback([](const std::string& plugin_key) {
if (wxTheApp == nullptr || wxGetApp().is_closing())
return;
PluginResourceTextures::instance().on_plugin_register(plugin_key);
Slic3r::GUI::bump_texture_library_generation();
});
plugin_mgr.subscribe_on_unload_callback([](const std::string& plugin_key) {
if (wxTheApp == nullptr || wxGetApp().is_closing())
return;
PluginResourceTextures::instance().on_plugin_deregister(plugin_key);
Slic3r::GUI::bump_texture_library_generation();
});
plugin_mgr.subscribe_on_capability_load_callback([](const PluginCapabilityId& capability) {
if (capability.type != PluginCapabilityType::ResourceTexture)
return;
if (wxTheApp == nullptr || wxGetApp().is_closing())
return;
PluginResourceTextures::instance().on_cap_register(capability);
Slic3r::GUI::bump_texture_library_generation();
});
plugin_mgr.subscribe_on_capability_unload_callback([](const PluginCapabilityId& capability) {
if (capability.type != PluginCapabilityType::ResourceTexture)
return;
if (wxTheApp == nullptr || wxGetApp().is_closing())
return;
PluginResourceTextures::instance().on_cap_deregister(capability);
Slic3r::GUI::bump_texture_library_generation();
});
// Packs already loaded before this wiring, or in tests where no callback fires.
PluginResourceTextures::instance().initial_sweep();
Slic3r::GUI::bump_texture_library_generation();
}
bool GUI_App::on_init_inner()
@@ -5434,12 +5484,7 @@ std::string GUI_App::handle_web_request(std::string cmd)
this->request_open_project({});
}
else if (command_str.compare("homepage_open_cloud") == 0) {
// Deferred: set_cloud_mode() may create a second native webview, which must
// not run inside this webview's script-message callback.
CallAfter([this]() {
if (WebViewPanel* home = WebViewPanel::if_built())
home->set_cloud_mode(true, cloud_webview_handoff());
});
this->open_orca_cloud();
}
else if (command_str.compare("homepage_close_cloud") == 0) {
CallAfter([]() {
@@ -5588,6 +5633,22 @@ std::string GUI_App::handle_web_request(std::string cmd)
return "";
}
void GUI_App::open_orca_cloud(const std::string& next)
{
// Remembered so a login while the pane is open re-seeds the same page rather than the default.
m_cloud_open_path = next;
// Deferred: set_cloud_mode() may create the second native webview, so this must not run inside
// the caller's frame (a webview script-message callback or an ImGui frame).
CallAfter([this]() {
if (mainframe != nullptr)
mainframe->select_tab(TAB_ID_HOME);
// The Home page is built lazily, so the tab switch may not have created it yet: ensure() builds
// it if needed, matching what showing the tab does, rather than dropping the open request.
if (WebViewPanel* home = WebViewPanel::ensure())
home->set_cloud_mode(true, cloud_webview_handoff(m_cloud_open_path), cloud_page_url(m_agent, m_cloud_open_path));
});
}
void GUI_App::handle_script_message(std::string msg, const std::string& provider)
{
try {
@@ -5865,7 +5926,7 @@ void GUI_App::on_update_machine_list(wxCommandEvent &evt)
}*/
}
wxString GUI_App::cloud_webview_handoff()
wxString GUI_App::cloud_webview_handoff(const std::string& next)
{
// The cloud pane loads the Start URL, not a token: the page checks the webview's own session
// and asks for a handoff itself (guide §4). Signed out, user_id is omitted and the page will
@@ -5873,7 +5934,7 @@ wxString GUI_App::cloud_webview_handoff()
auto orca = get_orca_cloud_agent(m_agent);
if (!orca)
return {};
return from_u8(orca->build_web_session_start_url());
return from_u8(orca->build_web_session_start_url(next));
}
void GUI_App::request_cloud_web_session(const std::string& state)
@@ -6016,7 +6077,7 @@ void GUI_App::on_user_login_handle(wxCommandEvent &evt)
m_cloud_handoff_state.clear();
if (WebViewPanel* home = WebViewPanel::if_built())
if (home->cloud_mode_active())
home->set_cloud_mode(true, cloud_webview_handoff());
home->set_cloud_mode(true, cloud_webview_handoff(m_cloud_open_path));
}
@@ -6863,7 +6924,7 @@ void GUI_App::push_notification(const MachineObject* obj, wxString msg, wxStrin
}
}
void GUI_App::reload_settings()
bool GUI_App::reload_settings()
{
if (preset_bundle && m_agent) {
// Load user's personal presets
@@ -6871,7 +6932,7 @@ void GUI_App::reload_settings()
int result = m_agent->get_user_presets(&user_presets);
if (result != 0) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": get_user_presets failed with code " << result << ", skipping sync";
return;
return false;
}
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << __LINE__ << " cloud user preset number is: " << user_presets.size();
auto refresh_synced_ui = [this, user_presets = std::move(user_presets)]() mutable {
@@ -6946,13 +7007,30 @@ void GUI_App::reload_settings()
refresh_synced_ui();
else
CallAfter(refresh_synced_ui);
return true;
}
return false;
}
void GUI_App::check_legacy_user_filament_presets(bool by_user)
{
// One check at a time: the startup check can come due while the manual one is open, and on macOS the menu bar
// stays usable while a modal dialog is up.
if (m_legacy_filament_check_running)
return;
if (!by_user && app_config->get_bool("legacy_user_filament_presets_dont_remind"))
return;
// The local copies may be older than the cloud ones another machine changed: migrating them would push changes
// made against stale copies and conflict with the cloud. The startup check waits for the presets to be pulled.
if (!by_user && m_user_preset_pull_pending) {
m_legacy_filament_check_due = true;
return;
}
// This check also answers a startup check that is still waiting.
m_legacy_filament_check_due = false;
m_legacy_filament_check_running = true;
ScopeGuard running_guard([this] { m_legacy_filament_check_running = false; });
const std::vector<LegacyUserFilamentPreset> legacy_presets = preset_bundle->legacy_user_filament_presets();
if (legacy_presets.empty()) {
if (by_user)
@@ -6997,6 +7075,9 @@ void GUI_App::check_legacy_user_filament_presets(bool by_user)
show_error(mainframe, format_wxstr(_L("Your filament presets could not be migrated. A backup of your presets was saved to:\n%1%"), backup));
return;
}
// Ask the sync loop to push on its next pass instead of its next 60 s round, which shortens the time the migrated
// presets stay unpushed.
m_sync_user_presets_now.store(true, std::memory_order_release);
preset_bundle->update_compatible(PresetSelectCompatibleType::Never);
mainframe->update_side_preset_ui();
for (auto tab : tabs_list) {
@@ -7763,6 +7844,7 @@ void GUI_App::start_sync_user_preset(bool with_progress_dlg)
BOOST_LOG_TRIVIAL(info) << "start_sync_service...";
// BBS
m_user_sync_token.reset(new int(0));
m_user_preset_pull_pending = true;
if (with_progress_dlg) {
// Mark a manual progress dialog as active so restart_sync_user_preset() ignores
// repeat triggers while it is on screen (prevents stacking modal dialogs).
@@ -7836,8 +7918,16 @@ void GUI_App::start_sync_user_preset(bool with_progress_dlg)
finishFn(ret == 0);
if (ret == 0 && m_agent && !t.expired())
reload_settings();
// Queued after the refresh reload_settings() queues, so it runs once the pulled presets are loaded.
if (ret == 0 && m_agent && !t.expired() && reload_settings())
CallAfter([this, t] {
// A stopped or restarted sync: the next sync, if one starts, pulls the presets again.
if (t.expired() || is_closing())
return;
m_user_preset_pull_pending = false;
if (m_legacy_filament_check_due)
check_legacy_user_filament_presets(false);
});
// For orca specific syncing
auto orca_agent = std::dynamic_pointer_cast<OrcaCloudServiceAgent>(m_agent->get_cloud_agent());
@@ -7852,8 +7942,11 @@ void GUI_App::start_sync_user_preset(bool with_progress_dlg)
// Sync once immediately, then every 60 seconds.
while (!t.expired()) {
++tick_tock;
// Sync once immediately, then every 60s, or right away when a force-push asked for it.
if (tick_tock % 120 == 0 || m_sync_user_presets_now.exchange(false, std::memory_order_acq_rel)) {
// Sync once immediately, then every 60s, or right away when a force-push or a preset migration asked
// for it. The request is consumed on every tick, so one made before a scheduled pass is served by it
// instead of running a second pass straight after.
const bool push_now = m_sync_user_presets_now.exchange(false, std::memory_order_acq_rel);
if (tick_tock % 120 == 0 || push_now) {
tick_tock = 0;
if (m_agent) {
if (!m_agent->is_user_login()) {
+13 -3
View File
@@ -355,6 +355,9 @@ private:
std::atomic<bool> m_restart_sync_pending {false};
std::atomic<bool> m_sync_user_preset_dlg_active {false}; // a manual "Sync Presets" progress dialog is on screen (see restart_sync_user_preset)
std::atomic<bool> m_sync_user_presets_now {false}; // request the sync loop to push user presets on its next tick
bool m_user_preset_pull_pending {false}; // set when a sync thread starts, cleared once its pull is applied; a failed pull or a stopped sync leaves it set
bool m_legacy_filament_check_due {false}; // the startup check waits for that pull
bool m_legacy_filament_check_running {false}; // a check is in progress, its dialog may be open
std::atomic<bool> m_migration_retry_pending {false};
bool m_is_dark_mode{ false };
bool m_adding_script_handler { false };
@@ -365,6 +368,8 @@ private:
// and a generation counter so a superseded mint/login result is dropped (guide §5, "only the
// newest request counts").
std::string m_cloud_handoff_state;
// The OrcaCloud path the open requests target, remembered so a login re-seed keeps the same page.
std::string m_cloud_open_path;
uint64_t m_cloud_handoff_gen { 0 };
bool m_show_error_msgdlg{false};
wxString m_info_dialog_content;
@@ -466,6 +471,9 @@ public:
#if wxUSE_WEBVIEW_EDGE
void init_webview_runtime();
#endif
// Switches to the Home tab and opens the OrcaCloud pane beside the homepage on `next`, a
// same-site OrcaCloud path (empty = the page's default). Work is deferred internally.
void open_orca_cloud(const std::string& next = std::string());
static unsigned get_colour_approx_luma(const wxColour& colour);
static bool dark_mode();
const wxColour get_label_default_clr_system();
@@ -572,7 +580,7 @@ public:
// called from the pane when the page asks for a session; it opens the desktop login dialog if
// signed out, otherwise mints a one-time token on a worker thread and loads the Complete URL
// in the same webview.
wxString cloud_webview_handoff();
wxString cloud_webview_handoff(const std::string& next = std::string());
void request_cloud_web_session(const std::string& state);
void mint_cloud_web_session(const std::string& state, uint64_t gen);
void on_cloud_web_session_minted(const std::string& state, uint64_t gen, const std::string& token_hash);
@@ -589,7 +597,8 @@ public:
void check_new_version_sf(bool show_tips = false, int by_user = 0);
// Offers to migrate user filament presets made against an older release that lost printers their parent
// listed then. At startup it asks on every start that finds such presets, unless the user chose not to be
// reminded; by_user also reports when there is nothing to migrate.
// reminded; while the user presets sync with the cloud, it waits until they have been pulled. by_user also
// reports when there is nothing to migrate.
void check_legacy_user_filament_presets(bool by_user);
bool process_network_msg(std::string dev_id, std::string msg);
void request_new_version(int by_user);
@@ -600,7 +609,8 @@ public:
std::string format_IP(const std::string& ip);
void show_dialog(wxString msg);
void push_notification(const MachineObject* obj, wxString msg, wxString title = wxEmptyString, UserNotificationStyle style = UserNotificationStyle::UNS_NORMAL);
void reload_settings();
// Returns false when the cloud presets could not be fetched, so nothing was reloaded.
bool reload_settings();
void remove_user_presets();
bool maybe_migrate_user_presets_on_login();
+31
View File
@@ -2875,6 +2875,37 @@ void ObjectList::del_layer_from_object(const int obj_idx, const t_layer_height_r
changed_object(obj_idx);
}
void ObjectList::rebuild_layer_ranges(const int obj_idx)
{
if (obj_idx < 0 || obj_idx >= int(m_objects->size()))
return;
const wxDataViewItem obj_item = m_objects_model->GetItemById(obj_idx);
if (!obj_item.IsOk())
return;
const t_layer_config_ranges& ranges = object(obj_idx)->layer_config_ranges;
// Adding and deleting tree items fires selection events on GTK; suppress them as every other
// bulk edit of the tree here does.
const bool prevent_events = m_prevent_list_events;
m_prevent_list_events = true;
wxDataViewItem root = m_objects_model->GetLayerRootItem(obj_item);
if (root.IsOk()) {
m_objects_model->DeleteChildren(root);
if (ranges.empty())
m_objects_model->Delete(root);
else {
for (const auto& range : ranges)
add_layer_item(range.first, root);
Expand(root);
}
} else {
add_layer_root_item(obj_item);
}
m_prevent_list_events = prevent_events;
changed_object(obj_idx);
}
void ObjectList::del_layers_from_object(const int obj_idx)
{
object(obj_idx)->layer_config_ranges.clear();
+5
View File
@@ -321,6 +321,11 @@ public:
void del_settings_from_config(const wxDataViewItem& parent_item);
void del_instances_from_object(const int obj_idx);
void del_layer_from_object(const int obj_idx, const t_layer_height_range& layer_range);
// Mirrors an object's height ranges in the tree after they have been rewritten from outside the
// list - the texture displacement gizmo writes a whole set at once for a ChromaStack stack's
// filament swaps. Deliberately leaves the selection alone: changing it while a gizmo is open
// tears the gizmo down under the user.
void rebuild_layer_ranges(const int obj_idx);
void del_layers_from_object(const int obj_idx);
bool del_from_cut_object(bool is_connector, bool is_model_part = false, bool is_negative_volume = false);
bool del_subobject_from_object(const int obj_idx, const int idx, const int type);
File diff suppressed because it is too large Load Diff
@@ -264,7 +264,11 @@ private:
ModelVolume* texture_volume();
const ModelVolume* texture_volume() const;
void add_texture_layer();
// `chroma` picks the kind: a relief layer, a height map pushed into the surface, or a ChromaStack
// picture, a colour picture made of filament swaps. The kind is a property of the layer and is
// fixed when it is created - there is no panel-wide mode, so a plaque can carry a picture on its
// face and a knurl on its rim at the same time.
void add_texture_layer(bool chroma = false);
void remove_texture_layer(int slot);
// Moves the layer in `slot` to position `to_index` of the slot-ordered stack (an insertion index,
// 0..layer count), which is the order blend modes apply in. Layers keep their data; what moves is
@@ -428,9 +432,116 @@ private:
void render_texture_library_popup(const ImVec2 &panel_min, const ImVec2 &panel_max);
int m_picker_slot = -1;
bool m_picker_open_request = false;
// Set when the popup opens, so the next layout scrolls the layer's current texture into view.
bool m_picker_reveal_selected = false;
float m_picker_scroll_target = 0.f; // see ease_wheel_scroll()
float m_picker_scroll_applied = -1.f;
void set_layer_texture(TextureDisplacementLayer &layer, const TextureLibraryEntry &entry);
void import_custom_texture(TextureDisplacementLayer &layer);
// ---- ChromaStack (see libslic3r/ChromaStack.hpp) ----
//
// A ChromaStack layer is an ordinary texture displacement layer whose height map is computed rather
// than imported: the picture the user picked is kept in `chroma_source`, the stack in
// `chroma`, and `image_data` holds whatever the last solve produced. Everything downstream -
// projection, preview, bake - therefore needs no changes at all, and this is the whole of the
// feature's GUI side: pick a picture, edit the stack, solve, hand the filament swaps to the
// object's height ranges.
// The project's loaded filaments as the colour model sees them: colour from the project, TD from
// each filament's preset. Index is the filament index the swaps are written from.
static std::vector<ChromaFilament> chroma_filaments();
// The layer height the print profile is set to, mm; 0 when there is no print preset. The solve is
// only valid for the layer height it was run at, so the panel compares it against the stack's.
static float print_layer_height();
// Where the active layer's painted patch sits, in the object's own printed coordinates - which is
// what the stack has to be lined up with, because a filament swap happens at a print Z and the
// relief grows upward from this patch.
struct ChromaPatch
{
bool valid = false;
float top_mm = 0.f; // the patch's surface above the bottom of the object
float spread_mm = 0.f; // how far from flat the patch is in Z; 0 is a horizontal face
// The patch's extent in the plane the texture is projected from, in the bake frame - i.e. the
// (x, y) the Z-facing plane of the tri-planar projection samples by. This is what the picture
// has to be placed against: tiling_scale and offset are in that space, not in the patch's.
Vec2f planar_min = Vec2f::Zero();
Vec2f planar_max = Vec2f::Zero();
};
ChromaPatch chroma_patch(const TextureDisplacementLayer &layer) const;
// The painted face's height in layers, which is what the stack's base has to be: -1 when nothing
// is painted yet or there is no layer height to divide by.
int chroma_measured_base(const TextureDisplacementLayer &layer) const;
// Extracting the painted patch is far too expensive to repeat once a frame, which is how often the
// panel wants the number. The paint's own timestamp and the transform the patch was measured in are
// between them everything the answer depends on.
mutable std::array<ChromaPatch, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_chroma_patch_cache{};
mutable std::array<uint64_t, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_chroma_patch_stamp{};
mutable std::array<Transform3d, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_chroma_patch_trafo{};
// The patch is measured in two frames at once - the instance's, for the print height, and the
// bake's, for the placement - so both are part of what the cached answer depends on.
mutable std::array<Transform3d, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_chroma_patch_bake{};
// Picks a colour picture for the layer, turns ChromaStack on, and seeds a stack from the loaded
// filaments and the patch if the layer has none yet. Solves straight away, so the relief appears
// without a second click.
void chroma_pick_picture(TextureDisplacementLayer &layer);
// Re-runs the solve and replaces the layer's height map with its result. False (with the reason
// shown to the user) when the layer has no picture, no stack or no relief layers.
bool chroma_solve_layer(TextureDisplacementLayer &layer);
// Writes the stack's filament swaps onto the object as height ranges, replacing whatever ranges it
// had. Takes its own undo snapshot.
void chroma_apply_swaps(const TextureDisplacementLayer &layer);
// The stack's swaps as the object's height ranges already express them: true when applying would
// change nothing, which is what the panel's hint keys off.
bool chroma_swaps_applied(const TextureDisplacementLayer &layer) const;
// The whole of a ChromaStack card between its header and More settings: the picture and the print
// it predicts, the base, the filaments and the colours they reach, and the two actions.
void render_chroma_card(TextureDisplacementLayer &layer, float card_pad, float label_w, float wrap_w);
// The surface drawn in the colours the stack will print, over whatever neutral surface is there:
// the live preview's displaced mesh before a bake, the volume's own mesh after one. Every triangle
// takes the palette colour of the printed layer its centroid falls in (chroma_level_at()), which
// is what the eye will see at that height - so the relief's terraces read as the picture, the walls
// between them show the bands they cut through, and looking straight down gives the blended colours
// the whole feature exists to produce.
//
// Nothing else in the application can show this: the colours are blends of translucent filaments
// that no single filament can make, so they cannot be expressed as the per-triangle filament
// assignment the 3D scene paints with. They exist only here and in the sliced preview.
void rebuild_chroma_colors(const TextureDisplacementLayer &layer);
bool render_chroma_colors(const TextureDisplacementLayer &layer);
GLModel m_chroma_color_model;
// The surface the overlay was built from. The mesh alone: the colours are not in it, so a change
// to the stack costs a palette upload rather than a mesh rebuild.
size_t m_chroma_color_key = 0;
// The stack's colours, one texel per printed layer, and what they were built from. A texture
// because a uniform array cannot hold a base of several hundred layers.
std::unique_ptr<GLTexture> m_chroma_palette_tex;
size_t m_chroma_palette_key = 0;
bool m_show_chroma_colors = true;
// What the last solve of each slot reported, for the panel. Not saved: it is a property of the
// solve, and a project is reopened with whatever height map the solve produced.
struct ChromaReport
{
bool valid = false;
double mean = 0.;
double max = 0.;
};
std::array<ChromaReport, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_chroma_report{};
// The predicted print, per slot: the palette colours the solve chose, as a panel thumbnail. The
// 3D view cannot show this - the colours do not exist until the print swaps filament - so it is
// the only place the user sees what the stack will actually produce.
std::array<std::unique_ptr<GLTexture>, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_chroma_preview;
// The source picture's thumbnail, beside the prediction, keyed by the image it was built from.
std::array<std::unique_ptr<GLTexture>, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_chroma_source_thumb;
std::array<const void *, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_chroma_source_key{};
// Per slot: the stack has been edited since the height map was solved, so the relief on screen is the
// previous stack's. Nothing else can show this - a stale height map looks like a perfectly good one.
std::array<bool, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_chroma_stale{};
// Paint / Erase, from the panel. See get_left_button_state_type().
bool m_erase_mode = false;
// Per slot: whether the layer's card shows every setting or only Depth, Tile size and Rotation.
@@ -508,120 +619,25 @@ private:
// is where the live adjustment is applied. Called whenever the panel moves or reverts it.
void invalidate_adjust_textures();
// "Adjust Texture" mode: instead of painting, the active layer's placement is a control lying
// on the model. The gizmo is one tile of the texture in the paint patch's own tangent plane:
// where it sits is the layer's offset, how it is turned is rotation_deg, how big it is is the
// tile extents - so each handle is one of the values it edits rather than a stand-in for it.
// Anchored to the centroid/average-normal of the active layer's paint patch (see
// libslic3r::compute_layer_paint_anchor()), so nothing is drawn until something is painted.
// "Adjust Texture" mode: instead of painting, dragging an on-canvas handle changes the active
// layer's offset. The handle is a flat panel lying in the paint patch's own tangent plane
// (a "pan" - drag anywhere on it for free 2D movement), plus two arrows along the patch's
// own U/V axes that constrain the drag to just that one axis for precise nudging. Anchored to
// the centroid/average-normal of the active layer's current paint patch (see
// libslic3r::compute_layer_paint_anchor()), so nothing is drawn if it has nothing painted yet.
//
// Drawn through ImGui's foreground draw list rather than the "flat" shader, which is what gives
// it antialiased sub-pixel strokes, rounded joins, a contrast halo and the numeric readout. The
// cost is that render_adjust_texture_gizmo() has to be called from inside the gizmo's ImGui
// frame (see on_render_input_window()), not from on_render() with the rest of the GL drawing.
// Move is the whole interior, free in both axes. AxisMove is the four arrows, each holding a
// move to one of the tile's axes. EdgeScale is the four edge bars, one tile extent each, and
// CornerScale the four corner grips, which take both extents at once.
//
// The arrows and the bars share the same four rays out of the tile, so they are kept apart by
// distance rather than direction: the bar hugs its edge, then a clear gap, then the arrow well
// outside it. adjust_pick() leans on that - see the order it tests them in.
enum class AdjustHandle { None, Move, AxisMove, EdgeScale, CornerScale, Rotate };
// The gizmo's geometry for one frame, in screen pixels. Worked out once and shared by the
// drawing and the hit test, so the two cannot disagree about where a handle is. Everything
// except the frame itself is a fixed number of pixels, which is what keeps the control equally
// usable on a 5 mm part and a 500 mm one - the old handle scaled with the volume's bounding box
// and so was unusable at both ends.
struct AdjustFrame
{
bool valid = false;
Vec2d center = Vec2d::Zero();
// +u+v, +u-v, -u-v, -u+v. A scale grip sits on each.
std::array<Vec2d, 4> corner{};
// +u, -u, +v, -v: the middle of each edge, and the two ends of the bar that sits just
// outside it. The bar's ends are kept rather than recomputed so the drawing and the hit test
// cannot end up with it in two different places.
std::array<Vec2d, 4> edge_mid{};
std::array<Vec2d, 4> bar_a{};
std::array<Vec2d, 4> bar_b{};
// The axis arrow further out on the same ray: where its shaft starts and where its chevron
// ends. Kept, like the bar's ends, so the drawing and the hit test cannot disagree.
std::array<Vec2d, 4> arm_base{};
std::array<Vec2d, 4> arm_tip{};
std::array<Vec2d, 4> arm_dir{}; // unit, pointing outward from the centre
// A ring is placed off every corner, but only one is drawn and only that one can be picked:
// four marks for a single operation is clutter, and whichever corner the cursor is near is
// the one worth offering. `rotate_shown` says which, and see update_rotate_corner() for why
// it does not simply track the nearest.
std::array<Vec2d, 4> rotate_at{};
int rotate_shown = 0;
// The tile projects to less than ADJUST_MIN_TILE_PX across, so the frame was drawn at that
// floor instead. It is dashed to say so, and the readout still gives the true millimetres.
bool clamped = false;
// What the frame was built from, in mesh-local mm: the tile's own (rotated) edge directions
// and its extents. The drag arithmetic reuses these rather than recomputing them, so it
// cannot end up working against a different tile than the one on screen.
Vec3f e_u = Vec3f::UnitX(), e_v = Vec3f::UnitY();
Vec2f tile_mm = Vec2f::Ones(); // (u, v) as stored on the layer
Vec2f half_planar = Vec2f::Ones(); // half extents in planar mm, v already aspect-corrected
Vec2f center_planar = Vec2f::Zero();
};
// NOTE: the drag direction/sign below is this session's best-effort reasoning about which way
// the texture should appear to move as the handle is dragged - it could not be visually
// confirmed while writing it (no way to render/see pixels in this environment), so it may
// need a one-line sign flip once actually tested.
bool update_adjust_anchor(); // recomputes m_adjust_anchor_pos/normal; false if nothing painted
// The selected instance's volume-to-world transform, which the bake frame is derived from.
bool adjust_volume_to_world(Transform3d &out) const;
// The anchor, carried from mesh-local into the bake frame. Converted on every use rather than
// stored converted: the object can be moved, rotated or scaled while this mode is open, and a
// stored copy would quietly describe the shape it used to be. The expensive part - finding the
// paint patch's centroid - stays cached in the local members.
bool adjust_anchor_in_frame(Vec3f &pos, Vec3f &normal) const;
// Drops every scrap of live gizmo state. Called from each of the several ways out of the mode -
// the panel's toggle, collapsing the card, switching to seam marking, closing the gizmo - so a
// stale hover highlight or a half-finished drag cannot survive into the next time it is opened.
void end_adjust_texture_mode();
bool on_mouse_adjust_texture(const wxMouseEvent &mouse_event);
void render_adjust_texture_gizmo();
// This frame's gizmo geometry, or an invalid frame when there is nothing to draw.
AdjustFrame adjust_frame() const;
// Which handle is under `mouse`, with `index` set to the arm or corner it belongs to. Tested
// outermost first; see the hit-area board of the design for the order and the radii.
AdjustHandle adjust_pick(const Vec2d &mouse, const AdjustFrame &f, int &index) const;
// Chooses which corner carries the rotate ring. The nearest to the cursor, but it only moves once
// the newcomer is clearly nearer, because without that margin the ring flits between two corners
// every time the cursor wanders along the line equidistant from both. Never moves during a drag.
void update_rotate_corner(const AdjustFrame &f, const Vec2d &mouse);
// Where the tile's centre sits, in planar mm relative to the anchor, for a layer's placement.
// Not the patch centroid: the gizmo *is* the texture's placement, so it has to travel as the
// offset changes - pinning it to the centroid is what made the old handle look broken, the
// texture sliding while the handle stayed put.
static Vec2f adjust_handle_planar(const TextureDisplacementLayer &layer, float aspect);
// The exact inverse: the offset that puts the tile's centre at `planar`. Rotating or resizing
// the tile changes where a given offset places it, so every drag recomputes the offset from the
// centre it wants to keep - which is what makes rotate and scale act *in place* rather than
// swinging the tile around the anchor.
static Vec2f adjust_offset_for_planar(const Vec2f &planar, float rot_deg, const Vec2f &tile_mm,
const Vec2f &mirror_sign, float aspect);
// The mesh-local-to-world transform of the selected instance, the (unrotated) tangent basis and
// the lifted anchor the whole gizmo is built on. False when there is nothing to build on - which
// is the single place that decides the gizmo is not drawable, so the drawing, the hit test and
// the drag cannot disagree about it.
bool adjust_basis(Transform3d &trafo, Vec3f &u_axis, Vec3f &v_axis, Vec3f &base) const;
// Screen-space quad of a tile with the given placement. Used for the pre-drag ghost, which is
// drawn from the captured start placement rather than from the layer, the layer having already
// been moved by then.
bool adjust_quad_for(const Vec2f &center_planar, float rot_deg, const Vec2f &tile_mm,
std::array<Vec2d, 4> &out) const;
// Where the cursor meets the drag plane, in planar mm relative to the anchor. The plane is
// deliberately the *fixed* anchor plane rather than the tile's current position: measuring a
// drag against a plane the drag itself moves is a feedback loop.
bool adjust_planar_under(const Vec2d &mouse, Vec2f &planar) const;
// Draws a small '+'/'-' next to the mouse over the 3D view while painting/selecting, so it is
// obvious whether the next stroke adds paint (default) or erases it (Shift). Uses ImGui's
// foreground draw list, so it must be called from inside the gizmo's ImGui frame.
void render_paint_cursor_hint();
// Bake-frame tangent-plane basis at m_adjust_anchor_normal: box_projection_axes(), so dragging
// Mesh-local tangent-plane basis at m_adjust_anchor_normal: box_projection_axes(), so dragging
// on-canvas maps consistently onto offset whichever face the anchor is on.
void adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_axis) const;
@@ -1157,6 +1173,8 @@ private:
// Library textures the picker has shown at least once, keyed by file path (see LibraryTexture).
std::map<std::string, LibraryTexture> m_library_textures;
// Generation of the library the above were cached against; a change clears them.
uint64_t m_library_generation = 0;
// Everything the *unwrap* depends on. update_uv_editor() runs from rebuild_preview(), i.e. on
// every stroke end and every slider release - but depth/tiling/rotation/offset/blend change
@@ -1198,12 +1216,15 @@ private:
// The config key behind m_undocked.
static constexpr const char *UNDOCKED_CONFIG_KEY = "texture_displacement_panel_undocked";
// Smooth scrolling for the panel body (everything between the header and the pinned Bake footer).
// ImGui jumps a fixed number of lines per wheel notch, which on tall layer cards reads as a hard
// jolt rather than a scroll. The wheel is intercepted (ImGuiWindowFlags_NoScrollWithMouse) and
// moves a *target* offset instead; the real scroll is eased toward it over the following frames.
// Smooth scrolling for a child window, called inside it: the panel body (everything between the header
// and the pinned Bake footer) and the texture library's grid. ImGui jumps a fixed number of lines per
// wheel notch, which on tall layer cards reads as a hard jolt rather than a scroll. The child sets
// ImGuiWindowFlags_NoScrollWithMouse, and the wheel moves `target` instead; the real scroll is eased
// toward it over the following frames. `applied` is what the easing wrote last frame, <0 until the
// first one.
void ease_wheel_scroll(float &target, float &applied);
float m_panel_scroll_target = 0.f;
float m_panel_scroll_applied = -1.f; // what the easing wrote last frame; <0 until the first one
float m_panel_scroll_applied = -1.f;
// Last frame's body content height and footer height. The body is a child window that has to be
// given its height before its content is laid out, so it is sized from the previous frame: as tall
// as its content, capped so the footer still fits above the bottom of the canvas.
@@ -1213,47 +1234,23 @@ private:
// See the "Adjust Texture" block of private methods above.
bool m_adjust_texture_mode = false;
bool m_adjust_anchor_valid = false;
// Mesh-local, as compute_layer_paint_anchor() produces them. Everything else about the gizmo
// works in the bake frame - world orientation and scale, origin at the volume's own, which is
// where the texture is projected (texture_displacement_bake_frame()) - so these go through
// adjust_anchor_in_frame() at the point of use. A tile is so many *printed* millimetres whatever
// the instance's scale, which is why measuring the gizmo in mesh-local millimetres made its tile
// differ from the rendered one by exactly that scale.
Vec3f m_adjust_anchor_pos = Vec3f::Zero();
Vec3f m_adjust_anchor_normal = Vec3f::UnitZ();
Vec3f m_adjust_anchor_pos = Vec3f::Zero(); // mesh-local
Vec3f m_adjust_anchor_normal = Vec3f::UnitZ(); // mesh-local
// What the cursor is over, and what is being dragged. `index` picks the arm or the corner
// (0..3, in AdjustFrame's order); it is -1 for Move and Rotate, which have only one of each.
// Only ever one of each is set, so two handles can never both look live.
// Which corner currently carries the rotate ring. Persists between frames, which is what gives
// update_rotate_corner() something to apply its margin against.
int m_adjust_rotate_corner = 0;
AdjustHandle m_adjust_hover_kind = AdjustHandle::None;
int m_adjust_hover_index = -1;
AdjustHandle m_adjust_drag_kind = AdjustHandle::None;
int m_adjust_drag_index = -1;
// Pan: free drag anywhere on the flat panel, moves offset along both axes. AxisU/AxisV: drag
// the corresponding arrow, moves offset along only that one axis.
enum class AdjustHandle { None, Pan, AxisU, AxisV };
AdjustHandle m_adjust_drag_handle = AdjustHandle::None;
Vec2f m_adjust_drag_start_offset = Vec2f::Zero();
// Anchor-relative planar position (see project_planar()) of the point under the mouse at the
// moment the current drag started; every subsequent frame's delta is measured against this,
// rather than accumulated frame-to-frame, to avoid drift.
Vec2f m_adjust_drag_start_planar = Vec2f::Zero();
// Everything the live drag is measured against, captured once on mouse-down. Every frame's
// delta is taken from these rather than accumulated frame to frame, so a long drag cannot
// drift away from the cursor.
Vec2f m_adjust_drag_start_center = Vec2f::Zero(); // tile centre, planar mm
Vec2f m_adjust_drag_start_grab = Vec2f::Zero(); // the grabbed point, planar mm
// How far the grab landed from the handle's own position, in planar mm. A scale drag reads the
// extents straight off the cursor, so without subtracting this the tile would jump by however
// far inside the generous hit radius the click happened to fall.
Vec2f m_adjust_drag_grab_bias = Vec2f::Zero();
float m_adjust_drag_start_rot = 0.f;
Vec2f m_adjust_drag_start_tile = Vec2f::Ones(); // (u, v) extents as stored
float m_adjust_drag_start_angle = 0.f; // planar angle of the grab, radians
// The mirroring the layer had when the drag began. A width or height handle dragged back through
// the tile's middle toggles its axis against this, rather than against whatever the last frame
// left behind, so dragging back and forth across the middle cannot ratchet the flag.
bool m_adjust_drag_start_mirror_u = false;
bool m_adjust_drag_start_mirror_v = false;
// The live value to put in the readout pill, built by the drag and drawn by the renderer in the
// same frame. Empty when no drag is running, which is also when no pill is drawn.
std::string m_adjust_readout;
// Lazily-built unit quad (the pan panel) and unit line-with-arrowhead (reused, rotated, for
// both the U and V axis arrows), transformed into place at render time.
GLModel m_adjust_panel_glmodel;
GLModel m_adjust_arrow_glmodel;
std::map<std::string, wxString> m_desc;
+6 -4
View File
@@ -83,11 +83,13 @@ namespace Slic3r::GUI::GLGizmoUtils {
bool clicked = toolbar_circular_button(icon, "##wiki_guide_btn", scale);
if (url && *url && clicked)
wxLaunchDefaultBrowser(wxString::FromUTF8(url));
// ImGuiWrapper::tooltip keeps the text readable on the dark tooltip background whatever text colour the
// host window pushed; a negative wrap position keeps the URL on one line.
if (ImGui::IsItemHovered()){
if(url && *url)
ImGui::SetTooltip("%s\n%s", _u8L("Wiki Guide").c_str(), url);
wxGetApp().imgui()->tooltip(_u8L("Wiki Guide") + "\n" + url, -1.f);
else
ImGui::SetTooltip("%s", _u8L("Wiki Guide").c_str());
wxGetApp().imgui()->tooltip(_u8L("Wiki Guide"), -1.f);
}
return clicked; // for dynamically generated links
}
@@ -101,9 +103,9 @@ namespace Slic3r::GUI::GLGizmoUtils {
wxLaunchDefaultBrowser(wxString::FromUTF8(url));
if (ImGui::IsItemHovered()){
if(url && *url)
ImGui::SetTooltip("%s\n%s", _u8L("Video Guide").c_str(), url);
wxGetApp().imgui()->tooltip(_u8L("Video Guide") + "\n" + url, -1.f);
else
ImGui::SetTooltip("%s", _u8L("Video Guide").c_str());
wxGetApp().imgui()->tooltip(_u8L("Video Guide"), -1.f);
}
return clicked; // for dynamically generated links
}
@@ -857,11 +857,12 @@ void GizmoObjectManipulation::do_render_move_window(ImGuiWrapper *imgui_wrapper,
if (render_combo(imgui_wrapper, "", modes, selection_idx, 0, coord_combo_width)) {
combox_changed = true;
}
ImGuiWrapper::pop_combo_style();
// After pop_combo_style(): its light popup background would hide the tooltip's white text.
if (ImGui::IsItemHovered()) {
auto tooltip_str = _L("Coordinate system used for transform actions.");
imgui_wrapper->tooltip(tooltip_str, imgui_wrapper->calc_text_size(tooltip_str).x + 3 * space_size);
}
ImGuiWrapper::pop_combo_style();
// ORCA use TextColored to match axes color
float offset_to_center = (unit_size - ImGui::CalcTextSize("O").x) / 2;
@@ -964,11 +965,12 @@ void GizmoObjectManipulation::do_render_move_window(ImGuiWrapper *imgui_wrapper,
if (render_combo(imgui_wrapper, "", align_modes, align_mode_idx, 0, coord_combo_width))
m_align_to_parent = align_mode_idx + 1 == align_modes.size();
ImGui::PopID();
ImGuiWrapper::pop_combo_style();
// After pop_combo_style(): its light popup background would hide the tooltip's white text.
if (ImGui::IsItemHovered()) {
wxString tooltip_str = _L("Reference for alignment");
imgui_wrapper->tooltip(tooltip_str, imgui_wrapper->calc_text_size(tooltip_str).x + 3 * space_size);
}
ImGuiWrapper::pop_combo_style();
const bool dark_mode = wxGetApp().dark_mode();
int current_axis = -1;
@@ -1377,11 +1379,12 @@ void GizmoObjectManipulation::do_render_scale_input_window(ImGuiWrapper* imgui_w
if (render_combo(imgui_wrapper, "", modes, selection_idx, 0, coord_combo_width)) {
combox_changed = true;
}
ImGuiWrapper::pop_combo_style();
// After pop_combo_style(): its light popup background would hide the tooltip's white text.
if (ImGui::IsItemHovered()) {
auto tooltip_str = _L("Coordinate system used for transform actions.");
imgui_wrapper->tooltip(tooltip_str, imgui_wrapper->calc_text_size(tooltip_str).x + 3 * space_size);
}
ImGuiWrapper::pop_combo_style();
// ORCA use TextColored to match axes color
float offset_to_center = (unit_size - ImGui::CalcTextSize("O").x) / 2;
+5 -3
View File
@@ -505,10 +505,12 @@ bool ImGuiWrapper::update_mouse_data(wxMouseEvent& evt)
io.MouseDown[0] = evt.LeftIsDown();
io.MouseDown[1] = evt.RightIsDown();
io.MouseDown[2] = evt.MiddleIsDown();
float wheel_delta = static_cast<float>(evt.GetWheelDelta());
if (wheel_delta != 0.0f) {
// Only a vertical wheel event with a direction is a wheel step. A macOS trackpad reports sideways motion
// as separate horizontal events, and its wheel events often carry a rotation of 0: wx truncates the
// sub-pixel deltas to whole ones and passes on events with no delta at all. Read as vertical steps,
// either kind scrolls against the gesture, and scrolling up bounces back and forth.
if (evt.GetWheelAxis() == wxMOUSE_WHEEL_VERTICAL && evt.GetWheelRotation() != 0)
io.MouseWheel = evt.GetWheelRotation() > 0 ? 1.f : -1.f;
}
unsigned buttons = (evt.LeftIsDown() ? 1 : 0) | (evt.RightIsDown() ? 2 : 0) | (evt.MiddleIsDown() ? 4 : 0);
m_mouse_buttons = buttons;
+3
View File
@@ -1739,6 +1739,9 @@ void MainFrame::fit_tab_labels()
for (size_t i = count - 1; i >= 1; --i) {
int right = ScreenToClient(m_slice_option_btn->ClientToScreen({})).x;
int left = sizer->GetSize().GetWidth();
// The overflow button sits between the tab buttons and the side tools, so it is not free space.
if (const wxWindow* overflow = ctrl->GetOverflowButton())
left += overflow->GetSize().GetWidth();
if (right - left - FromDIP(15) > 0) return;
ctrl->SetCompact(i, true);
m_tabpanel->Refresh();
+1
View File
@@ -57,6 +57,7 @@ public:
// ORCA: a companion widget shown right after the tab buttons (before any side_tools), e.g.
// an overflow indicator. Pass nullptr to remove it; ownership stays with the caller.
void SetOverflowButton(wxWindow* button);
wxWindow* GetOverflowButton() const { return m_overflow_button; }
private:
wxFlexGridSizer* m_buttons_sizer;
+3 -1
View File
@@ -2225,8 +2225,10 @@ void PublishSettingsDialog::refresh_tab_indicators()
if (section.variant_switch != nullptr) {
const wxString& title = m_categories[section.categories[i]].title;
const wxString text = on ? title + wxString(" ") + wxString(wxUniChar(0x2022)) : title;
if (section.variant_switch->GetOptionText(static_cast<unsigned int>(i)) != text)
if (section.variant_switch->GetOptionText(static_cast<unsigned int>(i)) != text) {
section.variant_switch->SetOptionText(static_cast<unsigned int>(i), text);
section.page->Layout();
}
}
any = any || on;
}
@@ -0,0 +1,63 @@
#pragma once
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/Polygon.hpp"
#include <algorithm>
#include <cstddef>
#include <map>
#include <utility>
#include <vector>
namespace Slic3r::GUI {
struct SequentialClearanceInstance
{
double instance_height;
BoundingBox bounding_box;
Polygon hull_polygon;
ObjectID instance_id;
};
// PrintApply may group rotated copies in a different order from ModelObject::instances.
// Reorder one object's instances only when validation has assigned all of them an order.
inline void sort_sequential_clearance_instances(
std::vector<SequentialClearanceInstance>::iterator first, std::vector<SequentialClearanceInstance>::iterator last,
const std::map<ObjectID, int>& print_order)
{
if (!std::all_of(first, last, [&print_order](const SequentialClearanceInstance& instance) {
const auto order = print_order.find(instance.instance_id);
return order != print_order.end() && order->second > 0;
}))
return;
std::stable_sort(first, last, [&print_order](const SequentialClearanceInstance& lhs, const SequentialClearanceInstance& rhs) {
return print_order.at(lhs.instance_id) < print_order.at(rhs.instance_id);
});
}
// Input follows object-list order with validated print order within each object.
inline std::vector<std::pair<Polygon, float>> sequential_clearance_height_polygons(
const std::vector<SequentialClearanceInstance>& instances, double printable_height, double height_to_lid, double height_to_rod)
{
std::vector<std::pair<Polygon, float>> height_polygons;
height_polygons.reserve(instances.size());
for (size_t k = 0; k < instances.size(); ++k) {
const auto& instance = instances[k];
double height = k + 1 == instances.size() ? printable_height : height_to_lid;
for (size_t i = k + 1; i < instances.size(); ++i) {
const auto& next_bbox = instances[i].bounding_box;
if (std::min(instance.bounding_box.max.y(), next_bbox.max.y()) >
std::max(instance.bounding_box.min.y(), next_bbox.min.y())) {
height = height_to_rod;
break;
}
}
if (height < instance.instance_height)
height_polygons.emplace_back(instance.hull_polygon, float(height));
}
return height_polygons;
}
} // namespace Slic3r::GUI
+1
View File
@@ -4538,6 +4538,7 @@ void TabFilament::build()
optgroup->append_single_option_line("filament_adhesiveness_category", "material_basic_information#adhesiveness-category");
optgroup->append_single_option_line("filament_density", "material_basic_information#density");
optgroup->append_single_option_line("filament_transmission_distance", "material_basic_information#transmission-distance");
optgroup->append_single_option_line("filament_shrink", "material_basic_information#shrinkage-xy");
optgroup->append_single_option_line("filament_shrinkage_compensation_z", "material_basic_information#shrinkage-z");
optgroup->append_single_option_line("filament_cost", "material_basic_information#price");
+75 -25
View File
@@ -1,6 +1,7 @@
#include "TextureLibrary.hpp"
#include <algorithm>
#include <atomic>
#include <boost/filesystem/operations.hpp>
#include <cstddef>
#include <boost/filesystem/directory.hpp>
@@ -21,6 +22,7 @@
#include <boost/system/error_code.hpp>
#include <vector>
#include <wx/app.h>
#include <wx/gdicmn.h>
#include <utility>
#include <wx/image.h>
@@ -36,6 +38,9 @@
#include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/I18N.hpp"
#include "slic3r/plugin/PluginFsUtils.hpp"
#include "slic3r/plugin/host/PluginResourceTextures.hpp"
namespace Slic3r::GUI {
namespace {
@@ -50,7 +55,7 @@ bool is_image_file(const boost::filesystem::path &path)
return ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp";
}
void scan_dir(const boost::filesystem::path &dir, bool is_user, std::vector<TextureLibraryEntry> &out)
void scan_dir(const boost::filesystem::path &dir, TextureSource source, std::vector<TextureLibraryEntry> &out)
{
boost::system::error_code ec;
if (!boost::filesystem::is_directory(dir, ec))
@@ -60,12 +65,43 @@ void scan_dir(const boost::filesystem::path &dir, bool is_user, std::vector<Text
for (boost::filesystem::directory_iterator it(dir, ec), end; it != end && !ec; it.increment(ec)) {
if (!boost::filesystem::is_regular_file(it->path(), ec) || !is_image_file(it->path()))
continue;
out.push_back({ it->path().stem().string(), it->path().string(), is_user });
out.push_back({ it->path().stem().string(), it->path().string(), source });
}
std::sort(out.begin() + first, out.end(),
[](const TextureLibraryEntry &a, const TextureLibraryEntry &b) { return a.name < b.name; });
}
// Appends every loaded pack's textures, ordered by the pack's display name and then by file name. Each
// entry carries the contributing pack's display name, which the picker uses to group a pack's textures
// into one section; a pack name is unique among loaded packs of this capability type, so the name both
// groups the section and keeps its entries contiguous. A pack whose directory fails validation is
// skipped, so one bad pack never hides the others.
void scan_plugin_textures(std::vector<TextureLibraryEntry> &out)
{
std::vector<TextureLibraryEntry> found;
for (const ResourceTextureEntry &pack : PluginResourceTextures::instance().entries()) {
std::string error;
const std::vector<std::string> files =
collect_resource_texture_files(pack.texture_dir, pack.plugin_root, error);
if (!error.empty()) {
BOOST_LOG_TRIVIAL(warning) << "Texture pack '" << pack.display_name << "' was skipped: " << error;
continue;
}
for (const std::string &file : files) {
const boost::filesystem::path path(file);
found.push_back({ path.stem().string(), file, TextureSource::Plugin, pack.display_name });
}
}
std::stable_sort(found.begin(), found.end(), [](const TextureLibraryEntry &a, const TextureLibraryEntry &b) {
if (a.pack_name != b.pack_name)
return a.pack_name < b.pack_name;
return a.name < b.name;
});
for (TextureLibraryEntry &entry : found)
out.push_back(std::move(entry));
}
// True if the image carries any colour at all, i.e. some pixel's channels are not all equal. A
// photo saved as RGB but actually grey should still be stored as a grayscale PNG - it is a third of
// the bytes, and the colour feature keys off has_color(), so storing a grey image as RGB would offer
@@ -187,27 +223,20 @@ std::vector<unsigned char> read_file_bytes(const std::string &path)
return std::vector<unsigned char>(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
}
// True if these bytes are a PNG libslic3r can decode, i.e. can be stored on a layer as-is. Mirrors
// exactly what decode_height_texture() accepts: 8-bit grayscale, or 8-bit colour (whose luminance is
// the height and whose RGB is available to colour the model). Anything else is converted on load.
// True if decode_height_texture() can read these bytes, so they can be stored on a layer as-is.
// Anything else is converted on load.
bool is_supported_height_map(const std::vector<unsigned char> &bytes)
{
if (bytes.empty())
return false;
const png::ReadBuf rbuf{ bytes.data(), bytes.size() };
if (!png::is_png(rbuf))
return false;
png::ImageGreyscale gray;
if (png::decode_png(rbuf, gray) && gray.cols > 0 && gray.rows > 0)
return true;
png::ImageColorscale color;
return png::decode_colored_png(rbuf, color) && color.cols > 0 && color.rows > 0 &&
color.bytes_per_pixel >= 3 &&
color.buf.size() == color.cols * color.rows * size_t(color.bytes_per_pixel);
return !bytes.empty() && png::is_decodable_8bit_png(png::ReadBuf{ bytes.data(), bytes.size() });
}
std::vector<TextureLibraryEntry> g_library;
bool g_library_scanned = false;
// The generation g_library was last built from, so texture_library() can rescan after a pack change.
uint64_t g_library_scan_generation = 0;
// Bumped by plugin callbacks, read by the GUI frame. Atomic so a read never races a write.
std::atomic<uint64_t> g_library_generation{ 0 };
// Writes `bytes` into the user's own texture folder as `stem`.png, uniquified so an existing texture -
// shipped-name collision, or a second edit of the same source - is never overwritten. Returns the new
@@ -255,6 +284,8 @@ const size_t RECENT_MAX = 200;
std::vector<std::string> recent_texture_names()
{
std::vector<std::string> names;
if (wxTheApp == nullptr) // headless or shutting down: there is no app config to read
return names;
if (wxGetApp().app_config == nullptr)
return names;
const std::string value = wxGetApp().app_config->get(RECENT_SECTION, RECENT_KEY);
@@ -269,6 +300,8 @@ std::vector<std::string> recent_texture_names()
void store_recent_texture_names(std::vector<std::string> names)
{
if (wxTheApp == nullptr) // headless or shutting down: there is no app config to write
return;
if (wxGetApp().app_config == nullptr)
return;
if (names.size() > RECENT_MAX)
@@ -279,8 +312,9 @@ void store_recent_texture_names(std::vector<std::string> names)
wxGetApp().app_config->set(RECENT_SECTION, RECENT_KEY, value);
}
// Reorders the user's entries (the tail of `library`) by recent use. Stable, so textures never used keep
// the name order scan_dir() gave them, after every used one.
// Reorders the user's entries by recent use. Stable, so textures never used keep the name order
// scan_dir() gave them, after every used one. Only the user's own range is touched: shipped and pack
// entries must keep the order their scans produced.
void sort_user_textures(std::vector<TextureLibraryEntry> &library)
{
const std::vector<std::string> names = recent_texture_names();
@@ -291,8 +325,11 @@ void sort_user_textures(std::vector<TextureLibraryEntry> &library)
const auto it = rank.find(boost::filesystem::path(e.path).filename().string());
return it == rank.end() ? std::numeric_limits<int>::max() : it->second;
};
const auto first_user = std::find_if(library.begin(), library.end(), [](const TextureLibraryEntry &e) { return e.is_user; });
std::stable_sort(first_user, library.end(),
const auto first_user = std::find_if(library.begin(), library.end(),
[](const TextureLibraryEntry &e) { return e.source == TextureSource::User; });
const auto user_end = std::find_if(first_user, library.end(),
[](const TextureLibraryEntry &e) { return e.source != TextureSource::User; });
std::stable_sort(first_user, user_end,
[&rank_of](const TextureLibraryEntry &a, const TextureLibraryEntry &b) { return rank_of(a) < rank_of(b); });
}
@@ -311,6 +348,16 @@ bool in_user_texture_dir(const std::string &path)
} // namespace
uint64_t texture_library_generation()
{
return g_library_generation.load();
}
void bump_texture_library_generation()
{
g_library_generation.fetch_add(1);
}
std::string user_texture_dir()
{
const boost::filesystem::path dir = boost::filesystem::path(Slic3r::data_dir()) / "textures" / "displacement";
@@ -325,17 +372,20 @@ std::string user_texture_dir()
const std::vector<TextureLibraryEntry> &texture_library(bool force_rescan)
{
if (g_library_scanned && !force_rescan)
const uint64_t generation = texture_library_generation();
if (g_library_scanned && !force_rescan && generation == g_library_scan_generation)
return g_library;
g_library.clear();
scan_dir(boost::filesystem::path(Slic3r::resources_dir()) / "textures" / "displacement", false, g_library);
scan_dir(boost::filesystem::path(Slic3r::resources_dir()) / "textures" / "displacement", TextureSource::Shipped, g_library);
const std::string user_dir = user_texture_dir();
if (!user_dir.empty())
scan_dir(boost::filesystem::path(user_dir), true, g_library);
scan_dir(boost::filesystem::path(user_dir), TextureSource::User, g_library);
sort_user_textures(g_library);
scan_plugin_textures(g_library);
g_library_scanned = true;
g_library_scan_generation = generation;
g_library_scanned = true;
return g_library;
}
+31 -11
View File
@@ -1,6 +1,7 @@
#ifndef slic3r_TextureLibrary_hpp_
#define slic3r_TextureLibrary_hpp_
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
@@ -10,26 +11,45 @@
namespace Slic3r::GUI {
// One selectable height-map texture in the texture-displacement gizmo's texture picker.
// Where a library texture came from. The picker lists each source under its own heading.
enum class TextureSource { Shipped, User, Plugin };
// One selectable height-map texture in the texture-displacement gizmo's texture picker. A texture that
// came from a plugin pack also carries the provenance the picker groups its sections by.
struct TextureLibraryEntry
{
std::string name; // display name (the file's stem, e.g. "Wood Grain")
std::string path; // absolute path on disk
bool is_user; // imported by the user, as opposed to shipped with OrcaSlicer
std::string name; // display name (the file's stem, e.g. "Wood Grain")
std::string path; // absolute path on disk
TextureSource source; // which of the three sources it came from
std::string pack_name; // contributing pack's display name; empty for Shipped and User
};
// Every available height-map texture: the ones shipped in resources/textures/displacement first, sorted
// by name, then the user's own from <data_dir>/textures/displacement - most recently used first (see
// touch_user_texture()), then any never used, by name.
// Every available height-map texture, in the order the picker lists them: the ones shipped in
// resources/textures/displacement first, by name; then the user's own from <data_dir>/textures/displacement,
// most recently used first (see touch_user_texture()) and the rest by name; then the loaded texture
// packs, ordered by pack display name and then by file name.
//
// The two live in separate directories deliberately: an app update replaces the resources tree
// wholesale, so anything the user imported has to sit somewhere that update can never overwrite or
// delete. `is_user` is what the picker uses to show them under separate headings.
// The shipped and user textures live in separate directories deliberately: an app update replaces the
// resources tree wholesale, so anything the user imported has to sit somewhere that update can never
// overwrite or delete. Pack textures are read-only like the shipped ones. Every texture carries its
// `source`; a pack texture also carries `pack_name`, and the picker groups pack textures into one
// collapsible section per pack name. A pack's name is unique among loaded packs of this capability type -
// the loader refuses a collision with "Capability collision for type ... and name ...", see
// PluginManager::check_registry_locked (PluginManager.cpp:211-225) - so the name on its own identifies a
// section and keeps that pack's entries contiguous.
//
// Scanned once and cached. Pass force_rescan after an import, or to pick up a file the user dropped
// into either folder by hand while the app was running.
// into either folder by hand while the app was running. A rescan also happens on its own when the pack
// registry changes, which is what texture_library_generation() tracks.
const std::vector<TextureLibraryEntry> &texture_library(bool force_rescan = false);
// Monotonic counter, bumped whenever the set of loaded texture packs changes. The GUI reads it to
// decide whether texture_library() must rescan, and plugin callbacks bump it. Safe to read from any
// thread at any time.
uint64_t texture_library_generation();
void bump_texture_library_generation();
// Moves one of the user's own textures to the front of the recently-used order texture_library() lists
// them in, and stores that order in the app config so it survives a restart. No-op for anything that is
// not in user_texture_dir().
+5 -18
View File
@@ -297,12 +297,9 @@ void UVEditorCanvas::set_island_fill_colors(std::vector<ColorRGBA> colors)
Refresh();
}
void UVEditorCanvas::set_uv_transform(float tiling_scale, float tiling_scale_v, const Vec2f &mirror_sign,
float rotation_deg, bool tile_enabled, bool tile_mirrored)
void UVEditorCanvas::set_uv_transform(float tiling_scale, float rotation_deg, bool tile_enabled, bool tile_mirrored)
{
m_tiling_scale = (std::abs(tiling_scale) > 1e-6f) ? tiling_scale : 1.f;
m_tiling_scale_v = (tiling_scale_v > 1e-6f) ? tiling_scale_v : m_tiling_scale;
m_mirror_sign = mirror_sign;
m_rotation_deg = rotation_deg;
m_tile_enabled = tile_enabled;
m_tile_mirrored = tile_mirrored;
@@ -664,18 +661,11 @@ Vec2f UVEditorCanvas::island_centroid(int island) const
Vec2f UVEditorCanvas::uv_delta_to_unwrap(const Vec2f &delta_uv) const
{
// apply_uv_transform() maps unwrap -> uv as uv = S^-1 R(unwrap) + offset, with S the tile
// extents, so the inverse of a *delta* (the translation drops out) is unwrap = R^-1(S delta_uv).
// The extents therefore multiply *before* the inverse rotation, which is the mirror image of the
// forward order; with a square tile the two are interchangeable, with a rectangular one they are
// not.
// apply_uv_transform() maps unwrap -> uv as uv = R(unwrap / tiling) + offset, so the inverse of
// a *delta* (the translation drops out) is unwrap = tiling * R^-1(delta_uv).
const float rad = m_rotation_deg * float(M_PI) / 180.f;
const float cs = std::cos(rad), sn = std::sin(rad);
// The mirror is its own sign on the same axis, and a negation is its own inverse, so it comes
// back out here exactly as it went in.
const Vec2f scaled(delta_uv.x() * m_tiling_scale * m_mirror_sign.x(),
delta_uv.y() * m_tiling_scale_v * m_mirror_sign.y());
return Vec2f(scaled.x() * cs + scaled.y() * sn, -scaled.x() * sn + scaled.y() * cs);
return Vec2f(delta_uv.x() * cs + delta_uv.y() * sn, -delta_uv.x() * sn + delta_uv.y() * cs) * m_tiling_scale;
}
Vec2f UVEditorCanvas::snap_correction(int island) const
@@ -2190,10 +2180,7 @@ UVEditorPanel::UVEditorPanel(wxWindow *parent) : wxPanel(parent, wxID_ANY)
void UVEditorPanel::apply_state(const UVEditorCanvas::PaneState &s)
{
const wxString name = s.has_layer ? s.layer_name : _L("No layer mapped with Unwrap");
// A square tile reads as one number; only a genuinely rectangular one is worth two.
const wxString tile = !s.has_layer ? wxString() :
std::abs(s.tile_mm_v - s.tile_mm) < 0.005f ? wxString::Format(_L("%.1f mm tile"), s.tile_mm) :
wxString::Format(_L("%.1f x %.1f mm tile"), s.tile_mm, s.tile_mm_v);
const wxString tile = s.has_layer ? wxString::Format(_L("%.1f mm tile"), s.tile_mm) : wxString();
const bool relayout = m_layer_name->GetLabel() != name || m_tile->GetLabel() != tile;
m_layer_name->SetLabel(name);
m_tile->SetLabel(tile);
+4 -10
View File
@@ -91,10 +91,7 @@ public:
// space, back into the unwrap's mm space - which is where a TextureIsland's offset actually
// lives (see apply_uv_transform()). The tile settings come along because the background has to
// repeat exactly the way the height sampler does, or the pane would stop showing what gets baked.
// `tiling_scale_v` is the tile's extent along v; pass <= 0 for a square tile, matching
// layer_tile_mm().
void set_uv_transform(float tiling_scale, float tiling_scale_v, const Vec2f &mirror_sign,
float rotation_deg, bool tile_enabled, bool tile_mirrored);
void set_uv_transform(float tiling_scale, float rotation_deg, bool tile_enabled, bool tile_mirrored);
// Same 8-bit grayscale pixels build_texture_displacement()'s height sampling uses, shown
// beneath the wireframe (expanded to RGBA on upload) so the unwrap can be checked against the
@@ -132,8 +129,7 @@ public:
{
bool has_layer = false; // an active layer mapped with Unwrap
wxString layer_name;
float tile_mm = 0.f; // tile extent along u
float tile_mm_v = 0.f; // along v; equal to tile_mm for a square tile
float tile_mm = 0.f;
std::vector<unsigned char> thumbnail_rgb; // thumbnail_px square, 3 bytes per pixel; empty for none
int thumbnail_px = 0;
float seam_angle_deg = 40.f;
@@ -296,10 +292,8 @@ private:
GLModel m_grid_glmodel;
float m_grid_step = 0.f; // the UV step m_grid_glmodel was built for; 0 = not built
float m_tiling_scale = 1.f;
float m_tiling_scale_v = 1.f; // already resolved: never the "unset" sentinel, see set_uv_transform()
Vec2f m_mirror_sign = Vec2f::Ones(); // +1 or -1 per axis, see layer_mirror_sign()
float m_rotation_deg = 0.f;
float m_tiling_scale = 1.f;
float m_rotation_deg = 0.f;
bool m_tile_enabled = true;
bool m_tile_mirrored = false;
bool m_snap_enabled = false;
+9 -1
View File
@@ -537,7 +537,7 @@ void WebViewPanel::cloud_signed_out(const wxString& signout_url)
}
}
void WebViewPanel::set_cloud_mode(bool on, const wxString& url)
void WebViewPanel::set_cloud_mode(bool on, const wxString& url, const wxString& page_url)
{
if (on) {
// load_cloud_url records the origin from whatever it loads, including the plain cloud
@@ -546,6 +546,10 @@ void WebViewPanel::set_cloud_mode(bool on, const wxString& url)
create_cloud_browser();
m_cloud_seed_url = url;
schedule_cloud_load(url.empty() ? wxString(kCloudUrl) : url);
} else if (!page_url.empty()) {
// The pane already holds a session, so an explicit page loads straight in; the handoff is
// only needed to establish one, not to reach an ordinary page.
load_cloud_url(page_url);
} else if (!url.empty() && url != m_cloud_seed_url) {
// Re-seed only when the Start URL actually changed — a login, an account switch or a
// sign-out changes it; an ordinary re-open does not (guide §8). Reloading an unchanged
@@ -556,6 +560,10 @@ void WebViewPanel::set_cloud_mode(bool on, const wxString& url)
load_cloud_after_signout(url);
}
m_cloud_browser->Show();
// The homepage shell owns the nav highlight and the cloud-mode layout class. A native open
// (not the page's own GotoCloud) must ask it to switch; the page's SetCloudMode no-ops if it
// already did. OnDocumentLoaded re-applies this when the homepage was still loading.
RunScript("SetCloudMode(true)");
} else if (m_cloud_browser) {
// Closing keeps the pane alive with its session, so re-opening does not have to sign in
// again (guide §10). Nothing is cleared here.
+4 -1
View File
@@ -118,7 +118,10 @@ public:
// Shows/hides the native OrcaCloud pane beside this homepage nav column. `url` is the
// cloud Start URL ({web}/auth/handoff?next=…&user_id=…), which the page uses to decide
// whether it already has a session; empty falls back to the plain cloud page.
void set_cloud_mode(bool on, const wxString& url = wxString());
// `page_url` is a full {web} page URL: an already-open pane is navigated straight there,
// without re-running the handoff, which is what an explicit "open this page" wants. Empty
// keeps whatever page the pane holds.
void set_cloud_mode(bool on, const wxString& url = wxString(), const wxString& page_url = wxString());
// True while the OrcaCloud pane exists and is visible.
bool cloud_mode_active() const { return m_cloud_browser && m_cloud_browser->IsShown(); }
+58 -33
View File
@@ -38,6 +38,8 @@
#include <cstddef>
#include <wx/simplebook.h>
#include <wx/dcgraph.h>
#include <wx/graphics.h>
#include <memory>
#include <boost/log/trivial.hpp>
#include <wx/timer.h>
@@ -1409,22 +1411,17 @@ void AMSLib::render_lite_lib(wxDC& dc)
dc.SetPen(wxPen(*wxTRANSPARENT_PEN));
if (m_info.material_cols.size() > 1) {
int left = FromDIP(10);
int gwidth = std::round(libsize.x / (m_info.material_cols.size() - 1));
//gradient
wxRect color_rect(FromDIP(10), FromDIP(10), libsize.x - FromDIP(18), libsize.y - FromDIP(18));
if (m_info.ctype == 0) {
for (int i = 0; i < m_info.material_cols.size() - 1; i++) {
auto rect = wxRect(left, FromDIP(10), libsize.x - FromDIP(18), libsize.y - FromDIP(18));
fill_gradient_rect_east(dc, rect, m_info.material_cols[i], m_info.material_cols[i + 1]);
left += gwidth;
}
fill_gradient_rect_east(dc, color_rect, m_info.material_cols.front(), m_info.material_cols.back());
}
else {
int cols_size = m_info.material_cols.size();
for (int i = 0; i < cols_size; i++) {
dc.SetBrush(wxBrush(m_info.material_cols[i]));
float x = FromDIP(10) + ((float)libsize.x - FromDIP(18)) * i / cols_size;
dc.DrawRoundedRectangle(x, FromDIP(10), ((float)libsize.x - FromDIP(17)) / cols_size, libsize.y - FromDIP(20), 0);
int x = color_rect.x + color_rect.width * i / cols_size;
int next_x = color_rect.x + color_rect.width * (i + 1) / cols_size;
dc.DrawRectangle(x, color_rect.y, next_x - x, color_rect.height);
}
dc.SetBrush(wxBrush(tmp_lib_colour));
}
@@ -1516,7 +1513,27 @@ void AMSLib::render_generic_lib(wxDC &dc)
int top = height - curr_height;
if (m_ams_model == EXT_AMS){
dc.DrawRoundedRectangle(FromDIP(1), FromDIP(1), size.x - FromDIP(2), size.y - FromDIP(1), m_radius - 1);
wxRect color_rect(FromDIP(1), FromDIP(1), size.x - FromDIP(2), size.y - FromDIP(1));
if ((m_info.ctype == 0 || m_info.ctype == 1) && m_info.material_cols.size() > 1 && alpha != 0) {
if (m_info.ctype == 0) {
fill_gradient_rect_east(dc, color_rect, m_info.material_cols.front(), m_info.material_cols.back());
}
else {
int cols_size = static_cast<int>(m_info.material_cols.size());
for (int i = 0; i < cols_size; i++) {
dc.SetPen(wxPen(*wxTRANSPARENT_PEN));
dc.SetBrush(wxBrush(m_info.material_cols[i]));
int x = color_rect.x + color_rect.width * i / cols_size;
int next_x = color_rect.x + color_rect.width * (i + 1) / cols_size;
dc.DrawRectangle(x, color_rect.y, next_x - x, color_rect.height);
}
}
dc.SetPen(wxPen(*wxTRANSPARENT_PEN));
dc.SetBrush(wxBrush(tmp_lib_colour));
}
else {
dc.DrawRoundedRectangle(color_rect.x, color_rect.y, color_rect.width, color_rect.height, m_radius - 1);
}
if (alpha == 0) {
dc.DrawBitmap(m_bitmap_transparent_def.bmp(), FromDIP(2), FromDIP(2));
}
@@ -2844,30 +2861,38 @@ void AMSPreview::doRender(wxDC &dc)
//dc.DrawRoundedRectangle((size.x - rec_size.x) / 2, (size.y - rec_size.y) / 2, rec_size.x, rec_size.y, FromDIP(2));
if (iter.material_cols.size() > 1)
{
int fleft = (size.x - AMS_ITEM_CUBE_SIZE.x) / 2;
float total_width = AMS_ITEM_CUBE_SIZE.x;
int gwidth = (total_width / (iter.material_cols.size()));
if (iter.ctype == 0) {
for (int i = 0; i < iter.material_cols.size() - 1; i++) {
if ((fleft + gwidth) > (AMS_ITEM_CUBE_SIZE.x)) {
gwidth = (fleft + AMS_ITEM_CUBE_SIZE.x) - fleft;
}
auto rect = wxRect(fleft, (size.y - AMS_ITEM_CUBE_SIZE.y) / 2, gwidth, AMS_ITEM_CUBE_SIZE.y);
fill_gradient_rect_east(dc, rect, iter.material_cols[i], iter.material_cols[i + 1]);
fleft += gwidth;
}
const wxSize swatch_size = m_ams_item_type == AMSModel::N3S_AMS ? AMS_ITEM_CUBE_SIZE : AMS_ITEM_CUBE_SIZE2;
const wxRect rect((size.x - swatch_size.x) / 2, (size.y - swatch_size.y) / 2, swatch_size.x, swatch_size.y);
const int radius = m_ams_item_type == AMSModel::N3S_AMS ? 0 : FromDIP(3);
std::unique_ptr<wxGraphicsContext> owned_gc;
wxGraphicsContext* gc = dc.GetGraphicsContext();
if (!gc) {
owned_gc.reset(wxGraphicsContext::CreateFromUnknownDC(dc));
gc = owned_gc.get();
}
else {
int cols_size = iter.material_cols.size();
for (int i = 0; i < cols_size; i++) {
dc.SetPen(wxPen(*wxTRANSPARENT_PEN));
dc.SetBrush(wxBrush(iter.material_cols[i]));
float x = (size.x - AMS_ITEM_CUBE_SIZE.x) / 2 + total_width * i / cols_size;
dc.DrawRectangle(x, (size.y - AMS_ITEM_CUBE_SIZE.y) / 2, total_width / cols_size, AMS_ITEM_CUBE_SIZE.y);
if (gc) {
wxGraphicsGradientStops stops(iter.material_cols.front(), iter.material_cols.back());
const size_t count = iter.material_cols.size();
if (iter.ctype == 0) {
for (size_t i = 1; i + 1 < count; ++i)
stops.Add(iter.material_cols[i], static_cast<float>(i) / (count - 1));
} else {
// Two stops at each boundary keep multicolour bands distinct.
for (size_t i = 1; i < count; ++i) {
const float position = static_cast<float>(i) / count;
stops.Add(iter.material_cols[i - 1], position);
stops.Add(iter.material_cols[i], position);
}
}
gc->PushState();
gc->SetPen(*wxTRANSPARENT_PEN);
gc->SetBrush(gc->CreateLinearGradientBrush(rect.x, rect.y, rect.x + rect.width, rect.y, stops));
gc->DrawRoundedRectangle(rect.x, rect.y, rect.width, rect.height, radius);
gc->PopState();
} else {
dc.SetPen(*wxTRANSPARENT_PEN);
dc.SetBrush(iter.material_colour);
dc.DrawRoundedRectangle(rect, radius);
}
}
else {
+1 -1
View File
@@ -32,7 +32,7 @@ END_EVENT_TABLE()
*/
#define TAB_BUTTON_SPACE 2
#define TAB_BUTTON_PADDING_X 2
#define TAB_BUTTON_PADDING_X 3
#define TAB_BUTTON_PADDING_Y 2
#define TAB_BUTTON_PADDING TAB_BUTTON_PADDING_X, TAB_BUTTON_PADDING_Y
+27
View File
@@ -74,6 +74,10 @@ webkit_web_view_run_javascript_finish (WebKitWebView
GError **error);
WEBKIT_API void
webkit_javascript_result_unref (WebKitJavascriptResult *js_result);
WEBKIT_API guint
webkit_get_major_version (void);
WEBKIT_API guint
webkit_get_minor_version (void);
}
#endif
@@ -427,6 +431,29 @@ bool WebView::NeedsRecreateOnShow()
return false;
#endif
}
bool WebView::NeedsCompositingWorkaround()
{
#ifdef __linux__
const guint major = webkit_get_major_version();
const guint minor = webkit_get_minor_version();
return major < 2 || (major == 2 && minor < 42);
#else
return false;
#endif
}
bool WebView::SupportsForceSharedMemory()
{
#ifdef __linux__
const guint major = webkit_get_major_version();
const guint minor = webkit_get_minor_version();
return major > 2 || (major == 2 && minor >= 44);
#else
return false;
#endif
}
#if wxUSE_WEBVIEW_EDGE
bool WebView::CheckWebViewRuntime()
{
+10
View File
@@ -34,6 +34,16 @@ public:
// ignoring every navigation. A panel that gets true here recreates its view on first Show().
static bool NeedsRecreateOnShow();
// True when the linked WebKitGTK predates 2.42, where accelerated compositing
// could fail silently on XWayland sessions, so it is forced off as a fallback.
// False on every other platform. Safe to call before GTK initialises.
static bool NeedsCompositingWorkaround();
// True when the linked WebKitGTK is new enough (>= 2.44) to honour
// WEBKIT_DMABUF_RENDERER_FORCE_SHM, the shared-memory transport switch.
// False on every other platform. Safe to call before GTK initialises.
static bool SupportsForceSharedMemory();
static void RecreateAll();
};
+1
View File
@@ -529,6 +529,7 @@ std::string PluginConfig::capability_type_label(PluginCapabilityType type)
case PluginCapabilityType::Visualization: return _u8L("Visualization");
case PluginCapabilityType::Script: return _u8L("Script");
case PluginCapabilityType::SlicingPipeline: return _u8L("Slicing Pipeline");
case PluginCapabilityType::ResourceTexture: return _u8L("Resource Texture");
default: return _u8L("Unknown");
}
}
+79 -17
View File
@@ -9,6 +9,7 @@
#include <boost/filesystem/directory.hpp>
#include <boost/filesystem/file_status.hpp>
#include <boost/log/trivial.hpp>
#include <boost/algorithm/string/case_conv.hpp>
#include <boost/nowide/fstream.hpp>
#include "PluginAuditManager.hpp"
@@ -33,10 +34,6 @@
#include <unordered_set>
#include <vector>
#ifdef WIN32
#include <boost/locale/encoding_utf.hpp>
#endif
namespace Slic3r {
const char* const INSTALL_STATE_FILE = ".install_state.json";
@@ -664,7 +661,7 @@ bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boo
namespace fs = boost::filesystem;
boost::system::error_code ec;
fs::create_directories(destination, ec);
fs::create_directories(extended_path(destination), ec);
if (ec) {
error = "Failed to create plugin staging directory: " + ec.message();
return false;
@@ -708,7 +705,7 @@ bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boo
const fs::path output_path = destination / relative_path;
if (stat.m_is_directory || mz_zip_reader_is_file_a_directory(&reader.archive, stat.m_file_index)) {
fs::create_directories(output_path, ec);
fs::create_directories(extended_path(output_path), ec);
if (ec) {
error = "Failed to create plugin zip directory " + output_path.string() + ": " + ec.message();
return false;
@@ -716,26 +713,19 @@ bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boo
continue;
}
fs::create_directories(output_path.parent_path(), ec);
fs::create_directories(extended_path(output_path.parent_path()), ec);
if (ec) {
error = "Failed to create plugin zip parent directory " + output_path.parent_path().string() + ": " + ec.message();
return false;
}
if (fs::exists(output_path, ec) && fs::is_directory(output_path, ec)) {
if (fs::exists(extended_path(output_path), ec) && fs::is_directory(extended_path(output_path), ec)) {
error = "Plugin zip file conflicts with an existing directory: " + output_path.string();
return false;
}
const std::string encoded_output_path = encode_path(output_path.string().c_str());
mz_bool extracted = mz_zip_reader_extract_to_file(&reader.archive, stat.m_file_index, encoded_output_path.c_str(), 0);
#ifdef WIN32
if (!extracted) {
const std::wstring wide_output_path = boost::locale::conv::utf_to_utf<wchar_t>(output_path.generic_string());
extracted = mz_zip_reader_extract_to_file_w(&reader.archive, stat.m_file_index, wide_output_path.c_str(), 0);
}
#endif
if (!extracted) {
// Shared with the rest of the archive code: long-path capable and binary-safe.
if (!extract_zip_entry_to_file(&reader.archive, stat.m_file_index, output_path)) {
error = "Failed to extract plugin zip entry " + relative_key + ": " +
MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive));
return false;
@@ -1255,4 +1245,76 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
return {};
}
std::vector<std::string> collect_resource_texture_files(const std::string& dir,
const std::string& plugin_root,
std::string& error)
{
namespace fs = boost::filesystem;
// Picker sanity bound, not a security control: the popup draws every entry in its grid.
constexpr size_t kMaxResourceTextureFiles = 64;
std::vector<std::string> files;
error.clear();
if (dir.empty()) {
error = _u8L("Resource texture directory is not set");
return files;
}
if (plugin_root.empty()) {
error = _u8L("Plugin folder could not be determined.");
return files;
}
boost::system::error_code ec;
const fs::path resolved_dir = fs::weakly_canonical(fs::path(dir), ec);
if (ec) {
error = _u8L("Resource texture directory could not be resolved");
return files;
}
if (!fs::is_directory(resolved_dir, ec) || ec) {
error = _u8L("Resource texture path is not a directory");
return files;
}
const fs::path resolved_root = fs::weakly_canonical(fs::path(plugin_root), ec);
if (ec) {
error = _u8L("Plugin folder could not be resolved");
return files;
}
if (!is_inside_allowed_root(resolved_dir, resolved_root)) {
error = _u8L("Resource texture directory is outside the plugin folder");
return files;
}
for (fs::directory_iterator it(resolved_dir, ec), end; it != end && !ec; it.increment(ec)) {
const fs::path& path = it->path();
if (!fs::is_regular_file(path, ec) || ec)
continue;
std::string ext = path.extension().string();
boost::algorithm::to_lower(ext);
if (ext != ".png" && ext != ".jpg" && ext != ".jpeg" && ext != ".bmp")
continue;
// Re-check per file: a symlink inside the pack must not resolve outside it.
if (!is_inside_allowed_root(path, resolved_root))
continue;
files.push_back(path.string());
if (files.size() >= kMaxResourceTextureFiles)
break;
}
if (ec) {
error = _u8L("Resource texture directory could not be read");
return {};
}
return files;
}
} // namespace Slic3r
+6
View File
@@ -176,6 +176,12 @@ bool read_wheel_plugin_metadata(const boost::filesystem::path& whl_path, PluginD
// Returns the path to the entry file, or an empty path with error set if zero or multiple candidates.
boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::path& plugin_dir, std::string& error);
// Validated non-recursive .png/.jpg/.jpeg/.bmp files directly inside `dir`, every one contained in
// `plugin_root`. Empty with `error` set on failure.
std::vector<std::string> collect_resource_texture_files(const std::string& dir,
const std::string& plugin_root,
std::string& error);
// Canonical writer: emits the .install_state.json schema for the given state.
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state);
// Builds a PluginInstallState from the descriptor and delegates to the canonical writer.
+32
View File
@@ -9,6 +9,7 @@
#include "PluginFsUtils.hpp"
#include "PythonInterpreter.hpp"
#include "PythonPluginBridge.hpp"
#include "pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp"
#include "libslic3r/Utils.hpp"
#include "slic3r/GUI/I18N.hpp"
#include "slic3r/GUI/format.hpp"
@@ -387,6 +388,37 @@ bool load(const PluginDescriptor& descriptor,
instance->set_config_ui_available(false);
}
// ResourceTexture capabilities resolve their texture directory here, under the
// GIL this block already holds, and cache the validated result. A resolution
// fault only costs this capability its textures, so it is caught locally like
// has_config_ui() above rather than failing the load.
if (auto* resource_texture = dynamic_cast<ResourceTexturePluginCapability*>(instance.get())) {
try {
const std::string texture_dir = resource_texture->get_texture_dir();
if (texture_dir.empty()) {
resource_texture->set_texture_dir({});
} else {
std::string texture_error;
collect_resource_texture_files(texture_dir, descriptor.plugin_root, texture_error);
if (texture_error.empty()) {
resource_texture->set_texture_dir(texture_dir);
} else {
BOOST_LOG_TRIVIAL(warning)
<< "Plugin capability '" << found.name << "' of plugin '" << descriptor.plugin_key
<< "': texture directory rejected (" << texture_error
<< "); the capability will provide no textures";
resource_texture->set_texture_dir({});
}
}
} catch (const std::exception& ex) {
BOOST_LOG_TRIVIAL(warning)
<< "Plugin capability '" << found.name << "' of plugin '" << descriptor.plugin_key
<< "': get_texture_dir() failed (" << ex.what()
<< "); the capability will provide no textures";
resource_texture->set_texture_dir({});
}
}
capabilities.push_back(std::move(instance));
}
} catch (const std::exception& ex) {
+3
View File
@@ -25,6 +25,7 @@
#include "PyPluginTrampoline.hpp"
#include "pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp"
#include "pluginTypes/pages/PagesPluginCapability.hpp"
#include "pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp"
#include "pluginTypes/script/ScriptPluginCapability.hpp"
#include "pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
#include <pybind11/pytypes.h>
@@ -374,6 +375,7 @@ void bind_python_api(pybind11::module_& m)
.value("Visualization", PluginCapabilityType::Visualization)
.value("Script", PluginCapabilityType::Script)
.value("SlicingPipeline", PluginCapabilityType::SlicingPipeline)
.value("ResourceTexture", PluginCapabilityType::ResourceTexture)
.value("Unknown", PluginCapabilityType::Unknown)
.export_values();
@@ -527,6 +529,7 @@ void bind_python_api(pybind11::module_& m)
PagesPluginCapability::RegisterBindings(m);
ScriptPluginCapability::RegisterBindings(m);
SlicingPipelinePluginCapability::RegisterBindings(m);
ResourceTexturePluginCapability::RegisterBindings(m);
PluginHost::RegisterBindings(m);
BOOST_LOG_TRIVIAL(debug) << "Registered ScriptPluginCapability Python bindings";
+5 -1
View File
@@ -14,7 +14,7 @@
namespace Slic3r {
enum class PluginCapabilityType { PrinterConnection = 0, Pages, Analysis, Importer, Exporter, Visualization, Script, SlicingPipeline, Unknown };
enum class PluginCapabilityType { PrinterConnection = 0, Pages, Analysis, Importer, Exporter, Visualization, Script, SlicingPipeline, ResourceTexture, Unknown };
struct PluginCapabilityId
{
@@ -48,6 +48,7 @@ inline std::string plugin_capability_type_to_string(PluginCapabilityType type)
case PluginCapabilityType::Visualization: return "visualization";
case PluginCapabilityType::Script: return "script";
case PluginCapabilityType::SlicingPipeline: return "slicing-pipeline";
case PluginCapabilityType::ResourceTexture: return "resource-texture";
default: return "unknown";
}
}
@@ -63,6 +64,7 @@ inline std::string plugin_capability_type_display_name(PluginCapabilityType type
case PluginCapabilityType::Visualization: return "Visualization";
case PluginCapabilityType::Script: return "Script";
case PluginCapabilityType::SlicingPipeline: return "Slicing Pipeline";
case PluginCapabilityType::ResourceTexture: return "Resource Texture";
default: return "Unknown";
}
}
@@ -92,6 +94,8 @@ inline PluginCapabilityType plugin_capability_type_from_string(std::string_view
return PluginCapabilityType::Script;
if (lowered == "slicing-pipeline")
return PluginCapabilityType::SlicingPipeline;
if (lowered == "resource-texture")
return PluginCapabilityType::ResourceTexture;
return PluginCapabilityType::Unknown;
}
+7
View File
@@ -4,6 +4,7 @@
#include "slic3r/GUI/GUI.hpp"
#include "slic3r/GUI/Notebook.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/MainFrame.hpp"
#include "slic3r/GUI/Widgets/Button.hpp"
#include "slic3r/GUI/wxExtensions.hpp"
#include "slic3r/plugin/PluginManager.hpp"
@@ -382,6 +383,7 @@ void PluginPages::relayout()
m_parent->SelectPageByName(id_to_reselect);
}
const bool overflow_changed = need_overflow != (m_overflow_button != nullptr);
if (need_overflow) {
if (m_overflow_button == nullptr) {
auto* btn = new Button(m_parent->GetBtnsListCtrl(), wxString(L"\u25BE"), wxString(), wxNO_BORDER);
@@ -398,6 +400,11 @@ void PluginPages::relayout()
m_overflow_button->Destroy();
m_overflow_button = nullptr;
}
// The tab strip changed width; compact the labels again so the side tools stay visible.
if ((!up_to_date || overflow_changed) && !GUI::wxGetApp().is_closing())
if (GUI::MainFrame* mainframe = GUI::wxGetApp().mainframe)
mainframe->fit_tab_labels();
}
void PluginPages::select_page(const PluginCapabilityId& id)
@@ -0,0 +1,115 @@
#include "PluginResourceTextures.hpp"
#include "slic3r/plugin/PluginDescriptor.hpp"
#include "slic3r/plugin/PluginManager.hpp"
#include "slic3r/plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp"
#include <map>
#include <memory>
#include <mutex>
#include <optional>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <string>
#include <utility>
#include <vector>
namespace Slic3r {
PluginResourceTextures &PluginResourceTextures::instance()
{
static PluginResourceTextures registry;
return registry;
}
std::optional<ResourceTextureEntry> PluginResourceTextures::build_entry(const PluginCapabilityId &id) const
{
// Query by id and re-check state; the manager copies the shared_ptr and releases its lock.
auto capability = PluginManager::instance().get_plugin_capability(id, /*only_enabled=*/false);
if (!capability || !capability->is_enabled() || capability->type() != PluginCapabilityType::ResourceTexture)
return std::nullopt;
auto texture_capability = std::dynamic_pointer_cast<ResourceTexturePluginCapability>(capability);
if (!texture_capability)
return std::nullopt;
// An empty directory means resolution failed at load, so the pack contributes nothing.
const std::string &texture_dir = texture_capability->texture_dir();
if (texture_dir.empty())
return std::nullopt;
PluginDescriptor descriptor;
if (!PluginManager::instance().try_get_plugin_descriptor_for_capability(capability->name(),
PluginCapabilityType::ResourceTexture,
descriptor))
return std::nullopt;
ResourceTextureEntry entry;
entry.id = capability->identity();
entry.plugin_key = entry.id.plugin_key;
entry.display_name = capability->name();
entry.texture_dir = texture_dir;
entry.plugin_root = descriptor.plugin_root;
return entry;
}
void PluginResourceTextures::on_plugin_register(const std::string &plugin_key)
{
for (const auto &capability : PluginManager::instance().get_plugin_capabilities(plugin_key, PluginCapabilityType::ResourceTexture)) {
if (capability)
on_cap_register(capability->identity());
}
}
void PluginResourceTextures::on_plugin_deregister(const std::string &plugin_key)
{
std::lock_guard<std::mutex> lock(m_mutex);
for (auto it = m_entries.begin(); it != m_entries.end();) {
if (it->first.plugin_key == plugin_key)
it = m_entries.erase(it);
else
++it;
}
}
void PluginResourceTextures::on_cap_register(const PluginCapabilityId &id)
{
std::optional<ResourceTextureEntry> entry = build_entry(id);
std::lock_guard<std::mutex> lock(m_mutex);
if (entry)
m_entries[entry->id] = std::move(*entry);
}
void PluginResourceTextures::on_cap_deregister(const PluginCapabilityId &id)
{
std::lock_guard<std::mutex> lock(m_mutex);
m_entries.erase(id);
}
void PluginResourceTextures::initial_sweep()
{
std::map<PluginCapabilityId, ResourceTextureEntry> entries;
for (const auto &capability : PluginManager::instance().get_plugin_capabilities("", PluginCapabilityType::ResourceTexture)) {
if (!capability)
continue;
std::optional<ResourceTextureEntry> entry = build_entry(capability->identity());
if (entry)
entries[entry->id] = std::move(*entry);
}
std::lock_guard<std::mutex> lock(m_mutex);
m_entries = std::move(entries);
}
std::vector<ResourceTextureEntry> PluginResourceTextures::entries() const
{
std::lock_guard<std::mutex> lock(m_mutex);
std::vector<ResourceTextureEntry> result;
result.reserve(m_entries.size());
for (const auto &entry : m_entries)
result.push_back(entry.second);
return result;
}
} // namespace Slic3r
@@ -0,0 +1,50 @@
#pragma once
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <map>
#include <mutex>
#include <optional>
#include <string>
#include <vector>
namespace Slic3r {
// One ResourceTexture capability contributing a texture directory to the texture picker.
struct ResourceTextureEntry
{
PluginCapabilityId id;
std::string plugin_key;
std::string display_name;
std::string texture_dir;
std::string plugin_root; // so a scan can re-check containment
};
// Tracks every loaded and enabled ResourceTexture capability by id. Holds strings only: a
// shared_ptr here would keep a Python object alive past Py_Finalize.
class PluginResourceTextures
{
public:
static PluginResourceTextures &instance();
PluginResourceTextures(const PluginResourceTextures &) = delete;
PluginResourceTextures &operator=(const PluginResourceTextures &) = delete;
void on_plugin_register(const std::string &plugin_key);
void on_plugin_deregister(const std::string &plugin_key);
void on_cap_register(const PluginCapabilityId &id);
void on_cap_deregister(const PluginCapabilityId &id);
void initial_sweep();
std::vector<ResourceTextureEntry> entries() const;
private:
PluginResourceTextures() = default;
std::optional<ResourceTextureEntry> build_entry(const PluginCapabilityId &id) const;
mutable std::mutex m_mutex;
std::map<PluginCapabilityId, ResourceTextureEntry> m_entries;
};
} // namespace Slic3r
@@ -0,0 +1,32 @@
#include "ResourceTexturePluginCapability.hpp"
#include "ResourceTexturePluginCapabilityTrampoline.hpp"
#include <pybind11/pybind11.h>
#include <pybind11/pytypes.h>
#include <boost/log/trivial.hpp>
#include <memory>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <string>
namespace py = pybind11;
namespace Slic3r {
void ResourceTexturePluginCapability::RegisterBindings(pybind11::module_& module)
{
BOOST_LOG_TRIVIAL(debug) << "Registering orca.resource_texture bindings";
auto resource_texture = module.def_submodule("resource_texture", "Plugin resource texture API");
py::class_<ResourceTexturePluginCapability, PluginCapabilityInterface,
PyResourceTexturePluginCapabilityTrampoline,
std::shared_ptr<ResourceTexturePluginCapability>>(resource_texture,
"ResourceTexturePluginCapabilityBase")
.def(py::init<>())
.def("get_type", &ResourceTexturePluginCapability::get_type)
.def("get_texture_dir", &ResourceTexturePluginCapability::get_texture_dir);
}
} // namespace Slic3r
@@ -0,0 +1,28 @@
#pragma once
#include "../../PythonPluginInterface.hpp"
#include "pybind11/pybind11.h"
#include <string>
#include <utility>
namespace Slic3r {
class ResourceTexturePluginCapability : public PluginCapabilityInterface
{
public:
static void RegisterBindings(pybind11::module_& module);
PluginCapabilityType get_type() const override { return PluginCapabilityType::ResourceTexture; }
// Python contract: the directory the host scans for textures. Resolved once at materialization
// under the GIL; never called from the GUI. Overridden in Python through the trampoline.
virtual std::string get_texture_dir() const = 0;
// C++-only read of the validated directory cached by the loader; empty if resolution failed.
const std::string& texture_dir() const { return m_texture_dir; }
void set_texture_dir(std::string dir) { m_texture_dir = std::move(dir); }
private:
std::string m_texture_dir;
};
} // namespace Slic3r
@@ -0,0 +1,30 @@
#pragma once
#include "ResourceTexturePluginCapability.hpp"
#include "../../PyPluginTrampoline.hpp"
#include <pybind11/pybind11.h>
#include <pybind11/pytypes.h>
#include <string>
#include "slic3r/plugin/PythonPluginInterface.hpp"
namespace Slic3r {
class PyResourceTexturePluginCapabilityTrampoline :
public PyPluginCommonTrampoline<ResourceTexturePluginCapability>
{
public:
using PyPluginCommonTrampoline<ResourceTexturePluginCapability>::PyPluginCommonTrampoline;
std::string get_texture_dir() const override
{
ORCA_PY_OVERRIDE_AUDITED(
[] {},
PYBIND11_OVERRIDE_PURE,
std::string,
ResourceTexturePluginCapability,
get_texture_dir);
}
};
} // namespace Slic3r
@@ -163,7 +163,7 @@ M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 30s lead 30.0s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
M104 S0 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
+109
View File
@@ -1742,3 +1742,112 @@ TEST_CASE("Each filament cools with the fan speeds of its extruder variant", "[M
CHECK(gcode.find("; start range high " + std::to_string(range_high) + "\n") != std::string::npos);
}
}
TEST_CASE("Shared heater on IMEX printer stays hot until all filaments on it are done", "[MultiFilament]")
{
DynamicPrintConfig cfg = multifilament_config(7);
cfg.set_deserialize_strict({
{"ooze_prevention", 1},
{"preheat_time", 0},
{"single_extruder_multi_material", 0},
});
imex_7x4_printer(cfg);
const std::string gcode = slice_with_object_overrides({make_cube(20., 20., 10.), make_cube(20., 20., 4.), make_cube(20., 20., 10.)},
cfg,
{{{"extruder", 1}, {"layer_height", 0.30}},
{{"extruder", 2}, {"layer_height", 0.15}},
{{"extruder", 5}}});
bool premature_s0_t0 = false;
double current_z = 0.0;
GCodeReader reader;
reader.parse_buffer(gcode, [&](GCodeReader& /*self*/, const GCodeReader::GCodeLine& line) {
if (line.has_z())
current_z = line.z();
const std::string raw = line.raw();
if (raw.find("M104 S0 T0") != std::string::npos && raw.find(";cooldown") != std::string::npos) {
if (current_z < 9.0)
premature_s0_t0 = true;
}
});
// Filaments 1 and 2 share head 0, and filament 1 prints up to 10mm.
// T0 must not shut down at ~4mm when filament 2 finishes.
CHECK_FALSE(premature_s0_t0);
}
TEST_CASE("Unused extruder turns off after its final layer on multi-object print", "[MultiFilament]")
{
const std::string gcode = slice_with_object_overrides(
{ make_cube(20., 20., 10.), make_cube(20., 20., 4.) },
multifilament_config(2, {
{ "single_extruder_multi_material", 0 },
{ "ooze_prevention", 1 },
{ "standby_temperature_delta", -40 },
{ "nozzle_temperature", "240,240" },
}),
{
{ { "extruder", 1 }, { "layer_height", 0.20 } },
{ { "extruder", 2 }, { "layer_height", 0.15 } }
});
int t1_s0_cooldown_count = 0;
int t0_s0_cooldown_count = 0;
std::istringstream stream(gcode);
for (std::string line; std::getline(stream, line);) {
if (line.find(";cooldown") != std::string::npos) {
if (line.find("M104 S0 T1") != std::string::npos)
++t1_s0_cooldown_count;
if (line.find("M104 S0 T0") != std::string::npos)
++t0_s0_cooldown_count;
}
}
// T1 (extruder 2, 4mm cube) gets exactly one S0 after its last layer.
// T0 (extruder 1, 10mm cube) never gets S0 mid-print.
CHECK(t1_s0_cooldown_count == 1);
CHECK(t0_s0_cooldown_count == 0);
}
TEST_CASE("Unused extruder turns off on non-IMEX three-tool print", "[MultiFilament]")
{
const std::string gcode = slice_with_object_overrides(
{ make_cube(20., 20., 4.), make_cube(20., 20., 10.), make_cube(20., 20., 10.) },
multifilament_config(3, {
{ "single_extruder_multi_material", 0 },
{ "ooze_prevention", 1 },
{ "standby_temperature_delta", -40 },
{ "nozzle_temperature", "240,240,240" },
}),
{
{ { "extruder", 1 } },
{ { "extruder", 2 } },
{ { "extruder", 3 } }
});
bool premature_s0_t1 = false;
bool premature_s0_t2 = false;
double current_z = 0.0;
GCodeReader reader;
reader.parse_buffer(gcode, [&](GCodeReader& /*self*/, const GCodeReader::GCodeLine& line) {
if (line.has_z())
current_z = line.z();
const std::string raw = line.raw();
if (raw.find(";cooldown") != std::string::npos && current_z < 9.0) {
if (raw.find("M104 S0 T1") != std::string::npos)
premature_s0_t1 = true;
if (raw.find("M104 S0 T2") != std::string::npos)
premature_s0_t2 = true;
}
});
// T1 and T2 print up to 10mm — neither should get S0 before that.
CHECK_FALSE(premature_s0_t1);
CHECK_FALSE(premature_s0_t2);
}
+1
View File
@@ -37,6 +37,7 @@ add_executable(${_TEST_NAME}_tests
test_fill_plane_path.cpp
test_fill_tpms_adaptive.cpp
test_geometry.cpp
test_chromastack.cpp
test_imex_arrange.cpp
test_imex_helpers.cpp
test_imex_zones.cpp
+379
View File
@@ -0,0 +1,379 @@
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <vector>
#include "libslic3r/Color.hpp"
#include "libslic3r/ChromaStack.hpp"
#include "libslic3r/PNGReadWrite.hpp"
using namespace Slic3r;
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;
namespace {
ChromaFilament filament(int index, const ColorRGB &color, float td_mm)
{
ChromaFilament f;
f.filament = index;
f.color = color;
f.td_mm = td_mm;
return f;
}
ChromaBand band(const ChromaFilament &f, int layers)
{
ChromaBand b;
b.filament = f;
b.layers = layers;
return b;
}
// An opaque filament (TD 1 hides what is under it within 0.1 mm) and a translucent one (TD 8 needs
// most of a millimetre), which is the whole range the model has to behave sensibly over.
const ChromaFilament WHITE_OPAQUE = filament(0, ColorRGB(1.f, 1.f, 1.f), 1.f);
const ChromaFilament BLACK_OPAQUE = filament(1, ColorRGB(0.f, 0.f, 0.f), 1.f);
const ChromaFilament BLACK_TRANSLUCENT = filament(2, ColorRGB(0.f, 0.f, 0.f), 8.f);
float luminance(const ColorRGB &c) { return 0.2126f * c.r() + 0.7152f * c.g() + 0.0722f * c.b(); }
} // namespace
TEST_CASE("A stack's palette has one entry per printed layer", "[ChromaStack]")
{
ChromaStack stack;
stack.layer_height_mm = 0.08f;
stack.base_layers = 4;
stack.bands = { band(WHITE_OPAQUE, 2), band(BLACK_TRANSLUCENT, 10) };
// The base is backing under the picture, not part of a band: the bands are the 12 layers the
// picture is built from, and the stack is those on top of the 4 the model already has.
REQUIRE(stack.relief_layers() == 12);
REQUIRE(stack.total_layers() == 16);
REQUIRE_THAT(stack.depth_mm(), WithinAbs(12 * 0.08f, 1e-6f));
const std::vector<ColorRGB> palette = chroma_palette(stack);
REQUIRE(palette.size() == 17);
// Index 0 is the base's own colour, so a column one layer tall is exactly its filament - whatever
// that filament's transmission distance.
REQUIRE(palette[0] == palette[1]);
REQUIRE_THAT(luminance(palette[1]), WithinAbs(1.f, 1e-3f));
}
TEST_CASE("A run of one filament gives the same palette however it is split into bands", "[ChromaStack]")
{
// The property that justifies compositing layer by layer at all: transmittance multiplies, so
// cutting a run in two must not change the colours. A model without it would make the palette
// depend on how the stack was typed in.
ChromaStack one;
one.layer_height_mm = 0.1f;
one.base_layers = 0;
one.bands = { band(WHITE_OPAQUE, 2), band(BLACK_TRANSLUCENT, 8) };
ChromaStack split = one;
split.bands = { band(WHITE_OPAQUE, 2), band(BLACK_TRANSLUCENT, 3), band(BLACK_TRANSLUCENT, 5) };
const std::vector<ColorRGB> a = chroma_palette(one);
const std::vector<ColorRGB> b = chroma_palette(split);
REQUIRE(a.size() == b.size());
for (size_t i = 0; i < a.size(); ++i)
REQUIRE_THAT(luminance(a[i]), WithinAbs(luminance(b[i]), 1e-5f));
}
TEST_CASE("A translucent filament darkens gradually and an opaque one at once", "[ChromaStack]")
{
ChromaStack translucent;
translucent.layer_height_mm = 0.08f;
translucent.base_layers = 0;
translucent.bands = { band(WHITE_OPAQUE, 1), band(BLACK_TRANSLUCENT, 12) };
const std::vector<ColorRGB> slow = chroma_palette(translucent);
// Monotonically darker, and still short of the filament's own black after a single layer: that
// gradient is what a ChromaStack stack prints its midtones with.
for (size_t i = 2; i < slow.size(); ++i)
REQUIRE(luminance(slow[i]) < luminance(slow[i - 1]));
REQUIRE(luminance(slow[2]) > 0.5f);
// Twelve layers of a TD 8 filament pass under 3% of the light, which is the dark end of the ramp
// rather than true black - and that is the honest prediction, not a rounding artifact.
REQUIRE(luminance(slow.back()) < 0.25f);
ChromaStack opaque;
opaque.layer_height_mm = 0.08f;
opaque.base_layers = 0;
opaque.bands = { band(WHITE_OPAQUE, 1), band(BLACK_OPAQUE, 12) };
const std::vector<ColorRGB> fast = chroma_palette(opaque);
// TD 1 is opaque within about 0.1 mm, so two layers of it leave little of the white showing.
REQUIRE(luminance(fast[3]) < 0.2f);
}
TEST_CASE("Solving a picture of the palette's own colors reproduces it exactly", "[ChromaStack]")
{
ChromaStack stack;
stack.layer_height_mm = 0.08f;
stack.base_layers = 2;
stack.bands = { band(WHITE_OPAQUE, 2), band(BLACK_TRANSLUCENT, 8) };
const std::vector<ColorRGB> palette = chroma_palette(stack);
const int relief = stack.relief_layers();
// One pixel per reachable height, in order.
std::vector<uint8_t> rgb;
for (int i = 0; i <= relief; ++i) {
const ColorRGB &c = palette[size_t(stack.base_layers + i)];
rgb.push_back(uint8_t(std::lround(c.r() * 255.f)));
rgb.push_back(uint8_t(std::lround(c.g() * 255.f)));
rgb.push_back(uint8_t(std::lround(c.b() * 255.f)));
}
const ChromaSolve solve = chroma_solve(rgb.data(), relief + 1, 1, stack);
REQUIRE_FALSE(solve.empty());
REQUIRE(solve.width == relief + 1);
REQUIRE(solve.levels.size() == size_t(relief + 1));
// Every pixel comes back as the colour it was generated from, and the error is rounding only.
//
// The colour rather than the height: the bottom band prints in the same filament as the base, so
// its first layers show exactly the colour the base already showed, and any of those heights is a
// correct answer for that pixel. Asserting the height would be asserting which of several equally
// right answers the search happens to reach first.
for (int i = 0; i <= relief; ++i) {
const int chosen = int(solve.levels[size_t(i)]);
REQUIRE(chosen >= stack.base_layers);
REQUIRE(chosen <= stack.total_layers());
const ColorRGB &got = palette[size_t(chosen)];
const ColorRGB &want = palette[size_t(stack.base_layers + i)];
REQUIRE_THAT(got.r(), WithinAbs(want.r(), 2.f / 255.f));
REQUIRE_THAT(got.g(), WithinAbs(want.g(), 2.f / 255.f));
REQUIRE_THAT(got.b(), WithinAbs(want.b(), 2.f / 255.f));
}
// Only 8-bit rounding of the palette separates the two, which near black is still under one dE.
REQUIRE(solve.max_delta_e < 2.);
// The height map's ends are the base and the top of the stack, which is what ties a grey level to
// a printed layer.
REQUIRE(solve.grey.front() == 0);
REQUIRE(solve.grey.back() == 255);
}
TEST_CASE("A picture the filaments cannot reach is reported as a poor match", "[ChromaStack]")
{
ChromaStack stack;
stack.layer_height_mm = 0.08f;
stack.base_layers = 2;
stack.bands = { band(WHITE_OPAQUE, 2), band(BLACK_TRANSLUCENT, 8) };
// Saturated red against a stack of white and black: no height can produce it, and the panel's
// warning depends on the solve saying so rather than silently picking a grey.
const std::vector<uint8_t> red{ 255, 0, 0 };
const ChromaSolve solve = chroma_solve(red.data(), 1, 1, stack);
REQUIRE_FALSE(solve.empty());
REQUIRE(solve.mean_delta_e > 15.);
}
TEST_CASE("Dithering keeps every pixel in the stack's range", "[ChromaStack]")
{
ChromaStack stack;
stack.layer_height_mm = 0.08f;
stack.base_layers = 2;
stack.bands = { band(WHITE_OPAQUE, 2), band(BLACK_TRANSLUCENT, 8) };
stack.dither = true;
// A flat mid-grey: the error diffusion has to spread it over neighbouring heights without ever
// leaving the stack, which is what a clamped palette index would quietly get wrong.
std::vector<uint8_t> grey(size_t(16 * 16) * 3, 128);
const ChromaSolve solve = chroma_solve(grey.data(), 16, 16, stack);
REQUIRE_FALSE(solve.empty());
REQUIRE(solve.levels.size() == size_t(16 * 16));
const auto [lo, hi] = std::minmax_element(solve.levels.begin(), solve.levels.end());
REQUIRE(int(*lo) >= stack.base_layers);
REQUIRE(int(*hi) <= stack.total_layers());
}
TEST_CASE("The solved height map is an 8-bit grayscale PNG of the picture's size", "[ChromaStack]")
{
// The displacement pipeline reads one thing from a layer: an 8-bit grayscale PNG. A solve that
// produced anything else would leave the relief silently flat.
ChromaStack stack;
stack.layer_height_mm = 0.08f;
stack.base_layers = 2;
stack.bands = { band(WHITE_OPAQUE, 2), band(BLACK_TRANSLUCENT, 6) };
std::vector<uint8_t> picture(size_t(8 * 4) * 3);
for (size_t i = 0; i < picture.size(); ++i)
picture[i] = uint8_t(i % 256);
const ChromaSolve solve = chroma_solve(picture.data(), 8, 4, stack);
const std::vector<unsigned char> png = chroma_encode_height_png(solve);
REQUIRE_FALSE(png.empty());
const png::ReadBuf buf{ png.data(), png.size() };
png::ImageGreyscale decoded;
REQUIRE(png::is_png(buf));
REQUIRE(png::decode_png(buf, decoded));
REQUIRE(decoded.cols == 8);
REQUIRE(decoded.rows == 4);
REQUIRE(decoded.buf == solve.grey);
}
TEST_CASE("Each band becomes a height range, and a repeated filament is not a swap", "[ChromaStack]")
{
ChromaStack stack;
stack.layer_height_mm = 0.1f;
stack.base_layers = 0;
stack.bands = { band(WHITE_OPAQUE, 5), band(BLACK_OPAQUE, 3), band(BLACK_OPAQUE, 2) };
const std::vector<ChromaRange> ranges = chroma_ranges(stack, 0.);
// Three bands, two filaments: the second and third merge, because nothing is swapped between them.
REQUIRE(ranges.size() == 2);
REQUIRE_THAT(ranges[0].z_min, WithinAbs(0., 1e-9));
REQUIRE_THAT(ranges[0].z_max, WithinAbs(0.5, 1e-9));
REQUIRE(ranges[0].filament == WHITE_OPAQUE.filament);
REQUIRE_THAT(ranges[1].z_min, WithinAbs(0.5, 1e-9));
REQUIRE_THAT(ranges[1].z_max, WithinAbs(1.0, 1e-9));
REQUIRE(ranges[1].filament == BLACK_OPAQUE.filament);
}
TEST_CASE("A default stack puts the lightest filament at the bottom", "[ChromaStack]")
{
// Light to dark is what gives the long tonal ramp; the order is the one thing a seeded stack has
// to get right, since everything else is a layer count the user can drag.
const std::vector<ChromaFilament> loaded{ BLACK_OPAQUE, WHITE_OPAQUE,
filament(3, ColorRGB(0.5f, 0.5f, 0.5f), 4.f) };
const ChromaStack stack = chroma_default_stack(loaded, 0.08f, 4, 24);
REQUIRE(stack.bands.size() == 3);
REQUIRE(stack.bands.front().filament.filament == WHITE_OPAQUE.filament);
REQUIRE(stack.bands.back().filament.filament == BLACK_OPAQUE.filament);
REQUIRE(stack.base_layers == 4);
// The base is printed in the bottom band, so the relief is what was asked for and the base sits
// underneath it.
REQUIRE(stack.total_layers() == 4 + 24);
REQUIRE(stack.relief_layers() == 24);
REQUIRE_THAT(stack.layer_height_mm, WithinRel(0.08f));
}
TEST_CASE("An empty stack or picture solves to nothing", "[ChromaStack]")
{
ChromaStack empty;
REQUIRE(chroma_palette(empty).empty());
REQUIRE(chroma_ranges(empty, 0.).empty());
// A base with nothing on it: there is no picture to solve, and no filament to print the base in.
ChromaStack base_only;
base_only.layer_height_mm = 0.08f;
base_only.base_layers = 4;
REQUIRE(base_only.relief_layers() == 0);
REQUIRE(chroma_palette(base_only).empty());
const std::vector<uint8_t> pixel{ 128, 128, 128 };
REQUIRE(chroma_solve(pixel.data(), 1, 1, base_only).empty());
ChromaStack usable;
usable.layer_height_mm = 0.08f;
usable.base_layers = 2;
usable.bands = { band(WHITE_OPAQUE, 2), band(BLACK_OPAQUE, 4) };
REQUIRE(chroma_solve(nullptr, 4, 4, usable).empty());
}
TEST_CASE("The base extends the bottom band's range down to the first layer", "[ChromaStack]")
{
// The base is not a swap of its own: it prints in whatever is loaded at the start, which is the
// bottom band's filament, so the first range has to reach the build plate.
ChromaStack stack;
stack.layer_height_mm = 0.1f;
stack.base_layers = 20;
stack.bands = { band(WHITE_OPAQUE, 5), band(BLACK_OPAQUE, 5) };
const std::vector<ChromaRange> ranges = chroma_ranges(stack, 0.);
REQUIRE(ranges.size() == 2);
REQUIRE_THAT(ranges[0].z_min, WithinAbs(0., 1e-9));
REQUIRE_THAT(ranges[0].z_max, WithinAbs(2.5, 1e-9)); // 20 base + 5 band layers at 0.1 mm
REQUIRE_THAT(ranges[1].z_min, WithinAbs(2.5, 1e-9));
REQUIRE_THAT(ranges[1].z_max, WithinAbs(3.0, 1e-9));
REQUIRE(ranges.back().z_max == Catch::Approx(double(stack.total_layers()) * double(stack.layer_height_mm)));
}
TEST_CASE("A surface's color follows the printed layer it sits at", "[ChromaStack]")
{
// What the preview colours by: a swap happens at one print Z, so the layer a surface sits in is
// what decides its colour, whatever the height map says.
ChromaStack stack;
stack.layer_height_mm = 0.1f;
stack.base_layers = 2;
stack.bands = { band(WHITE_OPAQUE, 3), band(BLACK_OPAQUE, 5) };
REQUIRE(chroma_level_at(stack, 0.05) == 1); // inside the first layer
REQUIRE(chroma_level_at(stack, 0.10) == 1); // exactly its top, which that layer printed
REQUIRE(chroma_level_at(stack, 0.15) == 2);
REQUIRE(chroma_level_at(stack, 0.50) == 5); // the top of the base plus the white band
// Clamped at both ends: below the plate and above the stack there is nothing else to show.
REQUIRE(chroma_level_at(stack, -1.) == 1);
REQUIRE(chroma_level_at(stack, 99.) == stack.total_layers());
}
TEST_CASE("How fast a filament covers is its TD against the layer height", "[ChromaStack]")
{
// The number that decides whether a band is a gradient or a step, and the one the panel reports:
// a filament hides what is under it at about a tenth of its TD, so how many layers that is
// depends entirely on how thick they are.
REQUIRE_THAT(chroma_layers_to_opaque(4.f, 0.2f), WithinRel(2.f, 1e-4f));
REQUIRE_THAT(chroma_layers_to_opaque(4.f, 0.08f), WithinRel(5.f, 1e-4f));
REQUIRE_THAT(chroma_layers_to_opaque(8.f, 0.08f), WithinRel(10.f, 1e-4f));
// An opaque filament at a coarse layer height covers within one layer, which is where a stack
// stops having any colours between its filaments at all.
REQUIRE(chroma_layers_to_opaque(1.f, 0.2f) < 1.f);
REQUIRE_THAT(chroma_layers_to_opaque(4.f, 0.f), WithinAbs(0.f, 1e-6f));
}
TEST_CASE("A finer layer height buys colors between the filaments", "[ChromaStack]")
{
// The whole of the user-visible problem in one assertion: the same stack, the same filaments, two
// layer heights. The coarse one steps from white to black in two layers; the fine one fades.
const auto distinct = [](float layer_height) {
ChromaStack stack;
stack.layer_height_mm = layer_height;
stack.base_layers = 0;
stack.bands = { band(WHITE_OPAQUE, 1), band(filament(5, ColorRGB(0.f, 0.f, 0.f), 8.f), 10) };
const std::vector<ColorRGB> palette = chroma_palette(stack);
int n = 0;
for (size_t i = 1; i < palette.size(); ++i)
if (std::abs(luminance(palette[i]) - luminance(palette[i - 1])) > 0.02f)
++n;
return n;
};
REQUIRE(distinct(0.08f) > distinct(0.3f));
}
TEST_CASE("Layers past a filament's covering depth add no color", "[ChromaStack]")
{
// The band is twice as long as the filament needs to hide what is under it, so half of it shows a
// colour the layer below already showed. That is invisible in the stack and obvious here.
ChromaStack stack;
stack.layer_height_mm = 0.08f;
stack.base_layers = 0;
stack.bands = { band(WHITE_OPAQUE, 1), band(filament(5, ColorRGB(0.f, 0.f, 0.f), 4.f), 10) };
// Trimming keeps every layer that still changes the colour and drops the rest, so the stack gets
// shorter and loses none of its colours.
const int distinct = chroma_distinct_levels(stack);
const int before = stack.relief_layers();
chroma_trim_bands(stack);
REQUIRE(stack.relief_layers() <= before);
REQUIRE(chroma_distinct_levels(stack) == distinct);
// The white band is a single layer and has nothing to give up.
REQUIRE(stack.bands.front().layers == 1);
// A light band over a dark one is the case where layers really are wasted: it saturates within a
// few layers, and the rest of a long band shows what the layer below already showed.
ChromaStack light_over_dark;
light_over_dark.layer_height_mm = 0.08f;
light_over_dark.base_layers = 0;
light_over_dark.bands = { band(filament(5, ColorRGB(0.f, 0.f, 0.f), 4.f), 1), band(WHITE_OPAQUE, 14) };
const int kept = chroma_distinct_levels(light_over_dark);
chroma_trim_bands(light_over_dark);
REQUIRE(light_over_dark.bands.back().layers < 14);
REQUIRE(chroma_distinct_levels(light_over_dark) == kept);
}
+50
View File
@@ -18,6 +18,7 @@
#include <fstream>
#include <ios>
#include <iterator>
#include <libslic3r/Utils.hpp>
#include <miniz.h>
#include <string>
#include <utility>
@@ -176,6 +177,55 @@ TEST_CASE("Confined extraction writes nothing outside the target for Windows-spe
CHECK(list_dir(tmp.path()) == std::vector<std::string>{"bundle.zip", "cache"});
}
TEST_CASE("Confined extraction writes files under a target path longer than MAX_PATH", "[MinizExtension]")
{
ScopedTemporaryDir tmp;
const fs::path zip_file = tmp.path() / "bundle.zip";
// Directory entries are explicit: extract_archive_confined recreates directories only for them.
write_zip(zip_file, {{"vendor/", ""}, {"vendor/machine/", ""}, {"vendor.json", "{\"a\":1}"},
{"vendor/machine/printer.json", "{\"b\":2}"}});
// One 240-char component keeps every name below the NTFS 255 limit while pushing the full
// target path past the 260-char MAX_PATH the Win32 APIs enforce when LongPathsEnabled is off.
const fs::path target = tmp.path() / std::string(240, 'd');
REQUIRE(target.string().size() > 260);
fs::create_directories(extended_path(target));
REQUIRE(extract_archive_confined(zip_file.string(), target.string()));
// Verify through extended-length paths, otherwise the checks themselves are bounded by MAX_PATH.
CHECK(fs::is_directory(extended_path(target / "vendor")));
CHECK(read_file(extended_path(target / "vendor.json")) == "{\"a\":1}");
CHECK(read_file(extended_path(target / "vendor" / "machine" / "printer.json")) == "{\"b\":2}");
// The temporary dir guard cannot remove a deep tree on Windows, so drop it here.
boost::system::error_code ec;
fs::remove_all(extended_path(target), ec);
CHECK_FALSE(ec);
}
TEST_CASE("A zip archive under a path longer than MAX_PATH can be written and read", "[MinizExtension]")
{
ScopedTemporaryDir tmp;
// Create the long directory with the extended-length path, then exercise the archive
// reader/writer against an archive whose own path is past MAX_PATH.
const fs::path long_dir = tmp.path() / std::string(240, 'z');
fs::create_directories(extended_path(long_dir));
const fs::path zip_file = long_dir / "bundle.zip";
REQUIRE(zip_file.string().size() > 260);
write_zip(zip_file, {{"vendor.json", "{\"a\":1}"}});
const fs::path target = tmp.path() / "out";
fs::create_directories(target);
REQUIRE(extract_archive_confined(zip_file.string(), target.string()));
CHECK(read_file(target / "vendor.json") == "{\"a\":1}");
boost::system::error_code ec;
fs::remove_all(extended_path(long_dir), ec);
CHECK_FALSE(ec);
}
#ifndef _WIN32
TEST_CASE("Confined extraction replaces a symlink at the destination instead of writing through it", "[MinizExtension]")
{
+46
View File
@@ -521,3 +521,49 @@ TEST_CASE("ascii_iequals compares ASCII letters regardless of case", "[Utils]")
CHECK_FALSE(ascii_iequals("[", "{"));
CHECK_FALSE(ascii_iequals("@", "`"));
}
TEST_CASE("extended_path turns a Windows path into its long-path form", "[Utils]") {
#ifdef _WIN32
namespace fs = boost::filesystem;
SECTION("a normal absolute drive path is prefixed") {
CHECK(extended_path(fs::path(R"(C:\models\model.3mf)")).wstring() == LR"(\\?\C:\models\model.3mf)");
}
SECTION("separators are made native") {
CHECK(extended_path(fs::path("C:/models/model.3mf")).wstring() == LR"(\\?\C:\models\model.3mf)");
}
SECTION("dot segments are collapsed before prefixing") {
// The "\\?\" prefix disables Win32 normalisation, so "." and ".." must be gone beforehand.
CHECK(extended_path(fs::path(R"(C:\models\..\model.3mf)")).wstring() == LR"(\\?\C:\model.3mf)");
}
SECTION("a trailing separator is dropped, not turned into a dot element") {
CHECK(extended_path(fs::path(R"(C:\models\)")).wstring() == LR"(\\?\C:\models)");
}
SECTION("a UNC path becomes an extended UNC path") {
CHECK(extended_path(fs::path(R"(\\server\share\model.3mf)")).wstring() == LR"(\\?\UNC\server\share\model.3mf)");
}
SECTION("an already extended path is returned unchanged") {
const fs::path extended(R"(\\?\C:\models\model.3mf)");
CHECK(extended_path(extended) == extended);
}
SECTION("a device path is returned unchanged") {
const fs::path device(R"(\\.\C:\models\model.3mf)");
CHECK(extended_path(device) == device);
}
SECTION("a bare device path without a root directory is returned unchanged") {
// Not "absolute" by Boost's rules, so it must be recognized before absolutizing.
const fs::path device(R"(\\.\PhysicalDrive0)");
CHECK(extended_path(device) == device);
}
SECTION("a relative path is completed against the working directory") {
const fs::path result = extended_path(fs::path("model.3mf"));
CHECK(result.wstring().rfind(L"\\\\?\\", 0) == 0);
CHECK(result.filename() == fs::path("model.3mf"));
}
#else
SECTION("non-Windows returns the path unchanged") {
const boost::filesystem::path p = boost::filesystem::temp_directory_path() / "model.3mf";
CHECK(extended_path(p) == p);
}
#endif
}
+3
View File
@@ -32,6 +32,7 @@ add_executable(${_TEST_NAME}_tests
test_printer_agent.cpp
test_plugin_install.cpp
test_plugin_lifecycle.cpp
test_plugin_resource_texture.cpp
test_plugin_printer_agent.cpp
test_printhost.cpp
test_slicing_pipeline_bindings.cpp
@@ -44,7 +45,9 @@ add_executable(${_TEST_NAME}_tests
test_texture_color_palette.cpp
test_texture_island_view.cpp
test_file_url.cpp
test_imgui_wrapper.cpp
test_user_manager.cpp
test_sequential_clearance.cpp
../fff_print/test_helpers.cpp
)
@@ -1,5 +1,6 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_all.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <slic3r/GUI/DeviceCore/DevManager.h>
#include <slic3r/GUI/DeviceManager.hpp>
@@ -16,6 +17,7 @@
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
using namespace Slic3r;
using json = nlohmann::json;
@@ -49,6 +51,21 @@ public:
AgentInfo get_agent_info() override { return m_info; }
int send_message(std::string dev_id, std::string json_str, int, int) override
{
published_device = std::move(dev_id);
published_message = json::parse(json_str);
return 0;
}
int send_message_to_printer(std::string dev_id, std::string json_str, int qos, int flag) override
{
return send_message(std::move(dev_id), std::move(json_str), qos, flag);
}
std::string published_device;
json published_message;
private:
AgentInfo m_info;
};
@@ -160,3 +177,62 @@ TEST_CASE("Device manager filters and rehomes devices by printer-agent ownership
CHECK(manager.get_my_machine_list("integration-agent-a").empty());
CHECK(manager.get_my_machine_list("integration-agent-b").count(machine.dev_id) == 1);
}
TEST_CASE("AMS filament settings keep legacy single-color commands compatible", "[DeviceManager][AMSMaterialsSetting][integration]")
{
ScopedAppConfig app_config;
auto agent = std::make_shared<TestPrinterAgent>("integration-agent");
NetworkAgent network(nullptr, agent);
DeviceManager manager(&network, false, &app_config.config);
MachineObject object(&manager, &network, "Test printer", "integration-device", "192.0.2.10");
object.dev_connection_type = GENERATE("lan", "cloud");
REQUIRE(object.command_ams_filament_settings(1, 2, "filament-id", "setting-id", "FF000080", "PLA", 190, 220) == 0);
const json& payload = agent->published_message.at("print");
CHECK(agent->published_device == object.get_dev_id());
CHECK(payload.at("command") == "ams_filament_setting");
CHECK(payload.at("ams_id") == 1);
CHECK(payload.at("slot_id") == 2);
CHECK(payload.at("tray_color") == "FF000080");
CHECK_FALSE(payload.contains("cols"));
CHECK_FALSE(payload.contains("ctype"));
}
TEST_CASE("AMS filament settings preserve ordered gradient and multicolor RGBA components", "[DeviceManager][AMSMaterialsSetting][integration]")
{
ScopedAppConfig app_config;
auto agent = std::make_shared<TestPrinterAgent>("integration-agent");
NetworkAgent network(nullptr, agent);
DeviceManager manager(&network, false, &app_config.config);
MachineObject object(&manager, &network, "Test printer", "integration-device", "192.0.2.10");
object.dev_connection_type = GENERATE("lan", "cloud");
const int color_type = GENERATE(0, 1);
const std::vector<std::string> colors{"FF000080", "0000FF40", "00FF00FF"};
REQUIRE(object.command_ams_filament_settings(1, 2, "filament-id", "setting-id", colors.front(), "PLA", 190, 220,
colors, color_type) == 0);
const json& payload = agent->published_message.at("print");
CHECK(payload.at("tray_color") == colors.front());
CHECK(payload.at("cols").get<std::vector<std::string>>() == colors);
CHECK(payload.at("ctype") == color_type);
}
TEST_CASE("AMS filament settings default a supplied single-color list to type two", "[DeviceManager][AMSMaterialsSetting][integration]")
{
ScopedAppConfig app_config;
auto agent = std::make_shared<TestPrinterAgent>("integration-agent");
NetworkAgent network(nullptr, agent);
DeviceManager manager(&network, false, &app_config.config);
MachineObject object(&manager, &network, "Test printer", "integration-device", "192.0.2.10");
object.dev_connection_type = GENERATE("lan", "cloud");
const std::vector<std::string> colors{"FF000080"};
REQUIRE(object.command_ams_filament_settings(1, 2, "filament-id", "setting-id", colors.front(), "PLA", 190, 220,
colors) == 0);
const json& payload = agent->published_message.at("print");
CHECK(payload.at("cols").get<std::vector<std::string>>() == colors);
CHECK(payload.at("ctype") == 2);
}
+61
View File
@@ -0,0 +1,61 @@
// How ImGuiWrapper turns wx mouse events into ImGui input. ImGuiWrapper lives in libslic3r_gui; this is
// the suite that links it. Same Windows include prologue as test_texture_color_palette.cpp (wx pulls in
// <windows.h>; keep WIN32_LEAN_AND_MEAN / NOMINMAX ahead of it).
#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#endif
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <imgui/imgui.h>
#include <wx/event.h>
#include "slic3r/GUI/ImGuiWrapper.hpp"
using Catch::Matchers::WithinAbs;
using Slic3r::GUI::ImGuiWrapper;
namespace {
// ImGui's wheel after one wheel event, as wx on macOS builds it: a delta of 10 and the rotation in whole
// points of trackpad travel.
float wheel_after(int rotation, wxMouseWheelAxis axis)
{
ImGuiWrapper imgui;
imgui.set_display_size(800.f, 600.f); // update_mouse_data() ignores input until the display has a size
wxMouseEvent evt(wxEVT_MOUSEWHEEL);
evt.m_wheelRotation = rotation;
evt.m_wheelDelta = 10;
evt.m_wheelAxis = axis;
imgui.update_mouse_data(evt);
return ImGui::GetIO().MouseWheel;
}
} // namespace
TEST_CASE("a vertical wheel event steps ImGui's wheel in its own direction", "[ImGuiWrapper]")
{
const int rotation = GENERATE(1, 7, 120, -1, -7, -120);
CHECK_THAT(wheel_after(rotation, wxMOUSE_WHEEL_VERTICAL), WithinAbs(rotation > 0 ? 1.0 : -1.0, 0.0));
}
// Both kinds arrive from a macOS trackpad while scrolling; see ImGuiWrapper::update_mouse_data().
TEST_CASE("a wheel event with no rotation or on the horizontal axis does not step ImGui's wheel", "[ImGuiWrapper][Regression]")
{
const auto [rotation, axis] = GENERATE(table<int, wxMouseWheelAxis>({
{ 0, wxMOUSE_WHEEL_VERTICAL },
{ 5, wxMOUSE_WHEEL_HORIZONTAL },
{ -5, wxMOUSE_WHEEL_HORIZONTAL },
}));
CHECK_THAT(wheel_after(rotation, axis), WithinAbs(0.0, 0.0));
}
+69
View File
@@ -3,6 +3,7 @@
#include <catch2/catch_all.hpp>
#include <ios>
#include <libslic3r/Utils.hpp>
#include <slic3r/plugin/PluginLoader.hpp>
#include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PluginFsUtils.hpp>
@@ -10,13 +11,17 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include "catch2/catch_message.hpp"
#include "plugin_test_utils.hpp"
#include "test_utils.hpp"
#include <boost/filesystem.hpp>
#include <fstream>
#include <string>
#include <libslic3r/Zipper.hpp>
using namespace Slic3r;
namespace fs = boost::filesystem;
@@ -31,6 +36,14 @@ fs::path write_py_file(const fs::path& dir, const std::string& filename, const s
return p;
}
// The shared production helper: makes the path absolute and lexically normalized and, on
// Windows, prefixes it with the extended-length "\\?\" form, so the test can create, verify and
// clean up a tree past MAX_PATH even with LongPathsEnabled off. Identity on other platforms.
fs::path to_extended_path(const fs::path& p)
{
return Slic3r::extended_path(p);
}
} // namespace
TEST_CASE("install_plugin rejects a cloud UUID containing path traversal", "[PluginInstall]")
@@ -184,3 +197,59 @@ TEST_CASE("an unsubscribed cloud plugin follows its header version", "[PluginIns
REQUIRE(read_install_state(plugin_dir, scanned));
CHECK(scanned.installed_version == "1.0.0");
}
TEST_CASE("extract_zip_to_directory writes entries under a destination longer than MAX_PATH",
"[PluginInstall][PluginFsUtils]")
{
ScopedTemporaryDir dir("deep-extract");
const fs::path wheel = dir.path() / "deep_pkg-1.0-py3-none-any.whl";
const std::string payload = "print('hello from a deep path')\n";
{
Zipper zipper(wheel.string(), Zipper::NO_COMPRESSION);
zipper.add_entry("pkg/", "", 0); // explicit directory entry
zipper.add_entry("pkg/module.py", payload.data(), payload.size());
zipper.finalize();
}
// A single 240-char component keeps every filename below the NTFS 255 limit while pushing the
// full destination (temp dir + component) past the 260-char MAX_PATH the Win32 APIs enforce
// when LongPathsEnabled is off.
const fs::path destination = dir.path() / std::string(240, 'd');
REQUIRE(destination.string().size() > 260);
std::string error;
REQUIRE(extract_zip_to_directory(wheel, destination, error));
INFO("extract error: " << error);
CHECK(error.empty());
// Verify through a long-path-capable path, otherwise the check itself is bounded by MAX_PATH.
const fs::path module_py = destination / "pkg" / "module.py";
REQUIRE(fs::exists(to_extended_path(module_py)));
REQUIRE(fs::is_regular_file(to_extended_path(module_py)));
// Remove the deep subtree here: ScopedTemporaryDir's plain remove_all cannot reach past
// MAX_PATH on Windows.
boost::system::error_code ec;
fs::remove_all(to_extended_path(destination), ec);
CHECK_FALSE(ec);
}
TEST_CASE("extract_zip_to_directory rejects an entry that escapes the destination",
"[PluginInstall][PluginFsUtils]")
{
ScopedTemporaryDir dir("escape-extract");
const fs::path wheel = dir.path() / "evil-1.0-py3-none-any.whl";
{
Zipper zipper(wheel.string(), Zipper::NO_COMPRESSION);
zipper.add_entry("pkg/module.py", "print('ok')\n", 12);
zipper.add_entry("../escape.txt", "escaped\n", 8);
zipper.finalize();
}
std::string error;
CHECK_FALSE(extract_zip_to_directory(wheel, dir.path() / "out", error));
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("escapes the plugin package"));
CHECK_FALSE(fs::exists(dir.path() / "escape.txt"));
}

Some files were not shown because too many files have changed in this diff Show More