Skip to content

fix(geometry): only extend an opening cap the cutter actually exits through (#3219) - #3340

Draft
louistrue wants to merge 1 commit into
mainfrom
fix/3219-flush-cap-pad-overcut
Draft

fix(geometry): only extend an opening cap the cutter actually exits through (#3219)#3340
louistrue wants to merge 1 commit into
mainfrom
fix/3219-flush-cap-pad-overcut

Conversation

@louistrue

Copy link
Copy Markdown
Collaborator

Fixes #3219.

What was wrong

extend_opening_mesh_through_host pushes an opening cutter's cap past a coincident host facet so a flush interface becomes a clean transversal crossing instead of a coplanar graze (#1007, host #1112). It qualified a cap on coincidence alone.

Archicad exports a wall with Material Preservation: Explode where necessary like this: the wall body lives on aggregated IfcBuildingElementPart layers, every layer's Brep already carries its own window, and all 22 IfcRelVoidsElement sit on the body-less parent wall. ifc-lite propagates them down, which is correct (ifcopenshell's find_openings walks Decomposes() the same way).

So the cutter's side planes coincide with the hole's jambs. Both read as caps, and each was pushed 0.30 * span into the pier beside it. Every window came out scaled by exactly 1.600 with its centre unmoved:

authored gap after cut widening/side 0.30 x authored
1.198 1.924 0.363 0.359
0.595 0.975 0.190 0.178
2.403 3.849 0.723 0.721
1.801 2.891 0.545 0.540

Eleven windows, 9.308 m of extra opening, 6.348 m3 implied against a measured 6.366 m3 loss. The pad accounts for the whole error.

The correct answer, established independently

The subtraction should remove nothing on this model, because the holes are already there. Two independent confirmations:

  • manifold3d puts the intersection volume of every (part, opening) pair at 0.0000 m3.
  • ifcopenshell 0.8.2 logs Eliminated 11 disjoint operands / 11 touching operands and returns identical volume with opening subtraction on and off.

The fix

Coincidence is now necessary but not sufficient. A cap must also lie under the cutter's cross-section footprint and face the way an exit faces: an exit facet faces away from the host material, a jamb faces into the hole because the host continues past it. The facing is area-weighted so facet scatter cannot outvote the real surface.

The sign is read from the host's own signed volume rather than assumed. IFC winding is not reliably outward and the host is not oriented until after the cut (element.rs runs orient_mesh_outward_verdict on the result). Without that term an inward-wound host lost the #1007 clearance push entirely and got the pier-eating back.

Genuine exit caps keep the identical push, so the #1007 clearance is unchanged. The pad, its constant, the flush band and the function signature are all untouched.

part before truth after
#208380 -18.8% 33.8117 -0.0%
#208418 -18.6% 42.7030 -0.1%
#208435 -27.0% 31.3913 +0.0%
#412188 -8.3% 41.6703 +0.0%

Tests

Four, all asserting removed volume or cutter extent rather than mechanism. The existing void tests ray-cast "the wall has a hole", which is monotone in the cut and structurally cannot catch an over-cut — that is why this shipped.

Each was verified to fail on the code without its condition, for the right reason:

test without message
flush_cap_is_not_pushed_into_a_pre_cut_jamb orientation removed 0.9601 m3
remote_coplanar_facet_does_not_outvote_a_local_exit_cap footprint pushed clear by only 0.0000 of a 0.9000 span
an_inward_wound_host_is_read_the_same_as_an_outward_one signed-volume term cutter 1.400 .. 4.600 vs authored 2.000 .. 4.000
flush_cap_on_a_genuine_exit_is_still_pushed_clear pad 0.30 fails at pad=0 and pad=1.5

The last one pins the 30% clearance for the first time: before this PR, pad = 0 and pad = 1.5 both left the entire suite green.

Structure

The decision moved to router/voids/synthesis/exit_cap.rs, reusing OpeningFrame, project_aabb_in_frame, mesh_point and mesh_signed_volume rather than re-deriving them. synthesis.rs drops 1002 -> 950, so its ratchet budget is lowered, not raised; the new module is under the 400 rule and needs no row.

Gates

cargo test -p ifc-lite-geometry --no-fail-fast: 90 binaries, 960 passed, 0 failed, exit 0.
cargo test -p ifc-lite-processing --no-fail-fast: 58 binaries, 289 passed, 0 failed, exit 0.
cargo clippy -p ifc-lite-geometry --all-targets -- -D warnings: exit 0.
Run with all 164 model fixtures fetched, so the fixture-gated void tests actually execute.

Deliberately not fixed here

  1. Two thin IfcCovering layers on the same model come out as torn, non-watertight shells (198 and 662 edges of degree != 2). This survives setting the pad to zero, so it is independent of this change. Follow-up issue.
  2. A host whose winding is genuinely MIXED can still mis-tally a cap. One flipped facet in a two-triangle quad gives an exact area tie, which skips the push. The direction is safe (a rim sliver, never an over-cut) but it is a regression against main for that input class.
  3. The penetration axis itself is chosen badly. infer_opening_frame picks the smallest-extent axis with no authored extrusion direction, which for these 44 m deep, 1.21 m wide cutters picks the width. Flipping the comparator was tried and refuted by measurement (+523% on one part of wall #392764). This PR makes the wrong axis harmless at the point it did damage instead.
  4. accept_cut in coaxial_union.rs asserts "the watertightness of the cut is guaranteed upstream". Measured false: the first subtract takes a closed 188-triangle host to an open 236-triangle one.

Coupling worth knowing

With this fix, roughly 60 remove-nothing outcomes per load of this model now flow through the #635 engulf guard and #964 redundant-void detection instead of being padded into removing real material. Weakening that guard pair would partially re-open #3219.

…hrough (#3219)

`extend_opening_mesh_through_host` pushes an opening cutter's cap past a
coincident host facet so a flush interface becomes a clean transversal
crossing instead of a coplanar graze (#1007, host #1112). It qualified a
cap on coincidence alone.

On a host whose Brep ALREADY carries the hole the cutter's side planes
coincide with the hole's jambs, so both jambs read as caps and each was
pushed 0.30 * span into the pier beside it. Archicad exports a wall with
"Material Preservation: Explode where necessary" exactly that way: the
body lives on aggregated IfcBuildingElementPart layers and every layer's
Brep carries its own window, while all 22 IfcRelVoidsElement sit on the
body-less parent wall and are propagated down. Every window came out
scaled by exactly 1.600 with its centre unmoved. On the reporter's model
that removed 18.8%, 18.6%, 27.0% and 8.3% of four wall layers.

Coincidence is now necessary but not sufficient. A cap must also lie
under the cutter's cross-section footprint and face the way an exit
faces: an exit facet faces away from the host material, a jamb faces into
the hole because the host continues past it. The facing is area-weighted
so facet scatter cannot outvote the real surface, and the sign is read
from the host's own signed volume rather than assumed, because IFC
winding is not reliably outward and the host is not oriented until after
the cut. Genuine exit caps keep the identical push, so the #1007
clearance is unchanged.

The correct answer for this model is that the subtraction removes NOTHING
(the holes are already there), confirmed independently by ifcopenshell
0.8.2 and by manifold3d, which puts the intersection volume of every
(part, opening) pair at 0.0000 m3. The four affected layers now land
within 0.1%.

The decision moved to router/voids/synthesis/exit_cap.rs, reusing
OpeningFrame, project_aabb_in_frame, mesh_point and mesh_signed_volume
rather than re-deriving them; synthesis.rs drops from 1002 to 950 lines,
so its ratchet budget is lowered rather than raised.

Four tests assert removed volume or cutter extent, not mechanism. The
existing void tests ray-cast "the wall has a hole", which is monotone in
the cut and structurally cannot catch an over-cut, which is why this
shipped. They also pin the 30% clearance from both sides for the first
time: pad = 0 and pad = 1.5 both left the whole suite green before.

Not fixed here, tracked separately: two thin IfcCovering layers on the
same model come out as torn, non-watertight shells, which survives
setting the pad to zero and is therefore independent; and a host whose
winding is genuinely MIXED can still mis-tally a cap, which skips the
push and costs a rim sliver rather than an over-cut.
@coderabbitai

coderabbitai Bot commented Aug 27, 2026

Copy link
Copy Markdown

Warning

Review limit reached

  • Run on-demand review

This review includes 6 billable files and costs up to $1.50.

Or wait 6 minutes for your next included review.

View limit details

Limit details: You’ve used the included review currently available. Your 90 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ad8cb448-c8ce-4cd8-95b0-e670e909bdf6

📥 Commits

Reviewing files that changed from the base of the PR and between a4572fd and fe86be9.

📒 Files selected for processing (6)
  • .changeset/exit-cap-orientation-precut-host.md
  • rust/geometry/src/router/voids/synthesis.rs
  • rust/geometry/src/router/voids/synthesis/exit_cap.rs
  • rust/geometry/src/router/voids/synthesis_tests.rs
  • rust/processing/tests/module_size_allowlist.txt
  • rust/processing/tests/module_size_ratchet.rs

Comment @coderabbitai help to get the list of available commands.

@cursor

cursor Bot commented Aug 27, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_4a2c69ad-18fb-4f54-92b4-72805e340a04)

@github-actions

Copy link
Copy Markdown
Contributor

Viewer benchmark

✅ No threshold regressions detected.

01_Snowdon_Towers_Sample_Structural(1).ifc

Baseline recorded 2026-07-01T20:31:05.538Z on github-actions ubuntu-latest, viewer-benchmark-ci (headless Chrome, SwiftShader ANGLE), production build.

Metric Current Baseline Delta Threshold Status
firstBatchWaitMs 1941ms 2905ms -33.2% +50%
firstVisibleGeometryMs 2509ms 3652ms -31.3% +50%
streamCompleteMs 3014ms 3598ms -16.2% +50%
spatialReadyMs 1543ms 1032ms +49.5% +50%
metadataCompleteMs 2073ms 3063ms -32.3% +50%
totalWallClockMs 3100ms 3700ms -16.2% +50%

AC20-FZK-Haus.ifc

Baseline recorded 2026-07-01T20:30:59.972Z on github-actions ubuntu-latest, viewer-benchmark-ci (headless Chrome, SwiftShader ANGLE), production build.

Metric Current Baseline Delta Threshold Status
firstBatchWaitMs 395ms 1075ms -63.3% +50%
firstVisibleGeometryMs 1327ms 1572ms -15.6% +50%
streamCompleteMs 1140ms 1980ms -42.4% +50%
spatialReadyMs 926ms 915ms +1.2% +50%
metadataCompleteMs 1032ms 1392ms -25.9% +50%
totalWallClockMs 1500ms 3300ms -54.5% +50%

Refresh the baseline from a CI run: dispatch the Benchmark workflow with record_baseline, download the benchmark-baseline artifact, and commit baseline.json (see tests/benchmark/README.md).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fe86be9f07

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// convention from the sign of its signed volume instead of assuming one.
// Without this an inward-wound host loses the #1007 clearance push AND
// gets the #3219 pier-eating back, both silently.
let orient = if mesh_signed_volume(host) < 0.0 { -1.0 } else { 1.0 };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Derive winding from the local host component

On a multi-body host whose disconnected components have opposite windings, this uses the net signed volume of every component even though the later footprint test considers only local facets. An unrelated, larger inward-wound component can therefore make orient negative for an outward-wound pre-cut wall; its jamb normals are then classified as exits and receive the 30% extension, reintroducing the geometry over-cut this change is meant to prevent. The winding convention needs to come from the connected component containing the coincident cap facets rather than the whole merged mesh.

Useful? React with 👍 / 👎.

Comment on lines +97 to +100
a.min(b).min(c) <= hi + band && a.max(b).max(c) >= lo - band
};
spans(&self.frame.cross_a, self.lo.x, self.hi.x)
&& spans(&self.frame.cross_b, self.lo.y, self.hi.y)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Test actual projected triangle overlap

For a coplanar oblique facet, overlap of the separate u and v intervals does not imply that the projected triangle intersects the cutter rectangle; for example, a triangle can lie wholly beyond a diagonal separating line while its bounding intervals overlap both axes. Such an off-footprint facet is still included in the area-weighted facing tally, so a sufficiently large remote facet can suppress a genuine exit or falsely turn a pre-cut jamb into one. Use a real projected triangle-versus-rectangle intersection test rather than only these two marginal interval checks.

Useful? React with 👍 / 👎.

@louistrue

Copy link
Copy Markdown
Collaborator Author

Parking this behind #3341

CI caught a corpus-level regression that no local gate could see: Geometry watertightness census, 32 regressed / 10 improved across 112 models. I chased it through four designs. The short version is that the cap heuristic is the wrong place to fix this, and the evidence for that is measured rather than argued.

Four variants, same golden

variant regressed improved unmatched edges (golden 20453) collapsed (golden 50)
winding sign + footprint (this branch head) 32 10 19778 53
winding sign only 30 11 20742 52
footprint only 16 3 20856 51
ray-parity occupancy + footprint 20 10 21859 51

All four fix #3219 (the four affected wall layers go from -18.8% / -18.6% / -27.0% / -8.3% to within 0.1%). All four regress the corpus.

Occupancy is best on regressions and worst on tearing. Seven of its twenty are genuine open-edge increases, not the census misreading a triangle-count drop — ISSUE_129 #9094 goes 723 -> 1674 open edges, #295370 622 -> 986. So they cannot be argued away as a golden artifact.

Why this points at the kernel, not the cap gate

Two independent discriminators — a signed facet normal, and winding-independent ray parity — fix the bug identically and both regress the corpus. That rules out the discriminator being the problem.

What is left is the pad itself. It over-extends by 30% of the opening's span by design (see the calibration note above let pad in synthesis.rs: 5% gives a 74:1 worst rim aspect, 15% a 1250:1 resonance, 30-40% a clean ~25:1). The corpus has come to depend on that over-extension to close tears the kernel would otherwise leave. Stop over-extending anywhere and the tears surface.

That is the defect in #3341: subtract_mesh removes 1.0 m3 from a box that merely touches another, with a deterministic proptest seed and no IFC involved. Fix the kernel's grazing/coincident handling and the pad stops being load-bearing for tear-closing; the cap gate can then be tightened without paying in tears.

Two things I got wrong, recorded so nobody repeats them

  1. The winding assumption. The original signed-normal test assumed outward winding. element.rs does not orient the host until after the cut, and IFC winding is not reliably outward. Reproduced: an inward-wound host lost the Roof Brep with openings renders extra inward-facing faces #1007 clearance push entirely, and an inward-wound pre-cut wall got the full pier-eating back (cutter 1.400 .. 4.600 against an authored 2.000 .. 4.000). I first claimed the failure direction was safe. It is not.
  2. The AABB fallback was never the mechanism. I implemented a guard to suppress the IfcBooleanClippingResult not applied — walls and gable ends show uncut geometry #635 box-cut on a demoted cap. It measured zero effect. The arithmetic said so up front and I did not check it: the fallback fires 11 times across the whole corpus, which cannot account for 32 regressions.

State of this branch

Branch head is the reviewed winding-sign commit. The ray-parity occupancy variant is not pushed — it is a substantial redesign that has not been through /simplify and /code-review, and the pre-flight guard correctly refused to let it out on a stale stamp. It is worth redoing on top of a fixed kernel rather than landing now.

Recommend: land the #3341 kernel fix first, then revisit this with the census as the gate from the start.

@louistrue
louistrue marked this pull request as draft August 27, 2026 12:36
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.

walls/windows are still badly cut

1 participant