Skip to content

Commit bd56861

Browse files
zmerlynnclaude
andauthored
Record normals on Manifold; auto-substitute on GetMeshGL; round-trip via MeshGL (#1718)
* Record normals on Manifold; auto-substitute on GetMeshGL; roundtrip via MeshGLP Following Emmett's #1712 direction: make "where are my normals?" a property of the Manifold rather than something the caller threads through every call. After CalculateNormals(), GetMeshGL() returns solid-frame normals without needing the idx repeated; an ofMesh -> getMesh roundtrip preserves the recording via a new optional MeshGLP::hasNormals flag. The standard slot is the first three extra-property channels (MeshGL channels 3, 4, 5). CalculateNormals / SmoothByNormals / UpdateNormals gain default normalIdx values (0 / 0 / -1 respectively) so the no-arg call is the new preferred form; non-zero values are kept for compatibility but flagged in the docs. Refine clears the recording (interpolated normals are wrong). Bindings updated in lock-step: Python (nb::arg defaults, has_normals field on MeshGL/MeshGL64), WASM (JS wrapper for smoothByNormals, helpers.cpp roundtrips hasNormals, .d.ts type updates), C ( manifold_meshgl{,64}_has_normals accessors). Single-signature shape means no overload disambiguation needed in any binding. Open follow-ups: [[deprecated]] attribute + internal caller migration, SetProperties detection (flagged with TODO). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Record normals on Manifold; auto-apply transforms via runFlags bit 1 Fixes #1712, fixes #1719. Make "where are my normals?" a property of the Manifold rather than something the caller threads through every call. After CalculateNormals, GetMeshGL returns world-frame normals without an explicit normalIdx, and a MeshGL <-> Manifold round-trip preserves the recording per-run even across mixed-input Booleans (e.g., a cube without normals composed with a sphere that has them). The #1712 repro from (sphere - sphere.Translate(10)).CalculateNormals().GetMeshGL() now returns the expected outward-from-cavity normals. The underlying cross-mesh seam-vert sign issue (#1719) is fixed in SetNormals. Mechanism: - runFlags becomes a bitmask: bit 0 = backside (existing), bit 1 = hasNormals (new). New MeshGLP::HasNormals(run) accessor mirrors Backside(run). Backside fixed from `runFlags[run] == 1` to `& 1` so it survives the addition of a second bit. - CalculateNormals(0) marks the per-meshID Relation.hasNormals flag, which Impl::HasNormals() ANDs across to give the impl-wide answer. GetMeshGL(-1) auto-substitutes slot 0 on output when HasNormals() is true. - Normals at the standard slot are stored world-frame on the Impl. Impl::Transform and csg_tree.cpp::Compose eager-transform properties_[0..2] per-meshID so the slot stays in sync with vertPos_ and faceNormal_ across any sequence of transforms, including mixed- input Boolean/Compose outputs where some meshIDs carry normals and others don't. EagerTransformPropNormals is a shared helper. - SetNormals multi-normal walk sign-flips cross(next, here) when it disagrees with faceNormal_[next.face]. This handles Boolean subtractee triangles (winding unchanged, faceNormal_ negated by the Boolean post-pass) without losing the existing "flair" curving for smooth surfaces. This is what fixes #1719. - CreateProperties negates Q's slot 0..2 per source-meshID for subtractee cavity inversion. - GetNormal returns properties_[0..2] directly when the meshID's Relation.hasNormals is true (world-frame already); applies the per- meshID inverse-normal-transform when false, preserving the master contract for hand-built MeshGL inputs that don't set the bit. - Non-zero normalIdx to CalculateNormals uses the legacy deferred- transform path: stores per-mesh-frame, recovered to world-frame by GetMeshGL's per-run transform on export. Docstring notes this path will be removed in a future release. API additions: - MeshGLP::HasNormals(run) C++ accessor plus C and Python bindings. - MeshGLP::Backside(run) C and Python bindings (previously C++-only). Tests: - Properties.CalculateNormals: previously-FIXME'd dot(pos, norm) > 0 asserts enabled (#1719 regression). - Manifold.CalculateNormalsRoundTripsThroughGetMeshGL: ofMesh+getMesh preserving the per-run flag, Rotate before and after CalculateNormals, no-arg invocation, non-zero idx. - Manifold.GetNormalLegacyContract: per-mesh-frame fallback in GetNormal for MeshGLs without bit 1. - Manifold.TransformMixedNormalsPerMeshID, Manifold.ComposeMixedNormalsPerMeshID, Manifold.BooleanSubtractMixedQPerMeshIDNegation: per-meshID iteration in Impl::Transform / Compose / CreateProperties for mixed-input Boolean outputs. - Manifold.CalculateNormalsNonZeroIdxSurvivesTransform: legacy non-zero normalIdx path across post-calc transforms. - CBIND.run_flag_accessors: exercises the new C accessors. Known limitations: - Hand-built MeshGL inputs that share propVerts across runs with differing hasNormals settings produce one-interpretation-wins behavior at the shared propVerts (the stored value can only have one semantic). Standard CalculateNormals / Boolean / Compose outputs never produce this shape. Documented on MeshGLP::HasNormals(run). - Warp / WarpBatch / SetProperties keep the recording set; if the user replaces slot 0..2 or warps non-rigidly, the stored normals are stale until they call CalculateNormals again. Documented on each method. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address PR #1718 review feedback Substantive: - Split CalculateNormalsRoundTripsThroughGetMeshGL into 8 named tests (NormalsCavity, NormalsRotateBefore/AfterCalc, NormalsAutoSubstitute, NormalsRoundTrip, NormalsRefinePreserved, NormalsSmoothByNormalsNoArg, NormalsNonStandardSlotNotRecorded). Pithier names per Emmett's request. - Fix Backside(run) docstring: framework already orients stored normals on the standard flow, the bit is informational only. - SafeNormalize during the eager-transform in Manifold::Impl::EagerTransformPropNormals so non-orthogonal transforms and barycentric interpolation don't leave non-unit values that compound downstream (e.g., at Boolean edges feeding SmoothByNormals). - Add `backside(run)` and `hasNormals(run)` methods on the WASM Mesh class, plus runFlags field documentation in the .d.ts. - Add a deliberately-failing test (NormalsSharedPropVertMixedFlags) that demonstrates the data-model conflict in the shared-propVert + mixed- hasNormals case. Hand-built MeshGL inputs with shared propVerts across runs of differing hasNormals can only store one slot 0..2 value; Impl::Transform picks one interpretation, corrupting the other. Test is intentionally red as a discussion-driver for PR #1718 review thread (whether to fix by splitting propVerts in the Manifold(MeshGL) ctor, or to document the constraint). Trivial: - Rename wasHasNormals -> hadNormals in Impl::InitializeOriginal. - Drop verbose eager-transform comment block in Impl::Transform; the helper call site is self-documenting. - Note AND-not-OR semantics on Impl::HasNormals(); rename hint covered by the new doc. - TODOs at the legacy non-zero-normalIdx codepaths (the getTransform lambda in SetNormals, the legacy fallback in GetMeshGLImpl per-vert normalization) marking them for removal alongside the deprecated API. - Deprecation notice on GetMeshGL/GetMeshGL64's explicit normalIdx parameter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Split shared propVerts across runs with mixed hasNormals Addresses comment 3254084222 on PR #1718. When a single input propVert is referenced by triangles from runs with differing hasNormals flags, the eager-transform contract can't track both interpretations through one slot 0..2: a Transform would rotate the hasNormals=true run's normal and corrupt the hasNormals=false run's color (or vice versa). The MeshGL ctor now duplicates such propVerts so each camp gets its own slot. The first-seen camp keeps the original index; the opposite camp gets an appended alt copy holding a snapshot of the original property data. Subsequent triangles from each camp resolve through their respective indices. DedupePropVerts already skips cross-meshID pairs, so the duplicate survives ctor cleanup, and EagerTransformPropNormals is gated on TriHasNormals per triangle, so only the hasNormals camp's copy gets rotated. Behaviour preserved for the existing numProp == 0 cases (the single-array legacy push pattern is retained when there's no distinct prop space). For numProp > 0 we always populate both triProp and triVert now - prior code skipped triVert when prop2vert was empty, but the split can introduce triProp values >= numVert that need triVert for valid v0/v1 in CreateHalfedges. Docs: re-add a paragraph to MeshGLP::HasNormals (and the WASM hasNormals method) describing the auto-split, replacing the "undefined for hand-built mixed-flag inputs" caveat that ce0e18d removed when it staged the failing test. The new test NormalsSharedPropVertMixedFlags (added in ce0e18d as a discussion-driver red test) now passes; its docstring is updated to describe the fix rather than the failure. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix clang-format on myHasN assignment clang-format v20 (CI) wants the && wrapped onto a continuation rather than aligned after `=`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Revert "Split shared propVerts across runs with mixed hasNormals" Per discussion on PR #1718 (review comment 3254084222): the shared- propVert + mixed-hasNormals input shape is genuinely weird (no standard output produces it) and adding the auto-split to every MeshGL ingest slows the import path - already a sore spot relative to Boolean - and adds maintenance surface for a case users shouldn't be hitting. Reverting cf8d898 (the split implementation + doc changes) and ce6e394 (the clang-format follow-up that touched the same block). Test wording and updated doc explaining the constraint follow in a separate commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Document undefined behavior for cross-run shared propVerts Add a caveat paragraph on `MeshGLP::HasNormals` (C++ header) and the WASM `hasNormals(run)` method noting: - hasNormals is per-run, so different runs may set it differently (leading with this since the original removed-then-not-restored caveat was misread on review as forbidding mixed-flag runs). - Behavior is undefined when a single propVert is shared by triangles from runs that disagree - a Transform rotates the slot on behalf of the hasNormals=true camp and clobbers any hasNormals=false sharer. - Standard CalculateNormals / Boolean / Compose outputs never produce that shape - hand-built MeshGL inputs are the only way to hit it. Repurpose the `NormalsSharedPropVertMixedFlagsUndefined` test (renamed from `NormalsSharedPropVertMixedFlags`) to pin the documented behaviour: assert the hasNormals camp's slot rotates as expected (`run0Bad == 0`) and the no-normals camp's value is clobbered (`run1Bad > 0`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 22571ee commit bd56861

19 files changed

Lines changed: 711 additions & 161 deletions

bindings/c/include/manifold/manifoldc.h

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,8 @@ float manifold_meshgl_tolerance(ManifoldMeshGL* m);
448448
size_t manifold_meshgl_run_flags_length(ManifoldMeshGL* m);
449449
uint8_t* manifold_meshgl_run_flags(void* mem, ManifoldMeshGL* m);
450450
size_t manifold_meshgl_num_run(ManifoldMeshGL* m);
451-
void manifold_meshgl_update_normals(ManifoldMeshGL* m, int normal_idx);
451+
int manifold_meshgl_backside(ManifoldMeshGL* m, size_t run);
452+
int manifold_meshgl_has_normals(ManifoldMeshGL* m, size_t run);
452453

453454
size_t manifold_meshgl64_num_prop(ManifoldMeshGL64* m);
454455
size_t manifold_meshgl64_num_vert(ManifoldMeshGL64* m);
@@ -474,7 +475,8 @@ double manifold_meshgl64_tolerance(ManifoldMeshGL64* m);
474475
size_t manifold_meshgl64_run_flags_length(ManifoldMeshGL64* m);
475476
uint8_t* manifold_meshgl64_run_flags(void* mem, ManifoldMeshGL64* m);
476477
size_t manifold_meshgl64_num_run(ManifoldMeshGL64* m);
477-
void manifold_meshgl64_update_normals(ManifoldMeshGL64* m, int normal_idx);
478+
int manifold_meshgl64_backside(ManifoldMeshGL64* m, size_t run);
479+
int manifold_meshgl64_has_normals(ManifoldMeshGL64* m, size_t run);
478480

479481
// Triangulation
480482

bindings/c/manifoldc.cpp

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -694,8 +694,12 @@ size_t manifold_meshgl_num_run(ManifoldMeshGL* m) {
694694
return from_c(m)->NumRun();
695695
}
696696

697-
void manifold_meshgl_update_normals(ManifoldMeshGL* m, int normal_idx) {
698-
from_c(m)->UpdateNormals(normal_idx);
697+
int manifold_meshgl_backside(ManifoldMeshGL* m, size_t run) {
698+
return from_c(m)->Backside(run) ? 1 : 0;
699+
}
700+
701+
int manifold_meshgl_has_normals(ManifoldMeshGL* m, size_t run) {
702+
return from_c(m)->HasNormals(run) ? 1 : 0;
699703
}
700704

701705
size_t manifold_meshgl64_num_prop(ManifoldMeshGL64* m) {
@@ -775,8 +779,12 @@ size_t manifold_meshgl64_num_run(ManifoldMeshGL64* m) {
775779
return from_c(m)->NumRun();
776780
}
777781

778-
void manifold_meshgl64_update_normals(ManifoldMeshGL64* m, int normal_idx) {
779-
from_c(m)->UpdateNormals(normal_idx);
782+
int manifold_meshgl64_backside(ManifoldMeshGL64* m, size_t run) {
783+
return from_c(m)->Backside(run) ? 1 : 0;
784+
}
785+
786+
int manifold_meshgl64_has_normals(ManifoldMeshGL64* m, size_t run) {
787+
return from_c(m)->HasNormals(run) ? 1 : 0;
780788
}
781789

782790
ManifoldManifold* manifold_as_original(void* mem, ManifoldManifold* m) {

bindings/python/manifold3d.cpp

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -324,10 +324,10 @@ NB_MODULE(manifold3d, m) {
324324
nb::arg("endpoint"),
325325
"Cast a ray segment, returning all hits sorted by distance.")
326326
.def("calculate_normals", &Manifold::CalculateNormals,
327-
nb::arg("normal_idx"), nb::arg("min_sharp_angle") = 60,
327+
nb::arg("normal_idx") = 0, nb::arg("min_sharp_angle") = 60,
328328
manifold__calculate_normals__normal_idx__min_sharp_angle)
329329
.def("smooth_by_normals", &Manifold::SmoothByNormals,
330-
nb::arg("normal_idx"), manifold__smooth_by_normals__normal_idx)
330+
nb::arg("normal_idx") = 0, manifold__smooth_by_normals__normal_idx)
331331
.def("smooth_out", &Manifold::SmoothOut, nb::arg("min_sharp_angle") = 60,
332332
nb::arg("min_smoothness") = 0,
333333
manifold__smooth_out__min_sharp_angle__min_smoothness)
@@ -610,6 +610,8 @@ NB_MODULE(manifold3d, m) {
610610
.def_ro("run_original_id", &MeshGL::runOriginalID)
611611
.def_ro("run_flags", &MeshGL::runFlags)
612612
.def_ro("face_id", &MeshGL::faceID)
613+
.def("backside", &MeshGL::Backside, nb::arg("run"))
614+
.def("has_normals", &MeshGL::HasNormals, nb::arg("run"))
613615
.def("merge", &MeshGL::Merge, mesh_gl__merge);
614616

615617
nb::class_<MeshGL64>(m, "Mesh64")
@@ -723,6 +725,8 @@ NB_MODULE(manifold3d, m) {
723725
.def_ro("run_original_id", &MeshGL64::runOriginalID)
724726
.def_ro("run_flags", &MeshGL64::runFlags)
725727
.def_ro("face_id", &MeshGL64::faceID)
728+
.def("backside", &MeshGL64::Backside, nb::arg("run"))
729+
.def("has_normals", &MeshGL64::HasNormals, nb::arg("run"))
726730
.def("merge", &MeshGL64::Merge, mesh_gl__merge);
727731

728732
nb::class_<RayHit>(m, "RayHit")

bindings/wasm/bindings.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ EMSCRIPTEN_BINDINGS(whatever) {
180180
.function("refine", &Manifold::Refine)
181181
.function("refineToLength", &Manifold::RefineToLength)
182182
.function("refineToTolerance", &Manifold::RefineToTolerance)
183-
.function("smoothByNormals", &Manifold::SmoothByNormals)
183+
.function("_SmoothByNormals", &Manifold::SmoothByNormals)
184184
.function("_SmoothOut", &Manifold::SmoothOut)
185185
.function("_Warp", &man_js::Warp)
186186
.function("_WarpBatch", &man_js::WarpBatch)

bindings/wasm/bindings.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,10 +252,14 @@ Module.setup = function() {
252252
};
253253

254254
Module.Manifold.prototype.calculateNormals = function(
255-
normalIdx, minSharpAngle = 60) {
255+
normalIdx = 0, minSharpAngle = 60) {
256256
return this._CalculateNormals(normalIdx, minSharpAngle);
257257
};
258258

259+
Module.Manifold.prototype.smoothByNormals = function(normalIdx = 0) {
260+
return this._SmoothByNormals(normalIdx);
261+
};
262+
259263
Module.Manifold.prototype.setProperties = function(numProp, func) {
260264
const oldNumProp = this.numProp();
261265
const wasmFuncPtr = addFunction(function(newPtr, vec3Ptr, oldPtr) {
@@ -447,6 +451,16 @@ Module.setup = function() {
447451
mat4[15] = 1;
448452
return mat4;
449453
}
454+
455+
backside(run) {
456+
return this.runFlags != null && run < this.runFlags.length &&
457+
(this.runFlags[run] & 1) !== 0;
458+
}
459+
460+
hasNormals(run) {
461+
return this.runFlags != null && run < this.runFlags.length &&
462+
(this.runFlags[run] & 2) !== 0;
463+
}
450464
}
451465

452466
Module.Mesh = Mesh;

bindings/wasm/manifold-encapsulated-types.d.ts

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -739,10 +739,12 @@ export class Manifold {
739739
*
740740
* @param normalIdx The first property channel of the normals. NumProp must be
741741
* at least normalIdx + 3. Any vertex where multiple normals exist and don't
742-
* agree will result in a sharp edge.
742+
* agree will result in a sharp edge. Default is 0, the standard slot.
743+
* Non-zero values are retained for compatibility and will not be supported
744+
* in a future release.
743745
* @group Smoothing
744746
*/
745-
smoothByNormals(normalIdx: number): Manifold;
747+
smoothByNormals(normalIdx?: number): Manifold;
746748

747749
/**
748750
* Smooths out the Manifold by filling in the halfedgeTangent vectors. The
@@ -846,10 +848,13 @@ export class Manifold {
846848
* Fills in vertex properties for normal vectors, calculated from the mesh
847849
* geometry. Flat faces composed of three or more triangles will remain flat.
848850
*
849-
* @param normalIdx The property channel in which to store the X
850-
* values of the normals. The X, Y, and Z channels will be sequential. The
851-
* property set will be automatically expanded to include up through normalIdx
852-
* + 2.
851+
* @param normalIdx The property channel in which to store the X values of the
852+
* normals. The X, Y, and Z channels will be sequential. The property set will
853+
* be automatically expanded to include up through normalIdx + 2. Default is
854+
* 0, the standard slot; in that case the Manifold records the recording so
855+
* a subsequent getMesh() without an explicit normalIdx returns solid-frame
856+
* normals. Non-zero values are retained for compatibility and will not be
857+
* supported in a future release.
853858
*
854859
* @param minSharpAngle Any edges with angles greater than this value will
855860
* remain sharp, getting different normal vector properties on each side of
@@ -859,7 +864,7 @@ export class Manifold {
859864
* all.
860865
* @group Properties
861866
*/
862-
calculateNormals(normalIdx: number, minSharpAngle?: number): Manifold;
867+
calculateNormals(normalIdx?: number, minSharpAngle?: number): Manifold;
863868

864869
// Boolean Operations
865870

@@ -1336,6 +1341,14 @@ export class Mesh {
13361341
*/
13371342
runTransform: Float32Array;
13381343

1344+
/**
1345+
* Optional: For each run, a bitmask of flags. Bit 0 = backside (this run
1346+
* is on the backside of its original mesh, e.g. from a subtraction).
1347+
* Bit 1 = hasNormals (the first three extra-property channels of this run
1348+
* hold world-frame vertex normals). See `backside(run)` / `hasNormals(run)`.
1349+
*/
1350+
runFlags: Uint8Array;
1351+
13391352
/**
13401353
* Optional: Length NumTri, contains the source face ID this triangle comes
13411354
* from. Simplification will maintain all edges between triangles with
@@ -1429,4 +1442,31 @@ export class Mesh {
14291442
* @param run triangle run index.
14301443
*/
14311444
transform(run: number): Mat4;
1445+
1446+
/**
1447+
* Returns true if this triangle run is on the backside compared to the
1448+
* original mesh, e.g. from a subtraction. Informational only - the
1449+
* framework already orients stored normals so the standard `getMesh()`
1450+
* flow returns world-frame values regardless of this bit.
1451+
*
1452+
* @param run triangle run index.
1453+
*/
1454+
backside(run: number): boolean;
1455+
1456+
/**
1457+
* Returns true if the first three extra-property channels of this run
1458+
* carry world-frame vertex normals (set by `calculateNormals(0)` and
1459+
* round-tripped via `runFlags` bit 1). Consumers should treat the slot
1460+
* as normals and skip re-applying `runTransform` to it.
1461+
*
1462+
* hasNormals is per-run, so different runs may set it differently.
1463+
* Behavior is undefined when a single propVert is shared by triangles
1464+
* from runs that disagree - the slot has one interpretation, and a
1465+
* transform rotates it for hasNormals=true and clobbers any
1466+
* hasNormals=false sharer. Standard `calculateNormals` / boolean /
1467+
* compose outputs never produce that shape.
1468+
*
1469+
* @param run triangle run index.
1470+
*/
1471+
hasNormals(run: number): boolean;
14321472
}

include/manifold/manifold.h

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ class Manifold {
245245
int numProp,
246246
std::function<void(double*, vec3, const double*)> propFunc) const;
247247
Manifold CalculateCurvature(int gaussianIdx, int meanIdx) const;
248-
Manifold CalculateNormals(int normalIdx, double minSharpAngle = 60) const;
248+
Manifold CalculateNormals(int normalIdx = 0, double minSharpAngle = 60) const;
249249
///@}
250250

251251
/** @name Smoothing
@@ -256,7 +256,7 @@ class Manifold {
256256
Manifold Refine(int) const;
257257
Manifold RefineToLength(double) const;
258258
Manifold RefineToTolerance(double) const;
259-
Manifold SmoothByNormals(int normalIdx) const;
259+
Manifold SmoothByNormals(int normalIdx = 0) const;
260260
Manifold SmoothOut(double minSharpAngle = 60, double minSmoothness = 0) const;
261261
static Manifold Smooth(const MeshGL&,
262262
const std::vector<Smoothness>& sharpenedEdges = {});

include/manifold/mesh.h

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -157,17 +157,6 @@ struct MeshGLP {
157157

158158
MeshGLP() = default;
159159

160-
/**
161-
* Updates the normals of the mesh based on the runTransform matrices and
162-
* backside flags, which are then cleared to avoid double-applying them when
163-
* round-tripping.
164-
*
165-
* @param normalIdx Specifies the first of the three consecutive property
166-
* channels forming the (x, y, z) normals to update. NumProp must be at least
167-
* normalIdx + 3 and normalIdx must be >= 3.
168-
*/
169-
void UpdateNormals(int normalIdx);
170-
171160
/**
172161
* Updates the mergeFromVert and mergeToVert vectors in order to create a
173162
* manifold solid. If the MeshGL is already manifold, no change will occur
@@ -235,13 +224,34 @@ struct MeshGLP {
235224

236225
/**
237226
* Returns true if this triangle run is on the backside compared to the
238-
* original mesh, e.g. from a subtraction. In this case vertex normals will
239-
* need to be flipped. UpdateNormals() will take care of this.
227+
* original mesh, e.g. from a subtraction. Informational only - the framework
228+
* already orients stored normals so the standard `getMesh()` flow returns
229+
* world-frame values regardless of this bit.
240230
*
241231
* @param run The index of the triangle run (0 <= run < runFlags.size()).
242232
*/
243233
bool Backside(size_t run) const {
244-
return run < runFlags.size() && runFlags[run] == 1;
234+
return run < runFlags.size() && (runFlags[run] & 1) != 0;
235+
}
236+
237+
/**
238+
* Returns true if the first three extra-property channels (slots 3, 4, 5)
239+
* of this run carry world-frame vertex normals (set by
240+
* `Manifold::CalculateNormals(0)` and round-tripped via `runFlags` bit 1).
241+
* Consumers should treat the slot as normals and skip re-applying
242+
* `runTransform` to it.
243+
*
244+
* hasNormals is per-run, so different runs may set it differently.
245+
* Behavior is undefined when a single propVert is shared by triangles
246+
* from runs that disagree - the slot has one interpretation, and a
247+
* Transform rotates it for hasNormals=true and clobbers any
248+
* hasNormals=false sharer. Standard `CalculateNormals` / Boolean /
249+
* Compose outputs never produce that shape.
250+
*
251+
* @param run The index of the triangle run (0 <= run < runFlags.size()).
252+
*/
253+
bool HasNormals(size_t run) const {
254+
return run < runFlags.size() && (runFlags[run] & 2) != 0;
245255
}
246256
};
247257

src/boolean_result.cpp

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -569,7 +569,8 @@ struct Barycentric {
569569
};
570570

571571
void CreateProperties(Manifold::Impl& outR, const Manifold::Impl& inP,
572-
const Manifold::Impl& inQ, ExecutionContext::Impl* ctx) {
572+
const Manifold::Impl& inQ, bool invertQ,
573+
ExecutionContext::Impl* ctx) {
573574
ZoneScoped;
574575
// Invariant: every ctx-passing parallel op is followed by IsCancelled to
575576
// keep partial output from feeding unconditional downstream consumers.
@@ -608,6 +609,15 @@ void CreateProperties(Manifold::Impl& outR, const Manifold::Impl& inP,
608609
const auto& properties = PQ ? inP.properties_ : inQ.properties_;
609610
const auto& halfedge = PQ ? inP.halfedge_ : inQ.halfedge_;
610611

612+
// For Subtract, Q's triangles are flipped in the result, so Q's
613+
// world-frame vertex normals (slot 0..2 when hasNormals) need a sign
614+
// flip to point outward from the result's solid (into the cavity).
615+
// Check is per-source-triangle, not whole input - inQ may be a mixed
616+
// Boolean result.
617+
const bool negateNormals =
618+
!PQ && invertQ && oldNumProp >= 3 &&
619+
Manifold::Impl::TriHasNormals(inQ.meshRelation_, ref.faceID);
620+
611621
for (const int i : {0, 1, 2}) {
612622
const int vert = outR.halfedge_.Start(3 * tri + i);
613623
const vec3& uvw = bary[3 * tri + i];
@@ -665,7 +675,9 @@ void CreateProperties(Manifold::Impl& outR, const Manifold::Impl& inP,
665675
for (const int j : {0, 1, 2})
666676
oldProps[j] =
667677
properties[oldNumProp * halfedge.Prop(3 * ref.faceID + j) + p];
668-
outR.properties_.push_back(la::dot(uvw, oldProps));
678+
double val = la::dot(uvw, oldProps);
679+
if (negateNormals && p < 3) val = -val;
680+
outR.properties_.push_back(val);
669681
} else {
670682
outR.properties_.push_back(0);
671683
}
@@ -936,7 +948,7 @@ Manifold::Impl Boolean3::Result(OpType op) const {
936948
DEBUG_ASSERT(outR.IsManifold(), logicErr,
937949
"triangulated mesh is not manifold!");
938950

939-
CreateProperties(outR, inP_, inQ_, ctx_);
951+
CreateProperties(outR, inP_, inQ_, invertQ, ctx_);
940952
if (auto c = phase()) return *c;
941953

942954
UpdateReference(outR, inP_, inQ_, invertQ, ctx_);

src/csg_tree.cpp

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,18 @@ std::shared_ptr<CsgLeafNode> CsgLeafNode::Compose(
314314
newProp.end(), numPropOut);
315315
copy(oldRange.begin(), oldRange.end(), newRange.begin());
316316
}
317+
// Properties copy above doesn't go through the on-the-fly transform
318+
// applied to vertPos_/faceNormal_ below; eager-transform slot 0..2
319+
// per-meshID so world-frame normals stay in sync. Covers mixed
320+
// input nodes (some meshIDs with hasNormals, some without).
321+
if (numProp >= 3 && node->transform_ != mat3x4(la::identity)) {
322+
const mat3 normalTransform =
323+
la::inverse(la::transpose(mat3(node->transform_)));
324+
Manifold::Impl::EagerTransformPropNormals(
325+
node->pImpl_->halfedge_, node->pImpl_->meshRelation_,
326+
normalTransform, newProp, oldProp.size() / numProp, numPropOut,
327+
propVertIndices[i]);
328+
}
317329
}
318330

319331
if (node->transform_ == mat3x4(la::identity)) {

0 commit comments

Comments
 (0)