Skip to content

Commit c4665af

Browse files
committed
feat: improve CSG output shading
1 parent 027f7e4 commit c4665af

17 files changed

Lines changed: 536 additions & 189 deletions

MC3_FORMAT.md

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -710,8 +710,8 @@ All five are supported by the MeshCraft editor and exported by `mc3togltf`.
710710

711711
### `<uv_mapping>` — per-object UV override
712712

713-
Optional child element on most primitives (box/sphere/cylinder/cone/plane/
714-
cube/torus/capsule/disk/grid/icosphere/mesh/extrude/group/CSG roots),
713+
Optional child element on geometry-producing primitives (box/sphere/cylinder/
714+
cone/plane/cube/torus/capsule/disk/grid/icosphere/mesh/extrude) and CSG roots,
715715
overriding that object's default UV generation.
716716

717717
```xml
@@ -727,9 +727,10 @@ overriding that object's default UV generation.
727727
| `offset_u` / `offset_v` | float | `0.0` | Per-axis UV offset |
728728
| `rotation` | float (degrees) | `0.0` | UV rotation |
729729

730-
**`projection` (`SYS-W14-24`, 2026-07-20):** `"box"` and `"sphere"` are
731-
implemented by `mc3togltf` (editor-viewport parity is intentionally out
732-
of scope — the exported glTF is the ground truth for appearance).
730+
**`projection` (`SYS-W14-24`, `SYS-W14-06`):** `"box"` and `"sphere"` are
731+
implemented by `mc3togltf`. Ordinary primitive viewport projection remains
732+
export-only, but CSG roots use the same generated projection in both the
733+
exporter and live CSG preview.
733734
`"box"`/triplanar picks, per vertex, the dominant axis of that vertex's
734735
normal (or its direction from the mesh's local bounding-box center if
735736
normals are absent) and projects onto the other two axes using **raw,
@@ -741,11 +742,12 @@ mesh's local bounding-box center, normalized to `[0, 1]` on both axes.
741742
`scale_u`/`scale_v`/`offset_u`/`offset_v`/`rotation` are still applied
742743
on top of either regenerated projection, same as for `"planar"`.
743744

744-
Per-object UV mapping is ignored on CSG boolean output (see
745-
[CSG operations](#csg-operations)'s "UV coordinates are not real"
746-
limitation) — it only affects primitives whose geometry is generated
747-
directly, not the Manifold-evaluated result of a `<union>`/`<difference>`/
748-
`<intersection>`.
745+
For a CSG root, `uv_mapping` is applied **after** the Manifold boolean to the
746+
generated result: `"planar"` projects local X/Z, `"box"` uses the dominant
747+
normal axis, and `"sphere"` is the documented equirectangular projection.
748+
Without it, a CSG result defaults to box projection. This is generated mapping,
749+
not a retained authored unwrap from the operands; see [CSG operations](#csg-operations)
750+
for that remaining limitation.
749751

750752
### `<mesh>` — external OBJ file
751753

@@ -832,12 +834,22 @@ Pass `--allow-approximate-csg` (CLI) or enable the "Allow approximate CSG export
832834

833835
**Strict mode is the default** (`allowApproximateCSG = false` in `GltfExporter`; the `mc3togltf_csg_strict` test asserts this): a CSG node containing an unsupported child type fails the whole export with an error rather than silently producing wrong geometry. There is no separate "strict" flag to set — it's simply what happens unless `--allow-approximate-csg` is explicitly passed.
834836

835-
**Limitations of CSG output** (the Manifold-evaluated result mesh, not the approximate-fallback path), per `CsgEvaluator.cpp`'s `manifoldToMeshData()`:
836-
- **UV coordinates are not real** — a texcoord channel is present (same vertex count as positions/normals), but every value is a hardcoded `(0, 0)` placeholder, not an actual UV unwrap. Any material with texture slots (base color, normal, etc.) samples the same texel everywhere on a CSG result.
837-
- **Normals are not preserved from the child geometry** — flat per-face normals are recomputed from each triangle's winding via cross product; there is no smooth-shading / vertex-normal-interpolation option for CSG output.
838-
- Child material assignments are not preserved — the CSG root's material is used for the entire merged mesh, regardless of what materials the children had.
839-
840-
**Investigated: could UV be preserved (STAB-0225)?** Not without a real feature addition. Every `Manifold` fed into a boolean op in `CsgEvaluator.cpp` is built either from Manifold's own built-in primitive generators (`Manifold::Cube`/`Sphere`/`Cylinder`, which carry no UV data at all) or from a `MeshGL` with `numProp = 3` (position-only) for the Torus/Capsule/IcoSphere path — no UV channel is ever fed in for Manifold to carry through the boolean op in the first place. Manifold v3's `MeshGL` *does* support extra per-vertex properties beyond position (and interpolates them across new cut edges during boolean ops), so preserving UVs is technically possible — but it would require rebuilding every CSG-eligible primitive with a UV-carrying `MeshGL` (including writing new UV-aware constructors for the cases currently using Manifold's built-in generators) and handling the interpolated-but-unwrapped seams that boolean cuts create. That's a real, non-trivial feature, out of scope for this stabilization effort — documented as an explicit limitation above rather than attempted.
837+
**CSG output shading/material behavior** (`SYS-W14-06`):
838+
839+
- Manifold calculates vertex normals after the boolean with its 60-degree
840+
sharp-edge threshold. Curved result surfaces shade smoothly; hard edges such
841+
as box corners stay sharp. This is generated result geometry, so it does not
842+
retain arbitrary authored normal vectors from inputs.
843+
- The output always has usable generated UVs: default box projection or the
844+
CSG root's explicit `uv_mapping`. It intentionally does **not** preserve the
845+
operands' original UV seams/unwrapping through new cut surfaces. Retaining
846+
that would require feeding UV-carrying `MeshGL` data for every analytic
847+
primitive and defining seam policy for boolean-created vertices.
848+
- An explicit material on the CSG root remains a full-result override. Without
849+
one, `mc3togltf` restores the Manifold source relation as one glTF primitive
850+
per effective child material, including cut faces associated with that input.
851+
The live preview uses the CSG root material/texture only; its per-child
852+
material split is export-only for now.
841853

842854
---
843855

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ reference.
278278
- Preferences dialog: auto-save interval, snap (translate/rotate/scale), grid spacing, and theme are all persisted (`savePrefsAlg`/`loadPrefsAlg`)
279279
- Headless screenshot: a `.png` path writes a real PNG (`stbi_write_png`); every other extension (e.g. `.ppm`) writes raw PPM (P6) bytes regardless of what the extension actually says
280280
- MCB: as of `SYS-W14-25` (2026-07-20), compression is implemented — `MeshCraft::Mcb::saveToBinary`/`saveToFile` take an opt-in `compress` parameter (default `false`, unchanged output) that zlib-deflates the document payload; `loadFromBinary`/`loadFromFile` transparently detect and decompress it. Requires this build to have been compiled with zlib available (system package, optional — see `THIRD_PARTY.md`); degrades to a clear "requires zlib"/"compiled without zlib support" error rather than misparsing a compressed file or silently ignoring the `compress` request. No editor UI toggle — see `MC3_FORMAT.md`'s MCB section for the full header layout
281-
- CSG export to glTF: evaluated by Manifold (union/difference/intersection). Unsupported child types inside a CSG node (Plane, Disk, Grid, Mesh, Extrude) cause the export to fail with a clear error in default mode. Pass `--allow-approximate-csg` (CLI) or enable "Allow approximate CSG export" (editor) to bypass Manifold and export children separately (debug fallback, geometrically incorrect). CSG result mesh has flat normals; child materials are not preserved (CSG root material is used)
281+
- CSG export to glTF: evaluated by Manifold (union/difference/intersection). Unsupported child types inside a CSG node (Plane, Disk, Grid, Mesh, Extrude) cause the export to fail with a clear error in default mode. Pass `--allow-approximate-csg` (CLI) or enable "Allow approximate CSG export" (editor) to bypass Manifold and export children separately (debug fallback, geometrically incorrect). Real CSG produces smooth normals with sharp creases, generated box/default or root-selected UV projection, and separate glTF primitives for child materials when no CSG-root material overrides them; operand UV unwraps themselves are not preserved through cuts
282282
- No full end-to-end UI-interaction-simulation harness (nothing scripts a sequence of real user clicks/drags through the live application window) — but this understates real coverage: 36 `render`-labeled tests include real pixel-sampling checks against headless `--screenshot` output (fog, light/camera gizmos, look-through-camera, embedded GLB, background/skybox textures, LOD, CSG preview cache, etc. — see `TESTING.md`), and a handful of `unit`-labeled tests drive real ImGui widget frames (drag/click gestures, undo-snapshot timing, scene-hierarchy drag-drop) directly against production widget code with no GL context needed
283283
- AI Assistant: not available on Emscripten or Android builds (`cpp-httplib`/OpenSSL are only fetched/linked when `NOT EMSCRIPTEN AND NOT ANDROID`). The API key is **never persisted to disk** — it lives only in the in-memory UI text buffer for the session, pre-filled from the `ANTHROPIC_API_KEY` environment variable if set, and is not part of the saved preferences file. The **Model** field (default `claude-sonnet-5`) and **Max tokens** (4096–64000, default 32000) are both user-editable in the AI panel and control the outgoing API request directly — there is no fixed/hardcoded model. **Scope** selects what's sent as context: "Full scene" serializes the entire document; "Selection only" serializes just the currently-selected objects (plus all materials/definitions, so references still resolve) and is disabled when nothing is selected. Applying a response that would drastically shrink the scene's object count (more than half, on a scene of 10+ objects) requires an explicit second "Confirm Replace" click rather than applying immediately. Response validation is structural, not semantic: it checks the XML is well-formed, has a `<mc3>` root, isn't empty, and conforms to `mc3.xsd` (element order, attribute types/patterns, ID/IDREF cross-references) — but `mc3.xsd` has no numeric range constraints (no `minInclusive`/`minExclusive` anywhere), so a geometrically nonsensical response (e.g. negative `size`/`radius`) passes validation and applies to the scene as-is
284284
- Model Registry: no thumbnail column (design placeholder only, never implemented — see `m1m2m3.md`); requires the system SQLite3 library on desktop builds (stubbed out, feature disabled, on Emscripten/Android); no sync between multiple registry database files — it's a single local SQLite file at `~/.meshcraft/modelregistry.sqlite3`, not backed up or shared automatically

TESTING.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
_Last updated: 2026-07-26. Counts below were verified against a freshly configured Release build after `SYS-W14-05` embedded-GLB support. This document is derived from the actual `CMakeLists.txt` test registrations and test source files — if it drifts from a freshly configured build's `ctest -N` output, trust `ctest -N`, not this file's claimed count._
44

5-
MeshCraft's tests run through **CTest****183 tests registered** (`ctest --print-labels` label breakdown after the new target: `ai` 1, `commands` 1, `export` 71, `format` 37, `lint` 4, `perf` 2, `registry` 1, `render` 36, `unit` 32), mixing C++ assertion-based binaries and Python/bash subprocess-driven checks against the `mc3togltf`/`mc3tomcb` CLIs and the `MeshCraft` editor binary itself (headless `--screenshot` real-pixel-sampling tests, plus 3 tests that drive real headless Blender for GLB-import verification). `render_display_preflight` verifies an actual `xvfb-run` + `xdpyinfo` X client before the render subset; if unavailable, it reports a CTest skip and CMake disables only the other render-labelled tests. **Known gap:** the "CLI-driving Python tests" table below documents the most significant/representative tests in each category but is not exhaustively 1:1 with all registrations — `ctest -N` and `ctest --print-labels` are authoritative for the complete list.
5+
MeshCraft's tests run through **CTest****184 tests registered** (`ctest --print-labels` label breakdown after the new target: `ai` 1, `commands` 1, `export` 72, `format` 37, `lint` 4, `perf` 2, `registry` 1, `render` 36, `unit` 32), mixing C++ assertion-based binaries and Python/bash subprocess-driven checks against the `mc3togltf`/`mc3tomcb` CLIs and the `MeshCraft` editor binary itself (headless `--screenshot` real-pixel-sampling tests, plus 3 tests that drive real headless Blender for GLB-import verification). `render_display_preflight` verifies an actual `xvfb-run` + `xdpyinfo` X client before the render subset; if unavailable, it reports a CTest skip and CMake disables only the other render-labelled tests. **Known gap:** the "CLI-driving Python tests" table below documents the most significant/representative tests in each category but is not exhaustively 1:1 with all registrations — `ctest -N` and `ctest --print-labels` are authoritative for the complete list.
66

77
---
88

@@ -21,7 +21,7 @@ ctest -N
2121

2222
# Run one test by name (regex match)
2323
ctest -R mc3_commands --output-on-failure
24-
ctest -R mc3togltf_csg --output-on-failure # matches all 4 CSG tests
24+
ctest -R mc3togltf_csg --output-on-failure # matches all CSG export tests
2525

2626
# Run one group by label (format/export/render/registry/ai/commands/lint/perf/unit)
2727
ctest -L export --output-on-failure
@@ -31,7 +31,7 @@ ctest --print-labels # list all labels
3131
ctest --rerun-failed --output-on-failure
3232
```
3333

34-
Expected result: every registered test passes. On 2026-07-26 the fresh Release build reported 183 registrations and passed all of them as two disjoint host runs: 147/147 with `-LE render` and 36/36 with `-L render` under Xvfb. The focused suites are run with at most `-j4` after each change. A failing test prints its assertion/subprocess output inline with `--output-on-failure`; without that flag, CTest only shows pass/fail per test name.
34+
Expected result: every registered test passes. On 2026-07-26 the fresh Release build reported 184 registrations and passed all of them as two disjoint host runs: 148/148 with `-LE render` and 36/36 with `-L render` under Xvfb. The focused suites are run with at most `-j4` after each change. A failing test prints its assertion/subprocess output inline with `--output-on-failure`; without that flag, CTest only shows pass/fail per test name.
3535

3636
Each C++ test binary can also be run directly (bypassing CTest) for faster iteration:
3737

@@ -105,7 +105,7 @@ These spawn the built `mc3togltf`/`mc3tomcb` binaries as subprocesses and assert
105105
| `mc3togltf_csg_export` | `mc3togltf/test/*.py` | Union/difference/intersection evaluated by Manifold and exported as real merged geometry | Exported mesh has expected triangle count / bounds |
106106
| `mc3togltf_csg_unsupported` | `mc3togltf/test/*.py` | An unsupported CSG child type (Plane, Disk, Grid, Mesh, Extrude) is detected and reported | Error names the unsupported type |
107107
| `mc3togltf_csg_nested` | `mc3togltf/test/*.py` | Nested CSG operations (CSG-of-CSG) export correctly | Exported mesh matches expected nested-boolean result |
108-
| `mc3togltf_csg_semantics` / `mc3togltf_csg_mesh_child` / `mc3togltf_csg_stress` | `mc3togltf/test/*.py` | `isCutter`/world-transform/empty-result/material-on-node CSG semantics; a `<mesh>` child inside a CSG node; a deep/many-child CSG stress case | Calibrated vertex-count / structural assertions |
108+
| `mc3togltf_csg_semantics` / `mc3togltf_csg_mesh_child` / `mc3togltf_csg_stress` / `mc3togltf_csg_shading_materials` | `mc3togltf/test/*.py` | `isCutter`/world-transform/empty-result/material-on-node CSG semantics; a `<mesh>` child inside a CSG node; a deep/many-child CSG stress case; generated CSG UVs, smooth normals, and child-material glTF primitives | Calibrated geometry / GLB-buffer / structural assertions |
109109
| `mc3togltf_instance_deform_cache` | `mc3togltf/test/*.py` | Instances of the same definition with different `<deform>` produce separate cached meshes (not incorrectly shared) | Distinct mesh indices per distinct deform |
110110
| `mc3togltf_float_cache_key` / `mc3togltf_geom_cache_key` | `mc3togltf/test/*.py` | Two primitives with close-but-not-equal float dimensions produce 2 distinct meshes (cache key doesn't collide on float rounding); the geometry cache key's full construction is exercised directly | `len(meshes) == 2`; cache-key assertions |
111111
| `mc3togltf_obj_robustness` | `mc3togltf/test/obj_robustness_test.py` | Untrusted OBJ input: out-of-range negative vertex index and an infinite (`1e400`-overflow) coordinate must not crash the exporter | Exit 0, a `Warning:`/`non-finite` message per malformed file, valid mesh still exports geometry, no `null` (non-finite) values in any accessor `min`/`max` |

include/MeshCraft/Renderer/CsgCacheAlg.hpp

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,17 @@ inline std::size_t csgSubtreeHashAlg(const Mc3::Mc3Object& obj, const Mc3::Mc3Do
4747
if (obj.deform) {
4848
hf(obj.deform->scale[0]); hf(obj.deform->scale[1]); hf(obj.deform->scale[2]);
4949
}
50+
// CSG results now generate TEXCOORD_0 from the root's UV projection, so
51+
// changing any mapping parameter must rebuild the cached textured vertex
52+
// buffer even though the boolean topology itself is unchanged.
53+
if (obj.uvMapping) {
54+
hi(static_cast<int>(obj.uvMapping->projection));
55+
hf(obj.uvMapping->scaleU); hf(obj.uvMapping->scaleV);
56+
hf(obj.uvMapping->offsetU); hf(obj.uvMapping->offsetV);
57+
hf(obj.uvMapping->rotation);
58+
} else {
59+
hi(-1);
60+
}
5061
// csgOperation: a Union/Difference/Intersection node's own operation type
5162
// must be part of its hash — otherwise switching an existing CSG group's
5263
// operation (with no geometry change) produces an identical hash and the

0 commit comments

Comments
 (0)