Skip to content

fix(#571): the plate approximation now honours the tolerance it was given - #591

Merged
gsdali merged 1 commit into
refactor/381-pass1bfrom
fix/571-plate-approx-contract
Aug 1, 2026
Merged

fix(#571): the plate approximation now honours the tolerance it was given#591
gsdali merged 1 commit into
refactor/381-pass1bfrom
fix/571-plate-approx-contract

Conversation

@gsdali

@gsdali gsdali commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Six bridge functions build a surface with GeomPlate_MakeApproxnot the three #571 lists.
Five of them passed Nbmax = 1 with dmax = tolerance * 10, and between them those two arguments
made tolerance unenforceable.

# site Nbmax dmax
1 OCCTShapePlatePoints (OCCTBridge_Healing.mm) 1 tolerance * 10
2 OCCTShapePlateCurves (OCCTBridge_Healing.mm) 1 tolerance * 10
3 OCCTShapePlatePointsAdvanced (OCCTBridge_ProjLib_NLPlate.mm) 1 tolerance * 10
4 OCCTShapePlateMixed (OCCTBridge_ProjLib_NLPlate.mm) 1 tolerance * 10
5 OCCTSurfacePlateThrough (OCCTBridge_ProjLib_NLPlate.mm) 1 tolerance * 10
6 OCCTGeomPlateSurface (OCCTBridge_ProjLib_NLPlate.mm) caller's maxSegments (default 20) tolerance * 0.1

Sites 1 and 6 are reachable from overloads of one Swift nameShape.plateSurface(through:)
and Shape.plateSurface(points:) — doing one job with contracts 22x apart on accuracy.

Q2 — was the CritOrder = 0 criterion inert?

Yes, and dmax was a dead argument — but not for the reason the issue proposed. The issue
guessed the threshold could never be exceeded. It is exceeded, and nothing happens.

Nbmax caps the Bezier patch count, and 1 is the one value that disarms the algorithm.
AdvApp2Var_ApproxAFunc2Var::ComputePatches derives its cut decision NumDec from myMaxPatches;
every branch needs a sum of at least 2 to fit under the cap, so at 1 NumDec stays 0.
AdvApp2Var_Patch::CutSense then returns 0 whether or not the criterion is satisfied — "the fit
missed" and "the fit is fine" issue the same instruction. The criterion is still computed and still
reported through CriterionError(); it just cannot act:

Nbmax=1 dmax=1e-09  seuil=0.01  critErr=0.0981545   violated=YES | uP=9  vP=9
Nbmax=1 dmax=1e-06  seuil=0.01  critErr=0.0981545   violated=YES | uP=9  vP=9
Nbmax=2 dmax=1e-09  seuil=0.01  critErr=0.00484093  violated=no  | uP=16 vP=16

Sweeping dmax across nine orders of magnitude at Nbmax = 1 gives bit-identical control nets.
Nbmax = 2 is enough; 2 through 100 produce the identical surface on this fixture.

dmax sets that threshold, as seuil = max(Tol3d, 10 * dmax), so tolerance * 10 asked the G0
criterion to accept 100x the requested tolerance. It is not merely dead weight once subdivision
is allowed: at Nbmax = 20 that value reproduces the bad single-patch answer exactly, while
tolerance * 0.1 gives the good one. tolerance * 0.1 makes 10 * dmax == Tol3d — the value the
sixth site already used, so measurement picks it over the other five.

Q1 — did the surfaces move under patch 0019?

No. 54 plate fits fingerprinted either side of 0019, the stock side built by override-linking
the unpatched AdvApp2Var_ApproxF2var.cxx ahead of the archive: every fingerprint identical,
degrees and pole counts identical. Only the reported ApproxError() moved, rising 1.03x to 5.37x
(median 1.15x) as the interior contribution is counted for the first time. At the implicit C1
default the NDMINU floor is already 8, so #522's collapse could not reach these sites — exactly
as #571 predicted. Nothing in the plate family needed re-baselining.

Q3 — should Continuity be explicit?

Passed explicitly, staying GeomAbs_C1. It is the continuity of joins between patches, a
different axis from the constraint order handed to GeomPlate_PointConstraint/CurveConstraint, so
plateSurface(constrainedBy:continuity:) still applies the caller's .g0/.g1/.g2 to the
boundary constraints only and does not forward it to the fit. It is not cosmetic — C0/C1/C2 give
17x17, 16x16 and 21x21 control nets. Only those three are accepted at all: G1, G2, C3 and
CN each throw
AdvApp2Var_ApproxAFunc2Var : UContinuity Error (measured), which is why
occtGeomAbsFromSurfaceContinuity — whose order-1 answer is GeomAbs_G1 — must not feed it.

What changes for callers

before after
worst deviation, 25-point wavy plate at tolerance: 0.01 0.0724 (7.2x the request) 0.0032
control points in U 9 (a single degree-8 patch) 16
plateSurface(through:) vs plateSurface(points:), same input 22x apart identical

Surfaces from these six entry points move; callers holding derived geometry should regenerate
it. maxSegments: 1 is clamped to 2 at the one entry point exposing the cap, since it is not a
coarser request but the value that voids tolerance entirely.

Re-enabled suites

Two plate suites carried .disabled("Plate surface operations cause segfault in OCCT"). 18 tests,
13 consecutive clean runs — and they pass against the pre-fix arguments too, so the annotation
was stale rather than describing anything this change cured. They cover two of the six sites.

Verification

  • swift test: 5049 tests, 3 failures, all in Issue496CylindricalHoleTests and confirmed
    identical on the base commit 5452a73
    — pre-existing, unrelated to plates.
  • Every new test checked by injecting the defect it targets and confirming it fails: old
    Nbmax/dmax (3 tests), re-diverging site 1 from the shared helper (1), removing the
    maxSegments clamp (1), and the curve-constrained boundary deviation (1).
  • Bridge-only: no kernel patch, no OCCT.xcframework rebuild. Package.swift untouched, per the
    convention that the url/checksum bump belongs to the release commit.
  • Scripts/check-bridge-index.py: 680 symbols across 369 classes, 0 stale.

Reproducers, both sweep transcripts and the criterion probe:
Scripts/repro/571-plate-approx-contract/.

Closes #571

🤖 Generated with Claude Code

@gsdali

gsdali commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Review: #591fix(#571): the plate approximation now honours the tolerance it was given

Overview

Six bridge functions build a surface via GeomPlate_MakeApprox, a AdvApp2Var_ApproxAFunc2Var consumer that bypasses GeomConvert_ApproxSurface entirely — so it sat outside every census built around that class, including #571's own original list of three sites (measurement found six). Five of the six hard-coded Nbmax = 1 with dmax = tolerance * 10; together those two arguments made the tolerance parameter unenforceable, not just coarser. This is a genuine bug fix (unlike #589/#590's test-only follow-ups): all six sites now share one helper (occtPlateApproxSurface) with one contract, Nbmax floors at 2, dmax is fixed at tolerance * 0.1, and continuity is passed explicitly.

Correctness — verified directly against OCCT kernel source, not the PR's account of it

Checked all three cited kernel decision points in this project's own pinned occt-src:

  • The seuil = max(Tol3d, 10 * dmax) formula is exact. GeomPlate_MakeApprox.cxx:391-404: double seuil = Tol3d; if (CritOrder == 0 && Tol3d < 10 * dmax) { seuil = 10 * dmax; ... } — functionally identical to max(Tol3d, 10*dmax) as claimed.
  • ComputePatches's NumDec derivation from myMaxPatches matches the cited snippet verbatim, including the exact branch conditions (AdvApp2Var_ApproxAFunc2Var.cxx:391-398): every branch requires NbPatch + NbU/NbPatch + NbV to be at least 2 to fit under the cap, so at myMaxPatches = 1 no branch can set NumDec away from its 0 default.
  • CutSense's degenerate behavior at NumDec = 0 is exact, same code, confirmed at AdvApp2Var_Patch.cxx:1045-1062: if (Crit.IsSatisfied(*this)) { return 0; } else { return NumDec; } — with NumDec forced to 0, "satisfied" and "violated" produce the identical return value, exactly the PR's central claim that the criterion is computed but cannot act.
  • The C0/C1/C2-only continuity ladder is exact. AdvApp2Var_ApproxAFunc2Var.cxx's switch(myContInU) explicitly handles only GeomAbs_C0/C1/C2; every other value (including G1, G2, C3, CN) falls to default: throw Standard_ConstructionError("AdvApp2Var_ApproxAFunc2Var : UContinuity Error") — the exact message quoted in the PR.
  • The "continuity was implicit at all six sites" claim checks out against the pinned header: GeomPlate_MakeApprox.hxx defaults Continuity = GeomAbs_C1, and the pre-fix code at every site omitted the argument entirely (visible in the diff's 6-argument constructor calls), so all six really were relying on that default rather than deciding it.
  • No bridge function silently loses caller control it used to expose. Checked OCCTBridge.h for all six signatures: OCCTShapePlatePointsAdvanced, OCCTShapePlateMixed, and OCCTSurfacePlateThrough never took maxDegree/maxSegments parameters at all (their degree parameter is GeomPlate_BuildPlateSurface's plate-build degree, a different OCCT object from the approximation step) — so hard-coding occtPlateApproxDefaultMaxDegree()/occtPlateApproxDefaultMaxSegments() at those three sites is not a regression in caller-reachable configurability, just a correction of what was already a fixed internal choice.
  • The one site that does forward a caller-supplied cap wires it correctly. OCCTGeomPlateSurfaceShape.plateSurface(points:maxDegree:maxSegments:): traced the parameter order through both the old direct constructor call (GeomPlate_MakeApprox(plateSurf, tolerance, maxSegments, maxDegree, tolerance*0.1, 0), i.e., maxSegments feeding OCCT's Nbmax slot) and the new helper call (occtPlateApproxSurface(plateSurf, tolerance, maxDegree, maxSegments, ...), matching the helper's own (plate, tolerance, maxDegree, maxSegments, continuity) signature) — no positional swap, the rename is faithful.

The dead-argument argument is well-supported, not just asserted

The claim that dmax is inert at Nbmax = 1 is the kind of thing that's easy to state and hard to actually prove — the PR proves it two ways rather than one: (1) the criterion-probe transcript shows critErr/violated=YES unchanged across a 1e-9-to-1e-3 dmax sweep at Nbmax=1, and (2) the full plate-sweep transcript shows bit-identical pole fingerprints (not just "close", an exact digest match) across nine orders of magnitude of dmax at Nbmax=1 on two independent fixtures. It also correctly avoids overgeneralizing — the same transcript shows dmax is not dead once Nbmax allows subdivision (Nbmax=20 with the old tolerance*10 reproduces the bad single-patch answer exactly), so the fix changes dmax too, not only Nbmax, and the PR's own reasoning explains why both were necessary.

The #522 scope question is handled correctly, not glossed over

A PR fixing a nearby OCCT approximator defect right after #522/#570 shipped invites an obvious question: is this actually #522 fallout in disguise? The PR answers it with the same fingerprinting technique used in #590 (stock override-link vs patched, pole-by-pole comparison) and gets a clean negative: all 54 fingerprints identical, only ApproxError() moving as the interior term is counted for the first time — consistent with the stated mechanism (C1's NDMINU floor is already 8, above where #522's collapse could reach). This is the right way to rule out a confound rather than asserting "unrelated" without checking.

Test coverage

  • degenerateMaxSegmentsIsClamped is the test that actually exercises the clamp path via the public API (maxSegments: 1) rather than just asserting the internal helper's arithmetic — this is the one test in the suite that would catch a regression if the < 2 ? 2 : clamp were ever accidentally dropped or inverted.
  • bothEntryPointsAgree directly falsifies the "22x apart" defect described in the PR body — a reviewer's natural follow-up ("did fixing this actually converge the two overloads, or just move both wrong answers together?") is answered by a test, not a claim.
  • curveConstrainedPlateHonoursTolerance deliberately uses an 8-point warped octagon rather than a 4-corner ring specifically because a simple ring is reproduced exactly by one patch and "proves nothing" — this shows awareness of the same fixture trap test(#570): the healing conversions were returning a straight chord through an offset sphere #590 called out (every pre-existing plate test used a near-planar/low-point-count set, which is exactly why this defect went unnoticed for as long as it did).
  • Proved rather than assumed, per this project's established discipline: each new test's docstring states the specific pre-fix value it pins (e.g., "Pre-GeomPlate_MakeApprox drives the #522 approximator directly, and no census counts it #571 this measured 0.0724 — 7.2x the tolerance"), and the PR body states each was confirmed to fail against the unfixed arguments before the fix — matching this project's "inject the defect, confirm the test catches it" bar.
  • Re-enabling the two .disabled(...) suites (18 tests) is handled honestly: the PR states they pass against the pre-fix arguments too, so the stale annotation is correctly not claimed as evidence for this fix — it's reported as an unrelated finding surfaced during the same investigation, which is the right way to report an incidental discovery rather than inflating it into part of the fix's justification.

Documentation

  • The Shape.plateSurface(points:) doc comment and the CHANGELOG both state the maxSegments: 1 → 2 clamp as a behavior change, not a silent implementation detail — a caller reading either surface learns the contract without needing to read the bridge source.
  • Surface-Advanced.md and the cookbook page both add the same "verify, don't assume" framing with the same uPoleCount <= degree + 1 single-patch detection idiom — checked that the wording is consistent across both rather than paraphrased differently, matching this project's established documentation-consistency bar.
  • The CHANGELOG explicitly says surfaces from these six entry points move and callers holding derived geometry should regenerate it — correctly flagged as a breaking behavior change (not just a bug fix with no caller-visible consequence), consistent with the PR title's own "Breaking:" prefix in the CHANGELOG heading.

Risk / merge notes

Verdict

Approve. Every load-bearing kernel claim — the seuil formula, ComputePatches's NumDec derivation, CutSense's degenerate return at NumDec = 0, and the C0/C1/C2-only continuity ladder — checked out exactly against the real kernel source at the cited locations, not just the PR's narrative. The dead-argument claim for dmax at Nbmax=1 is proven with a bit-identical fingerprint sweep rather than asserted, and the PR correctly distinguishes "dead here" from "not dead once Nbmax allows subdivision." The #522-relatedness question is checked with the same override-link fingerprinting technique used in #590 and correctly resolved negative. No blocking correctness, convention, or coverage issues.

…iven

Six bridge functions build a surface with GeomPlate_MakeApprox, not the three
the issue lists. Five of them passed Nbmax = 1 with dmax = tolerance * 10, and
between them those two arguments made `tolerance` unenforceable.

Nbmax caps the number of Bezier patches, and 1 is the one value that disarms
the algorithm. AdvApp2Var_ApproxAFunc2Var::ComputePatches derives its cut
decision from that cap; at 1 every branch leaves it at "do not cut", so
AdvApp2Var_Patch::CutSense returns the same answer whether or not the G0
criterion was satisfied. The criterion is still evaluated and still reported
through CriterionError() -- it just cannot act. Measured on a 25-point wavy
plate at tolerance 1e-2: the criterion came back at 9.8e-2 against its own
1e-2 threshold, violated, and the surface was returned unchanged. So the
issue's question 2 is answered yes, the criterion was inert, but not for the
reason it proposed: the threshold was exceeded and nothing happened, rather
than being unreachable. Sweeping dmax across nine orders of magnitude at
Nbmax = 1 gives bit-identical control nets.

dmax sets that threshold, as seuil = max(Tol3d, 10 * dmax), so tolerance * 10
asked the criterion to accept 100x what the caller requested. It is not merely
dead weight once subdivision is allowed: at Nbmax = 20 that value reproduces
the bad single-patch answer exactly, while tolerance * 0.1 gives the good one.
tolerance * 0.1 makes 10 * dmax == Tol3d, and is what the sixth site used.

All six now share occtPlateApproxSurface. At tolerance 1e-2 the worst deviation
on that fixture goes from 0.0724 (7.2x the request) to 0.0032, and
Shape.plateSurface(through:) and Shape.plateSurface(points:) -- overloads of
one name that were 22x apart on accuracy -- agree exactly. Surfaces from these
entry points move; callers holding derived geometry should regenerate it.

maxSegments = 1 is clamped to 2 at the one entry point that exposes the cap,
since it is not a coarser request but the value that voids tolerance entirely.

Continuity (question 3) is now passed explicitly and stays GeomAbs_C1. It is
the continuity of joins between patches, a different axis from the constraint
order, so plateSurface(constrainedBy:continuity:) still applies the caller's
value to the boundary constraints only. Only C0/C1/C2 are accepted at all --
G1, G2, C3 and CN each throw "AdvApp2Var_ApproxAFunc2Var : UContinuity Error",
measured -- which is why occtGeomAbsFromSurfaceContinuity, whose order-1 answer
is GeomAbs_G1, must not feed it.

Question 1: the surfaces did not move under patch 0019. Fingerprinting the
control net of 54 plate fits either side of it, the stock side built by
override-linking the unpatched AdvApp2Var_ApproxF2var.cxx, shows every one
identical; only the reported ApproxError() rose, by 1.03x to 5.37x, as the
interior contribution is counted for the first time. At the implicit C1 default
the degree floor is already 8, so #522's collapse could not reach these sites,
exactly as the issue predicted. Nothing needed re-baselining.

Two plate suites carrying .disabled("Plate surface operations cause segfault in
OCCT") are re-enabled: 18 tests, 13 consecutive clean runs, and they pass
against the pre-fix arguments too, so the annotation was stale rather than
describing anything this change cured. They cover two of the six sites.

Bridge-only: no kernel patch, no xcframework rebuild. Each new test was checked
by injecting the defect it targets and confirming it fails.

Closes #571

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gsdali
gsdali force-pushed the fix/571-plate-approx-contract branch from 3e3fd62 to 650de30 Compare August 1, 2026 08:32
@gsdali
gsdali merged commit 156771b into refactor/381-pass1b Aug 1, 2026
1 of 2 checks passed
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