Skip to content

fix(brep): standard loop winding, enforced by validate() - #40

Open
Basnederveen wants to merge 1 commit into
OpenGeometry-io:mainfrom
Bas-Automation-Services:fix/standard-loop-winding
Open

Basnederveen wants to merge 1 commit into
OpenGeometry-io:mainfrom
Bas-Automation-Services:fix/standard-loop-winding

Conversation

@Basnederveen

Copy link
Copy Markdown

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_cylinder wind the cap holes like the outer loops, and build the inner wall with the outer wall's loop before setting its sense to Reverse. Their STEP export therefore writes the hole FACE_BOUNDs and the inner wall's bound running the wrong way.
  • Several boolean results that keep those faces (and some cylinder, round-opening and batch results) inherit the same loops.

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 before validate(), 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 for from_json, the bindings or the TypeScript API. Rust code that deserialises a BrepEnvelope with serde directly (bypassing from_json) and then validates a body with non-standard loops now gets InvalidTopology.
  • Straight pcurves on planar faces are measured from their ends, so the check costs little on prismatic bodies.

Found by the new check: generic_closed_loop_handles_periodic_winding (cone ∩ sphere) returns the wrong region on main. The result keeps the sphere cap outside the cone: a point inside both operands classifies Outside, a point inside the sphere and outside the cone classifies Inside. Its cone patch's loop runs against the patch's material normal, so the result is now refused. The test (renamed generic_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, sense of half-edges and outgoing_halfedge of 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.json
  • cases/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}: four ORIENTED_EDGE senses 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 on main, e.g. cylinder, so I could only check them within four ULP, via wasm_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 and same_sense (fails on main).
  • 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 until orient_loops() turns its loops; a body serialised with the old winding loads through from_json() with its loops turned.
  • Gate on x86_64 Windows, 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, and cargo run --release --example budgets (2.3 ms against the 2 s bound). check:api and the browser runs were not run: they need the SDK build, and the TypeScript API does not change.
  • Cost: the winding check adds 2-32 µs per 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.rs
  • boolean result sites: assembly/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.rs
  • main/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])
  • the stored fixtures above

🤖 Generated with Claude Code

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
@Basnederveen

Copy link
Copy Markdown
Author

Heads-up for merging this together with #39: once both are in, #39's test a_face_whose_material_normal_points_into_the_solid_is_rejected fails. With standard winding enforced here, flipping one face's sense is already rejected by validate(), before validate_orientation runs:

InvalidTopology("face 5 ('back') has loops running against its material normal")

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 validate() to pass and validate_orientation to reject it:

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 sphere.orient_loops().unwrap(); before assert_inverted. With this change (and the one noted on #38), all seven open PRs merged together pass the full suite and the source checks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant