Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bc300933e | ||
|
|
5cfd91a309 | ||
|
|
db0e788d35 | ||
|
|
d2ba88c93d | ||
|
|
fc5ac6d2a5 | ||
|
|
64ddc1fbba | ||
|
|
d2675f15d1 | ||
|
|
5371c85362 | ||
|
|
546b9c621d |
@@ -0,0 +1,131 @@
|
||||
# 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.
|
||||
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 112 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Qidi",
|
||||
"version": "02.04.00.16",
|
||||
"version": "02.04.00.17",
|
||||
"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=[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",
|
||||
"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",
|
||||
"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[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_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_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[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_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_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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
#!/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()
|
||||
@@ -22,7 +22,6 @@
|
||||
#include "libslic3r/BrimEarsPoint.hpp"
|
||||
#include "libslic3r/Config.hpp"
|
||||
#include "libslic3r/CustomGCode.hpp"
|
||||
#include "libslic3r/TextureDisplacement.hpp"
|
||||
#include "libslic3r_version.h"
|
||||
|
||||
#include "bbs_3mf.hpp"
|
||||
@@ -64,7 +63,6 @@
|
||||
#include <ostream>
|
||||
#include <sstream>
|
||||
#include <set>
|
||||
#include <iterator>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <map>
|
||||
@@ -356,13 +354,6 @@ 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";
|
||||
|
||||
@@ -856,30 +847,11 @@ 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);
|
||||
@@ -895,8 +867,6 @@ 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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1230,11 +1200,6 @@ 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;
|
||||
@@ -2108,13 +2073,6 @@ 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
|
||||
@@ -3352,48 +3310,6 @@ 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');
|
||||
@@ -4050,9 +3966,6 @@ 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));
|
||||
}
|
||||
@@ -5404,18 +5317,6 @@ 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);
|
||||
@@ -5431,8 +5332,6 @@ 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
|
||||
@@ -5597,28 +5496,12 @@ 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
|
||||
@@ -5922,9 +5805,6 @@ 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));
|
||||
}
|
||||
@@ -6928,9 +6808,6 @@ 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>";
|
||||
|
||||
@@ -7774,20 +7651,6 @@ 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();
|
||||
@@ -8237,42 +8100,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
|
||||
return file_path;
|
||||
}
|
||||
|
||||
// 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)
|
||||
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
|
||||
@@ -8383,8 +8211,6 @@ bool _BBS_3MF_Exporter::_add_model_config_file_to_archive(mz_zip_archive& archiv
|
||||
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())
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
#include <mutex>
|
||||
#include <numeric>
|
||||
#include <optional>
|
||||
#include "nlohmann/json.hpp"
|
||||
#include <queue>
|
||||
#include <ratio>
|
||||
#include <string>
|
||||
@@ -5167,256 +5166,5 @@ 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
|
||||
|
||||
@@ -397,38 +397,14 @@ 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, v2_flip_edges, color_mix_enabled, color_despeckle, color_mix_count,
|
||||
active_slot, panel_mode);
|
||||
v2_relocate, color_mix_enabled, color_despeckle, color_mix_count);
|
||||
}
|
||||
};
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -20,6 +20,8 @@ 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
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
#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
|
||||
@@ -0,0 +1,75 @@
|
||||
#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_
|
||||
@@ -3232,9 +3232,16 @@ 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,7 +3558,6 @@ 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;
|
||||
@@ -3594,24 +3593,6 @@ 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
|
||||
@@ -3660,14 +3641,6 @@ 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)
|
||||
@@ -3676,7 +3649,6 @@ 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.
|
||||
@@ -3729,6 +3701,189 @@ 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();
|
||||
@@ -3873,6 +4028,14 @@ 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();
|
||||
@@ -3931,7 +4094,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())
|
||||
m_imgui->tooltip(entry.name, m_imgui->scaled(18.f));
|
||||
panel_tooltip(entry.name);
|
||||
|
||||
if (user && ImGui::IsMouseHoveringRect(a, b)) {
|
||||
const float r = std::round(tile * 0.17f);
|
||||
@@ -3944,7 +4107,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)
|
||||
m_imgui->tooltip(_u8L("Remove from My textures"), m_imgui->scaled(18.f));
|
||||
panel_tooltip(_u8L("Remove from My textures"));
|
||||
if (remove_clicked)
|
||||
to_remove = entry;
|
||||
}
|
||||
@@ -3971,10 +4134,9 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
|
||||
if (ImGui::Button("+##import_texture", ImVec2(tile, tile)))
|
||||
import_clicked = true;
|
||||
if (ImGui::IsItemHovered())
|
||||
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));
|
||||
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."));
|
||||
end_grid(origin, count + 1);
|
||||
}
|
||||
|
||||
@@ -3984,6 +4146,8 @@ 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)
|
||||
@@ -5615,18 +5779,16 @@ void GLGizmoTextureDisplacement::render_debug_stage_panel(ModelVolume *mv)
|
||||
run_stage_debug();
|
||||
m_imgui->disabled_end();
|
||||
if (ImGui::IsItemHovered())
|
||||
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));
|
||||
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."));
|
||||
|
||||
ImGui::SameLine();
|
||||
m_imgui->bbl_checkbox(_L("Check topology"), m_debug_check_topology);
|
||||
if (ImGui::IsItemHovered())
|
||||
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));
|
||||
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."));
|
||||
|
||||
if (m_debug_stages.empty())
|
||||
return;
|
||||
@@ -5694,9 +5856,8 @@ void GLGizmoTextureDisplacement::render_debug_stage_panel(ModelVolume *mv)
|
||||
show_error(nullptr, _u8L("Could not write the stage meshes."));
|
||||
}
|
||||
if (ImGui::IsItemHovered())
|
||||
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));
|
||||
panel_tooltip(_u8L("Write every captured stage out as an OBJ file, so they can be opened "
|
||||
"side by side in a mesh viewer."));
|
||||
}
|
||||
|
||||
void GLGizmoTextureDisplacement::queue_prepare(const TextureDisplacementPrepareParams ¶ms,
|
||||
@@ -5821,6 +5982,12 @@ 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;
|
||||
@@ -5844,7 +6011,6 @@ 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);
|
||||
@@ -5858,9 +6024,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 auto hover_tip = [&](const auto &text, const std::vector<TipMedia> &media = {}) {
|
||||
if (ImGui::IsItemHovered())
|
||||
m_imgui->tooltip(text, wrap_w);
|
||||
panel_tooltip(text, media);
|
||||
};
|
||||
|
||||
// Framed icon toggle: a 1 px frame, and when on a teal frame over a teal tint with the icon in its
|
||||
@@ -5868,7 +6034,8 @@ 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 wxString &unavailable = wxString()) -> bool {
|
||||
const wxString &tip_text, const std::vector<TipMedia> &media = {},
|
||||
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));
|
||||
@@ -5895,12 +6062,12 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
|
||||
ImGui::PopStyleVar(2);
|
||||
ImGui::PopID();
|
||||
if (ImGui::IsItemHovered())
|
||||
m_imgui->tooltip(na ? tip_text + "\n\n" + unavailable : tip_text, wrap_w);
|
||||
panel_tooltip(na ? tip_text + "\n\n" + unavailable : tip_text, media);
|
||||
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) -> bool {
|
||||
const wxString &tip_text, const std::vector<TipMedia> &media = {}) -> 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;
|
||||
@@ -5918,7 +6085,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::PopID();
|
||||
if (!tip_text.empty() && ImGui::IsItemHovered())
|
||||
m_imgui->tooltip(tip_text, wrap_w);
|
||||
panel_tooltip(tip_text, media);
|
||||
return clicked;
|
||||
};
|
||||
// A short vertical rule between groups of buttons on one row; call it right after an item.
|
||||
@@ -6014,7 +6181,6 @@ 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.
|
||||
@@ -6037,7 +6203,6 @@ 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();
|
||||
|
||||
@@ -6122,11 +6287,13 @@ 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.")))
|
||||
"holding Shift."),
|
||||
{ { "paint.gif" } }))
|
||||
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.")))
|
||||
_L("Erase - remove the active layer where you click or drag. The right button paints."),
|
||||
{ { "erase.gif" } }))
|
||||
m_erase_mode = true;
|
||||
}
|
||||
|
||||
@@ -6140,20 +6307,22 @@ 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"))) {
|
||||
_L("Brush - paint over the surface by dragging"), { { "paint.gif" } })) {
|
||||
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"))) {
|
||||
if (icon_toggle(802, "texture_displacement_face.svg", is_face_mode, icon_md, _L("Face"), _L("Face - click individual triangles"),
|
||||
{ { "tool_face.gif" } })) {
|
||||
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"))) {
|
||||
"angle threshold"),
|
||||
{ { "tool_smart_fill.gif" } })) {
|
||||
m_tool_type = ToolType::SMART_FILL;
|
||||
m_cursor_type = TriangleSelector::CursorType::POINTER;
|
||||
}
|
||||
@@ -6167,11 +6336,13 @@ 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"), whole_na))
|
||||
_L("Select whole model - paint every face of the model with the active layer"),
|
||||
{ { "select_all.png" } }, 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_na)) {
|
||||
_L("Erase whole model - clear the active layer's paint from every face"),
|
||||
{ { "erase_all.png" } }, erase_na)) {
|
||||
Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Reset texture displacement selection"),
|
||||
UndoRedo::SnapshotType::GizmoAction);
|
||||
int idx = -1;
|
||||
@@ -6194,7 +6365,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"));
|
||||
hover_tip(m_desc.at("cursor_size"), { { "cursor_size.gif" } });
|
||||
vsep(icon_sm);
|
||||
const bool is_circle = m_cursor_type == TriangleSelector::CursorType::CIRCLE;
|
||||
centre_on_row(icon_sm);
|
||||
@@ -6211,7 +6382,8 @@ 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"));
|
||||
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") } });
|
||||
} else {
|
||||
centre_on_row(frame_h);
|
||||
ImGui::AlignTextToFramePadding();
|
||||
@@ -6248,19 +6420,21 @@ 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")))
|
||||
_L("Normal - the real geometry, exactly what Bake will produce. Slower to update"),
|
||||
{ { "tile_on.png" } }))
|
||||
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")))
|
||||
"editing. Updates instantly while you paint"),
|
||||
{ { "view_fast.png" } }))
|
||||
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"),
|
||||
uv_view_na)) {
|
||||
{ { "view_checker.png" } }, uv_view_na)) {
|
||||
new_mode = 2;
|
||||
open_uv_editor = true;
|
||||
}
|
||||
@@ -6268,13 +6442,14 @@ 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"),
|
||||
uv_view_na)) {
|
||||
{ { "view_distortion.png" } }, 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")))
|
||||
_L("Wireframe - overlay the mesh edges; independent of the view above"),
|
||||
{ { "view_wireframe.png" } }))
|
||||
wf_toggle = true;
|
||||
|
||||
const std::string auto_label = _u8L("Auto");
|
||||
@@ -6483,14 +6658,17 @@ 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."));
|
||||
"millimetres. Black does not move it at all, unless Midlevel says otherwise."),
|
||||
{ { "depth.gif" } });
|
||||
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."));
|
||||
"single copy."),
|
||||
{ { "tile_size_large.png", _L("Larger") }, { "tile_size_small.png", _L("Smaller") } });
|
||||
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."));
|
||||
"edge of the model."),
|
||||
{ { "rotation.gif" } });
|
||||
|
||||
if (m_layer_expanded[size_t(layer.slot)]) {
|
||||
{
|
||||
@@ -6510,7 +6688,8 @@ 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."));
|
||||
"cut can pass through the other side."),
|
||||
{ { "midlevel.gif" } });
|
||||
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."));
|
||||
@@ -6520,12 +6699,14 @@ 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."));
|
||||
"costs fine detail."),
|
||||
{ { "smoothing_off.png", _L("Off") }, { "smoothing_on.png", _L("On") } });
|
||||
|
||||
// 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."));
|
||||
"blends into the bare surface instead of stopping at a step."),
|
||||
{ { "edge_fade_off.png", _L("Off") }, { "edge_fade_on.png", _L("On") } });
|
||||
ImGui::SameLine();
|
||||
m_imgui->disabled_begin(!layer.edge_smoothing);
|
||||
ImGui::SetNextItemWidth(-card_pad);
|
||||
@@ -6540,7 +6721,8 @@ 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."));
|
||||
"round. The same as using a negative of the image."),
|
||||
{ { "invert_off.png", _L("Off") }, { "invert_on.png", _L("On") } });
|
||||
ImGui::SameLine();
|
||||
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + 0.5f * (ImGui::GetWindowContentRegionMax().x - card_pad - x0)));
|
||||
|
||||
@@ -6556,13 +6738,12 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
|
||||
}
|
||||
m_imgui->disabled_end();
|
||||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
||||
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);
|
||||
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."));
|
||||
|
||||
// The rest of colour belongs to the whole stack, not to this layer, so it only appears once -
|
||||
// under whichever layer turned colour on.
|
||||
@@ -6572,7 +6753,8 @@ 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."));
|
||||
"prints the same either way. Off uses one filament per area."),
|
||||
{ { "mix_off.png", _L("Off") }, { "mix_on.png", _L("On") } });
|
||||
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
|
||||
@@ -6581,7 +6763,8 @@ 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."));
|
||||
"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") } });
|
||||
// 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());
|
||||
@@ -6590,7 +6773,8 @@ 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."));
|
||||
"lower it if small features are being swallowed."),
|
||||
{ { "denoise_off.png", _L("Off") }, { "denoise_on.png", _L("On") } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6606,29 +6790,35 @@ 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.") },
|
||||
"patch wrapping around a sharp edge has no seam."),
|
||||
"map_cube.png" },
|
||||
{ "menu_obj_cylinder.svg", _L("Cylindrical"),
|
||||
_L("Cylindrical - wraps the texture around the painted area's own center, for round shapes.") },
|
||||
_L("Cylindrical - wraps the texture around the painted area's own center, for round shapes."),
|
||||
"map_cylinder.png" },
|
||||
{ "menu_obj_sphere.svg", _L("Spherical"),
|
||||
_L("Spherical - wraps the texture around the painted area's own center in both directions.") },
|
||||
_L("Spherical - wraps the texture around the painted area's own center in both directions."),
|
||||
"map_sphere.png" },
|
||||
{ "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.") },
|
||||
"piece can lie flat on its own."),
|
||||
"map_unwrap.png" },
|
||||
{ "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.") },
|
||||
"projector."),
|
||||
"map_view.png" },
|
||||
};
|
||||
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].tip, { { mappings[mi].picture } }) &&
|
||||
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.
|
||||
@@ -6732,19 +6922,20 @@ 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."));
|
||||
"at the size set by Tile size."),
|
||||
{ { "tile_off.png", _L("Off") }, { "tile_on.png", _L("On") } });
|
||||
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_na)) {
|
||||
icon_sm, _L("Repeat"), _L("Repeat"), { { "tile_repeat.png" } }, 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_na)) {
|
||||
{ { "tile_mirror.png" } }, tile_na)) {
|
||||
layer.tile_method = static_cast<TextureTileMethod>(1);
|
||||
m_preview_params_dirty = true;
|
||||
}
|
||||
@@ -6774,14 +6965,16 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
|
||||
}
|
||||
m_imgui->disabled_end();
|
||||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
||||
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);
|
||||
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) } });
|
||||
|
||||
ImGui::SameLine(0.f, gap_s);
|
||||
if (icon_toggle(842, "texture_displacement_adjust.svg", m_adjust_texture_mode, icon_sm, _L("Adjust placement"),
|
||||
@@ -6964,7 +7157,8 @@ 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."));
|
||||
"survives the refinement."),
|
||||
{ { "painted_only_off.png", _L("Off") }, { "painted_only_on.png", _L("On") } });
|
||||
|
||||
if (m_subdivide_adaptive) {
|
||||
if (m_subdivide_target_mm <= 0.f && mv != nullptr) {
|
||||
@@ -6992,7 +7186,8 @@ 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."));
|
||||
"triangles on most textures."),
|
||||
{ { "adaptive_off.png", _L("Off") }, { "adaptive_on.png", _L("On") } });
|
||||
|
||||
// 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
|
||||
@@ -7049,7 +7244,8 @@ 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."));
|
||||
"only the painted area unless you need the whole model finer."),
|
||||
{ { "subdivide.gif" } });
|
||||
}
|
||||
|
||||
{
|
||||
@@ -7113,12 +7309,14 @@ 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."));
|
||||
"even mesh; this is what makes one out of an uneven import."),
|
||||
{ { "remesh_before.png", _L("Before") }, { "remesh_after.png", _L("After") } });
|
||||
|
||||
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."));
|
||||
"a cube comes back with wobbly edges."),
|
||||
{ { "preserve_edges_off.png", _L("Off") }, { "preserve_edges_on.png", _L("On") } });
|
||||
ImGui::SameLine();
|
||||
m_imgui->disabled_begin(!m_remesh_keep_sharp_edges);
|
||||
ImGui::SetNextItemWidth(-1.f);
|
||||
@@ -7134,7 +7332,8 @@ 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)."));
|
||||
"survives (already-baked relief is kept too)."),
|
||||
{ { "remesh_before.png", _L("Before") }, { "remesh_after.png", _L("After") } });
|
||||
|
||||
// Settings for the whole stack rather than one layer. Standard mode pins them instead of showing them.
|
||||
if (mv != nullptr) {
|
||||
@@ -7150,7 +7349,8 @@ 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."));
|
||||
"on a layer is a different thing: it blurs the image before it is used."),
|
||||
{ { "geometry_smoothing.gif" } });
|
||||
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)) {
|
||||
@@ -7214,7 +7414,8 @@ 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."));
|
||||
"the refinement produced, however many that is."),
|
||||
{ { "subdivide.gif" } });
|
||||
}
|
||||
if (SHOW_PIPELINE_DEV_CONTROLS && opts.pipeline_v2) {
|
||||
// Keeping the relief above the plate is not a checkbox: it is unconditional, in both pipelines
|
||||
@@ -7229,7 +7430,8 @@ 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."));
|
||||
"relief look jagged."),
|
||||
{ { "collapse_slivers_off.png", _L("Off") }, { "collapse_slivers_on.png", _L("On") } });
|
||||
}
|
||||
// Below both pipelines' own settings, because it replays whichever of them is selected.
|
||||
if (SHOW_PIPELINE_DEV_CONTROLS)
|
||||
@@ -7340,7 +7542,8 @@ 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."));
|
||||
"back down and the detail goes with it."),
|
||||
{ { "subdivide.gif" } });
|
||||
|
||||
// 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
|
||||
@@ -7383,7 +7586,8 @@ 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."));
|
||||
"spent it where it shows most. Raise it if the relief still looks coarse."),
|
||||
{ { "subdivide.gif" } });
|
||||
}
|
||||
|
||||
const float button_h = std::round(frame_h * 1.25f);
|
||||
@@ -7435,18 +7639,17 @@ 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))
|
||||
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);
|
||||
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."));
|
||||
|
||||
ImGui::SameLine();
|
||||
if (busy) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#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"
|
||||
@@ -262,9 +263,6 @@ 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
|
||||
@@ -974,12 +972,9 @@ 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. 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.
|
||||
// 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.
|
||||
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
|
||||
@@ -1031,6 +1026,50 @@ 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
|
||||
|
||||
@@ -28,12 +28,9 @@
|
||||
#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>
|
||||
@@ -352,24 +349,6 @@ 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)
|
||||
@@ -384,7 +363,19 @@ static void store_painted_cube(const std::string& path)
|
||||
selector.set_facet(1, EnforcerBlockerType::Extruder3);
|
||||
REQUIRE(volume->mmu_segmentation_facets.set(selector));
|
||||
}
|
||||
store_project(path, model);
|
||||
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));
|
||||
}
|
||||
|
||||
// Loads `path` through the BBS importer into `model`, releasing the plates it returns. The
|
||||
@@ -2089,307 +2080,3 @@ 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,190 +2157,6 @@ 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;
|
||||
|
||||