mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-11 11:07:40 +00:00
Add texture displacement documentation
This commit is contained in:
604
TEXTURE_DISPLACEMENT.md
Normal file
604
TEXTURE_DISPLACEMENT.md
Normal file
@@ -0,0 +1,604 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
A paint-style gizmo (`GLGizmoTextureDisplacement`) that lets you:
|
||||||
|
- Paint one or more "layers" onto a model's surface, each a height-map texture with its own
|
||||||
|
depth/tiling/rotation/offset/invert/tile-mode/projection-mode/blend-mode.
|
||||||
|
- Pick a texture from a shipped library (`resources/textures/displacement/`) or import your own
|
||||||
|
(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.
|
||||||
|
- 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.
|
||||||
|
- Unwrap a painted patch with a real CGAL LSCM parameterization and view it in a dedicated,
|
||||||
|
dockable 2D "UV Editor" pane.
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
**Important gotcha**: `ModelVolume` does **not** hold `std::array<FacetsAnnotation, 8>`. It holds
|
||||||
|
8 individually-named fields (`texture_displacement_facets_0` .. `_7`) plus a
|
||||||
|
`texture_displacement_facet(int slot)` accessor. Reason: `FacetsAnnotation`'s ctor is private,
|
||||||
|
friended only to `ModelVolume`; `std::array`'s own implicitly-generated special member functions
|
||||||
|
are generated with **`std::array`'s** access rights, not the enclosing class's, so friendship does
|
||||||
|
not propagate through the array wrapper. This is a real MSVC C2280 if you try it — confirmed by
|
||||||
|
attempting it. `TextureDisplacementFacetsData` (a `std::array<TriangleSelector::TriangleSplittingData, 8>`,
|
||||||
|
used to carry paint-mask *data* around, e.g. into the bake job) is fine as a real `std::array`
|
||||||
|
since `TriangleSplittingData` has an ordinary public ctor — only the `FacetsAnnotation` object
|
||||||
|
itself has the friend-ctor problem.
|
||||||
|
|
||||||
|
Plus `std::vector<TextureDisplacementLayer> texture_displacement_layers;` — the plain-data layer
|
||||||
|
definitions (texture bytes + params), ordinary public ctor, safe in a vector.
|
||||||
|
|
||||||
|
Touch points that had to mirror the existing `FacetsAnnotation` pattern (see `supported_facets` for
|
||||||
|
the template): all constructors' asserts, copy ctors' init lists, the `-1`-id deserialization ctor,
|
||||||
|
`set_new_unique_id()`, cereal `save`/`load`, `is_texture_displacement_painted()`, and
|
||||||
|
`reset_extra_facets()` (called whenever a topology-changing op like Simplify or subdivision
|
||||||
|
replaces the mesh — this is what drops any unbaked texture-displacement paint, since there's no
|
||||||
|
remap-across-topology-change support for it yet, see Limitations).
|
||||||
|
|
||||||
|
### Bake algorithm (`libslic3r/TextureDisplacement.cpp`)
|
||||||
|
|
||||||
|
`build_texture_displacement(base_mesh, layers, facets_data)` is **accumulate-then-displace, and
|
||||||
|
topology-preserving**: the returned mesh has exactly the input's vertices and triangles, in the 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.
|
||||||
|
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
|
||||||
|
`selector.get_facets_strict(ENFORCER)` → the painted patch. Two facts are exploited:
|
||||||
|
- `get_facets_strict()` returns the mesh's **entire** referenced vertex array regardless of which
|
||||||
|
state was asked for — only `.indices` is filtered by state. So `get_facets_strict(ENFORCER)`
|
||||||
|
and `get_facets_strict(NONE)` share identical vertex indexing, which is what lets boundary
|
||||||
|
detection be a plain index check instead of a position-hash lookup.
|
||||||
|
- The selector's vertex array *starts with* the mesh's own vertices (extra ones created where a
|
||||||
|
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.
|
||||||
|
4. A vertex used by at least one **unpainted** triangle is a boundary vertex — pinned, never
|
||||||
|
displaced (its final position is ambiguous, it belongs to both regions). Only vertices used
|
||||||
|
exclusively by painted triangles get displaced. This is what keeps bakes seamless with zero
|
||||||
|
remeshing/hole-filling at the seam.
|
||||||
|
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.
|
||||||
|
|
||||||
|
**This replaced a sequential design** that re-meshed after each layer and carried the next layer's
|
||||||
|
paint mask onto the result with `TriangleSelector::remap_painting()`. That was the root cause of the
|
||||||
|
reported "the second texture is never applied" bug: remapping a mask onto a mesh whose vertices had
|
||||||
|
just been displaced out from under it routinely produced an empty bitstream, and the layer was then
|
||||||
|
silently `continue`d past. It was also what forced the per-layer vertex duplication and the final
|
||||||
|
`its_compactify_vertices()` weld. The current formulation has neither problem, is substantially
|
||||||
|
faster (no remap, no welding, one pass), makes blend modes possible at all (they need a shared
|
||||||
|
per-vertex accumulator, which sequential re-meshing cannot provide), and — because the output keeps
|
||||||
|
the input's exact vertex indexing — lets GUI code overlay a preview on the base mesh with no index
|
||||||
|
translation.
|
||||||
|
|
||||||
|
### Blend modes
|
||||||
|
|
||||||
|
`TextureBlendMode` {Add, Subtract, Multiply, Divide}, per layer, applied per vertex against the
|
||||||
|
total accumulated by the layers **below** it (lower slots). The quantity blended is a signed
|
||||||
|
displacement in **mm**, not a pixel value.
|
||||||
|
|
||||||
|
Add/Subtract are self-explanatory. Multiply/Divide are *scaling* operations and so need a unit
|
||||||
|
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×.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Projection methods
|
||||||
|
|
||||||
|
Four 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.)
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
|
- **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
|
||||||
|
moved), and stores them as `TextureDisplacementLayer::view_project_right/up` (unit vectors, so the
|
||||||
|
projected coordinate stays in mm and `tiling_scale` keeps meaning mm). `sample_layer_height()`
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Manual seams and island cutting
|
||||||
|
|
||||||
|
`TextureDisplacementLayer::lscm_seam_edges` — undirected mesh-vertex-index edge pairs the unwrap is
|
||||||
|
forced to cut along, on top of the dihedral-angle seams. `segment_into_charts()` takes a set of these
|
||||||
|
(translated from mesh → compacted-patch numbering inside `compute_patch_unwrap()`) and refuses to
|
||||||
|
union two triangles across a marked edge whatever their angle. Both the unwrap cache key and the
|
||||||
|
gizmo's `UVEditorState` include the seam list, so marking a seam (which leaves the paint mask
|
||||||
|
untouched) still forces a re-solve. Like the paint masks, seams are mesh-index-space and so dropped on
|
||||||
|
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.
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
|
||||||
|
### Subdivision (`subdivide_mesh_uniform()`)
|
||||||
|
|
||||||
|
Deliberately **whole-mesh and uniform**, not limited to the painted patch. A patch-only /
|
||||||
|
adaptive subdivision would create a classic T-junction/cracking problem where the denser
|
||||||
|
(subdivided) and sparser (untouched) regions meet — the fine side has edge midpoints the coarse
|
||||||
|
side doesn't know about, producing a real (non-manifold-looking) crack in the baked geometry. This
|
||||||
|
was consciously scoped down from the original plan's "adaptive per-patch subdivider" idea to avoid
|
||||||
|
that correctness risk (a subtly-cracked mesh is a much worse outcome than "not implemented yet").
|
||||||
|
Algorithm: recursive 1-to-4 triangle split via edge midpoints, with a shared per-pass midpoint cache
|
||||||
|
(keyed by sorted vertex-index pair) so triangles sharing an edge get the *same* new vertex — capped
|
||||||
|
at `max_iterations` (default 6) passes to bound worst-case triangle-count explosion.
|
||||||
|
|
||||||
|
Wired as a "Subdivide model" button in the gizmo panel — a real, committed geometry change (like
|
||||||
|
Bake), using the same `save_painting()`/`set_mesh()`/`restore_painting()` dance `GLGizmoSimplify`
|
||||||
|
uses: supported/seam/mmu/fuzzy-skin masks get remapped onto the new triangles, texture-displacement
|
||||||
|
paint does not (no remap support yet) and is dropped rather than left pointing at now-meaningless
|
||||||
|
triangle indices.
|
||||||
|
|
||||||
|
### Preview pipeline (perf)
|
||||||
|
|
||||||
|
`rebuild_preview()` used to call `build_texture_displacement()` **synchronously on the UI thread**
|
||||||
|
on every stroke-end and every slider release. With multiple painted layers this got slow (each
|
||||||
|
layer's PNG sampling + vertex welding + `remap_painting()` stacks up). Fixed by moving the actual
|
||||||
|
computation into `TextureDisplacementPreviewJob` (mirrors `TextureDisplacementBakeJob`'s
|
||||||
|
process()/finalize() split), queued on the app's shared UI job worker via plain `queue_job()` (not
|
||||||
|
`replace_job()` — that worker is shared app-wide, including with Bake; `replace_job()` would cancel
|
||||||
|
an in-flight bake if one happened to be running). A `m_preview_generation` counter discards stale
|
||||||
|
results if a burst of edits queues several jobs in a row and an older one finishes after a newer one.
|
||||||
|
|
||||||
|
Two other real perf fixes worth remembering:
|
||||||
|
- Sliders in ImGui report "changed" continuously on every drag frame, not just once on release —
|
||||||
|
gating the (then-synchronous) rebuild behind "mouse button not currently down" was necessary to
|
||||||
|
stop dozens of rebuilds per drag.
|
||||||
|
- `decode_height_texture()` used to re-decode the same PNG from scratch on every call. Now cached
|
||||||
|
in `TextureDisplacement.cpp`, keyed by a `weak_ptr` to the layer's `image_data` (not just the raw
|
||||||
|
pointer — a `weak_ptr` correctly detects a freed-then-reused address, where a raw-pointer key
|
||||||
|
would alias a stale cache entry onto an unrelated later texture).
|
||||||
|
|
||||||
|
### Fast bump 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Two projection paths (`use_vertex_uv` uniform):**
|
||||||
|
- **Triplanar (`use_vertex_uv = 0`)** — `uv` and the `T`/`B` axes are both derived in-shader from
|
||||||
|
the dominant normal component, mirroring `project_planar()`/`apply_uv_transform()`, and the slope is
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
`[0,1)`.
|
||||||
|
|
||||||
|
> **Known divergence from the CPU path (not yet reconciled).** The shader's `project_uv()` mirrors
|
||||||
|
> the *hard-axis* `project_planar()`, but the CPU's Triplanar mode is now a **blended** three-plane
|
||||||
|
> sample (see Projection methods — that change is what fixed the 90°-corner seam). The two therefore
|
||||||
|
> still agree on any face that is roughly axis-aligned (one blend weight ≈ 1 there, so the blend
|
||||||
|
> degenerates to exactly the hard-axis pick), and disagree in the cross-fade band around a sharp
|
||||||
|
> edge — precisely where the fast preview will still show the old hard seam that the true preview and
|
||||||
|
> the bake no longer have. Reconciling it means sampling all three planes in the shader and blending
|
||||||
|
> the three gradients by `pow(abs(N), TRIPLANAR_BLEND_SHARPNESS)`, the same weights
|
||||||
|
> `sample_layer_height()` uses. Also note the shader is still **active-layer-only** and knows nothing
|
||||||
|
> about `TextureBlendMode`, so a multi-layer stack cannot match the true preview by construction.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
Known unverified detail: the **offset-drag direction/sign** is reasoning-based (increasing `offset`
|
||||||
|
shifts which texel is sampled at a fixed world position, which visually slides the pattern the
|
||||||
|
*opposite* way — so the code subtracts), not visually confirmed, since this environment can't
|
||||||
|
render pixels. May need a one-line sign flip once actually tested. The rotation-arrow-implied
|
||||||
|
direction should be reliable (it follows directly from a self-consistent 2D basis, no such
|
||||||
|
ambiguity).
|
||||||
|
|
||||||
|
### UV Editor pane
|
||||||
|
|
||||||
|
`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 Blender-style 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
|
||||||
|
`get_uv_editor_canvas()` returns the inner canvas the gizmo talks to.
|
||||||
|
|
||||||
|
Deliberately **shares the app's one real `wxGLContext`** (`wxGetApp().init_glcontext(*this)`, the
|
||||||
|
same call `View3D`/`Preview`/`AssembleView` make) rather than creating an independent context like
|
||||||
|
`SkipPartCanvas` does elsewhere in this codebase — this is what lets it reuse the already-registered
|
||||||
|
`"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`. This is
|
||||||
|
the fix for the ~200 ms-per-frame island-drag stall (#3): the old design pre-transformed every UV on
|
||||||
|
the CPU and re-uploaded the entire wireframe on every mouse-move event, which on a million-triangle
|
||||||
|
patch is exactly as slow as it sounds. Now 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
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Bugs found and fixed this session (worth remembering)
|
||||||
|
|
||||||
|
These were all real, confirmed root causes (found by reading the actual code path, not guessed):
|
||||||
|
|
||||||
|
1. **Cross-face projection distortion** — see "Triplanar" above. Fixed by projecting each vertex
|
||||||
|
with its own normal instead of one shared patch-average normal.
|
||||||
|
2. **Disabled-tile smearing to infinity** — clamping the UV *coordinate* into `[0,1]` instead of
|
||||||
|
returning 0 outside it, when tiling is off. Fixed in `DecodedHeightTexture::sample()`.
|
||||||
|
3. **Invisible checkbox/radio "checked" state in light mode** — `ImGuiWrapper::push_toolbar_style()`
|
||||||
|
sets `ImGuiCol_CheckMark` to white while the checkbox/radio frame background is fully transparent
|
||||||
|
(alpha 0) over a light window background — a white checkmark on an effectively-white background
|
||||||
|
is invisible by construction. This is a **pre-existing, general app-wide bug**, not specific to
|
||||||
|
this feature (every panel using `push_toolbar_style()` in light mode has it) — fixed by changing
|
||||||
|
just the light-mode branch's `CheckMark` color to the app's teal accent.
|
||||||
|
4. **Distorted (non-aspect-correct) texture thumbnails** — was forcing a square `ImGui::Image` size
|
||||||
|
regardless of the source image's actual aspect ratio.
|
||||||
|
5. **`std::array<FacetsAnnotation, 8>` compile error** — see Data model above (MSVC C2280,
|
||||||
|
`std::array`'s implicit special members don't inherit element-type friendship).
|
||||||
|
6. **Eigen ternary expression-template type mismatch** (MSVC C2446) — `cond ? (n / len) :
|
||||||
|
Vec3f::UnitZ()` fails because the two branches are different unevaluated Eigen expression
|
||||||
|
*types* with no common type; fixed by wrapping the non-`UnitZ()` branch in an explicit
|
||||||
|
`Vec3f(...)` to force a concrete common type.
|
||||||
|
7. **Post-bake stale preview** — `GLGizmoPainterBase::data_changed()`'s change-detection only
|
||||||
|
checks object id / volume count, neither of which changes when Bake replaces a volume's mesh
|
||||||
|
(same object, same volume count, just a new mesh/id on the volume itself) — so the gizmo kept
|
||||||
|
rendering/painting against the pre-bake `TriangleSelectorPatch` until manually deselected and
|
||||||
|
reselected. Fixed by explicitly calling `update_from_model_object()` in the bake-completion
|
||||||
|
callback.
|
||||||
|
8. **Bake job / crash-report `resources` junction going stale** — unrelated to this feature's code,
|
||||||
|
but hit during testing: `build/src/Release/resources` was a leftover **empty plain directory**
|
||||||
|
instead of the junction CMake's post-build step creates (`if not exist` skipped it because the
|
||||||
|
empty folder already "existed"), so the built exe couldn't find `resources/data/hints.ini`,
|
||||||
|
leaving `HintDatabase`'s hint list empty → `rand() % 0` divide-by-zero crash before the UI ever
|
||||||
|
opened. Fixed by deleting the empty folder and manually recreating the `mklink /J` junction.
|
||||||
|
9. **Fast bump preview showing a solid black object** — `GLModel::render()` **unconditionally**
|
||||||
|
re-sets the shader's `"uniform_color"` uniform from its own internal `Geometry::color` field
|
||||||
|
(defaulting to `ColorRGBA::BLACK()`) right before every draw call — so a manual
|
||||||
|
`shader->set_uniform("uniform_color", ...)` call made just before `.render()` gets silently
|
||||||
|
clobbered. Any `GLModel` that needs a specific flat color **must** call `.set_color(...)` on the
|
||||||
|
model itself, not set the shader uniform directly. Found by reading `GLModel::render()`'s actual
|
||||||
|
source rather than guessing at shader/lighting math.
|
||||||
|
10. **UV editor canvas rendering nothing / showing stale content on resize** — the canvas requested
|
||||||
|
a generic `wxGLAttributes().Defaults()` pixel format while sharing the app's one real
|
||||||
|
`wxGLContext` (which was originally created against `View3D`'s canvas, itself requesting a
|
||||||
|
specific RGBA/24-bit-depth/8-bit-stencil format). `wxGLCanvas::SetCurrent()` on WGL/GLX
|
||||||
|
generally requires the target window's pixel format to be compatible with the one the context
|
||||||
|
was created against; a mismatch can make `SetCurrent()` silently fail, leaving the canvas
|
||||||
|
showing whatever was last in its backbuffer (looks exactly like "blank" or "stale image on
|
||||||
|
resize"). Fixed by requesting the same explicit attribute list
|
||||||
|
`OpenGLManager::create_wxglcanvas()` uses for the main view canvases — but see bug #14: the
|
||||||
|
first attempt at this copied only *part* of that list and the symptom therefore survived.
|
||||||
|
11. **`<glad/gl.h>` / `<wx/glcanvas.h>` include-order conflict** — `wx/glcanvas.h` pulls in the
|
||||||
|
platform's real `GL/gl.h`; if that happens before `<glad/gl.h>` is processed in the same
|
||||||
|
translation unit, glad's own header errors out (`OpenGL (gl.h) header already included`).
|
||||||
|
Fixed by including `<glad/gl.h>` first in `UVEditorCanvas.hpp`, before `<wx/glcanvas.h>` — any
|
||||||
|
file that includes this header (including `Plater.cpp`, transitively) needs glad to win that
|
||||||
|
race.
|
||||||
|
12. **Fast preview hidden behind the paint-highlight overlay** — `render_painter_gizmo()` always
|
||||||
|
drew the selection-highlight overlay on top with a depth-bias trick (`glPolygonOffset`) that
|
||||||
|
only makes sense for the *true*-displacement preview: real geometry moves in the painted area,
|
||||||
|
so the depth-biased overlay only wins the depth test in the *unpainted* (coincident) region.
|
||||||
|
The bump preview never moves geometry — its depth is identical to the overlay's *everywhere* —
|
||||||
|
so the overlay was winning the depth test across the whole surface and hiding the bump shading
|
||||||
|
entirely. Fixed by skipping the overlay draw entirely when the bump-preview path is active.
|
||||||
|
13. **Fast preview's apparent depth not matching the true preview's** — the bump shader built its
|
||||||
|
perturbed normal as `normalize(N + depth_mm * vec3(hL-hR, hD-hU, 0))`. Two things wrong with
|
||||||
|
that. (a) `hL-hR` is a height difference across *one texel step*, i.e. `dh/du` already scaled by
|
||||||
|
`2·texel`, and `du` is in uv units, not mm — the real surface slope needs the full chain rule
|
||||||
|
back through `uv = R(rotation) · planar_mm / tiling_scale`, i.e. a further `1/tiling_scale` and
|
||||||
|
a rotation of the gradient by `−rotation`. The missing `1/(2·texel·tiling_scale)` factor is
|
||||||
|
~26× at a 1024px texture and a 20mm tile size, all in the flattening direction — which is
|
||||||
|
exactly what "fast preview has a different height from the real preview" looks like. (b) the
|
||||||
|
gradient was added to model-space `xy`, but the two axes the planar projection actually runs
|
||||||
|
along are `yz`/`xz`/`xy` depending on the dominant normal component, so on any face not
|
||||||
|
dominated by `z` the perturbation was applied to the wrong axes. Fixed by computing the real
|
||||||
|
mm-per-mm slope and rotating it into the projection's own `T`/`B` axes (see "Fast bump preview"
|
||||||
|
above for the derivation).
|
||||||
|
14. **UV editor pane still blank after bug #10** — three separate causes, all of them live at once:
|
||||||
|
- The bug-#10 fix copied `OpenGLManager::create_wxglcanvas()`'s attribute list but **dropped its
|
||||||
|
multisampling attributes** (`WX_GL_SAMPLE_BUFFERS`/`WX_GL_SAMPLES`, 4 samples by default), on
|
||||||
|
the reasoning that a flat 2D wireframe view doesn't need AA. But a differing sample count *is*
|
||||||
|
a differing pixel format, so this left exactly the `wglMakeCurrent()` mismatch bug #10 set out
|
||||||
|
to fix. It now mirrors the full list, AA included, reading `OpenGLManager::can_multisample()`
|
||||||
|
(already resolved by then — `View3D` is constructed first).
|
||||||
|
- `set_mesh()`/`set_background_texture()` each called `render()` **inline**, and both are reached
|
||||||
|
from `update_uv_editor()` → `rebuild_preview()` → the gizmo's ImGui panel — i.e. from the
|
||||||
|
middle of the *3D* canvas's GL frame, and (since `show_uv_editor(true)` is the last line of
|
||||||
|
`update_uv_editor()`) while this pane was still **hidden**. `wxGLCanvas::SetCurrent()` returns
|
||||||
|
false outright on a canvas that isn't shown on screen, and the old code ignored the return
|
||||||
|
value — so every GL call in `render()`, `glViewport`/`glClear` included, silently landed on the
|
||||||
|
3D canvas instead. These now only mark dirty + `Refresh()`; `render()` bails unless
|
||||||
|
`IsShownOnScreen()` *and* `SetCurrent()` succeeds; and `Plater::show_uv_editor()` defers its
|
||||||
|
AUI relayout via `CallAfter` so the pane's first size/paint can't be delivered mid-frame either.
|
||||||
|
- Even once drawing, nothing would have been *visible*: the background quad spanned `[-1,1]²`
|
||||||
|
while LSCM UVs land around `[0,1]²`, and the view was centered on the origin at a half-extent
|
||||||
|
of 0.6. The quad is now the unit square in the same UV space the wireframe uses (which is also
|
||||||
|
where `sample()` maps the texture, regardless of its pixel aspect), the projection's Y is
|
||||||
|
negated so `v` runs down-screen (putting the texture's first pixel row at the top rather than
|
||||||
|
upside down), and the view auto-fits to the unwrap ∪ unit square the first time a patch shows up.
|
||||||
|
15. **A second texture layer was silently never applied** — the reported "multiple textures don't
|
||||||
|
work reliably". Root cause was the old sequential bake: layer N's paint mask was stored against
|
||||||
|
the volume's original mesh, so before layer N+1 could be deserialized the mask had to be carried
|
||||||
|
onto the mesh layer N had *just displaced*, via `TriangleSelector::remap_painting()`. Remapping a
|
||||||
|
mask onto geometry that has moved out from under it routinely returned an empty bitstream, and
|
||||||
|
the code then did `if (data.bitstream.empty()) continue;` — i.e. dropped the layer **without any
|
||||||
|
diagnostic**. Fixed structurally rather than patched: every layer is now evaluated against the
|
||||||
|
base mesh and merged per vertex (see Bake algorithm), so no remap happens at all. Covered by a
|
||||||
|
regression test.
|
||||||
|
16. **Hard-axis triplanar seam at exactly two of a box's four vertical corners** — see "Triplanar"
|
||||||
|
under Projection methods. Worth recording the *diagnostic* here, because the asymmetry is what
|
||||||
|
pinned it down: the user reported the seam at the (X+,Y−) and (X−,Y+) corners with the other two
|
||||||
|
clean. That is precisely what a dominant-axis switch predicts (`u = y` on an X face, `u = x` on a
|
||||||
|
Y face; those agree where `x == y` and differ by the corner width where `x == −y`) and it ruled
|
||||||
|
out every "the texture is wrong" hypothesis, since the texture itself is fine — the *mapping* is
|
||||||
|
discontinuous. Fixed by blending the three axis projections instead of picking one.
|
||||||
|
17. **Use-after-free when removing a texture layer** (latent, pre-existing — found while touching the
|
||||||
|
panel, not caused by it). The layer list's "Remove" button called `remove_texture_layer()` *in
|
||||||
|
the middle of rendering that layer's row*. That erases the layer from
|
||||||
|
`mv->texture_displacement_layers`, shifting every later element down — after which the loop
|
||||||
|
happily carried on dereferencing `layer` for the rest of the row's widgets (depth/tiling sliders,
|
||||||
|
`PopID`) and kept iterating `ordered`, a vector of pointers into the storage that had just moved.
|
||||||
|
Never crashed loudly because `vector::erase` doesn't reallocate, so the reads landed on a *valid*
|
||||||
|
but *wrong* (shifted) layer. Fixed by recording the slot and doing the removal after the loop.
|
||||||
|
18. **Every LSCM island collapsed to a point** (the UV editor was empty; the Tile-size slider did
|
||||||
|
nothing; an LSCM bake came out as a flat "single-face extrude"). One line in
|
||||||
|
`compute_patch_unwrap()`: `chart.indices = std::move(chart_mesh.indices);` ran *before*
|
||||||
|
`area_3d(chart_mesh)` was taken. `area_3d()` iterates those indices, so on the moved-from
|
||||||
|
(emptied) mesh it returned `0`, giving `scale = sqrt(0 / uv_area) = 0` — **every chart's UVs
|
||||||
|
multiplied by zero**. That one zero explained all three symptoms at once (no island extent to
|
||||||
|
draw; a zero-size unwrap is still zero after any tiling divide; the bake sampled ~one constant
|
||||||
|
texel per chart). Fixed by measuring the 3D area before the move. Found only by instrumenting the
|
||||||
|
actual island bbox into the panel — three rounds of reasoning from screenshots had each guessed
|
||||||
|
wrong, because a collapsed-to-a-point unwrap and an off-screen-framed one look identical.
|
||||||
|
|
||||||
|
## 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, and now `subdivide_mesh_uniform()` all drop it via `reset_extra_facets()`).
|
||||||
|
The other four paint channels (supported/seam/mmu/fuzzy) do get remapped in these cases.
|
||||||
|
- **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**, while the true preview stacks every painted layer —
|
||||||
|
so with more than one layer painted the two will legitimately not agree, independently of bug #13.
|
||||||
|
- **Bump preview and true preview both only refresh at stroke-end**, not continuously during an
|
||||||
|
active drag (a deliberate scope cut for simplicity/consistency — the original plan's "instant
|
||||||
|
update mid-stroke" idea for the bump shader specifically was not carried through).
|
||||||
|
- **On-canvas Adjust-Texture gizmo's offset-drag direction is unverified** (see above).
|
||||||
|
- **The bump shader still uses hard-axis, single-layer projection** while the CPU path is now
|
||||||
|
blended-triplanar and blend-mode aware — see the callout under "Fast bump preview".
|
||||||
|
- **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 "Subdivide model" button is for.
|
||||||
|
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.
|
||||||
|
- **Placeholder toolbar icon** — reuses `toolbar_fuzzy_skin_paint.svg`, noted as a TODO in code.
|
||||||
|
- **Textures are matched to the picker by absolute path** (`TextureDisplacementLayer::path`), so the
|
||||||
|
picker's "which entry is selected" highlight goes blank if a project is moved between machines.
|
||||||
|
Harmless — the layer keeps its own embedded `image_data` and still bakes correctly.
|
||||||
|
|
||||||
|
### Requested UV-editing features — status
|
||||||
|
|
||||||
|
A user working through the feature end-to-end asked for a batch of UV-editing features. All of the
|
||||||
|
functional ones are now implemented (see the sections above): checker (#13), distortion heatmap
|
||||||
|
(#14), mesh wireframe overlay (#8), cut island (#17), project-from-view (#6), and Blender-style mark
|
||||||
|
seam (#9), plus per-island fills, the fast preview honouring the LSCM unwrap and island moves (#1),
|
||||||
|
the UV pane toolbar + status line (#18), Shift-snap rotation, midlevel/bidirectional displacement
|
||||||
|
(#19), and island scale/average/padding/snap (#15/#16/#2).
|
||||||
|
|
||||||
|
Remaining cosmetic / known gaps:
|
||||||
|
- The UV pane toolbar has **text buttons, not icons** — no existing SVG reads cleanly as "average
|
||||||
|
island scale" / "snap islands", so real icons are deferred rather than mis-assigned.
|
||||||
|
- **Mark-seam edge picking** snaps to the nearest edge of the *hit facet* only; it does not
|
||||||
|
highlight the candidate edge on hover before you click (a hover-preview would be a nice refinement).
|
||||||
|
- **Cut island** always halves along the longest 3D axis; there is no UI to pick the cut line.
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
**libslic3r (core, no GUI dependency):**
|
||||||
|
- `src/libslic3r/TextureDisplacement.hpp/.cpp` — data model, bake algorithm, projection methods,
|
||||||
|
tiling, subdivision. See doc comments throughout, they're kept accurate and up to date.
|
||||||
|
- `src/libslic3r/MeshBoolean.hpp/.cpp` — added `parameterize_lscm()` 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`,
|
||||||
|
`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.
|
||||||
|
- `src/libslic3r/Model.hpp/.cpp` — the 8 named `FacetsAnnotation` fields + accessor,
|
||||||
|
`texture_displacement_layers`, 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/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
|
||||||
|
GUI side, because libslic3r has no image toolkit; both the import path and the "pick a shipped
|
||||||
|
texture" path go through the same one function.
|
||||||
|
- `resources/textures/displacement/*.png` — the 10 shipped height maps (Bricks, Grid, Hexagons,
|
||||||
|
Knurl, Noise, Quilt, Studs, Waves, Weave, Wood Grain). All 512×512 8-bit grayscale and **seamless**
|
||||||
|
(each is periodic over the full image in both axes, so tiling shows no seam). Generated
|
||||||
|
procedurally; the whole `resources/` tree is installed recursively by CMake, so a new folder under
|
||||||
|
it ships with no build-system change.
|
||||||
|
- `src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp/.cpp` — background bake commit.
|
||||||
|
- `src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp/.cpp` — background preview compute
|
||||||
|
(mirrors the bake job's shape but commits nothing to the Model).
|
||||||
|
- `src/slic3r/GUI/UVEditorCanvas.hpp/.cpp` — the 2D UV unwrap viewer widget.
|
||||||
|
- `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.
|
||||||
|
- `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 (bug #3 above; a
|
||||||
|
pre-existing, general bug, not scoped to this feature).
|
||||||
|
|
||||||
|
**CMake:** all new source files added to `src/libslic3r/CMakeLists.txt`,
|
||||||
|
`src/slic3r/CMakeLists.txt` (in roughly-alphabetical position matching each list's existing
|
||||||
|
convention), and `tests/libslic3r/CMakeLists.txt` for the unit test file.
|
||||||
|
|
||||||
|
**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** (the bug that
|
||||||
|
rewrite fixed), 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:
|
||||||
|
|
||||||
|
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 notes
|
||||||
|
|
||||||
|
- Everything here lives in `libslic3r`/`libslic3r_gui`/`libslic3r_cgal` — no new external
|
||||||
|
dependency, no `deps/` rebuild needed. CGAL's parameterization package was already vendored.
|
||||||
|
- To build just this feature's code path fastest: `cmake --build build --config Release --target
|
||||||
|
libslic3r_gui -- -m` (pulls in `libslic3r` and `libslic3r_cgal` as needed). The full app target
|
||||||
|
is `OrcaSlicer_app_gui` (produces `build/src/Release/orca-slicer.exe`) — only needed to actually
|
||||||
|
run and visually test, not to verify compilation.
|
||||||
|
- `BUILD_TESTS` is `OFF` in the existing build cache; flip it on to actually run
|
||||||
|
`test_texture_displacement.cpp`.
|
||||||
326
TEXTURE_DISPLACEMENT_GUIDE.md
Normal file
326
TEXTURE_DISPLACEMENT_GUIDE.md
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
# Texture Displacement — Feature & Controls Guide
|
||||||
|
|
||||||
|
Texture Displacement is a paint-style gizmo that stamps height-map textures onto a model's surface
|
||||||
|
and turns them into real relief — engraved or embossed detail — either as a live preview or baked
|
||||||
|
into actual mesh geometry. You paint where the texture applies, stack multiple textures as blended
|
||||||
|
layers, choose how each is projected onto the surface, and (for the unwrap projection) lay the result
|
||||||
|
out by hand in a dedicated 2D **UV Editor** pane.
|
||||||
|
|
||||||
|
This document describes every feature and control. For the internal architecture and algorithms, see
|
||||||
|
`TEXTURE_DISPLACEMENT.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of contents
|
||||||
|
|
||||||
|
1. [Quick start](#quick-start)
|
||||||
|
2. [Entering the tool](#entering-the-tool)
|
||||||
|
3. [Selection modes](#selection-modes)
|
||||||
|
4. [View modes](#view-modes)
|
||||||
|
5. [Auto update](#auto-update)
|
||||||
|
6. [Texture layers](#texture-layers)
|
||||||
|
7. [Per-layer settings](#per-layer-settings)
|
||||||
|
8. [Projection methods](#projection-methods)
|
||||||
|
9. [The UV Editor](#the-uv-editor)
|
||||||
|
10. [Seams](#seams)
|
||||||
|
11. [Adjust placement (on-model)](#adjust-placement-on-model)
|
||||||
|
12. [Preparing the mesh: Subdivide & Remesh](#preparing-the-mesh-subdivide--remesh)
|
||||||
|
13. [Baking & resetting](#baking--resetting)
|
||||||
|
14. [Controls reference](#controls-reference)
|
||||||
|
15. [Tips & limitations](#tips--limitations)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
1. Select an object and open the **Texture displacement** gizmo from the left toolbar.
|
||||||
|
2. A texture layer is added automatically. Pick a texture from the layer's picker, or import your own.
|
||||||
|
3. **Paint** the area you want the texture to affect (or press **Select whole model**).
|
||||||
|
4. The relief appears live on the model. Tune **Depth**, **Tile size**, **Rotation**, etc.
|
||||||
|
5. If the model is low-poly, use **Subdivide** or **Remesh** so there are enough vertices for detail.
|
||||||
|
6. Press **Bake** to convert the preview into real geometry, or leave it as a live preview.
|
||||||
|
|
||||||
|
> The tool only ever affects the **painted** area. Everything you don't paint keeps its original
|
||||||
|
> surface, and bake blends the relief seamlessly into it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entering the tool
|
||||||
|
|
||||||
|
The gizmo lives on the left gizmo toolbar (icon: `toolbar_texture_displacement.svg`). Its settings
|
||||||
|
panel opens beside the toolbar. You can **Dock panel / Undock panel** (top of the panel) to pin it or
|
||||||
|
float it freely over the 3D view, and **Close** at the bottom exits the gizmo.
|
||||||
|
|
||||||
|
When you first open the tool on a never-textured object it starts with **one texture layer already
|
||||||
|
added**, so you can paint straight away.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Selection modes
|
||||||
|
|
||||||
|
Choose *how* you paint. All three write into the **active layer's** mask.
|
||||||
|
|
||||||
|
| Mode | What it does |
|
||||||
|
|------|--------------|
|
||||||
|
| **Brush** | Free-hand painting with a round brush. Shows a **Brush size** slider and a **Circle / Sphere** choice (circle = surface disc, sphere = 3D ball that also paints around curves). |
|
||||||
|
| **Face** | Click a single triangle to paint it. |
|
||||||
|
| **Connected area** | Click to flood-fill a region; the **Angle threshold** slider limits how far the fill spreads across changes in surface angle. |
|
||||||
|
|
||||||
|
- **Select whole model** — marks the entire model as painted for the active layer, instead of
|
||||||
|
brushing it by hand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## View modes
|
||||||
|
|
||||||
|
A row of icon buttons labelled **View** controls how the painted area is shown. The first four are a
|
||||||
|
radio group; **Wireframe** is an independent toggle. Hover any icon for its tooltip.
|
||||||
|
|
||||||
|
| View | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| **Normal** | The true displaced geometry — exactly what **Bake** produces. Rebuilt in the background. |
|
||||||
|
| **Fast** | A GPU bump-shaded approximation of the *active layer only*. No real geometry movement — quick to update, not exact. Best while tuning or dragging islands. |
|
||||||
|
| **Checker** | A test grid painted over the unwrap so you can see stretching (squares stay square where the map isn't distorted). |
|
||||||
|
| **Distortion** | A blue→green→red heatmap of how much each area is compressed or stretched in UV space. Needs the **Unwrap (LSCM)** projection. |
|
||||||
|
| **Wireframe** | Overlays the mesh edges (white). Independent of the view above; in **Normal** view it sits on the displaced surface. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auto update
|
||||||
|
|
||||||
|
**Auto update** (on by default) rebuilds the true displaced geometry as soon as *anything* changes —
|
||||||
|
painting, swapping textures, moving sliders. Turn it off on very heavy models to only rebuild when you
|
||||||
|
release a slider (painting still updates on stroke end).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Texture layers
|
||||||
|
|
||||||
|
You can stack up to **8** texture layers. Each has its own independent paint mask, its own texture,
|
||||||
|
and its own parameters, and they combine in slot order like layers in an image editor.
|
||||||
|
|
||||||
|
- **Add a layer** — the **+ icon** to the right of the *Texture layers* heading (reuses the tool icon
|
||||||
|
for now).
|
||||||
|
- **Remove** — the button on each layer's header row.
|
||||||
|
- **Active layer** — click a layer's header (or anywhere in its block) to make it active. The active
|
||||||
|
layer is the one you paint into and the one whose block is tinted. Only one layer is active at a time.
|
||||||
|
- **Erase all** — clears the active layer's paint.
|
||||||
|
|
||||||
|
Each layer shows a texture **picker** (large preview + name). Open it to choose from the shipped
|
||||||
|
library or import your own image (any png/jpg/bmp; it's converted to an 8-bit grayscale height map and
|
||||||
|
copied into your user texture folder so app updates can't overwrite it).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-layer settings
|
||||||
|
|
||||||
|
| Control | Range / options | What it does |
|
||||||
|
|---------|-----------------|--------------|
|
||||||
|
| **Depth (mm)** | 0.01–10 (log) | Maximum displacement along the surface normal. |
|
||||||
|
| **Tile size (mm)** | 0.2–200 (log) | Physical size of one texture tile on the surface. |
|
||||||
|
| **Rotation** | 0–360° | Rotates the texture on the surface. |
|
||||||
|
| **Midlevel** | 0–10 | The grey level that means "don't move". At 0 the texture only pushes outward; raise it and darker texels cut *inward* (one map both embosses and engraves). 0.5 makes mid-grey neutral. |
|
||||||
|
| **Smoothing** | 0–1 | Blurs the height texture before it displaces — rounds hard edges and removes speckle without needing a softer source image. |
|
||||||
|
| **Edge smoothing** | checkbox + **Edge amount** 0–1 | Fades the relief to flat toward the *edge of the painted area*, so it blends into the surrounding surface. A small amount softens only a thin band at the very edge; the maximum flattens the whole painted face. |
|
||||||
|
| **Invert** | checkbox | Flips the height map (peaks become valleys). |
|
||||||
|
| **Blend** | Add / Subtract / Multiply / Divide | How this layer combines with the layers **below** it where they overlap. Add/Subtract pile relief on or carve it away; Multiply/Divide scale the relief underneath (a mask). The lowest painted layer is the **Base** and always behaves additively. |
|
||||||
|
| **Tile** | checkbox + **Repeat / Mirrored repeat** | When off, the texture is placed once (a decal) instead of repeating. Mirrored repeat flips every other tile to hide seams. |
|
||||||
|
| **Projection** | see below | How the texture is mapped onto the painted surface. |
|
||||||
|
|
||||||
|
> **Midlevel warning:** cutting inward can fold the surface through itself in sharp concave corners or
|
||||||
|
> thin walls. Keep Depth small relative to the feature you're cutting into; the panel warns when a deep
|
||||||
|
> inward setting is risky.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Projection methods
|
||||||
|
|
||||||
|
How the 2D texture is wrapped onto the 3D painted area.
|
||||||
|
|
||||||
|
| Method | Best for | Notes |
|
||||||
|
|--------|----------|-------|
|
||||||
|
| **Triplanar (blended)** | Patches wrapping around edges | Projects from all three axes at once and blends, so there's no seam across a sharp edge. |
|
||||||
|
| **Cylindrical** | Round, tube-like selections | Wraps the texture around the patch's own centre/axis. |
|
||||||
|
| **Spherical** | Ball-like selections | Longitude/latitude wrap around the patch centre. |
|
||||||
|
| **Unwrap (LSCM)** | Flat, controlled layout | A real conformal unwrap. Cuts the area into pieces at sharp edges (see **Seam angle**), flattens each, and lets you lay them out by hand in the **UV Editor**. Unlocks Checker/Distortion, seams, and island editing. |
|
||||||
|
| **From view** | Decals / slide-projector look | Projects straight onto the surface from the current camera direction. Use **Capture current view** to re-lay it from wherever you're looking. |
|
||||||
|
|
||||||
|
### LSCM-only controls
|
||||||
|
|
||||||
|
These appear when a layer uses **Unwrap (LSCM)**:
|
||||||
|
|
||||||
|
- **Seam angle** (5–90°) — edges sharper than this are cut so each piece lies flat. Lower cuts more
|
||||||
|
(less stretching, more seams); raise to keep more in one piece. A box's 90° corners are cut by
|
||||||
|
default. *Ignored once you've marked any seam by hand* (your seams then define the pieces).
|
||||||
|
- **Connect islands** (on by default) — lays the unwrap out as a **connected net**: pieces that share
|
||||||
|
an edge are unfolded next to each other (a cube becomes a joined net instead of six loose squares).
|
||||||
|
They stay separate islands, so you can still move any of them by hand. Turn off for the classic
|
||||||
|
packed-grid layout.
|
||||||
|
- **Open UV editor** — shows the flattened unwrap in a side pane (see below). Opens *only* when you
|
||||||
|
turn this on — it never pops up on its own.
|
||||||
|
- **Mark seams** / **Path** / **Clear seams** — see [Seams](#seams).
|
||||||
|
- An **Unwrap: N islands, F faces, V verts** read-out tells you what the unwrap actually produced.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The UV Editor
|
||||||
|
|
||||||
|
A dockable 2D pane (enable **Open UV editor** on an LSCM layer) showing the flattened unwrap over the
|
||||||
|
height texture. Islands are the flattened pieces; you can rearrange them freely — nothing re-packs them
|
||||||
|
behind your back. Moving an island updates the model **live** (in Fast view it tracks the cursor
|
||||||
|
smoothly, via a shader uniform — no rebuild until you release).
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
| Action | Control |
|
||||||
|
|--------|---------|
|
||||||
|
| Pan | Middle-drag |
|
||||||
|
| Zoom | Mouse wheel (zooms about the cursor) |
|
||||||
|
| Frame everything | **Home** or **F**, or the **Frame** toolbar button |
|
||||||
|
|
||||||
|
### Editing an island
|
||||||
|
|
||||||
|
| Action | Control |
|
||||||
|
|--------|---------|
|
||||||
|
| Select | Left-click an island |
|
||||||
|
| Move | Left-drag |
|
||||||
|
| Rotate | Right-drag, or press **R** then move the mouse (click/Enter to confirm, Esc to cancel) |
|
||||||
|
| Rotate snapped | Hold **Shift** while rotating — snaps to **global** 15° marks (0/15/30…). A protractor dial with tick marks and the current angle is shown. |
|
||||||
|
| Scale | Press **S** then move the mouse (click/Enter to confirm, Esc to cancel) |
|
||||||
|
| Undo / Redo | **Ctrl+Z** / **Ctrl+Shift+Z** or **Ctrl+Y** |
|
||||||
|
|
||||||
|
The **selected** island gets a bold light-green outline and a brighter wireframe; unselected islands
|
||||||
|
are a translucent light-green wash. The texture underneath repeats exactly as it will when baked.
|
||||||
|
A **status line** along the bottom always names the current gesture and the shortcuts in play.
|
||||||
|
|
||||||
|
### Toolbar
|
||||||
|
|
||||||
|
| Button | Action |
|
||||||
|
|--------|--------|
|
||||||
|
| **Frame** | Frame all islands (same as Home). |
|
||||||
|
| **Snap** | Toggle magnetic snapping — a dragged island sticks its boundary to a neighbour's when they come close. |
|
||||||
|
| **Avg scale** | Give every island the same texel density (Blender's "Average Islands Scale"). |
|
||||||
|
| **Cut** | Split the selected island across its long axis (useful for very long islands). |
|
||||||
|
| **Join** | Unfold the selected island onto its nearest neighbour along their shared edge — keeps both as separate islands with their own borders. |
|
||||||
|
| **Unjoin** | Send the selected island back to its own packed position. |
|
||||||
|
|
||||||
|
> **Checker / Distortion in the UV editor:** selecting those View modes also colours the UV pane — a
|
||||||
|
> checker background, or a per-island distortion heatmap — so you can judge stretch in 2D as well as
|
||||||
|
> on the model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seams
|
||||||
|
|
||||||
|
Seams are edges the unwrap is forced to cut along, on top of whatever the Seam angle cuts — the
|
||||||
|
Blender "mark seam" workflow. They let you control exactly where the unwrap splits.
|
||||||
|
|
||||||
|
Enable **Mark seams** on an active LSCM layer, then:
|
||||||
|
|
||||||
|
- **Click an edge** on the model to mark it (it turns **red**); click a red edge again to unmark it.
|
||||||
|
The edge under the cursor is highlighted **yellow** so you can see what a click will toggle.
|
||||||
|
- **Path mode** (the **Path** checkbox) — for dense meshes where clicking each edge is tedious: click a
|
||||||
|
start point, then an end point, and the whole **shortest path** between them is seamed at once. It
|
||||||
|
chains (each click extends from the last point); the start vertex is shown in **green**.
|
||||||
|
- **Ctrl+drag** rotates/pans the camera while in seam mode.
|
||||||
|
- **Clear seams** removes them all.
|
||||||
|
|
||||||
|
Once any seam is marked, the automatic Seam-angle cutting is disabled so *your* seams define the
|
||||||
|
islands — pieces you leave un-seamed merge together.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adjust placement (on-model)
|
||||||
|
|
||||||
|
**Adjust placement** (on an active layer) lets you position the texture by dragging a handle on the
|
||||||
|
model instead of nudging the Rotation/offset numbers. The handle is a flat panel in the patch's
|
||||||
|
tangent plane (drag anywhere on it to move freely) plus U/V arrows for single-axis nudges. It's
|
||||||
|
anchored to the painted patch, so paint something first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Preparing the mesh: Subdivide & Remesh
|
||||||
|
|
||||||
|
Displacement can only move vertices that exist, so a coarse model needs more of them first.
|
||||||
|
|
||||||
|
### Subdivide
|
||||||
|
|
||||||
|
Splits every triangle into four, **1–5 times** (each step roughly quadruples the triangle count).
|
||||||
|
|
||||||
|
- **Subdivide steps** (1–5) — how many times to split.
|
||||||
|
- **Preview subdivision** — shows the result as a **cyan wireframe** without changing the model.
|
||||||
|
- **Apply** — commits the subdivision to the geometry.
|
||||||
|
- **Done** — ends the preview and leaves the model as it is.
|
||||||
|
|
||||||
|
### Remesh
|
||||||
|
|
||||||
|
Rebuilds the whole model with triangles close to a target edge length — evens out a mesh with wildly
|
||||||
|
varying triangle sizes (CGAL isotropic remeshing).
|
||||||
|
|
||||||
|
- **Target edge (mm)** — desired triangle edge length (seeded to the model's current average).
|
||||||
|
- **Remesh** — splits the big triangles and merges the small ones to that size.
|
||||||
|
|
||||||
|
> Both Subdivide-Apply and Remesh **replace the geometry** and clear any *not-yet-baked* paint on it
|
||||||
|
> (already-baked relief is kept). If you had the mesh **Wireframe** on before, it stays on afterward.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Baking & resetting
|
||||||
|
|
||||||
|
- **Bake** — converts the current preview into real, permanent mesh geometry, restricted to the
|
||||||
|
painted area. Runs in the background; the button shows *Baking…* while it works.
|
||||||
|
- **Erase all** — clears the active layer's paint.
|
||||||
|
|
||||||
|
Baking is the exact same algorithm as the **Normal** preview, so what you see is what you get.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Controls reference
|
||||||
|
|
||||||
|
### Mouse — 3D view (while painting)
|
||||||
|
|
||||||
|
| Input | Action |
|
||||||
|
|-------|--------|
|
||||||
|
| Left-drag | Paint the active layer |
|
||||||
|
| Ctrl + drag | Rotate / pan the camera (works in seam mode too) |
|
||||||
|
| Wheel | Zoom |
|
||||||
|
|
||||||
|
### Mouse & keys — UV Editor
|
||||||
|
|
||||||
|
| Input | Action |
|
||||||
|
|-------|--------|
|
||||||
|
| Left-click | Select island |
|
||||||
|
| Left-drag | Move island |
|
||||||
|
| Right-drag | Rotate island |
|
||||||
|
| **R** / **S** | Modal rotate / scale (mouse drives it, click or Enter confirms, Esc cancels) |
|
||||||
|
| **Shift** (while rotating) | Snap to global 15° marks |
|
||||||
|
| Middle-drag | Pan |
|
||||||
|
| Wheel | Zoom about cursor |
|
||||||
|
| **Home** / **F** | Frame all islands |
|
||||||
|
| **Ctrl+Z** / **Ctrl+Shift+Z** / **Ctrl+Y** | Undo / redo |
|
||||||
|
|
||||||
|
### Seam mode
|
||||||
|
|
||||||
|
| Input | Action |
|
||||||
|
|-------|--------|
|
||||||
|
| Click edge | Mark / unmark a seam (yellow = hover, red = marked) |
|
||||||
|
| Click (Path mode) | Set start, then seam the shortest path to the next click |
|
||||||
|
| Ctrl + drag | Rotate / pan camera |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tips & limitations
|
||||||
|
|
||||||
|
- **Paint first, then bake.** The preview is free to explore; only Bake changes the real mesh.
|
||||||
|
- **Not enough detail?** Subdivide or Remesh before painting fine textures.
|
||||||
|
- **Inward cuts** (high Midlevel + big Depth) can self-intersect on thin walls or sharp concave
|
||||||
|
corners — keep Depth modest there.
|
||||||
|
- **Fast vs Normal:** Fast preview shades a bump and shows only the active layer; use it for quick
|
||||||
|
tuning and smooth UV dragging, but trust **Normal**/**Bake** for the exact result.
|
||||||
|
- **Topology changes drop unbaked paint.** Subdivide-Apply, Remesh, and Simplify replace the mesh, and
|
||||||
|
texture-displacement paint isn't remapped across that change (already-baked relief is unaffected).
|
||||||
|
- **Island placements** are tied to the current unwrap. Re-painting or changing the Seam angle can
|
||||||
|
re-segment the charts and renumber them, so a re-unwrap re-lays the connected net and discards
|
||||||
|
hand placements made before it.
|
||||||
|
- **Connect islands** is on by default; turn it off (per layer) for the classic packed-grid layout, or
|
||||||
|
if an unfold looks wrong on an unusual mesh.
|
||||||
Reference in New Issue
Block a user