Compare commits

..
Author SHA1 Message Date
ExPikaPaka 682814878d Address the review on saving texture displacement
Read each layer's texture from the archive only. `layer.path` comes from the project
file, so following it let any .3mf pick a local file to read, a UNC path among them,
and have the next save copy what it found into the archive. Reverts the disk fallback
added in 8ecfee6a2.

Store a paint mask only for the slots a triangle names. Eight empty strings per
triangle cost every project hundreds of megabytes on a large mesh, whether or not it
uses the feature. A slot's run is padded to the triangles seen so far on its first
value, so it stays indexed by triangle, and ends at its last painted one.

Report a malformed layer block instead of throwing out of the project load. The typed
accessors throw on a value of the wrong type and nothing above them caught that, so
one bad number cost the whole file rather than one volume's relief.

Cover the store and load round trip in test_3mf.cpp: the layer, its image, the options
and the paint, the two regressions above, paint at either end of the triangle list, two
volumes sharing a slot, and the wrong-typed documents at the unit level.
2026-10-12 14:20:46 +02:00
SoftFever 30e48183e3 Merge branch 'main' into feature/texdisp-3mf-persistence
# Conflicts:
#	src/libslic3r/TextureDisplacement.hpp
#	tests/libslic3r/test_texture_displacement.cpp
2026-10-12 16:58:03 +08:00
ExPikaPaka 8ecfee6a21 Save where the panel was left, and recover a texture the archive lacks
The project carried the layer stack but not the user's place in it: a reopened
project landed on slot 0 in Standard, whatever they had been editing. The active
layer and Standard/Pro now live on the volume, next to the rest of its options,
and come back with it. Docked/undocked goes to the application config instead -
it describes the user's window, not the model, so opening someone else's project
must not move their panel.

A layer whose image_data was empty at save time was written with no texture and
came back with none. It now falls back to the file the layer came from, which the
importer put in the user's own texture library, so a project reopened on the same
machine keeps its pixels.

The texture is always stored as .png: the importer converts whatever the user
picked, so the extension was being taken from a name that no longer described the
bytes.

Also adds v2_flip_edges to the cereal field list, which the undo stack was
silently dropping.
2026-10-09 09:35:35 +02:00
ExPikaPaka fbf4652e12 Fix the build and clang-tidy failures in the persistence change
The round-trip test named a TextureTileMethod enumerator that does not
exist (MirroredRepeat, not Mirror) and used a bare Approx, which Catch2 v3
keeps in its own namespace. The rest of the file compares floats with
WithinAbs, so these follow it rather than reaching for Catch::Approx.

bbs_3mf.cpp reads and writes the layer JSON but picked up the declarations
through another header, and the same for boost::to_lower.
2026-10-09 09:05:42 +02:00
ExPikaPaka be9ff50130 Merge remote-tracking branch 'origin/main' into feature/texdisp-3mf-persistence
# Conflicts:
#	src/libslic3r/TextureDisplacement.cpp
2026-10-09 09:03:51 +02:00
SoftFever dee1f1715f Merge branch 'main' into feature/texdisp-3mf-persistence 2026-10-09 12:15:16 +08:00
ExPikaPaka fcb1f62bd9 Save texture displacement in the project file
A painted relief did not survive closing the project: nothing about the feature
was written to the .3mf, so the layers, their textures and the paint mask were
all lost on save. The layer struct's cereal save()/load() serve the undo/redo
stack only, which is one session and one binary stream.

Write the layer stack and the per-volume options as JSON in the archive, and
each layer's texture as the image file it was loaded from, both referenced by
path - the same split EmbossShape already makes for its SVG. The image stays
out of the XML deliberately: it is binary and routinely megabytes, and base64
in an attribute would bloat the one file every reader parses just to list the
objects. JSON rather than the cereal stream next to it because that one is
positional and unversioned, which would make every future field a
project-breaking change; an unknown key is ignored and a missing one keeps its
default, so a project written by either side keeps loading.

The paint masks go in as one attribute per layer slot, mirroring paint_color.
Older readers ignore attributes they do not know, so a project written here
still opens in a build without the feature - it loses the relief, which is all
it could have done with it anyway.

The layers are restored while the volume is being built rather than after the
archive is walked: volumes are constructed only once the whole archive has been
read, so a hook placed after the walk ran before any volume existed and
restored nothing.
2026-10-05 08:36:22 +02:00
70 changed files with 1099 additions and 1642 deletions
-131
View File
@@ -1,131 +0,0 @@
# Tooltip illustrations
The texture-displacement panel's tooltips can carry a picture under their text: a still image, or a
short animation that loops while the tooltip is open. Several settings in that panel describe a shape
rather than a number - what Rotation turns, what Tile repeats, what Invert turns inside out - and a
sentence about a shape is read twice and understood once.
## Where a tooltip is drawn
`GLGizmoTextureDisplacement::panel_tooltip()` replaces `ImGuiWrapper::tooltip()` throughout the panel.
It differs in two ways.
It places the tooltip beside the panel instead of under the mouse. The panel is pinned to the right
edge of the canvas, so a tooltip following the mouse covers the control it is explaining, and one
carrying a picture covers most of the panel with it. The tooltip goes to the left of
`m_tooltip_anchor_min`/`_max` - the panel's own screen rectangle, or the texture picker's while that
popup is up and the tooltip comes from inside it - and to the right only when there is no room on the
left. This is the placement the picker popup itself already uses.
ImGui normally keeps a tooltip glued to the mouse and ignores `SetNextWindowPos()`. Orca's imgui
branch honours a position set for a tooltip (see the `ImGuiWindowFlags_Tooltip` branch of `Begin()` in
`deps_src/imgui/imgui.cpp`) and still clamps the result inside the canvas, which is what keeps a tall
tooltip near the bottom edge on screen.
It also gives the tooltip a fixed width rather than one derived from its text, because a picture has
to be laid out against something, and a column of tooltips that are all one width is steadier to read.
## Where the pictures live
`resources/images/texture_displacement/`, as PNG, JPG, BMP or GIF. A tooltip names them by file:
```cpp
hover_tip(_u8L("Repeats the texture across the painted area. ..."),
{ { "tile_off.png", _L("Off") }, { "tile_on.png", _L("On") } });
```
Several in one tooltip are laid out side by side at equal widths, which is how a setting is shown as a
comparison - the same model at two values of it. The caption under each is translated; the file name
is not.
A file that is missing or will not decode is skipped, and a tooltip whose pictures are all missing is
exactly the text tooltip it was before. So the art for a tooltip can land after the tooltip does, and
a half-finished set never breaks the panel.
## When a tooltip appears
A tooltip waits for the pointer to rest on its control for 0.45 s. The panel is dense and the tooltip
lands on the model beside it, so with no wait a sweep from the top of the panel down to Bake flashes a
dozen large panels over the scene. Once one has just been up, the wait drops to 0.1 s for 0.7 s
afterwards, so reading several settings in a row stays quick - but not to zero, or that same sweep
would flash again the moment one tooltip had been seen.
No tooltip appears at all while a control is being used - a slider dragged, a value typed into one, a
combo open, a layer card being dragged. The pointer sits on the very thing being edited, so a tooltip
there covers the model exactly while the user is watching it change. The full wait comes back
afterwards rather than the short one: having just let go of a slider is not a reason to be shown a
picture of it.
A pointer resting on a control produces no events, and the canvas only renders when something asks it
to, so the wait has to ask for its own frames, and so does an animation once one is on screen. So does
a tooltip that has just appeared, animated or not: ImGui hides a window on the first frame it is
submitted while it measures it, and does so every time a tooltip reappears because those windows are
recycled, so the frame a tooltip is decided on is never the frame it is seen on. Both use
`GLCanvas3D::request_extra_frame()`, which ends `on_idle()` with `evt.RequestMore()` and so obliges wx
to deliver the next idle. `schedule_extra_frame()` is not enough on its own: it only arranges a timer
whose event an idle event is assumed to follow, and on_idle renders nothing unless the canvas was
already dirty when it ran. Asking for frames as fast as they come is safe here because which frame is
drawn comes from the clock, not from the frame count, so a redraw that arrives early simply draws the
same frame again.
## Decoding and playback
`slic3r/GUI/AnimatedTexture.hpp` decodes a file once and uploads one GL texture per frame. Stills go
through `wxImage`; GIFs go through `wxGIFDecoder`, whose raw palette indices are composited here frame
by frame, replaying the disposal method each frame asks for. That replay is what makes an animation
look continuous: a GIF frame is a sub-rectangle drawn over what came before it, so an animation that
only re-sends the pixels that changed draws as a flicker of disconnected patches without it.
A frame delay of 0 or 1 hundredths of a second is read as 100 ms, the convention browsers settle on,
so an animation authored against that plays at the speed it was authored at.
The animation restarts when its tooltip opens, rather than running off a free-running clock: these are
short loops of one setting going from one value to another, and joining one halfway through shows the
end of a change the user never saw the start of.
Which frame is drawn is measured on a real clock, not `ImGui::GetTime()`. Nothing in OrcaSlicer ever sets
`io.DeltaTime`, so it keeps imgui's default of 1/60 per frame and imgui's clock counts frames rather
than seconds. On a canvas that renders only when asked, that is not a clock at all: an animation driven
by it crawls while the canvas is idle and races while a moving mouse is rendering it flat out.
## Making one
`scripts/gen_texture_displacement_tips.py` draws the lot, in four languages, picked by what the
setting actually is:
- **A shaded relief**, for the settings that are about a pattern: Depth, Tile, Tile size, Rotation,
Repeat/Mirror, Smoothing, Edge fade, Invert, Blend and the Normal, Fast, Checker and Distortion
views. One motif - a ring inside a square frame - runs through all of them, because using one shape
everywhere is what lets two pictures be compared: whatever differs between them is the setting,
since nothing else is.
- **A flat mesh of triangles**, for the settings that are about the mesh or about a tool: the paint
tools, brush size, the connected-area fill, subdivision, feature-adaptive refinement, remeshing,
hard edges, slivers and the wireframe view. These carry the one accent colour in the set, meaning
"painted", because that is the only thing in such a picture that is not geometry.
- **A section**, for Midlevel and for smoothing the geometry. The reason generalises: shading reads
slopes, so a height field moved up or down as a whole shades identically to where it started, and
any setting whose meaning is *where the surface sits relative to where it was* is invisible from
above and has to be drawn from the side.
- **A flat ramp of greys**, for Mix filaments, Mixed colors and Denoise. Those are about which
filament a spot ends up as; lighting one would only add a second thing changing between two
pictures meant to differ in one.
The five Mapping pictures are one ball with a checker laid on it five ways, which is the whole of what
choosing a projection does - except Unwrap, which is not a projection onto the model at all and so is
drawn as the flat layout it produces.
Captures of the real tool are still the first choice for anything involving a real model or real
filament colour: Bake, the mixed-colour count on a photograph, Adjust placement, the frame window and
the UV editor's seam tool have no picture yet for that reason.
Either way:
- Square, around 256 px (192 for an animation, where every frame ships). The tooltip is about 20 em
wide, so that is roughly native size on a 100% display and downscales cleanly above it. A single
picture is capped at 10 em tall and centred, two side by side take half the width each.
- Neutral grey. These say what a shape does; colour in one reads as a third thing the setting does,
and has to work against both themes.
- Animations loop seamlessly and stay short - 16 frames at 100 ms, under about 150 KB. They sit in
`resources/`, which ships with every copy of the application. A sweep driven by a cosine over the
frame count loops with no step, since it starts and ends at the same value and the same rate.
- No text in the picture. Captions are translated; pixels are not.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Qidi",
"version": "02.04.00.17",
"version": "02.04.00.16",
"force_update": "0",
"description": "Qidi configurations",
"machine_model_list": [
@@ -83,7 +83,7 @@
"extruder_clearance_height_to_lid": "120",
"layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\nLOG_Z\nG92 E0\n",
"machine_end_gcode": "M141 S0\nM104 S0\nM140 S0\nG1 E-3 F1800\nG0 Z{min(max_print_height, max_layer_z + 3)} F600\nG0 X0 Y0 F12000\n{if max_layer_z < max_print_height / 2}G1 Z{max_print_height / 2 + 10} F600{else}G1 Z{min(max_print_height, max_layer_z + 3)}{endif}",
"machine_start_gcode": "PRINT_START BED=[bed_temperature_initial_layer_single] HOTEND=[nozzle_temperature_initial_layer] CHAMBER=[chamber_temperature]\nM83\nM140 S[bed_temperature_initial_layer_single]\nM104 S[nozzle_temperature_initial_layer]\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 3} Z0\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 6}\nG1 Z1 F600\n",
"machine_start_gcode": "PRINT_START BED=[hot_plate_temp_initial_layer] HOTEND=[nozzle_temperature_initial_layer] CHAMBER=[chamber_temperature]\nM83\nM140 S[hot_plate_temp_initial_layer]\nM104 S[nozzle_temperature_initial_layer]\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 3} Z0\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 6}\nG1 Z1 F600\n",
"thumbnails_format": "PNG",
"default_filament_profile": [
"Generic PLA @Qidi Q1 Pro 0.4 nozzle"
@@ -100,7 +100,7 @@
"default_filament_profile": [
"Generic PLA @Qidi"
],
"machine_start_gcode": "G28\nM140 S[bed_temperature_initial_layer_single]\nM190 S[bed_temperature_initial_layer_single]\nM109 S[nozzle_temperature_initial_layer]\nG92 E-19\nG0 Y5 Z0.3 F3600\nG1 X5 E0 F2400\n",
"machine_start_gcode": "G28\nM140 S[hot_plate_temp_initial_layer]\nM190 S[hot_plate_temp_initial_layer]\nM109 S[nozzle_temperature_initial_layer]\nG92 E-19\nG0 Y5 Z0.3 F3600\nG1 X5 E0 F2400\n",
"machine_end_gcode": "M104 S0\nM140 S0\n;Retract the filament\nG92 E0\nG1 E-3 F300\nG28\nM84\n",
"scan_first_layer": "0"
}
@@ -24,7 +24,7 @@
"210x210/COLPIC",
"110x110/PNG"
],
"machine_start_gcode": "PRINT_START\nG28\nM141 S0\nG0 Z50 F600\nM190 S[bed_temperature_initial_layer_single]\nG28 Z\nG29; mesh bed leveling ,comment this code to close it\nG0 X0 Y0 Z50 F6000\nM141 S{overall_chamber_temperature}\nM109 S[nozzle_temperature_initial_layer]\nM106 P3 S255\nM83\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 12} E{-10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 E{10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\n",
"machine_start_gcode": "PRINT_START\nG28\nM141 S0\nG0 Z50 F600\nM190 S[hot_plate_temp_initial_layer]\nG28 Z\nG29; mesh bed leveling ,comment this code to close it\nG0 X0 Y0 Z50 F6000\nM141 S{overall_chamber_temperature}\nM109 S[nozzle_temperature_initial_layer]\nM106 P3 S255\nM83\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 12} E{-10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 E{10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\n",
"machine_end_gcode": "M141 S0\nM104 S0\nM140 S0\nG1 E-3 F1800\nG0 Z{min(max_print_height, max_layer_z + 3)} F600\nG0 X0 Y0 F12000\n{if max_layer_z < max_print_height / 2}G1 Z{max_print_height / 2 + 10} F600{else}G1 Z{min(max_print_height, max_layer_z + 3)}{endif}",
"scan_first_layer": "0"
}
-793
View File
@@ -1,793 +0,0 @@
#!/usr/bin/env python3
"""Draws the illustrations the texture-displacement tooltips show.
The panel's tooltips can carry a picture under their text (see GLGizmoTextureDisplacement's
panel_tooltip()). A good many of those settings - the ones that are a shape rather than a photograph -
are better drawn from the same arithmetic the feature uses than captured from the screen, which is
what this script does: it shades a height field exactly as a relief would catch the light, so
"what Rotation turns" and "what Tile repeats" are visible rather than described.
Settings that involve the model, the mesh or colour are not here: those want a capture of the real
tool. See docs/HLSD/tooltip-illustrations.md.
Output goes to resources/images/texture_displacement/. Run it after changing one of the patterns
below; nothing in the build depends on it.
python3 scripts/gen_texture_displacement_tips.py
Needs numpy and pillow.
"""
import os
import numpy as np
from PIL import Image, ImageDraw
SIZE = 256 # px, square - the panel draws these at whatever width the tooltip has
GIF_SIZE = 192 # animations are the same picture, smaller: every frame of one ships in resources/
RELIEF = 30.0 # how hard the shading reacts to a slope; purely cosmetic
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "resources", "images", "texture_displacement")
# Neutral enough to sit in a tooltip in either theme: a dark ground, a mid grey for the flat surface
# and a cool near-white for the lit slopes.
SHADOW = np.array([0.13, 0.14, 0.15])
MIDTONE = np.array([0.40, 0.42, 0.43])
HIGHLIGHT = np.array([0.78, 0.84, 0.83])
LIGHT = np.array([-0.45, -0.62, 0.65])
LIGHT /= np.linalg.norm(LIGHT)
def grid(size=SIZE):
"""Unit coordinates, -0.5 .. 0.5, y down as in an image."""
a = (np.arange(size) + 0.5) / size - 0.5
return np.meshgrid(a, a)
def shade(height):
"""A height field in 0..1, lit as a relief: the picture the tooltip shows."""
# The slope has to mean the same thing at either size, so the gradient is taken per unit of the
# picture rather than per pixel - otherwise the smaller animations would come out twice as flat.
gy, gx = np.gradient(height * RELIEF * (height.shape[0] / float(SIZE)))
normal = np.dstack([-gx, -gy, np.ones_like(height)])
normal /= np.linalg.norm(normal, axis=2, keepdims=True)
lam = np.clip(normal @ LIGHT, 0.0, 1.0)
# Two ramps rather than one: the dark half separates the slopes, the lit half picks up the
# accent, and the flat ground lands in the middle of both.
low = SHADOW + (MIDTONE - SHADOW) * (lam / 0.5)[..., None]
high = MIDTONE + (HIGHLIGHT - MIDTONE) * ((lam - 0.5) / 0.5)[..., None]
rgb = np.where((lam < 0.5)[..., None], low, high)
# A touch of the height itself, so a tall feature still reads as tall where it is flat on top.
#
# Deliberately almost grey: these say what a shape does, and a relief catching the light is grey.
# A coloured one would read as a third thing the setting does, and the amount of colour would move
# with the slope - a pattern at 45 degrees would flash where the same pattern upright did not.
rgb = np.clip(rgb * (0.88 + 0.18 * height[..., None]), 0.0, 1.0)
return (rgb * 255.0 + 0.5).astype(np.uint8)
def vignette(rgb):
"""Rounds the picture off at its border, so it does not end in a hard rectangle."""
x, y = grid(rgb.shape[0])
edge = np.clip((0.5 - np.maximum(np.abs(x), np.abs(y))) / 0.045, 0.0, 1.0)
return (rgb * edge[..., None]).astype(np.uint8)
def save_png(name, rgb):
path = os.path.join(OUT_DIR, name)
Image.fromarray(vignette(rgb)).save(path, optimize=True)
print("wrote", os.path.normpath(path), os.path.getsize(path) // 1024, "KB")
def save_gif(name, frames, delay_ms):
path = os.path.join(OUT_DIR, name)
images = [Image.fromarray(vignette(f)).convert("P", palette=Image.ADAPTIVE, colors=24) for f in frames]
images[0].save(path, save_all=True, append_images=images[1:], duration=delay_ms, loop=0, optimize=True)
print("wrote", os.path.normpath(path), os.path.getsize(path) // 1024, "KB")
def smoothstep(edge0, edge1, x):
t = np.clip((x - edge0) / (edge1 - edge0), 0.0, 1.0)
return t * t * (3.0 - 2.0 * t)
def blur(a, radius):
"""Separable box blur, run twice - near enough a gaussian for a picture of a blur."""
for _ in range(2):
for axis in (0, 1):
padded = np.pad(a, [(radius, radius) if i == axis else (0, 0) for i in range(2)], mode="edge")
cumulative = np.cumsum(padded, axis=axis)
cumulative = np.concatenate([np.zeros_like(np.take(cumulative, [0], axis=axis)), cumulative], axis=axis)
n = a.shape[axis]
hi = np.take(cumulative, np.arange(n) + 2 * radius + 1, axis=axis)
lo = np.take(cumulative, np.arange(n), axis=axis)
a = (hi - lo) / (2.0 * radius + 1.0)
return a
# --- the patterns ------------------------------------------------------------------------------
#
# One motif runs through nearly all of them - a ring inside a square frame. Using the same shape for
# every setting is what lets the pictures be compared: whatever changes between two of them is the
# setting, because nothing else is different.
def _motif_cell(u, v):
"""The motif within one cell, u and v in -0.5 .. 0.5."""
r = np.hypot(u, v)
ring = np.clip(1.0 - np.abs(r - 0.26) / 0.085, 0.0, 1.0)
frame_d = np.maximum(np.abs(u), np.abs(v))
frame = np.clip(1.0 - np.abs(frame_d - 0.42) / 0.055, 0.0, 1.0)
return np.clip(np.maximum(ring, frame) ** 0.7, 0.0, 1.0)
def motif(repeats, size=SIZE):
"""The motif laid out `repeats` times across the picture."""
x, y = grid(size)
return _motif_cell((x * repeats) % 1.0 - 0.5, (y * repeats) % 1.0 - 0.5)
def decal(motif_scale=0.46):
"""One copy of the motif in the middle of a flat field - what Tile off leaves."""
x, y = grid()
u, v = x / motif_scale, y / motif_scale
inside = (np.maximum(np.abs(u), np.abs(v)) <= 0.5).astype(float)
return _motif_cell(u, v) * inside
def ridges(angle_deg, period=0.16, size=SIZE):
"""Parallel ridges at an angle: the simplest pattern whose direction is unmistakable."""
x, y = grid(size)
a = np.radians(angle_deg)
t = (x * np.cos(a) + y * np.sin(a)) / period
return 0.2 + 0.6 * (0.5 + 0.5 * np.sin(2.0 * np.pi * t))
def bosses(repeats, size=SIZE):
"""Rounded domes standing on a flat field - a shape with an unmistakable right way up."""
x, y = grid(size)
u = (x * repeats) % 1.0 - 0.5
v = (y * repeats) % 1.0 - 0.5
return smoothstep(0.36, 0.08, np.hypot(u, v))
def chevron_tiles(mirrored, repeats=3):
"""Cells of diagonal ridges, laid out repeated or mirrored.
Repeated, every cell starts the pattern over and the seam between two of them is a step; mirrored,
each cell is the reflection of its neighbour and the ridges meet across the seam as chevrons. That
seam is the whole of what the Repeat/Mirror pair does, and a motif symmetric in itself would hide
it entirely.
"""
def axis(t):
if not mirrored:
return (t * repeats) % 1.0
s = (t * repeats) % 2.0
return np.where(s < 1.0, s, 2.0 - s)
x, y = grid()
t = (axis(x + 0.5) + axis(y + 0.5)) * 1.5
return 0.22 + 0.56 * (0.5 + 0.5 * np.cos(2.0 * np.pi * t))
def painted_area(size=SIZE, radius=0.44):
"""A round painted area in the middle of bare surface, with a soft edge so it does not alias."""
x, y = grid(size)
return smoothstep(radius, radius - 1.5 / size, np.hypot(x, y))
def painted_patch(fade, size=SIZE):
"""The motif inside a painted area, either stopping at its edge or fading into it."""
x, y = grid(size)
r = np.hypot(x, y) / 0.44
amount = smoothstep(1.0, 0.55, r) if fade else 1.0
return motif(3, size) * painted_area(size) * amount
def profile_wave(size=SIZE, periods=2.5):
"""A one-dimensional height profile with clear highs, lows and a flat stretch between them."""
t = (np.arange(size) + 0.5) / size * periods * 2.0 * np.pi
h = 0.5 + 0.34 * np.cos(t) + 0.16 * np.cos(2.0 * t + 0.9)
return np.clip((h - h.min()) / (h.max() - h.min()), 0.0, 1.0)
def profile_view(height_1d, midlevel, amplitude=0.26, size=SIZE):
"""A side view of the surface: material below, air above, and the bare surface as a dashed line.
Shading cannot say how high something is - it reads slopes, and a height field moved up or down as
a whole has exactly the slopes it had before. Midlevel is entirely about where the texture sits
relative to the surface it is on, so it is drawn in section, where that is the one thing visible.
"""
base = 0.52 * size
surface = base - (height_1d - midlevel) * amplitude * size
rows = np.arange(size)[:, None]
covered = np.clip(rows - surface[None, :] + 0.5, 0.0, 1.0) # 1 where there is material
air = np.array([0.11, 0.12, 0.13])
material = np.array([0.44, 0.46, 0.47])
rgb = air * (1.0 - covered)[..., None] + material * covered[..., None]
# The lit top face, and the bare surface the texture is measured against.
edge = np.clip(1.0 - np.abs(rows - surface[None, :]) / 1.6, 0.0, 1.0)
rgb = rgb * (1.0 - edge[..., None]) + np.array([0.80, 0.86, 0.85]) * edge[..., None]
dash = ((np.arange(size) // 7) % 2 == 0)[None, :]
line = (np.abs(rows - base) < 0.9) & dash
rgb[line] = np.array([0.58, 0.62, 0.62])
return (np.clip(rgb, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)
def dome(size=SIZE):
"""One broad swell filling the picture - the layer underneath, for the blend pictures."""
x, y = grid(size)
return smoothstep(0.52, 0.02, np.hypot(x, y))
# --- meshes, cursors and sections ---------------------------------------------------------------
#
# The second family of pictures. The settings above are about a pattern, and a shaded relief is what a
# pattern looks like; these are about a tool or about the mesh, where what matters is which triangles
# exist and which of them are painted. So they are drawn flat, the way the panel's own wireframe and
# paint overlays draw them, with one accent colour meaning "painted" and nothing else coloured.
SS = 3 # supersampling: PIL fills polygons without antialiasing, so draw big and shrink
INK = (0.62, 0.67, 0.67) # mesh edges
BARE = (0.28, 0.30, 0.31) # unpainted surface
PAINT = (0.20, 0.47, 0.45) # painted with the active layer, in the panel's own accent
CURSOR = (0.92, 0.95, 0.95)
MARGIN = 0.05 # keeps the mesh off the picture's border, where the vignette darkens it
def _px(p, size):
"""Unit coordinates to pixels, inside the margin."""
return ((MARGIN + p[0] * (1.0 - 2.0 * MARGIN)) * size, (MARGIN + p[1] * (1.0 - 2.0 * MARGIN)) * size)
def _rgb255(c):
return tuple(int(round(v * 255.0)) for v in c)
def raster(draw_fn, size=SIZE, background=BARE):
"""Runs draw_fn(draw, size_in_pixels) on a supersampled canvas and hands back the shrunk result.
`background` is a colour, or a picture already drawn by the relief half of this script - which is
how a mesh is shown sitting on the surface it refines.
"""
if isinstance(background, np.ndarray):
image = Image.fromarray(background).resize((size * SS, size * SS), Image.BILINEAR)
else:
image = Image.new("RGB", (size * SS, size * SS), _rgb255(background))
draw_fn(ImageDraw.Draw(image), size * SS)
return np.asarray(image.resize((size, size), Image.LANCZOS)).astype(np.uint8)
def tri_mesh(n, jitter=0.0, seed=1):
"""A square patch of triangles, optionally with its inner vertices nudged off the grid.
Jitter is not decoration: a perfectly regular grid reads as graph paper, and the point of these
pictures is a mesh over a surface.
"""
rng = np.random.default_rng(seed)
v = np.stack(np.meshgrid(np.linspace(0.0, 1.0, n + 1), np.linspace(0.0, 1.0, n + 1)), axis=-1)
if jitter:
v[1:n, 1:n] += (rng.random((n - 1, n - 1, 2)) - 0.5) * (jitter / n)
tris = []
for i in range(n):
for j in range(n):
tris.append((v[i, j], v[i, j + 1], v[i + 1, j + 1]))
tris.append((v[i, j], v[i + 1, j + 1], v[i + 1, j]))
return tris
def centroids(tris):
return np.array([np.mean(t, axis=0) for t in tris])
def neighbours(tris):
"""Which triangles share an edge with which, keyed on the rounded vertex positions."""
edges = {}
for i, t in enumerate(tris):
for a, b in ((0, 1), (1, 2), (2, 0)):
key = tuple(sorted((tuple(np.round(t[a], 6)), tuple(np.round(t[b], 6)))))
edges.setdefault(key, []).append(i)
out = [[] for _ in tris]
for shared in edges.values():
if len(shared) == 2:
out[shared[0]].append(shared[1])
out[shared[1]].append(shared[0])
return out
def draw_mesh(draw, size, tris, painted=None, edge=INK, edge_width=0.7):
fill_bare, fill_paint = _rgb255(BARE), _rgb255(PAINT)
for i, t in enumerate(tris):
draw.polygon([_px(p, size) for p in t],
fill=fill_paint if painted is not None and painted[i] else fill_bare,
outline=_rgb255(edge), width=max(1, int(edge_width * SS)))
def draw_brush(draw, size, centre, radius, filled=False):
"""The brush cursor: a ring, which is how the gizmo draws it on the model."""
c = _px(centre, size)
r = radius * (1.0 - 2.0 * MARGIN) * size
if filled:
draw.ellipse([c[0] - r, c[1] - r, c[0] + r, c[1] + r], fill=(255, 255, 255, 40))
draw.ellipse([c[0] - r, c[1] - r, c[0] + r, c[1] + r], outline=_rgb255(CURSOR), width=max(1, int(1.6 * SS)))
def draw_pointer(draw, size, at):
"""A small arrow, for the tools that are clicked rather than dragged."""
x, y = _px(at, size)
k = 0.055 * size
draw.polygon([(x, y), (x, y + 1.45 * k), (x + 0.38 * k, y + 1.05 * k),
(x + 0.62 * k, y + 1.55 * k), (x + 0.90 * k, y + 1.40 * k),
(x + 0.66 * k, y + 0.92 * k), (x + 1.05 * k, y + 0.84 * k)],
fill=_rgb255(CURSOR), outline=(30, 32, 34), width=max(1, int(0.9 * SS)))
def stroke_path(t):
"""The path a demonstrated brush stroke follows, t in 0..1."""
return np.array([0.14 + 0.72 * t, 0.46 + 0.22 * np.sin(2.0 * np.pi * t * 1.1)])
def brush_frames(size, erase, steps=14, radius=0.115, hold=3):
tris = tri_mesh(9, jitter=0.55)
cents = centroids(tris)
painted = np.ones(len(tris), bool) if erase else np.zeros(len(tris), bool)
frames = []
for k in range(steps):
centre = stroke_path(k / (steps - 1.0))
hit = np.linalg.norm(cents - centre, axis=1) < radius
painted = painted & ~hit if erase else painted | hit
state = painted.copy()
frames.append(raster(lambda d, s, st=state, c=centre: (draw_mesh(d, s, tris, st), draw_brush(d, s, c, radius)), size))
frames += [frames[-1]] * hold
return frames
def face_frames(size, steps=9, hold=3):
"""The Face tool: one triangle per click, under a pointer."""
tris = tri_mesh(7, jitter=0.5, seed=4)
cents = centroids(tris)
order = np.argsort(np.linalg.norm(cents - np.array([0.5, 0.5]), axis=1))[:steps]
painted = np.zeros(len(tris), bool)
frames = []
for i in order:
painted[i] = True
state = painted.copy()
at = cents[i]
frames.append(raster(lambda d, s, st=state, a=at: (draw_mesh(d, s, tris, st), draw_pointer(d, s, a)), size))
frames += [frames[-1]] * hold
return frames
def flood_frames(size, creases, steps=12, hold=3, seed=7):
"""The Connected area tool: a flood from one click, stopped by the creases it may not cross.
`creases` is a list of x positions; a triangle pair straddling one cannot pass the fill between
them, which is what an edge sharper than the angle threshold does on the model.
"""
tris = tri_mesh(9, jitter=0.5, seed=seed)
cents = centroids(tris)
adj = neighbours(tris)
blocked = lambda i, j: any((cents[i][0] - x) * (cents[j][0] - x) < 0.0 for x in creases)
seed_tri = int(np.argmin(np.linalg.norm(cents - np.array([0.22, 0.5]), axis=1)))
painted = np.zeros(len(tris), bool)
painted[seed_tri] = True
frontier = [seed_tri]
frames = []
for _ in range(steps):
state = painted.copy()
frames.append(raster(lambda d, s, st=state: (draw_mesh(d, s, tris, st),
draw_creases(d, s, creases),
draw_pointer(d, s, cents[seed_tri])), size))
nxt = []
for i in frontier:
for j in adj[i]:
if not painted[j] and not blocked(i, j):
painted[j] = True
nxt.append(j)
frontier = nxt
frames += [frames[-1]] * hold
return frames
def draw_creases(draw, size, creases):
"""A fold too sharp for the fill to cross: drawn dark with a bright crest, like a real one."""
for x in creases:
a, b = _px((x, -0.02), size), _px((x, 1.02), size)
draw.line([a, b], fill=_rgb255((0.08, 0.09, 0.10)), width=max(1, int(4.0 * SS)))
draw.line([a, b], fill=_rgb255((0.97, 0.99, 0.99)), width=max(1, int(1.4 * SS)))
def split4(tri):
a, b, c = tri
ab, bc, ca = (a + b) * 0.5, (b + c) * 0.5, (c + a) * 0.5
return [(a, ab, ca), (ab, b, bc), (ca, bc, c), (ab, bc, ca)]
def refine(tris, density, max_depth, threshold=0.45):
"""Splits a triangle into four wherever `density` at its middle asks for it, up to max_depth."""
def go(tri, depth):
if depth >= max_depth:
return [tri]
centre = np.mean(tri, axis=0)
if density(centre[0], centre[1]) < threshold:
return [tri]
return [out for part in split4(tri) for out in go(part, depth + 1)]
return [out for tri in tris for out in go(tri, 0)]
def sampler(field):
"""Looks a numpy field up at unit coordinates, for use as a density."""
n = field.shape[0]
return lambda x, y: float(field[int(np.clip(y * n, 0, n - 1)), int(np.clip(x * n, 0, n - 1))])
def feature_density(size=192):
"""High where the pattern bends, which is where feature-adaptive refinement spends triangles.
One large copy of the motif rather than a field of them: the point is that the triangles gather
somewhere and not elsewhere, and a pattern that bends everywhere has no elsewhere to show.
"""
m = motif(1, size)
gy, gx = np.gradient(m)
g = blur(np.hypot(gx, gy), 2)
return g / max(g.max(), 1e-6)
def irregular_mesh(seed=3):
"""The uneven mesh a model arrives with: coarse here, fine there, nothing of one size."""
rng = np.random.default_rng(seed)
tris = tri_mesh(4, jitter=0.9, seed=seed)
out = []
for t in tris:
out.extend(split4(t) if rng.random() < 0.45 else [t])
final = []
for t in out:
final.extend(split4(t) if rng.random() < 0.3 else [t])
return final
def snap_to_crease(tris, crease, distance=0.07):
"""Pulls every vertex near the crease onto it, so mesh edges run along it instead of across."""
(x0, y0), (x1, y1) = crease
d = np.array([x1 - x0, y1 - y0], float)
d /= np.linalg.norm(d)
def snap(p):
t = np.dot(p - np.array([x0, y0]), d)
foot = np.array([x0, y0]) + t * d
return foot if np.linalg.norm(p - foot) < distance else p
return [tuple(snap(np.asarray(v, float)) for v in t) for t in tris]
def sliver_mesh(seed=5):
"""A mesh carrying the thin triangles refinement inherits from a model's own triangulation."""
rng = np.random.default_rng(seed)
tris = tri_mesh(7, jitter=0.4, seed=seed)
out = []
for t in tris:
if rng.random() < 0.45:
a, b, c = (np.asarray(v, float) for v in t)
mid = (b + c) * 0.5
a = mid + (a - mid) * 0.08 # all but flattened onto the opposite edge
out.append((a, b, c))
else:
out.append(t)
return out
def draw_profile(draw, size, height_1d, baseline=0.62, amplitude=0.30):
"""A side view of a surface, as a filled region under its profile."""
n = len(height_1d)
top = [(i / (n - 1.0) * size, (baseline - (height_1d[i] - 0.5) * amplitude) * size) for i in range(n)]
draw.polygon(top + [(size, size * 1.1), (0, size * 1.1)], fill=_rgb255((0.44, 0.46, 0.47)))
draw.line(top, fill=_rgb255((0.82, 0.88, 0.87)), width=max(1, int(1.8 * SS)))
def bayer(size, n=4):
"""An ordered dither matrix tiled over the picture, for the mixing illustrations."""
m = np.array([[0]])
while m.shape[0] < n:
m = np.block([[4 * m, 4 * m + 2], [4 * m + 3, 4 * m + 1]])
m = (m + 0.5) / m.size
return np.tile(m, (size // m.shape[0] + 1, size // m.shape[1] + 1))[:size, :size]
def levels_image(levels, dither=False, speckle=0.0, size=SIZE, seed=11):
"""A smooth ramp reduced to a handful of greys - what a bake does to a photograph's colours.
Flat rather than lit: this family of settings is about which filament a pixel ends up as, and a
relief would only add a second thing changing between two pictures that are meant to differ in
one.
"""
x, y = grid(size)
ramp = np.clip(0.5 + 0.5 * np.cos(2.0 * np.pi * (x + 0.25 * y)), 0.0, 1.0)
noise = bayer(size) - 0.5 if dither else 0.0
q = np.round(np.clip(ramp + noise / max(levels - 1, 1), 0.0, 1.0) * (levels - 1)) / (levels - 1)
if speckle > 0.0:
rng = np.random.default_rng(seed)
hit = rng.random((size, size)) < speckle
q = np.where(hit, rng.integers(0, levels, (size, size)) / (levels - 1), q)
grey = 0.16 + 0.62 * q
return (np.dstack([grey, grey * 1.03, grey * 1.02]).clip(0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)
def draw_mesh_lines(draw, size, tris, colour=(0.90, 0.94, 0.94), width=0.8):
"""Only the edges, for drawing a mesh over a picture of the surface it sits on."""
for t in tris:
pts = [_px(p, size) for p in t]
draw.line(pts + [pts[0]], fill=_rgb255(colour), width=max(1, int(width * SS)))
def draw_line(draw, size, segment, colour=(0.97, 0.99, 0.99)):
a, b = _px(segment[0], size), _px(segment[1], size)
draw.line([a, b], fill=_rgb255((0.08, 0.09, 0.10)), width=max(1, int(4.0 * SS)))
draw.line([a, b], fill=_rgb255(colour), width=max(1, int(1.4 * SS)))
def posterize(rgb, levels):
"""The shaded picture reduced to flat bands - what the fast preview's per-fragment quantizing
does to a surface that the real preview shades smoothly."""
v = rgb.astype(float) / 255.0
return (np.round(v * (levels - 1)) / (levels - 1) * 255.0 + 0.5).astype(np.uint8)
def with_checker(rgb, squares=10, strength=0.30, warp=False):
"""A checker laid over the surface, evenly or stretched.
Even squares are what the Checker view is for - a texture laid out without stretching. The warped
version is the same squares through a mapping that does stretch, which is what the Distortion view
is there to find: cells crowded in one place and pulled apart in another.
"""
size = rgb.shape[0]
x, y = grid(size)
u, v = (np.sign(x) * np.abs(2.0 * x) ** 2.4 * 0.5, y * (1.0 + 0.9 * np.abs(x))) if warp else (x, y)
cells = (np.floor((u + 0.5) * squares) + np.floor((v + 0.5) * squares)) % 2.0
return (rgb * (1.0 - strength * cells)[..., None]).astype(np.uint8)
def projected_sphere(kind, size=SIZE, squares=9):
"""A ball with a checker laid on it by one of the panel's projections.
One shape, five mappings: what differs between the pictures is only where the squares go, which is
the whole of what choosing a projection does. A sphere is the shape that shows it - every one of
these mappings is well behaved on a flat face and tells itself apart on a curved one.
"""
a = (np.arange(size) + 0.5) / size * 2.24 - 1.12
x, y = np.meshgrid(a, a)
inside = x * x + y * y <= 1.0
z = np.sqrt(np.clip(1.0 - x * x - y * y, 0.0, 1.0))
lam = np.clip(x * LIGHT[0] + y * LIGHT[1] + z * LIGHT[2], 0.0, 1.0)
rgb = SHADOW + (HIGHLIGHT - SHADOW) * (lam ** 0.85)[..., None]
around = np.arctan2(x, z) / (2.0 * np.pi) + 0.5
if kind == "sphere":
u, v = around, np.arccos(np.clip(-y, -1.0, 1.0)) / np.pi
elif kind == "cylinder":
u, v = around, (y + 1.0) * 0.5
elif kind == "view":
u, v = (x + 1.0) * 0.5, (y + 1.0) * 0.5
elif kind == "cube":
axis = np.argmax(np.abs(np.dstack([x, y, z])), axis=2)
u = np.where(axis == 0, z, x)
v = np.where(axis == 1, z, y)
u, v = (u + 1.0) * 0.5, (v + 1.0) * 0.5
else:
raise ValueError(kind)
cells = (np.floor(u * squares * 2.0) + np.floor(v * squares)) % 2.0
rgb = rgb * (1.0 - 0.34 * cells)[..., None]
rgb = np.where(inside[..., None], rgb, np.array([0.13, 0.14, 0.15]))
return (np.clip(rgb, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)
def uv_islands(size=SIZE, squares=9, seed=12):
"""The flat layout an unwrap produces: the surface cut into islands and laid out side by side.
Not a projection onto the model like the other four, so not drawn on one - an unwrap is the model
carrying its own coordinates, and this is what those look like.
"""
rng = np.random.default_rng(seed)
x, y = grid(size)
cells = (np.floor((x + 0.5) * squares * 2.0) + np.floor((y + 0.5) * squares)) % 2.0
checker = (np.array([0.40, 0.43, 0.43]) + np.array([0.22, 0.24, 0.24]) * (1.0 - cells)[..., None])
def blob(centre, radius, lobes):
angles = np.linspace(0.0, 2.0 * np.pi, 26, endpoint=False)
wobble = 1.0 + 0.22 * np.sin(lobes * angles + rng.random() * 6.0)
return [(centre[0] + np.cos(t) * radius * w, centre[1] + np.sin(t) * radius * w)
for t, w in zip(angles, wobble)]
islands = [blob((0.31, 0.32), 0.21, 3), blob((0.72, 0.30), 0.17, 4),
blob((0.34, 0.74), 0.17, 4), blob((0.72, 0.71), 0.20, 3)]
mask = np.asarray(raster(lambda d, px: [d.polygon([(p[0] * px, p[1] * px) for p in island], fill=(255, 255, 255))
for island in islands],
size, background=(0.0, 0.0, 0.0))).astype(float)[..., 0] / 255.0
rgb = checker * mask[..., None] + np.array([0.13, 0.14, 0.15]) * (1.0 - mask)[..., None]
out = (np.clip(rgb, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)
return raster(lambda d, px: [d.polygon([(p[0] * px, p[1] * px) for p in island], outline=_rgb255(INK),
width=max(1, int(1.1 * SS)))
for island in islands],
size, background=out)
def main():
os.makedirs(OUT_DIR, exist_ok=True)
# Tile: one copy against the pattern filling the painted area.
save_png("tile_off.png", shade(decal()))
save_png("tile_on.png", shade(motif(3)))
# Tile size: how wide one copy is.
save_png("tile_size_large.png", shade(motif(2)))
save_png("tile_size_small.png", shade(motif(5)))
# Repeat against Mirror, where the seam between two copies is the whole point.
save_png("tile_repeat.png", shade(chevron_tiles(mirrored=False)))
save_png("tile_mirror.png", shade(chevron_tiles(mirrored=True)))
# Invert: the negative of the image. Domes rather than the ring motif, which is close enough to
# its own negative that inverting it would look like nothing had happened.
save_png("invert_off.png", shade(bosses(3)))
save_png("invert_on.png", shade(1.0 - bosses(3)))
# Smoothing: a stepped image against the same one blurred before it is used. Domes again, where
# the steps show up as contour rings - what a photograph with too few grey levels does.
stepped = np.floor(bosses(3) * 5.0) / 4.0
save_png("smoothing_off.png", shade(np.clip(stepped, 0.0, 1.0)))
save_png("smoothing_on.png", shade(np.clip(blur(stepped, max(2, SIZE // 40)), 0.0, 1.0)))
# Edge fade: relief that stops at the edge of the painted area against relief that sinks into it.
save_png("edge_fade_off.png", shade(painted_patch(fade=False)))
save_png("edge_fade_on.png", shade(painted_patch(fade=True)))
# Blend: piling relief on top of the layer below against using it as a mask over it. Add keeps
# both shapes, Multiply keeps the motif only where the layer under it is high.
base = dome()
save_png("blend_add.png", shade(np.clip(0.55 * base + 0.42 * motif(3), 0.0, 1.0)))
save_png("blend_multiply.png", shade(base * motif(3)))
# Rotation: a half turn brings parallel ridges back onto themselves, so a single sweep loops with
# no jump and the file stays a fraction of the size a there-and-back one would be.
steps = 16
save_gif("rotation.gif", [shade(ridges(180.0 * i / steps, size=GIF_SIZE)) for i in range(steps)], delay_ms=100)
# Depth and Midlevel are both about where the relief sits relative to the surface it is on, so
# both are drawn as a painted area in the middle of bare surface, which is that reference. Without
# it neither would show: shading reads slopes, and a height field that moves up or down as a whole
# has exactly the same slopes it had before.
#
# A cosine over the frame count starts and ends at the same value with no step in between, so a
# single pass through it loops.
area = painted_area(GIF_SIZE)
pattern = motif(3, GIF_SIZE)
sweep = [0.5 - 0.5 * np.cos(2.0 * np.pi * i / steps) for i in range(steps)]
# Depth: the relief growing out of the surface and settling back flush with it.
frames = [shade(np.clip(0.5 + 0.45 * pattern * (0.08 + 0.92 * t) * area, 0.0, 1.0)) for t in sweep]
save_gif("depth.gif", frames, delay_ms=100)
# Midlevel: the grey that stays put, sweeping from "the whole texture pushes out" through
# "mid-grey stays where the surface is" to "the whole of it cuts in". Drawn in section - see
# profile_view() for why this one is not a relief like the rest.
wave = profile_wave(GIF_SIZE)
frames = [profile_view(wave, t, size=GIF_SIZE) for t in sweep]
save_gif("midlevel.gif", frames, delay_ms=100)
# The paint tools, each shown doing the one thing that tells it apart from the others.
save_gif("paint.gif", brush_frames(GIF_SIZE, erase=False), delay_ms=110)
save_gif("erase.gif", brush_frames(GIF_SIZE, erase=True), delay_ms=110)
save_gif("tool_face.gif", face_frames(GIF_SIZE), delay_ms=180)
save_gif("tool_smart_fill.gif", flood_frames(GIF_SIZE, creases=[0.63]), delay_ms=110)
# Brush size: the ring, and what it covers, over one size and back.
brush_tris = tri_mesh(9, jitter=0.55, seed=2)
brush_cents = centroids(brush_tris)
frames = []
for i in range(steps):
r = 0.07 + 0.16 * (0.5 - 0.5 * np.cos(2.0 * np.pi * i / steps))
under = np.linalg.norm(brush_cents - np.array([0.5, 0.5]), axis=1) < r
frames.append(raster(lambda d, sz, u=under, rr=r: (draw_mesh(d, sz, brush_tris, u),
draw_brush(d, sz, (0.5, 0.5), rr)), GIF_SIZE))
save_gif("cursor_size.gif", frames, delay_ms=90)
# Angle threshold: the same fill, let past one fold and then past both.
for name, creases in (("smart_fill_angle_low.png", [0.40, 0.70]), ("smart_fill_angle_high.png", [0.70])):
done = flood_frames(SIZE, creases=creases, steps=22, hold=0)[-1]
save_png(name, done)
# Whole model: every face painted, and every face cleared.
whole = tri_mesh(9, jitter=0.55, seed=2)
save_png("select_all.png", raster(lambda d, sz: draw_mesh(d, sz, whole, np.ones(len(whole), bool)), SIZE))
save_png("erase_all.png", raster(lambda d, sz: draw_mesh(d, sz, whole, np.zeros(len(whole), bool)), SIZE))
# The views the panel offers over the painted area.
relief = shade(motif(3))
save_png("view_fast.png", posterize(relief, 3))
# Checker and Distortion are about how the texture is laid out, not about the texture, so they go
# on a plain swell where the checker itself is what there is to look at.
plain = shade(0.25 + 0.5 * dome())
save_png("view_checker.png", with_checker(plain, squares=7, strength=0.40))
save_png("view_distortion.png", with_checker(plain, squares=7, strength=0.40, warp=True))
save_png("view_wireframe.png", raster(lambda d, sz: draw_mesh_lines(d, sz, tri_mesh(9, jitter=0.55, seed=2)),
SIZE, background=relief))
# The projections: one ball, the checker put on it five different ways.
for kind in ("cube", "cylinder", "sphere", "view"):
save_png("map_%s.png" % kind, projected_sphere(kind))
save_png("map_unwrap.png", uv_islands())
# Refinement: where the triangles go, and how many.
density_feature = sampler(feature_density())
coarse = tri_mesh(3)
save_png("adaptive_off.png", raster(lambda d, sz: draw_mesh(d, sz, refine(coarse, lambda x, y: 1.0, 2)), SIZE))
save_png("adaptive_on.png", raster(lambda d, sz: draw_mesh(d, sz, refine(coarse, density_feature, 3)), SIZE))
disc = sampler(painted_area(192, radius=0.34))
painted_mesh = refine(coarse, disc, 3)
save_png("painted_only_on.png",
raster(lambda d, sz: draw_mesh(d, sz, painted_mesh,
np.array([disc(*np.mean(t, axis=0)) > 0.5 for t in painted_mesh])), SIZE))
everywhere = refine(coarse, lambda x, y: 1.0, 3)
save_png("painted_only_off.png",
raster(lambda d, sz: draw_mesh(d, sz, everywhere,
np.array([disc(*np.mean(t, axis=0)) > 0.5 for t in everywhere])), SIZE))
# Subdivide: every triangle into four, a step at a time.
level = tri_mesh(2)
frames = []
for _ in range(4):
frames.append(raster(lambda d, sz, m=level: draw_mesh(d, sz, m), GIF_SIZE))
level = [out for t in level for out in split4(t)]
save_gif("subdivide.gif", frames, delay_ms=650)
# Remesh: the model's own uneven triangulation, rebuilt at one size.
save_png("remesh_before.png", raster(lambda d, sz: draw_mesh(d, sz, irregular_mesh()), SIZE))
save_png("remesh_after.png", raster(lambda d, sz: draw_mesh(d, sz, tri_mesh(8, jitter=0.35, seed=9)), SIZE))
# Hard edges: a fold the remesh runs across, against one it lays its edges along.
crease = ((0.06, 0.84), (0.94, 0.20))
across = tri_mesh(5, jitter=0.45, seed=6)
save_png("preserve_edges_off.png",
raster(lambda d, sz: (draw_mesh(d, sz, across), draw_line(d, sz, crease)), SIZE))
save_png("preserve_edges_on.png",
raster(lambda d, sz: (draw_mesh(d, sz, snap_to_crease(across, crease, 0.13)),
draw_line(d, sz, crease)), SIZE))
# Thin triangles, and the same mesh without them.
save_png("collapse_slivers_off.png", raster(lambda d, sz: draw_mesh(d, sz, sliver_mesh()), SIZE))
save_png("collapse_slivers_on.png", raster(lambda d, sz: draw_mesh(d, sz, tri_mesh(7, jitter=0.4, seed=5)), SIZE))
# Smoothing the geometry after the texture is in it: the steps relax out of the surface.
stepped_profile = np.floor(profile_wave(GIF_SIZE, periods=2.0) * 5.0) / 4.0
frames = []
for t in sweep:
h = stepped_profile.copy()
for _ in range(int(round(t * 6))):
h = blur(h[None, :], 2)[0]
frames.append(raster(lambda d, sz, hh=h: draw_profile(d, sz, hh), GIF_SIZE, background=(0.11, 0.12, 0.13)))
save_gif("geometry_smoothing.gif", frames, delay_ms=100)
# Mixing, how many mixes, and denoise - all about which filament a spot ends up as, so all flat.
save_png("mix_off.png", levels_image(4))
save_png("mix_on.png", levels_image(4, dither=True))
save_png("mixed_colors_few.png", levels_image(3, dither=True))
save_png("mixed_colors_many.png", levels_image(8, dither=True))
save_png("denoise_off.png", levels_image(5, dither=True, speckle=0.05))
save_png("denoise_on.png", levels_image(5, dither=True))
if __name__ == "__main__":
main()
+175 -1
View File
@@ -22,6 +22,7 @@
#include "libslic3r/BrimEarsPoint.hpp"
#include "libslic3r/Config.hpp"
#include "libslic3r/CustomGCode.hpp"
#include "libslic3r/TextureDisplacement.hpp"
#include "libslic3r_version.h"
#include "bbs_3mf.hpp"
@@ -63,6 +64,7 @@
#include <ostream>
#include <sstream>
#include <set>
#include <iterator>
#include <memory>
#include <optional>
#include <map>
@@ -354,6 +356,13 @@ static constexpr const char* CUSTOM_SUPPORTS_ATTR = "paint_supports";
static constexpr const char* CUSTOM_FUZZY_SKIN_ATTR = "paint_fuzzy_skin";
static constexpr const char* CUSTOM_SEAM_ATTR = "paint_seam";
static constexpr const char* MMU_SEGMENTATION_ATTR = "paint_color";
// Texture displacement. One paint mask per layer slot, mirroring paint_color; the layer stack itself is
// a JSON file in the archive, named by the volume metadata key below (see add_texture_displacement()).
static constexpr const char* TEXTURE_DISPLACEMENT_ATTRS[Slic3r::TEXTURE_DISPLACEMENT_MAX_LAYERS] = {
"paint_texture_0", "paint_texture_1", "paint_texture_2", "paint_texture_3",
"paint_texture_4", "paint_texture_5", "paint_texture_6", "paint_texture_7" };
static constexpr const char* TEXTURE_DISPLACEMENT_KEY = "texture_displacement";
static constexpr const char* TEXTURE_DISPLACEMENT_DIR = "Metadata/texture_displacement/";
// BBS
static constexpr const char* FACE_PROPERTY_ATTR = "face_property";
@@ -847,11 +856,30 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
std::vector<std::string> custom_seam;
std::vector<std::string> mmu_segmentation;
std::vector<std::string> fuzzy_skin;
// One per texture displacement layer slot, indexed by triangle like the masks above, but
// empty for a slot nothing paints and no longer than its last painted triangle.
// See push_texture_displacement().
std::vector<std::string> texture_displacement[TEXTURE_DISPLACEMENT_MAX_LAYERS];
// BBS
std::vector<std::string> face_properties;
bool empty() { return vertices.empty() || triangles.empty(); }
// Records the paint mask of the triangle just appended, for one layer slot. A slot no
// triangle names keeps its vector empty instead of holding one empty string per triangle:
// eight slots over a million triangles would otherwise cost hundreds of megabytes, for
// every project, whether or not it uses the feature. The vector is padded up to the
// triangles seen so far the first time a value turns up, so it stays indexed by triangle,
// and both readers stop at its size, so trailing triangles without a value need nothing.
void push_texture_displacement(int slot, std::string &&mask_string) {
std::vector<std::string> &mask = texture_displacement[slot];
if (mask_string.empty() && mask.empty())
return;
assert(!triangles.empty());
mask.resize(triangles.size() - 1);
mask.push_back(std::move(mask_string));
}
// backup & restore
void swap(Geometry& o) {
std::swap(vertices, o.vertices);
@@ -867,6 +895,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
custom_seam.clear();
mmu_segmentation.clear();
fuzzy_skin.clear();
for (std::vector<std::string> &slot : texture_displacement)
slot.clear();
}
};
@@ -1200,6 +1230,11 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
/*IdToSlaSupportPointsMap m_sla_support_points;
IdToSlaDrainHolesMap m_sla_drain_holes;*/
PathToEmbossShapeFileMap m_path_to_emboss_shape_files;
// Texture displacement: every file under Metadata/texture_displacement/, by archive path. Volumes
// are built only after the whole archive has been walked, so by the time a volume names its JSON
// every file it could refer to is already in here, whatever order the archive happened to be in.
std::map<std::string, std::string> m_texture_displacement_files;
void _apply_texture_displacement(ModelVolume &volume, const std::string &json_path);
std::string m_curr_metadata_name;
std::string m_curr_characters;
std::string m_name;
@@ -2073,6 +2108,13 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
else if (_is_svg_shape_file(name)) {
_extract_embossed_svg_shape_file(name, archive, stat);
}
else if (boost::algorithm::istarts_with(name, TEXTURE_DISPLACEMENT_DIR)) {
std::string contents(stat.m_uncomp_size, '\0');
if (mz_zip_reader_extract_to_mem(&archive, stat.m_file_index, contents.data(), stat.m_uncomp_size, 0))
m_texture_displacement_files.emplace(name, std::move(contents));
else
add_error("Error while reading texture displacement data");
}
else if (!dont_load_config && boost::algorithm::iequals(name, SLICE_INFO_CONFIG_FILE)) {
m_parsing_slice_info = true;
//extract slice info from archive
@@ -3310,6 +3352,48 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
}
}*/
// Restores one volume's texture displacement stack from the JSON it named. Called while the volume is
// being built, which is after the whole archive has been walked - so every file it can refer to is
// already extracted, and no deferral is needed.
//
// A missing or malformed JSON leaves the volume with no layers but keeps the paint mask it already
// loaded, which is the same state as a project saved by a build without the feature: recoverable by
// picking the texture again rather than a hard failure.
void _BBS_3MF_Importer::_apply_texture_displacement(ModelVolume &volume, const std::string &json_path)
{
const auto json = m_texture_displacement_files.find(json_path);
if (json == m_texture_displacement_files.end()) {
add_error("Missing texture displacement data: " + json_path);
return;
}
std::vector<TextureDisplacementLayer> layers;
TextureDisplacementOptions options;
if (!texture_displacement_layers_from_json(json->second, layers, options)) {
add_error("Malformed texture displacement data: " + json_path);
return;
}
// The pixels come from the archive and nowhere else. A layer the archive carries no image for -
// saved while its texture had not been loaded, or written by a build that stored only the path -
// keeps its settings without an image, which the user recovers by picking the texture again.
// `layer.path` is left alone on purpose: it names a file on the machine the project was made on,
// so reading it here would let any project choose a local file to open - a UNC path among them -
// and have the next save copy it into the archive. The EmbossShape SVGs are read from the archive
// only, for the same reason.
for (TextureDisplacementLayer &layer : layers) {
if (layer.path_in_3mf.empty())
continue;
const auto image = m_texture_displacement_files.find(layer.path_in_3mf);
if (image == m_texture_displacement_files.end()) {
add_error("Missing texture displacement image: " + layer.path_in_3mf);
continue;
}
layer.image_data = std::make_shared<std::vector<unsigned char>>(image->second.begin(),
image->second.end());
}
volume.texture_displacement_layers = std::move(layers);
volume.texture_displacement_options = options;
}
void _BBS_3MF_Importer::_extract_embossed_svg_shape_file(const std::string &filename, mz_zip_archive &archive, const mz_zip_archive_file_stat &stat){
assert(m_path_to_emboss_shape_files.find(filename) == m_path_to_emboss_shape_files.end());
auto file = std::make_unique<std::string>(stat.m_uncomp_size, '\0');
@@ -3966,6 +4050,9 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
m_curr_object->geometry.custom_seam.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_SEAM_ATTR));
m_curr_object->geometry.mmu_segmentation.push_back(bbs_get_attribute_value_string(attributes, num_attributes, MMU_SEGMENTATION_ATTR));
m_curr_object->geometry.fuzzy_skin.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_FUZZY_SKIN_ATTR));
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot)
m_curr_object->geometry.push_texture_displacement(
slot, bbs_get_attribute_value_string(attributes, num_attributes, TEXTURE_DISPLACEMENT_ATTRS[slot]));
// BBS
m_curr_object->geometry.face_properties.push_back(bbs_get_attribute_value_string(attributes, num_attributes, FACE_PROPERTY_ATTR));
}
@@ -5317,6 +5404,18 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
volume->mmu_segmentation_facets.touch();
volume->fuzzy_skin_facets.shrink_to_fit();
volume->fuzzy_skin_facets.touch();
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) {
const std::vector<std::string> &mask = sub_object->geometry.texture_displacement[slot];
if (mask.empty())
continue; // written by a build without the feature
FacetsAnnotation &facets = volume->texture_displacement_facet(slot);
facets.reserve(triangles_count);
for (size_t i = 0; i < triangles_count && i < mask.size(); ++i)
if (!mask[i].empty())
facets.set_triangle_from_string(i, mask[i]);
facets.shrink_to_fit();
facets.touch();
}
}
volume->set_type(volume_data->part_type);
@@ -5332,6 +5431,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
for (const Metadata& metadata : volume_data->metadata) {
if (metadata.key == NAME_KEY)
volume->name = metadata.value;
else if (metadata.key == TEXTURE_DISPLACEMENT_KEY)
_apply_texture_displacement(*volume, metadata.value);
//else if ((metadata.key == MODIFIER_KEY) && (metadata.value == "1"))
// volume->set_type(ModelVolumeType::PARAMETER_MODIFIER);
//for old format
@@ -5496,12 +5597,28 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
volume->seam_facets.shrink_to_fit();
volume->mmu_segmentation_facets.shrink_to_fit();
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) {
const std::vector<std::string> &mask = geometry.texture_displacement[slot];
if (mask.empty())
continue; // written by a build without the feature
FacetsAnnotation &facets = volume->texture_displacement_facet(slot);
facets.reserve(triangles_count);
for (size_t i = 0; i < triangles_count; ++i) {
const size_t index = volume_data.first_triangle_id + i;
if (index < mask.size() && !mask[index].empty())
facets.set_triangle_from_string(i, mask[index]);
}
facets.shrink_to_fit();
facets.touch();
}
volume->set_type(volume_data.part_type);
// apply the remaining volume's metadata
for (const Metadata& metadata : volume_data.metadata) {
if (metadata.key == NAME_KEY)
volume->name = metadata.value;
else if (metadata.key == TEXTURE_DISPLACEMENT_KEY)
_apply_texture_displacement(*volume, metadata.value);
//else if ((metadata.key == MODIFIER_KEY) && (metadata.value == "1"))
// volume->set_type(ModelVolumeType::PARAMETER_MODIFIER);
//for old format
@@ -5805,6 +5922,9 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
current_object->geometry.custom_seam.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_SEAM_ATTR));
current_object->geometry.mmu_segmentation.push_back(bbs_get_attribute_value_string(attributes, num_attributes, MMU_SEGMENTATION_ATTR));
current_object->geometry.fuzzy_skin.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_FUZZY_SKIN_ATTR));
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot)
current_object->geometry.push_texture_displacement(
slot, bbs_get_attribute_value_string(attributes, num_attributes, TEXTURE_DISPLACEMENT_ATTRS[slot]));
// BBS
current_object->geometry.face_properties.push_back(bbs_get_attribute_value_string(attributes, num_attributes, FACE_PROPERTY_ATTR));
}
@@ -6808,6 +6928,9 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
stream << " <Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>\n";
stream << " <Default Extension=\"model\" ContentType=\"application/vnd.ms-package.3dmanufacturing-3dmodel+xml\"/>\n";
stream << " <Default Extension=\"png\" ContentType=\"image/png\"/>\n";
stream << " <Default Extension=\"jpg\" ContentType=\"image/jpeg\"/>\n";
stream << " <Default Extension=\"jpeg\" ContentType=\"image/jpeg\"/>\n";
stream << " <Default Extension=\"json\" ContentType=\"application/json\"/>\n";
stream << " <Default Extension=\"gcode\" ContentType=\"text/x.gcode\"/>\n";
stream << "</Types>";
@@ -7651,6 +7774,20 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
output_buffer += "\"";
}
// One attribute per texture displacement layer slot. Older readers ignore attributes they do
// not know, so a project written here still opens in a build without the feature - it just
// loses the paint, which is also all it could have done with it.
for (int slot = 0; slot < TEXTURE_DISPLACEMENT_MAX_LAYERS; ++slot) {
const std::string texture_paint = volume->texture_displacement_facet(slot).get_triangle_as_string(i);
if (texture_paint.empty())
continue;
output_buffer += " ";
output_buffer += TEXTURE_DISPLACEMENT_ATTRS[slot];
output_buffer += "=\"";
output_buffer += texture_paint;
output_buffer += "\"";
}
// BBS
if (i < its.properties.size()) {
std::string prop_str = its.properties[i].to_string();
@@ -8100,7 +8237,42 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
return file_path;
}
bool _BBS_3MF_Exporter::_add_model_config_file_to_archive(mz_zip_archive& archive, const Model& model, PlateDataPtrs& plate_data_list, const ObjectToObjectDataMap &objects_data, const DynamicPrintConfig& config, int export_plate_idx, bool save_gcode, bool use_loaded_id)
// Writes a volume's texture displacement stack into the archive and points the volume's metadata at it.
// The layer settings go in as JSON and each layer's texture as the image file it was loaded from, both
// referenced by path - the same split EmbossShape makes for its SVG. The image deliberately stays out of
// the XML: it is binary and routinely megabytes, and base64 in an attribute would bloat the one file
// every reader has to parse just to list the objects.
static void add_texture_displacement(std::stringstream &stream, const ModelVolume &volume, mz_zip_archive &archive,
const std::string &id)
{
if (volume.texture_displacement_layers.empty())
return;
// A copy, because path_in_3mf is only meaningful inside the archive being written and the volume
// being exported is const (and may be saved again, elsewhere, with different paths).
std::vector<TextureDisplacementLayer> layers = volume.texture_displacement_layers;
for (TextureDisplacementLayer &layer : layers) {
layer.path_in_3mf.clear();
if (!layer.image_data || layer.image_data->empty())
continue;
// Always .png: the importer converts whatever the user picked (jpg, bmp, ...) to an 8-bit
// greyscale PNG, so image_data is PNG whatever layer.path happens to be named.
const std::string path = std::string(TEXTURE_DISPLACEMENT_DIR) + id + "_" + std::to_string(layer.slot) + ".png";
// No deflate: a PNG is already compressed, so a second pass only costs time.
if (mz_zip_writer_add_mem(&archive, path.c_str(), layer.image_data->data(), layer.image_data->size(),
MZ_NO_COMPRESSION))
layer.path_in_3mf = path;
}
const std::string json = texture_displacement_layers_to_json(layers, volume.texture_displacement_options);
const std::string json_path = std::string(TEXTURE_DISPLACEMENT_DIR) + id + ".json";
if (!mz_zip_writer_add_mem(&archive, json_path.c_str(), json.data(), json.size(), MZ_DEFAULT_COMPRESSION))
return;
stream << " <" << METADATA_TAG << " " << KEY_ATTR << "=\"" << TEXTURE_DISPLACEMENT_KEY << "\" "
<< VALUE_ATTR << "=\"" << xml_escape(json_path) << "\"/>\n";
}
bool _BBS_3MF_Exporter::_add_model_config_file_to_archive(mz_zip_archive& archive, const Model& model, PlateDataPtrs& plate_data_list, const ObjectToObjectDataMap &objects_data, const DynamicPrintConfig& config, int export_plate_idx, bool save_gcode, bool use_loaded_id)
{
std::stringstream stream;
// Store mesh transformation in full precision, as the volumes are stored transformed and they need to be transformed back
@@ -8211,6 +8383,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
if (const std::optional<EmbossShape> &es = volume->emboss_shape; es.has_value()) {
to_xml(stream, *es, *volume, archive, m_fullpath_sources);
}
add_texture_displacement(stream, *volume, archive, std::to_string(volume->id().id));
if (const std::optional<TextConfiguration> &tc = volume->text_configuration;
tc.has_value())
+252
View File
@@ -19,6 +19,7 @@
#include <mutex>
#include <numeric>
#include <optional>
#include "nlohmann/json.hpp"
#include <queue>
#include <ratio>
#include <string>
@@ -5166,5 +5167,256 @@ indexed_triangle_set cut_mesh_at_steps(const indexed_triangle_set &mesh, const s
return out;
}
// ---------------------------------------------------------------------------------------------
// Project persistence. See the declarations in TextureDisplacement.hpp for why this is JSON rather
// than the cereal stream next to them.
// ---------------------------------------------------------------------------------------------
namespace {
nlohmann::json vec2_to_json(const Vec2f &v) { return nlohmann::json::array({ v.x(), v.y() }); }
nlohmann::json vec3_to_json(const Vec3f &v) { return nlohmann::json::array({ v.x(), v.y(), v.z() }); }
Vec2f vec2_from_json(const nlohmann::json &j, const Vec2f &fallback)
{
if (!j.is_array() || j.size() != 2)
return fallback;
return Vec2f(j[0].get<float>(), j[1].get<float>());
}
Vec3f vec3_from_json(const nlohmann::json &j, const Vec3f &fallback)
{
if (!j.is_array() || j.size() != 3)
return fallback;
return Vec3f(j[0].get<float>(), j[1].get<float>(), j[2].get<float>());
}
// Reads key `k` into `out` when it is present and of the expected type, leaving `out` alone otherwise.
// That "leave it alone" is the whole point: every member starts at its struct default, so a project
// written by an older build simply keeps the defaults for whatever it did not know about.
template<class T> void read(const nlohmann::json &j, const char *k, T &out)
{
const auto it = j.find(k);
if (it == j.end())
return;
try {
out = it->get<T>();
} catch (...) {
}
}
void read_enum(const nlohmann::json &j, const char *k, int &out)
{
const auto it = j.find(k);
if (it != j.end() && it->is_number_integer())
out = it->get<int>();
}
} // namespace
std::string texture_displacement_layers_to_json(const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementOptions &options)
{
nlohmann::json root;
root["version"] = 1;
nlohmann::json &opt = root["options"];
opt["displace_border"] = options.displace_border;
opt["smooth_enabled"] = options.smooth_enabled;
opt["smooth_strength"] = options.smooth_strength;
opt["smooth_iterations"] = options.smooth_iterations;
opt["smooth_skip_border"] = options.smooth_skip_border;
opt["pipeline_v2"] = options.pipeline_v2;
opt["v2_refine_mm"] = options.v2_refine_mm;
opt["v2_regularize"] = options.v2_regularize;
opt["v2_max_triangles_k"] = options.v2_max_triangles_k;
opt["v2_relocate"] = options.v2_relocate;
opt["v2_flip_edges"] = options.v2_flip_edges;
opt["color_mix_enabled"] = options.color_mix_enabled;
opt["color_despeckle"] = options.color_despeckle;
opt["color_mix_count"] = options.color_mix_count;
opt["active_slot"] = options.active_slot;
opt["panel_mode"] = options.panel_mode;
nlohmann::json &arr = root["layers"];
arr = nlohmann::json::array();
for (const TextureDisplacementLayer &l : layers) {
nlohmann::json j;
j["slot"] = l.slot;
j["name"] = l.name;
j["path"] = l.path;
j["path_in_3mf"] = l.path_in_3mf;
j["depth_mm"] = l.depth_mm;
j["tiling_scale"] = l.tiling_scale;
j["rotation_deg"] = l.rotation_deg;
j["offset"] = vec2_to_json(l.offset);
j["invert"] = l.invert;
j["midlevel"] = l.midlevel;
j["smoothing"] = l.smoothing;
j["edge_smoothing"] = l.edge_smoothing;
j["edge_smoothing_amount"] = l.edge_smoothing_amount;
j["auto_connect_islands"] = l.auto_connect_islands;
j["tile_enabled"] = l.tile_enabled;
j["tile_method"] = int(l.tile_method);
j["projection_method"] = int(l.projection_method);
j["blend_mode"] = int(l.blend_mode);
j["color_enabled"] = l.color_enabled;
j["lscm_seam_angle_deg"] = l.lscm_seam_angle_deg;
j["island_padding_mm"] = l.island_padding_mm;
j["view_project_right"] = vec3_to_json(l.view_project_right);
j["view_project_up"] = vec3_to_json(l.view_project_up);
j["view_project_projective"] = l.view_project_projective;
j["view_project_matrix"] = l.view_project_matrix;
// Flat pairs rather than nested arrays: shorter, and the reader can simply ignore a trailing
// odd element instead of having to validate every sub-array.
nlohmann::json &seams = j["lscm_seam_edges"];
seams = nlohmann::json::array();
for (const auto &[a, b] : l.lscm_seam_edges) {
seams.push_back(a);
seams.push_back(b);
}
nlohmann::json &islands = j["islands"];
islands = nlohmann::json::array();
for (const TextureIsland &island : l.islands)
islands.push_back(nlohmann::json::array(
{ island.offset.x(), island.offset.y(), island.rotation_deg, island.scale }));
j["island_groups"] = l.island_groups;
nlohmann::json &overrides = j["lscm_uv_overrides"];
overrides = nlohmann::json::array();
for (const auto &[key, uv] : l.lscm_uv_overrides)
overrides.push_back(nlohmann::json::array({ key, uv.x(), uv.y() }));
arr.push_back(std::move(j));
}
return root.dump();
}
bool texture_displacement_layers_from_json(const std::string &text,
std::vector<TextureDisplacementLayer> &layers,
TextureDisplacementOptions &options)
{
nlohmann::json root;
try {
root = nlohmann::json::parse(text);
} catch (...) {
return false;
}
if (!root.is_object())
return false;
TextureDisplacementOptions out_options;
std::vector<TextureDisplacementLayer> out_layers;
// Past the parse, a value of the wrong type still throws: get<int>() on a string, get<float>() on
// null. Nothing between here and the project load catches that, so one bad number would fail the
// whole file instead of this one block. Report it malformed and let the volume load without it.
try {
if (const auto it = root.find("options"); it != root.end() && it->is_object()) {
const nlohmann::json &opt = *it;
read(opt, "displace_border", out_options.displace_border);
read(opt, "smooth_enabled", out_options.smooth_enabled);
read(opt, "smooth_strength", out_options.smooth_strength);
read(opt, "smooth_iterations", out_options.smooth_iterations);
read(opt, "smooth_skip_border", out_options.smooth_skip_border);
read(opt, "pipeline_v2", out_options.pipeline_v2);
read(opt, "v2_refine_mm", out_options.v2_refine_mm);
read(opt, "v2_regularize", out_options.v2_regularize);
read(opt, "v2_max_triangles_k", out_options.v2_max_triangles_k);
read(opt, "v2_relocate", out_options.v2_relocate);
read(opt, "v2_flip_edges", out_options.v2_flip_edges);
read(opt, "color_mix_enabled", out_options.color_mix_enabled);
read(opt, "color_despeckle", out_options.color_despeckle);
read(opt, "color_mix_count", out_options.color_mix_count);
read(opt, "active_slot", out_options.active_slot);
read(opt, "panel_mode", out_options.panel_mode);
out_options.active_slot = std::clamp(out_options.active_slot, 0, int(TEXTURE_DISPLACEMENT_MAX_LAYERS) - 1);
out_options.panel_mode = std::clamp(out_options.panel_mode, 0, 1);
}
if (const auto it = root.find("layers"); it != root.end() && it->is_array()) {
for (const nlohmann::json &j : *it) {
if (!j.is_object())
continue;
TextureDisplacementLayer l;
read(j, "slot", l.slot);
read(j, "name", l.name);
read(j, "path", l.path);
read(j, "path_in_3mf", l.path_in_3mf);
read(j, "depth_mm", l.depth_mm);
read(j, "tiling_scale", l.tiling_scale);
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);
read(j, "invert", l.invert);
read(j, "midlevel", l.midlevel);
read(j, "smoothing", l.smoothing);
read(j, "edge_smoothing", l.edge_smoothing);
read(j, "edge_smoothing_amount", l.edge_smoothing_amount);
read(j, "auto_connect_islands", l.auto_connect_islands);
read(j, "tile_enabled", l.tile_enabled);
read(j, "color_enabled", l.color_enabled);
int tile_method = int(l.tile_method), projection = int(l.projection_method), blend = int(l.blend_mode);
read_enum(j, "tile_method", tile_method);
read_enum(j, "projection_method", projection);
read_enum(j, "blend_mode", blend);
l.tile_method = TextureTileMethod(tile_method);
l.projection_method = TextureProjectionMethod(std::clamp(projection, 0, 4));
l.blend_mode = TextureBlendMode(blend);
read(j, "lscm_seam_angle_deg", l.lscm_seam_angle_deg);
read(j, "island_padding_mm", l.island_padding_mm);
if (const auto v = j.find("view_project_right"); v != j.end())
l.view_project_right = vec3_from_json(*v, l.view_project_right);
if (const auto v = j.find("view_project_up"); v != j.end())
l.view_project_up = vec3_from_json(*v, l.view_project_up);
read(j, "view_project_projective", l.view_project_projective);
if (const auto v = j.find("view_project_matrix"); v != j.end() && v->is_array() && v->size() == 12)
for (size_t i = 0; i < 12; ++i)
l.view_project_matrix[i] = (*v)[i].get<float>();
if (const auto v = j.find("lscm_seam_edges"); v != j.end() && v->is_array())
for (size_t i = 0; i + 1 < v->size(); i += 2)
l.lscm_seam_edges.emplace_back((*v)[i].get<int>(), (*v)[i + 1].get<int>());
if (const auto v = j.find("islands"); v != j.end() && v->is_array())
for (const nlohmann::json &e : *v) {
if (!e.is_array() || e.size() != 4)
continue;
TextureIsland island;
island.offset = Vec2f(e[0].get<float>(), e[1].get<float>());
island.rotation_deg = e[2].get<float>();
island.scale = e[3].get<float>();
l.islands.push_back(island);
}
read(j, "island_groups", l.island_groups);
if (const auto v = j.find("lscm_uv_overrides"); v != j.end() && v->is_array())
for (const nlohmann::json &e : *v) {
if (!e.is_array() || e.size() != 3)
continue;
l.lscm_uv_overrides.emplace_back(e[0].get<int>(), Vec2f(e[1].get<float>(), e[2].get<float>()));
}
out_layers.push_back(std::move(l));
}
}
} catch (...) {
return false;
}
layers = std::move(out_layers);
options = out_options;
return true;
}
} // namespace Slic3r
+25 -1
View File
@@ -397,14 +397,38 @@ struct TextureDisplacementOptions
// finer than the mesh leaves behind, without eating features that are genuinely a facet wide.
int color_despeckle = 2;
// Where the panel was left, rather than anything the bake reads. It lives with the volume because
// that is what it describes: reopening a project should put the user back on the layer they were
// editing, in the mode they were editing it in, not on slot 0 in Standard.
int active_slot = 0;
int panel_mode = 0; // 0 Standard, 1 Pro
template<class Archive> void serialize(Archive &ar)
{
ar(displace_border, smooth_enabled, smooth_strength, smooth_iterations, smooth_skip_border,
pipeline_v2, v2_refine_mm, v2_regularize, v2_max_triangles_k,
v2_relocate, color_mix_enabled, color_despeckle, color_mix_count);
v2_relocate, v2_flip_edges, color_mix_enabled, color_despeckle, color_mix_count,
active_slot, panel_mode);
}
};
// Project persistence (see bbs_3mf.cpp). The layer stack and the per-volume options are written to the
// .3mf as JSON rather than through the cereal save()/load() above: those two are positional and
// unversioned, which is right for the undo/redo stack they serve (one session, one binary) but would
// make every future field a project-breaking change. A JSON object tolerates both directions - an
// unknown key is ignored, a missing one keeps the member's default - so old projects keep loading and
// new ones degrade gracefully in older builds.
//
// The texture image itself is *not* in here. It is a binary blob that belongs in the archive as a file
// of its own, exactly as EmbossShape stores its SVG; `path_in_3mf` names that file, and the caller is
// responsible for writing it and for filling `image_data` back in on load.
std::string texture_displacement_layers_to_json(const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementOptions &options);
// Returns false and leaves both outputs untouched when the text is not valid JSON.
bool texture_displacement_layers_from_json(const std::string &text,
std::vector<TextureDisplacementLayer> &layers,
TextureDisplacementOptions &options);
// How much detail a height texture carries: central differences of the grey image, the mean gradient
// and the share of texels steeper than 30 grey levels, mapped to how many texels one mesh edge may
// span (1 for a hard-edged image, 4 for a smooth one). Cached per image, like the decode.
-2
View File
@@ -20,8 +20,6 @@ set(SLIC3R_GUI_SOURCES
GUI/Widgets/FilamentLoad.hpp
GUI/AboutDialog.cpp
GUI/AboutDialog.hpp
GUI/AnimatedTexture.cpp
GUI/AnimatedTexture.hpp
GUI/AmsMappingPopup.cpp
GUI/AmsMappingPopupUpdate.cpp
GUI/AmsMappingPopup.hpp
-240
View File
@@ -1,240 +0,0 @@
#include "AnimatedTexture.hpp"
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <fstream>
#include <ios>
#include <iterator>
#include <memory>
#include <string>
#include <utility>
#include <vector>
#include <boost/filesystem/operations.hpp>
#include <boost/filesystem/path.hpp>
#include <boost/log/trivial.hpp>
#include <boost/system/error_code.hpp>
#include <wx/animdecod.h>
#include <wx/gdicmn.h>
#include <wx/gifdecod.h>
#include <wx/image.h>
#include <wx/log.h>
#include <wx/mstream.h>
#include "slic3r/GUI/GLTexture.hpp"
namespace Slic3r::GUI {
namespace {
std::vector<unsigned char> read_file_bytes(const std::string &path)
{
std::ifstream ifs(path, std::ios::binary);
return std::vector<unsigned char>(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
}
bool has_gif_signature(const std::vector<unsigned char> &bytes)
{
return bytes.size() > 6 && bytes[0] == 'G' && bytes[1] == 'I' && bytes[2] == 'F';
}
// wxImage keeps colour and alpha in two separate buffers, and a file with no alpha at all has none;
// OpenGL wants the four channels interleaved.
std::vector<unsigned char> image_to_rgba(const wxImage &image)
{
const size_t n = size_t(image.GetWidth()) * size_t(image.GetHeight());
const unsigned char *rgb = image.GetData();
const unsigned char *a = image.HasAlpha() ? image.GetAlpha() : nullptr;
std::vector<unsigned char> out(n * 4);
for (size_t i = 0; i < n; ++i) {
out[i * 4 + 0] = rgb[i * 3 + 0];
out[i * 4 + 1] = rgb[i * 3 + 1];
out[i * 4 + 2] = rgb[i * 3 + 2];
out[i * 4 + 3] = a != nullptr ? a[i] : 255;
}
return out;
}
// One decoded frame on its way to the GPU: the whole animation's area, already composited.
struct DecodedFrame
{
std::vector<unsigned char> rgba;
double delay; // seconds
};
// Every frame of a GIF, composited onto the animation's own canvas.
//
// A GIF frame is a sub-rectangle that is drawn over what came before it, and the one before says how
// to clean up after itself (leave it, erase it, put back what it covered). Without replaying that, an
// animation that only re-sends the pixels that changed - which is most of them - draws as a flicker of
// disconnected patches. wxGIFDecoder hands over the raw palette indices, which is what makes replaying
// it here straightforward: a transparent index means "keep what is underneath", nothing else does.
bool decode_gif(const std::vector<unsigned char> &bytes, int &width, int &height, std::vector<DecodedFrame> &out)
{
wxMemoryInputStream stream(bytes.data(), bytes.size());
wxGIFDecoder decoder;
// A truncated file still decodes everything up to the cut, which is worth showing.
const wxGIFErrorCode err = decoder.LoadGIF(stream);
if ((err != wxGIF_OK && err != wxGIF_TRUNCATED) || decoder.GetFrameCount() == 0)
return false;
const wxSize size = decoder.GetAnimationSize();
width = size.GetWidth();
height = size.GetHeight();
if (width <= 0 || height <= 0)
return false;
std::vector<unsigned char> canvas(size_t(width) * size_t(height) * 4, 0);
std::vector<unsigned char> saved;
for (unsigned int i = 0; i < decoder.GetFrameCount(); ++i) {
const wxSize frame_size = decoder.GetFrameSize(i);
const wxPoint frame_pos = decoder.GetFramePosition(i);
const wxAnimationDisposal disposal = decoder.GetDisposalMethod(i);
const unsigned char *indices = decoder.GetData(i);
const unsigned char *palette = decoder.GetPalette(i);
const int transparent = decoder.GetTransparentColourIndex(i);
if (disposal == wxANIM_TOPREVIOUS)
saved = canvas;
if (indices != nullptr && palette != nullptr)
for (int y = 0; y < frame_size.GetHeight(); ++y) {
const int cy = frame_pos.y + y;
if (cy < 0 || cy >= height)
continue;
for (int x = 0; x < frame_size.GetWidth(); ++x) {
const int cx = frame_pos.x + x;
if (cx < 0 || cx >= width)
continue;
const unsigned char index = indices[size_t(y) * size_t(frame_size.GetWidth()) + size_t(x)];
if (int(index) == transparent)
continue;
unsigned char *dst = &canvas[(size_t(cy) * size_t(width) + size_t(cx)) * 4];
dst[0] = palette[3 * size_t(index) + 0];
dst[1] = palette[3 * size_t(index) + 1];
dst[2] = palette[3 * size_t(index) + 2];
dst[3] = 255;
}
}
// Browsers treat 0 and 1 hundredths of a second as "as fast as the file can mean", and settle
// on 100 ms; an animation authored against that convention plays at the intended speed only if
// this does the same.
const long delay_ms = decoder.GetDelay(i);
out.push_back({ canvas, (delay_ms <= 10 ? 100.0 : double(delay_ms)) / 1000.0 });
if (disposal == wxANIM_TOBACKGROUND) {
for (int y = 0; y < frame_size.GetHeight(); ++y) {
const int cy = frame_pos.y + y;
if (cy < 0 || cy >= height)
continue;
const int x0 = std::max(0, frame_pos.x);
const int x1 = std::min(width, frame_pos.x + frame_size.GetWidth());
if (x1 > x0)
std::fill_n(&canvas[(size_t(cy) * size_t(width) + size_t(x0)) * 4], size_t(x1 - x0) * 4, 0);
}
} else if (disposal == wxANIM_TOPREVIOUS) {
canvas = saved;
}
}
return !out.empty();
}
} // namespace
bool AnimatedTexture::load_from_file(const std::string &path)
{
reset();
boost::system::error_code ec;
if (!boost::filesystem::is_regular_file(path, ec))
return false;
const std::vector<unsigned char> bytes = read_file_bytes(path);
if (bytes.empty())
return false;
std::vector<DecodedFrame> frames;
int width = 0, height = 0;
if (has_gif_signature(bytes)) {
if (!decode_gif(bytes, width, height, frames)) {
BOOST_LOG_TRIVIAL(error) << "AnimatedTexture: cannot decode GIF " << path;
return false;
}
} else {
wxLogNull no_log; // a format wxWidgets cannot read is handled here, not in a dialog
wxImage image;
wxMemoryInputStream stream(bytes.data(), bytes.size());
if (!image.LoadFile(stream, wxBITMAP_TYPE_ANY) || !image.IsOk()) {
BOOST_LOG_TRIVIAL(error) << "AnimatedTexture: cannot decode image " << path;
return false;
}
width = image.GetWidth();
height = image.GetHeight();
frames.push_back({ image_to_rgba(image), 0.0 });
}
m_width = width;
m_height = height;
m_frames.reserve(frames.size());
for (DecodedFrame &frame : frames) {
auto texture = std::make_unique<GLTexture>();
// Mipmapped: a hint is drawn at whatever width the panel has room for, which is usually
// smaller than the source, and an unfiltered downscale of a diagram is all aliasing.
if (!texture->load_from_raw_data(std::move(frame.rgba), (unsigned int) width, (unsigned int) height, false,
/* use_mipmaps */ true)) {
BOOST_LOG_TRIVIAL(error) << "AnimatedTexture: cannot upload " << path;
reset();
return false;
}
m_frames.push_back({ std::move(texture), m_duration, frame.delay });
m_duration += frame.delay;
}
return true;
}
void AnimatedTexture::reset()
{
m_frames.clear();
m_width = 0;
m_height = 0;
m_duration = 0.;
}
unsigned int AnimatedTexture::texture_id(double time) const
{
if (m_frames.empty())
return 0;
if (m_frames.size() == 1 || m_duration <= 0.)
return m_frames.front().texture->get_id();
const double t = time - m_duration * std::floor(time / m_duration);
// Frames are few; a linear walk beats keeping an index around and getting it stale.
size_t i = 0;
while (i + 1 < m_frames.size() && t >= m_frames[i].start + m_frames[i].delay)
++i;
return m_frames[i].texture->get_id();
}
const AnimatedTexture *AnimatedTextureCache::get(const std::string &path)
{
const auto it = m_items.find(path);
if (it != m_items.end())
return it->second.get();
auto texture = std::make_unique<AnimatedTexture>();
if (!texture->load_from_file(path))
texture.reset(); // remembered as a failure, so the file is not re-read every frame
const AnimatedTexture *result = texture.get();
m_items.emplace(path, std::move(texture));
return result;
}
} // namespace Slic3r::GUI
-75
View File
@@ -1,75 +0,0 @@
#ifndef slic3r_AnimatedTexture_hpp_
#define slic3r_AnimatedTexture_hpp_
#include <map>
#include <memory>
#include <string>
#include <vector>
#include "slic3r/GUI/GLTexture.hpp"
namespace Slic3r::GUI {
// An illustration for the UI to draw: one still image, or the frames of an animated GIF together with
// the delays the file asks for. Decoded on the CPU once and uploaded as one GL texture per frame.
//
// A texture per frame rather than an atlas: these are small hints a few hundred pixels across and a
// handful of frames long, so the extra textures cost little, while drawing a frame stays a plain
// ImGui::Image with no UV arithmetic and no dependence on the maximum texture size.
//
// load_from_file() uploads to the GPU, so it needs a current GL context - call it from rendering code.
class AnimatedTexture
{
public:
AnimatedTexture() = default;
AnimatedTexture(AnimatedTexture &&) = default;
AnimatedTexture &operator=(AnimatedTexture &&) = default;
AnimatedTexture(const AnimatedTexture &) = delete;
AnimatedTexture &operator=(const AnimatedTexture &) = delete;
// PNG, JPG and BMP through wxImage; GIF, animated or not, through wxGIFDecoder. False, and nothing
// loaded, if the file is missing or cannot be decoded.
bool load_from_file(const std::string &path);
void reset();
bool is_valid() const { return !m_frames.empty(); }
bool is_animated() const { return m_frames.size() > 1; }
int width() const { return m_width; }
int height() const { return m_height; }
// Length of one loop, in seconds; 0 for a still image.
double duration() const { return m_duration; }
// The texture to draw `time` seconds into the loop, which wraps. 0 when nothing is loaded.
unsigned int texture_id(double time) const;
private:
struct Frame
{
std::unique_ptr<GLTexture> texture;
double start{0.}; // seconds into the loop this frame appears at
double delay{0.}; // seconds it stays up
};
std::vector<Frame> m_frames;
int m_width{0};
int m_height{0};
double m_duration{0.};
};
// The illustrations a panel has shown at least once, keyed by absolute path, decoded and uploaded on
// first use and kept until clear() - which has to run while the GL context is still current.
class AnimatedTextureCache
{
public:
// Null when the file is missing or cannot be decoded. The failure is remembered too, so a tooltip
// whose illustration has not been drawn yet costs one lookup, not one decode attempt per frame.
const AnimatedTexture *get(const std::string &path);
void clear() { m_items.clear(); }
private:
std::map<std::string, std::unique_ptr<AnimatedTexture>> m_items; // null value: tried once, failed
};
} // namespace Slic3r::GUI
#endif // slic3r_AnimatedTexture_hpp_
-7
View File
@@ -3232,16 +3232,9 @@ bool GUI_App::on_init_inner()
#ifdef _MSW_DARK_MODE
#ifndef __WINDOWS__
// The label colours and the dark mode flag were set above from the saved mode. When the system theme
// differs from it, refresh them, or dialogs that map their colours stay in the old mode for this session.
const bool saved_dark_mode = dark_mode();
wxSystemAppearance app = wxSystemSettings::GetAppearance();
GUI::wxGetApp().app_config->set("dark_color_mode", app.IsDark() ? "1" : "0");
GUI::wxGetApp().app_config->save();
if (dark_mode() != saved_dark_mode) {
init_label_colours();
Update_dark_mode_flag();
}
#endif // __APPLE__
@@ -3558,6 +3558,7 @@ const ModelVolume* GLGizmoTextureDisplacement::texture_volume() const
void GLGizmoTextureDisplacement::update_model_object()
{
store_panel_state();
bool updated = false;
ModelObject *mo = m_c->selection_info()->model_object();
int idx = -1;
@@ -3593,6 +3594,24 @@ void GLGizmoTextureDisplacement::update_from_model_object(bool first_update)
{
wxBusyCursor wait;
// Opening the gizmo puts the panel back where it was left: the layer and the mode come off the
// volume, because they are the user's place in this model's stack, and the dock state off the
// application config, because it is about their window. Only on open - this also runs on an
// undo/redo and on a layer switch, where overriding what the user is doing now would be wrong.
if (first_update) {
if (const ModelVolume *mv = texture_volume(); mv != nullptr) {
const TextureDisplacementOptions &opts = mv->texture_displacement_options;
m_panel_mode = std::clamp(opts.panel_mode, 0, 1);
// Only if that slot still holds a layer: the stack can have been edited by anything that
// does not go through the panel, and a selection pointing at nothing shows an empty panel.
const auto &stack = mv->texture_displacement_layers;
if (std::any_of(stack.begin(), stack.end(),
[&opts](const TextureDisplacementLayer &l) { return l.slot == opts.active_slot; }))
m_active_layer_slot = opts.active_slot;
}
m_undocked = wxGetApp().app_config->get_bool(UNDOCKED_CONFIG_KEY);
}
const ModelObject *mo = m_c->selection_info()->model_object();
m_triangle_selectors.clear();
// The base keeps the last mesh a fill tool hovered, and render_painter_gizmo() reads it as a hover that is
@@ -3641,6 +3660,14 @@ void GLGizmoTextureDisplacement::update_from_model_object(bool first_update)
rebuild_preview();
}
void GLGizmoTextureDisplacement::store_panel_state()
{
if (ModelVolume *mv = texture_volume(); mv != nullptr) {
mv->texture_displacement_options.active_slot = m_active_layer_slot;
mv->texture_displacement_options.panel_mode = m_panel_mode;
}
}
void GLGizmoTextureDisplacement::set_active_layer(int slot)
{
if (slot == m_active_layer_slot)
@@ -3649,6 +3676,7 @@ void GLGizmoTextureDisplacement::set_active_layer(int slot)
// reflect - otherwise they would be silently lost.
update_model_object();
m_active_layer_slot = slot;
store_panel_state();
update_from_model_object(false);
// The on-canvas gizmo (if on) is anchored to whichever layer is active - keep it in sync
// instead of leaving it pointing at the previous layer's (now stale) paint patch.
@@ -3701,189 +3729,6 @@ void GLGizmoTextureDisplacement::ensure_panel_icons()
m_panel_icon_map[names[i]] = icons[i];
}
// Seconds on a real clock, for the tooltips' own timing.
//
// Not ImGui::GetTime(): nothing in OrcaSlicer ever sets io.DeltaTime, so it keeps imgui's default of
// 1/60 per frame and its clock counts frames rather than seconds. On a canvas that renders only when
// something asks it to, that is not a clock at all - an animation driven by it crawled while the canvas
// was idle and raced while a moving mouse was rendering it flat out.
static double tip_now()
{
using clock = std::chrono::steady_clock;
static const clock::time_point base = clock::now();
return std::chrono::duration<double>(clock::now() - base).count();
}
// How long the pointer has to rest on a control before its tooltip appears. The panel is dense and the
// tooltip lands on the model beside it, so with no wait at all a sweep from the top of the panel down to
// Bake flashes a dozen large panels over the scene. Once one has just been up the wait drops to almost
// nothing, so reading several settings in a row stays quick - but not to nothing, or that same sweep
// would flash again the moment one tooltip had been seen.
static constexpr double TIP_DELAY = 0.45;
static constexpr double TIP_DELAY_AGAIN = 0.10;
static constexpr double TIP_STICKY = 0.70;
void GLGizmoTextureDisplacement::panel_tooltip(const wxString &text, const std::vector<TipMedia> &media)
{
panel_tooltip(into_u8(text), media);
}
void GLGizmoTextureDisplacement::panel_tooltip(const std::string &text, const std::vector<TipMedia> &media)
{
// Nothing explains a control while the control is being used: dragging a slider, typing a value
// into one, picking from a combo, dragging a layer card. The pointer sits on the very thing being
// edited, so without this the tooltip covers the model exactly while the user is watching it change.
// The full wait comes back afterwards rather than the short one - having just let go of a slider is
// not a reason to be shown a picture of it.
if (ImGui::IsAnyItemActive() || ImGui::IsAnyMouseDown()) {
m_tip_item = ImVec2(-1.f, -1.f);
m_tip_last_shown = -1e9;
return;
}
// The item's position stands in for its identity: moving to another control restarts the wait, and
// staying on this one carries it on.
const double now = tip_now();
const ImVec2 item = ImGui::GetItemRectMin();
if (item.x != m_tip_item.x || item.y != m_tip_item.y) {
m_tip_item = item;
m_tip_hover_since = now;
}
if (now - m_tip_hover_since < (now - m_tip_last_shown < TIP_STICKY ? TIP_DELAY_AGAIN : TIP_DELAY)) {
// A pointer resting on a control produces no events at all, so unless the wait asks for frames
// itself nothing redraws the canvas and the wait never finishes - the tooltip then appears only
// on the next pixel of mouse movement.
//
// request_extra_frame() and not schedule_extra_frame(): the latter only arranges a timer, whose
// event an idle event is assumed to follow, and GLCanvas3D::on_idle() renders nothing unless the
// canvas was already dirty when it ran. request_extra_frame() ends on_idle with
// evt.RequestMore(), which obliges wx to deliver the next one. It is what 3DBed's fade uses.
// Costing a few hundred milliseconds of frames is the cheaper half of that trade.
m_parent.request_extra_frame();
return;
}
m_tip_last_shown = now;
// ImGui hides a window on the first frame it is submitted, to measure it before showing it - and
// for a tooltip it does so every time one reappears, because the windows are recycled (see the
// Popup|Tooltip branch of Begin()). So the frame on which a tooltip is decided is never the frame
// on which it is seen, and one that then asks for nothing more stays invisible until the next pixel
// of mouse movement happens to produce another frame. Keep frames coming until it has settled.
const int shown_frame = ImGui::GetFrameCount();
m_tip_settled = (shown_frame == m_tip_shown_frame + 1) ? m_tip_settled + 1 : 0;
m_tip_shown_frame = shown_frame;
if (m_tip_settled < 2)
m_parent.request_extra_frame();
const ImGuiStyle &style = ImGui::GetStyle();
const float gap = m_imgui->scaled(0.5f);
const float wrap = m_imgui->scaled(20.f);
// The width has to be known here, not left to ImGui, because the tooltip is placed by its left edge
// (below) and an illustration has to be laid out against something. One carrying illustrations takes
// the full wrap width; a plain one takes exactly what its text needs, as it did when ImGui sized it,
// so a two-word tooltip is not a wide box with two words in it.
const float text_w = ImGui::CalcTextSize(text.c_str(), nullptr, false, wrap).x;
const float width = (media.empty() ? std::min(wrap, std::ceil(text_w)) : wrap) + 2.f * style.WindowPadding.x;
// Beside the anchor (the panel, or the texture picker when the tooltip comes from inside it), never
// over it - the same reasoning, and the same placement, as the picker popup itself. ImGui normally
// keeps a tooltip glued to the mouse; it honours a position set here instead (see the Orca branch in
// imgui's Begin()), and still clamps the result inside the canvas.
const bool left = m_tooltip_anchor_min.x - gap - width >= 0.f;
const float x = left ? m_tooltip_anchor_min.x - gap - width : m_tooltip_anchor_max.x + gap;
ImGui::SetNextWindowPos(ImVec2(x, ImGui::GetItemRectMin().y)); // level with the control it explains
ImGui::SetNextWindowSize(ImVec2(width, 0.f)); // height still follows the content
ImGui::BeginTooltip();
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.00f, 1.00f, 1.00f, 1.00f));
ImGui::PushTextWrapPos(0.f); // the window's own edge, which is what the fixed width above sets
ImGui::TextUnformatted(text.c_str());
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
render_tip_media(media, width - 2.f * style.WindowPadding.x);
ImGui::EndTooltip();
}
void GLGizmoTextureDisplacement::render_tip_media(const std::vector<TipMedia> &media, float width)
{
// Collect what is actually there first: the layout divides the width between the illustrations, so
// one that is missing has to drop out of the count rather than leave a gap.
std::vector<std::pair<const AnimatedTexture *, const TipMedia *>> shown;
std::string key;
for (const TipMedia &m : media) {
const AnimatedTexture *texture =
m_tip_media.get((boost::filesystem::path(resources_dir()) / "images" / "texture_displacement" / m.file).string());
if (texture != nullptr && texture->is_valid() && texture->width() > 0 && texture->height() > 0) {
shown.emplace_back(texture, &m);
key += m.file + "|";
}
}
if (shown.empty())
return;
// Each animation plays from its first frame when its tooltip opens: these are short loops showing
// one setting going from one value to another, and joining one halfway through shows the user the
// end of a change they never saw the start of.
// Restart on a different tooltip, and on the same one opening again - which is what a gap in the
// frames it was drawn on means, since it is drawn on every frame for as long as it is on screen.
const int this_frame = ImGui::GetFrameCount();
if (key != m_tip_media_key || this_frame != m_tip_media_frame + 1) {
m_tip_media_key = key;
m_tip_media_start = tip_now();
}
m_tip_media_frame = this_frame;
const double time = tip_now() - m_tip_media_start;
const ImGuiStyle &style = ImGui::GetStyle();
const float spacing = style.ItemSpacing.x;
const float cell_w = (width - spacing * float(shown.size() - 1)) / float(shown.size());
const ImU32 frame = ImGui::GetColorU32(ImGuiCol_Text, 0.25f);
// A single illustration would otherwise be drawn at the tooltip's full width, and a square one at
// full width is a picture as tall as the text beside it is long. Capped and centred in its column.
const float max_h = m_imgui->scaled(10.f);
ImGui::Spacing();
for (size_t i = 0; i < shown.size(); ++i) {
const AnimatedTexture *texture = shown[i].first;
const TipMedia &entry = *shown[i].second;
const float cell_x = ImGui::GetCursorPosX();
float w = cell_w;
float h = std::round(cell_w * float(texture->height()) / float(texture->width()));
if (h > max_h) {
w = std::round(max_h * float(texture->width()) / float(texture->height()));
h = max_h;
}
ImGui::BeginGroup();
const unsigned int id = texture->texture_id(time);
ImGui::SetCursorPosX(cell_x + std::round(0.5f * (cell_w - w)));
ImGui::Image((ImTextureID) (intptr_t) id, ImVec2(w, h));
ImGui::GetWindowDrawList()->AddRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax(), frame, style.FrameRounding);
// The canvas only redraws on demand, so an animation has to ask for its own frames. It asks for
// them as fast as they come rather than pacing itself with a timer: which frame is drawn is
// decided by the clock above, so a redraw that arrives early simply draws the same frame again,
// and a guaranteed frame is worth more here than a frugal one that may never arrive.
if (texture->is_animated())
m_parent.request_extra_frame();
if (!entry.caption.empty()) {
// Centred under its own illustration, and never wider than its column.
const std::string caption = into_u8(entry.caption);
const float text_w = ImGui::CalcTextSize(caption.c_str()).x;
ImGui::SetCursorPosX(cell_x + std::max(0.f, (cell_w - text_w) * 0.5f));
ImGui::PushTextWrapPos(cell_x + cell_w);
ImGui::TextDisabled("%s", caption.c_str());
ImGui::PopTextWrapPos();
}
ImGui::EndGroup();
if (i + 1 < shown.size())
ImGui::SameLine(0.f, spacing);
}
}
void GLGizmoTextureDisplacement::add_texture_layer()
{
ModelVolume *mv = texture_volume();
@@ -4028,14 +3873,6 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
return;
}
// While this popup is up its own tooltips belong beside it, not beside the panel it is already
// covering the left of. Restored before every exit below.
const ImVec2 prev_anchor_min = m_tooltip_anchor_min;
const ImVec2 prev_anchor_max = m_tooltip_anchor_max;
m_tooltip_anchor_min = ImGui::GetWindowPos();
m_tooltip_anchor_max = ImVec2(m_tooltip_anchor_min.x + ImGui::GetWindowSize().x,
m_tooltip_anchor_min.y + ImGui::GetWindowSize().y);
m_imgui->push_bold_font();
m_imgui->text(_L("Choose a texture"));
m_imgui->pop_bold_font();
@@ -4094,7 +3931,7 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
if (entry.path == layer->path)
dl->AddRect(ImVec2(a.x - 1.f, a.y - 1.f), ImVec2(b.x + 1.f, b.y + 1.f), selected_col, 0.f, 0, 2.f);
if (ImGui::IsItemHovered())
panel_tooltip(entry.name);
m_imgui->tooltip(entry.name, m_imgui->scaled(18.f));
if (user && ImGui::IsMouseHoveringRect(a, b)) {
const float r = std::round(tile * 0.17f);
@@ -4107,7 +3944,7 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
dl->AddLine(ImVec2(c.x - k, c.y - k), ImVec2(c.x + k, c.y + k), IM_COL32_WHITE, 1.5f);
dl->AddLine(ImVec2(c.x - k, c.y + k), ImVec2(c.x + k, c.y - k), IM_COL32_WHITE, 1.5f);
if (hot)
panel_tooltip(_u8L("Remove from My textures"));
m_imgui->tooltip(_u8L("Remove from My textures"), m_imgui->scaled(18.f));
if (remove_clicked)
to_remove = entry;
}
@@ -4134,9 +3971,10 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
if (ImGui::Button("+##import_texture", ImVec2(tile, tile)))
import_clicked = true;
if (ImGui::IsItemHovered())
panel_tooltip(_u8L("Import your own image as a height map. It is converted to the format the slicer "
"bakes from and saved to your personal texture folder, kept separate from the "
"textures shipped with OrcaSlicer."));
m_imgui->tooltip(_u8L("Import your own image as a height map. It is converted to the format the slicer "
"bakes from and saved to your personal texture folder, kept separate from the "
"textures shipped with OrcaSlicer."),
m_imgui->scaled(20.f));
end_grid(origin, count + 1);
}
@@ -4146,8 +3984,6 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
if (chosen || import_clicked)
ImGui::CloseCurrentPopup();
m_tooltip_anchor_min = prev_anchor_min;
m_tooltip_anchor_max = prev_anchor_max;
ImGui::EndPopup();
// Only once the loop over the library is done: importing, picking (which reorders the recently used)
@@ -5779,16 +5615,18 @@ void GLGizmoTextureDisplacement::render_debug_stage_panel(ModelVolume *mv)
run_stage_debug();
m_imgui->disabled_end();
if (ImGui::IsItemHovered())
panel_tooltip(_u8L("Run the same bake the Bake button runs, keeping the mesh after every "
"stage, and step through them here. Nothing is committed to the model - "
"this is for finding which stage a bad result came from."));
m_imgui->tooltip(_u8L("Run the same bake the Bake button runs, keeping the mesh after every "
"stage, and step through them here. Nothing is committed to the model - "
"this is for finding which stage a bad result came from."),
m_imgui->scaled(20.f));
ImGui::SameLine();
m_imgui->bbl_checkbox(_L("Check topology"), m_debug_check_topology);
if (ImGui::IsItemHovered())
panel_tooltip(_u8L("Count open and non-manifold edges after each stage, which is how a stage "
"that tore the mesh is spotted. It scans every edge, so it adds noticeably "
"to the capture on a fine bake."));
m_imgui->tooltip(_u8L("Count open and non-manifold edges after each stage, which is how a stage "
"that tore the mesh is spotted. It scans every edge, so it adds noticeably "
"to the capture on a fine bake."),
m_imgui->scaled(20.f));
if (m_debug_stages.empty())
return;
@@ -5856,8 +5694,9 @@ void GLGizmoTextureDisplacement::render_debug_stage_panel(ModelVolume *mv)
show_error(nullptr, _u8L("Could not write the stage meshes."));
}
if (ImGui::IsItemHovered())
panel_tooltip(_u8L("Write every captured stage out as an OBJ file, so they can be opened "
"side by side in a mesh viewer."));
m_imgui->tooltip(_u8L("Write every captured stage out as an OBJ file, so they can be opened "
"side by side in a mesh viewer."),
m_imgui->scaled(20.f));
}
void GLGizmoTextureDisplacement::queue_prepare(const TextureDisplacementPrepareParams &params,
@@ -5982,12 +5821,6 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGuiWrapper::push_toolbar_style(m_parent.get_scale());
GizmoImguiBegin(get_name(), flags);
// Where this frame's tooltips place themselves (see panel_tooltip()). The size is last frame's -
// this window auto-resizes, so its height is only known once its content has been laid out - which
// is close enough for a tooltip that needs a frame of hovering to appear in the first place.
m_tooltip_anchor_min = ImGui::GetWindowPos();
m_tooltip_anchor_max = ImVec2(m_tooltip_anchor_min.x + ImGui::GetWindowSize().x,
m_tooltip_anchor_min.y + ImGui::GetWindowSize().y);
ensure_panel_icons();
process_uv_commands(); // clicks from the UV editor pane, run here where the GL context is current
const float previous_body_h = m_panel_body_h;
@@ -6011,6 +5844,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
const float gap_s = std::round(m_imgui->scaled(0.4f));
const float label_w = m_imgui->scaled(4.8f); // label column of a label + slider row
const float card_pad = std::round(m_imgui->scaled(0.55f));
const float wrap_w = m_imgui->scaled(20.f);
const ImVec4 orca = ImGuiWrapper::COL_ORCA;
const ImVec4 col_link = ImGuiWrapper::COL_ORCA;
const ImVec4 col_frame = ImGui::GetStyleColorVec4(ImGuiCol_Separator);
@@ -6024,9 +5858,9 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGuiWrapper::pop_combo_style();
return changed;
};
const auto hover_tip = [&](const auto &text, const std::vector<TipMedia> &media = {}) {
const auto hover_tip = [&](const auto &text) {
if (ImGui::IsItemHovered())
panel_tooltip(text, media);
m_imgui->tooltip(text, wrap_w);
};
// Framed icon toggle: a 1 px frame, and when on a teal frame over a teal tint with the icon in its
@@ -6034,8 +5868,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
// `unavailable`, when not empty, draws it faded but still hoverable, so the tooltip can say why it
// cannot be used - a disabled ImGui item would swallow the hover and leave the user guessing.
const auto icon_toggle = [&](int uid, const std::string &iconfile, bool active, float sz, const wxString &label,
const wxString &tip_text, const std::vector<TipMedia> &media = {},
const wxString &unavailable = wxString()) -> bool {
const wxString &tip_text, const wxString &unavailable = wxString()) -> bool {
const bool na = !unavailable.empty();
const auto it = m_panel_icon_map.find(iconfile);
const float pad = std::max(1.f, std::round(sz * 0.14f));
@@ -6062,12 +5895,12 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::PopStyleVar(2);
ImGui::PopID();
if (ImGui::IsItemHovered())
panel_tooltip(na ? tip_text + "\n\n" + unavailable : tip_text, media);
m_imgui->tooltip(na ? tip_text + "\n\n" + unavailable : tip_text, wrap_w);
return clicked && !na;
};
// Borderless icon button (non-toggle): always the grey monochrome variant.
const auto icon_button = [&](int uid, const std::string &iconfile, float sz, const wxString &label,
const wxString &tip_text, const std::vector<TipMedia> &media = {}) -> bool {
const wxString &tip_text) -> bool {
const auto it = m_panel_icon_map.find(iconfile);
const float pad = std::max(1.f, std::round(sz * 0.14f));
bool clicked = false;
@@ -6085,7 +5918,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::PopStyleVar();
ImGui::PopID();
if (!tip_text.empty() && ImGui::IsItemHovered())
panel_tooltip(tip_text, media);
m_imgui->tooltip(tip_text, wrap_w);
return clicked;
};
// A short vertical rule between groups of buttons on one row; call it right after an item.
@@ -6181,6 +6014,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
"not what you wanted and you know why."));
if (clicked && !on) {
m_panel_mode = m;
store_panel_state();
if (!pro_mode()) {
// Leaving the subdivision preview open would strand a wireframe whose controls just
// disappeared, so close it as part of the switch.
@@ -6203,6 +6037,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
_L("Detach this panel so it can be dragged anywhere over the 3D view, or dock it back beside "
"the toolbar.")))
m_undocked = !m_undocked;
wxGetApp().app_config->set_bool(UNDOCKED_CONFIG_KEY, m_undocked);
}
ImGui::Separator();
@@ -6287,13 +6122,11 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::SetCursorPosX(toggles_x);
if (icon_toggle(820, "texture_displacement_add.svg", !m_erase_mode, icon_sm, _L("Paint"),
_L("Paint - add the active layer where you click or drag. The right button erases, and so does "
"holding Shift."),
{ { "paint.gif" } }))
"holding Shift.")))
m_erase_mode = false;
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(821, "texture_displacement_add_negative.svg", m_erase_mode, icon_sm, _L("Erase"),
_L("Erase - remove the active layer where you click or drag. The right button paints."),
{ { "erase.gif" } }))
_L("Erase - remove the active layer where you click or drag. The right button paints.")))
m_erase_mode = true;
}
@@ -6307,22 +6140,20 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
const bool is_area_mode = m_tool_type == ToolType::SMART_FILL;
const float row_y = ImGui::GetCursorPosY();
if (icon_toggle(801, "texture_displacement_brush.svg", is_brush_mode, icon_md, _L("Brush"),
_L("Brush - paint over the surface by dragging"), { { "paint.gif" } })) {
_L("Brush - paint over the surface by dragging"))) {
m_tool_type = ToolType::BRUSH;
if (m_cursor_type == TriangleSelector::CursorType::POINTER)
m_cursor_type = TriangleSelector::CursorType::CIRCLE;
}
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(802, "texture_displacement_face.svg", is_face_mode, icon_md, _L("Face"), _L("Face - click individual triangles"),
{ { "tool_face.gif" } })) {
if (icon_toggle(802, "texture_displacement_face.svg", is_face_mode, icon_md, _L("Face"), _L("Face - click individual triangles"))) {
m_tool_type = ToolType::BRUSH;
m_cursor_type = TriangleSelector::CursorType::POINTER;
}
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(803, "texture_displacement_connected_area.svg", is_area_mode, icon_md, _L("Connected area"),
_L("Connected area - flood-fill the region reachable without crossing an edge sharper than the "
"angle threshold"),
{ { "tool_smart_fill.gif" } })) {
"angle threshold"))) {
m_tool_type = ToolType::SMART_FILL;
m_cursor_type = TriangleSelector::CursorType::POINTER;
}
@@ -6336,13 +6167,11 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
wxString();
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(806, "texture_displacement_select_all.svg", false, icon_md, _L("Select whole model"),
_L("Select whole model - paint every face of the model with the active layer"),
{ { "select_all.png" } }, whole_na))
_L("Select whole model - paint every face of the model with the active layer"), whole_na))
select_whole_model();
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(807, "texture_displacement_erase_all.svg", false, icon_md, _L("Erase whole model"),
_L("Erase whole model - clear the active layer's paint from every face"),
{ { "erase_all.png" } }, erase_na)) {
_L("Erase whole model - clear the active layer's paint from every face"), erase_na)) {
Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Reset texture displacement selection"),
UndoRedo::SnapshotType::GizmoAction);
int idx = -1;
@@ -6365,7 +6194,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
centre_on_row(frame_h);
ImGui::SliderFloat("##cursor_radius", &m_cursor_radius, CursorRadiusMin, CursorRadiusMax, "%.2f mm",
ImGuiSliderFlags_AlwaysClamp);
hover_tip(m_desc.at("cursor_size"), { { "cursor_size.gif" } });
hover_tip(m_desc.at("cursor_size"));
vsep(icon_sm);
const bool is_circle = m_cursor_type == TriangleSelector::CursorType::CIRCLE;
centre_on_row(icon_sm);
@@ -6382,8 +6211,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
centre_on_row(frame_h);
ImGui::SliderFloat("##smart_fill_angle", &m_smart_fill_angle, SmartFillAngleMin, SmartFillAngleMax, "%.0f°",
ImGuiSliderFlags_AlwaysClamp);
hover_tip(_u8L("Angle threshold - the fill stops at edges sharper than this"),
{ { "smart_fill_angle_low.png", _L("Low") }, { "smart_fill_angle_high.png", _L("High") } });
hover_tip(_u8L("Angle threshold - the fill stops at edges sharper than this"));
} else {
centre_on_row(frame_h);
ImGui::AlignTextToFramePadding();
@@ -6420,21 +6248,19 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + m_imgui->scaled(2.6f)));
if (icon_toggle(701, "texture_displacement_real_preview.svg", cur_mode == 0, icon_md, _L("Normal"),
_L("Normal - the real geometry, exactly what Bake will produce. Slower to update"),
{ { "tile_on.png" } }))
_L("Normal - the real geometry, exactly what Bake will produce. Slower to update")))
new_mode = 0;
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(702, "texture_displacement_fast_preview.svg", cur_mode == 1, icon_md, _L("Fast"),
_L("Fast - the relief is only shaded on, not built, and only for the layer you are "
"editing. Updates instantly while you paint"),
{ { "view_fast.png" } }))
"editing. Updates instantly while you paint")))
new_mode = 1;
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(703, "texture_displacement_checker.svg", cur_mode == 2, icon_md, _L("Checker"),
_L("Checker - a test grid instead of the texture. Where the squares stay square the "
"texture is undistorted; where they stretch, it will too. Opens the UV editor if "
"it is closed"),
{ { "view_checker.png" } }, uv_view_na)) {
uv_view_na)) {
new_mode = 2;
open_uv_editor = true;
}
@@ -6442,14 +6268,13 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
if (icon_toggle(704, "texture_displacement_distortion.svg", cur_mode == 3, icon_md, _L("Distortion"),
_L("Distortion - blue-to-red stretch heatmap over the unwrap. Opens the UV editor if "
"it is closed"),
{ { "view_distortion.png" } }, uv_view_na)) {
uv_view_na)) {
new_mode = 3;
open_uv_editor = true;
}
vsep(icon_md);
if (icon_toggle(705, "texture_displacement_wireframe.svg", m_wireframe_overlay, icon_md, _L("Wireframe"),
_L("Wireframe - overlay the mesh edges; independent of the view above"),
{ { "view_wireframe.png" } }))
_L("Wireframe - overlay the mesh edges; independent of the view above")))
wf_toggle = true;
const std::string auto_label = _u8L("Auto");
@@ -6658,17 +6483,14 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
// Depth and tile size are logarithmic, so both ends of their wide ranges stay usable.
m_preview_params_dirty |= float_row("##depth", _L("Depth"), &layer.depth_mm, 0.01f, 10.f, "%.3f mm", true, card_pad);
hover_tip(_u8L("Height of the relief: how far white in the texture lifts the surface, in "
"millimetres. Black does not move it at all, unless Midlevel says otherwise."),
{ { "depth.gif" } });
"millimetres. Black does not move it at all, unless Midlevel says otherwise."));
m_preview_params_dirty |= float_row("##tile_size", _L("Tile size"), &layer.tiling_scale, 0.2f, 200.f, "%.2f mm", true, card_pad);
hover_tip(_u8L("How wide one copy of the texture is on the model. Smaller repeats the pattern "
"more often and makes its detail finer; with Tile off, this is the size of the "
"single copy."),
{ { "tile_size_large.png", _L("Larger") }, { "tile_size_small.png", _L("Smaller") } });
"single copy."));
m_preview_params_dirty |= float_row("##rotation", _L("Rotation"), &layer.rotation_deg, 0.f, 360.f, "%.0f°", false, card_pad);
hover_tip(_u8L("Turns the texture on the surface, in degrees - for lining a pattern up with an "
"edge of the model."),
{ { "rotation.gif" } });
"edge of the model."));
if (m_layer_expanded[size_t(layer.slot)]) {
{
@@ -6688,8 +6510,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
"outwards; at 0.5 mid-gray stays put, so darker grays cut in and lighter ones "
"still push out - one image both embosses and engraves.\n\n"
"What cuts in has to fit: inside a sharp corner or through a thin wall, a deep "
"cut can pass through the other side."),
{ { "midlevel.gif" } });
"cut can pass through the other side."));
if (layer.midlevel > 0.f && layer.depth_mm > 1.f) {
ImGui::PushTextWrapPos(content_rx - card_pad);
m_imgui->warning_text(_L("Deep inward displacement may self-intersect."));
@@ -6699,14 +6520,12 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_preview_params_dirty |= float_row("##smoothing", _L("Smoothing"), &layer.smoothing, 0.f, 1.f, "%.2f", false, card_pad);
hover_tip(_u8L("Blurs the image before it is used, which rounds off hard steps and removes "
"speckle from a noisy photo. Raise it if the relief looks harsh or grainy; it "
"costs fine detail."),
{ { "smoothing_off.png", _L("Off") }, { "smoothing_on.png", _L("On") } });
"costs fine detail."));
// Edge fade: the relief flattens toward the boundary of the painted area.
m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Edge fade") + "##edge_smoothing", layer.edge_smoothing);
hover_tip(_u8L("Flattens the relief as it approaches the edge of the painted area, so it "
"blends into the bare surface instead of stopping at a step."),
{ { "edge_fade_off.png", _L("Off") }, { "edge_fade_on.png", _L("On") } });
"blends into the bare surface instead of stopping at a step."));
ImGui::SameLine();
m_imgui->disabled_begin(!layer.edge_smoothing);
ImGui::SetNextItemWidth(-card_pad);
@@ -6721,8 +6540,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
const float x0 = ImGui::GetCursorPosX();
m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Invert"), layer.invert);
hover_tip(_u8L("Turns the relief inside out: what stood out is cut in, and the other way "
"round. The same as using a negative of the image."),
{ { "invert_off.png", _L("Off") }, { "invert_on.png", _L("On") } });
"round. The same as using a negative of the image."));
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + 0.5f * (ImGui::GetWindowContentRegionMax().x - card_pad - x0)));
@@ -6738,12 +6556,13 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
}
m_imgui->disabled_end();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
panel_tooltip(has_color ?
_u8L("Prints the painted area in the texture's colors as well as its "
"relief. Each color is matched to the nearest of your loaded "
"filaments; anything you did not paint keeps the object's own.") :
_u8L("This texture is a grayscale height map, so it has no colors to "
"apply. Import a color image to use this."));
m_imgui->tooltip(has_color ?
_u8L("Prints the painted area in the texture's colors as well as its "
"relief. Each color is matched to the nearest of your loaded "
"filaments; anything you did not paint keeps the object's own.") :
_u8L("This texture is a grayscale height map, so it has no colors to "
"apply. Import a color image to use this."),
wrap_w);
// The rest of colour belongs to the whole stack, not to this layer, so it only appears once -
// under whichever layer turned colour on.
@@ -6753,8 +6572,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_preview_params_dirty = true;
hover_tip(_u8L("Interleaves two filaments to fake the colors in between, so a handful "
"of filaments can cover a photo or a gradient. An image of flat colors "
"prints the same either way. Off uses one filament per area."),
{ { "mix_off.png", _L("Off") }, { "mix_on.png", _L("On") } });
"prints the same either way. Off uses one filament per area."));
if (opts.color_mix_enabled) {
cached_palette(); // brings m_palette_filaments up to date
// Every mix can become a filament slot, and there are only so many beside the
@@ -6763,8 +6581,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
if (int_row("##color_mix_count", _L("Mixed colors"), &opts.color_mix_count, 1, max_mixes, "%d", card_pad))
m_preview_params_dirty = true;
hover_tip(_u8L("The most mixed filaments a bake may add. They are picked from the "
"texture's colors, and only the ones the bake actually uses are created."),
{ { "mixed_colors_few.png", _L("Few") }, { "mixed_colors_many.png", _L("Many") } });
"texture's colors, and only the ones the bake actually uses are created."));
// After the slider, so a change shows in the same frame.
ImGui::TextDisabled("%s", Slic3r::format(_u8L("%1% printable colors from %2% filaments"),
int(cached_palette().size()), int(m_palette_filaments.size())).c_str());
@@ -6773,8 +6590,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_preview_params_dirty = true;
hover_tip(_u8L("Cleans up single stray triangles of the wrong color, which detail finer "
"than the mesh leaves behind. Raise it if the result looks speckled, "
"lower it if small features are being swallowed."),
{ { "denoise_off.png", _L("Off") }, { "denoise_on.png", _L("On") } });
"lower it if small features are being swallowed."));
}
}
@@ -6790,35 +6606,29 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
const char *file;
wxString label;
wxString tip;
const char *picture; // the same ball with the checker laid on it this way
};
const MappingIcon mappings[] = {
{ "menu_obj_cube.svg", _L("Triplanar (blended)"),
_L("Triplanar - projects the texture from all three axes at once and blends between them, so a "
"patch wrapping around a sharp edge has no seam."),
"map_cube.png" },
"patch wrapping around a sharp edge has no seam.") },
{ "menu_obj_cylinder.svg", _L("Cylindrical"),
_L("Cylindrical - wraps the texture around the painted area's own center, for round shapes."),
"map_cylinder.png" },
_L("Cylindrical - wraps the texture around the painted area's own center, for round shapes.") },
{ "menu_obj_sphere.svg", _L("Spherical"),
_L("Spherical - wraps the texture around the painted area's own center in both directions."),
"map_sphere.png" },
_L("Spherical - wraps the texture around the painted area's own center in both directions.") },
{ "texture_displacement_map_unwrap.svg", _L("Unwrap (LSCM)"),
_L("Unwrap - flattens the painted area and maps the texture onto it with as little stretching as "
"possible. The area is cut into pieces at its sharp edges first (see Seam angle), so each "
"piece can lie flat on its own."),
"map_unwrap.png" },
"piece can lie flat on its own.") },
{ "texture_displacement_map_view.svg", _L("From view"),
_L("From view - projects straight onto the painted area from where you are looking, like a slide "
"projector."),
"map_view.png" },
"projector.") },
};
for (int mi = 0; mi < int(IM_ARRAYSIZE(mappings)); ++mi) {
if (mi > 0)
ImGui::SameLine(0.f, gap_s);
const auto method = static_cast<TextureProjectionMethod>(mi);
if (icon_toggle(830 + mi, mappings[mi].file, layer.projection_method == method, icon_sm, mappings[mi].label,
mappings[mi].tip, { { mappings[mi].picture } }) &&
mappings[mi].tip) &&
layer.projection_method != method) {
// Capture the current view the moment "From view" is chosen, so it does something
// sensible immediately rather than projecting from a stale/default direction.
@@ -6922,20 +6732,19 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
const float x0 = ImGui::GetCursorPosX();
m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Tile") + "##tile_enabled", layer.tile_enabled);
hover_tip(_u8L("Repeats the texture across the painted area. Off places one copy, like a decal, "
"at the size set by Tile size."),
{ { "tile_off.png", _L("Off") }, { "tile_on.png", _L("On") } });
"at the size set by Tile size."));
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w));
const wxString tile_na = layer.tile_enabled ? wxString() : _L("Turn Tile on to choose how the texture repeats.");
if (icon_toggle(840, "texture_displacement_tile_repeat.svg", layer.tile_method == static_cast<TextureTileMethod>(0),
icon_sm, _L("Repeat"), _L("Repeat"), { { "tile_repeat.png" } }, tile_na)) {
icon_sm, _L("Repeat"), _L("Repeat"), tile_na)) {
layer.tile_method = static_cast<TextureTileMethod>(0);
m_preview_params_dirty = true;
}
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(841, "menu_mirror_x.svg", layer.tile_method == static_cast<TextureTileMethod>(1), icon_sm,
_L("Mirrored repeat"), _L("Mirrored repeat - every other tile is flipped, so edges meet seamlessly"),
{ { "tile_mirror.png" } }, tile_na)) {
tile_na)) {
layer.tile_method = static_cast<TextureTileMethod>(1);
m_preview_params_dirty = true;
}
@@ -6965,16 +6774,14 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
}
m_imgui->disabled_end();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
panel_tooltip(base_layer ?
_u8L("The base layer has nothing beneath it to combine with, so it always adds.") :
_u8L("What this layer does where it overlaps the ones below. Add and "
"Subtract pile relief on or carve it away. Multiply and Divide scale "
"what is underneath, which turns this layer into a mask over it - "
"there, Depth acts as a strength, and 1 mm leaves white areas "
"untouched."),
base_layer ? std::vector<TipMedia>{} :
std::vector<TipMedia>{ { "blend_add.png", from_u8(blend_add) },
{ "blend_multiply.png", from_u8(blend_multiply) } });
m_imgui->tooltip(base_layer ?
_u8L("The base layer has nothing beneath it to combine with, so it always adds.") :
_u8L("What this layer does where it overlaps the ones below. Add and "
"Subtract pile relief on or carve it away. Multiply and Divide scale "
"what is underneath, which turns this layer into a mask over it - "
"there, Depth acts as a strength, and 1 mm leaves white areas "
"untouched."),
wrap_w);
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(842, "texture_displacement_adjust.svg", m_adjust_texture_mode, icon_sm, _L("Adjust placement"),
@@ -7157,8 +6964,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
}
hover_tip(_u8L("Adds triangles only where you painted, instead of everywhere. On a large part with a small "
"decal that is the difference between thousands of triangles and millions, and your paint "
"survives the refinement."),
{ { "painted_only_off.png", _L("Off") }, { "painted_only_on.png", _L("On") } });
"survives the refinement."));
if (m_subdivide_adaptive) {
if (m_subdivide_target_mm <= 0.f && mv != nullptr) {
@@ -7186,8 +6992,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
preview_live();
hover_tip(_u8L("Spends the triangles where the texture actually bends - packed along ridges and edges, "
"sparse over flat ground - instead of spreading them evenly. The same detail for fewer "
"triangles on most textures."),
{ { "adaptive_off.png", _L("Off") }, { "adaptive_on.png", _L("On") } });
"triangles on most textures."));
// The edge-length target is a baseline in both modes. In feature mode it is what guarantees the
// curvature test can actually see the texture: left too coarse, a big triangle over a fine
@@ -7244,8 +7049,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
}
hover_tip(_u8L("Splits every triangle of the model into four, this many times over. Each step "
"quadruples the count - 3 turns 100 k triangles into 6.4 M - so prefer refining "
"only the painted area unless you need the whole model finer."),
{ { "subdivide.gif" } });
"only the painted area unless you need the whole model finer."));
}
{
@@ -7309,14 +7113,12 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
}
float_row("##remesh_edge", _L("Target edge"), &m_remesh_target_edge_mm, 0.1f, 20.f, "%.2f mm", true, 0.f);
hover_tip(_u8L("Triangle size the whole model is rebuilt with, in millimetres. Displacement works best on an "
"even mesh; this is what makes one out of an uneven import."),
{ { "remesh_before.png", _L("Before") }, { "remesh_after.png", _L("After") } });
"even mesh; this is what makes one out of an uneven import."));
m_imgui->bbl_checkbox(_L("Keep sharp edges") + "##remesh_sharp", m_remesh_keep_sharp_edges);
hover_tip(_u8L("Holds hard edges and open borders in place while the rest is remeshed. Without it "
"the remesher slides vertices along the surface and rounds every crisp edge off - "
"a cube comes back with wobbly edges."),
{ { "preserve_edges_off.png", _L("Off") }, { "preserve_edges_on.png", _L("On") } });
"a cube comes back with wobbly edges."));
ImGui::SameLine();
m_imgui->disabled_begin(!m_remesh_keep_sharp_edges);
ImGui::SetNextItemWidth(-1.f);
@@ -7332,8 +7134,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
hover_tip(_u8L("Rebuilds the whole model with triangles close to this edge length - splitting the big "
"ones and merging the small ones - so displacement has an even density to work with. "
"Replaces the geometry; your paint is carried onto the new triangles spatially, so it "
"survives (already-baked relief is kept too)."),
{ { "remesh_before.png", _L("Before") }, { "remesh_after.png", _L("After") } });
"survives (already-baked relief is kept too)."));
// Settings for the whole stack rather than one layer. Standard mode pins them instead of showing them.
if (mv != nullptr) {
@@ -7349,8 +7150,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Smooth result"), opts.smooth_enabled);
hover_tip(_u8L("Smooths the geometry after the texture has been applied, to take the hard steps out of a "
"low-resolution image. Only what the displacement moved is touched. The Smoothing slider "
"on a layer is a different thing: it blurs the image before it is used."),
{ { "geometry_smoothing.gif" } });
"on a layer is a different thing: it blurs the image before it is used."));
if (opts.smooth_enabled) {
float percent = opts.smooth_strength * 100.f;
if (float_row("##dispsmooth", _L_CONTEXT("Strength", "Texture Displacement"), &percent, 1.f, 100.f, "%.0f %%", false, 0.f)) {
@@ -7414,8 +7214,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
"Relief already baked into the rest of the model is kept on top of it, so a "
"second bake somewhere else gets the same budget as the first. Raise it if the "
"warning below Resolution says the detail will not fit; 0 keeps every triangle "
"the refinement produced, however many that is."),
{ { "subdivide.gif" } });
"the refinement produced, however many that is."));
}
if (SHOW_PIPELINE_DEV_CONTROLS && opts.pipeline_v2) {
// Keeping the relief above the plate is not a checkbox: it is unconditional, in both pipelines
@@ -7430,8 +7229,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
hover_tip(_u8L("Collapse the thin triangles refinement inherits from the model's own "
"tessellation, before displacement samples them. A sliver's three corners "
"land on three unrelated parts of the texture, which is what makes the "
"relief look jagged."),
{ { "collapse_slivers_off.png", _L("Off") }, { "collapse_slivers_on.png", _L("On") } });
"relief look jagged."));
}
// Below both pipelines' own settings, because it replays whichever of them is selected.
if (SHOW_PIPELINE_DEV_CONTROLS)
@@ -7542,8 +7340,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
"smaller than the detail you want out of the texture - a 0.5 mm groove needs "
"triangles well under 0.5 mm. Smaller costs triangles fast: halving it needs "
"four times as many, and once they no longer fit the budget the bake simplifies "
"back down and the detail goes with it."),
{ { "subdivide.gif" } });
"back down and the detail goes with it."));
// What this resolution costs over what is painted, against what the budget allows. Refining
// past the budget is not an error - the bake simplifies back down to it - but the result
@@ -7586,8 +7383,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_imgui->disabled_end();
hover_tip(_u8L("How many thousand triangles the refinement may add to the model. The triangle "
"that fits worst is always split first, so even a run that spends the lot has "
"spent it where it shows most. Raise it if the relief still looks coarse."),
{ { "subdivide.gif" } });
"spent it where it shows most. Raise it if the relief still looks coarse."));
}
const float button_h = std::round(frame_h * 1.25f);
@@ -7639,17 +7435,18 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_imgui->pop_bold_font();
GLGizmoUtils::pop_orca_button_style();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
panel_tooltip(mv != nullptr && !mv->is_texture_displacement_painted() ?
(m_seam_edit_mode ? _u8L("Nothing is painted yet. The UV editor's seam tool is on, so "
"strokes on the model mark seams instead of painting - turn "
"it off in the pane to paint.") :
_u8L("Nothing is painted yet.")) :
pro_mode() ?
_u8L("Turn the painted height maps into real geometry, by moving the vertices that are "
"already there. Use Subdivide first if the mesh is too coarse to show the detail.") :
_u8L("Turn the painted height maps into real geometry. The mesh is remeshed to an even "
"density and refined where the texture bends first, so the detail has vertices to "
"land on - all in one step."));
m_imgui->tooltip(mv != nullptr && !mv->is_texture_displacement_painted() ?
(m_seam_edit_mode ? _u8L("Nothing is painted yet. The UV editor's seam tool is on, so "
"strokes on the model mark seams instead of painting - turn "
"it off in the pane to paint.") :
_u8L("Nothing is painted yet.")) :
pro_mode() ?
_u8L("Turn the painted height maps into real geometry, by moving the vertices that are "
"already there. Use Subdivide first if the mesh is too coarse to show the detail.") :
_u8L("Turn the painted height maps into real geometry. The mesh is remeshed to an even "
"density and refined where the texture bends first, so the detail has vertices to "
"land on - all in one step."),
wrap_w);
ImGui::SameLine();
if (busy) {
@@ -4,7 +4,6 @@
#include "GLGizmoPainterBase.hpp"
#include "libslic3r/TextureBake/TextureBakeDebug.hpp"
#include "libslic3r/TextureDisplacement.hpp"
#include "slic3r/GUI/AnimatedTexture.hpp"
#include "slic3r/GUI/GLModel.hpp"
#include "slic3r/GUI/GLTexture.hpp"
#include "slic3r/GUI/I18N.hpp"
@@ -263,6 +262,9 @@ private:
// Standard is active so what Preview shows is always what Bake will do. Returns true if it actually
// changed something, so the caller can invalidate the preview.
bool apply_standard_mode_presets(ModelVolume *mv);
// Copies the panel's own state (active layer, Standard/Pro) onto the volume, so the project file
// can carry it. Cheap and idempotent - called wherever either of the two changes.
void store_panel_state();
// Standard mode's Bake: remesh to an even density, refine where the texture bends, then displace.
// The order matters and is the whole reason this is one button - a height map can only move
// existing vertices, so the mesh has to be prepared first, and remeshing after painting would drop
@@ -972,9 +974,12 @@ private:
PatchUnwrap m_uv_editor_unwrap;
// When set, the panel is a free-floating window the user can drag anywhere (with a title bar to
// grab), instead of being pinned to the right of the gizmo toolbar. Persisted across gizmo
// open/close within a session, so the choice sticks while working.
// grab), instead of being pinned to the right of the gizmo toolbar. Kept in the application
// config rather than in the project: it describes the user's window, not the model, so opening
// someone else's project must not move their panel.
bool m_undocked = false;
// 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
@@ -1026,50 +1031,6 @@ private:
std::map<std::string, IconManager::Icons> m_panel_icon_map; // file name -> [normal, colour, disabled]
bool m_panel_icons_tried = false;
void ensure_panel_icons();
// One illustration under a tooltip's text: a still image or an animation (see AnimatedTexture),
// named by file within resources/images/texture_displacement, with a caption under it. Several of
// them in one tooltip are laid out side by side, which is how a setting is shown as a comparison -
// the same model at two values of it.
struct TipMedia
{
std::string file;
wxString caption;
};
// Every tooltip the panel shows. Placed beside the panel rather than under the mouse: the panel is
// pinned to the right edge of the canvas, so a tooltip left to follow the mouse covers the control
// it is explaining, and one carrying an illustration covers most of the panel with it.
//
// `media` is drawn under the text, at the tooltip's full width; files that are missing or will not
// decode are skipped, so a tooltip whose illustration has not been drawn yet is exactly the text
// tooltip it was before.
void panel_tooltip(const std::string &text, const std::vector<TipMedia> &media = {});
void panel_tooltip(const wxString &text, const std::vector<TipMedia> &media = {});
void render_tip_media(const std::vector<TipMedia> &media, float width);
// Screen rectangle a tooltip places itself beside: the panel window, or - while the texture picker
// is up and tooltips are coming from inside it - the picker popup, which already sits beside the
// panel. Both are set from the window's own rectangle as it is drawn.
ImVec2 m_tooltip_anchor_min{0.f, 0.f};
ImVec2 m_tooltip_anchor_max{0.f, 0.f};
// Illustrations any tooltip has shown, decoded and uploaded once (see AnimatedTexture.hpp).
AnimatedTextureCache m_tip_media;
// Which tooltip's illustrations are on screen, and when they first appeared: an animation plays
// from its first frame each time its tooltip opens, rather than from wherever a free-running clock
// happened to be.
std::string m_tip_media_key;
double m_tip_media_start = 0.;
int m_tip_media_frame = -2; // the frame it was last drawn on, to spot the tooltip reopening
// How long the pointer has rested on the control a tooltip belongs to, and when one was last on
// screen, both on the real clock tip_now() keeps (see the .cpp for why ImGui's own will not do).
ImVec2 m_tip_item{-1.f, -1.f};
double m_tip_hover_since = 0.;
double m_tip_last_shown = -1e9;
// The frame a tooltip was last drawn on, and how many it has been drawn on in a row: ImGui hides
// one on its first frame while it measures it, so it has to be kept on screen to be seen at all.
int m_tip_shown_frame = -2;
int m_tip_settled = 0;
};
} // namespace Slic3r::GUI
+326 -13
View File
@@ -28,9 +28,12 @@
#include <miniz.h>
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/TriangleSelector.hpp"
#include "libslic3r/TextureDisplacement.hpp"
#include "libslic3r/PNGReadWrite.hpp"
#include <memory>
#include <cstdint>
#include <ios>
#include <iterator>
#include <nlohmann/json.hpp>
#include <boost/filesystem/operations.hpp>
@@ -349,6 +352,24 @@ static bool replace_in_3mf_entry(const std::string& path, const std::string& suf
return replaced;
}
// Writes `model` out as a one-plate project, which is what the application's Save does with it.
static void store_project(const std::string& path, Model& model)
{
ScopedTemporaryDir backup_dir("orca_project_src");
model.set_backup_path(backup_dir.string());
DynamicPrintConfig cfg;
PlateData plate;
plate.plate_index = 0;
StoreParams sp;
sp.path = path.c_str();
sp.model = &model;
sp.config = &cfg;
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
sp.plate_data_list.push_back(&plate);
REQUIRE(store_bbs_3mf(sp));
}
// Stores a one-plate project holding a cube whose first two facets are painted Extruder2 and
// Extruder3, which the exporter writes as paint_color="8" and paint_color="0C".
static void store_painted_cube(const std::string& path)
@@ -363,19 +384,7 @@ static void store_painted_cube(const std::string& path)
selector.set_facet(1, EnforcerBlockerType::Extruder3);
REQUIRE(volume->mmu_segmentation_facets.set(selector));
}
ScopedTemporaryDir backup_dir("orca_paint_src");
model.set_backup_path(backup_dir.string());
DynamicPrintConfig cfg;
PlateData plate;
plate.plate_index = 0;
StoreParams sp;
sp.path = path.c_str();
sp.model = &model;
sp.config = &cfg;
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
sp.plate_data_list.push_back(&plate);
REQUIRE(store_bbs_3mf(sp));
store_project(path, model);
}
// Loads `path` through the BBS importer into `model`, releasing the plates it returns. The
@@ -2080,3 +2089,307 @@ TEST_CASE("Loading a gcode.3mf from a stream keeps the plate's pick and pattern
CHECK(dst_plates.front()->pattern_bbox_file == "Metadata/plate_1.json");
release_PlateData_list(dst_plates);
}
// Encodes a flat grayscale PNG the way the texture panel's import does, so what the archive carries
// is a real texture rather than an opaque blob.
static std::shared_ptr<std::vector<unsigned char>> make_gray_png(uint8_t value, size_t w = 4, size_t h = 4)
{
ScopedTemporaryFile file(".png");
const std::vector<uint8_t> pixels(w * h, value);
REQUIRE(png::write_gray_to_file(file.string(), w, h, pixels));
boost::nowide::ifstream in(file.string(), std::ios::binary);
std::vector<unsigned char> bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
REQUIRE_FALSE(bytes.empty());
return std::make_shared<std::vector<unsigned char>>(std::move(bytes));
}
// Every entry name in a 3mf archive, with the separators normalised the way the importer sees them.
static std::vector<std::string> zip_entry_names(const std::string& path)
{
std::vector<std::string> names;
mz_zip_archive zip;
mz_zip_zero_struct(&zip);
REQUIRE(open_zip_reader(&zip, path));
const mz_uint n = mz_zip_reader_get_num_files(&zip);
for (mz_uint i = 0; i < n; ++i) {
mz_zip_archive_file_stat st;
if (!mz_zip_reader_file_stat(&zip, i, &st) || st.m_is_directory)
continue;
std::string name(st.m_filename);
std::replace(name.begin(), name.end(), '\\', '/');
names.push_back(std::move(name));
}
close_zip_reader(&zip);
return names;
}
static const std::string TEXTURE_DISPLACEMENT_ENTRY_DIR = "Metadata/texture_displacement/";
// Stores a one-plate project holding a cube with one texture displacement layer, painted on that
// layer's own slot. The caller fills the layer in, and gets back the mask string the exporter wrote
// for `painted_facet`, which is what the load has to reproduce.
//
// The painted facet is deliberately not the first one: the importer keeps a slot's masks only as far
// as its last painted triangle, so a facet in the middle is the case where that run has to be padded
// in front and left short at the back.
static void store_texture_displaced_cube(const std::string& path, const TextureDisplacementLayer& layer,
const TextureDisplacementOptions& options, std::string& painted_mask,
int painted_facet = 5)
{
Model model;
ModelObject* object = model.add_object();
ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.));
object->add_instance();
volume->texture_displacement_layers.push_back(layer);
volume->texture_displacement_options = options;
REQUIRE(painted_facet < int(volume->mesh().its.indices.size()));
{
TriangleSelector selector(volume->mesh());
selector.set_facet(painted_facet, EnforcerBlockerType::ENFORCER);
REQUIRE(volume->texture_displacement_facet(layer.slot).set(selector));
}
painted_mask = volume->texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facet);
REQUIRE_FALSE(painted_mask.empty());
store_project(path, model);
}
// Returns the only volume of the only object in the project at `path`, loaded through the importer.
static const ModelVolume& load_single_volume(const std::string& path, Model& model, const ScopedTemporaryDir& backup_dir)
{
REQUIRE(load_project(path, model, backup_dir));
REQUIRE(model.objects.size() == 1);
REQUIRE(model.objects.front()->volumes.size() == 1);
return *model.objects.front()->volumes.front();
}
// A texture displacement stack is the one paint tool whose settings live outside the volume's config:
// the layer stack goes into the archive as its own JSON file, each layer's texture as a PNG beside it,
// and the paint as a per-triangle attribute (see add_texture_displacement). A project only reopens
// with its relief intact if all three come back together.
TEST_CASE("A texture displacement layer, its texture and its paint survive a project round trip", "[3mf][TextureDisplacement]")
{
TextureDisplacementLayer layer;
layer.slot = 3; // not the first slot, so the per-slot attribute names have to line up
layer.name = "Bark";
layer.path = "/textures/bark.png";
layer.depth_mm = 1.25f;
layer.tiling_scale = 7.5f;
layer.midlevel = 0.5f;
layer.invert = true;
layer.tile_method = TextureTileMethod::MirroredRepeat;
layer.projection_method = TextureProjectionMethod::LSCM;
layer.blend_mode = TextureBlendMode::Subtract;
layer.lscm_seam_edges = { { 1, 2 }, { 3, 5 } };
layer.image_data = make_gray_png(128);
TextureDisplacementOptions options;
options.smooth_enabled = true;
options.smooth_iterations = 4;
options.active_slot = layer.slot;
options.panel_mode = 1;
const int painted_facet = 5;
std::string painted;
ScopedTemporaryFile temp(".3mf");
store_texture_displaced_cube(temp.string(), layer, options, painted, painted_facet);
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
Model model;
const ModelVolume& volume = load_single_volume(temp.string(), model, backup_dir);
REQUIRE(volume.texture_displacement_layers.size() == 1);
const TextureDisplacementLayer& read = volume.texture_displacement_layers.front();
CHECK(read.slot == layer.slot);
CHECK(read.name == layer.name);
CHECK(read.path == layer.path);
CHECK_THAT(read.depth_mm, Catch::Matchers::WithinAbs(layer.depth_mm, 1e-6f));
CHECK_THAT(read.tiling_scale, Catch::Matchers::WithinAbs(layer.tiling_scale, 1e-6f));
CHECK_THAT(read.midlevel, Catch::Matchers::WithinAbs(layer.midlevel, 1e-6f));
CHECK(read.invert == layer.invert);
CHECK(read.tile_method == layer.tile_method);
CHECK(read.projection_method == layer.projection_method);
CHECK(read.blend_mode == layer.blend_mode);
CHECK(read.lscm_seam_edges == layer.lscm_seam_edges);
// The texture rides along in the archive, named after the slot it belongs to, byte for byte.
CHECK(boost::algorithm::starts_with(read.path_in_3mf, TEXTURE_DISPLACEMENT_ENTRY_DIR));
CHECK(boost::algorithm::ends_with(read.path_in_3mf, "_" + std::to_string(layer.slot) + ".png"));
REQUIRE(read.image_data);
CHECK(*read.image_data == *layer.image_data);
CHECK(volume.texture_displacement_options.smooth_enabled);
CHECK(volume.texture_displacement_options.smooth_iterations == options.smooth_iterations);
CHECK(volume.texture_displacement_options.active_slot == options.active_slot);
CHECK(volume.texture_displacement_options.panel_mode == options.panel_mode);
// The paint comes back on the facet it was put on, on its own slot and nowhere else.
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facet) == painted);
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(0).empty());
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot)
if (slot != layer.slot)
CHECK(volume.texture_displacement_facet(slot).empty());
}
// A layer names the file its texture was imported from, which the library panel uses to point at the
// entry the layer came from. That name must not be opened: the project may come from anyone, the path
// may name any file the user can read - on Windows a UNC path names a host as well - and the next save
// would copy whatever was found there into the archive. A layer the archive has no image for comes
// back without pixels instead, which the user fixes by picking the texture again.
TEST_CASE("A texture displacement layer whose image the archive lacks is not read from the path it names", "[3mf][TextureDisplacement][Regression]")
{
// A file that really is there, so the test fails if the importer ever reaches for it.
ScopedTemporaryFile texture(".png");
REQUIRE(png::write_gray_to_file(texture.string(), 4, 4, std::vector<uint8_t>(16, 200)));
TextureDisplacementLayer layer;
layer.slot = 0;
layer.name = "Bark";
layer.path = texture.string(); // and no image_data: nothing for the exporter to put in the archive
std::string painted;
ScopedTemporaryFile temp(".3mf");
store_texture_displaced_cube(temp.string(), layer, TextureDisplacementOptions{}, painted);
for (const std::string& name : zip_entry_names(temp.string()))
if (boost::algorithm::starts_with(name, TEXTURE_DISPLACEMENT_ENTRY_DIR))
CHECK_FALSE(boost::algorithm::ends_with(name, ".png"));
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
Model model;
const ModelVolume& volume = load_single_volume(temp.string(), model, backup_dir);
REQUIRE(volume.texture_displacement_layers.size() == 1);
CHECK(volume.texture_displacement_layers.front().path == texture.string());
CHECK_FALSE(bool(volume.texture_displacement_layers.front().image_data));
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(5) == painted);
}
// The stack's JSON is arbitrary text by the time it is read back, and a value of the wrong type in it
// throws out of the parse. That has to cost the volume its layers, not the project: the model, and the
// paint the user can still build on, load as they would from a build without the feature.
TEST_CASE("A malformed texture displacement block leaves the rest of the project loadable", "[3mf][TextureDisplacement][Regression]")
{
TextureDisplacementLayer layer;
layer.slot = 0;
layer.name = "Bark";
layer.image_data = make_gray_png(128);
std::string painted;
ScopedTemporaryFile temp(".3mf");
store_texture_displaced_cube(temp.string(), layer, TextureDisplacementOptions{}, painted);
REQUIRE(rewrite_3mf_entries(temp.string(), [](std::string& name, std::string& data) {
if (!boost::algorithm::starts_with(name, TEXTURE_DISPLACEMENT_ENTRY_DIR) ||
!boost::algorithm::ends_with(name, ".json"))
return false;
// `offset` goes through a typed accessor, so a string in it throws; a plain setting like
// depth_mm would be shrugged off by the parse instead, leaving the layer loadable.
data = R"({"version":1,"layers":[{"slot":0,"offset":["deep","deep"]}]})";
return true;
}));
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
Model model;
bool loaded = false;
REQUIRE_NOTHROW(loaded = load_project(temp.string(), model, backup_dir));
REQUIRE(loaded);
REQUIRE(model.objects.size() == 1);
REQUIRE(model.objects.front()->volumes.size() == 1);
const ModelVolume& volume = *model.objects.front()->volumes.front();
CHECK(volume.texture_displacement_layers.empty());
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(5) == painted);
}
// A slot's masks are kept only as far as its last painted triangle, so where the paint falls in the
// list decides whether that run has to be padded in front of the paint, cut short behind it, or both.
TEST_CASE("Texture displacement paint survives wherever it falls in the triangle list", "[3mf][TextureDisplacement]")
{
const int painted_facet = GENERATE(0, 5, 11); // first, middle and last facet of a cube
INFO("painted facet " << painted_facet);
TextureDisplacementLayer layer;
layer.slot = 0;
layer.name = "Bark";
layer.image_data = make_gray_png(128);
std::string painted;
ScopedTemporaryFile temp(".3mf");
store_texture_displaced_cube(temp.string(), layer, TextureDisplacementOptions{}, painted, painted_facet);
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
Model model;
const ModelVolume& volume = load_single_volume(temp.string(), model, backup_dir);
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facet) == painted);
for (int facet = 0; facet < int(volume.mesh().its.indices.size()); ++facet)
if (facet != painted_facet)
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(facet).empty());
}
// Each volume's stack is written under the volume's own id, and the paint of a whole object arrives as
// one run of triangles that the volumes take their slices of. Two volumes using the same slot are the
// case where both of those have to tell the volumes apart.
TEST_CASE("Two volumes of one object keep their own texture displacement layers", "[3mf][TextureDisplacement]")
{
const auto first_image = make_gray_png(64);
const auto second_image = make_gray_png(192);
REQUIRE(*first_image != *second_image);
// The facet each volume is painted on, volume-local, so a mask landing on the wrong volume shows up.
const int painted_facets[2] = { 0, 7 };
std::string painted[2];
ScopedTemporaryFile temp(".3mf");
{
Model model;
ModelObject* object = model.add_object();
object->add_instance();
for (int i = 0; i < 2; ++i) {
ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.));
TextureDisplacementLayer layer;
layer.slot = 0; // the same slot in both, which is what makes their names collide
layer.name = i == 0 ? "First" : "Second";
layer.depth_mm = i == 0 ? 0.5f : 1.5f;
layer.image_data = i == 0 ? first_image : second_image;
volume->texture_displacement_layers.push_back(layer);
TriangleSelector selector(volume->mesh());
selector.set_facet(painted_facets[i], EnforcerBlockerType::ENFORCER);
REQUIRE(volume->texture_displacement_facet(layer.slot).set(selector));
painted[i] = volume->texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facets[i]);
REQUIRE_FALSE(painted[i].empty());
}
store_project(temp.string(), model);
}
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
Model model;
REQUIRE(load_project(temp.string(), model, backup_dir));
REQUIRE(model.objects.size() == 1);
REQUIRE(model.objects.front()->volumes.size() == 2);
const std::shared_ptr<std::vector<unsigned char>> images[2] = { first_image, second_image };
const std::string names[2] = { "First", "Second" };
const float depths[2] = { 0.5f, 1.5f };
std::string entries[2];
for (int i = 0; i < 2; ++i) {
INFO("volume " << i);
const ModelVolume& volume = *model.objects.front()->volumes[i];
REQUIRE(volume.texture_displacement_layers.size() == 1);
const TextureDisplacementLayer& layer = volume.texture_displacement_layers.front();
CHECK(layer.name == names[i]);
CHECK_THAT(layer.depth_mm, Catch::Matchers::WithinAbs(depths[i], 1e-6f));
REQUIRE(layer.image_data);
CHECK(*layer.image_data == *images[i]);
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facets[i]) == painted[i]);
CHECK(volume.texture_displacement_facet(layer.slot).get_triangle_as_string(painted_facets[1 - i]).empty());
entries[i] = layer.path_in_3mf;
}
// Both volumes paint slot 0, so only the volume id keeps their textures apart in the archive.
CHECK(entries[0] != entries[1]);
}
@@ -2157,6 +2157,190 @@ TEST_CASE("A second bake beside a first comes out as fine as a single bake", "[T
CHECK(second <= single * 5 / 4);
}
// The .3mf stores the layer stack as JSON (see texture_displacement_layers_to_json). Two properties
// matter: everything the UV editor and the panel can set has to survive a round trip, and a document
// written by a build that knew fewer fields has to keep loading, with the missing ones left at their
// defaults rather than zeroed.
TEST_CASE("Texture displacement layers survive a JSON round trip", "[TextureDisplacement]")
{
std::vector<TextureDisplacementLayer> layers(2);
TextureDisplacementLayer &a = layers[0];
a.slot = 0;
a.name = "Bark";
a.path = "/textures/bark.png";
a.depth_mm = 1.25f;
a.tiling_scale = 7.5f;
a.rotation_deg = 30.f;
a.offset = Vec2f(0.25f, -0.5f);
a.invert = true;
a.midlevel = 0.125f;
a.smoothing = 0.75f;
a.edge_smoothing = true;
a.edge_smoothing_amount = 0.25f;
a.auto_connect_islands = false;
a.tile_enabled = false;
a.tile_method = TextureTileMethod::MirroredRepeat;
a.projection_method = TextureProjectionMethod::LSCM;
a.blend_mode = TextureBlendMode::Subtract;
a.color_enabled = true;
a.lscm_seam_angle_deg = 45.f;
a.island_padding_mm = 0.5f;
a.lscm_seam_edges = { { 1, 2 }, { 3, 5 } };
a.islands = { TextureIsland{ Vec2f(1.f, 2.f), 15.f, 1.5f }, TextureIsland{} };
a.island_groups = { 0, 0 };
a.lscm_uv_overrides = { { -4, Vec2f(0.5f, 0.75f) } };
TextureDisplacementLayer &b = layers[1];
b.slot = 1;
b.projection_method = TextureProjectionMethod::ViewProjected;
b.view_project_right = Vec3f(0.f, 1.f, 0.f);
b.view_project_up = Vec3f(0.f, 0.f, 1.f);
b.view_project_projective = true;
for (size_t i = 0; i < b.view_project_matrix.size(); ++i)
b.view_project_matrix[i] = float(i) + 0.5f;
TextureDisplacementOptions options;
options.displace_border = false;
options.smooth_enabled = true;
options.smooth_strength = 0.6f;
options.smooth_iterations = 5;
options.pipeline_v2 = false;
options.v2_max_triangles_k = 250;
options.color_mix_count = 3;
options.color_despeckle = 4;
options.active_slot = 3;
options.panel_mode = 1;
const std::string json = texture_displacement_layers_to_json(layers, options);
std::vector<TextureDisplacementLayer> read_layers;
TextureDisplacementOptions read_options;
REQUIRE(texture_displacement_layers_from_json(json, read_layers, read_options));
REQUIRE(read_layers.size() == layers.size());
const TextureDisplacementLayer &ra = read_layers[0];
CHECK(ra.name == a.name);
CHECK(ra.path == a.path);
CHECK_THAT(ra.depth_mm, WithinAbs(a.depth_mm, 1e-6f));
CHECK_THAT(ra.tiling_scale, WithinAbs(a.tiling_scale, 1e-6f));
CHECK_THAT(ra.rotation_deg, WithinAbs(a.rotation_deg, 1e-6f));
CHECK(ra.offset.isApprox(a.offset));
CHECK(ra.invert == a.invert);
CHECK_THAT(ra.midlevel, WithinAbs(a.midlevel, 1e-6f));
CHECK_THAT(ra.smoothing, WithinAbs(a.smoothing, 1e-6f));
CHECK(ra.edge_smoothing == a.edge_smoothing);
CHECK_THAT(ra.edge_smoothing_amount, WithinAbs(a.edge_smoothing_amount, 1e-6f));
CHECK(ra.auto_connect_islands == a.auto_connect_islands);
CHECK(ra.tile_enabled == a.tile_enabled);
CHECK(ra.tile_method == a.tile_method);
CHECK(ra.projection_method == a.projection_method);
CHECK(ra.blend_mode == a.blend_mode);
CHECK(ra.color_enabled == a.color_enabled);
CHECK_THAT(ra.lscm_seam_angle_deg, WithinAbs(a.lscm_seam_angle_deg, 1e-6f));
CHECK_THAT(ra.island_padding_mm, WithinAbs(a.island_padding_mm, 1e-6f));
CHECK(ra.lscm_seam_edges == a.lscm_seam_edges);
REQUIRE(ra.islands.size() == a.islands.size());
CHECK(ra.islands[0].offset.isApprox(a.islands[0].offset));
CHECK_THAT(ra.islands[0].rotation_deg, WithinAbs(a.islands[0].rotation_deg, 1e-6f));
CHECK_THAT(ra.islands[0].scale, WithinAbs(a.islands[0].scale, 1e-6f));
CHECK(ra.island_groups == a.island_groups);
REQUIRE(ra.lscm_uv_overrides.size() == 1);
CHECK(ra.lscm_uv_overrides[0].first == -4);
CHECK(ra.lscm_uv_overrides[0].second.isApprox(Vec2f(0.5f, 0.75f)));
const TextureDisplacementLayer &rb = read_layers[1];
CHECK(rb.projection_method == TextureProjectionMethod::ViewProjected);
CHECK(rb.view_project_right.isApprox(b.view_project_right));
CHECK(rb.view_project_up.isApprox(b.view_project_up));
CHECK(rb.view_project_projective);
CHECK(rb.view_project_matrix == b.view_project_matrix);
CHECK(read_options.displace_border == options.displace_border);
CHECK(read_options.smooth_enabled == options.smooth_enabled);
CHECK_THAT(read_options.smooth_strength, WithinAbs(options.smooth_strength, 1e-6f));
CHECK(read_options.smooth_iterations == options.smooth_iterations);
CHECK(read_options.pipeline_v2 == options.pipeline_v2);
CHECK(read_options.v2_max_triangles_k == options.v2_max_triangles_k);
CHECK(read_options.color_mix_count == options.color_mix_count);
CHECK(read_options.color_despeckle == options.color_despeckle);
// Where the panel was left comes back with the rest, so a reopened project is on the same layer.
CHECK(read_options.active_slot == options.active_slot);
CHECK(read_options.panel_mode == options.panel_mode);
}
TEST_CASE("Texture displacement JSON keeps defaults for keys it does not carry", "[TextureDisplacement]")
{
// What a build that knew fewer fields would have written: one layer, almost nothing set.
const std::string sparse = R"({"version":1,"layers":[{"slot":2,"name":"Old","depth_mm":0.75}]})";
std::vector<TextureDisplacementLayer> layers;
TextureDisplacementOptions options;
REQUIRE(texture_displacement_layers_from_json(sparse, layers, options));
REQUIRE(layers.size() == 1);
const TextureDisplacementLayer &l = layers[0];
const TextureDisplacementLayer fresh;
const TextureDisplacementOptions defaults;
CHECK(l.slot == 2);
CHECK(l.name == "Old");
CHECK_THAT(l.depth_mm, WithinAbs(0.75f, 1e-6f));
// Everything absent keeps the struct's own default rather than becoming zero.
CHECK_THAT(l.tiling_scale, WithinAbs(fresh.tiling_scale, 1e-6f));
CHECK(l.auto_connect_islands == fresh.auto_connect_islands);
CHECK(l.tile_enabled == fresh.tile_enabled);
CHECK(l.projection_method == fresh.projection_method);
CHECK_THAT(l.lscm_seam_angle_deg, WithinAbs(fresh.lscm_seam_angle_deg, 1e-6f));
CHECK(options.color_mix_count == defaults.color_mix_count);
CHECK(options.pipeline_v2 == defaults.pipeline_v2);
CHECK(options.active_slot == defaults.active_slot);
CHECK(options.panel_mode == defaults.panel_mode);
std::vector<TextureDisplacementLayer> unused_layers;
TextureDisplacementOptions unused_options;
CHECK(!texture_displacement_layers_from_json("not json at all", unused_layers, unused_options));
}
// The vectors, the islands and the UV overrides are read through nlohmann's typed accessors, which
// throw on a value of the wrong type rather than returning anything. Nothing between here and the
// project load catches that, so a document carrying one has to come back as "malformed block".
TEST_CASE("Texture displacement JSON reports a value of the wrong type instead of throwing", "[TextureDisplacement]")
{
const std::string json = GENERATE(
R"({"layers":[{"slot":0,"offset":["a","b"]}]})",
R"({"layers":[{"slot":0,"view_project_right":[1,null,3]}]})",
R"({"layers":[{"slot":0,"view_project_matrix":[1,2,3,4,5,6,7,8,9,10,11,"x"]}]})",
R"({"layers":[{"slot":0,"lscm_seam_edges":["a","b"]}]})",
R"({"layers":[{"slot":0,"islands":[[1,2,"a",4]]}]})",
R"({"layers":[{"slot":0,"lscm_uv_overrides":[[1,"a",3]]}]})");
INFO(json);
std::vector<TextureDisplacementLayer> layers;
TextureDisplacementOptions options;
bool parsed = true;
REQUIRE_NOTHROW(parsed = texture_displacement_layers_from_json(json, layers, options));
CHECK_FALSE(parsed);
CHECK(layers.empty());
}
// A plain setting is read through a helper that shrugs a wrong type off, which is the deliberate
// other half of the case above: the field keeps its default and the rest of the layer still loads.
TEST_CASE("Texture displacement JSON keeps the default for a setting of the wrong type", "[TextureDisplacement]")
{
const std::string json = R"({"options":{"smooth_strength":"a lot"},"layers":[{"slot":"two","name":"Old","depth_mm":0.75}]})";
std::vector<TextureDisplacementLayer> layers;
TextureDisplacementOptions options;
REQUIRE(texture_displacement_layers_from_json(json, layers, options));
REQUIRE(layers.size() == 1);
const TextureDisplacementLayer fresh;
const TextureDisplacementOptions defaults;
CHECK(layers[0].slot == fresh.slot);
CHECK(layers[0].name == "Old");
CHECK_THAT(layers[0].depth_mm, WithinAbs(0.75f, 1e-6f));
CHECK_THAT(options.smooth_strength, WithinAbs(defaults.smooth_strength, 1e-6f));
}
TEST_CASE("whether a layer's texture has colour agrees with its decode", "[TextureDisplacement]")
{
TextureDisplacementLayer gray;