Add Parallax preview & fix Undo\Redo history

This commit is contained in:
ExPikaPaka
2026-08-12 10:14:38 +02:00
parent 2db770b672
commit d926f7c490
7 changed files with 819 additions and 367 deletions

View File

@@ -1,9 +1,7 @@
# Texture Displacement - Technical Notes
Branch: `feature/texture_displacement`. This document is a knowledge dump of the whole feature as
it stands: architecture, file map, algorithms, known bugs found and fixed (with root causes worth
remembering), and what's still deferred. Written so a fresh session (or a fresh pair of eyes) can
pick this up without re-deriving everything from scratch.
Branch: `feature/texture_displacement`. Reference for the feature as it stands: what it does, how the
algorithms work, and where the code lives.
## What it does
@@ -14,24 +12,67 @@ A paint-style gizmo (`GLGizmoTextureDisplacement`) that lets you:
(saved into `<data_dir>/textures/displacement/`, kept separate so app updates can't clobber it).
- Combine overlapping layers with image-editor-style blend modes (Add/Subtract/Multiply/Divide).
- Preview the true displaced result live, before baking (background job, not on the UI thread).
- Optionally preview via a fast GPU bump-map shader instead (no real geometry movement, just
shading) for a lighter-weight alternative.
- Preview via a fast GPU shader instead (no real geometry movement) for a lighter-weight alternative.
- Bake into real mesh geometry on demand, restricted to the painted area only.
- Subdivide a low-poly model first so there are enough vertices to show fine detail.
- Remesh and subdivide so a low-poly model has enough vertices to show fine detail.
- Unwrap a painted patch with a real CGAL LSCM parameterization and view it in a dedicated,
dockable 2D "UV Editor" pane.
## Standard vs Pro mode
A two-position slider in the panel header, right of the Dock/Undock button.
**Pro** shows every mesh-preparation control; Remesh, Subdivide and Bake are run separately by the user,
in whatever order they like.
**Standard** hides all of it and folds one fixed recipe into the Bake button, because a height map only
ever *moves vertices that already exist* - painting onto an imported 12-triangle box and pressing Bake
would otherwise do nothing visible. Standard's Bake is:
1. `plan_remesh()` + `replace_mesh_keep_all_paint()` - isotropic remesh to 1 mm, sharp edges above 40
degrees protected. Gives the subdivider an even starting density whatever the input looked like.
2. `plan_adaptive_subdivision()` + `apply_adaptive_subdivision()` - feature-adaptive refinement, max
edge 20 mm, detail 0.02 mm, min edge 0.02 mm.
3. `bake()` - the ordinary background displacement job.
Both preparation stages are *planned* before the undo snapshot and *applied* after it, so a stage with
nothing to do is skipped without leaving an empty undo step. The standalone Pro buttons share the same
plan/apply split.
**All three stages sit under one undo step.** `Plater::take_snapshot()` records the state *before* the
change, so a single snapshot taken at the top of `bake_standard()` means one Undo returns the mesh to
exactly what was imported. `TextureDisplacementBakeInput::take_snapshot` lets the caller say who owns
the undo step - true for the Pro-mode button, false for the pipeline, whose background job commits long
after that snapshot's scope has closed.
The presets live in one place (`STD_*` constants) and `apply_standard_mode_presets()` pins the hidden
controls to them every frame while Standard is active, so the live preview cannot disagree with what
Bake will do. Switching to Standard also closes the subdivision preview, whose controls have just gone.
One control survives into Standard: **"Added triangles (k)"**, the subdivision budget. It is deliberately
*not* pinned - pinning would fight the user's own slider every frame - because unlike the rest of the
recipe its right value depends on the part rather than on the method (a big model, or a fine texture,
simply needs more triangles). Default 1500. The widget is one lambda shared by both layouts.
Standard remeshes *after* painting, so the remesh has to preserve paint: `ModelVolume::restore_painting()`
only remaps the four standard channels, so `replace_mesh_keep_all_paint()` additionally runs
`TriangleSelector::remap_painting()` over the eight texture-displacement masks. The Pro Remesh button
goes through the same helper. If the remap comes back empty the pipeline stops with a message rather
than baking a flat mesh.
## Architecture
### Data model (per `ModelVolume`)
Each of up to `TEXTURE_DISPLACEMENT_MAX_LAYERS` (8) layers gets its **own independent
`FacetsAnnotation`** paint mask - the exact same `TriangleSelector`/`FacetsAnnotation` machinery
every other paint gizmo (FdmSupports, Seam, MMU, FuzzySkin) already uses, just one full instance
per layer slot instead of one per volume. This is what makes "layered/blended" painting work for
free: the same triangle can be `ENFORCER` in layer 2's mask and layer 5's mask simultaneously, and
at bake/preview time each layer displaces the surface left by the previous one (image-editor-layer
semantics).
`FacetsAnnotation`** paint mask - the same `TriangleSelector`/`FacetsAnnotation` machinery every other
paint gizmo (FdmSupports, Seam, MMU, FuzzySkin) already uses, just one full instance per layer slot
instead of one per volume. This is what makes layered/blended painting work for free: the same triangle
can be `ENFORCER` in layer 2's mask and layer 5's mask simultaneously, and at bake/preview time each
layer displaces the surface left by the previous one (image-editor-layer semantics).
Whole-stack settings (border handling, post-process smoothing) live beside the layers in
`texture_displacement_options` (`TextureDisplacementOptions`), since they belong to no single layer.
### Bake algorithm (`libslic3r/TextureDisplacement.cpp`)
@@ -42,13 +83,13 @@ same order - only the positions of displaced vertices differ.
1. `its_compactify_vertices()` on a copy of the input. In practice a no-op (it only drops
*unreferenced* vertices, and preserves the order and indices of the rest). It is there to
guarantee the index alignment step 3 depends on.
2. Area-weighted vertex normals of the **undisplaced** mesh, computed once. Every layer both
projects and displaces along these, so a vertex covered by several layers moves along one single
well-defined direction. Where the paint does *not* cover every triangle around a vertex, the normal
is recomputed from the painted triangles alone (the union over all layers, so it stays one direction
per vertex). On the rim of a fully painted top face the whole-mesh normal is the 45 degrees bisector
it shares with the side wall, and displacing along that flares the rim outwards instead of raising
it. Interior vertices are unaffected - all their triangles are painted, so the two coincide. Paint
2. Area-weighted vertex normals of the **undisplaced** mesh, computed once. Every layer both projects
and displaces along these, so a vertex covered by several layers moves along one single well-defined
direction. Where the paint does *not* cover every triangle around a vertex, the normal is recomputed
from the painted triangles alone (the union over all layers, so it stays one direction per vertex):
on the rim of a fully painted top face the whole-mesh normal is the 45-degree bisector it shares with
the side wall, and displacing along that flares the rim outwards instead of raising it. Interior
vertices are unaffected - all their triangles are painted, so the two normals coincide. Paint
coverage per original triangle comes straight off `TriangleSplittingData::triangles_to_split`.
3. For each layer in slot order: deserialize its stored paint mask into a `TriangleSelector` against
the **base mesh** (never against a previous layer's output), then
@@ -61,36 +102,33 @@ same order - only the positions of displaced vertices differ.
brush stroke split a triangle are appended after them), and `get_facets_strict()` emits the
referenced ones in order. Combined with step 1, **selector vertex index `i` is our vertex `i`**.
Split vertices live past the end of our array and are simply skipped - they sit on the paint
boundary anyway (splitting only happens at partial coverage), so they would be pinned regardless.
boundary anyway (splitting only happens at partial coverage).
4. A vertex used by at least one **unpainted** triangle is a border vertex. Whether it moves is
`TextureDisplacementOptions::displace_border`, and it **does by default**. The original design
pinned it, justified as stopping the patch tearing away from the surrounding surface - which stopped
being true the moment the bake became topology-preserving. There is no seam to tear: a border vertex
is *one* vertex shared by both regions, and moving it simply tilts the unpainted triangles that use
it. What pinning actually does is clamp the outermost ring of relief to zero, so on a fully painted
face the pattern collapses into a ring of steep ramps right at the edge - the reported "it doesn't
extrude at the border" artifact. Pinning is kept as an option for the case where the relief must not
spill past the paint at all. Either way the border still drives the `edge_smoothing` falloff.
`TextureDisplacementOptions::displace_border`, and it does by default. Nothing can tear: the bake is
topology-preserving, so a border vertex is *one* vertex shared by both regions and moving it simply
tilts the unpainted triangles that use it. Pinning it instead clamps the outermost ring of relief to
zero, which on a fully painted face collapses the pattern into a ring of steep ramps at the edge; it
is kept as an option for when the relief must not spill past the paint at all. Either way the border
drives the `edge_smoothing` falloff.
5. Per interior vertex: sample the height texture (`sample_layer_height()`, see Projection methods)
and fold `height * depth_mm * (invert ? -1 : 1)` into that vertex's running total via the layer's
`TextureBlendMode` (see Blend modes). A `visited` set makes each layer fold in exactly **once**
per vertex, no matter how many of the patch's triangles share it - otherwise a Multiply/Subtract
layer would apply two or three times over depending on local triangle fan-out.
6. Finally, move each touched vertex along its (step 2) normal by its accumulated total.
6. Move each touched vertex along its (step 2) normal by its accumulated total.
7. Optionally (`TextureDisplacementOptions::smooth_*`) relax the result - see Post-process smoothing.
### Post-process smoothing
`smooth_mesh_vertices(mesh, movable, strength, iterations)` - plain Laplacian relaxation, run after all
layers have been folded in, restricted to the vertices flagged in `movable`. Each pass moves a movable
vertex a `strength` fraction of the way to the average of its one-ring, read from a **snapshot** of the
previous pass so the result does not depend on vertex order (a Gauss-Seidel sweep would smooth several
times as hard at the end of the array as at the start). Neighbours come from a CSR-style adjacency built
once per call.
`smooth_mesh_vertices(mesh, movable, strength, iterations)` - Laplacian relaxation, run after all layers
have been folded in, restricted to the vertices flagged in `movable`. Each pass moves a movable vertex a
`strength` fraction of the way to the average of its one-ring, read from a **snapshot** of the previous
pass so the result does not depend on vertex order (a Gauss-Seidel sweep would smooth several times as
hard at the end of the array as at the start). Neighbours come from a CSR-style adjacency built once per
call. Topology-preserving, like the bake.
Its job is to round off the hard steps a bitmap height map leaves behind, which is a different knob from
`TextureDisplacementLayer::smoothing` - that blurs the *height map* before it is ever sampled, this
relaxes the *geometry* afterwards. Topology-preserving, like the bake.
Its job is to round off the hard steps a bitmap height map leaves behind - a different knob from
`TextureDisplacementLayer::smoothing`, which blurs the *height map* before it is ever sampled.
Two ways in, sharing one set of settings on the volume:
- The **"Smooth result"** checkbox + "Smoothing (%)" / "Passes" ride along with Preview and Bake.
@@ -103,14 +141,13 @@ Two ways in, sharing one set of settings on the volume:
operation in the gizmo that keeps **every** paint channel verbatim - it saves and restores the eight
texture-displacement masks around `set_mesh()` rather than remapping or dropping them.
**"Ignore outer ring"** (`smooth_skip_border`, **on by default**) drops the patch's own outermost ring of
**"Ignore outer ring"** (`smooth_skip_border`, on by default) drops the patch's own outermost ring of
vertices from `movable`. That ring's neighbours *outside* the paint never move, so relaxing it drags the
rim of the relief back down toward the flat surface and the pattern comes out half-melted exactly where
it meets the edge - crisp everywhere else, which is what makes it look like a bug rather than a setting.
Held out, the border keeps the full depth the texture asked for and only the interior relaxes. Turning it
off softens the outer edge deliberately (a blunter version of the per-layer edge-smoothing falloff).
Note this is the *smoothing* rim, a separate question from whether that rim is displaced at all
(`displace_border`, step 4 above) - the two are independent and both default to "keep the border sharp".
rim of the relief down toward the flat surface and the pattern comes out half-melted where it meets the
edge. Held out, the border keeps the full depth the texture asked for and only the interior relaxes.
Turning it off softens the outer edge deliberately (a blunter version of the per-layer edge-smoothing
falloff). This is the *smoothing* rim, independent of whether that rim is displaced at all
(`displace_border`, step 4 above); both default to keeping the border sharp.
### Blend modes
@@ -122,50 +159,46 @@ Add/Subtract are self-explanatory. Multiply/Divide are *scaling* operations and
convention: they treat the layer's own value as a **factor relative to 1 mm**. That makes `depth_mm`
a gain, and - the property that makes a Multiply layer usable as a mask - a layer with depth 1 mm
sampling a white (1.0) texel multiplies by exactly 1, i.e. leaves the layers below unchanged.
Divide floors its divisor's magnitude at 0.05 - a black texel samples to *exactly* zero, so the
divisor really does hit zero in ordinary use, and an unbounded `1/0` would fling vertices thousands
of mm away and poison the mesh's bounding box (and every plate/print-volume check downstream). The
floor doubles as a cap on how far Divide can amplify the relief beneath it: at most 20×.
Divide floors its divisor's magnitude at 0.05: a black texel samples to *exactly* zero, so the divisor
really does hit zero in ordinary use, and an unbounded `1/0` would fling vertices thousands of mm away
and poison the mesh's bounding box (and every plate/print-volume check downstream). The floor doubles as
a cap on how far Divide can amplify the relief beneath it: at most 20×.
The **lowest painted layer ignores its blend mode**: it has nothing beneath it, and Multiply/Divide
against an implicit zero base would annihilate (or blow up) it. Enforced in `build_texture_
displacement()` (the first layer to reach a given vertex always folds in additively) and surfaced in
the UI, which labels that layer "Base layer" instead of offering a control that silently does nothing.
against an implicit zero base would annihilate (or blow up) it. Enforced in
`build_texture_displacement()` (the first layer to reach a given vertex always folds in additively) and
surfaced in the UI, which labels that layer "Base layer" instead of offering a control that does nothing.
### Projection methods
Four choices per layer (`TextureProjectionMethod`), all funneling through `apply_uv_transform()`
Five choices per layer (`TextureProjectionMethod`), all funneling through `apply_uv_transform()`
(scale by `1/tiling_scale`, rotate by `rotation_deg`, add `offset`). They are dispatched by
`sample_layer_height()`, which returns a **height**, not a UV - because Triplanar takes three
texture samples per vertex and so has no single UV that represents it.
- **Triplanar** (default) - samples the texture on all three world planes (`(y,z)`, `(x,z)`, `(x,y)`)
and blends the three by the vertex's own normal raised to `TRIPLANAR_BLEND_SHARPNESS` (4).
This is the fix for a real, user-reported bug. The previous version *hard-picked* the single axis
most aligned with the normal, which is discontinuous wherever that dominant axis flips: on a +X
face the planar coordinate is `(y, z)`, on a Y face it is `(x, z)`, so at the shared edge `u`
jumps from `y_edge` to `x_edge`. On a box centred near the origin those two happen to **agree** at
the (+,+) and (,) corners and **differ by the full corner width** at the (+,) and (,+) corners
- which is exactly the "two bad corners, two good ones" symmetry that was observed. A weighted
blend is continuous across the transition by construction, since the weight of the axis being left
behind falls smoothly to zero. (Note this removes the hard *seam*; some cross-fade blurring in the
band right at a 90° edge is inherent to triplanar mapping. A genuinely seam-free wrap around a box
needs a real unwrap - that is what the LSCM mode is for.)
and blends the three by the vertex's own normal raised to `TRIPLANAR_BLEND_SHARPNESS` (4). Hard-picking
the single axis most aligned with the normal instead is discontinuous wherever that dominant axis
flips: on a +X face the planar coordinate is `(y, z)`, on a Y face it is `(x, z)`, so at the shared
edge `u` jumps. A weighted blend is continuous across the transition by construction, since the weight
of the axis being left behind falls smoothly to zero. This removes the hard *seam*; some cross-fade
blurring in the band right at a 90° edge is inherent to triplanar mapping. A genuinely seam-free wrap
around a box needs a real unwrap - that is what the LSCM mode is for.
- **Cylindrical** - wraps around an axis through the patch centroid, axis auto-picked as the world
axis *least* aligned with the average normal (perpendicular to the outward radial normal, as a
cylinder's own axis would be). `u = angle * local_radius` (arc length in mm), `v = distance along
axis`. Approximation, not an exact fit for arbitrary geometry.
axis`. An approximation, not an exact fit for arbitrary geometry, and the axis/centre are not
user-overridable.
- **Spherical** - longitude/latitude around the centroid, scaled by local radius. Same caveat.
- **LSCM** - real UV unwrap via `MeshBoolean::cgal::parameterize_lscm()` (CGAL's
`Surface_mesh_parameterization` package, LSCM algorithm). Computed **once per patch** (not
per-vertex like the others - it's a single global least-squares solve), then each vertex looks up
its precomputed UV. Requires the patch to be a single topological disk (one connected component,
one boundary loop) - `compute_lscm_uvs()` returns empty and the layer silently falls back to
Triplanar if not (e.g. multiple disconnected painted islands, or a fully closed patch).
CGAL's parameterizer needs a mesh with no isolated/unreferenced vertices, but `get_facets_strict()`
returns the *whole* mesh's vertex array - so there's a compaction step
(`compact_patch_with_map()`) that builds a clean sub-mesh + an index map back to the original
(uncompacted) vertex numbering, purely local to this file.
one boundary loop) - `compute_lscm_uvs()` returns empty and the layer falls back to Triplanar if not
(e.g. multiple disconnected painted islands, or a fully closed patch). CGAL's parameterizer needs a
mesh with no isolated/unreferenced vertices, but `get_facets_strict()` returns the *whole* mesh's
vertex array - so `compact_patch_with_map()` builds a clean sub-mesh plus an index map back to the
original vertex numbering, purely local to this file.
- **ViewProjected** ("From view") - a flat projection along a fixed direction captured from the 3D
camera, like a slide projector. `capture_view_projection()` takes the camera's right/up axes,
transforms them into the volume's *local* frame (so the projection rides along if the part is later
@@ -174,7 +207,7 @@ texture samples per vertex and so has no single UV that represents it.
projects `Vec2f(dot(pos, right), dot(pos, up))`. Single-valued per point, so - like LSCM but unlike
blended Triplanar - the fast preview and UV-check overlay precompute it per vertex
(`compute_layer_vertex_uvs()`) and drive the shader's `use_vertex_uv` path. Faces angled away from
the projector smear; that is inherent to view projection, not a bug.
the projector smear; that is inherent to view projection.
Two companions to this mode:
- **Projection frame overlay** (`TextureProjectorFrame`, see below) - a semi-transparent window
@@ -202,35 +235,34 @@ untouched) still forces a re-solve. Like the paint masks, seams are mesh-index-s
any topology change.
Two ways to write to it:
- **Mark seam (manual, #9)** - a "Mark seams" click mode (`m_seam_edit_mode`) that suppresses
painting. A click raycasts the volume (`m_c->raycaster()->raycasters()[idx]->unproject_on_mesh()`,
`idx` = the volume's slot among model-part volumes), finds the facet's edge nearest the hit point,
and toggles it. Marked edges render as a red overlay (`render_seam_overlay()`), pulled toward the
camera so they read on top. This is the Blender mark-seam workflow.
- **Cut island (auto, #17)** - `cut_island()` takes the selected chart's triangles (back-mapped from
the unwrap via `source_vertex`), finds their 3D bounding box, and marks every edge that straddles
the mid-plane perpendicular to the longest axis. The re-unwrap then splits the chart across its
narrow waist - the "islands might be very long" case. Exposed as the UV pane's **Cut** button.
- **Mark seam (manual)** - a "Mark seams" click mode (`m_seam_edit_mode`) that suppresses painting. A
click raycasts the volume (`m_c->raycaster()->raycasters()[idx]->unproject_on_mesh()`, `idx` = the
volume's slot among model-part volumes), finds the facet's edge nearest the hit point, and toggles it.
Marked edges render as a red overlay (`render_seam_overlay()`), pulled toward the camera so they read
on top. This is the Blender mark-seam workflow.
- **Cut island (auto)** - `cut_island()` takes the selected chart's triangles (back-mapped from the
unwrap via `source_vertex`), finds their 3D bounding box, and marks every edge that straddles the
mid-plane perpendicular to the longest axis. The re-unwrap then splits the chart across its narrow
waist. Exposed as the UV pane's **Cut** button.
### UV-check overlays (checker / distortion)
`resources/shaders/{110,140}/texture_displacement_uvcheck.{vs,fs}`, one shader with a `mode` uniform,
drawn over the painted patch (`rebuild_uvcheck_mesh()`/`render_uvcheck_mesh()`, P3N3T2: `normal.x` =
distortion, `tex_coord` = uv), pulled forward with a polygon offset. **Checker** (#13) samples a
procedural checkerboard at the layer's uv (per-vertex for LSCM/ViewProjected, in-shader triplanar
otherwise) - squares that stay square mean low distortion. **Distortion** (#14) colours each triangle
blue→green→red by `log2(uv_area / surface_area)` centred on the patch's *median* stretch (so a
globally-scaled unwrap reads as uniformly ideal and only relative stretch shows), averaged to
vertices. A separate **Show mesh wireframe** toggle (#8) draws the whole volume's triangle edges,
rebuilt only when the vertex count changes (not per stroke).
distortion, `tex_coord` = uv), pulled forward with a polygon offset. **Checker** samples a procedural
checkerboard at the layer's uv (per-vertex for LSCM/ViewProjected, in-shader triplanar otherwise) -
squares that stay square mean low distortion. **Distortion** colours each triangle blue→green→red by
`log2(uv_area / surface_area)` centred on the patch's *median* stretch (so a globally-scaled unwrap
reads as uniformly ideal and only relative stretch shows), averaged to vertices. A separate **Show mesh
wireframe** toggle draws the whole volume's triangle edges, rebuilt only when the vertex count changes
(not per stroke).
### Tiling
`DecodedHeightTexture::sample(uv, tile_enabled, tile_method)`. Two tile methods when enabled
(Repeat, MirroredRepeat). **When `tile_enabled` is false, sampling outside `[0,1)` returns `0`
directly** - clamping the *coordinate* into range (what an earlier version did) instead smears the
border row/column of pixels outward to infinity in every direction, which is a real bug that was
reported and fixed (visually: streaky lines radiating out from the painted patch).
directly** rather than clamping the *coordinate* into range, which would smear the border row/column of
pixels outward to infinity in every direction (streaky lines radiating out from the painted patch).
### Subdivision — two modes
@@ -242,16 +274,15 @@ no subdivision), Apply snaps back to 0. Drops texture-displacement paint (no rem
`save_painting()`/`set_mesh()`/`restore_painting()` dance; the other four channels are remapped.
**Adaptive (`subdivide_mesh_adaptive()`)** — refine **only the painted area**, by **Rivara longest-edge
bisection**. This is the algorithm that was "scoped out" originally for fear of the T-junction/crack
problem; it is safe because it is *conformal by construction*. Only **terminal** edges are ever bisected
- an edge that is the longest edge of *every* triangle sharing it - which splits both those triangles
along one shared midpoint at once, so a hanging node is never created. The edge to split for a triangle
that wants refining is found by **longest-edge propagation (LEPP)**: walk to the longest edge of
ever-longer-edged neighbours until a terminal one is reached, and bisect that. Edge length strictly
increases along the path (ties broken by mesh-vertex key, which both sides of an edge compute
identically), so the walk cannot cycle, and Rivara's result is that repeating it refines the original
triangle in a bounded number of bisections. The transition triangles it pulls in just outside the
painted patch are the graded band that makes the size change conformal.
bisection**, which is *conformal by construction*. Only **terminal** edges are ever bisected - an edge
that is the longest edge of *every* triangle sharing it - which splits both those triangles along one
shared midpoint at once, so a hanging node is never created. The edge to split for a triangle that wants
refining is found by **longest-edge propagation (LEPP)**: walk to the longest edge of ever-longer-edged
neighbours until a terminal one is reached, and bisect that. Edge length strictly increases along the
path (ties broken by mesh-vertex key, which both sides of an edge compute identically), so the walk
cannot cycle, and Rivara's result is that repeating it refines the original triangle in a bounded number
of bisections. The transition triangles it pulls in just outside the painted patch are the graded band
that makes the size change conformal.
The win: a small decal on a big model no longer quadruples the *whole* model's triangle count.
@@ -260,16 +291,11 @@ of sweeps: it holds every triangle that is over its criteria in a max-heap keyed
it is, pops the worst, walks its LEPP, bisects, and re-scores. Edge adjacency (`nb[e]`, the triangle
across each edge) is built **once** and maintained incrementally through each bisection, so the cost
scales with the refined region rather than with the whole model. `max_triangles` is the only bound;
stopping on it leaves a perfectly valid, still-conformal mesh that spent its budget on the largest errors.
stopping on it leaves a perfectly valid, still-conformal mesh that spent its budget on the largest
errors. A fixed sweep count instead spends itself grading the *coarse surroundings* - whose edges are
the longest, so they win every terminal-edge contest - and never reaches the painted patch.
This shape replaced a first version that ran a fixed 12 sweeps, each rebuilding a whole-mesh edge map and
bisecting one terminal edge per active triangle. Two failure modes came out of that, and they are worth
remembering because they look like separate bugs and are not: the sweeps were consumed grading the
*coarse surroundings* (whose edges are the longest, so they win every terminal-edge contest), which both
**stopped refinement of the painted patch far short** of the requested detail and left the band outside
it looking wildly over-refined relative to the patch itself.
**It carries the paint forward**, which is what makes it usable (uniform/remesh both drop paint). Because
**It carries the paint forward**, which is what makes it usable (uniform subdivide drops paint). Because
the refinement is *driven by* the paint, the remap is trivial: `subdivide_mesh_adaptive()` fills an
`out_source[new_tri] = input_tri` map (children inherit their parent), and the gizmo rebuilds each
layer's mask on the new mesh - a new triangle is painted iff its source was fully painted in that
@@ -277,32 +303,30 @@ layer. `collect_paint_region()` derives both:
- the union refine-region: **exactly** the original triangles the brush touched, read straight off
`TriangleSplittingData::triangles_to_split` (`serialize()` records an entry per original triangle that
is either split - i.e. partially painted, the patch boundary - or carries a non-default state). No
dilation. An earlier version marked every triangle sharing a *vertex* with the patch, which drags in a
whole fan of huge unpainted neighbours and then refines *those* down to the resolution floor, since the
height field the detail test samples is not restricted to the painted area. The conformal closure
already grades the size change outward on its own; it does not need help.
dilation: marking every triangle that shares a *vertex* with the patch drags in a whole fan of huge
unpainted neighbours and refines *those* down to the resolution floor, since the height field the
detail test samples is not restricted to the painted area. The conformal closure already grades the
size change outward on its own.
- the per-layer fully-painted-triangle sets (a `get_facets_strict(ENFORCER)` sub-triangle with all three
*original* vertex indices == a whole, fully-painted original triangle; a partial stroke's sub-triangles
always carry a split vertex).
The other four channels still ride the normal `restore_painting()` remap. Covered by a conformality unit
test (`every_edge_used_twice` on a partially-refined cube - an exact crack detector for a closed mesh),
plus tests that the target edge length is actually *reached* and that the budget caps the result without
opening a crack.
The other four channels ride the normal `restore_painting()` remap.
Both share the gizmo's Preview/Apply/Done flow; the **"Only painted area (adaptive)"** checkbox picks
the mode, and the adaptive preview follows the paint live (`rebuild_preview()` refreshes the wireframe
while the subdivide preview is open in adaptive mode). The panel shows the previewed triangle count.
Both modes share the gizmo's Preview/Apply/Done flow; the **"Only painted area (adaptive)"** checkbox
picks the mode, and the adaptive preview follows the paint live (`rebuild_preview()` refreshes the
wireframe while the subdivide preview is open in adaptive mode). The panel shows the previewed triangle
count.
**Feature-adaptive (follow texture detail).** A sub-mode of adaptive (the **"Follow texture detail"**
checkbox) that puts triangles where the *displaced surface actually bends*, not evenly. The insight:
a flat region or a linear **ramp** needs no extra vertices (linear interpolation is exact for a ramp);
what needs them is **curvature** - the *second* derivative, not the gradient. So the extra predicate is a
**chord-error** test: sample the combined displacement at the triangle's three edge midpoints *and its
centroid* (sampling the interior is what catches a bump sitting inside a triangle, the blind spot of an
edge-only test) and take the largest departure from the flat triangle's barycentric interpolation. Refine
while that exceeds `chord_tolerance_mm` ("Detail (mm)"). Zero chord error on a ramp ⇒ untouched; high on
a bump/ridge/noise ⇒ refined until captured. Same conformal machinery, so still crack-free. The
checkbox) that puts triangles where the *displaced surface actually bends*, not evenly. A flat region or
a linear **ramp** needs no extra vertices (linear interpolation is exact for a ramp); what needs them is
**curvature** - the *second* derivative, not the gradient. So the extra predicate is a **chord-error**
test: sample the combined displacement at the triangle's three edge midpoints *and its centroid*
(sampling the interior is what catches a bump sitting inside a triangle, the blind spot of an edge-only
test) and take the largest departure from the flat triangle's barycentric interpolation. Refine while
that exceeds `chord_tolerance_mm` ("Detail (mm)"). Zero chord error on a ramp ⇒ untouched; high on a
bump/ridge/noise ⇒ refined until captured. Same conformal machinery, so still crack-free. The
per-triangle error is cached and recomputed only for the children of a split.
Four knobs bracket it, and all four matter:
@@ -320,33 +344,40 @@ The height field is `make_combined_displacement_sampler()` - it mirrors `build_t
per-layer setup (decode, patch centroid, cylinder axis, blend order, "lowest layer folds additively")
but evaluated per point. Two deliberate simplifications, both erring toward *more* detail (safe -
over-refinement is never a crack): every sampleable layer is sampled at every point (no per-point paint
test), and edge-smoothing falloff is ignored. Note the first one is *why* the refine region must not be
dilated - outside the paint the sampler still reports full relief. **LSCM layers are skipped** (no
per-point UV); a purely LSCM stack yields a null sampler and the code falls back to the length baseline
alone. Per-vertex heights are sampled lazily, so a small patch on a huge model never pays for the rest of
it. Covered by unit tests: a Gaussian bump refines densely at its center and leaves flat corners coarse,
a linear ramp produces *zero* extra triangles (the case a gradient criterion would over-refine), and a
flat field still honours the max-edge baseline.
test), and edge-smoothing falloff is ignored. The first is *why* the refine region must not be dilated -
outside the paint the sampler still reports full relief. **LSCM layers are skipped** (no per-point UV); a
purely LSCM stack yields a null sampler and the code falls back to the length baseline alone. Per-vertex
heights are sampled lazily, so a small patch on a huge model never pays for the rest of it.
### Fast bump preview (GPU-only, no CPU meshing)
### Fast preview (GPU-only, no CPU meshing)
`resources/shaders/{110,140}/texture_displacement_bump.{vs,fs}`, registered as
`"texture_displacement_bump"`. Perturbs the *shading* normal from the height texture's local
gradient instead of moving geometry - active-layer-only, toggled via a "Fast preview (normal map)"
checkbox. Vertex format is `GLModel::Geometry::EVertexLayout::P3N3T2`: `normal.x` carries the
per-vertex paint weight (0/1) and `tex_coord` carries a precomputed texture UV, so it can use
`GLModel` normally instead of needing a hand-rolled VBO/VAO manager. Weight buffer is
rebuilt at the same cadence as the true-displacement preview (stroke-end/slider-release), using the
**live** `TriangleSelector` state (not the flushed model facets), so it doesn't lag by a full model
round-trip.
`"texture_displacement_bump"`. Shades the *displaced* surface without moving geometry - active-layer
only, selected from the View row, and the default when the gizmo opens (`m_use_bump_preview = true`).
Vertex format is `GLModel::Geometry::EVertexLayout::P3N3T2`: `normal.x` carries the per-vertex paint
weight (0/1), `normal.y` flags the UV island currently being dragged, and `tex_coord` carries a
precomputed texture UV, so it can use `GLModel` normally instead of a hand-rolled VBO/VAO manager.
The mesh is **flat** (vertices not shared between triangles): every corner of a painted triangle gets
weight 1, every corner of an unpainted one weight 0. A coarse mesh needs that - one painted face of a raw
cube has no strictly-interior vertex, so per-vertex weighting would either bleed onto the neighbours or
vanish outright. Duplicating vertices costs no shading quality here because the shader takes its surface
normal from screen-space derivatives of position, not from a per-vertex normal.
**Both preview meshes work in the patch's vertex space, not the mesh's.** Those agree only until a
*brush* stroke splits a triangle: `get_facets_strict()` then appends the split vertices, so the patch
array is longer. `rebuild_bump_preview_mesh()` and `rebuild_uvcheck_mesh()` therefore index
`patch.vertices` throughout. The weight buffer is rebuilt at the same cadence as the true-displacement
preview (stroke-end/slider-release) but from the **live** `TriangleSelector` state, not the flushed model
facets, so it does not lag by a full model round-trip.
The perturbed normal is the analytic one for a height field `H = ±depth_mm · h(uv)` displaced along
`N` over any orthonormal surface tangent pair `T`/`B`:
N' = normalize(N (dH/da)·T (dH/db)·B), a = dot(p,T), b = dot(p,B)
The two slopes have to be genuine **mm-per-mm** derivatives for the preview's apparent depth to
match the bake's - see bug #13.
The two slopes have to be genuine **mm-per-mm** derivatives for the preview's apparent depth to match
the bake's.
**Two projection paths (`use_vertex_uv` uniform):**
- **Triplanar (`use_vertex_uv = 0`)** - `uv` and the `T`/`B` axes are both derived in-shader from
@@ -354,32 +385,58 @@ match the bake's - see bug #13.
formed analytically. `T`/`B` are the projection's axis-aligned pair, exact only when the face is
axis-aligned; the shader drops the along-normal component to keep the gradient in the surface. Here
one `uv` unit is exactly `tiling_scale` mm, so the `1/tiling_scale` gradient factor is right.
- **Precomputed UV (`use_vertex_uv = 1`, used for LSCM)** - `uv` comes per-vertex from the CPU
(`compute_lscm_uvs(patch, layer)`, so island placement + tiling/rotation/offset are already folded
- **Precomputed UV (`use_vertex_uv = 1`, used for LSCM and ViewProjected)** - `uv` comes per-vertex from
the CPU (`compute_layer_vertex_uvs()`, so island placement + tiling/rotation/offset are already folded
in), and the perturbed normal is built with **Mikkelsen's method** ("Bump Mapping Unparametrized
Surfaces on the GPU"): the surface gradient taken directly from the screen-space derivatives of the
*sampled height* and position. **This makes no uv→mm scale assumption**, which is essential -
the first cut used the same global `1/tiling_scale` factor as triplanar and the depth came out
visibly wrong, because an LSCM map is **conformal, not isometric**: it is globally area-scaled but
the *local* mm-per-uv varies across the chart. `dFdx(h)` captures the true on-screen rate of change
however the chart is stretched. **This path is also what makes the fast preview follow the UV
editor: move an island and its uv - hence its bump - moves with it** (the bump mesh rebuilds on
drag-end, since `on_island_edited(finished)` `rebuild_preview()``rebuild_bump_preview_mesh()`).
The branch is uniform (`use_vertex_uv` is a uniform) and the paint weight gates by multiply, so the
*sampled height* and position. **This makes no uv→mm scale assumption**, which is essential, because an
LSCM map is **conformal, not isometric**: it is globally area-scaled but the *local* mm-per-uv varies
across the chart, so a single global `1/tiling_scale` factor gets the apparent depth wrong. `dFdx(h)`
captures the true on-screen rate of change however the chart is stretched. This path is also what makes
the fast preview follow the UV editor: move an island and its uv - hence its shading - moves with it
(the mesh rebuilds on drag-end, `on_island_edited(finished)``rebuild_preview()`
`rebuild_bump_preview_mesh()`). The branch is uniform and the paint weight gates by multiply, so the
texture derivatives stay well defined. A triangle straddling a seam has a discontinuous uv → the
`det≈0` guard skips it (a localised preview-only artifact, never in the bake).
Remaining deliberate approximation: the GPU sampler's wrap mode stands in for
`tile_enabled`/`tile_method`, so with tiling *off* the GPU repeats where the CPU returns 0 outside
**Parallax (triplanar path).** Perturbing the shading normal alone welds the pattern to the base surface:
it does not slide as the camera orbits, and does not get deeper as `depth_mm` grows. The triplanar path
therefore shades at the point the *displaced* surface would show at this pixel, found by **ray marching**
(parallax occlusion mapping). A point at ray parameter `s`, i.e. `P + V·s` (`P` the base point, `V` the
unit direction to the eye), sits at height `s·dot(V,n)` above the undisplaced surface. The displaced
surface lives in a shell between the extreme values of `amp·(h midlevel)` - taken from both ends of
`h ∈ [0,1]`, so it holds for an inverted layer and a raised midlevel too, where the surface sits *below*
the undisplaced one. The march starts at the top of that shell, where the ray is outside the surface by
construction, and steps inward until the ray height drops below the sampled height. That crossing *is*
the visible point.
Solving `Q = P + V·(H(Q)/dot(V,n))` by fixed-point iteration instead is geometrically exact but the
divisor goes to zero edge-on; the sample then lands a large fraction of a tile away and the iteration
oscillates, which reads as a second, flat copy of the pattern ghosted over the real one. Clamping the
step to one tile does not help - a tile-sized shift lands on the neighbouring tile, the same pattern
again. Offset limiting (stepping along the tangential part of `V`) is stable but understates parallax
enough that the relief still flattens as soon as the camera tilts. Marching has neither problem.
The hit is interpolated between the last two samples, which keeps `PARALLAX_STEPS` (24) affordable, and
the whole march is skipped when sweeping the shell would move the sample point less than half a texel -
the head-on case, so the common view pays almost nothing. The 140 variant samples with
`textureLod(…, 0.0)` inside the loop, since implicit derivatives are undefined in non-uniform control
flow. Two uniforms exist for this: `midlevel` (parallax needs the real height, not just its derivative)
and `eye_model_pos` (the camera in the volume's local frame).
Parallax cannot change the model's silhouette or cast shadows; the View row's Normal mode is one click
away for that. The LSCM path stays plain Mikkelsen bump - it has no closed-form uv, so there is no cheap
way to re-project a marched position. One further approximation: the GPU sampler's wrap mode stands in
for `tile_enabled`/`tile_method`, so with tiling *off* the GPU repeats where the CPU returns 0 outside
`[0,1)`.
### On-canvas "Adjust Texture" gizmo
A per-active-layer toggle ("Adjust placement (drag on model)") that disables painting and shows a
flat pan panel (free 2D drag on both axes) plus two arrows along the patch's own U/V axes
(constrained single-axis drag). Anchored to the painted patch's centroid/average-normal
(`compute_layer_paint_anchor()`). Hit-testing is screen-space distance/point-to-segment (not real
3D ray intersection against the handle geometry) - simple and good enough at this handle size.
A per-active-layer toggle ("Adjust placement") that disables painting and shows a flat pan panel (free
2D drag on both axes) plus two arrows along the patch's own U/V axes (constrained single-axis drag).
Anchored to the painted patch's centroid/average-normal (`compute_layer_paint_anchor()`). Hit-testing is
screen-space distance/point-to-segment, not real 3D ray intersection against the handle geometry - simple
and good enough at this handle size.
### Projection frame overlay (ViewProjected)
@@ -393,14 +450,14 @@ position and size *are* the placement, read on demand at Apply - which is also w
visible-facet raycast runs. So dragging it is free and nothing recomputes until asked.
Plain 2D (`wxPaintDC`), not a `wxGLCanvas`: a second GL canvas would have to share the app's one real
`wxGLContext`, the cause of bugs #10 and #14 below. It only ever draws a bitmap and a border.
`wxGLContext`. It only ever draws a bitmap and a border.
**The projective mapping (`apply_projection_frame()`)**. The frame defines a
**screen-space** rectangle, but the bake samples from a **local-space** position, so the two have to be
reconciled. `view_project_right/up` can only express an *affine* projection - exact under an
orthographic camera, but wrong under perspective, where the near end of a part projects larger than the
far end and no pair of axes reproduces that. So the layer instead stores a full projective map
(`view_project_matrix`, row-major 3×4, `uv = (row0·p̃/row2·p̃, row1·p̃/row2·p̃)`), built like this:
**The projective mapping (`apply_projection_frame()`)**. The frame defines a **screen-space** rectangle,
but the bake samples from a **local-space** position, so the two have to be reconciled.
`view_project_right/up` can only express an *affine* projection - exact under an orthographic camera, but
wrong under perspective, where the near end of a part projects larger than the far end and no pair of
axes reproduces that. So the layer instead stores a full projective map (`view_project_matrix`, row-major
3×4, `uv = (row0·p̃/row2·p̃, row1·p̃/row2·p̃)`), built like this:
- `K = projection · view · (instance · volume)`, i.e. local → clip, the same product the renderer uses.
Note `Camera::get_projection_matrix()` is typed `Transform3d` (nominally affine) but its perspective
@@ -433,7 +490,7 @@ reopening keeps it where it was left.
`UVEditorCanvas` (`src/slic3r/GUI/UVEditorCanvas.hpp/.cpp`) - a standalone `wxGLCanvas` rendering the
flattened LSCM islands (per-island wireframe + outline + fill) over the height texture (background
quad tiled across the whole unwrap), with mouse pan/zoom. It is wrapped in a **`UVEditorPanel`**
(same file) that adds a button row (Frame / Snap / Average scale) and a status line
(same file) that adds a button row (Frame / Snap / Avg scale / Cut / Join / Unjoin) and a status line
along the bottom naming the current gesture and the shortcuts in play. The *panel* is what is
registered as a `wxAuiPaneInfo` pane on `Plater`'s `m_aui_mgr`; `Plater::show_uv_editor(bool)`
shows/hides it (deferred via `CallAfter`, since the gizmo calls it mid-3D-frame), and
@@ -445,51 +502,24 @@ same call `View3D`/`Preview`/`AssembleView` make) rather than creating an indepe
`"flat"`/`"flat_texture"` shaders and `GLModel` as-is, instead of needing its own shader
compilation/VBO management.
**Geometry is uploaded once, in the unwrap's own (raw, mm) coordinates**, one `GLModel` set per
island; each island is then drawn through its own 2x3 affine (`island_transform_matrix()` composed
with the layer's tiling/rotation/offset) passed as the `flat` shader's `view_model_matrix`. A
drag updates one matrix per island and touches no vertex
buffer - `on_island_edited(!finished)` calls only `set_island_transforms()`, and the full
`set_islands()` rebuild happens solely when the unwrap itself changes (`unwrap_changed` in
`update_uv_editor()`).
**Geometry is uploaded once, in the unwrap's own (raw, mm) coordinates**, one `GLModel` set per island;
each island is then drawn through its own 2x3 affine (`island_transform_matrix()` composed with the
layer's tiling/rotation/offset) passed as the `flat` shader's `view_model_matrix`. A drag updates one
matrix per island and touches no vertex buffer - `on_island_edited(!finished)` calls only
`set_island_transforms()`, and the full `set_islands()` rebuild happens solely when the unwrap itself
changes (`unwrap_changed` in `update_uv_editor()`).
**Gestures** (canvas-owned, reported to the gizmo as incremental deltas via `IslandEditFn`): left-drag
= move, right-drag or **R** = rotate (hold **Shift** to snap to 15° steps - quantised on the
*cumulative* rotation, not each delta, so it doesn't judder, and accumulated incrementally so it
survives crossing ±180°), **S** = scale (R/S modal, click/Enter to confirm, Esc to cancel), wheel =
zoom about the cursor, middle-drag = pan, **Home**/**F** = frame all. Scale writes
`TextureIsland::scale`; "Average scale" (`average_island_scales()`) sets every island to the mean, so
`TextureIsland::scale`; "Avg scale" (`average_island_scales()`) sets every island to the mean, so
one island scaled by hand can be matched back to its neighbours' texel density. **Snap** (canvas-owned
`m_snap_enabled`, toggled from the toolbar) sticks a dragged island's nearest boundary vertex onto a
neighbouring island's at drag-*end* only - a magnet that re-applies mid-drag is very hard to pull out
of. Toolbar commands the canvas can't service itself (Average scale) are forwarded to the gizmo via
`CommandFn`; view-only ones (Frame, Snap) it handles directly.
## Known limitations / deferred work
- **No `.3mf` serialization** for texture-displacement paint data or texture assets. A background
agent attempted this in an earlier session, hit its own usage limit mid-edit, and left
`bbs_3mf.cpp` with an undefined forward-declared function; that partial edit was reverted rather
than shipped broken. Practical impact: **baked** geometry round-trips fine (it's just an ordinary
part of the mesh via the existing mesh serialization path) - what does *not* survive a project
save/reload is any *unbaked* paint stroke and texture layer definition.
- **No remap-across-topology-change** for texture-displacement paint (`ModelObject::split()`, mesh
boolean ops, Simplify, uniform subdivide, and remesh all drop it via `reset_extra_facets()`). The
other four paint channels (supported/seam/mmu/fuzzy) do get remapped in these cases. The lone
exception is **adaptive subdivide**, which carries texture-displacement paint forward itself via its
source map (see the Subdivision section) - a targeted remap that only works because the operation is
driven by the paint.
- **Cylindrical/Spherical axis/center are auto-picked heuristically**, not user-controllable - no
UI to override the auto-detected wrap axis if it picks the "wrong" one for an odd shape.
- **Fast preview covers the active layer only** — and is now the **default** view when the gizmo
opens (`m_use_bump_preview = true`): it is the instant, no-CPU-meshing preview, so it is the better
first impression while painting. The exact true-displacement view is one click away in the View row.
- **Displacement resolution is capped by the mesh's own vertex density.** Baking only ever *moves*
existing vertices (it never inserts any), so a coarse patch cannot show fine texture detail no
matter how high-resolution the height map is - that is what the subdivision controls are for
(uniform, adaptive, or feature-adaptive; see the Subdivision section). Since the rewrite the bake is
topology-preserving, so this is now a hard, explicit property rather than something partly papered
over by the old per-layer re-meshing.
of. Toolbar commands the canvas can't service itself (Avg scale, Cut, Join, Unjoin) are forwarded to the
gizmo via `CommandFn`; view-only ones (Frame, Snap) it handles directly.
## File map
@@ -498,23 +528,18 @@ of. Toolbar commands the canvas can't service itself (Average scale) are forward
tiling, subdivision (uniform + adaptive longest-edge bisection), post-process smoothing
(`smooth_mesh_vertices()`), and `TextureDisplacementOptions` (the whole-stack settings). See doc
comments throughout, they're kept accurate and up to date.
- `src/libslic3r/MeshBoolean.hpp/.cpp` - added `parameterize_lscm()` and `remesh_isotropic()`
in the `cgal` sub-namespace,
reusing the existing `CGALMesh`/`_EpicMesh`/conversion-helper infrastructure already there for
mesh boolean ops. New CGAL includes: `Polygon_mesh_processing/border.h`,
- `src/libslic3r/MeshBoolean.hpp/.cpp` - `parameterize_lscm()` and `remesh_isotropic()` in the `cgal`
sub-namespace, reusing the existing `CGALMesh`/`_EpicMesh`/conversion-helper infrastructure already
there for mesh boolean ops. CGAL includes: `Polygon_mesh_processing/border.h`,
`Polygon_mesh_processing/connected_components.h`, `Surface_mesh_parameterization/{Error_code,
LSCM_parameterizer_3, parameterize}.h`. No new dependency - CGAL 5.6.3 is already vendored and
the `Surface_mesh_parameterization` package headers were already present, just unused before now.
LSCM_parameterizer_3, parameterize}.h`. No new dependency - CGAL 5.6.3 is already vendored and the
`Surface_mesh_parameterization` package headers were already present.
- `src/libslic3r/Model.hpp/.cpp` - the 8 named `FacetsAnnotation` fields + accessor,
`texture_displacement_layers`, `texture_displacement_options`, and all the mirrored touch points
(see Data model above).
**GUI:**
- `src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp/.cpp` - the gizmo. Panel controls: dock/
undock toggle, brush/face/connected-area selection mode + "select whole model" button, per-layer
texture picker + depth/tiling/rotation/invert/tile-mode/projection-mode/blend-mode controls,
"Adjust placement" toggle (on-canvas gizmo), "Fast preview (normal map)" toggle, "Subdivide model"
button, Add layer/Erase all/Bake.
- `src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp/.cpp` - the gizmo and its whole panel.
- `src/slic3r/GUI/TextureLibrary.hpp/.cpp` - scans the shipped + user texture folders, imports an
arbitrary image into the user folder (converting it to the 8-bit grayscale PNG libslic3r decodes),
and loads a library file's bytes for a layer. The image→grayscale-PNG conversion lives here, on the
@@ -534,18 +559,23 @@ of. Toolbar commands the canvas can't service itself (Average scale) are forward
- `src/slic3r/GUI/Plater.hpp/.cpp` - `uv_editor_canvas` member, AUI pane registration,
`get_uv_editor_canvas()`/`show_uv_editor()`.
- `src/slic3r/GUI/GLShadersManager.cpp` - registers `"texture_displacement_bump"`.
- `resources/shaders/{110,140}/texture_displacement_bump.{vs,fs}` - the bump-preview shader.
- `resources/shaders/{110,140}/texture_displacement_bump.{vs,fs}` - the fast-preview shader.
- `src/slic3r/GUI/Gizmos/GLGizmoPainterBase.hpp` - `PainterGizmoType::TEXTURE_DISPLACEMENT`.
- `src/slic3r/GUI/Gizmos/GLGizmosManager.hpp/.cpp` - `EType::TextureDisplacement` registration.
- `src/slic3r/GUI/ImGuiWrapper.cpp` - the light-mode checkmark-color fix
**Tests:** `tests/libslic3r/test_texture_displacement.cpp` - **run and passing** (7 cases, 116
assertions). Covers `decode_height_texture` round-trip, empty-layer no-op, full-cube uniform
displacement, boundary-vertex pinning on a hand-built fan mesh, and - added with the bake rewrite -
a regression test that a **second layer over the same area actually contributes**,
a table-driven check of all four blend modes, and that the lowest layer ignores its
blend mode. `BUILD_TESTS` is `OFF` in the checked-in build cache; flip it on to run them:
## Tests
`tests/libslic3r/test_texture_displacement.cpp`. Covers `decode_height_texture` round-trip, empty-layer
no-op, full-cube uniform displacement, a second layer over the same area contributing, all four blend
modes (table-driven), the lowest layer ignoring its blend mode, border displace/pin, post-process
smoothing and its mask guarantees, and adaptive subdivision: conformality (`every_edge_used_twice` on a
partially-refined cube - an exact crack detector for a closed mesh), the target edge length actually
being reached, the triangle budget capping the result without opening a crack, curvature-driven
refinement (a Gaussian bump refines at its centre, a linear ramp adds nothing), and the max-edge
baseline.
`BUILD_TESTS` is `OFF` in the checked-in build cache; flip it on to run them:
cmake -S . -B build -DBUILD_TESTS=ON
cmake --build build --config Release --target libslic3r_tests -- -m
./build/tests/libslic3r/Release/libslic3r_tests.exe "[TextureDisplacement]" --order rand
./build/tests/libslic3r/Release/libslic3r_tests.exe "[TextureDisplacement]" --order rand

View File

@@ -5,6 +5,9 @@
#define INTENSITY_CORRECTION 0.6
#define PARALLAX_STEPS 24
#define H_AT(uv) texture2D(height_tex, uv).r
const vec3 LIGHT_TOP_DIR = vec3(-0.4574957, 0.4574957, 0.7624929);
#define LIGHT_TOP_DIFFUSE (0.8 * INTENSITY_CORRECTION)
#define LIGHT_TOP_SPECULAR (0.125 * INTENSITY_CORRECTION)
@@ -30,6 +33,8 @@ uniform float tiling_scale;
uniform float rotation_rad;
uniform vec2 uv_offset;
uniform bool invert;
uniform float midlevel; // the height that means "don't move"; needed by the parallax step
uniform vec3 eye_model_pos; // camera position in this volume's local space, for the view ray
uniform bool use_vertex_uv;
// 2x3 affine (lin = (m00, m01, m10, m11), tr = (m02, m12)) applied to the dragged island's uv; see the
// 140 variant. Identity when nothing is dragged.
@@ -96,10 +101,50 @@ void main()
if (abs(det) > 1e-12)
triangle_normal = normalize(triangle_normal - (dHdx * R1 + dHdy * R2) / det);
} else if (weight > 0.0) {
vec2 uv = project_uv(model_pos.xyz, triangle_normal);
vec3 t, b;
projection_axes(triangle_normal, t, b);
// Parallax occlusion mapping: march the view ray through the height shell and shade at the
// first point where it drops below the displaced surface (see header).
float amp = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0);
vec3 view_dir = normalize(eye_model_pos - model_pos.xyz);
float v_dot_n = dot(view_dir, triangle_normal);
vec2 uv = project_uv(model_pos.xyz, triangle_normal);
// The shell the displaced surface lives inside, as signed heights along the normal. Taken from
// both ends of h in [0, 1] so it stays correct for an inverted layer or a raised midlevel,
// where the surface sits *below* the undisplaced one.
float h_end_a = amp * (0.0 - midlevel);
float h_end_b = amp * (1.0 - midlevel);
float h_hi = max(h_end_a, h_end_b);
float h_lo = min(h_end_a, h_end_b);
// How far, in mm, sweeping the ray across the shell slides the sample point sideways. Below half
// a texel there is no parallax to find and the march would be pure cost - which is the common
// case of looking straight down at a surface.
float sweep = length(view_dir - triangle_normal * v_dot_n) * (h_hi - h_lo) / max(v_dot_n, 1e-4);
if (v_dot_n > 0.05 && sweep > 0.5 * tiling_scale * height_tex_texel.x) {
// A point at ray parameter s (model_pos + view_dir * s) sits at height s * v_dot_n above the
// undisplaced surface. Start at the top of the shell, where the ray is outside the surface
// by construction, and step inward; the crossing is what this pixel actually sees.
float s = h_hi / v_dot_n;
float ds = (h_hi - h_lo) / (v_dot_n * float(PARALLAX_STEPS));
vec2 prev_uv = project_uv(model_pos.xyz + view_dir * s, triangle_normal);
float prev_gap = h_hi - amp * (H_AT(prev_uv) - midlevel); // >= 0 by construction
for (int i = 0; i < PARALLAX_STEPS; ++i) {
s -= ds;
vec2 cur_uv = project_uv(model_pos.xyz + view_dir * s, triangle_normal);
float gap = s * v_dot_n - amp * (H_AT(cur_uv) - midlevel);
if (gap <= 0.0) {
// Crossed between the last two samples - interpolating the hit is what stops it
// quantising to the step size, and so what keeps the step count affordable.
uv = mix(prev_uv, cur_uv, clamp(prev_gap / max(prev_gap - gap, 1e-6), 0.0, 1.0));
break;
}
prev_uv = cur_uv;
prev_gap = gap;
}
}
float hL = texture2D(height_tex, uv - vec2(height_tex_texel.x, 0.0)).r;
float hR = texture2D(height_tex, uv + vec2(height_tex_texel.x, 0.0)).r;
float hD = texture2D(height_tex, uv - vec2(0.0, height_tex_texel.y)).r;

View File

@@ -19,7 +19,36 @@
// Two projection paths:
// * Triplanar (use_vertex_uv = 0): uv and the tangent axes are derived in-shader from the dominant
// normal axis, mirroring libslic3r's project_planar()/apply_uv_transform(), and the slope is
// formed analytically (there is a closed-form uv, so 1 uv unit is exactly tiling_scale mm).
// formed analytically (there is a closed-form uv, so 1 uv unit is exactly tiling_scale mm). This
// path also runs a parallax step before shading, see below.
//
// Parallax. A pure bump map perturbs shading only, so the pattern is welded to the base surface: it
// does not shift as the camera orbits and it does not get any deeper as depth_mm grows, which is
// exactly when the preview stops reading as real geometry. The triplanar path therefore shades at the
// point the *displaced* surface would show at this pixel rather than at the pixel's own base position.
//
// Two cheaper formulations were tried first and both are wrong here, which is worth recording:
// * Solving Q = P + V * (H(Q) / dot(V, n)) by fixed-point iteration. Geometrically exact, but the
// divisor goes to zero edge-on, and an unbounded step is not a small error - the sample lands a
// large fraction of a tile away and the iteration oscillates instead of converging. It reads as a
// *second, flat copy* of the pattern ghosted over the real one. Clamping the step to one tile does
// not help either: a tile-sized shift lands on the neighbouring tile, which is the same pattern.
// * Offset limiting (Welsh): step along the tangential part of V, whose length caps the shift at one
// depth. Stable and cheap, but it understates parallax by exactly the factor that matters - the
// relief still flattens as soon as the camera tilts, which is the complaint it was meant to fix.
//
// So this ray-marches instead (parallax occlusion mapping). A point at ray parameter s, i.e. P + V * s,
// sits at height s * dot(V, n) above the undisplaced surface. The displaced surface lives in a shell
// between the extreme values of amp * (h - midlevel); the march starts at the top of that shell, where
// the ray is outside the surface by construction, and steps inward until the ray height falls below the
// sampled height. That crossing *is* the visible point - no divergence, no ghosting, and parallax stays
// correct at any angle. The hit is interpolated between the last two samples, which is what keeps
// PARALLAX_STEPS low enough to afford. The march is skipped when sweeping the shell would move the
// sample point less than half a texel (the head-on case), so the common view pays almost nothing.
//
// The gradient/shading below is evaluated at the resulting uv, so the relief both slides correctly
// under camera motion and visibly deepens with depth_mm. What it still cannot do is change the
// model's silhouette or cast shadows; for that, switch the View row to Normal.
// * Precomputed uv (use_vertex_uv = 1, used for LSCM): uv comes per-vertex from the CPU (the LSCM
// unwrap with island placement + tiling/rotation/offset already folded in), and the perturbed
// normal is built with Mikkelsen's method -- the surface gradient taken straight from the
@@ -31,6 +60,11 @@
#define INTENSITY_CORRECTION 0.6
#define PARALLAX_STEPS 24
// Explicit LOD: the march samples inside non-uniform control flow, where implicit
// derivatives are undefined.
#define H_AT(uv) textureLod(height_tex, uv, 0.0).r
// normalized values for (-0.6/1.31, 0.6/1.31, 1./1.31)
const vec3 LIGHT_TOP_DIR = vec3(-0.4574957, 0.4574957, 0.7624929);
#define LIGHT_TOP_DIFFUSE (0.8 * INTENSITY_CORRECTION)
@@ -58,6 +92,8 @@ uniform float tiling_scale;
uniform float rotation_rad;
uniform vec2 uv_offset;
uniform bool invert;
uniform float midlevel; // the height that means "don't move"; needed by the parallax step
uniform vec3 eye_model_pos; // camera position in this volume's local space, for the view ray
uniform bool use_vertex_uv; // true: sample at vertex_uv with a derived tangent frame (LSCM)
// A 2x3 affine (columns packed as lin = (m00, m01, m10, m11), tr = (m02, m12)) applied to the uv of
// the island currently being dragged in the UV editor (island_active > 0.5). Identity when nothing is
@@ -144,10 +180,50 @@ void main()
// Triplanar path: uv and the tangent axes are reconstructed in-shader from the dominant
// normal component (see header). The gradient is expressed analytically because there is a
// closed-form uv here, unlike the LSCM case.
vec2 uv = project_uv(model_pos.xyz, triangle_normal);
vec3 t, b;
projection_axes(triangle_normal, t, b);
// Parallax occlusion mapping: march the view ray through the height shell and shade at the
// first point where it drops below the displaced surface (see header).
float amp = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0);
vec3 view_dir = normalize(eye_model_pos - model_pos.xyz);
float v_dot_n = dot(view_dir, triangle_normal);
vec2 uv = project_uv(model_pos.xyz, triangle_normal);
// The shell the displaced surface lives inside, as signed heights along the normal. Taken from
// both ends of h in [0, 1] so it stays correct for an inverted layer or a raised midlevel,
// where the surface sits *below* the undisplaced one.
float h_end_a = amp * (0.0 - midlevel);
float h_end_b = amp * (1.0 - midlevel);
float h_hi = max(h_end_a, h_end_b);
float h_lo = min(h_end_a, h_end_b);
// How far, in mm, sweeping the ray across the shell slides the sample point sideways. Below half
// a texel there is no parallax to find and the march would be pure cost - which is the common
// case of looking straight down at a surface.
float sweep = length(view_dir - triangle_normal * v_dot_n) * (h_hi - h_lo) / max(v_dot_n, 1e-4);
if (v_dot_n > 0.05 && sweep > 0.5 * tiling_scale * height_tex_texel.x) {
// A point at ray parameter s (model_pos + view_dir * s) sits at height s * v_dot_n above the
// undisplaced surface. Start at the top of the shell, where the ray is outside the surface
// by construction, and step inward; the crossing is what this pixel actually sees.
float s = h_hi / v_dot_n;
float ds = (h_hi - h_lo) / (v_dot_n * float(PARALLAX_STEPS));
vec2 prev_uv = project_uv(model_pos.xyz + view_dir * s, triangle_normal);
float prev_gap = h_hi - amp * (H_AT(prev_uv) - midlevel); // >= 0 by construction
for (int i = 0; i < PARALLAX_STEPS; ++i) {
s -= ds;
vec2 cur_uv = project_uv(model_pos.xyz + view_dir * s, triangle_normal);
float gap = s * v_dot_n - amp * (H_AT(cur_uv) - midlevel);
if (gap <= 0.0) {
// Crossed between the last two samples - interpolating the hit is what stops it
// quantising to the step size, and so what keeps the step count affordable.
uv = mix(prev_uv, cur_uv, clamp(prev_gap / max(prev_gap - gap, 1e-6), 0.0, 1.0));
break;
}
prev_uv = cur_uv;
prev_gap = gap;
}
}
float hL = texture(height_tex, uv - vec2(height_tex_texel.x, 0.0)).r;
float hR = texture(height_tex, uv + vec2(height_tex_texel.x, 0.0)).r;
float hD = texture(height_tex, uv - vec2(0.0, height_tex_texel.y)).r;

View File

@@ -747,9 +747,13 @@ void GLGizmoTextureDisplacement::rebuild_bump_preview_mesh()
if (patch.indices.empty())
return;
const indexed_triangle_set &base = mv->mesh().its;
if (base.vertices.size() != patch.vertices.size())
return; // shouldn't happen: get_facets_strict() always returns the full vertex array
// No "patch.vertices.size() == mesh vertex count" check here, and that is the point: a *brush*
// stroke splits triangles, so the selector appends split vertices and the patch array is longer
// than the mesh's. An earlier version bailed out on that as "shouldn't happen", which meant the
// bump model was never built while brushing and render_painter_gizmo() silently fell back to the
// Normal (true-displacement) preview - Fast looked broken for brush and fine for Face/Connected
// area, because only the brush splits. Everything below indexes the patch's own vertex array, so
// the extra vertices are simply carried through.
// Unpainted triangles, so the surrounding surface still renders (the render path hides the real
// model in bump mode). get_facets_strict() returns the same vertex array whatever state is asked.
@@ -762,7 +766,7 @@ void GLGizmoTextureDisplacement::rebuild_bump_preview_mesh()
// mesh rebuilds (on drag end) with them. The other projections keep projecting in-shader.
const TextureDisplacementLayer *active = active_layer();
std::vector<Vec2f> vertex_uv = active != nullptr ? compute_layer_vertex_uvs(patch, *active) : std::vector<Vec2f>{};
m_bump_preview_uses_vertex_uv = vertex_uv.size() == base.vertices.size();
m_bump_preview_uses_vertex_uv = vertex_uv.size() == patch.vertices.size();
if (!m_bump_preview_uses_vertex_uv)
vertex_uv.clear();
@@ -945,6 +949,13 @@ void GLGizmoTextureDisplacement::render_bump_preview_mesh()
shader->set_uniform("rotation_rad", layer->rotation_deg * float(M_PI) / 180.f);
shader->set_uniform("uv_offset", layer->offset);
shader->set_uniform("invert", layer->invert);
// The parallax step in the triplanar path needs the real height, not just its gradient, so it
// needs the midlevel the bake subtracts - and the camera position in the volume's own local space,
// to build the view ray it walks along. Without the parallax the pattern is welded to the base
// surface: it does not slide as the camera orbits and does not deepen with depth_mm, which is
// exactly when the fast preview stops looking like geometry.
shader->set_uniform("midlevel", layer->midlevel);
shader->set_uniform("eye_model_pos", Vec3f((trafo_matrix.inverse() * camera.get_position()).cast<float>()));
// When set, the shader samples at the per-vertex uv baked into the mesh (LSCM) rather than
// projecting; see rebuild_bump_preview_mesh().
shader->set_uniform("use_vertex_uv", m_bump_preview_uses_vertex_uv);
@@ -970,9 +981,11 @@ void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh()
const indexed_triangle_set patch = m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER);
if (patch.indices.empty())
return;
const indexed_triangle_set &base = mv->mesh().its;
if (base.vertices.size() != patch.vertices.size())
return;
// Everything below works in the *patch's* vertex space, not the mesh's. Those agree only until a
// brush stroke splits a triangle, after which the patch array is longer - and since patch triangle
// indices are used to index it, reading the mesh's array instead would run off the end. An earlier
// version guarded that by bailing out, which quietly disabled the Checker and Distortion overlays
// for anything painted with the brush.
const TextureDisplacementLayer *layer = active_layer();
if (layer == nullptr)
return;
@@ -980,16 +993,16 @@ void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh()
// The checker samples wherever the projection puts it; the projections the shader can't
// reconstruct (LSCM, ViewProjected) get a precomputed per-vertex uv, the rest project in-shader.
std::vector<Vec2f> uv = compute_layer_vertex_uvs(patch, *layer);
const bool have_uvs = uv.size() == base.vertices.size();
const bool have_uvs = uv.size() == patch.vertices.size();
m_uvcheck_uses_vertex_uv = have_uvs;
// Per-vertex area distortion in [0,1] (0.5 == ideal), only when both requested and possible.
std::vector<float> distortion(base.vertices.size(), 0.5f);
std::vector<float> distortion(patch.vertices.size(), 0.5f);
if (m_uv_check_mode == UVCheckMode::Distortion && have_uvs) {
std::vector<float> tri_log(patch.indices.size(), 0.f);
for (size_t f = 0; f < patch.indices.size(); ++f) {
const stl_triangle_vertex_indices &t = patch.indices[f];
const float a3 = 0.5f * (base.vertices[t[1]] - base.vertices[t[0]]).cross(base.vertices[t[2]] - base.vertices[t[0]]).norm();
const float a3 = 0.5f * (patch.vertices[t[1]] - patch.vertices[t[0]]).cross(patch.vertices[t[2]] - patch.vertices[t[0]]).norm();
const Vec2f e0 = uv[t[1]] - uv[t[0]];
const Vec2f e1 = uv[t[2]] - uv[t[0]];
const float a2 = 0.5f * std::abs(e0.x() * e1.y() - e0.y() * e1.x());
@@ -1003,8 +1016,8 @@ void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh()
std::nth_element(sorted.begin(), sorted.begin() + sorted.size() / 2, sorted.end());
median = sorted[sorted.size() / 2];
}
std::vector<float> sum(base.vertices.size(), 0.f);
std::vector<int> cnt(base.vertices.size(), 0);
std::vector<float> sum(patch.vertices.size(), 0.f);
std::vector<int> cnt(patch.vertices.size(), 0);
for (size_t f = 0; f < patch.indices.size(); ++f) {
// +/- 2 stops (4x stretch either way) spans the full blue->red range.
const float d = std::clamp(0.5f + (tri_log[f] - median) / 4.f, 0.f, 1.f);
@@ -1020,10 +1033,10 @@ void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh()
GLModel::Geometry init_data;
init_data.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3N3T2 };
init_data.reserve_vertices(base.vertices.size());
init_data.reserve_vertices(patch.vertices.size());
init_data.reserve_indices(patch.indices.size() * 3);
for (size_t vi = 0; vi < base.vertices.size(); ++vi)
init_data.add_vertex(base.vertices[vi], Vec3f(distortion[vi], 0.f, 0.f),
for (size_t vi = 0; vi < patch.vertices.size(); ++vi)
init_data.add_vertex(patch.vertices[vi], Vec3f(distortion[vi], 0.f, 0.f),
have_uvs ? uv[vi] : Vec2f::Zero());
for (const stl_triangle_vertex_indices &tri : patch.indices)
init_data.add_triangle(unsigned(tri[0]), unsigned(tri[1]), unsigned(tri[2]));
@@ -2753,51 +2766,82 @@ bool GLGizmoTextureDisplacement::collect_paint_region(
return any_paint;
}
void GLGizmoTextureDisplacement::subdivide_model_adaptive()
bool GLGizmoTextureDisplacement::plan_adaptive_subdivision(const ModelVolume &mv, SubdivisionPlan &out) const
{
ModelVolume *mv = texture_volume();
ModelObject *mo = m_c->selection_info()->model_object();
if (mv == nullptr || mo == nullptr || m_subdivide_target_mm <= 0.f)
return;
if (m_subdivide_target_mm <= 0.f)
return false;
update_model_object(); // flush any in-progress stroke into the committed masks first
std::vector<uint8_t> region;
std::array<std::vector<uint8_t>, TEXTURE_DISPLACEMENT_MAX_LAYERS> painted_tri;
if (!collect_paint_region(region, &painted_tri)) {
show_error(nullptr, _u8L("Paint the area you want to subdivide first - adaptive subdivision only "
"refines where you have painted."));
return;
}
std::vector<uint8_t> region;
if (!collect_paint_region(region, &out.painted_tri))
return false;
// Feature-adaptive: sample the combined displacement so refinement follows texture curvature. A
// null sampler (only LSCM layers, or nothing decodable) falls back to the uniform target below.
// null sampler (only LSCM layers, or nothing decodable) falls back to the length baseline alone.
HeightFieldSampler sampler;
if (m_subdivide_feature) {
TextureDisplacementFacetsData facets{};
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
facets[size_t(i)] = mv->texture_displacement_facet(i).get_data();
sampler = make_combined_displacement_sampler(mv->mesh().its, mv->texture_displacement_layers, facets);
facets[size_t(i)] = mv.texture_displacement_facet(i).get_data();
sampler = make_combined_displacement_sampler(mv.mesh().its, mv.texture_displacement_layers, facets);
}
// Do the (potentially slow) refinement before taking the snapshot, so a no-op leaves no empty
// undo step - mirrors remesh_model().
// "Min edge" is a feature-mode control (it is the floor the curvature test refines down to); in
// plain adaptive mode the target edge length is the only criterion, so the floor must not be
// allowed to silently override a target the user set below it.
const float tol = m_subdivide_feature ? m_subdivide_detail_mm : 0.f;
const float floor = m_subdivide_feature ? m_subdivide_min_edge_mm : 0.f;
std::vector<int> source;
indexed_triangle_set refined;
{
wxBusyCursor wait;
// The budget slider is "triangles the refinement may *add*", so the model's own count is the
// baseline - otherwise the control would be meaningless (or a dead end) on a dense model.
refined = subdivide_mesh_adaptive(mv->mesh().its, region, m_subdivide_target_mm,
int(mv->mesh().its.indices.size()) + m_subdivide_budget_k * 1000,
&source, sampler, tol, floor);
out.refined = subdivide_mesh_adaptive(mv.mesh().its, region, m_subdivide_target_mm,
int(mv.mesh().its.indices.size()) + m_subdivide_budget_k * 1000,
&out.source, sampler, tol, floor);
}
if (refined.indices.size() == mv->mesh().its.indices.size()) {
return out.refined.indices.size() != mv.mesh().its.indices.size();
}
void GLGizmoTextureDisplacement::apply_adaptive_subdivision(ModelVolume &mv, SubdivisionPlan &&plan)
{
// Other paint channels ride across via the standard remap; texture-displacement paint is rebuilt
// below from the plan's source map, which is the whole point of driving this by the paint.
std::optional<TriangleSelector::SavedPainting> saved_painting = mv.save_painting();
mv.set_mesh(TriangleMesh(std::move(plan.refined)));
mv.set_new_unique_id();
mv.calculate_convex_hull();
mv.restore_painting(saved_painting); // resets extra facets (incl. texture-displacement) + remaps the rest
// Carry each layer's paint onto the new mesh: a new triangle is painted iff its source triangle
// was fully painted in that layer. Children inherit their parent's source, so this is exact.
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) {
if (plan.painted_tri[size_t(slot)].empty())
continue;
TriangleSelector sel(mv.mesh());
for (size_t i = 0; i < plan.source.size(); ++i)
if (plan.painted_tri[size_t(slot)][size_t(plan.source[i])])
sel.set_facet(int(i), EnforcerBlockerType::ENFORCER);
mv.texture_displacement_facet(slot).set(sel);
}
}
void GLGizmoTextureDisplacement::subdivide_model_adaptive()
{
ModelVolume *mv = texture_volume();
ModelObject *mo = m_c->selection_info()->model_object();
if (mv == nullptr || mo == nullptr)
return;
update_model_object(); // flush any in-progress stroke into the committed masks first
if (!mv->is_texture_displacement_painted()) {
show_error(nullptr, _u8L("Paint the area you want to subdivide first - adaptive subdivision only "
"refines where you have painted."));
return;
}
// Plan before the snapshot, so a no-op leaves no empty undo step - mirrors remesh_model().
SubdivisionPlan plan;
if (!plan_adaptive_subdivision(*mv, plan)) {
show_error(nullptr, _u8L("Nothing to subdivide - the painted area already meets the target edge "
"length and detail tolerance, or the triangle budget is already used up."));
return;
@@ -2805,26 +2849,7 @@ void GLGizmoTextureDisplacement::subdivide_model_adaptive()
Plater *plater = wxGetApp().plater();
Plater::TakeSnapshot snapshot(plater, _u8L("Adaptive subdivide for texture displacement"), UndoRedo::SnapshotType::GizmoAction);
// Other paint channels ride across via the standard remap; texture-displacement paint is rebuilt
// by hand below from the source map, which is the whole point of driving this by the paint.
std::optional<TriangleSelector::SavedPainting> saved_painting = mv->save_painting();
mv->set_mesh(TriangleMesh(std::move(refined))); // refined is not needed past here; source carries the paint map
mv->set_new_unique_id();
mv->calculate_convex_hull();
mv->restore_painting(saved_painting); // resets extra facets (incl. texture-displacement) + remaps the rest
// Carry each layer's paint onto the new mesh: a new triangle is painted iff its source triangle
// was fully painted in that layer. Children inherit their parent's source, so this is exact.
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) {
if (painted_tri[slot].empty())
continue;
TriangleSelector sel(mv->mesh());
for (size_t i = 0; i < source.size(); ++i)
if (painted_tri[slot][source[i]])
sel.set_facet(int(i), EnforcerBlockerType::ENFORCER);
mv->texture_displacement_facet(slot).set(sel);
}
apply_adaptive_subdivision(*mv, std::move(plan));
if (ObjectList *obj_list = wxGetApp().obj_list()) {
const ModelObjectPtrs &objs = plater->model().objects;
@@ -2921,6 +2946,62 @@ void GLGizmoTextureDisplacement::smooth_model()
m_parent.set_as_dirty();
}
void GLGizmoTextureDisplacement::replace_mesh_keep_all_paint(ModelVolume &mv, TriangleMesh &&new_mesh)
{
const indexed_triangle_set old_its = mv.mesh().its;
std::array<TriangleSelector::TriangleSplittingData, TEXTURE_DISPLACEMENT_MAX_LAYERS> saved_texture;
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
saved_texture[size_t(i)] = mv.texture_displacement_facet(i).get_data();
std::optional<TriangleSelector::SavedPainting> saved_painting = mv.save_painting();
mv.set_mesh(std::move(new_mesh));
mv.set_new_unique_id();
mv.calculate_convex_hull();
mv.restore_painting(saved_painting); // clears the extra facets, then remaps the four standard channels
// ... and the same spatial remap for the texture-displacement masks, which restore_painting() knows
// nothing about. Without this a remesh would wipe the texture paint, which is fine for a standalone
// "Remesh" click but fatal for Standard mode's pipeline: it remeshes *after* the user has painted,
// and the subdivision and displacement that follow are both driven by that paint.
const Transform3d to_source = Slic3r::Geometry::translation_transform(mv.mesh().get_init_shift());
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) {
if (saved_texture[size_t(i)].bitstream.empty())
continue;
TriangleSelector::TriangleSplittingData remapped =
TriangleSelector::remap_painting(old_its, saved_texture[size_t(i)], mv.mesh().its, to_source,
{}); // no existing paint to merge with: set_mesh() just cleared it
if (!remapped.bitstream.empty())
mv.texture_displacement_facet(i).set_data(std::move(remapped));
}
}
bool GLGizmoTextureDisplacement::plan_remesh(const ModelVolume &mv, float target_edge_mm, float sharp_angle_deg,
TriangleMesh &out)
{
if (target_edge_mm <= 0.f)
return false;
// CGAL isotropic remeshing can be slow on a big mesh, which is why this is separated from applying
// it: the caller runs it before taking the snapshot so a failure leaves no empty undo step.
const indexed_triangle_set &src = mv.mesh().its;
indexed_triangle_set remeshed;
{
wxBusyCursor wait;
remeshed = MeshBoolean::cgal::remesh_isotropic(src, double(target_edge_mm), 3, double(sharp_angle_deg));
}
// remesh_isotropic() signals failure by handing the input straight back, so compare against it
// structurally. Vertex count alone is not enough: a remesh that only redistributes triangles at
// roughly the current density legitimately lands on the same count, and treating that as failure
// meant a perfectly good result got thrown away with an error message.
if (remeshed.indices.empty() ||
(remeshed.vertices.size() == src.vertices.size() && remeshed.indices.size() == src.indices.size() &&
remeshed.indices == src.indices))
return false;
out = TriangleMesh(std::move(remeshed));
return true;
}
void GLGizmoTextureDisplacement::remesh_model()
{
ModelVolume *mv = texture_volume();
@@ -2928,39 +3009,18 @@ void GLGizmoTextureDisplacement::remesh_model()
if (mv == nullptr || mo == nullptr || m_remesh_target_edge_mm <= 0.f)
return;
Plater *plater = wxGetApp().plater();
// CGAL isotropic remeshing can be slow on a big mesh; do it before taking the snapshot so a failure
// (it returns the input unchanged) doesn't leave an empty undo step.
const indexed_triangle_set &src = mv->mesh().its;
indexed_triangle_set remeshed;
{
wxBusyCursor wait;
remeshed = MeshBoolean::cgal::remesh_isotropic(mv->mesh().its, double(m_remesh_target_edge_mm), 3,
m_remesh_keep_sharp_edges ? double(m_remesh_sharp_angle_deg) : 0.0);
}
// remesh_isotropic() signals failure by handing the input straight back, so compare against it
// structurally. Vertex count alone is not enough: a remesh that only redistributes triangles at
// roughly the current density legitimately lands on the same count, and treating that as failure
// meant a perfectly good result got thrown away with an error message.
const bool unchanged = remeshed.indices.empty() ||
(remeshed.vertices.size() == src.vertices.size() && remeshed.indices.size() == src.indices.size() &&
remeshed.indices == src.indices);
if (unchanged) {
TriangleMesh remeshed;
if (!plan_remesh(*mv, m_remesh_target_edge_mm, m_remesh_keep_sharp_edges ? m_remesh_sharp_angle_deg : 0.f,
remeshed)) {
show_error(nullptr, _u8L("Remeshing did not change the model. It may be non-manifold (open edges or "
"edges shared by more than two triangles), or the target edge length may "
"already be met."));
return;
}
Plater *plater = wxGetApp().plater();
Plater::TakeSnapshot snapshot(plater, _u8L("Remesh model for texture displacement"), UndoRedo::SnapshotType::GizmoAction);
// Same save/replace/restore-painting dance as subdivide: texture-displacement paint has no remap
// across a topology change, so it is dropped rather than left pointing at triangles that moved.
std::optional<TriangleSelector::SavedPainting> saved_painting = mv->save_painting();
mv->set_mesh(TriangleMesh(std::move(remeshed)));
mv->set_new_unique_id();
mv->calculate_convex_hull();
mv->restore_painting(saved_painting);
replace_mesh_keep_all_paint(*mv, std::move(remeshed));
if (ObjectList *obj_list = wxGetApp().obj_list()) {
const ModelObjectPtrs &objs = plater->model().objects;
@@ -3075,7 +3135,7 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_thumbnail(const TextureDisplace
return m_thumbnails[slot].get();
}
void GLGizmoTextureDisplacement::bake()
void GLGizmoTextureDisplacement::bake(bool own_snapshot)
{
ModelVolume *mv = texture_volume();
if (!mv || m_bake_in_progress)
@@ -3101,7 +3161,112 @@ void GLGizmoTextureDisplacement::bake()
if (m_state == On && m_c->selection_info() && m_c->selection_info()->model_object())
update_from_model_object(false);
m_parent.set_as_dirty();
});
}, own_snapshot);
}
// Standard mode's fixed recipe. These are both the values the hidden controls are pinned to and what
// bake_standard() drives its remesh and subdivision with - one set of numbers, so the preview cannot
// disagree with the bake. Chosen to be safe on an arbitrary imported part rather than optimal on any
// particular one: a 1 mm isotropic remesh gives the subdivider an even starting density whatever the
// input looked like, and the subdivision then spends up to 1.5 M triangles chasing texture curvature
// down to a 0.02 mm floor, which is finer than any FDM nozzle will resolve. The triangle budget is not
// here: it is the one control Standard mode still shows, so it belongs to the user (its default is
// m_subdivide_budget_k's initialiser).
static constexpr float STD_REMESH_EDGE_MM = 1.0f;
static constexpr float STD_REMESH_SHARP_DEG = 40.f;
static constexpr float STD_SUBDIV_MAX_EDGE_MM = 20.f;
static constexpr float STD_SUBDIV_DETAIL_MM = 0.02f;
static constexpr float STD_SUBDIV_MIN_EDGE_MM = 0.02f;
bool GLGizmoTextureDisplacement::apply_standard_mode_presets(ModelVolume *mv)
{
bool changed = false;
const auto pin = [&changed](auto &field, auto value) {
if (field != value) {
field = value;
changed = true;
}
};
if (mv != nullptr) {
pin(mv->texture_displacement_options.displace_border, true);
pin(mv->texture_displacement_options.smooth_enabled, false);
}
pin(m_subdivide_adaptive, true);
pin(m_subdivide_feature, true);
pin(m_subdivide_target_mm, STD_SUBDIV_MAX_EDGE_MM);
pin(m_subdivide_detail_mm, STD_SUBDIV_DETAIL_MM);
pin(m_subdivide_min_edge_mm, STD_SUBDIV_MIN_EDGE_MM);
// Deliberately *not* pinned: the triangle budget stays visible and editable in Standard mode, so
// pinning it would fight the user's own slider every frame.
pin(m_remesh_target_edge_mm, STD_REMESH_EDGE_MM);
pin(m_remesh_keep_sharp_edges, true);
pin(m_remesh_sharp_angle_deg, STD_REMESH_SHARP_DEG);
return changed;
}
void GLGizmoTextureDisplacement::bake_standard()
{
ModelVolume *mv = texture_volume();
ModelObject *mo = m_c->selection_info()->model_object();
if (mv == nullptr || mo == nullptr || m_bake_in_progress)
return;
update_model_object(); // flush the active layer's in-progress strokes before anything reads them
if (!mv->is_texture_displacement_painted()) {
show_error(nullptr, _u8L("Nothing is painted, there is nothing to bake."));
return;
}
apply_standard_mode_presets(mv); // belt and braces: never bake with values the panel is not showing
// Both stages are planned before the snapshot so a stage that has nothing to do is simply skipped.
// Skipping is normal, not a failure: a mesh that is already even needs no remesh, and one that is
// already fine enough for the texture needs no subdivision.
TriangleMesh remeshed;
const bool do_remesh = plan_remesh(*mv, STD_REMESH_EDGE_MM, STD_REMESH_SHARP_DEG, remeshed);
Plater *plater = wxGetApp().plater();
{
// ONE undo step for the whole pipeline. take_snapshot() records the state *before* the change,
// so a single Undo goes all the way back to the untouched mesh - which is the only thing "undo
// the bake" can sensibly mean when the bake is also what prepared the geometry. The background
// displacement job is told not to add its own (see queue_texture_displacement_bake's
// take_snapshot), because an undo step landing between the subdivision and the displacement
// leaves a mesh with 1.5 M extra triangles and no relief on it - and baking again from there
// subdivides that mesh a second time.
Plater::TakeSnapshot snapshot(plater, _u8L("Bake texture displacement"),
UndoRedo::SnapshotType::GizmoAction);
if (do_remesh)
replace_mesh_keep_all_paint(*mv, std::move(remeshed));
// The remesh carries the paint across spatially, but if that remap came back empty the rest of
// the pipeline has nothing to work from - stop here rather than silently baking a flat mesh.
if (!mv->is_texture_displacement_painted()) {
show_error(nullptr, _u8L("The painted area could not be transferred onto the remeshed model. Undo, "
"then switch to Pro mode to prepare the mesh before painting."));
return;
}
// Planning the subdivision has to happen inside the snapshot because it reads the mesh the
// remesh just produced. It is the expensive step, but by here we are committed anyway.
SubdivisionPlan plan;
if (plan_adaptive_subdivision(*mv, plan))
apply_adaptive_subdivision(*mv, std::move(plan));
}
if (ObjectList *obj_list = wxGetApp().obj_list()) {
const ModelObjectPtrs &objs = plater->model().objects;
auto it = std::find(objs.begin(), objs.end(), mo);
if (it != objs.end())
obj_list->update_info_items(size_t(it - objs.begin()));
}
plater->changed_object(*mo);
update_from_model_object(false); // reload selectors against the prepared mesh + carried paint
m_parent.set_as_dirty();
// ... and finally the displacement itself, in the background exactly as the Pro-mode button does -
// except that it commits into the snapshot taken above instead of pushing another one.
bake(/* own_snapshot */ false);
}
void GLGizmoTextureDisplacement::render_paint_cursor_hint()
@@ -3237,6 +3402,42 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_imgui->tooltip(_u8L("Detach this panel so it can be dragged anywhere over the 3D view, or dock it "
"back beside the toolbar."),
m_imgui->scaled(20.f));
// Standard / Pro, right-aligned on the header row. A two-position slider rather than a checkbox
// because it is a mode, not an option: Standard hides every mesh-preparation control and folds the
// whole recipe into Bake, Pro shows all of it and hands the ordering to the user.
{
const float mode_w = m_imgui->scaled(6.2f);
ImGui::SameLine(std::max(ImGui::GetCursorPosX(), ImGui::GetWindowContentRegionMax().x - mode_w));
ImGui::PushItemWidth(mode_w);
// The format string carries no conversion, so ImGui prints it verbatim as the slider's label -
// which is how a two-position slider gets word labels instead of "0" and "1".
const std::string mode_label = pro_mode() ? _u8L("Pro") : _u8L("Standard");
if (ImGui::SliderInt("##panel_mode", &m_panel_mode, 0, 1, mode_label.c_str())) {
m_panel_mode = std::clamp(m_panel_mode, 0, 1);
if (!pro_mode()) {
// Leaving the subdivision preview open would strand a wireframe whose controls just
// disappeared, so close it as part of the switch.
m_subdivide_editing = false;
m_subdivide_preview_tris = -1;
m_subdivide_preview_glmodel.reset();
if (apply_standard_mode_presets(mv))
m_preview_params_dirty = true;
}
m_parent.set_as_dirty();
}
ImGui::PopItemWidth();
if (ImGui::IsItemHovered())
m_imgui->tooltip(_u8L("Standard - paint, then press Bake: the mesh is remeshed, refined where the "
"texture bends, and displaced in one step.\n\nPro - every mesh-preparation "
"control is shown and you run Remesh, Subdivide and Bake yourself, in the "
"order you want."),
m_imgui->scaled(20.f));
}
// Pinned every frame while Standard is active, so what Preview shows is always what Bake will do.
if (!pro_mode() && apply_standard_mode_presets(mv))
m_preview_params_dirty = true;
ImGui::Separator();
// Selection mode: which of TriangleSelector's existing click/brush mechanisms drives painting.
@@ -3854,8 +4055,9 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
}
// (The "Add layer" button now lives next to the "Texture layers" heading, as an icon.)
// Settings for the whole stack rather than one layer, so they sit outside the layer list.
if (mv != nullptr) {
// Settings for the whole stack rather than one layer, so they sit outside the layer list. Standard
// mode pins them (see apply_standard_mode_presets()) instead of showing them.
if (pro_mode() && mv != nullptr) {
TextureDisplacementOptions &opts = mv->texture_displacement_options;
ImGui::Separator();
@@ -3912,7 +4114,34 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
}
ImGui::Separator();
m_imgui->text(_u8L("Not enough vertices for fine detail?"));
m_imgui->text(_u8L("Subdivision"));
// The triangle budget is the one subdivision control Standard mode keeps: its right value depends
// on the part rather than on the recipe (a big model, or a fine texture, simply needs more of them),
// and raising or lowering it is safe without understanding anything else in this section. Shared
// with the Pro layout below so there is one definition of the widget.
const auto budget_slider = [this]() {
ImGui::PushItemWidth(m_imgui->scaled(8.4f));
if (ImGui::SliderInt((_u8L("Added triangles (k)") + "##subdivbudget").c_str(), &m_subdivide_budget_k, 10, 2000)) {
m_subdivide_budget_k = std::clamp(m_subdivide_budget_k, 10, 2000);
if (m_subdivide_editing)
rebuild_subdivide_preview();
m_parent.set_as_dirty();
}
ImGui::PopItemWidth();
if (ImGui::IsItemHovered())
m_imgui->tooltip(_u8L("How many thousand triangles the refinement may add. It always splits the "
"worst-fitting triangle first, so a run that uses the whole budget has still spent "
"it where it shows most - raise this if the result still looks too coarse."),
m_imgui->scaled(20.f));
};
// Everything else from here to the Bake row is mesh preparation, which is exactly what Standard
// mode takes over: it pins those to one recipe and runs them from the Bake button (see
// bake_standard()), so showing them would only invite changing numbers that get overwritten.
if (!pro_mode())
budget_slider();
if (pro_mode()) {
if (ImGui::Checkbox(_u8L("Only painted area (adaptive)").c_str(), &m_subdivide_adaptive)) {
if (m_subdivide_editing)
@@ -3996,18 +4225,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_imgui->scaled(20.f));
}
// The budget. Refinement is worst-error-first, so a run that hits it has still spent its
// triangles on the biggest deviations - raising it buys detail, it does not redistribute it.
if (ImGui::SliderInt((_u8L("Added triangles (k)") + "##subdivbudget").c_str(), &m_subdivide_budget_k, 10, 2000)) {
m_subdivide_budget_k = std::clamp(m_subdivide_budget_k, 10, 2000);
preview_live();
}
if (ImGui::IsItemHovered())
m_imgui->tooltip(_u8L("How many thousand triangles the refinement may add. It always splits the "
"worst-fitting triangle first, so a run that uses the whole budget has still spent "
"it where it shows most - raise this if the preview still looks too coarse."),
m_imgui->scaled(20.f));
ImGui::PopItemWidth();
budget_slider();
if (m_subdivide_editing && m_subdivide_preview_tris > 0)
m_imgui->text(Slic3r::format(_u8L("Preview: %1% triangles"), m_subdivide_preview_tris));
@@ -4074,7 +4293,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
// Remesh: even out uneven triangle sizes (CGAL isotropic remeshing). GPU Delaunay isn't practical
// here, but this delivers the same goal - a consistent triangle size across the whole model.
m_imgui->text(_u8L("Uneven triangle sizes?"));
m_imgui->text(_u8L("Remeshing"));
if (m_remesh_target_edge_mm <= 0.f && mv != nullptr) {
// Seed the target with the model's current mean edge length, so the default is a sensible
// "make everything about the size it already averages".
@@ -4114,9 +4333,12 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
if (ImGui::IsItemHovered())
m_imgui->tooltip(_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 and clears any not-yet-baked paint (already-baked bumps are kept)."),
"Replaces the geometry; your paint is carried onto the new triangles spatially, so it "
"survives (already-baked bumps are kept too)."),
m_imgui->scaled(20.f));
} // pro_mode()
ImGui::Separator();
m_imgui->disabled_begin(mv == nullptr || !mv->is_texture_displacement_painted());
@@ -4136,9 +4358,23 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::SameLine();
m_imgui->disabled_begin(m_bake_in_progress || mv == nullptr || !mv->is_texture_displacement_painted());
if (m_imgui->button(m_bake_in_progress ? _L("Baking...") : m_desc.at("bake")))
bake();
if (m_imgui->button(m_bake_in_progress ? _L("Baking...") : m_desc.at("bake"))) {
// Standard mode's Bake is the whole pipeline (remesh -> refine -> displace); Pro's is only the
// displacement, because there the user has already prepared the mesh with the controls above.
if (pro_mode())
bake();
else
bake_standard();
}
m_imgui->disabled_end();
if (ImGui::IsItemHovered())
m_imgui->tooltip(pro_mode() ?
_u8L("Turn the painted height maps into real geometry, by moving the vertices that are "
"already there. Use Subdivide first if the mesh is too coarse to show the detail.") :
_u8L("Turn the painted height maps into real geometry. The mesh is remeshed to an even "
"density and refined where the texture bends first, so the detail has vertices to "
"land on - all in one step."),
m_imgui->scaled(20.f));
ImGui::Separator();
if (m_imgui->button(_L("Close")))

View File

@@ -67,7 +67,51 @@ private:
void add_texture_layer();
void remove_texture_layer(int slot);
void set_active_layer(int slot); // flushes the previous layer's edits, then reloads selectors
void bake();
// `own_snapshot` false when the caller has already taken an undo step that is meant to cover the
// displacement too - see bake_standard().
void bake(bool own_snapshot = true);
// Standard (0) vs Pro (1), driven by the two-position slider in the panel header.
//
// Pro is the panel as it has always been: every geometry-preparation control is visible and the
// user drives Remesh, Subdivide and Bake themselves, in whatever order they like. Standard hides
// all of that, pins it to one fixed recipe, and folds it into the Bake button - paint, press Bake,
// done - so the common case does not require knowing that a height map can only move vertices that
// already exist. Nothing about Pro changed when Standard was added.
int m_panel_mode = 0;
bool pro_mode() const { return m_panel_mode != 0; }
// Pins every control Standard mode hides to its preset value. Idempotent, called each frame while
// 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);
// 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
// the paint if it were not remapped across (see replace_mesh_keep_all_paint()).
void bake_standard();
// Replaces `mv`'s mesh and carries *every* paint channel onto it, texture displacement included -
// the four standard channels via ModelVolume::restore_painting(), the eight texture-displacement
// masks via the same TriangleSelector::remap_painting() spatial remap, which restore_painting()
// does not cover. Shared by Remesh and by Standard mode's pipeline.
static void replace_mesh_keep_all_paint(ModelVolume &mv, TriangleMesh &&new_mesh);
// Remesh and adaptive Subdivide are each split into a *plan* (the heavy geometry work, touching
// nothing) and an *apply* (pure mutation). That split is what lets both the standalone buttons and
// Standard mode's one-button pipeline run the expensive part **before** taking the undo snapshot,
// so a run that turns out to be a no-op does not leave an empty undo step behind - and it keeps
// snapshot ownership with the caller, which matters because the buttons want one snapshot per click
// while the pipeline wants a single one around remesh + subdivide together.
struct SubdivisionPlan
{
indexed_triangle_set refined;
std::vector<int> source; // new tri -> input tri
std::array<std::vector<uint8_t>, TEXTURE_DISPLACEMENT_MAX_LAYERS> painted_tri; // per layer, per input tri
};
// False means "nothing to refine" and `out` must not be used.
bool plan_adaptive_subdivision(const ModelVolume &mv, SubdivisionPlan &out) const;
static void apply_adaptive_subdivision(ModelVolume &mv, SubdivisionPlan &&plan);
// False means the remesh failed or changed nothing (CGAL signals failure by handing the input back).
static bool plan_remesh(const ModelVolume &mv, float target_edge_mm, float sharp_angle_deg, TriangleMesh &out);
// Marks every facet of every model-part volume as painted for the currently active layer -
// "whole model" as an alternative to brushing/clicking every triangle by hand.
@@ -327,7 +371,7 @@ private:
// dense model). Refinement is worst-error-first, so hitting the budget still yields the best mesh
// that many triangles can buy - and it is what keeps a fine "Detail" over a noisy texture from
// turning into an out-of-memory, or an unrenderable preview wireframe.
int m_subdivide_budget_k = 200;
int m_subdivide_budget_k = 1500;
void subdivide_model_adaptive();
// Fills `region` (per current-mesh triangle, 1 = refine) from the union of every layer's painted
// area. If `painted_tri` is non-null, also fills, per layer, the fully-painted triangles to carry
@@ -345,7 +389,9 @@ private:
// Isotropic remeshing (CGAL) to even out wildly varying triangle sizes so displacement has a
// consistent density to work with. Target edge length in mm; 0 means "not yet initialised", filled
// with the mesh's mean edge length the first time the control is shown. Like subdivide, it replaces
// the geometry and drops not-yet-baked paint (no remap across a topology change).
// the geometry, but unlike subdivide it keeps every paint channel: replace_mesh_keep_all_paint()
// remaps the texture-displacement masks spatially, which is also what lets Standard mode remesh
// *after* the user has painted.
float m_remesh_target_edge_mm = 0.f;
// Dihedral angle above which an edge counts as a hard feature and is held fixed by the remesher.
// Off by default would round every sharp edge off, so this is on; 0 disables the protection.

View File

@@ -41,41 +41,55 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept
Plater *plater = wxGetApp().plater();
Plater::TakeSnapshot snapshot(plater, _u8L("Bake texture displacement"), UndoRedo::SnapshotType::GizmoAction);
const auto commit = [this, plater]() {
ModelVolume *volume = get_model_volume(m_input.volume_id, plater->model().objects);
if (volume == nullptr)
return;
ModelVolume *volume = get_model_volume(m_input.volume_id, plater->model().objects);
if (volume == nullptr)
return;
volume->set_mesh(std::move(m_result));
volume->set_new_unique_id();
volume->calculate_convex_hull();
volume->set_mesh(std::move(m_result));
volume->set_new_unique_id();
volume->calculate_convex_hull();
// Clear the paint mask of every layer that was actually baked so a repeat bake (or the paint
// overlay) doesn't act on triangles that no longer represent the same unbaked surface. The
// texture layer definitions themselves (and paint outside the baked area, if any) are left
// untouched so the user can keep sculpting with the same textures.
for (const TextureDisplacementLayer &layer : m_input.layers)
if (!layer.empty() && layer.slot >= 0 && layer.slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS))
volume->texture_displacement_facet(layer.slot).reset();
// Clear the paint mask of every layer that was actually baked so a repeat bake (or the paint
// overlay) doesn't act on triangles that no longer represent the same unbaked surface. The
// texture layer definitions themselves (and paint outside the baked area, if any) are left
// untouched so the user can keep sculpting with the same textures.
for (const TextureDisplacementLayer &layer : m_input.layers)
if (!layer.empty() && layer.slot >= 0 && layer.slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS))
volume->texture_displacement_facet(layer.slot).reset();
ModelObject *object = volume->get_object();
if (object == nullptr)
return;
ModelObject *object = volume->get_object();
if (object == nullptr)
return;
if (ObjectList *obj_list = wxGetApp().obj_list()) {
const ModelObjectPtrs &objs = plater->model().objects;
auto it = std::find(objs.begin(), objs.end(), object);
if (it != objs.end())
obj_list->update_info_items(size_t(it - objs.begin()));
}
if (ObjectList *obj_list = wxGetApp().obj_list()) {
const ModelObjectPtrs &objs = plater->model().objects;
auto it = std::find(objs.begin(), objs.end(), object);
if (it != objs.end())
obj_list->update_info_items(size_t(it - objs.begin()));
plater->changed_object(*object);
};
// Standard mode's Bake is remesh -> subdivide -> displace under a single snapshot, and this job runs
// long after that snapshot's scope has closed. Adding one here would put an undo step *between* the
// subdivision and the displacement: the first Undo would land on a mesh carrying every added
// triangle and no relief at all, and pressing Bake again from there would subdivide that mesh a
// second time. So the caller says who owns the undo step.
if (m_input.take_snapshot) {
Plater::TakeSnapshot snapshot(plater, _u8L("Bake texture displacement"), UndoRedo::SnapshotType::GizmoAction);
commit();
} else {
commit();
}
plater->changed_object(*object);
}
void queue_texture_displacement_bake(const ModelVolume &volume, std::function<void()> on_finished)
void queue_texture_displacement_bake(const ModelVolume &volume, std::function<void()> on_finished,
bool take_snapshot)
{
TextureDisplacementBakeInput input;
input.take_snapshot = take_snapshot;
input.volume_id = volume.id();
input.base_mesh = volume.mesh().its;
input.layers = volume.texture_displacement_layers;

View File

@@ -22,6 +22,10 @@ struct TextureDisplacementBakeInput
std::vector<TextureDisplacementLayer> layers;
TextureDisplacementFacetsData facets_data;
TextureDisplacementOptions options;
// Whether this job pushes its own undo step when it commits. False when the caller has already
// taken one that is meant to cover the displacement as well - Standard mode's Bake, which remeshes
// and subdivides first and has to undo as a single action.
bool take_snapshot = true;
};
// Bakes a volume's painted texture-displacement layers into real mesh geometry in the background,
@@ -45,7 +49,8 @@ private:
// the app's UI job worker. `on_finished` is always called once the job settles (success, failure,
// or cancellation), so the caller can clear its own "bake in progress" UI state. Must be called
// from the main thread.
void queue_texture_displacement_bake(const ModelVolume &volume, std::function<void()> on_finished);
void queue_texture_displacement_bake(const ModelVolume &volume, std::function<void()> on_finished,
bool take_snapshot = true);
} // namespace Slic3r::GUI