Repository navigation
fix(brep): standard loop winding, enforced by validate() - #40
Basnederveen wants to merge 1 commit into
Conversation
Face loops now follow the standard B-rep / STEP rule (ISO 10303-42): seen against the face's material normal (surface normal times sense), outer loops run counter-clockwise and holes clockwise, so the face lies to the left of every loop. Most producers already did; the tube builders (cylinder_with_circular_hole, annular_cylinder) wound the cap holes like the outer loops and gave the inner wall the outer wall's loop before setting its sense to Reverse, so their STEP export wrote the hole bounds and the inner wall's bound running the wrong way. Boolean results that keep those faces inherited them. - validate() rejects a face whose loops run against its material normal, measured as signed area in UV (continuous across chart seams and inside an intersection pcurve; two loops bounding a seamless band are judged against each other). Straight pcurves on planes are measured from their ends. - orient_loops() (crate-internal) reverses misoriented loops in place. Builder::finish_solid, the sweep builder and the boolean result sites call it before validate(). - from_json() turns the loops of bodies written with the old winding, so saved bodies keep loading. The check refuses the cone-sphere intersection of generic_closed_loop_handles_periodic_winding: on main it keeps the sphere cap outside the cone (a point inside both operands classifies Outside), and its cone patch's loop runs against the patch's normal. The test now asserts the refusal. Stored fixtures edited by hand because their bodies had the old winding: only the half-edges' from/to/next/prev/sense and the vertices' outgoing_halfedge change, every number is byte-identical (tube bodies, validity builders, five boolean-matrix and six batch-matrix results, the annular-first input). The tube STEP texts change in four ORIENTED_EDGE senses each (the two hole bounds and the inner wall's circles). The tube tessellations list the reversed loops' vertices the other way round; eight positions differ by one ULP from sampling the circles in the other direction, and were recorded on x86_64 Windows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VFZvqx1RmXzPtbL9VXP4H7
|
Heads-up for merging this together with #39: once both are in, #39's test That's correct behaviour. A single flipped face can no longer be valid topology, so the orientation check matters for a body that is consistently inside out. The test then becomes: flip every face, re-wind the loops, and expect for face in &mut cube.topology.faces {
face.sense = face.sense.reversed();
}
cube.orient_loops().unwrap();
assert_inverted(&cube, 0);The sphere case keeps its loop and only needs |
What
Face loops now follow the standard B-rep / STEP rule (ISO 10303-42): seen against the face's material normal (surface normal times
sense), outer loops run counter-clockwise and holes clockwise, so the face lies to the left of every loop.Most builders and booleans already did this. Some did not, and nothing checked it:
cylinder_with_circular_hole/annular_cylinderwind the cap holes like the outer loops, and build the inner wall with the outer wall's loop before setting its sense toReverse. Their STEP export therefore writes the holeFACE_BOUNDs and the inner wall's bound running the wrong way.Changes:
validate()rejects a face whose loops run against its material normal. The winding is measured as signed area in UV, continuous across chart seams and inside an intersection pcurve; two loops that bound a seamless band on a periodic surface are judged relative to each other.BrepEnvelope::orient_loops()(crate-internal) reverses misoriented loops in place (half-edge direction, sense and links; pcurves and twins kept).Builder::finish_solid, the sweep builder and the boolean result sites call it beforevalidate(), so producers may build loops either way.from_json()turns the loops of bodies written with the old winding before validating, so saved bodies keep loading. Not breaking forfrom_json, the bindings or the TypeScript API. Rust code that deserialises aBrepEnvelopewith serde directly (bypassingfrom_json) and then validates a body with non-standard loops now getsInvalidTopology.Found by the new check:
generic_closed_loop_handles_periodic_winding(cone ∩ sphere) returns the wrong region onmain. The result keeps the sphere cap outside the cone: a point inside both operands classifiesOutside, a point inside the sphere and outside the cone classifiesInside. Its cone patch's loop runs against the patch's material normal, so the result is now refused. The test (renamedgeneric_closed_loop_with_periodic_winding_refuses_the_outer_sphere_cap) now asserts that refusal; once the boolean keeps the right cap it should go back to asserting a valid result.Stored fixtures
Edited by hand (only
from,to,next,prev,senseof half-edges andoutgoing_halfedgeof vertices; every number is byte-identical), because these stored bodies had the old winding:cases/annular-cylinder.brep.json,cases/cylinder-with-hole.brep.json,cases/validity/builder-annular-cylinder.json,cases/validity/builder-cylinder-with-hole.jsoncases/boolean-matrix/{annular-first (a and result), cylinder-coaxial, cylinder-offset, cylinder-sliced, wall-round}.json(results)cases/batch-matrix/{prismatic.single-round, prismatic.single-round.fallback, staged.mixed-curved-openings.forward, staged.mixed-curved-openings.reverse, vertical-arc.arc-host, vertical-arc.wide-opening}.json(results)Derived outputs of the two tubes:
cases/{annular-cylinder,cylinder-with-hole}.step.{m,mm}: fourORIENTED_EDGEsenses each (the two hole bounds and the inner wall's two circles); nothing else.cases/{annular-cylinder,cylinder-with-hole}.tess.json: the reversed loops' vertices are listed the other way round. Same mesh; eight positions differ by one ULP because the circles are sampled in the other direction. These were recorded on x86_64 Windows. If the digit-for-digit comparison on the golden machine differs in the last bit, they need re-recording there. (The off-CI comparisons already differ on x86_64 Windows onmain, e.g.cylinder, so I could only check them within four ULP, viawasm_core.)Tests
exchange::step::tests::hole_bounds_run_clockwise_about_the_face_normal: reads the exported STEP of both tube builders and checks every circular bound's direction against the plane's normal andsame_sense(fails onmain).brep::validate::tests: cap holes clockwise and outer loops counter-clockwise;validate()rejects the old tube winding; a body with every face sense flipped is rejected untilorient_loops()turns its loops; a body serialised with the old winding loads throughfrom_json()with its loops turned.CI=true:cargo fmt --check,cargo clippy --all-targets -D warnings,cargo test --all-targets(all 16 test binaries pass; the lib suite goes from 373 to 378),wasm-pack test --node(incl.wasm_core's stored STEP and tessellation checks),npm run check:source,check:duplicates,check:cycles, andcargo run --release --example budgets(2.3 ms against the 2 s bound).check:apiand the browser runs were not run: they need the SDK build, and the TypeScript API does not change.validate()on the stored bodies (6-30% of the structural checks, release build).Files
main/opengeometry/src/brep/validate/winding.rs(new),validate/mod.rs,validate/tests.rs(new)main/opengeometry/src/brep/topology/envelope.rs(from_json)main/opengeometry/src/brep/builder/brep_builder.rs,src/operations/creating/sweep/mod.rsassembly/append.rs,batch/mod.rs,batch/prismatic_profile.rs,handlers/{planar/facets, rectilinear/grid/mod, sphere_sphere, vertical_arc_extrusion/cap_provenance, cylinder_cylinder/parallel}.rs,shell.rsmain/opengeometry/src/exchange/step/tests.rs;src/operations/modifying/boolean/tests/generic.rs(the cone-sphere test now asserts the refusal, since the source rules forbid#[ignore])🤖 Generated with Claude Code