diff --git a/docs/superpowers/issues/2026-07-07-ssx5-singular-review-ledger.md b/docs/superpowers/issues/2026-07-07-ssx5-singular-review-ledger.md index 3ccc527b..a5c9176a 100644 --- a/docs/superpowers/issues/2026-07-07-ssx5-singular-review-ledger.md +++ b/docs/superpowers/issues/2026-07-07-ssx5-singular-review-ledger.md @@ -76,13 +76,119 @@ mark an item `CLAIMED()` before starting it; both sessions edit the same fi → FIXED `bf22dff`: seg-pair window 2·atol → 5·atol, broadphase AABB pad 1·atol → 2.5·atol/box (pairwise 5·atol, consistent). The same-preimage guard absorbs the extra near-miss candidates (non-crossing branch pairs converge onto one preimage ⇒ rejected). Measured cost: candidate counts UNCHANGED on both probes (umbrella 3 AABB pairs → 1 window pair at either window; coverage case 10: 0 pairs at either), shipped c3_pass 0.1 ms (umbrella) / 0.3 ms (case 10) — identical to pre-fix; coverage timings ≤1.0× reference, zero new singularities anywhere. - [x] **L24. Coverage harness zero-singularities expectation is print-only** — CLAIMED(main-session) — [A m8] Turn into an assertion (exit code) so CI catches spurious-singularity regressions. → FIXED `f2f8497`: `check_case` returns coverage-ok AND zero-singularities (SPURIOUS SINGULARITIES line printed on violation; no-reference-points early path included); `__main__` already exits 1 on any False. -- [ ] **L25. Edge-graze transversal arc loss** — [A m6] PRE-EXISTING at `40a79a1` (not from this diff); track separately. -- [ ] **L26. Tangency-witness Ψ refinement evaluates DEHOMOGENIZED control nets for rational input** — discovered during L9 (`51f7c4e`) — `_bez_ssx5.py` `_tangency_witness`/`_check_tangency` (via `_deflate.DeflatedSystem`, `P1c = g1.surface[...,:-1]/g1.surface[...,-1:]`) and `_delta_float_gn` (`eval_surface(P1c, rational=False)`). The deflated system's Ψ = R1−R2 is evaluated from the per-control-point dehomogenized nets — the WRONG surface for non-uniform weights (same disease L9 fixed for the T nets, but Ψ here, not the T minors). Consequence measured: the TOP-level witness FAILS for strongly-rational touches (Ψ_dehom ≠ 0 at the true contact). **NOT currently a soundness bug** — the hull PRUNING uses the rational-correct `psi_vector_net`, and subdivision shrinks cells until the dehomogenized net converges to the rational surface, so the small-cell witness converges and the emitted xyz/stuv is recomputed rationally (`_emit_tangent_roots` line 725). Measured: 4 off-center asymmetric rational touches (weights to 6×) all found to machine precision (st_err ≈ 0). **Cost/risk:** the top-level fast-path witness is lost for strongly-rational input → extra subdivision; and a touch held only by a cell at the size-gate floor (4·unify_tol) could be refined slightly imprecisely (bounded by ~ptol, not observed). **Fix (deferred — cascades > 100 lines):** pass homogeneous nets + rational evaluators into `DeflatedSystem` (touches psi_point/psi_box/jac_point/jac_box, shared with `analyse_deflated_system`) and add a `rational` flag to `_delta_float_gn`. Repro: `/tmp` probes in the L9 session — off-center paraboloid `((s−0.3)²+(t−0.7)²)` rationalized w=(1,2,6) makes `_tangency_witness` on the top cell return `ok=False, 0 roots` while `bez_ssx` still finds the touch after subdivision. +- [x] **L25. Edge-graze transversal arc loss** — CLAIMED(main-session 2026-07-12) — [A m6] PRE-EXISTING at `40a79a1` (not from this diff); track separately. + → FIXED (this commit): re-pinned FIXTURE-FIRST (review-era evidence had expired): `examples/ssx/bez_ssx5_case15.py` — plane vs degree-(1,2) graph patch whose SSI parabola `s = (c/h)(t−t*)² + d0` hugs S1's s=0 domain edge with clearance `d0`, transversal in 3D everywhere (45° at closest approach). Crossing-lifecycle instrumentation (playbook monkeypatches on `_ssx_correct_fixed`/`_march_to_boundary`/`_trace_cell_by_registrations`) located the drop at ONE site — `_march_to_boundary`'s exit commitment — with TWO clearance-dependent modes, both measured: **(1)** d0 ≲ atol·sin_ang (repro 1e-5/1e-4): the predictor pokes past s=0 and the fixed-face Newton stalls at the closest-approach WITNESS (`fres = d0` exactly — the face carries NO Ψ zero), which passed the atol-scale acceptance (`eff_atol = atol·max(fsin,1e-3)` = 7.07e-4) and was committed as an exit vertex; the tracer's strict path certificate (3.2e-12 here) then rejected EVERY fragment → 0 branches + 2 debris points + `reasons=['trace_unverified']` — TOTAL transversal arc loss. **(2)** d0 ≳ atol·sin_ang (repro 1e-3): `fres > eff_atol` refused the exit and the marcher `break`ed unconditionally — both seeds' marches truncated one chord short of the graze → 2 branches with a silent gap (t∈[0.41,0.54], worst 97·atol) and falsely `complete=True`. Root cause: the marcher's exit-commit bar (atol-scale) was inconsistent with the path certificate (roundoff-scale), and a refused exit aborted the march instead of recognizing "the curve does not exit through this face". Fix (marcher altitude, single site): an exit commits ONLY on a certified on-face root (`fres ≤ _strict_ssx_root_tol(S1,S2)` — the same bar the tracer applies per vertex); otherwise retarget the predictor at the interior halfway point (the existing deviating-exit-chord machinery) and keep marching — a graze is walked PAST (the tangent's face component flips sign at closest approach), a stalled genuine exit is retried from geometrically closer inits until Newton certifies; bounded by the existing rejects(25)/h-floor/interior-progress guards, no new escape hatches. Measured: 20-variant sweep (t* lattice+off-lattice × sharpness c ∈ {0.01,1,16} × d0 ∈ {0, ±1e-5, ±1e-4, ±1e-3}) — every inside-graze now 1 transversal branch at 100% analytic coverage (4001/4001; was 0/4001 total loss or 3270/4001 truncated), exact grazes and outside double-crossings unchanged. Case 15 registered in harness ALL_CASES (106/106, exit 0). Tests: `test_edge_graze_transversal_arc_survives[inside-1e-5|inside-1e-4|inside-atol|exact|outside]` (analytic-truth coverage ≤ 5·atol + branch counts + complete + zero singularities/points). Gates: coverage harness exit 0, singular suite green, unit batch 75 passed. Residuals: a march that exhausts retarget capacity (h-floor / interior-progress floor / 25 rejects) still ends as a silent interior truncation (fragment kept, no reason marked) — pathological-only now; fold the "interior truncation honesty" question into L52's status consolidation. The harness's s-slice reference cloud carries no points within |t−t*| < 0.07 of the graze (slice-grid geometry), so mode-2 truncation is pinned by the pytest analytic test, not the harness. +- **CLAIM:** L26 — CLAIMED(main-session; promoted into the case-14 P0 root cause before the deferred P2 sweep). +- [x] **L26. Tangency-witness Ψ refinement evaluates DEHOMOGENIZED control nets for rational input** — discovered during L9 (`51f7c4e`) — `_bez_ssx5.py` `_tangency_witness`/`_check_tangency` (via `_deflate.DeflatedSystem`, `P1c = g1.surface[...,:-1]/g1.surface[...,-1:]`) and `_delta_float_gn` (`eval_surface(P1c, rational=False)`). The deflated system's Ψ = R1−R2 is evaluated from the per-control-point dehomogenized nets — the WRONG surface for non-uniform weights (same disease L9 fixed for the T nets, but Ψ here, not the T minors). Consequence measured: the TOP-level witness FAILS for strongly-rational touches (Ψ_dehom ≠ 0 at the true contact). **NOT currently a soundness bug** — the hull PRUNING uses the rational-correct `psi_vector_net`, and subdivision shrinks cells until the dehomogenized net converges to the rational surface, so the small-cell witness converges and the emitted xyz/stuv is recomputed rationally (`_emit_tangent_roots` line 725). Measured: 4 off-center asymmetric rational touches (weights to 6×) all found to machine precision (st_err ≈ 0). **Cost/risk:** the top-level fast-path witness is lost for strongly-rational input → extra subdivision; and a touch held only by a cell at the size-gate floor (4·unify_tol) could be refined slightly imprecisely (bounded by ~ptol, not observed). **Fix (deferred — cascades > 100 lines):** pass homogeneous nets + rational evaluators into `DeflatedSystem` (touches psi_point/psi_box/jac_point/jac_box, shared with `analyse_deflated_system`) and add a `rational` flag to `_delta_float_gn`. Repro: `/tmp` probes in the L9 session — off-center paraboloid `((s−0.3)²+(t−0.7)²)` rationalized w=(1,2,6) makes `_tangency_witness` on the top cell return `ok=False, 0 roots` while `bez_ssx` still finds the touch after subdivision. + → FIXED (this branch): the tangency witness, Δ refiner, Φ equation selection, and singular trace now consume homogeneous surface nets and evaluate Ψ/Jacobians through the true rational quotient. `_delta_float_gn(..., rational=True)` also independently scales polynomial TΨ equations while validating physical Ψ and normal alignment. Case 14's true rational generator now refines on both surfaces; non-uniform homogeneous scaling and physical-gap controls are pinned by tests. - [x] **L27. Shared-edge overlap claims: corrupt index-paired endpoints poison branches, crossings, and runtime** — CLAIMED(main-session) — USER-REPORTED (corner-sharing bilinear pair, 2026-07-07): two patches sharing three corners + two whole edges meeting at an exact tangency A. CSX's boundary-overlap claims paired t/u/v range endpoints BY INDEX — garbage for curve-on-surface overlaps (a whole edge "overlapping" one surface point) — and shipped unverified: 3 branches ~39000·atol off the SSI, genuine crossings filtered out by the garbage endpoints (one true branch starved to a 0.142 stub), flat-projection variant orders of magnitude slower. → FIXED `5e55b53`: overlap endpoints resolved by point INVERSION (`_invert_point_on_surface`); claims residual-VERIFIED (shipped chord ≤ 2·atol at 5 samples) before acceptance — rejected claims contribute nothing; overlap-curve JUNCTIONS emitted structurally as tangent_points (≥2 distinct OPEN branches, non-collinear, healthy parallel normals, ISOLATED tangency — sin_ang grows past 1e-3 within max(8·atol, 5% arc) along both branches; strips/tangent-rings stay suppressed); F1 endpoint exception scoped to OPEN ends (closed-branch seams still subsumed); tracer collects candidates across all 4 attempts (winner by arc; graze-signature exits — unmatched face exit with tangent ∥ face — keep attempting). SEMANTIC UPGRADE: legacy overlaps case now 2 branches (the TRUE count — the "4-vs-2 pre-existing gap" WAS these corrupt pairings) + its junction tangent_point at (128.25,129.86,0), same feature class as the user's A. User case final: 2 overlap branches residual=0 + 1 tangent_point at A + 0 points, 0.03s; flat variant 0.08s (was minutes). Tests `test_shared_edges_junction_tangent_point[False/True]`; gate 50/50 + harness exit 0 + tangential legacy unchanged + 59 unit. Residual: overlap branches remain 2-point chords (curved genuine overlaps are chord-approximated — pre-existing semantics); junction emission requires the tangency, non-tangent transversal corner junctions of overlap curves emit nothing (none constructed yet). +## New user cases (2026-07-10) + +- [x] **L28. Case 12: coplanar patches partially overlap in a 2D region** — CLAIMED(main-session) — P1 USER-REPORTED (`examples/ssx/bez_ssx5_case12.py`). The current branch/point schema cannot represent the true 2D overlap region and repeated runs have produced different lower-dimensional artifacts. Per Cheng et al. Fig. 8 this is the `#(Δ_B)=∞`, 2-dimensional partial-overlap sub-case. Design the output contract with the user before implementation; candidate contracts include tagged boundary loops, a trimmed-region structure, or paired parameter-space loops. + → FIXED (this commit; contract = APPROVED Option C, review doc §8): NEW `_ssx5_overlap.py` ships `SSXOverlapRegion` exactly as specified — `boundary` loops of `(branch_index, reversed)` refs into `result['branches']`, sample-synchronized closed `uv1_loops`/`uv2_loops`, `normal_agreement` ±1, certified `interior_stuv` witness, `certification` dict in atol units (+`orientation_consistent`); `result['overlap_regions']` is additive and always present. **Rim discovery is self-contained**: all 8 domain edges are sampled (33 coarse, bisection-refined ends, 17-sample dense resample) and point-inverted (damped 2×2 GN, 5×5 seed grid) onto the opposite surface; a rim exists only where EVERY sample certifies ≤ atol — this uniformly covers affine rims (CSX claims) and the curved-UV rims the exact-affine certificate correctly drops, resolving L42's note (the assembler sources curved rims from its own sampling). Tolerance-band corner stubs (measured residual ≈ atol vs ≤ 2e-14 on genuine exact rims) are removed by iterative degree-1 endpoint peeling BEFORE loop walking — case 12 initially assembled 0 loops because a stub at corner C poisoned the first-match walk. Loops close head-to-tail at 2·atol; multiple outer loops become SEPARATE regions with holes assigned by containment (no-silent-caps); orientation = region-left in S1's (u,v) (outer CCW, holes CW), uv2 consistency recorded. Interior witness per the §8 band rule: ≥ 4·ptol/axis from every rim loop in BOTH parameter planes AND residual ≤ atol; no witness ⇒ no region (band stays curve-only). Region rims REPLACE their L27 2-point chords in `result['branches']` (the §8 sampling upgrade, real per-sample stuv on both surfaces); unreferenced overlap branches stay verbatim. **Reason retirement**: CSX exports the typed L42 outcome (`uncertified_overlap_span` + `non_span_truncation`); `_run_csx` types span-only truncations as `overlap_region_unsupported` (not `work_budget`) and sinks the spans; `_SSXSoftBudget.retire_reason` clears the structural reason once every overlap box AND every sunk span is covered by a certified region (never under hard exhaustion); `unresolved_multiplicity` marks now record their Δ-root/cluster/point sites (`structural_sites`) and retire only when EVERY site lies in/near a certified region (region-interior ambiguity = a sample of the represented 2-D C2 set). **Measured**: case 12 → 1 region, 1 loop of 4 rims (2 shared-edge + 2 interior curved-preimage), `complete=True, reasons=[]` (§8's goal); full containment (genuinely non-affine S1, zero claims, 4 curved rims) → 1 region complete; identical patches → whole-domain region complete; L27 skew fixture → 0 regions, complete, junction `tangent_point` + branches unchanged (negative control); u-flipped S2 → `normal_agreement=-1`. **Rational twin**: 1 certified region, overlap/multiplicity reasons retired, `work_budget` legitimately REMAINS — measured rational-CSX capability gap (two boundary faces burn the whole 20k tier with 0 results inside `_find_csx_boundary_zeros`; a third floods to the 128-result cap with no valley confirmation, hence no span typing) — registered as **L55**. **AS-BUILT deviation from §8's test list**: the "region + separate transversal branch elsewhere" sub-case is UNREALIZABLE for exact polynomial/rational pairs — z vanishing on an open 2-D set vanishes identically, so one connected patch pair cannot exactly overlap in a region AND cross transversally elsewhere; documented here instead of a fake tolerance fixture. Tests: 6 `test_overlap_region_*` in the singular gate. Gates: coverage 7×100% exit 0; singular suite green; unit batch + adapters + contracts + boolean2d 171 passed (+3 pre-existing `geom.curves` errors). +- [x] **L29. Case 13: rational sphere patch vs bilinear quad never terminates** — CLAIMED(main-session) — P0 USER-REPORTED (`examples/ssx/bez_ssx5_case13.py`). Identify the exact non-terminating loop with instrumentation before changing behavior; add a failing regression, fix the local soundness defect and missing local budget, and verify the global soft-budget fallback. + → FIXED (this branch): instrumentation showed crossing-less descendants repeatedly paying the same four-plane Φ seed enumeration after the already-certified tangent point had been deduplicated. Ancestor-box attempt caching now makes that search once-per-region, and Φ enumeration/marching spend the shared call budget. The original script terminates with one certified `tangent_point`; its unresolved near-tangent complement is returned explicitly partial rather than falsely complete. +- [x] **L30. Case 14: two rational cone segments never terminate** — CLAIMED(main-session) — P0 USER-REPORTED (`examples/ssx/bez_ssx5_case14.py`). True SSI is one singular tangent branch near degenerate apex rows (`Σ=0` at repeated control points). Identify the exact non-terminating loop with instrumentation before changing behavior; add a failing regression, fix the local soundness defect and missing local budget, and add the global soft-budget fallback without masking the root cause. + → FIXED (this branch): instrumentation isolated the freeze to `_find_ssx_boundary_zeros → bez_csx → _phase2_isolated_search`: an identically collapsed rational apex edge produced 16,385 pseudo-roots, followed by quadratic dedup and interval splitting. CSX now classifies the positive-dimensional curve-parameter fiber directly, uses a bounded point-on-surface solve, and SSX canonicalizes the two apex fibers before tracing the certified tangent generator. The original script terminates with one tangential branch and honest partial status for the unresolved Δ complement. + +## Proactive overlap/rational/no-hang audit (2026-07-10) + +- [x] **L31. High-multiplicity regular tangent valleys pass residual-only branch certification** — CLAIMED(regular-touch-redteam) — For the exact family `S1=(s,t,(t-1/2)^d)` vs `z=0`, d=8/10/12 publishes branches 8–48·atol away from the sole SSI line while `|Psi|` is at roundoff scale; d=4 loses the real line. Root cause: a rank-2 Psi Jacobian plus an unhinted 2-D nullspace lets registration tracing choose the singular transverse direction, and residual magnitude is not a root-location certificate at multiplicity d. + → FIXED (this branch): fixed-face square polishing, a bounded Decimal multiplicity fallback, and a certified partner-direction hint keep the nullspace trace on the exact locus. Degrees 4/8/10/12 each return one tangential line; unresolved multiplicity remains explicit partial. +- [x] **L32. Global SSX budget starts a superlinear 4-D distance-net build before its first charge** — CLAIMED(main-session) — `max_cells=0` still materializes the full squared-distance Bernstein tensor, and the same tensor was built twice. An 16x16 planar pair spent ~9.7 s with reported `cells_processed=0`; larger input can freeze/OOM before the advertised watchdog applies. Preflight the constructor, return immediately at zero allowance, and reuse one net. + → FIXED (this branch): zero allowance returns before setup, non-disjoint inputs precharge a control-product-squared estimate, and the top distance net is built once then propagated by subdivision. +- [x] **L33. NURBS CCX/CSX adapters reset `max_cells` for every span pair** — CLAIMED(budget-redteam) — 12 mocked CCX pairs at max_cells=3 reported complete after 36 cells; 20 CSX pairs at max_cells=2 reported complete after 40. Maintain one aggregate remaining allowance/result cap and sanitize partial span outputs. + → FIXED (this branch): both adapters share remaining cell/result allowances across span pairs, stop on exhaustion, discard overproduction, and propagate partial status. Aggregate mock regressions and the full NCSX adapter suite pass. +- [x] **L34. Post-assembly SSX junction/filter scans escape `max_postprocess_work`** — CLAIMED(main-session) — overlap endpoint pairing and later point/branch/C1 scans continued quadratically after exhaustion (1024 synthetic overlap branches: 5.5 s while `postprocess_work=0`). Charge actual scan sizes and stop conservatively on denial. + → FIXED (this branch): assembly, containment, junction, C1/C3, and final filter scans charge a separate finite call-wide postprocess allowance; denial stops optional scans conservatively while retaining already-certified overlap output. +- [x] **L35. Closest-point degenerate continuation runs outside its cell budget** — CLAIMED(budget-redteam) — with `max_cells=1`, a rank-1 seed consumed the only pop and still entered a 2000-step `trace_equidistant_curve`, reporting complete. Reserve/charge continuation iterations from the same allowance and aggregate NURBS patch budgets. + → FIXED (this branch): continuation iterations spend the same remaining allowance, truncated traces are never published, and NURBS patch aggregation propagates exhaustion. The 48-test closest-point gate passes. +- [x] **L36. CCX/CSX exactness certificates depend on absolute world translation** — CLAIMED(rational-redteam) — translating a 1e-8 exterior CSX near-root or a 5e-4 offset overlap to large coordinates turns it into complete topology; collapsed fibers have the same absolute-scale defect. Center a common Cartesian origin in homogeneous form before residual/identity scaling. CCX also lacks a source-operation cancellation floor and rejects an exact restricted subcurve. + → FIXED (this branch): exactness contexts use a shared Cartesian origin in homogeneous form and scale only local motion; affine restriction certificates include a source-operation cancellation floor. Translated real roots survive, while translated exterior roots, false overlaps, and false collapsed fibers are rejected. +- [x] **L37. A regular tracer duplicates a certified overlap subset** — CLAIMED(main-session) — the L3 skew fixture shipped a full `overlap` plus a `transversal` copy of its second half. Remove only with the established same-location stuv AND xyz containment guards; parameter-far sheets must remain distinct. + → FIXED (this branch): postassembly removes a regular subset only when both interpolated stuv and xyz containment guards hold. The skew fixture now ships the overlap plus its genuine parameter-distinct tangent point, without the duplicate regular half. +- [x] **L38. Fixed 20k nested-CSX scheduling cap makes regular case 11 falsely partial** — CONFIRMED(final-budget-policy review) — internal cut call 86 needed 20,495 cells but 220k of the shared SSX allowance remained; consuming the truncated two-root set would be topology-unsound. + → FIXED (this branch): topology-critical internal cuts receive CSX's established 100k allowance once, clamped by the remaining shared budget; they never repay a discarded smaller attempt. Independent top-boundary faces retain a separate 20k soft-partial cap. Case 11 completes within a 60k global allowance and returns one nondegenerate closed branch with 16/16 objective coverage. +- [x] **L39. CSX depth 50 stops one level-cycle before a known corner-root remainder reaches resolution** — CONFIRMED(overlap review) — the legacy overlap mini-case returned correct geometry but `incomplete=True`; three parameter axes needed depth 53 to certify the interval next to `(t,u,v)=(1,0,1)` root-free. + → FIXED (this branch): the bounded CSX default/helper depth is 64 while cell/result budgets remain authoritative. The direct corner fixture completes in 200 cells, and the legacy SSX case is complete with 2 overlap branches plus 1 tangent junction. +- [x] **L40. Tiered internal CSX retry double-spends work and creates avoidable partial results** — CONFIRMED(final-budget-policy review) — case 11 used 69,913 shared cells when a capped 20k attempt was discarded and repeated, versus 49,913 for one established-cap solve; at `max_cells=60_000` the retry policy falsely stopped partial. + → FIXED (this branch): the two call classes now have separate first-and-only allowances: 20k for independent soft top-boundary probes and 100k for topology-critical internal cuts, both clamped to the shared remainder. The 60k regression is complete and also asserts a nondegenerate closed loop. + ## Refuted / already-fixed during review - Off-curve budget starvation (B C7) — fixed by `3b80072` (skip-exempt budgeting) before/while the reviews ran. - ×1000-scale branch TRUNCATION — pre-existing core-marcher behavior, bit-identical at base (B C11); the ×1000 tangency-detection limit stays an accepted, documented limit (plan Risk 6) — but see L18 for ×10. + +## Independent budget/adapter review (2026-07-12, main-session; `ssx5-singular-hardening` @ `5d05ddc`) + +Source: 11-angle review + main-session verification. Full analysis, measured evidence, verdicts and probe method: `docs/superpowers/plans/2026-07-12-ssx5-budget-review-and-overlap-contract.md` (§10 + appendix). Line numbers refer to `5d05ddc`. User decisions 2026-07-12: overlap contract **Option C** (review doc §8) APPROVED; **schema v2 + adapter status policy** (§6) APPROVED; this L41–L54 registration APPROVED. Coordination: same `CLAIMED()` protocol as above. + +- [x] **L41. Schema v2 + adapter status policy (APPROVED work, tracking)** — P1 — CLAIMED(main-session) — replace `budget_exhausted`/`budget_usage` with `complete: bool` + `status.reasons[]` + `status.work` at `bez_ssx` (reason string at every `mark_incomplete` site; delete unread `hard_exhausted`/`output_counts` publications and dead `stop_when`/`stop_requested`/`max_boxes` knobs); flip `_nccx4`/`_ncsx4` from raise-on-incomplete to always-return-status and migrate the production call sites (`topo/brep/boolean2d.py:80/420`, public `ccx()`, `ssx/boundary_intersection.py:226/253`, `implicitize.py:460`); update `test_bez_ssx6_contract.py` + harness readers. Absorbs review findings 2 (nurbs_ccx default raise crashes boolean2d), 3 (nurbs_csx raises on `parameter_fibers` — repro: collapsed 3-ctrl point-curve on bilinear z=0 patch, verified live), 11 (`csx_max_results=0`/`boundary_csx_max_cells=0`/`csx_max_cells=0` silently promoted to 1 via `max(1,…)` at `_bez_ssx5.py:6322/6332`). Naming evidence: case 12 reports `budget_exhausted=True` at 1.9% budget usage (structural, not budget). + → FIXED (this commit): **Schema v2 shipped** — `result_fields()` now publishes `complete` + `status.reasons`/`status.work` only; `mark_incomplete(reason)` argument is REQUIRED (a missed site is a TypeError, so `complete == (not reasons)` holds by construction); all 24 marking sites classified from measured firings (instrumented runs of cases 12/13/14 + strip/shared-edge fixtures): charge denials/`mark_exhausted`/CSX-tier truncations/c1-c3 truncation → `work_budget`; output/postprocess caps + closing-march truncation → `output_cap`/`postprocess_cap`; max_depth dump → NEW `depth_limit`; boundary fibers → `parameter_fiber`; truncated Δ/Φ enumerations + uncertified Φ-loop paths → `unresolved_tangential_zone`; rank-deficient-root/tangent-cluster/point-only-fallback ambiguity → `unresolved_multiplicity`, EXCEPT ambiguous Δ-roots inside `_overlap_region_boxes` → `overlap_region_unsupported` (the case-12-class signature; both `_emit_tangent_roots` and `_emit_offcurve_tangent_roots` split by box membership); strict-Ψ path-verification failure → NEW `trace_unverified` (two honest additions beyond §6's list, documented in the module header). **Measured**: case 12 → `['work_budget', 'overlap_region_unsupported']` (structural cause now named; L42 should remove the first, L28 empties it); case 13 → `['depth_limit']`; case 14 → fiber + tangential + multiplicity + work; strip fixture (2-D partial overlap) → `overlap_region_unsupported`; L27 shared-edge (1-D curve-only) stays `complete=True, reasons=[]`. **Adapters**: `return_status` default flipped to True (3-tuple always; `return_status=False` = explicit fail-fast opt-in), plus the DEFAULT aggregate `max_cells` now scales with the BVH candidate count (`100k × n_candidates`, shared ledger; explicit values stay absolute) — finding 2's second half: at `5d05ddc` the suite's own `TestNurbsCCXMultiple2D::test_count`/`test_no_false_positives` crashed with the RuntimeError and after a bare flip returned 2/11 intersections on default knobs; now 11/11. **Caller-graph correction (finding 3)**: `boundary_intersection.py:226/253` and `implicitize.py:460` import the OLD `_ncsx` adapter via `csx/__init__` (verified at `5d05ddc` too) — they were never on the v4 raise path; the live v4 callers were `boolean2d.py:80/420` + public `ccx()` (migrated: 3-tuple unpack + RuntimeWarning on incomplete, no behavior change on complete results; tracked examples updated; untracked `examples/csx/*_new.py` WIP scripts still 2-unpack and need `, _status` when next touched). Finding 11: zero `csx_max_cells`/`boundary_csx_max_cells`/`csx_max_results` now refuse without invoking `bez_csx` (its net build precedes its first charge), routed through the existing soft/hard truncation handling; pinned by `test_zero_csx_allowance_knobs_are_honored`. Dead knobs deleted: `solve_zero_dim` `stop_when`/`stop_requested` (machinery + stats key + `c1_pass` read + test fakes), `max_boxes` param (fixed internal `16·max_cells` backstop; sole passer `_emit_offcurve_tangent_roots` dropped — behavior change bounded to sub-16-cell exhaustion corners, sound direction). `_bez_ssx6` guard + contract test verified unchanged-and-green (they consume the CSX-level dict, untouched until L52); coverage-harness SSX reader prints `reasons`. Tests: 7 new (`test_schema_v2_*` × 6 incl. the collapsed-edge fiber fixture at unit-test size, + zero-allowance knobs), ~20 assertions migrated. Gates: coverage 7×100% exit 0 (timings ≤1.0× review baseline), singular suite green, unit batch + ssx6 contract + c1_regular + boolean2d + both exactness contracts = 153 passed; `test_nccx4`/`test_ncsx4` 17 passed (3 pre-existing `mmcore.geom.curves` import errors, documented in the 2026-06-10 handoff §2, untouched). +- [x] **L42. CSX curved-UV overlap fallback** — P0 — CLAIMED(main-session) — `csx/_bez_csx4.py:1487`: overlaps emitted only under `_certify_affine_csx_overlap`; curved-UV EXACT overlaps flood Phase 2 as isolated roots REPORTED COMPLETE. Repro (measured old-vs-new): parabola `[[0.2,0.2,0],[1,1.8,0],[1.8,0.2,0]]` on the bilinear z=0 patch over [0,2]² — tiny: `{isolated:0, overlaps:1}`; HEAD: `{isolated:1679, overlaps:0, budget_exhausted:False, boundary_topology_complete:True}` @33,685 cells; accepted by every downstream consumer incl. ssx6's guard. Port CCX's non-affine fallback (or emit honest partial). **PREREQUISITE for L28** — region rims are assembled from CSX overlap claims and curved rims are exactly what the affine-only certificate drops. + → FIXED (this commit): a valley-confirmed pair whose exact-affine certificate fails now arms a bounded Phase-2 fallback allowance (`_NON_AFFINE_OVERLAP_FALLBACK_CELLS = 4_000` — CCX's pattern at CSX 3-var cell pricing), and the outcome is tested for the continuum signature (>12 in-span roots at ≤4·ptol_t consecutive spacing — catches a SHORT exact overlap that completes under the cap and would otherwise revive the completeness lie); fallback exhaustion OR the chain sets `budget_exhausted=True` AND `boundary_topology_complete=False` (an uncertifiable positive-dimensional boundary structure must never drive SSX topology silently). NO span exclusion, NO tolerance merging — the sub-atol-valley invariant holds: a benign near-tangent valley still yields its genuine isolated roots under the cap, forms no chain, and stays complete (`test_bounded_newton_stall_near_tangent_is_not_a_distinct_root` green). Measured on the repro: 1,679 roots @33,685 cells COMPLETE → 214 roots @4,006 cells, partial + topology-incomplete; affine control (diagonal on the same patch) still certifies `overlaps:1`, complete. Gate risk measured ZERO before landing: across all 7 coverage cases (full SSX + 1,400 reference CSX slices) the valley check confirmed exactly 2 pairs, both failing endpoint polish BEFORE the certificate — the fallback path is unreachable by gate geometry (coverage 7×100% exit 0 at unchanged timings; case 12 unchanged: same reasons/rims/4,702 cells — its truncation lives in the boundary-zero phase, not the valley path). Test: `tests/test_bez_csx4.py::test_curved_uv_exact_overlap_is_not_reported_complete`. **NOTE for L28:** curved rims still ship NO overlap claim — this fix stops the lie but does not represent the rim; the region assembler must source curved rims from its own sampling (§8's "properly sampled" fold-in) or extend the claim schema then. +- [x] **L43. C3 broadphase pair-test pricing** — P0 — CLAIMED(main-session) — `_ssx5_singular.py:1356-1363`: every vectorized AABB pair test charged 1 shared cell (~ns of numpy priced like ~ms of subdivision). ~707 polyline segments → ~250k pair charges = entire default budget on ~10 ms of numpy → C3 truncated mid-scan + spurious `budget_exhausted`; c3 is already the #2 cell consumer on regular gates (case 6: 18,731 of 56,552). Price as pairs/128 per the `precompute` convention. + → FIXED (this commit): the tile pair-count charge is now `max(1, (pair_count + 127) // 128)` (the `precompute` convention); `pairs_processed` stats keep raw counts; `_process_pair`'s seg_dist/Newton/anchor/dedup work (the real cost) keeps 1:1 pricing. Measured: case 6 `c3` charge 18,731 → 348 (total 56,552 → 38,169 cells — the recovered headroom is exactly the mispriced numpy), coverage timings unchanged, complete=True. Test: `test_c3_broadphase_pair_pricing_is_batched` — two far-apart 420-vertex branches (~350k raw pairs) previously exhausted the 250k default on zero candidates; now `work_processed < 20k`, `budget_exhausted=False`. +- [x] **L44. Point-dedup n² precharge skips dedup on denial** — P0 — CLAIMED(main-session) — `_bez_ssx5.py:7832-7835`: charges `len(points)²` postprocess units for the rewritten O(n·108) bucketed `_deduplicate_ssx_points`; at the 1024 output cap that is 1,048,576 > 250k default → denied → exactly the dense pseudo-root outputs the rewrite targeted ship UN-deduplicated, with a spurious partial flag. Charge the actual bucketed cost. + → FIXED (this commit): NEW `_point_dedup_charge(n) = max(1, (n·108 + 127)//128)` (3⁴ param + 3³ xyz neighbor probes per point at the shared per-128 rate) replaces the n² quote at the site; at the 1024 cap the charge is ≤ 1024 units (was 1,048,576) and the dedup RUNS. Test: `test_point_dedup_charge_is_linear_and_dedup_runs_at_output_cap` (charge fits a default budget + a 1024-point duplicated cloud collapses 2×512→512). +- [x] **L45. NaN chain: inverted strict gates + floor crash** — P0 — CLAIMED(main-session) — `_bez_ssx5.py:606` (and `:4696`) written reject-if-greater (`if pres > tol: continue`) so a NaN residual is ACCEPTED (unsound direction); `_bez_ssx5.py:305` `math.floor` in the binned dedup raises `ValueError` on NaN / `OverflowError` on inf (crash reproduced by direct call; legacy comparison dedup was NaN-safe). Trigger: rational eval with w→0 on a face → garbage certified crossing or whole-call crash after budgets were spent. Fix: accept-if `pres <= tol` + `np.isfinite` guards at both sites. + → FIXED (this commit): both gates rewritten accept-if with finiteness guards — boundary polish (`not (isfinite(pres) and pres <= strict_tol) → continue`) and the tracer's strict-path vertex verification (same pattern); the binned dedup keeps non-finite points UNBINNED and unique (`_keys` returns None → point kept verbatim — the legacy NaN-safe contract), so a w→0 upstream eval can no longer crash a completed run or ship a garbage certified crossing. Tests: `test_point_dedup_is_nan_safe` (NaN/inf points survive, finite dup still collapses, no ValueError/OverflowError) + `test_boundary_polish_gate_rejects_nan_residual` (all-NaN polish ⇒ zero certified crossings; pre-fix every one was accepted). Pre-fix failure of each is an arithmetic certainty (`math.floor(nan)` raises; `nan > tol` is False). +- [x] **L46. Closest-point: budget signal dropped at NURBS level + interior starvation** — P0 — CLAIMED(main-session) — `_bez_closest_point.py:1272` (+1156): aggregators pass no `stats=`, no shared/adjustable allowance; a 20k-capped patch only `warnings.warn`s and returns far-local-min entities merged as the certified globally-closest set (band-semantics contract violated silently). Plus `:843`: the 4 boundary searches can starve the interior heap to zero pops under the new single shared budget (old code gave the interior its own allowance). + → FIXED (this commit): `nurbs_curve_closest_points`/`nurbs_surface_closest_points` gained keyword-only `max_cells=`/`stats=` — ONE shared allowance across all Bézier spans/patches (default: per-piece 20k × piece count, the L41 candidate-scaled convention; explicit values absolute), spend-through per piece, aggregate `cells_processed`/`budget_exhausted` published via `_publish_work_stats`; a consumer of the band-semantics promise can now detect a capped sub-solve instead of silently merging far-local-min entities. Interior starvation: the best-first heap keeps its OWN pop allowance (`pops >= max_cells` instead of `boundary_cells + pops >= max_cells`); boundary exhaustion still marks the result capped but no longer zeroes the interior; work counters report the honest total (≤ 2·max_cells; the L37-era pin `test_surface_closest_shares_one_seven_cell_budget` updated to the new contract: allowances [7,6,5,4] preserved, surface_cells 3→7, total 7→11). Trace-phase accounting intentionally unchanged (still shared — its `trace_capped` flag stays honest). Demonstrator: `test_interior_search_not_starved_by_boundary` — a boundary phase that burns the whole allowance AND reports one far corner entity used to ship that corner as the certified closest set while the true interior bowl minimum (d=0.5 vs 1.6) was silently absent (the paranoia center-Newton fallback only fires on an EMPTY entity set, so it masked nothing here); now the interior minimum ships. + `test_nurbs_surface_aggregator_propagates_budget_signal` (shared allowance reaches the patch; aggregate exhaustion published). Closest-point suite 50 passed. +- [x] **L47. CCX near-coincident / non-affine coincident overlap semantics** — CLAIMED(main-session 2026-07-12; USER DECISION: residual tier, Option A) — P1, DECIDE-FIRST — `ccx/_bez_ccx4.py:898`: overlap promotion narrowed from tolerance/Newton-based to exact-affine identity. Repro (re-run 2026-07-12): cubic vs itself offset 1e-9 @atol=1e-3 — old: `overlaps=[(0,1)]`; HEAD: 0 isolated + 0 overlaps @2,015 cells with `budget_exhausted=True` (failure misbilled to budget). For an offset pair "empty" may be the intended exact-semantics answer; for a same-locus non-affine reparameterization the overlap is real and unrepresentable. User decides the exact-vs-tolerance contract, then apply the L42-style fallback + reason-correct status here. + → FIXED (this commit; contract = USER-APPROVED residual tier): overlaps now carry `certification: 'exact'|'tolerance'`. The exact-affine identity path is UNCHANGED (`'exact'`). NEW `_tolerance_overlap_certificate`: (1) endpoint pre-gate — the four domain-end inversions (`_project_point_on_curve`, coarse-scan + damped 1-D GN) must pin an admissible span at ≤ atol on both ends (interior-ended bands stay unpromoted); (2) 65 dense samples across the span, every C1 point inverted onto C2 at residual ≤ atol with monotone pairing; (3) **sub-tolerance-topology guard (CSX invariant, 1-D form)**: a transverse-direction FLIP between consecutive gap-scale samples = crossing structure ⇒ promotion REFUSED and the brackets handed to `_strict_polish_ccx` for isolated-root certification. An unsound variant was caught during implementation and removed: flagging a roundoff-scale residual DIP as an interior root — false positive wherever the curve tangent is parallel to the offset direction (measured: the y-offset cubic dips to (1e-9)²·κ at its vertical-tangent point; a non-flipping exact touch inside the band is legitimately covered by the overlap). Reason-correct status: the bounded 2k fallback now ALSO arms on pre-gate BAND evidence (not just classifier-OVERLAP — measured: the non-affine exact case classifies BOUNDARY_ZERO kind=4, and the one-sided t⁸ fixture previously ground 100k cells/108 s), where band evidence = a qualifying domain end whose INWARD probes (2%/5% into the domain, re-inverted) stay ≤ atol — a mere corner CONTACT (consecutive boolean2d loop edges sharing a vertex within atol) is NOT band evidence and keeps the full phase-2 budget (a raw hit-count gate would have silently capped every shared-vertex pair at 2k cells); `_finalize` exports the typed L42-mirror outcome — `uncertified_overlap_span=(u_lo,u_hi)` + `boundary_topology_complete=False` — whenever the overlap-class structure stayed unpromoted AND the fallback exhausted. **Measured**: offset twins 1e-9 (both orientations) → 1 `'tolerance'` overlap (0,1)↔(0,1) or (0,1)↔(1,0), complete, isolated=[]; non-affine exact reparam q(s)=(s²+s)/2 (deg 2 vs deg 4) → 1 `'tolerance'` overlap, residual_max ≤ 1e-9, complete; interior ±1.2e-4 double-crossing inside an atol band → 0 overlaps + the 2 transversal roots (u≈0.4/0.6) via bracket polish, complete (invariant held); curve1-vs-curve2 (the historical "legacy overlap": same path to ~3e-9 but WOVEN — genuine crossings at fitting-noise amplitude, ~1e-9-parallel so below strict certification) → typed span (0, 0.8276) + topology-incomplete at 2,004 cells (honest partial; was misbilled bare budget); NURBS adapter end-to-end: `nurbs_ccx` on the twins → overlap u=(0,1) v=(0,1), `complete=True`, 8 cells — boolean2d's shared-edge 'AB' merge restored. Tests: 7 new in `tests/test_bez_ccx4.py` (`test_near_coincident_pair_ships_tolerance_overlap[same-dir|reversed]`, `test_exact_affine_overlap_certification_is_exact`, `test_non_affine_reparameterized_exact_overlap_certifies`, `test_interior_crossings_inside_tolerance_band_stay_isolated`, `test_uncertifiable_overlap_class_reports_typed_span_not_bare_budget`, `test_realistic_woven_near_coincident_reports_typed_span`). Gates: ccx4 18 passed; unit batch + nccx4 + ncsx4 + boolean2d green; SSX gates run in full — CSX DOES nest CCX (`_bez_csx4.py:29/665`, boundary isocurve calls) and the singular suite caught a pinned-behavior IMPROVEMENT: the rational case-12 twin's `work_budget` reason retired (see L55 → RESOLVED; `test_overlap_region_rational_twin_case12` pin updated to `reasons == []`); harness 8 cases exit 0. **Residuals (documented scope)**: woven near-coincident twins (crossings at noise amplitude) stay typed-partial rather than merging — promoting them needs a user-decided crossing-amplitude band bar (< atol) declaring sub-band flips "coincidence noise"; partial spans with BOTH ends interior to both domains are never candidates (no domain-end pin); `certification`/`uncertified_overlap_span` are bez-level only (the `_nccx4` structured dtype and NURBS-frame span mapping deferred to L52's status consolidation). +- [x] **L48. param-tol semantic shift reaches legacy consumers** — CLAIMED(main-session 2026-07-12) — P1 — `_nurbs_param_tol.py:541/565`: ALL non-uniform-weight rational inputs now take the conservative bound (was: negative-weight only) + rewritten optimistic formula. Measured: rational quarter-circle ptol shrinks 1.75×; the same arc translated to (1000,2000) grows ~350× (2.7e-4 vs 7.6e-7). Untouched consumers shift acceptance/dedup radii with zero coverage: `_bez_csx3.py:1661/1665` (public `ssx()` path), `closest_point.py:745`. Add legacy-pipeline regression tests or a consumer-scoped tolerance policy. + → FIXED (this commit; test-only pins, no production change): re-measurement CORRECTED the finding's reading — the NEW conservative bound is translation-INVARIANT (radius-1 rational quarter-circle: 2.7346e-4 at the origin AND at (1000,2000)); the review's 7.6e-7 was the OLD optimistic dispatch at the translated position, spuriously shrunk ~350× by homogeneous-derivative magnitudes scaling with |coordinates|. So the semantic shift ENLARGED legacy acceptance/dedup radii at large coordinates back to sane values; the exposure was that no test pinned either the dispatch or the consumers. NEW `tests/test_nurbs_param_tol_regression.py` (5 tests, 0.5 s): function-level pins — non-uniform rational curve → conservative 2.7346e-4, translation-invariant to rel 1e-9; uniform-weight → optimistic 1e-3, invariant; non-uniform rational surface → (2.2295e-4, 7.0711e-4), invariant — and consumer-level pins at origin AND (1000,2000): `_bez_csx3.bez_csx` (the `nurbs_ssx` public path's tol adapters) rational arc × plane → exactly 1 isolated root, same t to 1e-6; `closest_point.bez_curve_closest_point` (Bez-tree node ptol at :745) → t*=0.5 both positions. Measured outcome: both flagged consumers are translation-stable under the new bound — the shift broke nothing; it is now guarded. Consumer-scoped tolerance policy judged unnecessary on this evidence. +- [x] **L49. Cut-face `parameter_fiber` drop (unflagged)** — CLAIMED(main-session 2026-07-12) — P2, FIXTURE-FIRST — `_bez_ssx5.py:5986` + multi-cut loops (7207/7256) read only `result['isolated']`; the fiber-producing CSX path returns `budget_exhausted=False` → silent branch loss through interior pinches (case-14 loss class, one consumer over; flagged independently by two review angles). Build a pinched-interior-isoline fixture, then one shared CSX-result adapter for all three consumer sites. + → FIXED (this commit; fixture-first, honesty-scoped): built the exact interior-pinch fixture — middle control row `R1 = 2P − (R0+R2)/2` collapses the s=0.5 isoline EXACTLY to the point P (S_t ≡ 0 along it), P on the plane z=0; guided cuts through the pinch hand the collapsed isoline to cut-face CSX. **Measured**: 12+ nested CSX calls return `parameter_fibers` with `isolated=0, budget_exhausted=False` — dropped without a trace at the consumers (the flagged mechanism, live). **Branch loss did NOT reproduce** across 3 fixture variants (single residual curve; two-lines-through-pinch X; sharpness sweeps): the residual SSI is seeded from domain-boundary crossings and traced through P; the c1/c3 machinery types the structure (`cusp_curve` + 2 `self_intersection`s at the X). Two real defects measured instead: (a) the silent fiber drop (no reason, inconsistent with the boundary path's policy at the `mark_incomplete(REASON_PARAMETER_FIBER)` site), (b) a PERMANENT `work_budget` misbilling — reasons=['work_budget'] persists at `max_cells=1,000,000` with only 5,693 cells actually spent (a bounded c1-enumeration tier truncates on the positive-dimensional Σ line; that reason-classification item is §11.6's de-budget/C1-tier audit, tracked with L52, NOT this fix). Fix (a) at the consumer altitude: NEW `_surface_cut_face_fibers(csx_result, budget)` mirrors the boundary policy — marks `REASON_PARAMETER_FIBER` whenever a cut-face CSX returns fibers — wired at BOTH live multi-cut loops; the isolated flow is unchanged; NO interior-fiber canonicalization attempted (no loss evidence; risk > benefit — revisit only with a loss repro). Caller-graph correction: the ledger's "three consumer sites" include `_csx_on_cut_face`/`_isoline_csx_to_global` via `_midpoint_split` — measured DEAD code (no callers; handoff §3 already listed it) — left untouched for L52's consolidation. Test: `test_interior_pinch_cut_face_fibers_are_surfaced` (fiber reason named + cusp_curve typed + both analytic lines 101/101 covered ≤ 5·atol). Gates: singular suite green, harness 8 cases exit 0. +- [ ] **L50. Φ∩L seed cache degenerates to a top-cell bit** — P2, FIXTURE-FIRST — `_bez_ssx5.py:6921` (+6549): for depth>0 cells the cache key is rewritten to the top cell, so the first productive pass marks all of [0,1]⁴ seeded — a second geometrically distinct tangency system's crossing-free loops would never be Φ∩L-seeded (the branch-loss class the machinery exists to prevent). Build a two-tangency fixture; key the cache on the tangency system/region, not the ancestor box. + **2026-07-12 fixture-first investigation (main-session; mechanism CONFIRMED by reading, loss mode NOT reproduced — left open):** the over-claim is real at HEAD (`_seed_cell = top_cell if cell.depth > 0`; a productive pass appends `top_cell.box` = all of [0,1]⁴, so `_phi_already_seeded` blocks every later cell; the per-anchor `phi_seed_attempts` cache does NOT block distinct systems — only the box cache does). Three two-system fixtures failed to REACH the double-slice state: (1)+(2) product of two lifted dips (`fA·fB`, dips ~3e-4 sub-atol, two eps settings) — Φ∩L never fires (no exact Δ-roots, witness refuses), both rings ship complete via subdivision; (3) product of two EXACT touch-plus-loops (`rA²(rA²−epsA)·rB²(rB²−epsB)`, degree 8, eps 8e-3/5e-3) — both `tangent_point`s emitted AND both rings ship (4 open fragments), honest partial `['unresolved_tangential_zone','depth_limit']`, and `_phi_slice_loop_fragments` fires ZERO times (the degree-8 descent hits the depth limit before any crossing-less cell passes the witness gate). Analysis: a macro-scale (≥ size-gate) second-system loop is always recovered by subdivision (a loop-holding cell can never certify loop-free, so it descends to ring scale); the only loss window is a sub-size-gate (radius < 4·unify_tol ≈ 4e-4, i.e. eps ≲ 1.6e-7) crossing-free loop of a SECOND system — at/below the documented `_delta_float_gn` resolution limits (L19). Fix direction once a reaching fixture exists: cache the tangency-holding `cell.box` (NOT `top_cell.box`) — preserves case-13's once-per-region cure (descendants contained), unblocks distinct systems; keep `_seed_cell = top_cell` for SLICE EXTENT (that half is correct — Φ equations are chosen per-tangency and the slice must cover loops larger than the cell). Do not fix without the fixture. +- [x] **L51. CSX boundary-exhaustion discards verified zeros** — CLAIMED(main-session 2026-07-12) — P2, FIXTURE-FIRST — `csx/_bez_csx4.py:1440`: on boundary-phase exhaustion `csx_boundary_zeros = []` drops roots that already passed the strict per-root certificate; Phase 2 has ~0 cells left to re-find them → `{isolated: [], budget_exhausted: True}` despite certified roots in hand (CCX keeps its validated hits in the same situation). + → FIXED (this commit): the exhaustion branch now KEEPS the boundary-zero candidates — each is individually polished through the strict per-root certificate below (sound regardless of set completeness) — while the valley/overlap CLASSIFICATION is now explicitly gated on `not boundary_exhausted` (pairing endpoints out of a truncated set could fabricate a false overlap claim; that was the sound half of the old discard, now enforced at the right altitude). Malformed non-BoundaryZero payloads still discard. Fixture note (fixture-first honesty): a naive starved fixture (line × plane, 1 Phase-2 cell) PASSED pre-fix — Phase 2 re-found the trivial root in a single cell; the pinned fixture needed a re-find that genuinely costs cells: cubic with its only root at the t=0 endpoint plus a 6e-4 sub-atol valley at t=0.6 (sub-atol valleys must be resolved, never merged — so 1 remaining cell provably cannot conclude). Test: `test_boundary_exhaustion_keeps_certified_roots` (monkeypatched boundary phase returns 1 certified-polishable zero + exhausted at 99/100 cells; pre-fix `isolated == []`, post-fix the t=0 root ships with `budget_exhausted=True`, `boundary_topology_complete=False`, 0 overlaps). Gates: csx4 suite 33 passed; singular suite + harness green (nested-CSX consumer unaffected on gate geometry). +- [ ] **L52. Consolidation batch** — CLAIMED(main-session 2026-07-12, sliced execution; 4 slices SHIPPED, remainder CLAIMED by the 2026-07-12 continuation session) — P2 — + **SHIPPED 2026-07-12 (late session, per-slice commits):** (1) **dead code removed** — `_bez_ssx5.py`: `_find_exit_registration`, `_choose_cut`/`_choose_multi_cut`, `_isoline_csx_to_global`, `_csx_on_cut_face`, `_pick_midpoint_axis`, `_midpoint_split` (~13.4k chars; all zero-caller-verified; ssx6 keeps its own live copies); `_bez_ccx4.py`: `_curve_component_scale`. PROCESS NOTE (paid for): the first removal pass overcut — a marker-range cut swallowed the six LIVE partition functions sitting between `_isoline_csx_to_global` and `_split_bern_scalar_tensor` (64 singular-suite failures, caught by the pre-commit gate, zero commits affected); every removal boundary is now def-map-verified. (2) **zero-allowance net-build preflights**: measured ALREADY PRESENT in both `bez_ccx` and `bez_csx` (the §10 debt note was stale) — pinned by `test_zero_allowance_preflights_before_net_build` in both suites so a refactor cannot regress the ordering. (3) **adapter status-twin unification**: NEW `mmcore/numeric/intersection/_adapter_status.py` — single implementation of `new_status`/`mark_incomplete`/`remaining_allowances`/`consume_bezier_status` (the one-cell dispatch floor + truncation + honesty flags documented once); `_nccx4`/`_ncsx4` keep their historical private names as thin delegations, CSX keeps its `parameter_fibers` field + fiber mapping + fiber-refusal locally; message texts and raise/extend ordering preserved exactly (adapter + boolean2d suites green). (4) **budget_contract gate in-repo** (review §11.5): NEW `examples/ssx/bez_ssx5_budget_contract.py` — every registered case solved twice, validating schema-v2 presence, `complete == (not reasons)`, reasons ⊆ the documented vocabulary, finite work counters, stuv ⊂ [0,1]⁴, per-vertex both-surface residual ≤ 6·atol (measured worst 5e-14 across all 8 cases), and md5 branch determinism across runs; exit-coded and added to the kickoff §3 gate list. + **SHIPPED 2026-07-12 (continuation session):** (5a) **shared budget module — `SoftWorkBudget` extraction**: NEW `mmcore/numeric/_work_budget.py` owns the schema-v2 REASON_* vocabulary + `SoftWorkBudget` (verbatim `_SSXSoftBudget` extraction; re-exported through `_bez_ssx5`'s namespace so module-attribute consumers — the singular suite, the budget-contract gate's REASON_* vocabulary scan — are unchanged) + `charge_hook` (single implementation of the guarded `(lambda n: b.charge_cells(n, src)) if b is not None else None` pattern; all 7 wiring sites in `_bez_ssx5.py` migrated). The module docstring is the EXPLICIT charge-semantics registry the merge requires: check-then-charge all-or-nothing (SoftWorkBudget, c3 `_spend`) / clamp-and-charge with min-1 floor (`_adapter_status`, closest-point aggregators) / charge-at-completion-after-truncation (`BernsteinZeroBudget` results) / shared-remainder threading (`bez_ccx`/`bez_csx` locals) — four deliberate families, never to be silently averaged. 16 contract tests in `tests/test_work_budget.py` (all-or-nothing denial, no reason cascades, postprocess latch survives search exhaustion, retirement never clears hard exhaustion, `status.work` schema shape, re-export identity). Gates: coverage 8 cases exit 0, budget_contract exit 0, singular 113 passed, unit batch 110 passed (incl. the 16 new). + (5b+5e) **budget mechanics moved into `_work_budget`**: `BernsteinZeroBudget` + its ContextVar + `bernstein_zero_budget` moved verbatim (re-imported through `_bern_zero_1d` so ccx/csx/tests keep their import paths AND the solver reads the SAME ContextVar object — identity pinned by test); c3's hand-rolled `_spend` closure replaced by shared `LatchingSpend` (check-then-charge, all-or-nothing, latching, external hook consulted only after the local check — semantics pinned by 3 new contract tests incl. local-denial-never-phantom-charges-external). `_ssx5_singular` reads the ledger object directly at the 3 former nonlocal-flag sites. Contract tests now 23. Gates: coverage exit 0, budget_contract exit 0, singular 113, §3 unit batch green. + (5c) **ccx/csx inline counters → shared `DownCounter`**: the paired `cells_remaining -= n; cells_processed += n` locals in `bez_ccx`, `bez_csx`, and `_find_csx_boundary_zeros` replaced by one object (`spend(n)` keeps the pair in sync; construction clamps ≥0), with the bounded fallback sub-allowances (`min(remaining, 2_000)` ccx / `min(remaining, 4_000)` csx) drawn via `tier(cap)` — the shared-remainder family named in code. `spend` is deliberately UNCHECKED: every denial policy (preflight returns, result-cap breaks, honesty flags) stays inline at its site, exactly as before. The sub-ledgers themselves stay plain ints (their `remaining` intentionally diverges from billed floors). Contract tests for pairing/clamping/tier. Gates: coverage exit 0, budget_contract exit 0, singular 113, §3 unit batch 142 (incl. re-pinned exactness contracts); adapter suites 17 passed + the 3 L41-documented pre-existing `mmcore.geom.curves` errors (untouched). + (5d) **clamp-and-charge family onto `reconcile_reported`**: `_adapter_status.consume_bezier_status`'s min-1/clamp/overrun lines, the closest-point trace reconciliation, and both NURBS aggregator drains now delegate to one `reconcile_reported(reported, allowance, floor=1) -> (billed, overrun)`; equivalence argued per site (sub-calls capped at `remaining` ⇒ the clamp never binds on reachable paths; `remaining ≥ 1` guaranteed at the trace site by the preceding break). `_publish_work_stats` + the loop guards stay put as the documented stats-dict family. **With 5d the core 8-way merge is COMPLETE at the mechanics level** — all 8 accountings live in or delegate to `mmcore/numeric/_work_budget.py`; the two deliberately-unmerged pieces are POLICY: c1's fair-share double-ledger (slice 9 §11.6) and `solve_zero_dim`'s pre-pop shared charge vs post-process local count (documented in the registry docstring). Gates: 4/4 exit 0 (singular 113; extended unit batch 194). + (slice 11) **coverage-harness work-drift gate (§11.5 remainder)**: the harness now prints per-case `status.work` (`cells_processed`/`csx_calls`), compares against the checked-in `examples/ssx/bez_ssx5_work_baseline.json`, and FAILS (exit 1) when a case exceeds 2× its baseline cells — headroom regressions surface the day they happen. `--update-baseline` rewrites the file deliberately. Baselines recorded 2026-07-12 (cases 5–11, 15: 17.9k/38.4k/16.0k/7.0k/7.6k/36.3k/48.1k/186 cells). Failure path verified live (fake baseline 50 → x3.72 → exit 1). + (slice 12, first item) **`_nurbs_param_tol` `== 0.0` guards → `< _TINY`** (review §10 extended debt): the three optimistic-variant collapsed-speed guards now match the conservative variants' pattern. MEASUREMENT (fixture-first honesty): the implied inf-ptol overflow is UNREACHABLE today — numpy's squared-sum norm flushes sub-1.5e-162 components to a norm of exactly 0.0, so dt is either 0.0 (old guard caught it) or ≥ 2.2e-162 (quotient ≤ ~4.5e158, finite; probed at the boundary). Consistency hardening; `test_collapsed_speed_tolerances_stay_finite` pins finiteness across the denormal sweep so a future scaled-norm implementation cannot expose the quotient and ship an inf ptol (an inf ptol would make every destructive dedup box-test true downstream). Gates 4/4 exit 0. PROCESS NOTE: the first slice-5 adversarial-review workflow run was KILLED after its agents ran `git checkout` on 11 tracked files (tree restored from HEAD, zero commits affected, uncommitted param-tol edits redone); the relaunched review carries an explicit READ-ONLY-git rule, and the lesson is recorded in persistent memory. + **Slice-5 milestone adversarial review (2026-07-12 continuation session; 4 Opus finder lenses over `b0a2094..485a57d` — charge-semantics preservation, missed-sites/half-merges, import graph, L56-repin bug-laundering — + 2 refuters per finding):** semantics/coverage/imports lenses returned NO FINDINGS; **1 CONFIRMED minor** (both refuters upheld): the hump re-pin asserted certification/residual/isolated but not the span itself, so a mislocated/partial `u_range` (dropping coincident range boolean2d's shared-edge merge needs) would have passed → FIXED same day (span assertions added). First run of this review was killed for tree corruption (see the slice-12 PROCESS NOTE); the relaunched run held the READ-ONLY-git rule (tree verified clean after). + (6a) **shared `bernstein_product_1d` in `_bezier_common`** — csx's broadcast shapes + ccx's longdouble factor arithmetic (bit-identical to the old ccx copy on 1-D operands — pinned by an exact-equality reference test; identical to the old csx copy on macOS, a last-ulp factor upgrade on true-80-bit platforms, absorbed by the envelope); both modules delegate under their historical private names (object identity pinned); DEAD `_eval_curve_longdouble` removed (zero callers re-verified) + unused `import math` dropped. + (6b) **the EXPLICIT envelope reconciliation** (the ledger's headline slice-6 demand): csx `_certify_affine_csx_overlap` moves from its folded `4096·n₁n₂·ε_f64·(|l|+|r|+src)` bound to ccx's derived two-term structure — `64·n₁·n₂·ε_longdouble` operator term (scales with the dtype the products actually run in; platform-consistent) + `8192·(n₁+n₂)·ε_float64` source term (sources are float64 everywhere; the constant family ccx's float-built-subcurve fixture calibrates from below). MEASURED before changing: the survey's raw 64-vs-4096 framing OVERSTATED the divergence — the per-coordinate source scale already rejects clean single-axis offsets at every magnitude (probed dz ∈ [5e-324, 1e-8], all refused pre- and post-change); the real old-vs-new window is IN-AXIS drift only, boundary (3.0, 3.5]e-11 → (2.6, 3.0]e-11 on the cubic/bilinear probe (sound direction; refused candidates fall to the L42 typed `uncertified_overlap_span` fallback). Pinned by `test_in_axis_drift_beyond_float_built_floor_is_not_certified` (3.0e-11 in-axis drift refused — accepted by the old envelope; 1e-12 floor-from-below still certifies; single-axis dz still refused). Gates 4/4 exit 0 (singular 113; unit batch 153). + (6c) **the DECIDED csx exclusion margin**: `_residual_excludes_zero` now excludes only beyond `128·ε·max|coeff|` (the §4 L1 invariant; it was the last unmargined sign prune — exclusion without a margin is the unsound direction). Fixture-first: 240 exact-Fraction restriction-chain comparisons on tangential-graze nets → 0 wrongful exclusions, 0 exclusions within 1e-12·scale of the boundary (the wrongful-exclusion mode is NOT reached in that family; the margin is invariant compliance/insurance). Pipeline impact measured ZERO: the slice-11 work-drift gate shows ×1.00 baseline cells on all 8 cases post-change. RED unit test `test_exclusion_prune_carries_the_L1_roundoff_margin` (hairline 10ε clearance must refuse — old code excluded it). **Slice 6 core COMPLETE** (6a product + 6b envelope + 6c margin); the remaining kit-helper duplicates (centering/context ×2, strict gates ×2, restrictors ×~6, collapsed-geometry predicates ×3) fold into slice 7. + (7a+7b) **slice 7 — kit-helper duplicates**: `subdivide_curve`/`subdivide_sq_dist_net`/`restrict_net_axis(_v)` moved verbatim into `_bezier_common` (bodies were copy-paste identical modulo docstrings; ccx/csx delegate under historical names); the collapsed-geometry predicate ×3 (ssx `_curve_geometry_collapsed` + two csx fiber-path inline sites) unified into `geometry_collapsed` (the local-motion 128·ε rule, x=1e15 lesson preserved in its docstring; dehomogenization stays at sites). **Margin policies ×3 DISPOSITION**: csx exclusion got its margin in 6c; ccx's depth-scaled identity margin (`1024·(depth+1)·max(n)·ε_LD`) and the K=128 static L1 family serve DIFFERENT certificate shapes (identity-residual magnitude vs sign-hull exclusion) — reconciled by documentation, not merged (merging would change calibrated envelopes without a defect). RESIDUALS (noted, deliberately not done): centering/cartesian exactness prep ×2 (unify onto csx's `...`-general forms when next touching the kits), the ordered-restriction pair (`_restrict_curve_interval` vs `_restrict_bezier_axis_ordered` — same math, different split backends, bit-identity verification nontrivial vs payoff), csx one-axis specials `_restrict_net_t`/`_restrict_curve`. Gates 4/4 exit 0 (work baselines ×1.00). + (slice 8) **kwarg hygiene + threading disposition**: shared `reject_unknown_kwargs` in `_adapter_status`, wired into all three v4 `**kwargs` adapters (`nurbs_ccx`, `nurbs_ccx_multiple`, `nurbs_csx`; allowed = `max_cells`/`max_results`/`max_depth`). FIRST CONTACT caught real debris: the adapter suite's own `rational=True` to `nurbs_ccx_multiple` had been silently swallowed for its whole life (the adapter derives rationality per pair) — removed with a note. The `tol=`/`atol=` sibling divergence RESOLVED: `nurbs_csx`'s tolerance parameter renamed `atol` → `tol` (the adapter-level convention — `nurbs_ccx` + the old adapters; `atol` stays the bez-level spelling; verified zero live in-repo callers pass it by name before renaming; the TypeError message teaches the convention). NEW gated suite `tests/test_adapter_kwargs.py` (§3 batch updated). Optional-budget threading DISPOSITION (documented in the registry docstring): `charge_hook` + the ContextVar scope are the two shared mechanisms; the remaining `is not None` guards are deliberate per-site policy and are NOT to be mass-converted to a null-object — a silently-accepting null budget would turn forgotten wiring into an unbounded solver, the exact failure budgets exist to prevent. Gates 4/4 exit 0. + (9a) **§11.6 de-budget — the L49 c1 misbilling FIXED, invariant declared+enforced**: NEW documented structural reason `REASON_SINGULAR_SET = "unresolved_singular_set"` (a positive-dimensional Σ enumeration truncated at an internal cap; no resource knob can finish a point enumeration of a curve). The c1 wiring maps EVIDENCE instead of OR-ing all three flags into `work_budget`: `external_budget_exhausted` → work_budget (shared ledger genuinely dry); local truncation WITH the detected curve (`_c1_curve`) → SINGULAR_SET; local truncation with NO curve → work_budget (the tier is genuinely limiting and more cells could finish an isolated-cusp census). MEASURED at both fixtures: pinch — reasons=['parameter_fiber','work_budget'] persisted at max_cells=1e6 with c1=509 cells and tier remainder 19,491 (the inner solve truncated on its result cap; c1_pass itself refused to set `incomplete` because the cusp curve explained it — the wiring erased that); case 14 — c1 tally exactly 20,000 WITH curve detected, and a 5× tier measured >2 min without finishing (the tier's §7.3 justification, now in the call-site comment; tier KEPT). Case 14's residual `work_budget` traced (stack-spy) to the L42-class boundary-CSX continuum faces — an honest, knob-backed truncation per the L42 contract, untouched. §11.6 INVARIANT declared + ENFORCED: `bez_ssx5_budget_contract.py` now FAILS any registered gate case reporting `work_budget` ("gate geometries complete far below every allowance — work_budget there is a misbilled structural condition or a headroom regression"). RED test `test_positive_dim_sigma_truncation_is_structural_not_work_budget` (114th singular test). REMAINING slice-9 scope (9b): the typed case-13 complement entity (4-D AABB + reason, the `parameter_fibers` pattern). + **Slices-6–9a milestone adversarial review (2026-07-12 continuation session; 4 Opus lenses + 2 refuters/finding over `485a57d..3324a45`; the hardened READ-ONLY-git rule held — tree verified clean):** envelope + moves lenses NO FINDINGS; 1 refuted; 3 confirmed minors, all resolved same day: (1) FIXED — c1 remap contention hole: shared-budget scarcity flowing through the tier clamp (`remaining < 20k`) truncates c1 locally WITHOUT `external_budget_exhausted`, and c1's documented `curve_like` blind spot (fires on a budget-truncated multi-cusp census) could then hand a budget-fixable case the give-up `unresolved_singular_set` label → structural classification is now trusted only when the FULL 20k tier was available (`_c1_tier_clamped` guard; a clamped-tier truncation is work_budget — actionable). (2) FIXED — latent debris in the nccx 3D fixture (stale `rational=` + three 2-tuple unpacks, masked by the documented pre-existing import error; repaired by reading). (3) DISPOSITION — the UNTRACKED user-WIP `examples/csx/*_new.py` scripts call v4 `nurbs_csx` with `atol=`/`angle_tol=` and now get a loud TypeError instead of silently running at the DEFAULT tolerance (the intended slice-8 direction); extends the L41 note: when next touched they need `tol=` + 3-tuple unpacks (user-owned files, not edited). + (slice 10) **A2's two PLAUSIBLE leads RESOLVED**: (a) **csx `>12` chain constant — CONFIRMED worse than predicted, FIXED.** Analytic key: a genuine SHORT exact overlap span exists only by DOMAIN CLIPPING (polynomial identity forces full coincidence otherwise) and must be curved in uv to dodge the affine certificate. Fixture: corner-clipped quadratic (coincident span 4.2·ptol_t, ends at the u=1 domain edge) → shipped 3 lattice roots, `complete=True`, NO span — the span evidence HAD armed but the `>12` bar blocked the honesty flip. FIX: lattice-cluster detection independent of the count bar — a run of ≥3 roots, all gaps ≤ 4·ptol_t, whose GAP MIDPOINTS all pass the STRICT residual certificate → typed `uncertified_overlap_span` + topology-incomplete, certified roots kept, NO false exhaustion claim; multiple verified runs → `non_span_truncation=True`. The never-merge-tolerance-touches invariant holds BY CONSTRUCTION (sub-atol valley floors fail strict — pinned by the 3-root valley-chain negative control); the `>12` test stays for the armed L42 path. Tests: `test_short_clipped_overlap_span_is_not_reported_complete`, `test_sub_atol_valley_root_chain_is_never_merged_by_the_cluster`. (b) **65-grid even-crossing aliasing — VERIFIED DOCUMENTED** at the certificate site (comment cites the L54 entry); densifying the grid interacts with the recorded L47 user band-bar residual → documented limitation, no change without the user. Gates 4/4 exit 0 (singular 114, unit batch 158). + (9b) **typed unresolved complement SHIPPED** (§7.3 item 2): NEW additive result field `unresolved_regions` — each entry `{'stuv_min', 'stuv_max', 'reason'}` names a 4-D box left behind: depth-ceiling dumps (`depth_limit`), a work-exhausted queue's abandoned cells (`work_budget`), and the two bail-before-queue early returns (whole-domain box — found during TDD: precompute denial and boundary-phase exhaustion returned NOTHING typed). Bounded by the output cap (`unresolved_region` source). Case 13 measured: 5 typed boxes alongside its `depth_limit`. Test `test_unresolved_complement_is_typed_with_boxes` (case-13 geometry, both halves). Gates 4/4 exit 0. + **REMAINING L52 scope:** exactness kits into `_bezier_common` (the two roundoff envelopes must be reconciled EXPLICITLY, not silently — ccx `64·n₁n₂·ε_LD` op + separate `8192·(n₁+n₂)·ε_f64` source vs csx folded `4096·n₁n₂·ε_f64`; surveyed 2026-07-12: also unify the two `_bernstein_product_1d` (csx's broadcast form is the strict generalization), kill DEAD `_eval_curve_longdouble` (ccx:167), and DECIDE csx's zero-margin `_residual_excludes_zero` vs ccx's `1024·(depth+1)·max(n)·ε_LD` margined twin — exclusion with no margin is the unsound direction); the 3 hull-exclusion margin policies; collapsed-geometry predicate ×3; 3 interval restrictors; `tol=`/`atol=` kwarg unification + reject unknown kwargs; Optional-budget threading residue (the 41 `is not None` guards; `charge_hook` + the ContextVar exemplar are in place); the c1 fixed-tier saturation + the L49-found c1-enumeration `work_budget` misbilling on positive-dimensional Σ sets (§11.6 de-budget incl. typed case-13 complement); A2's leads (csx `>12` chain constant; 65-grid aliasing); coverage-harness per-case `status.work` 2×-drift recording (§11 step 5 remainder); efficiency items per review doc §10 extended list (incl. `dt == 0.0` exact-equality guard in `_nurbs_param_tol`; flag zoo: `hard_exhausted`/`output_counts` have zero readers). — one shared budget/status module (8 hand-rolled accountings with divergent charge semantics: `_SSXSoftBudget`, `BernsteinZeroBudget`, closest-point inline, `bez_ccx`/`bez_csx` inline, `_nccx4`/`_ncsx4` ~110-line already-diverged twins, `_ssx5_singular` closures); flag zoo → one status source of truth; exactness kits unified in `_bezier_common` (diverged roundoff envelopes 64·n·ε_longdouble vs 4096·n·ε_float64; duplicate `_bernstein_product_1d` names); 3 coexisting hull-exclusion margin policies; collapsed-geometry predicate ×3; 3 interval restrictors; `tol=`/`atol=` kwarg unification + reject unknown kwargs; dead `_curve_component_scale`; Optional-budget threading → null-object/ContextVar (`_bern_zero_1d` demonstrates); zero-allowance preflights for `bez_csx`/`bez_ccx` net builds; C1 fixed 20k tier vs shared remainder (case 14: c1 charge = 20,000 exactly, ~174k unspent — L38 disease); efficiency items per review doc §10 extended list. +- [x] **L53. ssx6 disposition** — CLAIMED(main-session 2026-07-12; USER DECISION: repair + document) — P2, DECIDE-FIRST — `_bez_ssx6.py:1902`: `list((lambda…, iterable))` corrupted filter crashes the entire interior cut-face path with `TypeError` (the new guard + contract test cover only the boundary path); module confirmed a stale pre-budget fork (no budget kwargs, fresh 100k per nested CSX, no `singularities`). Decide: repair + document its status, or quarantine explicitly. + → FIXED (this commit; user chose repair + document): the corrupted non-filter is now the real t-endpoint filter (same expression as the maintained v5 engine), pinned by `test_ssx6_interior_cut_face_filter_is_a_filter` (monkeypatched CSX + SimpleNamespace cell — RED reproduced the exact `TypeError: 'function' object is not subscriptable` at `_isoline_csx_to_global`, GREEN converts the interior root and drops the endpoint re-find). The module docstring now opens with a STALE-PRE-BUDGET-FORK charter: superseded by `_bez_ssx5.py`, no budgets / no-hang guarantee / schema v2 / typed singularities / L-series fixes; its contract guard hard-raises on incomplete nested CSX; use as historical comparison only — do not extend, and future review cycles should skip it beyond keeping it importable. Import isolation verified (nothing in `mmcore/` imports it). Gates: ssx6 contract suite 4 passed; singular suite + unit batch green (live engine untouched). +- [x] **L54. Follow-up line-scan audit (optional)** — CLAIMED(main-session 2026-07-12; BOTH ANGLES DONE) — P2 — re-run the two line-scan angles killed by the session limit (A1: `_bez_ssx5.py`/`_ssx5_singular.py` new hunks; A2: ccx/csx/closest-point/param-tol hunks); open lead from A2: strict residual gates on curves lying in a plane z = const ≠ 0. + **A1 results (Opus line-scan over `6f362b9..HEAD` SSX hunks — `_bez_ssx5.py` +472, `_ssx5_overlap.py` +647, `_ssx5_singular.py` ±40): NO FINDINGS.** 13 leads checked and refuted with runtime verification (schema invariant `complete == (not reasons)` holds incl. `retire_reason` gating; overlap assembler correct on full/partial/opposed/no-overlap + soundness-critical coverage directions; dead-knob removal safe — no caller passed them; `overlap_boxes` threading complete; `_run_csx` charges exactly once; NaN-kept dedup points never enter the spatial buckets; c3/point-dedup charge arithmetic sound). One latent weakness worth hardening was flagged (lead 1): a NaN `fres` from a degenerate fixed-face solve fell into the L25 COMMIT branch (`NaN > tol` is False), shipping a poisoned exit vertex — no wrong output today because the tracer's finite path-guard discards it downstream, but the defense lived in a different function. → HARDENED same day: the exit commit condition is now accept-if (`np.isfinite(fres) and fres <= strict_exit_tol` — the L45 convention), pinned by `test_exit_commit_refuses_nonfinite_fixed_face_residual` (monkeypatched NaN corrector: pre-fix the garbage vertex committed as a boundary exit; post-fix every attempt refused). + **A2 results (Opus line-scan over `6f362b9..HEAD` ccx/csx/closest-point/param-tol hunks, 2026-07-12 evening; main-session verified each):** + - **[CONFIRMED → FIXED same day] On-node crossing absorbed by the L47 flip test.** `_tolerance_overlap_certificate`'s flip loop skipped any pair touching a root-like sample, so a transversal crossing landing exactly ON one of the 65 sample nodes (dyadic-64 fractions — u*=0.5/0.25/0.75, the most common symmetric CAD locations) produced a root-like node severing both straddling pairs: the crossing was silently merged into a `certification='tolerance'` overlap reported complete (verified: line vs endpoint-sharing cubic band ±3e-4, crossing at 0.5/0.25 → absorbed; 0.3 → correctly 3 isolated). FIX: the flip test now BRIDGES root-like runs (compares consecutive GAP samples), bracket at the intervening root-like node (the root is exactly there). Tests: `test_on_node_interior_crossing_is_never_merged[node-0.5|node-0.25|off-node-0.3]`. + - **[Corollary, measured during the fix] "Offset twin" ≠ crossing-free.** A y-offset of a curve whose tangent turns PARALLEL to the offset genuinely CROSSES the original there (the offset slides the curve along itself; curve1's vertical tangent near u=0.75) — the original L47 primary fixture therefore contained a real sub-scale crossing, which the bridged flip now detects: it gets the woven-family honest outcome (typed span + topology-incomplete), and the clean-promotion tests were re-pinned on a monotone-x offset pair (crossing-free by construction). Test: `test_offset_twin_with_vertical_tangent_is_not_cleanly_promoted`. This narrows the promoted family exactly as the contract's never-merge-crossings guard demands; merging noise-scale crossings remains the recorded user band-bar decision. + - **[REFUTED with measurements] z=const≠0 strict-gate lead**: origin-centering in `_ccx_exactness_context` (and the CSX twins) zeroes a constant quiet-axis offset before component scales; the L47 `tiny` floor uses extent (translation-invariant). Verified z ∈ {0, 1, 1e3, 1e6}: identical roots, no false accepts/rejects. `_curve_component_scale` is DEAD code (already on L52's list). + - **[Refuted/fail-safe]** closed-curve seam trips the monotone-pairing check (over-rejects, cannot false-promote); reversed-orientation path holds at z=0/1000; `uncertified_overlap_span` always co-reported with topology-incomplete; the adapters' `return_status=True` flip breaks no in-repo 2-tuple unpacker (old `_nccx` callers unaffected; the one `_nccx4` consumer unpacks 3); closest-point L46 accounting has no charge-past-denial. + - **[PLAUSIBLE, recorded as leads — not fixed]** (a) `_bez_csx4` continuum-chain threshold `>12 in-span roots`: a very SHORT exact overlap span (≲12·ptol_t) completing under the 4k fallback cap could ship lattice roots reported complete — the constant is unjustified; needs a short-span fixture. (b) An EVEN number of crossings inside one 1/64 sample interval aliases to no flip (both gap neighbours same side) — inherent to the fixed 65-sample grid; requires oscillation faster than the grid (rare in CAD; `n_samples` is non-adaptive). Documented in the certificate's comment. +- [x] **L56. CCX exactness-contract suite stale-red since L47 (gate-coverage gap)** — CLAIMED(continuation session 2026-07-12) — P2, found while extending the unit-batch gate for L52 — `tests/test_ccx4_exactness_contract.py`: 9 of 14 tests failed at `b0a2094` (verified pre-existing; identical failures with the L52 slices stashed). Root cause: the suite pinned PRE-L47 semantics ("sub-tolerance sets are never reported as overlaps"; "non-affine candidate = bare bounded partial with topology_complete=True") and was NOT in the kickoff §3 gate list, so the L47 user-approved residual tier flipped it silently the same day it shipped (L47's own gate list ran ccx4/unit-batch/SSX, not the exactness contracts). + → RECONCILED (this commit): every failure verified against the L47 contract by direct measurement — parallel sub-atol offsets (5e-4, 1e-12, 5e-324; poly + rational × weight scales (1,1)/(1e-30,1e30); origin 0 and 2e10) all ship ONE `certification='tolerance'` overlap with `residual_max` = the geometric gap (translation cost ≲1% of gap; weight-scale invariant), and — the sharpened exactness property — NEVER certify `'exact'` (even 5e-324 stays 'tolerance'; the exact-affine identity keeps refusing at every magnitude, pinned via `_overlap_mapping_is_identity` directly). The sub-atol hump's endpoint touches are carried as the overlap's endpoints (non-flipping in-band contract case), and the non-affine candidate is the L47 ledger's own typed-span fixture (span (0, 0.8276), topology-incomplete, 2,004 cells) — the old pin's `boundary_topology_complete=True` was the pre-typed-span misbilling shape. Suite re-pinned to the approved contract (names updated to say what they now defend); NO code change. Gate fix: both exactness contract suites + `tests/test_work_budget.py` added to the §3 unit batch so contract suites can never drift out of the gates again. +- [ ] **L57. Planar-but-non-parallelogram quads lose edge-overlap branches (USER-REPORTED, trivial geometry)** — P0/P1, CLAIMED(continuation session 2026-07-12) — user fixtures: planar bilinear quad (z=0) vs a bilinear with one lifted corner (z-graph `2.895·u(1-v)`, zero set = the u=0 edge ∪ v=1 edge meeting at the (0,1) corner where ∇z=0). TRUTH: 2 branches joined at 1 `tangent_point`. MEASURED at HEAD `1efe8c2`: fixture C (planar surface IS a parallelogram, twist=0) → PERFECT (2 full branches, linked tangent point, complete=True, reasons=[]); fixtures A/B (planar quad twist=(−0.107, 5.34,0) ≠ 0) → branch on the v=1 edge complete, branch on the u=0 edge SILENTLY TRUNCATED to 37%/11% of its length (dangling end interior to both domains), `reasons=['overlap_region_unsupported']` (misattributed — the intersection is 1-D). ROOT CAUSE (probe-confirmed): the branch lies on S2's u=0 edge whose isocurve lies IN val1's plane → boundary CSX must certify a curve-on-surface overlap; the quad is planar but NOT affine-parameterized (twist ≠ 0) → `_certify_affine_csx_overlap` refuses → L42 curved-UV fallback grinds 4,022 cells into ~250 accumulating lattice pseudo-roots, 0 overlaps, typed span (0,1) — the L42 honest-partial gap biting on TRIVIAL PLANAR geometry, upstream of the truncated branch. FIX DIRECTION: a `_certify_planar_csx_overlap` tier BEFORE the fallback — (a) certify surface-net planarity + curve-in-plane algebraically (source-envelope family); (b) in-plane coincidence changes only at domain-boundary crossings, which the CSX boundary phase already computes; (c) one strict midpoint membership test per interval between consecutive boundary events decides in/out → certified overlap spans with NO subdivision grind. Existing primitives suffice. Fixtures A/B/C become the pinned suite; target = C-quality output on A/B. NOTE: the user's fixture-B report of an isolated point with a huge |S1(s,t)−S2(u,v)| residual did NOT reproduce at HEAD (2 branches, 0 points; possibly their driver/older commit) — keep the residual-validity check in the new tests anyway. +- [ ] **L58. Sphere-segment coincidence (2-D region on a real quadric)** — P1, PARKED by user decision 2026-07-12 ("too early for rational spheres and overlaps") — measured at HEAD on an exact same-sphere overlapping-segment pair (octant sub-patches, poles excluded): 1 region IS built but with 17 fragment branches, 128k `singular` cells, 8.4k `phi`, `reasons=['unresolved_tangential_zone','overlap_region_unsupported','work_budget']`, 278 unresolved boxes. The user's real-data run (with a pole in-segment) additionally shows `parameter_fiber` + a `cusp` at the pole corner. Same class as L57's root cause at the rim-certification layer (CSX has no curved/rational overlap tier) PLUS quadric-tangential Δ-grind. Blocked behind L57; revisit with the L57 planar tier as the template for a quadric/tolerance tier. +- [ ] **L59. CSX overlap certification tier (USER DECISION 2026-07-12: theorem-first; absorbs L57's fix as its 'exact' fast path)** — P0, CLAIMED(continuation session) — REGRESSION LOCATED BY BISECTION: broken at `5d05ddc` (2026-07-10 "harden singular and rational intersections"), NOT by the L41–L56 review arc (slice-10 and 6b explicitly exonerated by hunk-revert probes). tiny (`dcb5a76`) certifies the user's script-3 call 2 in **2.7s with 1 overlap** via permissive valley-pairing (no certificate exists there); `5d05ddc` gated overlap emission on the exact-AFFINE certificate with NO tier for genuine non-affine overlaps → the class fell into the phase-2 grind (HEAD: 57s default / 20.7s flat-100k, 0 overlaps, incomplete; the L42 bound and L41 candidate-scaled budgets only shaped the failure). The user's untracked scripts were the only coverage — now TRACKED (`196d162`, all six `overlap_nurbs_intersection_*_new.py`). + **USER DECISION (rationale on record):** theorem-first certification. Two Bézier/rational arcs coinciding on ANY open sub-arc lie on the same algebraic curve, so a maximal overlap can only terminate at a DOMAIN boundary of an operand — verify domain-pinned span endpoints + interior witnesses numerically, and the THEOREM (not floating point) carries the interior; fast early exit on endpoint evidence. Tolerance-coincidence IS coincidence (resolves the L47 residual band-bar): endpoint-pinned + witnessed + NO crossing flips ⇒ one `'tolerance'` overlap ('exact' when the algebraic identity holds). AMENDMENTS agreed: (1) multiple pinned spans possible (improper/folded reparameterizations; CSX domain clipping) — certify a SET of spans; (2) isolated self-intersection roots (nodes) coexist with spans and are never swallowed; (3) the flip guard (crossing structure never merged) is what protects near-tangent-loop topology — it stays. + **PLAN:** resurrect the boundary/valley evidence as the ARMING signal (endpoint membership first, before any subdivision); certify per span: domain-pinned ends (inversion residual ≤ atol) → interior witnesses → normal-side flip guard → emit span(s) with `certification`; certified spans BYPASS the 4k fallback and in-span phase-2 grind. CCX's L47 `_tolerance_overlap_certificate` is the template (curve-onto-surface inversion replaces curve-onto-curve). L57's planar-exact tier folds in as the 'exact' fast path. CSX exactness-contract pins get re-pinned to the tolerance semantics (matching CCX post-L56: translated 5e-4-gap fixtures → `'tolerance'` overlap, never `'exact'`). ACCEPTANCE: script-3 call 2 at tiny-class speed WITH the overlap reported; exactness contracts green under the new pins; near-tangent cases (case 11 family) untouched; the user's fixture A/B (planar quads) produce fixture-C-quality output. + → SHIPPED (this commit): `_tolerance_csx_overlap_certificate` — arming on cheap endpoint evidence (a curve-end zero on-surface or a valley pair; transversal SSX boundary calls skip it), 17-sample coarse scan, 65-sample dense membership with projection-continuation seeding, DOMAIN-PINNED span ends (curve t-ends OR projected uv-edge pins with bisection-refined tolerance boundaries — the real-data span's inner end has NO exact 3-D root, measured 6.7e-4 edge-line clearance, so the pin is projected), runs must span ≥2 grid samples (single-sample runs = the corner-contact signature, refused — caught live by the shared-edge junction fixture as a 4·atol stub branch), normal-side flip guard with root-like bridging, multi-span, `certification: 'exact'` (roundoff-level witnesses) or `'tolerance'`; certified spans are t-excluded from Phase 2 (no grind); tier priced 162 cells. SSX INTEGRATION (both caught by gates/acceptance BEFORE commit): (a) `_run_csx` feeds certified spans into the L28 rim channel — the assembler self-samples curved rational rims that the boundary path's straight-chord verification rightly rejects; the case-12 rational twin regained its region (1 region, complete, reasons=[]); (b) `_process_face` builds SAMPLED overlap branches (33-pt inverted, per-sample residual ≤ 2·atol) when a certified overlap's straight chord fails — the interim state where fixture A/B's truncation became FALSELY complete never reached history. MEASURED: real-data pair 12,234 cells empty-complete → 1 'tolerance' overlap complete (~180 cells); the L42 parabola → 1 'exact' overlap, 204 cells (was 1,679 lattice roots on tiny, bounded-partial after L42); fixtures A/B → BOTH branches full length linked to the tangent point (B: branch_links [(0,32),(1,0)], C-quality); exactness contracts re-pinned (each verified individually: 'tolerance' never 'exact', translation-invariant to the digit). Gates 4/4 exit 0 (singular 115, unit 161, work drift ×0.99–1.19). RESIDUAL → L60. +- [x] **L60. Near-band grind** — CLAIMED+SHIPPED(continuation session 2026-07-12) — script-3 call 2 was ≈51 s after L59 (profiled: top pairs 12k–29k cells each). DIAGNOSIS CORRECTED DURING TDD (process note, fixture-first honesty): the initial "sound emptiness certificate gets ~1000× on the worst pair" probe OVER-CLAIMED (its cell counter treated depth-limited leaves as excluded), and the worst pair was NOT comfortable-clearance emptiness — it is a ONE-SIDE-PINNED TOLERANCE BAND: the curve touches the patch at its t=0 end (1.6e-9, real-data-inexact), runs sub-atol (2.5e-5..7.7e-4) to t≈0.44 where the projected path exits through the u=1 patch edge exactly as the distance crosses atol, then departs. THREE fixes shipped: (1) `_residual_aligned_excludes_zero` — the geometry-ALIGNED zero exclusion (scalar Bernstein net `dot(G, n̂_mean)`, an exact linear combination of the exact component nets, 128·ε L1-margined; sound for ANY fixed direction) wired into the phase-2 cell loop AND as a whole-interval early test (several gate cases now BELOW their work baselines: ×0.91–0.99); (2) the L59 pin probe extended ONE GRID STEP OUTWARD — the tolerance boundary and the uv-domain exit COINCIDE on real data, so the projection at the refined boundary is still interior while one step outward it clamps to the edge (a genuine interior fade-out — the offset-twin signature — still refuses); (3) END-ADJACENT flip exemption — a genuine touch AT a pinned span end sits above the roundoff bridge floor on inexact data and is the span's own endpoint root, not interior crossing structure (interior flips still refuse; never-merge preserved). MEASURED: the worst pair 28,961 cells/0 overlaps → 1 'tolerance' overlap at ~hundreds of cells; script-3 call 2: 51 s → 30 s AND 2 → 7 certified spans (a periodic per-curve-span pattern; tiny reported only ONE overlap here — its 2.7 s also bought UNDER-REPORTED structure, so the speed comparison was never on equal output). Gates 4/4 exit 0 (singular 115, unit 162, work drift ×0.91–1.13). + **L60 follow-up (same session) — the user's "where do the gaps go?" challenge resolved the remainder.** MEASURED: the refused pairs' coincident stretches enter AND exit through PATCH EDGES at sub-atol clearance → ZERO exact boundary roots exist → the arming gate (curve-end zero or valley pair) never fired and the certificate never ran on exactly the pairs that mattered; the certified/refused alternation across patches was just which pairs happened to have an in-band curve end. (An earlier weave hypothesis was tested and REFUTED on the same data — 0 sign flips measured.) FIX: arm also for ZERO-boundary-zero pairs (transversal calls always carry zeros — untouched; interior-root-only nested calls arm-and-miss cheaply). The first attempt billed the flat 162-cell tier price per armed call and the §11.5 WORK-DRIFT GATE CAUGHT IT LIVE, twice (case 15 ×3.61 + case 7 ×2.00 in the harness; the constrained-budget case-11 test tipped into work_budget) — pricing split: the 17-projection arming scan bills 17, the dense pass bills 145 only on a HIT. Baselines deliberately refreshed via --update-baseline (the case-15 graze family now legitimately arms-scans-refuses: 186→382 cells; large cases ×0.97–1.23 from honest arming across nested calls). RESULT: script-3 call 2 = **ONE overlap (5.9230, 20.1120) vs tiny's (5.9238, 20.1131) — sub-ptol agreement, from the sound certificate — at 4.2 s** (tiny 2.7 s; 57 s at session start), and both engines agree curve2's coincidence is t∈[5.92, 20.11] of [0, 31.3] (the outer stretches are genuinely off-surface). Gates 4/4 exit 0, all 8 cases ×1.00 on the refreshed baselines. +- [x] **L61. USER-REPORTED residual branch loss on the bilinear non-affine family — RESOLVED 2026-07-12: STALE CHECKOUT (user-confirmed)** — P0, registered 2026-07-12 end of session — the user reports the original bilinear cases (non-affine planar quad × lifted bilinear) STILL lose part of the second branch in their environment. **NOT REPRODUCED at HEAD `f6d0015`**: all constructible variants pass — fixtures A (quad, lifted: both branches 9.763, linked tangent point, complete), B (quad', lifted: same), C (lifted, parallelogram: same), AND both order-swapped forms D=(lifted, quad-A) / E=(lifted, quad-B) (measured this session, full output in the session log). The failing configuration is therefore not captured by the pinned suite; candidate deltas: (a) the user's NURBS-level driver (their originals are `NURBSSurfaceTuple`s with non-unit knots 0..17.83 / 0..9.76 — single-span so the Bezier nets are identical, but the driver's normalization/parameter mapping is unverified); (b) a geometry variant not in the pinned set; (c) a run predating the last three commits (`3c3f593`/`f6d0015`/`347af54`). DISCIPLINE: obtain the user's EXACT failing script + geometry before any code change (the L57→L59→L60 chain shows each "same" symptom had a different mechanism); pin it as a test FIRST. **The user's three original examples are preserved VERBATIM — including the non-unit knot vectors the normalized fixtures drop — in `examples/ssx/bilinear_l_junction_user_cases.py` (committed; its `__main__` records that all three pass at the Bezier level at HEAD: full 9.763 branches, tangent point, complete).** Since the geometry is now proven good at bez level, hypothesis (d) moves to the front: the user's NURBS-level pathway — the driver's normalization and especially the branch stuv→knot-parameter mapping consumed by their VIEWER (a mapping bug would truncate the branch visually while the bez result is complete). Next session: reproduce through the user's actual driver, not just bez_ssx. **CLOSED: the user re-verified against current HEAD — the failing runs were an old build ("old wheels"); all cases work. The verbatim fixtures stay as permanent regression coverage. MERGE BLOCK LIFTED.** **The branch does NOT merge to `tiny` until L61 is closed** — failing simple-from-a-CAD-perspective cases outranks everything else on this branch. Methodology of record: `docs/superpowers/specs/2026-07-12-theorem-first-overlap-methodology.md` (§8 records this item). +**End-of-queue adversarial review (2026-07-12 evening, 19 opus agents: 5 finder lenses × diff `6f362b9..1257fdb`, 2 refuters per finding):** 7 raw findings, **1 CONFIRMED** (both refuters upheld): the L28 evidence-coverage box check tested only the box's S1 (s,t) center against `uv1_loops` — a box on a DIFFERENT S2 sheet sharing an S1 footprint (folded/self-overlapping S2) was falsely "explained" and could wrongly retire the structural reason (`_ssx5_overlap.py:605`; one refuter reproduced the one-sided predicate live against a folded quadratic S2). → FIXED in the same follow-up commit: coverage requires BOTH parameter planes (`_half_explained` on uv1 AND uv2, same rule as `_site_in_regions`); test `test_overlap_box_coverage_requires_both_parameter_planes`. The 6 refuted findings (retirement-family leads: covered-on-centers granularity, structural_sites completeness, soft-path face-discard scope; hole nesting depth ≥2; closest-point per-piece skip attribution) are recorded here as **L54 audit leads**, not defects — each had ≥1 refuter demonstrate the guarding condition that prevents the claimed failure. + +- [x] **L55. Rational-CSX boundary-phase cost + untyped result-cap floods** — P2 (found during L28, 2026-07-12) — measured on the rational twin of case 12 (exactly-rationalized bilinear pair, weights 1–2): two boundary faces burn their WHOLE `min(20k, remaining)` tier inside `_find_csx_boundary_zeros` with 0 results (rational Bernstein boundary nets vs the polynomial ~100-cell cost), and a third face floods isolated ptol-lattice roots to the 128-result cap WITHOUT a valley confirmation, so the L42 span typing never arms and the truncation stays `work_budget` (`non_span` path). Consequence: rational coincidence input keeps an honest but unhelpful `work_budget` reason even after L28 represents the region. Directions: profile the rational boundary-net hull quality; extend the L42 continuum signature to result-cap floods (chain test on the capped output without requiring the valley pair); consider the L47 decision's fallback shape. + → RESOLVED by L47 (same commit; "consider the L47 decision's fallback shape" turned out to be the whole fix): the flooding/burning boundary faces were coincident rational curve-on-plane pairs whose nested `bez_ccx_v4` calls could neither certify (exact-affine fails for rational coincidence) nor discretize (undiscretizable diagonal valley); the L47 residual tier now promotes them as `'tolerance'` overlaps in a handful of cells. Measured on the rational twin: `complete=True, reasons=[]`, 1 certified region, 15,297 cells / 2.2 s (was: `work_budget` with ~40k+ cells). The `test_overlap_region_rational_twin_case12` pin updated from `reasons == ['work_budget']` to `reasons == []` — the L28 goal state now holds on the rational twin too. Reopen only if a new rational fixture reproduces the boundary-tier burn through a non-coincidence path. diff --git a/docs/superpowers/plans/2026-07-10-ssx5-next-session-kickoff.md b/docs/superpowers/plans/2026-07-10-ssx5-next-session-kickoff.md new file mode 100644 index 00000000..c92426ed --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-ssx5-next-session-kickoff.md @@ -0,0 +1,143 @@ +# SSX v5 — next-session kickoff (singular-cases hardening) + +**Written:** 2026-07-10, from the branched interactive session. **Updated 2026-07-12.** +**Purpose:** everything a fresh session must read and obey before touching SSX code. + +> **Session-start prompt (copy-paste; updated 2026-07-12 end of day — L25/L47/L48/L49/L51/L53/L54 DONE, L55 resolved, L52 slices 1-4 shipped; see §2):** +> Continue SSX v5 hardening on branch `ssx5-singular-hardening` (NOT `tiny`; no pushes). +> Start with `git status && git log --oneline -14` — expect this kickoff-docs commit +> at HEAD, sitting directly on the L52-slice commits `2322399`/`09ebc47`/`e9736d4` +> (last code commits; 13+ commits above `28a9e4d`); reconcile, never clobber. +> Read first: this kickoff §0–§4 (§2 = what shipped in BOTH 2026-07-12 sessions and +> what remains); then the ledger entries L50 (investigated-open, fixture analysis + +> fix direction recorded), L52, and L54's results (A2's two PLAUSIBLE leads feed L52) in +> `docs/superpowers/issues/2026-07-07-ssx5-singular-review-ledger.md`; then review doc +> §11 steps 5–6 in `docs/superpowers/plans/2026-07-12-ssx5-budget-review-and-overlap-contract.md`. +> Queue: 1. **L52 remainder** (4 slices SHIPPED 2026-07-12 late session: dead code, +> preflight pins, adapter status-twin unification into `_adapter_status.py`, and the +> in-repo `budget_contract` gate — see the ledger's L52 progress block): the core +> 8-way budget merge, exactness kits (reconcile envelopes EXPLICITLY), margin +> policies, predicates/restrictors, kwarg hygiene, Optional-budget threading, the +> c1 tier + §11.6 de-budget (incl. the L49-found c1-enumeration misbilling), A2's +> csx `>12` chain constant + fixed-65-grid aliasing leads. +> 2. **L50** only if a REACHING two-tangency fixture is found +> (see the ledger's recorded analysis; do not fix without it). Schema v2 is IN PLACE: +> new work reads/emits `complete`/`status.reasons`, never `budget_exhausted`, at the +> bez_ssx level. CCX overlaps now carry `certification: 'exact'|'tolerance'` (L47 user +> contract; crossing structure is never merged — on-node crossings bridged, offset +> twins with tangent-parallel points are NOT crossing-free). +> Process: mark items CLAIMED() in the ledger before starting; TDD (failing test +> first); run §3 gates after every item and before every commit; commit per item with +> the ledger update in the same commit; adversarial review after substantial milestones. +> Invariants: §4. Subagents default to Opus (haiku for mechanical checks); Fable only +> where genuinely needed. + +## 0. Read these, in this order, before any edit + +1. Memory `project_ssx5_singular_cases.md` + `project_ssx5_branch_loss_fixes.md` (auto-indexed in MEMORY.md) — what shipped, accepted limits, tolerance conventions. +2. `docs/superpowers/plans/2026-06-10-ssx5-singular-cases-handoff.md` — pipeline map, tolerance ladders, primitives, debugging playbook. **Mandatory before touching `_bez_ssx5.py` or `_ssx5_singular.py`.** +3. `docs/superpowers/plans/2026-06-10-ssx5-singular-cases.md` — the executed plan; **AS-BUILT blockquotes override the original task text wherever they disagree.** +4. `docs/superpowers/issues/2026-07-07-ssx5-singular-review-ledger.md` — THE work-item source of truth (27 items, 25 closed at `dcb5a76`). Repro one-liners live in the item text; full evidence was in session task dirs that may no longer exist — reproduce from the ledger text, don't hunt for old /tmp files. +5. Reference paper: `3592452-2.pdf` (Cheng et al. 2023), §3–5 for C1/C2/C3 definitions; `237748.237751.pdf`/`.md` (Krishnan–Manocha) for the decomposition framework. + +## 1. Git discipline (user requirement — changed from before) + +- **All development on a dedicated branch, NOT on `tiny`.** First action: + `git status && git log --oneline -5` (another session may still be running — reconcile, never clobber), then `git checkout -b ssx5-singular-hardening` from current `tiny` HEAD. +- Commit per completed+validated item, ledger updated in the same commit (existing convention: `fix(ssx5): L — ...` / `docs(ssx5): ledger — ...`). +- Coordination protocol from the ledger header: mark an item `CLAIMED()` in the ledger before starting it. +- No pushes, no merges to `tiny` without the user. + +## 2. Open work items (priority order — updated 2026-07-12 EVENING: the queue below is DONE) + +**COMPLETE on `ssx5-singular-hardening` (2026-07-12, commits `ae6a4c6`..`1257fdb`; per-item evidence in the ledger):** +1. [x] **L41** — schema v2 shipped (`complete`/`status.reasons`/`status.work`; `mark_incomplete(reason)` required; reasons measured per case: 12 → overlap+work, 13 → depth_limit, 14 → fiber+tangential+multiplicity+work); adapters flipped to always-return-status with candidate-scaled default allowances; live v4 callers migrated (`boolean2d`×2 + public `ccx()`; `boundary_intersection`/`implicitize` verified on the OLD `_ncsx` adapter — review's caller graph corrected in the ledger); zero-allowance CSX knobs honored; dead knobs removed. +2. [x] **L42** — CSX curved-UV overlap fallback: bounded 4k Phase-2 allowance + continuum chain signature ⇒ `budget_exhausted` + `boundary_topology_complete=False` (repro: 1,679 roots @33,685 cells COMPLETE → 214 @4,006 partial); zero reachability from gate geometry measured before landing. +3. [x] **L28** — `SSXOverlapRegion` per approved Option C in NEW `_ssx5_overlap.py` (self-sampled rims incl. curved-UV, degree-1 stub peeling, multi-region loops, §8 band-rule witness, reason retirement via recorded structural sites); case 12 → 1 region, `complete=True, reasons=[]`; L27 skew = clean negative control. AS-BUILT: §8's "region + separate transversal branch" sub-case is unrealizable for exact polynomial pairs (ledger note). +4. [x] **L43–L46** — c3 pair pricing /128 (case-6 charge 18,731→348), dedup precharge linear (runs at the 1024 cap), NaN chain closed (accept-if gates + NaN-safe binned dedup), closest-point aggregators carry `max_cells`/`stats` + interior heap owns its pop allowance. + +**COMPLETE on `ssx5-singular-hardening` (2026-07-12 EVENING session 2; commits `34f5ba6`.. — per-item evidence in the ledger):** +1. [x] **L25** — edge-graze arc loss: `_march_to_boundary` exit commits only on a certified on-face root (strict bar = the tracer's path certificate); refused exits retarget interior and march PAST the graze. Both loss modes pinned (total strict-kill at d0≲atol·sin; silent truncation at d0≳atol·sin). Case 15 in harness ALL_CASES; 20-variant sweep clean. +2. [x] **L47** — USER DECISION: residual tier. CCX overlaps carry `certification: 'exact'|'tolerance'` (dense inversion pairing ≤ atol, monotone, transverse-FLIP guard — crossing structure never merged, brackets → strict roots); bounded fallback arms on band evidence (inward probes), typed `uncertified_overlap_span` replaces bare budget misbilling; boolean2d shared-edge merge restored. **L55 RESOLVED as a side effect** (rational case-12 twin now complete, reasons=[], 15.3k cells). Residual: woven noise-amplitude twins stay typed-partial (needs a user band-bar decision to merge). +3. [x] **L48** — param-tol pins (test-only). CORRECTION: the new conservative bound is translation-INVARIANT; the review's 7.6e-7 was the old optimistic dispatch. Both legacy consumers translation-stable, now guarded. +4. [x] **L49** — cut-face fibers surfaced (`REASON_PARAMETER_FIBER`, boundary-path parity) via the exact interior-pinch fixture; branch loss REFUTED (3 variants); found: permanent work_budget misbilling from the bounded c1 tier on positive-dim Σ lines (5,693 of 1M cells) → §11.6/L52. +5. [x] **L51** — CSX boundary-exhaustion keeps certified zeros (CCX parity); valley classification gated on complete sets. +6. [x] **L53** — USER DECISION: repair + document. ssx6 filter fixed; stale-pre-budget-fork charter in the module docstring; reviews skip it by charter. +8. [x] **L52 slices 1–4** (`2322399`/`09ebc47`/`e9736d4`): measured-dead code removed (boundary-overcut near-miss caught by the gates, recorded in the ledger); zero-allowance net-build preflights pinned (already present — stale review note); `_nccx4`/`_ncsx4` status twins unified into `_adapter_status.py`; the review probes institutionalized as the exit-coded `examples/ssx/bez_ssx5_budget_contract.py` gate (§3 list updated). Remainder scoped in the ledger's L52 progress block. + +7. [x] **L54** — BOTH audit angles completed same session (Opus agents over `6f362b9..HEAD`, findings main-session-verified): A2 CONFIRMED the on-node crossing absorption in the L47 flip test → fixed (bridged flip + on-node tests) + the offset-twin-crosses-at-parallel-tangent corollary pinned; the z=const≠0 lead REFUTED with measurements; A1 NO FINDINGS (13 leads refuted) + the NaN exit-commit latent weakness hardened (accept-if). Two PLAUSIBLE leads recorded in the ledger for L52's cycle (csx `>12` chain constant on short exact spans; even-crossings-per-interval aliasing on the fixed 65-grid). + +**COMPLETE on `ssx5-singular-hardening` (2026-07-12 continuation session; commits `5ca7507`..`05f0a7c`, 11 commits — per-slice evidence in the ledger's L52 progress block):** +1. [x] **L52 slices 5a–5e** — the core 8-way budget merge COMPLETE at mechanics level: NEW `mmcore/numeric/_work_budget.py` owns REASON_* vocabulary, `SoftWorkBudget` (verbatim `_SSXSoftBudget`), `BernsteinZeroBudget` (+ContextVar), `DownCounter` (ccx/csx paired locals), `LatchingSpend` (c3 `_spend`), `reconcile_reported` (adapter/closest-point clamp family), `charge_hook` (guarded-lambda pattern ×7 sites); module docstring = the explicit charge-semantics registry (4 deliberate families). Policy pieces deliberately unmerged: c1 fair-share double-ledger (slice 9), `solve_zero_dim` charge timing (documented). Milestone adversarial review over `b0a2094..485a57d`: semantics/coverage/imports lenses NO FINDINGS; 1 confirmed minor (hump-pin span assertions) → fixed. +2. [x] **L56 (new)** — ccx exactness-contract suite was stale-red since L47 (outside the gates); all 9 failures verified = the approved L47 tolerance tier by measurement; re-pinned (sharpened property: sub-tolerance offsets NEVER certify 'exact', even 5e-324); both exactness suites + `test_work_budget` joined the §3 unit batch. +3. [x] **L52 slice 11** — coverage harness enforces per-case `status.work` ≤ 2× `bez_ssx5_work_baseline.json` (exit-coded; `--update-baseline`); baselines recorded for all 8 cases. +4. [x] **L52 slice 6 (a/b/c)** — exactness kits: shared `bernstein_product_1d` in `_bezier_common` (csx broadcast shapes + ccx longdouble factors, bit-identity pinned; dead `_eval_curve_longdouble` removed); the EXPLICIT envelope reconciliation (csx affine certificate → ccx's two-term `64·n₁n₂·ε_LD` op + `8192·(n₁+n₂)·ε_f64` source; measured in-axis boundary (3.0,3.5]e-11 → (2.6,3.0]e-11; single-axis offsets unaffected — the survey's raw 64-vs-4096 framing overstated the divergence); csx exclusion prune got the §4 L1 margin (240 exact-Fraction chains: unreachable in that family; pipeline impact ×1.00 on all 8 work baselines). +5. [x] **L52 slice 12 (first item)** — `_nurbs_param_tol` `== 0.0` → `< _TINY` (measured unreachable-overflow; consistency + denormal-sweep pin). +PROCESS: the first slice-5 review workflow run corrupted the tree (agents ran `git checkout` on 11 files) — killed, restored from HEAD, relaunched with an explicit READ-ONLY-git rule (lesson in persistent memory `feedback_workflow_agents_tree_safety`). + +**ALSO COMPLETE same continuation session (commits `d867ca1`..`3324a45`):** +6. [x] **L52 slice 7 (a+b)** — shared `subdivide_curve`/`subdivide_sq_dist_net`/`restrict_net_axis(_v)` + `geometry_collapsed` ×3 in `_bezier_common`; margin policies ×3 resolved by documentation (different certificate shapes); residuals noted in the ledger (centering/cartesian prep ×2, ordered-restriction backend pair, csx one-axis specials). +7. [x] **L52 slice 8** — shared `reject_unknown_kwargs` on all 3 v4 adapters (first contact caught the test suite's own silently-swallowed `rational=`); `nurbs_csx` tolerance renamed `atol=` → `tol=` (adapter-level convention; zero live by-name callers verified); threading disposition documented in the registry (remaining `is not None` guards are site policy, NOT null-object candidates); new gated `tests/test_adapter_kwargs.py`. +8. [x] **L52 slice 9a** — §11.6 de-budget: NEW structural reason `unresolved_singular_set`; c1 wiring maps evidence (shared-dry → work_budget; curve-detected truncation → SINGULAR_SET — pinch fixture AND case 14 measured; no-curve → work_budget); 20k tier KEPT with the §7.3 justifying measurement in the call-site comment (5× tier >2 min without finishing); the "no gate case reports work_budget" INVARIANT enforced in the budget_contract gate. Case 14's residual work_budget = L42-contract boundary-CSX continuum truncation (honest, knob-backed). + +**ALSO COMPLETE (same continuation session, commits `e19fc51`/`37eb45d`):** the slices-6–9a milestone adversarial review (3 minors, all resolved: c1 contention guard `_c1_tier_clamped`, latent nccx-3D-fixture debris, untracked-example disposition) and **L52 slice 10** — A2's leads RESOLVED: (a) the csx `>12` chain constant CONFIRMED-and-fixed via the corner-clipped short-span fixture (lattice-cluster detection with STRICT gap-midpoint verification; never-merge invariant pinned by the valley-chain negative control); (b) 65-grid aliasing verified documented (no change without the user's L47 band-bar decision). + +**ALSO COMPLETE (late continuation session, commits `1efe8c2`..`ff2a10d`):** slice 9b (`unresolved_regions` typed complement — depth dumps, abandoned queues, bail-before-queue early returns all name their 4-D boxes); **L59 SHIPPED** (USER DECISION: theorem-first curve-on-surface overlap certification — see the ledger's L59 entry for the full record: the 5d05ddc regression located by bisection, the user's rationale verbatim, the tier + SSX integration, fixtures A/B at C-quality, all six user overlap scripts tracked as real-data gates); L57 absorbed into L59; L58 (sphere segments) PARKED by user decision. + +**ALSO COMPLETE:** **L60 (two rounds — see the ledger's L60 entry)**: aligned zero exclusion + one-side-pinned band certification + zero-boundary-zero arming with split pricing (the §11.5 drift gate caught the flat price live, twice); script-3 call 2 = ONE overlap in sub-ptol agreement with tiny at 4.2 s (57 s at session start); all six tracked overlap scripts migrated to 3-tuple returns, all running 0.1–4.2 s. + +**Next session (REORDERED 2026-07-12 end of session — merge to `tiny` is BLOCKED on item 1):** +0. [x] **L61 — RESOLVED (user-confirmed stale checkout; merge block LIFTED; verbatim fixtures kept as regression coverage)** — was: **L61 (MERGE-BLOCKING, REPRO-FIRST)** — the user reports the bilinear non-affine family still loses part of the second branch; NOT reproduced at HEAD `f6d0015` (all five constructible variants pass — see the ledger's L61 entry). FIRST action: obtain the user's exact failing script/geometry, pin it as a RED test, then systematic-debugging. Methodology of record: `docs/superpowers/specs/2026-07-12-theorem-first-overlap-methodology.md`. +1. **L52 slice 12 remainder** — §10 efficiency items; flag zoo (`hard_exhausted`/`output_counts`); slice-7 residuals (centering/cartesian prep ×2, ordered-restriction backend pair). +2. **L50** — investigated-open: fix ONLY with a reaching two-tangency fixture (analysis + fix direction in the ledger). **L58** — parked until the user re-opens rational spheres. +3. DEFERRED BY USER mid-session: the PR-to-tiny push + the nurbs_ssx foundation doc — re-confirm with the user before pushing anything. +Review doc (source of truth): `docs/superpowers/plans/2026-07-12-ssx5-budget-review-and-overlap-contract.md`. Subagent policy: default to Opus for scan/verify fan-outs. REVIEW-AGENT RULE (paid for): every repo-Bash agent prompt carries the READ-ONLY-git instruction; commit WIP before launching agent batches; `git status` after each batch. + +**P0 — COMPLETE on `ssx5-singular-hardening` (2026-07-10; pending commit):** +1. [x] **Case 14** (`examples/ssx/bez_ssx5_case14.py`) — instrumentation found the freeze in `_find_ssx_boundary_zeros → bez_csx → _phase2_isolated_search`: an identically collapsed rational apex edge was represented as 16,385 isolated pseudo-roots, then fed quadratic dedup/splitting. CSX now emits a typed parameter fiber through a bounded point-on-surface path; SSX canonicalizes both apex fibers and traces the certified rational tangent generator. The unresolved positive-dimensional Δ complement is honestly partial. +2. [x] **Case 13** (`examples/ssx/bez_ssx5_case13.py`) — crossing-less descendants repeatedly repaid the same Φ seed search after deduplication. Ancestor-box attempt caching plus shared Φ enumeration/marching charges make the search finite. The original script terminates with one certified tangent point and explicit partial status for the unresolved complement. +3. [x] **Global soft budget** — one call-wide allowance covers SSX cells, nested CSX calls/cells, singular/C1/C3 work, output growth, and a separate finite postprocess counter. Top-level independent boundary probes have a 20k local cap; topology-critical internal cuts receive one established 100k allowance, always clamped by the shared remainder. Zero/tiny allowances preflight the superlinear distance-net build. Exhaustion returns certified partial output with `'budget_exhausted': True` and detailed usage counters. + +**P1 — AWAITING USER OUTPUT-SCHEMA DECISION (no implementation started):** +4. [ ] **Case 12 / L28** (`examples/ssx/bez_ssx5_case12.py`) — true answer is a 2D surface-overlap region. The choices to present are: (A) tagged 3D boundary branch(es), (B) a structured trimmed-region entity, or (C, recommended) the structured region with paired parameter-space loops on both surfaces plus references to compatibility boundary branches. Do not claim or implement L28 until the user selects the contract. + → 2026-07-12: full contract proposal (refined Option C with dataclass, tolerance ladder, test list), the budget-concept review with measured evidence, and 15 ranked verified findings on `5d05ddc` are in `docs/superpowers/plans/2026-07-12-ssx5-budget-review-and-overlap-contract.md`. Blocking user decisions listed in its §11 step 0. + +**P2 — previously open ledger items:** +5. [ ] **L25 — edge-graze transversal arc loss** (pre-existing at `40a79a1`). Not started because priority stops at the pending P1 schema decision. Use the minimal edge-graze geometry, coverage harness, and crossing-lifecycle instrumentation (2026-06-09 playbook) before any fix. +6. [x] **L26 — rational Ψ in the tangency witness** — completed as part of P0: Δ/Φ/tangency decisions now evaluate homogeneous rational surfaces exactly rather than per-control-point dehomogenized polynomial surrogates. +7. **L27 residuals** (documented, not regressions) — only on demand. + +**Also completed:** the coverage harness honors a module-level `RATIONAL` flag and rejects incomplete reference CSX slices. Cases 12–14 are registered as L28–L30. The proactive overlap/rational/no-hang review added and fixed L31–L40; see the ledger for tests and evidence. + +## 3. Non-negotiable validation gates (run after EVERY task, before every commit) + +```bash +.venv/bin/python examples/ssx/bez_ssx5_coverage_check.py # exit 0: 8 cases (incl. 15 = L25 edge-graze), 100% coverage AND zero spurious singularities AND per-case work ≤ 2× bez_ssx5_work_baseline.json (all enforced; refresh baseline deliberately via --update-baseline) +.venv/bin/python examples/ssx/bez_ssx5_budget_contract.py # exit 0: schema-v2 contract + residual sanity + determinism, 2 runs/case (L52 / review §11.5) +.venv/bin/python -m pytest tests/test_bez_ssx5_singular.py -q # the singular gate (113 tests at last count) +.venv/bin/python -m pytest tests/test_bez_csx4.py tests/test_bez_ccx4.py tests/test_bez_ccx3_cases.py tests/test_bezier_common.py tests/test_bezier_curves_overlap.py tests/test_nurbs_param_tol_regression.py tests/test_ccx4_exactness_contract.py tests/test_csx4_exactness_contract.py tests/test_work_budget.py tests/test_adapter_kwargs.py -q # exactness/budget/kwarg contracts joined the batch 2026-07-12 (L56: contract suites outside the gates go stale-red silently) +``` +Plus: legacy 4 mini-cases (planes / transversal / tangential / overlaps — overlaps now correctly 2 branches + 1 junction `tangent_point` since L27); timings within ~1.2× of the numbers recorded in the ledger/memory. A "fix" that trades coverage or adds spurious singularities is a regression, full stop. + +## 4. Invariants that were paid for in blood (do not re-learn them) + +- **A parametric box is not a metric ball.** Every destructive op (dedup, subsumption, unification) carries BOTH a param radius AND an xyz guard. Ladders: destructive = 1·ptol ∧ xyz ≤ atol; matching/unification = 4·ptol box ∧ xyz ≤ 2·atol. +- **Sound prunes only.** Never prune a cell/box from one Newton trajectory's behavior; inconclusive ⇒ subdivide; termination comes from hull certificates + resolution floors + budgets (`max_cells`), never from optimism. +- **Sign/hull tests need roundoff margins** (L1): exclusion only beyond `k·eps·max|coeff|`; margins make exclusion stricter (sound direction). +- **Never merge or report tolerance-touches in CSX** (sub-atol valleys): it destroys sub-tolerance topology (near-tangent loops). The G-net component sign prune is what keeps CSX fast — keep it sound. +- **Marchers are xyz-reparameterized**: step target `h` in xyz via local speed; deviation control = sagitta + mid-chord verification (S-span blind spot!); exit chords verified; on rejection make geometric progress (halfway retargeting), don't just halve; stagnation escapes bounded at 25; bounce = max-over-path xyz ≤ atol. `_march_to_boundary` returns a 3-tuple `(stuv, xyz, exit_info)`. +- **Determinism**: no module-global PRNG (L17); deterministic mid-plane slices instead of random hyperplanes (user decision). +- **Units**: everything in model units; `atol` (default 1e-3) is the only user tolerance; report deviations as multiples of atol, never invent "mm". +- **All solvers budget-bounded** — the original no-hang guarantee still stands. +- User decisions on record: `result['singularities']` typed list + `SSXBranch.kind`; Bernstein Φ∩L subdivision solver (not Krawczyk/random-L); C₂ multiplicity ≥ 3 out of scope (paper's own limit). + +## 5. Environment quirks + +- Python: `.venv/bin/python` (3.14). `timeout` command does not exist on this Mac (use `run_in_background`). `/tmp` gets wiped (keep durable artifacts in-repo; the coverage harness IS the durable diagnostic). `rich` once vanished from the venv (`pip install rich` fixes the `_ncsx2` import chain). +- Useful instrumentation patterns (all monkeypatch, no source edits): LoggedCell subclass of `_Cell`, wrapped `_trace_cell_by_registrations`, wrapped `bez_csx` — see the handoff doc's debugging playbook. + +## 6. Process + +- `superpowers:systematic-debugging` for any defect (root cause before fixes — this is how L-series items were found); TDD for every fix (failing test first, into `tests/test_bez_ssx5_singular.py` or `tests/test_bez_csx4.py`). +- After any substantial milestone: run an adversarial multi-agent review workflow over the diff (two of these caught 3 confirmed majors each in freshly-written, test-passing code). Verify findings before acting on them; append confirmed ones to the ledger. +- End of session: update the ledger checkboxes, memory files, and this kickoff doc's §2. diff --git a/docs/superpowers/plans/2026-07-12-ssx5-budget-review-and-overlap-contract.md b/docs/superpowers/plans/2026-07-12-ssx5-budget-review-and-overlap-contract.md new file mode 100644 index 00000000..f8c4255f --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-ssx5-budget-review-and-overlap-contract.md @@ -0,0 +1,572 @@ +# SSX v5 — budget concept review, overlap output contract (L28/L25), next steps + +**Written:** 2026-07-12, independent review session over branch `ssx5-singular-hardening` +(scope = the one commit vs `tiny`: `5d05ddc` "fix(ssx5): harden singular and rational +intersections", +7,857/−573 across 27 files). +**Inputs:** 11-angle adversarial review of the diff (findings in §10), two purpose-built +empirical probes (§5), the validation gates re-run at HEAD, a survey of how other kernels +bound computation (§4), and the reference papers. + +--- + +## 1. Verdict (TL;DR) + +1. **The budget concept is sound and should stay — but not in its current public shape.** + Every serious kernel bounds work; what varies is honesty. mmcore's variant + (deterministic work units, one shared call-wide ledger, certified-partial output, + usage counters) is the *most* honest variant in the taxonomy (§4). It is not a + CAD-specific invention — it is the Z3-`rlimit` / SciPy-`maxiter+nfev` contract applied + to geometry, which CAD kernels have historically avoided by hiding their caps. +2. **Measured reliability is excellent** (§5): all gates green at HEAD; on every regular + coverage case the default budget has ≥4.4× headroom; a 49-run tiny-budget fuzz produced + **0 crashes, 0 contract violations**, only verified geometry (worst residual ≈ 0·atol + even under hard truncation), correct flags, and bit-identical determinism under binding + budgets. The soft-stop design does what it promises. +3. **The real defect is semantic, not mechanical: `budget_exhausted` conflates three + unrelated causes** — hard budget exhaustion, structural partiality (no overlap schema, + unresolved fibers/multiplicity), and output/postprocess caps. Measured proof: case 12 + converges at 4,702 cells (1.9% of allowance), identically at any budget, yet reports + `budget_exhausted=True`. A consumer who reads that name will raise budgets and change + nothing. Rename before anything public consumes it (§6). +4. **The user should never manage budgets — and today they don't have to.** All knobs have + defaults; the fields are read-only diagnostics. Keep it that way: the roadmap in §7 + drives the *hard*-exhaustion rate to zero on well-posed input (classification first, + budget as backstop), which is exactly the "improve early exits and case classification" + direction — the two are complements, not alternatives. Removing the backstop would + reintroduce the case-13/14 freezes; fixed-precision tangency/multiplicity decisions + cannot be classified exhaustively (L31's own honest-partial limit). +5. **L28 (2-D overlap region): recommend contract Option C** — a structured + `SSXOverlapRegion` that *references* rim branches and carries paired parameter-space + loops + certification (§8). Additive to the result schema; lands best together with the + §6 rename. +6. **L25 is sequencing-coupled to L28, not schema-coupled** (§9): an edge-graze arc is + transversal (measure-zero), so it does not need the region type — but its fix lives in + the same boundary-claim/rim machinery L28 will harden, and the overlap-vs-transversal + tolerance ladder should be decided in the L28 spec so L25 has a rule to lean on. +7. Review outcome (§10): the SSX core's exhaustion semantics hold under fuzz, **but the + review found real defects at the edges**: (a) two budget *mis-pricing* bugs that make + exhaustion fire spuriously at ordinary scale (C3 broadphase pair-tests priced as + cells; point-dedup precharged O(n²) for an O(n) algorithm — the dedup is then + *skipped*); (b) a crash-regression class in the NURBS adapters, whose new default + **raises `RuntimeError` on any incomplete sub-result** — and every production caller + (`boolean2d`, `implicitize`, ssx `boundary_intersection`) uses the default; (c) CCX + overlap semantics silently narrowed from tolerance-based to exact-affine certification + (near-coincident pairs now lose all geometry); (d) a NaN acceptance→crash chain; plus + the consolidation debt (8 hand-rolled budget accountings with diverging semantics, + duplicated exactness kits, dead knobs). Ranked list with verdicts in §10. + +--- + +## 2. What this branch actually adds + +One commit, four thrusts (ledger items in parentheses): + +| Thrust | Content | +|---|---| +| No-hang fixes | Case 13 (L29): ancestor-box attempt caching + shared Φ charges. Case 14 (L30): collapsed rational apex edge typed as a **parameter fiber** in CSX (16,385 pseudo-roots → one typed positive-dimensional entity) | +| Global soft budget | `_SSXSoftBudget` threaded through SSX + nested CSX + singular/C1/C3 passes + postprocess; result fields `budget_exhausted` / `budget_usage` | +| Rational hygiene | L26: tangency witness / Δ refiner / Φ selection / singular trace on true homogeneous quotients | +| Audit batch | L31–L40: multiplicity valleys, zero-allowance preflight, NURBS-adapter aggregate budgets, postprocess charging, closest-point continuation budgets, translation-invariant exactness certificates, overlap-subset dedup, 20k/100k cap policy, CSX depth 64, no retry double-spend | + +Main files: `_bez_ssx5.py` (+2,781), `_bez_ccx4.py`/`_bez_csx4.py` (+659 each), +`_ssx5_singular.py` (+401), adapters `_nccx4.py`/`_ncsx4.py`, `_bern_zero_1d.py`, +`_bez_closest_point.py`, `_nurbs_param_tol.py`, ~3.3k test lines. + +## 3. The budget mechanism as built (inventory) + +`_SSXSoftBudget` (`_bez_ssx5.py:54`): `max_cells=250_000`, `max_csx_calls=10_000`, +`max_output_items=1_024`, `max_postprocess_work=None→max_cells`; counters + two flags +(`exhausted`, `incomplete`) + `postprocess_exhausted`. + +**Charge classes** (from `budget_usage.cell_counts` at HEAD): `precompute` (distance-net +preflight, charged *before* the superlinear build — L32), `csx` (nested CSX actual usage, +charged after each child from its reported `cells_processed`), `ssx` (subdivision pops), +`branch_trace`/`branch_trace_verify`, `singular`/`phi`/`singular_trace`/`singular_dimension`, +`c1`, `c3`, `fiber_promotion`. Postprocess work is a **separate** counter so a hard-stopped +search can still assemble certified fragments (`_assembly_spend`, `_bez_ssx5.py:4839`). + +**Tier policy:** independent top-boundary CSX probes get `min(20k, remaining)` and their +truncation is *soft* (face discarded, run marked incomplete, other faces continue); +topology-critical internal cuts get one established `min(100k, remaining)` and truncation +is a *hard* stop (a truncated root set never drives topology decisions — +`_run_csx`, `_bez_ssx5.py:6309`). The C1 pass has its own fixed +`min(20_000, remaining)` tier (`_bez_ssx5.py:7694`) — see §7.3. + +**Exhaustion semantics by stage** (all verified by reading + fuzz): +search = stop scheduling, keep certified output; CSX child = discard-or-stop per tier; +assembly = stop optional scans conservatively; **verification filter = omit any branch it +lacks allowance to verify** (`_bez_ssx5.py:5474` — the sound direction: nothing unverified +ships); `result_fields()` publishes `budget_exhausted = exhausted OR incomplete` plus the +counters. No budget path raises; the only `raise` sites in `_bez_ssx5.py` are non-budget +internal errors. The v6 draft (`_bez_ssx6.py:_require_complete_csx_result`) deliberately +raises on unsafe CSX input — an integration-boundary assertion, pinned by +`tests/test_bez_ssx6_contract.py`, not a user-facing behavior. + +**Flag zoo (defect):** three internal booleans, four published views +(`budget_exhausted`, `hard_exhausted`, `incomplete`, `postprocess_exhausted`); +`hard_exhausted` and `output_counts` have **zero readers** in the entire repo (verified by +grep); `charge_postprocess` denial sets all three flags in lockstep by convention only. +`solve_zero_dim`'s new `stop_when`/`stop_requested` machinery has zero callers. + +## 4. Is a budget concept CAD-specific? Survey of other engines + +No — bounding work is universal; **what differs is whether the bound is admitted.** +Four regimes: + +| Regime | Representatives | User-visible? | On hitting the bound | +|---|---|---|---| +| **Hidden internal caps** | SISL (recursive subdivision + iteration, caps and recursion limits internal), OCCT's internal walkers/iteration ceilings, essentially every marching/subdivision intersector of the 1990s lineage | No | Silent partial result or silent success — the classic "boolean returned garbage with no warning" failure class | +| **Cooperative cancellation + transactional state** | Parasolid: registered interrupt → `PK_SESSION_abort`, model recovered via (partitioned) rollback; ACIS: `outcome` objects + `CheckOutcome` progress/interrupt interface; OCCT: `Message_ProgressRange` / `UserBreak()` + BOPAlgo error/**warning alert reports** on completion | Partly (cancel + status) | Clean failure; model state restored; alerts enumerate what went wrong | +| **Declared deterministic work metering** | Z3 `rlimit` (deterministic counter → result `unknown` + reason, reproducible across machines/load); every numerical library's `maxiter` + `nfev` + `status` (SciPy, IPOPT, …); gas/fuel metering in VMs as the general pattern | **Yes** | Honest status: "here is what I proved, here is what I spent, the answer is incomplete" | +| **Exact computation** | CGAL | N/A | No budget — unbounded time instead; a different contract, not a free lunch | + +mmcore's `_SSXSoftBudget` is squarely regime 3 — with two properties even Z3 doesn't +promise: **certified partial output** (everything returned is residual-verified; the fuzz +in §5 confirms it) and **cross-subsystem sharing** (SSX + nested CSX + singular passes +spend one ledger; the pre-L33 per-span reset bug is exactly what regime-1 engines never +notice). The genuinely uncommon part relative to commercial kernels is *exposing usage +counters in the result* — which numerical computing has done for fifty years (`nfev`), +and which is what makes §7.4's CI regression tracking possible at all. + +Two implications for us: +- **We are not out on a limb.** OCCT already ships "the operation finished with warnings, + here is the alert list" — our `complete/status` proposal (§6) is that, made precise. +- **We should adopt the one thing regimes 2 have that we lack:** a crisp separation + between *the answer is partial* (status) and *why* (reasons). That is §6. + +Sources: [Parasolid error handling / rollback](http://www.q-solid.com/Parasolid_Docs/chapters/fd_chap.60.html), +[Parasolid session support](http://www.q-solid.com/Parasolid_Docs/chapters/fd_chap.59.html), +[OCCT BOPAlgo_Options (progress, alerts)](https://dev.opencascade.org/doc/refman/html/class_b_o_p_algo___options.html), +[OCCT BOPAlgo_Algo](https://dev.opencascade.org/doc/occt-7.6.0/refman/html/class_b_o_p_algo___algo.html), +[aborting long OCCT computations](https://incoherency.co.uk/blog/stories/freecad-abort-long-running-operations.html), +[ACIS outcome/progress/interrupt](https://blog.spatial.com/3d-acis/outcome-checking-logging-and-progress-reporting-3d-acis), +[Z3 rlimit issue #56](https://github.com/Z3Prover/z3/issues/56), +[Z3 rlimit reproducibility discussion #611](https://github.com/Z3Prover/z3/issues/611), +[F* on Z3 rlimit semantics](https://fstar-lang.org/tutorial/book/under_the_hood/uth_smt.html), +[SISL / Dokken intersection lineage](https://www.sintef.no/projectweb/computational-geometry/intersections/). + +## 5. Measured reliability at HEAD + +**Gates:** `bez_ssx5_coverage_check.py` exit 0 (7 cases, 100% coverage, zero spurious +singularities); `test_bez_ssx5_singular.py` + `test_bez_ssx6_contract.py` — 91 passed. + +**Probe 1 — default budgets, cases 5–14** (usage of `max_cells=250_000`): + +| case | t, s | cells | % | csx_calls | flags | note | +|---|---|---|---|---|---|---| +| 5 | 4.7 | 21,076 | 8.4% | 155 | complete | | +| 6 | 9.9 | 56,552 | 22.6% | 292 | complete | worst regular case → headroom 4.4× | +| 7 | 4.5 | 21,804 | 8.7% | 216 | complete | | +| 8 | 2.3 | 15,755 | 6.3% | 50 | complete | | +| 9 | 2.2 | 7,824 | 3.1% | 51 | complete | | +| 10 | 9.1 | 42,585 | 17.0% | 261 | complete | | +| 11 | 11.7 | 49,913 | 20.0% | 263 | complete | the L38/L40 regression case | +| 12 | 0.9 | 4,702 | **1.9%** | 8 | `budget_exhausted=True`, hard=False | **structural**: no overlap schema (L28) — identical at 5k/20k/60k budget | +| 13 | 9.8 | 31,480 | 12.6% | 212 | `budget_exhausted=True`, hard=False | finds its tangent_point; honest partial for the near-tangent complement | +| 14 | 64.5 | 76,286 | 30.5% | 8 | `budget_exhausted=True`, hard=False | finds branch + cusp_curve + cusp; `c1` charge = 20,000 exactly — its tier saturated (§7.3) | + +Branch-sample residuals (points evaluated on both surfaces): ≈0·atol in every case. + +**Probe 2 — exhaustion fuzz** (cases {5,7,11,12,13,14} × `max_cells` {0,1,100,1k,5k,20k,60k} ++ tiny `max_csx_calls`/`max_output_items`/`max_postprocess_work` axes + repeat-call digests): +**49 runs, 0 crashes, 0 contract violations.** Specifically: schema always present; no +NaN/inf; stuv always in [0,1]⁴; worst branch residual 0.00·atol in *every* run including +hard-truncated ones; `hard_exhausted ⇒ budget_exhausted` held; tiny budgets stop in +≤0.2 s (the no-hang promise); results under a binding budget are **bit-identical across +repeated calls** (cases 7/13 @5k, 12 @60k). + +Two behaviors worth naming: +- **Flags are conservative, in the sound direction:** at `max_cells=20k` cases 5/7 ship + the full correct geometry yet still flag `budget_exhausted=True` (a sub-pass was denied; + completeness is no longer *proven*). A `True` flag with complete output is possible; + a `False` flag with incomplete output was never observed. +- **Crash exposure in `bez_ssx` v5 itself ≈ 0:** v5 never raises on budget paths; the + fail-fast `RuntimeError` lives in the un-integrated v6 draft as a deliberate contract. + **However**, the NURBS *adapters* (`_nccx4`/`_ncsx4`) now raise by default on any + incomplete sub-result, and all production callers use the default — that is the one + real crash-regression class this commit ships (§10, findings 1–2). + +## 6. The naming defect and the schema fix (do this before public integration) + +Today `budget_exhausted=True` means any of: (a) the work ledger ran dry +(`hard_exhausted`), (b) the result is structurally partial though the ledger has 97%+ +remaining (case 12 — missing L28 schema; case 13 — unresolved positive-dimensional +complement; boundary fibers), (c) an output/postprocess cap tripped. Three different +consumer reactions are required (raise budget / wait for schema / raise cap), and the +name steers all three to the wrong one. `budget_usage` partially disambiguates +(`hard_exhausted` vs `incomplete`) but nothing reads it, and the name still frames a +completeness question as a resource question — which is precisely the "shifting budget +responsibility onto the user" smell. + +**Proposal (schema v2):** + +```python +result = { + 'branches': [...], 'points': [...], 'singularities': [...], + 'overlap_regions': [...], # new, L28 (§8) + 'complete': bool, # the one bit consumers act on + 'status': { + 'reasons': [ # empty iff complete + 'work_budget', # hard ledger exhaustion — raising budget can help + 'output_cap', 'postprocess_cap', + 'parameter_fiber', # structural — raising budget cannot help + 'overlap_region_unsupported',# retired by L28 + 'unresolved_tangential_zone','unresolved_multiplicity', + ], + 'work': { ...current budget_usage counters, incl. cell_counts... }, + }, +} +``` + +- Nothing outside tests/examples consumes `budget_exhausted`/`budget_usage` yet (the + public `ssx()` does not route through `_bez_ssx5` — verified by grep), so the rename is + cheap **now** and expensive after BRep integration. +- Keep the kwargs (`max_cells`, …) as expert knobs with the current defaults. The user + contract becomes: *read `complete` (and `reasons` if you care why); never tune knobs to + get correctness.* +- Fold the flag zoo into it: one internal status object; delete the unread + `hard_exhausted`/`output_counts` published fields (or move under `work`); make + `reasons` the single source of truth the internal `mark_incomplete()` sites feed with a + reason string instead of a bare boolean. +- Update `_bez_ssx6._require_complete_csx_result` + its contract test and the coverage + harness reader in the same change. + +## 7. Keep budgets at all? (the user's alternative: better exits + classification) + +The measured record says the two are complements with a clear division of labor: + +1. **Classification does the correctness work.** Cases 13/14 terminate *because* the + commit classified their degeneracies (fiber typing, attempt caching) — not because a + budget truncates them. Regular cases never get near the ledger (§5). This is the + direction to keep investing (L28 next — §8). +2. **The budget does the honesty work.** Fixed-precision classification is provably + incompletable: L31 keeps degrees beyond its Decimal fallback "explicitly partial"; + the scale envelope degrades at ×300; sub-2e-4-eps trap sheets sit below the float GN + stall bar (documented in `_delta_float_gn`). For inputs outside every classifier, the + choice is: hang (pre-branch behavior), lie (regime-1 engines, §4), or bound-and-report. + Bound-and-report is correct. +3. **Drive the firing rate to zero — make hard exhaustion a monitored never-event:** + - **L28 region schema** retires the biggest *structural* flag class (case 12). + - **Type the case-13 complement**: emit the unresolved Δ-complement as a typed + diagnostic entity (its 4-D AABB + reason) instead of a bare flag, so "partial" + always names *what* is unresolved. Same pattern as `parameter_fibers`. + - **Audit fixed tiers against the shared remainder** — the C1 pass's + `min(20_000, remaining)` (`_bez_ssx5.py:7694`) saturates on case 14 (charge exactly + 20,000 with ~174k shared cells unspent). This is the same disease L38/L40 just fixed + for internal CSX cuts; either give C1 the established-allowance treatment or justify + the tier in a comment with a measurement. + - **Unit-pricing audit.** A budget is only as honest as its exchange rates: §10 found + C3 AABB pair-tests (~ns of numpy) charged 1:1 like subdivision cells (~ms), and a + postprocess precharge quoting the pre-rewrite O(n²) cost for an O(n) algorithm. + Rule to adopt: *charge units must be proportional to real cost* (the `precompute` + pair-coefficients/128 convention is the template); any `charge(N²)` on a linear + algorithm is a bug. + - **CI observability**: the gates should record `status.work.cells_processed` per case + and fail on >2× drift — headroom regressions become visible the day they happen, not + the day a user's model freezes. (This is what the counters are *for*; it is also the + argument for keeping them in the result.) + - **One budget implementation.** The review found the same accounting hand-rolled + 8 times with already-divergent charge semantics (§10). Semantic drift across copies + is how a sound design decays; consolidate into one shared class/module. + +## 8. L28 — the overlap-region output contract (decision needed) + +**Problem.** Case 12 (`examples/ssx/bez_ssx5_case12.py`): two coplanar bilinear patches +sharing an edge + a corner, interiors overlapping in a 2-D region — Cheng et al. Fig. 8, +the C2 sub-case #(Δ_B)=∞, 2-dimensional. The branch/point schema cannot express it; at +HEAD the case deterministically returns its 2 rim curves as `overlap` branches + +`budget_exhausted=True` (structural). Neither reference paper prescribes an output +structure (Krishnan–Manocha assume generic position; Cheng et al. classify but do not +represent), so this is genuinely our API decision. Industry practice for coincident +regions (STEP/Parasolid "same-domain" faces) is: region = boundary loops with **pcurves +in both parameter spaces + shared 3-D edges** — i.e., exactly option C below. + +**In-repo precedents to stay consistent with** (the dimension ladder): + +| Solver | 0-dim image | positive-dim preimage of a point | positive-dim image | +|---|---|---|---| +| CCX | `isolated` t-pairs | — | `overlaps`: paired t-intervals | +| CSX | `isolated` (t,u,v) | `parameter_fibers` (collapsed curve → fiber over one surface point) | `overlaps`: certified {t_range, u_range, v_range} claims | +| SSX | `points` | (fibers propagated from CSX) | **today:** 2-pt `overlap` chords; **L28:** `overlap_regions` | +| closest-point | `min` points | `degenerate_curve` rings | `degenerate_surface` patches | + +**Options** (from the kickoff, refined): + +- **A — tagged 3-D boundary branches only.** Cheapest; no region identity; every consumer + (booleans, trimming) must re-derive loop topology and pairing itself. Rejected: it + reproduces the L27 corrupt-pairing class downstream in every consumer. +- **B — standalone trimmed-region entity** with materialized uv loops on both surfaces. + Self-contained, but *duplicates* rim geometry that also ships as branches — two sources + of truth for the same curves, the exact drift the both-guards conventions exist to kill. +- **C — structured region referencing rim branches (RECOMMENDED, refined):** + +```python +@dataclass +class SSXOverlapRegion: + """C2 positive-dimensional component: S1 ≡ S2 (within atol) over a 2-D region. + Cheng et al. Fig. 8, #(Δ_B)=∞ / 2-dimensional (full or partial overlap).""" + boundary: list[list[tuple[int, bool]]] + # one inner list per closed rim loop; entries (branch_index, reversed) + # reference result['branches'] (kind='overlap' rim curves), ordered head-to-tail; + # loop 0 = outer, others = holes (islands where the surfaces depart). + uv1_loops: list[NDArray] # (N_i, 2) closed polylines on S1 — materialized from the + uv2_loops: list[NDArray] # referenced branches' stuv columns; sample-synchronized: + # uv1_loops[i][k] and uv2_loops[i][k] are preimages of the same 3-D point. + normal_agreement: int # +1 aligned normals over the region, −1 opposed + # (booleans/trimming need it; constant over a connected coincidence region) + interior_stuv: NDArray # (4,) certified interior witness (point-in-region seed) + certification: dict # {'boundary_resid_max', 'interior_resid', 'n_samples'} in atol units +``` + + Semantics and rules: + - `result['overlap_regions']` is additive; `branches`/`points`/`singularities` + unchanged → all existing consumers keep working (and option A is a projection of C). + - Rim branches stay `kind='overlap'`, but get **properly sampled** (the current 2-point + chords are an L27 documented residual; curved genuine overlaps need real sampling — + fold that upgrade into L28 since the region certification needs the samples anyway). + - Rim loop elements are overlap branches (an edge-of-S1 lying on S2 *is* an intersection + curve — CSX already certifies these claims post-L27); loops may also traverse + junction `tangent_point` singularities (L27's structural junctions) — junction + vertices need no new type. + - Orientation: loops ordered so the region is on the left in S1's (u,v); `uv2` loop + orientation then encodes `normal_agreement` redundantly (assert-consistency). + - **Tolerance ladder (also the L25 hinge, §9):** a coincidence band of width < atol in + the transverse direction stays a curve (`overlap` branch, no region); a 2-D region + requires an interior witness at ≥ 4·ptol from every rim loop with residual ≤ atol. + Destructive dedup near rims keeps the established both-guards ladder. + - Sub-cases the spec must pin with tests: partial overlap (case 12: expect **1 region, + rim = shared-edge branch + interior rim branches, plus the existing junction + semantics**); full containment (one loop entirely from one surface's domain-edge + images); identical patches (region = whole domain, 4 domain-edge rim pieces); + L27's skew fixture as a **negative control** (curve-only overlap ⇒ 0 regions); + rational twin of case 12; region + separate transversal branch elsewhere. + - CSX/CCX stay as they are (their `overlaps` are the 1-D analogue and already carry + certified endpoints); SSX's region assembler consumes CSX overlap claims + traced + rim fragments. + +**Where it lands:** assembly stage of `_bez_ssx5.py` (a new `_ssx5_overlap.py` module for +loop assembly + certification), after the L27 claim-verification machinery. Schema v2 +(§6) first, so the region ships with `reasons=[]` on case 12 rather than a budget flag. + +## 9. L25 (edge-graze transversal arc loss) — relationship to the overlap decision + +Known facts: pre-existing at `40a79a1`, found by review lens [A m6]; the detailed +evidence expired with the session task dir; the kickoff prescribes re-pinning with the +minimal edge-graze geometry + coverage harness + crossing-lifecycle instrumentation +(2026-06-09 playbook) before any fix. + +Assessment: an edge-graze is a **transversal** arc tangent to a patch *domain* edge — a +measure-zero feature, so it does not need `SSXOverlapRegion` as its output type. The +user's instinct that L25 and L28 are linked is still right, twice over: + +1. **Shared code locus.** A grazing arc lives exactly where CSX boundary claims, rim + verification (L27), and the tracer's graze-signature exits meet — the same machinery + L28's rim-loop assembly will harden and regression-pin. +2. **Shared tolerance ladder.** Near the graze, "short overlap claim on the edge" vs + "transversal arc that kisses the edge" is decided by the band-width rule — which §8 + defines. Fixing L25 before that rule exists invites another round of L27-style + corrupt-claim ambiguity; fixing it after inherits a spec. + +Hence the sequencing below (contract → L28 → L25), which also matches the kickoff's +priority order. The L25 work item itself: build the minimal graze fixture (plane vs +patch whose SSI is tangent to u=0 at one point), instrument the crossing lifecycle, +verify where the arc is dropped (claim filter vs tracer exit vs assembly), then fix at +that altitude with the harness asserting arc coverage. + +## 10. Review findings (11-angle adversarial review + verification) + +Method: 11 independent finder angles over the diff (line-scan ×2, removed-behavior, +cross-file tracer, language pitfalls, wrapper correctness, reuse, simplification, +efficiency, altitude, conventions), then verification. Verification was partly +re-planned mid-session (the account's session limit killed the A1 line-scan agent and +prevented a separate verifier fleet): the verdicts below rest on (a) finders' own +executed repros (marked *measured*), (b) main-session re-runs and direct line reads +(B2 re-reproduced, H3/H5/D2/E3 read at the exact lines, B1/B4 caller graph grepped). +Coverage note: both dedicated line-scan angles (A1: SSX files; A2: CCX/CSX/closest-point +files) were killed mid-scan by the session limit — their territory was substantially +but not exhaustively re-covered by the removed-behavior/tracer/wrapper/efficiency +angles, so a follow-up line-scan of `_bez_ssx5.py`'s new hunks is cheap insurance +(A2's dying lead worth checking: strict residual gates on curves lying in a plane +z = const ≠ 0). The conventions angle returned clean (no CLAUDE.md violations; no +>3.9 syntax; Black 140 respected). + +**Ranked findings** (severity-first; CONFIRMED = executed repro or direct line evidence, +PLAUSIBLE = mechanism verified, trigger fixture not yet built): + +1. **[CONFIRMED, measured] CSX overlaps narrowed to affine-certifiable only — curved-UV + exact overlaps silently flood as isolated roots and are reported COMPLETE.** + `csx/_bez_csx4.py:1487`. Parabola lying exactly on a bilinear patch: `tiny` → + `{isolated:0, overlaps:1}`; HEAD → `{isolated:1679, overlaps:0, + budget_exhausted:False, boundary_topology_complete:True}` after 33,685 cells. No flag + anywhere; every downstream consumer accepts it. CCX received a non-affine fallback in + this same commit; CSX did not. *This is the worst finding: silent wrong topology, + flagged complete.* Fix: port the CCX fallback (or emit an honest partial). +2. **[CONFIRMED] `nurbs_ccx`/`nurbs_ccx_multiple` default-raise on any incomplete span + crashes production callers.** `ccx/_nccx4.py:120` (raise), callers + `topo/brep/boolean2d.py:80/420`, public `ccx()`. Near-coincident or non-affine + same-support region curves → `RuntimeError` inside 2-D booleans that previously + returned. One such span pair measured 25,476 cells, so a handful exhausts the new + shared 100k allowance even without degeneracy. +3. **[CONFIRMED, live] `nurbs_csx` default-raise on `parameter_fibers` crashes the legacy + path on exactly the geometry this commit fixed at the Bezier level.** + `csx/_ncsx4.py:173`; callers `ssx/boundary_intersection.py:226/253`, + `implicitize.py:460` pass no `return_status`. Collapsed edge (cone apex / sphere + pole) on-surface → `RuntimeError`; pre-commit returned (slow) results. +4. **[CONFIRMED, re-run] CCX overlap loss for near-coincident / non-affinely-parameterized + coincident pairs.** `ccx/_bez_ccx4.py:898`. Cubic vs itself +1e-9 (atol=1e-3): old → + overlap (0,1); HEAD → 0 isolated + 0 overlaps, 2,015 cells, `budget_exhausted=True` + (misattributed to budget). For an offset pair "empty" is arguably the exact-semantics + answer, but a same-locus non-affine reparameterization (a genuine overlap) is also + uncertifiable and lost, and the failure is billed to the budget instead of to the + certificate. Needs the same non-affine fallback as #1 + reason-correct status. +5. **[CONFIRMED] C3 broadphase mis-pricing can burn the whole budget on numpy noise.** + `_ssx5_singular.py:1360`: every AABB pair test (~ns, vectorized) is charged one shared + cell (~ms of real work elsewhere). ~707 polyline segments → 250k pairs = the entire + default budget spent on ~10 ms of numpy; C3 truncates and the run flags + `budget_exhausted` on ordinary-sized output. (Probe corroboration: `c3` is already + the #2 cell consumer on regular cases — 18.7k of case 6's 56.5k.) Price as + pairs/128 like the `precompute` convention. +6. **[CONFIRMED] Final point-dedup precharges the pre-rewrite O(n²) cost, then SKIPS + dedup when denied.** `_bez_ssx5.py:7832-7835` charges `n²` postprocess units for the + new O(n·108) bucketed `_deduplicate_ssx_points`; at n>500 (always at the 1024 output + cap: 1,048,576 > 250k) the charge is denied → exactly the dense pseudo-root outputs + the rewrite targeted ship **un-deduplicated** with a spurious partial flag. +7. **[PLAUSIBLE ×2 independent finders] Cut-face CSX consumers drop `parameter_fibers` + unflagged → silent branch loss through interior pinches.** `_bez_ssx5.py:5986` + + multi-cut loops (7207/7256) read only `result['isolated']`; the fiber-producing CSX + path returns `budget_exhausted=False`, so nothing is even marked partial. Same loss + class case 14 fixed, one consumer over. Needs a pinched-interior-isoline fixture, + then one shared CSX-result adapter for all three consumer sites. +8. **[CONFIRMED] Closest-point NURBS aggregators drop the budget signal entirely.** + `_bez_closest_point.py:1272` (+1156): no `stats=`, no shared allowance, no kwarg to + raise the 20k default; a patch hitting its cap warns and returns far-local-min + entities that the aggregator merges as the certified globally-closest set — silently + wrong answer at the public level. Related [PLAUSIBLE] `:843`: under the new single + shared budget the 4 boundary searches can starve the interior heap to zero pops + (old code gave the interior its own allowance). +9. **[CONFIRMED crash repro; trigger PLAUSIBLE] NaN → `math.floor` crash in the new + binned dedup discards a whole completed run.** `_bez_ssx5.py:305`: `ValueError` on + NaN / `OverflowError` on inf (legacy comparison dedup was NaN-safe). Feeder: + **[CONFIRMED mechanism]** `:606` — strict gate written reject-if-greater + (`if pres > tol: continue`), so a NaN residual is ACCEPTED (unsound direction); same + inverted pattern at `:4696`. One NaN-producing rational eval (w→0) turns into either + a garbage certified crossing or a crash after the budget was spent. Fix: accept-if + (`pres <= tol`) + `np.isfinite` guards at both sites. +10. **[CONFIRMED, measured] `_nurbs_param_tol` semantic change reaches untouched legacy + consumers.** `_nurbs_param_tol.py:541`: all non-uniform-weight rational inputs now + take the conservative bound (was: negative-weight only) + rewritten optimistic + formula. Rational quarter-circle: ptol shrinks 1.75×; same arc translated to + (1000,2000): ptol grows ~350× (2.7e-4 vs 7.6e-7). Consumers `_bez_csx3.py:1661/1665` + (public `ssx()` path) and `closest_point.py:745` shift acceptance/dedup radii with + zero test coverage in this commit. Needs regression tests on the legacy pipeline or + a consumer-scoped tolerance policy. +11. **[CONFIRMED] Zero-allowance contract violated for three knobs.** + `_bez_ssx5.py:6322/6332`: `csx_max_results=0`, `boundary_csx_max_cells=0`, + `csx_max_cells=0` are silently promoted to 1 via `max(1, …)`, while `max_cells=0` / + `max_csx_calls=0` are honored as hard promises — inconsistent with the commit's own + documented zero-allowance semantics (and its preflight tests). +12. **[PLAUSIBLE] CSX boundary-phase exhaustion discards already-verified boundary + zeros.** `csx/_bez_csx4.py:1440`: certified roots in hand are dropped + (`csx_boundary_zeros = []`) and Phase 2 has ~0 cells left to re-find them — the + certified-partial contract loses its certified part (CCX keeps its validated hits in + the same situation). +13. **[PLAUSIBLE] Case-13 seed cache degenerates to a global "seeded once" bit.** + `_bez_ssx5.py:6921` + `6549`: for any depth>0 cell the cache key is rewritten to the + top cell, so the first productive Φ∩L pass marks all of [0,1]⁴ seeded; a second, + geometrically distinct tangency system's crossing-free loops would never be seeded — + the exact branch-loss class the Φ∩L machinery exists to prevent. Needs a + two-tangency fixture before/with any fix. +14. **[CONFIRMED] ssx6 draft: the new guard sits on top of a corrupted filter that + crashes the whole interior path.** `_bez_ssx6.py:1902`: + `list((lambda x: …, iterable))` builds `[, list]` → unconditional + `TypeError` in `_isoline_csx_to_global`; the contract test only exercises the + boundary path. ssx6 is also confirmed a stale pre-budget fork (no budget kwargs, no + singularities, fresh 100k per nested CSX). Decide: fix the filter + document ssx6's + status, or quarantine it explicitly. +15. **[CONFIRMED] The same budget accounting is hand-rolled 8× with already-divergent + semantics.** Inventory: `_SSXSoftBudget`, `BernsteinZeroBudget` (ContextVar-based), + closest-point `_publish_work_stats`+inline loops, `bez_ccx`/`bez_csx` inline + counters, `_nccx4`/`_ncsx4` `_new_status` twins (~110 lines copy-pasted, already + diverged), `_ssx5_singular` closures. Charge semantics differ per copy + (refuse-and-mark vs charge-then-compare vs check-then-increment vs charge-min). + Plus the flag zoo: `hard_exhausted` and `output_counts` are published but have zero + readers; `stop_when`/`stop_requested` and `max_boxes=max(256,16·max_cells)` are dead + knobs. Consolidate into one shared module as part of schema v2 (§6). + +**Additional verified debt** (doc-only; fold into the §11.4 consolidation batch): +duplicated exactness kits ccx/csx with *diverged roundoff envelopes* (64·n·ε_longdouble +vs 4096·n·ε_float64 for the same certificate class) and duplicate `_bernstein_product_1d` +names; three coexisting margin policies for the same hull-exclusion certificate (csx: +none, ssx: K=128 L1, ccx: depth-dependent); collapsed-geometry predicate in 3 places; +three Bezier interval restrictors with different edge behavior; `tol=` vs `atol=` kwarg +naming across sibling adapters with silent `**kwargs` swallowing of the wrong spelling; +`_curve_component_scale` dead; Optional-budget threading (41 `is not None` guards, 5 +duplicated `charge_box` lambdas — null-object or ContextVar, which `_bern_zero_1d` +already demonstrates); zero-allowance preflight exists only at the SSX top entry while +`bez_csx`/`bez_ccx` still build superlinear nets before their first charge; C1-pass +fixed `min(20k, remaining)` tier saturates on case 14 (charge = 20,000 exactly, ~174k +shared cells unspent) — same disease L38/L40 fixed for internal cuts; efficiency items +(hull certificate ordered before the 18× cheaper AABB prune in ccx phase 2; strict +polish ×5 seeds ~9 ms/cell; `_choose_phi_equations` rebuilds 16 derivative tensors per +seed, 1.48 ms/call; O(B²) identity-mapping attempts ~1.1 s worst; loop-invariant +`_strict_ssx_root_tol` recomputed per root; overlap-duplicate postprocess keeps paying +per-branch midpoint evals after budget denial; `dt == 0.0` exact-equality guard in +`_nurbs_param_tol` where `< _TINY` was intended). + +## 11. Next steps (proposed order) + +0. **User decisions:** + (a) overlap contract — Option C as specified in §8 — **APPROVED 2026-07-12**; + (b) schema v2 rename (§6) + adapter default flip to always-return-status — + **APPROVED 2026-07-12**; + (c) ledger registration — **APPROVED + REGISTERED 2026-07-12 as L41–L54** in + `docs/superpowers/issues/2026-07-07-ssx5-singular-review-ledger.md` (curated mapping, + not 1:1 with the 15 findings; folds and splits by class): + - L41 `[tracking]` schema v2 + adapter status policy — the approved (b); absorbs + findings 2, 3, 11 and the naming conflation. + - L42 `[P0 fix]` CSX curved-UV overlap fallback (finding 1) — **prerequisite for + L28**: region rims come from CSX overlap claims, and curved rims are exactly what + the affine-only certificate drops. + - L43 `[P0 fix]` C3 broadphase pair pricing (finding 5). + - L44 `[P0 fix]` point-dedup precharge + skipped dedup (finding 6). + - L45 `[P0 fix]` NaN chain: inverted gates :606/:4696 + floor crash :305 (finding 9). + - L46 `[P0 fix]` closest-point budget signal dropped + interior starvation (finding 8). + - L47 `[P1 decide-first]` CCX near-coincident / non-affine coincident overlap + semantics (finding 4) — exact-vs-tolerance contract is a policy call, then code. + - L48 `[P1 fix]` param-tol semantic shift: regression tests for legacy consumers + (finding 10). + - L49 `[P2 fixture-first]` cut-face parameter_fiber drop (finding 7). + - L50 `[P2 fixture-first]` Φ∩L seed-cache top-cell degeneration (finding 13). + - L51 `[P2 fixture-first]` CSX boundary-exhaustion discards verified zeros (finding 12). + - L52 `[P2 batch]` consolidation: one budget/status module, flag zoo, dead knobs, + exactness kits, margin policies (finding 15 + §10 extended debt). + - L53 `[P2 decide-first]` ssx6 disposition: fix corrupted filter + document staleness, + or quarantine (finding 14). + - L54 `[P2 audit, optional]` follow-up line-scan of `_bez_ssx5.py` new hunks + (A1/A2 territory), incl. the z=const≠0 strict-gate lead. +1. **Schema v2 + adapter-policy fix** (small, 1 session): status object, reason strings + at every `mark_incomplete` site, delete unread fields, update ssx6 contract test + + harness; **flip the `_nccx4`/`_ncsx4` default from raise-on-incomplete to + always-return-status** (soft-partial, same philosophy as the ssx5 core) and migrate + the six production call sites — this closes §10 findings 1–2 in the same stroke. +2. **L28** (TDD off the §8 test list; rim-branch sampling upgrade included; ledger claim + `CLAIMED(...)` per protocol; gates + case-12 objective harness). +3. **L25** (playbook re-pin, then fix; regression into the singular gate). +4. **Consolidation batch** from §10's confirmed cleanup findings — one shared budget + module (kills the 8-way drift), shared exactness kit in `_bezier_common.py`, margin + policy unification, dead-code removal (`stop_when`, `_curve_component_scale`, …). +5. **CI observability**: record per-case `status.work` in the coverage harness, fail on + 2× drift; institutionalize the two probes from this session (they run in ~3 min) as a + `budget_contract` gate — 0 crashes / 0 contract violations / determinism is now a + *tested* property, keep it that way. +6. **De-budget milestones** (§7.3): typed case-13 complement, C1 tier audit, then declare + the invariant "no gate case reports `work_budget` in reasons" and enforce it. + +## Appendix — probe method + +Probe scripts: scratchpad `budget_probe.py` / `budget_fuzz.py` (session +`ca19a011…/scratchpad`; step 5 above moves them in-repo). Both reuse +`bez_ssx5_coverage_check.load_case_surfaces`, evaluate branch samples on both surfaces +via an independent de Casteljau, and validate: schema presence, finiteness, stuv ⊂ +[0,1]⁴, residual ≤ 6·atol, `hard_exhausted ⇒ budget_exhausted`, md5-digest determinism. +Full JSON logs in the session task outputs. diff --git a/docs/superpowers/specs/2026-07-12-theorem-first-overlap-methodology.md b/docs/superpowers/specs/2026-07-12-theorem-first-overlap-methodology.md new file mode 100644 index 00000000..198317e5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-theorem-first-overlap-methodology.md @@ -0,0 +1,139 @@ +# Theorem-first overlap certification — methodology (USER DECISION 2026-07-12) + +**Status:** adopted; implemented for CSX in `_bez_csx4._tolerance_csx_overlap_certificate` +(ledger L59/L60); CCX's L47 tier is the ancestor. This document is the methodology of +record for all coincidence handling in mmcore's intersection engines, and the template +for the future quadric/rational tier (L58). + +## 1. The principle (the user's rationale, on record) + +Two polynomial/rational Bézier arcs that coincide on **any open sub-arc** lie on the +same irreducible algebraic curve — coincidence is all-or-nothing, never local. +Therefore a maximal overlap can only **terminate at a domain boundary** of one of the +operands: nothing interior to both domains can end it. + +The methodological consequence: **do not fight floating point for the interior.** +Numerics is used only to establish *which structure* we are in: + +1. verify the span's ends are **domain-pinned** (see §3), +2. verify **interior witnesses** are within tolerance, +3. verify **no interior crossing structure** (see §5), + +and then the *theorem*, not the arithmetic, carries the interior of the span. A +deliberately incomplete numerical proof of a mathematically complete property. This +replaces two failed regimes: + +- **tiny's valley rule** (pre-`5d05ddc`): permissive pairing with no certificate — + fast and often right, but provably unsound (merges sub-tolerance-distinct sets; + fails every exactness contract). +- **the 5d05ddc hardening**: exact-affine-only certification with no tier for genuine + non-affine overlaps — sound but incomplete; real coincidences (curve on a + non-parallelogram planar quad, curve on an extrusion) fell into a subdivision grind + and shipped truncated or empty (the "what got overlooked" of this branch). + +## 2. Tolerance semantics (resolves the L47 band-bar residual) + +**Tolerance-coincidence IS coincidence.** A span whose ends are domain-pinned, whose +witnesses sit within `atol`, and which carries no interior crossing structure is ONE +overlap. The `certification` field says which grade: + +- `'exact'` — witnesses at roundoff level (the `tiny = 4096·ε·max(1, diag)` floor); + algebraic identity in the source-envelope sense. +- `'tolerance'` — witnesses within `atol`; sets that are distinct-but-within-tolerance + are *reported as* coincident-within-tolerance. The exactness property survives in + sharpened form: **sub-tolerance-separated sets must never certify `'exact'`** + (measured: even a 5e-324 offset stays `'tolerance'`). + +Consequences already pinned in tests: parallel offsets at 0.5·atol, sub-atol humps, +and translated variants all promote as `'tolerance'` with translation-invariant +residuals (`test_csx4_exactness_contract.py`, `test_ccx4_exactness_contract.py`). + +## 3. Domain pinning (with the real-data amendments) + +A span end is pinned when any of: +- it is the **curve's t-domain end** (t=0/1), +- the projection onto the surface sits on a **uv-domain edge**, +- **(amendment, measured)** the projection *one grid step outward* clamps to a uv + edge — because on real data the tolerance boundary and the domain exit COINCIDE + (measured: d crosses atol at almost the same t where the path leaves through u=1, + so the projection at the refined boundary is still interior), and +- **(amendment, measured)** a span end may have **no exact 3-D root at all**: the + curve can leave the patch through an edge *region* at sub-atol clearance + (measured 6.7e-4 from the edge line). Pins are verified by inversion + residual, + never by requiring a boundary zero to exist. + +A span with **both ends interior-fading** (distance rising through atol with the +projection interior) is the **offset-twin signature and is never promoted** — the +CCX L47 rule, kept verbatim. + +## 4. Structural amendments (from the algebraic geometry) + +- **Multiplicity of spans:** improper/folded reparameterizations (a degree-6 double + traversal) and domain clipping legitimately produce **multiple** pinned spans — + the certificate returns a set, never assumes one. +- **Nodes ride along:** arcs of the same nodal curve can also meet at isolated + self-intersection points outside the spans; those stay isolated roots. +- **Corner contact is not band evidence:** a single-grid-sample in-band run (a graze + at one node whose bisection fringe can exceed 4·ptol) is refused; a genuine span + must be in-band across ≥ 2 consecutive grid samples (caught live: a 4·atol stub + branch at a shared corner). + +## 5. The crossing guard (what protects the blood-bought invariants) + +The **never-merge-tolerance-touches invariant** (near-tangent loop topology) is +protected by the *flip guard*, not by refusing overlaps: a transverse (normal-side) +**sign flip between consecutive interior gap samples** is crossing structure and +refuses promotion of that span. + +- Root-like samples (residual ≤ `tiny`) are bridged (the root is the coincidence). +- **(amendment, measured)** END-adjacent flips are exempt: a genuine touch AT a + pinned span end on real-world-inexact data sits above the roundoff floor (1.6e-9 + in the fixture) so bridging cannot cover it — it is the span's own endpoint root; + the theorem terminates the overlap there anyway. Interior flips still refuse. +- Sub-atol **valley chains** between strict-distinct zeros never merge: the strict + gap-midpoint certificate fails on valley floors (slice-10 lattice-cluster rule, + pinned by the 3-root valley-chain negative control). + +**Open (user decision pending):** CCX's woven near-coincident twins (crossings at +fitting-noise amplitude) still refuse per the pinned L47 contract test. If the +tolerance semantics of §2 should bridge sub-band weaving in CCX too, the candidate +rule is a *relative* bar (flip flanks ≤ K× the span's median in-band residual = +noise), which discriminates the pinned ±1.2e-4-crossings-in-band fixture (120,000× +median → refuse) from fit-noise weaving (~1× median → bridge). Not implemented. + +## 6. Cost discipline (the §11.5 lesson, twice) + +- **Arming is evidence-gated and cheap:** a curve-end zero on-surface, a valley + pair, or a zero-boundary-zero pair (coincident stretches entering AND exiting + through patch edges produce NO exact boundary roots — measured; transversal + nested calls always carry zeros and never pay the scan). +- **Split pricing:** the 17-projection arming scan bills 17 cells; the dense pass + (65 witnesses + refines) bills 145 **only on a hit**. A flat combined price + tripped the work-drift gate on nested cut-face calls (case 15 ×3.61) and a + constrained-budget test — the gate caught it live, twice, on the day it was built. +- Certified spans are **t-excluded from Phase 2**: no subdivision grind inside a + certified span. Baselines in `examples/ssx/bez_ssx5_work_baseline.json` refresh + only deliberately (`--update-baseline`). + +## 7. Measured record (why this methodology is trusted) + +| Case | tiny (valley rule) | 5d05ddc..pre-L59 | theorem-first | +|---|---|---|---| +| script-3 call 2 (extrusion) | 2.7 s, 1 overlap, unsound rule | 57 s, 0 overlaps, lost geometry | 4.2 s, 1 overlap **(5.9230, 20.1120)** vs tiny's (5.9238, 20.1131) — sub-ptol agreement | +| L42 parabola-on-bilinear | 1,679 lattice roots "complete" | bounded typed-partial | 1 `'exact'` overlap, 204 cells | +| bilinear L-junction (fixtures A/B) | — | branch truncated to 37%/11% | both branches full, linked to the tangent point | +| exactness contracts | FAIL (merges distinct sets) | pass | pass (re-pinned, sharpened never-'exact') | + +**The remaining fundamental cost:** inputs whose answer is genuinely delicate +(clearance ≈ atol, fast-rotating normals, real near-tangencies) must be resolved at +tolerance scale by any *honest* method. tiny was faster there only by answering wrong. + +## 8. Known incompleteness (merge-blocking, ledger L61) + +The user reports the bilinear non-affine family **still loses part of the second +branch** in their environment. At HEAD `f6d0015` every constructible variant passes +(A/B/C plus both order-swapped forms D/E: full 9.763-length branches, linked tangent +point, complete). The failing configuration is therefore not yet captured: it may be +the NURBS-level driver (non-unit knots), a geometry variant, or a stale checkout. +**Repro-first is mandatory**: no fix without the user's exact failing script and +geometry. The branch does not merge to `tiny` until this is closed. diff --git a/examples/ccx/multiple_int_3d.py b/examples/ccx/multiple_int_3d.py index d00aba0c..467c1210 100644 --- a/examples/ccx/multiple_int_3d.py +++ b/examples/ccx/multiple_int_3d.py @@ -216,13 +216,13 @@ viewer.add(curve, color=color) for pt in isolated['point']: - viewer.add(pt, color=(0.0, 1.0, 0.5,1.0),size_px=13) + viewer.add(pt, color=(0.0, 1.0, 0.5,1.0),size_px=6) if overlaps is not None: for start,end in overlaps['point']: - viewer.add(start, color=(0.0, 1.0, 0.5, 1.0), size_px=13) - viewer.add(end, color=(0.0, 1.0, 0.5, 1.0), size_px=13) + viewer.add(start, color=(0.0, 1.0, 0.5, 1.0), size_px=6) + viewer.add(end, color=(0.0, 1.0, 0.5, 1.0), size_px=6) diff --git a/examples/ccx/multiple_int_new_2d.py b/examples/ccx/multiple_int_new_2d.py index f650fad3..2d6d50da 100644 --- a/examples/ccx/multiple_int_new_2d.py +++ b/examples/ccx/multiple_int_new_2d.py @@ -62,7 +62,7 @@ from mmcore.numeric.intersection.ccx._nccx4 import nurbs_ccx,nurbs_ccx_multiple from mmcore.extras.renderer.renderer3d import Viewer,OrbitCamera -isolated,overlaps=nurbs_ccx_multiple(val,rational=True) +isolated,overlaps,_status=nurbs_ccx_multiple(val,rational=True) print('isolated:') print(isolated) print('overlaps:') diff --git a/examples/ccx/multiple_int_new_3d.py b/examples/ccx/multiple_int_new_3d.py index 01459936..75d25cfa 100644 --- a/examples/ccx/multiple_int_new_3d.py +++ b/examples/ccx/multiple_int_new_3d.py @@ -202,7 +202,7 @@ args=parser.parse_args() -isolated,overlaps=nurbs_ccx_multiple(val,tol=0.001,rational=True) +isolated,overlaps,_status=nurbs_ccx_multiple(val,tol=0.001,rational=True) print('\n\nOUT\n') print(len(isolated) if isolated is not None else 0,len(overlaps) if overlaps is not None else 0) print(isolated,overlaps) @@ -217,13 +217,13 @@ viewer.add(curve, color=color) for pt in isolated['point']: - viewer.add(pt, color=(0.0, 1.0, 0.5,1.0),size_px=13) + viewer.add(pt, color=(0.0, 1.0, 0.5,1.0),size_px=6) if overlaps is not None: for start,end in overlaps['point']: - viewer.add(start, color=(0.0, 1.0, 0.5, 1.0), size_px=13) - viewer.add(end, color=(0.0, 1.0, 0.5, 1.0), size_px=13) + viewer.add(start, color=(0.0, 1.0, 0.5, 1.0), size_px=6) + viewer.add(end, color=(0.0, 1.0, 0.5, 1.0), size_px=6) diff --git a/examples/ccx/tangential_int_2d.py b/examples/ccx/tangential_int_2d.py index 1214cdfd..6734c6a3 100644 --- a/examples/ccx/tangential_int_2d.py +++ b/examples/ccx/tangential_int_2d.py @@ -37,10 +37,10 @@ curve3=curve2._replace(control_points=curve2.control_points-(translate_vec[np.newaxis,:])) -isolated1,overs1=nurbs_ccx(curve1,curve2,tol) +isolated1,overs1,_status1=nurbs_ccx(curve1,curve2,tol) # curve1 x curve3 is not overlap! -isolated2,overs2=nurbs_ccx(curve1,curve3,tol) +isolated2,overs2,_status2=nurbs_ccx(curve1,curve3,tol) viewer=Viewer(camera=OrbitCamera(target=(-0.5 , 1.2,0.,),up=(0,1.,0.), ortho_half_height=1,distance=1,yaw= -3*np.pi/2,pitch= -np.pi/2)) viewer.cam.lock_orbit(True) @@ -52,8 +52,8 @@ for pt in isolated1['point']: viewer.add(pt - , color=(0.7, 0.9, 0.0, 1.0),size_px=13) + , color=(0.7, 0.9, 0.0, 1.0),size_px=6) for pt in isolated2['point']: - viewer.add(pt, color=(0.0, 0.6, 1.0, 1.0),size_px=13) + viewer.add(pt, color=(0.0, 0.6, 1.0, 1.0),size_px=6) viewer.run() diff --git a/examples/csx/nurbs_nurbs_intersection_1_new.py b/examples/csx/nurbs_nurbs_intersection_1_new.py new file mode 100644 index 00000000..4df08c8b --- /dev/null +++ b/examples/csx/nurbs_nurbs_intersection_1_new.py @@ -0,0 +1,149 @@ +import numpy as np +import rich + +from mmcore.geom._nurbs_eval import _nurbs_to_tuple, evaluate_nurbs_curve + +from mmcore.numeric.intersection.csx._ncsx4 import nurbs_csx + +cpts = np.array( + [ + [-9.1796875, 13.229166666666666, -4.5186767578125], + [-9.1796875, 14.739583333333332, -4.49395751953125], + [-9.1796875, 16.432291666666664, -4.580108642578125], + [-9.1796875, 18.372395833333332, -4.8531036376953125], + ] +) +spts = np.array( + [ + [ + [-5.849180481790346, 18.372395833333336, -1.5018374203712104], + [-5.858792686592141, 16.432291666666668, -2.719633841323509], + [-5.871782152540512, 14.739583333333334, -3.1229032219131403], + [-5.8852911971268185, 13.229166666666668, -3.116512598566417], + ], + [ + [-6.88688536134276, 18.372395833333336, -1.6832837287863824], + [-6.894094514944105, 16.432291666666668, -2.9325012796112815], + [-6.9038366144053835, 14.739583333333334, -3.3409109281989706], + [-6.913968397845114, 13.229166666666668, -3.3217200441495054], + ], + [ + [-7.97766402100707, 18.372395833333336, -2.2658223298287345], + [-7.983070886208079, 16.432291666666668, -3.617300740689732], + [-7.990377460804037, 14.739583333333334, -4.0455099012702895], + [-7.997976298383835, 13.229166666666668, -3.992029157974829], + ], + [ + [-9.1863730157553, 18.372395833333336, -2.7409113689805125], + [-9.190428164656058, 16.432291666666668, -4.174381706229336], + [-9.195908095603027, 14.739583333333334, -4.61538352236864], + [-9.201607223787876, 13.229166666666668, -4.527011611303911], + ], + ] +) + +import argparse +import time + +import rich + +from mmcore.geom._nurbs_eval import _tuple_to_nurbs, _curve_interval, evaluate_nurbs_curve +from mmcore.geom._nurbs_knots import trim_curve +from mmcore.numeric.intersection.csx._ncsx4 import nurbs_csx +import logging +from mmcore.geom.nurbs_iso import extract_surface_boundaries_tuple +# Creating intersection objects +import numpy as np +from mmcore.geom._nurbs_eval import NURBSSurfaceTuple +def parse_args(): + parser = argparse.ArgumentParser() + ssx_params = parser.add_argument_group(title="CSX Parameters") + ssx_params.add_argument("--atol", type=float, default=1e-3) + + + general_params = parser.add_argument_group(title="General") + general_params.add_argument('--viewer', action='store_true') + + return parser.parse_args() + + +args = parse_args() + + +from mmcore.geom.nurbs import NURBSCurve, NURBSSurface + + +surface: NURBSSurfaceTuple =_nurbs_to_tuple( NURBSSurface(np.array(spts), (3, 3))) + +curve = _nurbs_to_tuple(NURBSCurve(cpts)) +# ress = new_intersection_candidates(surf, curve, u, v, t, np.array(surf.evaluate_v2(u, v))) + +import time + +s = time.time() + +result = nurbs_csx(curve, surface,tol=args.atol) + +print(f"CSX v4 performed at: {time.time()-s} secs.") +print('isolated:') +if result[0] is not None: + rich.print(result[0]) +print('overlaps:') +if result[1] is not None: + rich.print(result[1]) +isolated,overlaps=result[0],result[1] + +if args.viewer: + + try: + if args.viewer: + from mmcore.extras.renderer.renderer3d import Viewer, OrbitCamera + + viewer = Viewer(camera=OrbitCamera(target=surface.control_points.reshape(-1, 3).mean(axis=0))) + srf = viewer.add_nurbs_surface(surface, color=(0.7, 0.7, 0.7, 1.), surface_color=(0.5, 0.5, 0.9, 0.1), + v_count=4) + + + def render_result(result, curve, surface=None): + if surface is not None: + srf = viewer.add_nurbs_surface(surface, color=(0.3, 0.3, 0.3, 0.05), v_count=4) + + crv = viewer.add(curve, color=(0.9, 0.9, 0.9, 1.0)) + isolated, overlaps,_ = result + if isolated is not None: + uvs = [] + for pt in isolated: + viewer.add(pt['point'], color=(0.0, 1.0, 0.5, 1.0), size_px=6) + + if overlaps is not None: + + for overlap in overlaps: + t0, t1 = overlap['t_range'] + + viewer.add(evaluate_nurbs_curve(curve, t0, d_order=0)['C'], color=(0.0, 1.0, 0.5, 1.0), + size_px=6) + viewer.add(evaluate_nurbs_curve(curve, t1, d_order=0)['C'], color=(0.0, 1.0, 0.5, 1.0), + size_px=6) + + for o in overlaps: + + t0 = o["t_range"][0] + t1 = o["t_range"][-1] + + pts = np.linspace(t0, t1, 800) + for t in pts: + evl = evaluate_nurbs_curve(curve, t, d_order=0) + viewer.add_point3d(evl['C'], color=(0.0, 1.0, 0.5, 1.0), size_px=3) + + + render_result(result, curve) + + viewer.run() + + + except ModuleNotFoundError as err: + print("mmcore.renderer is not installed, skip preview.") + except ImportError as err: + print("mmcore.renderer is not installed, skip preview.") + except Exception as err: + raise err diff --git a/examples/csx/overlap_nurbs_intersection.py b/examples/csx/overlap_nurbs_intersection.py index 970b434d..c94d4d4c 100644 --- a/examples/csx/overlap_nurbs_intersection.py +++ b/examples/csx/overlap_nurbs_intersection.py @@ -109,7 +109,7 @@ def parse_args(): parser = argparse.ArgumentParser() ssx_params=parser.add_argument_group(title="SSX Parameters") ssx_params.add_argument("--atol", type=float, default=1e-3) - ssx_params.add_argument("--angle_tol", type=float, default=0.052) + general_params=parser.add_argument_group(title="General") general_params.add_argument('--viewer', action='store_true') @@ -135,7 +135,7 @@ def parse_args(): #print('overlaps:') #rich.print(overlaps) s = time.time() -isolated,overlaps = nurbs_csx_v2(curve, surface, atol=args.atol, angle_tol=args.angle_tol,overlap_dist_tol=args.atol) +isolated,overlaps = nurbs_csx_v2(curve, surface, tol=args.atol,overlap_dist_tol=args.atol) print(f"CSX v2 performed at: {time.time()-s} secs.") #print('\n\n',result,'\n\n') @@ -152,14 +152,14 @@ def parse_args(): viewer=Viewer(camera=OrbitCamera(target= surface.control_points.reshape(-1,3).mean(axis=0))) - srf = viewer.add_nurbs_surface(surface, color=(0.7,0.7,0.7,1),surface_color=(0.5, 0.5, 0.9, 0.05), v_count=4) + srf = viewer.add_nurbs_surface(surface, color=(0.7,0.7,0.7,1),surface_color=(0.5, 0.5, 0.9, 0.1), v_count=4) crv=viewer.add(curve, color=(0.9, 0.9, 0.9, 1.0)) if isolated is not None: uvs=[] for pt in isolated['point']: - viewer.add(pt, color=(0.0, 1.0, 0.5,1.0),size_px=13) + viewer.add(pt, color=(0.0, 1.0, 0.5,1.0),size_px=6) if overlaps is not None: diff --git a/examples/csx/overlap_nurbs_intersection_2.py b/examples/csx/overlap_nurbs_intersection_2.py index 684c5a0e..f16ed091 100644 --- a/examples/csx/overlap_nurbs_intersection_2.py +++ b/examples/csx/overlap_nurbs_intersection_2.py @@ -19,7 +19,7 @@ def parse_args(): parser = argparse.ArgumentParser() ssx_params = parser.add_argument_group(title="SSX Parameters") ssx_params.add_argument("--atol", type=float, default=1e-3) - ssx_params.add_argument("--angle_tol", type=float, default=0.052) + general_params = parser.add_argument_group(title="General") general_params.add_argument('--viewer', action='store_true') @@ -73,7 +73,7 @@ def parse_args(): - result.append(nurbs_csx_v2(b, st2, atol=args.atol, angle_tol=args.angle_tol)) + result.append(nurbs_csx_v2(b, st2, tol=args.atol)) print("CSX v2 performed at: ",time.time()-s," secs.") isolated,overlaps=[],[] for i,o in result: @@ -92,8 +92,8 @@ def parse_args(): from mmcore.extras.renderer.renderer3d import Viewer,OrbitCamera viewer=Viewer(camera=OrbitCamera(distance=np.linalg.norm(st1.control_points.reshape(-1,3).mean(axis=0))**2,target= st1.control_points.reshape(-1,3).mean(axis=0))) - srf = viewer.add_nurbs_surface(st1, color=(0.7, 0.7, 0.7, 1),surface_color=(0.5, 0.5, 0.9, 0.05), v_count=4) - srf2 = viewer.add_nurbs_surface(st2, color=(0.7, 0.7, 0.7, 1), surface_color=(0.5, 0.5, 0.9, 0.05), v_count=4) + srf = viewer.add_nurbs_surface(st1, color=(0.7, 0.7, 0.7, 1),surface_color=(0.5, 0.5, 0.9, 0.1), v_count=4) + srf2 = viewer.add_nurbs_surface(st2, color=(0.7, 0.7, 0.7, 1), surface_color=(0.5, 0.5, 0.9, 0.1), v_count=4) def render_result(result,curve,surface=None): @@ -106,7 +106,7 @@ def render_result(result,curve,surface=None): uvs=[] for pt in isolated['point']: - viewer.add(pt, color=(0.0, 1.0, 0.5,1.0),size_px=13) + viewer.add(pt, color=(0.0, 1.0, 0.5,1.0),size_px=6) if overlaps is not None: diff --git a/examples/csx/overlap_nurbs_intersection_2_new.py b/examples/csx/overlap_nurbs_intersection_2_new.py index d52c4c90..a58c33b0 100644 --- a/examples/csx/overlap_nurbs_intersection_2_new.py +++ b/examples/csx/overlap_nurbs_intersection_2_new.py @@ -19,7 +19,7 @@ def parse_args(): parser = argparse.ArgumentParser() ssx_params = parser.add_argument_group(title="CSX Parameters") ssx_params.add_argument("--atol", type=float, default=1e-3) - ssx_params.add_argument("--angle_tol", type=float, default=0.052) + general_params = parser.add_argument_group(title="General") general_params.add_argument('--viewer', action='store_true') @@ -73,10 +73,10 @@ def parse_args(): - result.append(nurbs_csx(b, st2, atol=args.atol, angle_tol=args.angle_tol)) + result.append(nurbs_csx(b, st2, tol=args.atol)) print("CSX v4 performed at: ", time.time() - s, " secs.") isolated,overlaps=[],[] -for i,o in result: +for i,o,_status in result: if i is not None: isolated.extend(i) if o is not None: @@ -92,8 +92,8 @@ def parse_args(): from mmcore.extras.renderer.renderer3d import Viewer,OrbitCamera viewer=Viewer(camera=OrbitCamera(distance=np.linalg.norm(st1.control_points.reshape(-1,3).mean(axis=0))**2,target= st1.control_points.reshape(-1,3).mean(axis=0))) - srf = viewer.add_nurbs_surface(st1, color=(0.7, 0.7, 0.7, 1),surface_color=(0.5, 0.5, 0.9, 0.05), v_count=4) - srf2 = viewer.add_nurbs_surface(st2, color=(0.7, 0.7, 0.7, 1), surface_color=(0.5, 0.5, 0.9, 0.05), v_count=4) + srf = viewer.add_nurbs_surface(st1, color=(0.7, 0.7, 0.7, 1),surface_color=(0.5, 0.5, 0.9, 0.1), v_count=4) + srf2 = viewer.add_nurbs_surface(st2, color=(0.7, 0.7, 0.7, 1), surface_color=(0.5, 0.5, 0.9, 0.1), v_count=4) def render_result(result,curve,surface=None): @@ -101,12 +101,12 @@ def render_result(result,curve,surface=None): srf = viewer.add_nurbs_surface(surface, color=(0.7, 0.7, 0.7, 1), v_count=4) crv= viewer.add(curve, color=(0.9, 0.9, 0.9, 1.0)) - isolated, overlaps = result + isolated, overlaps ,_= result if isolated is not None: uvs=[] for pt in isolated: - viewer.add(pt['point'], color=(0.0, 1.0, 0.5,1.0),size_px=13) + viewer.add(pt['point'], color=(0.0, 1.0, 0.5,1.0),size_px=6) if overlaps is not None: diff --git a/examples/csx/overlap_nurbs_intersection_3.py b/examples/csx/overlap_nurbs_intersection_3.py index a0494bbe..158f7f8c 100644 --- a/examples/csx/overlap_nurbs_intersection_3.py +++ b/examples/csx/overlap_nurbs_intersection_3.py @@ -17,7 +17,7 @@ def parse_args(): parser = argparse.ArgumentParser() ssx_params = parser.add_argument_group(title="CSX Parameters") ssx_params.add_argument("--atol", type=float, default=1e-3) - ssx_params.add_argument("--angle_tol", type=float, default=0.052) + general_params = parser.add_argument_group(title="General") general_params.add_argument('--viewer', action='store_true') @@ -118,7 +118,7 @@ def parse_args(): from mmcore.extras.renderer.renderer3d import Viewer,OrbitCamera viewer=Viewer(camera=OrbitCamera(target= surface.control_points.reshape(-1,3).mean(axis=0))) - srf = viewer.add_nurbs_surface(surface, color=(0.7, 0.7, 0.7,1.), surface_color=(0.5, 0.5, 0.9, 0.05), v_count=4) + srf = viewer.add_nurbs_surface(surface, color=(0.7, 0.7, 0.7,1.), surface_color=(0.5, 0.5, 0.9, 0.1), v_count=4) def render_result(result,curve,surface=None): if surface is not None: @@ -130,7 +130,7 @@ def render_result(result,curve,surface=None): uvs=[] for pt in isolated['point']: - viewer.add(pt, color=(0.0, 1.0, 0.5,1.0),size_px=13) + viewer.add(pt, color=(0.0, 1.0, 0.5,1.0),size_px=6) if overlaps is not None: diff --git a/examples/csx/overlap_nurbs_intersection_3_new.py b/examples/csx/overlap_nurbs_intersection_3_new.py new file mode 100644 index 00000000..48e4952f --- /dev/null +++ b/examples/csx/overlap_nurbs_intersection_3_new.py @@ -0,0 +1,168 @@ +import pickle +import time +from pathlib import Path + +import rich + +from mmcore._test_data import csx as csx_cases + +from mmcore.numeric.intersection.csx._ncsx4 import nurbs_csx + +from mmcore.geom._nurbs_eval import _tuple_to_nurbs, _nurbs_to_tuple, evaluate_nurbs_curve +from mmcore.geom._nurbs_knots import split_curve_multiple +import numpy as np +from mmcore.geom._nurbs_eval import NURBSCurveTuple +import argparse +def parse_args(): + parser = argparse.ArgumentParser() + ssx_params = parser.add_argument_group(title="CSX Parameters") + ssx_params.add_argument("--atol", type=float, default=1e-3) + + + general_params = parser.add_argument_group(title="General") + general_params.add_argument('--viewer', action='store_true') + + return parser.parse_args() + + +args = parse_args() + + +curve1 = NURBSCurveTuple( + order=2, + knot=np.array([ 0. , 0. , 20.65300965, 65.96260962, 69.30975481, + 69.30975481]), + control_points=np.array([[ 66.01811696, 90.95353754, 45.81225139], + [ 66.01811696, 90.95353754, 20.75799567], + [111.32771693, 90.95353754, 20.75799567], + [110.18292585, 87.8082499 , 20.75799567]]), + weights=np.array([1., 1., 1., 1.]) +) + + +curve2 = NURBSCurveTuple( + order=4, + knot=np.array([0., 0., 0., 0., 2.10490611, + 2.10490611, 2.10490611, 4.35578185, 4.35578185, 6.60665591, + 6.60665591, 8.85752794, 8.85752794, 11.10839997, 11.10839997, + 13.359272, 13.359272, 15.61014479, 15.61014479, 17.86102249, + 17.86102249, 20.11190103, 20.11190103, 20.11190103, 31.3033027, + 31.3033027, 31.3033027, 31.3033027]), + control_points=np.array([[90.06507871, 81.18027761, -4.25499835], + [90.51406358, 81.58907357, -4.0387382], + [90.96304844, 81.99786953, -3.82247806], + [91.41203331, 82.4066655, -3.60621791], + [92.85239561, 83.71810012, -2.91244628], + [94.01988661, 85.30553815, -2.19782612], + [95.65026587, 88.77912871, -0.72689091], + [96.11300253, 90.66371219, 0.02942412], + [96.28502389, 94.43829065, 1.58374726], + [95.99448464, 96.32669496, 2.38175534], + [94.72680657, 99.82380743, 4.01946362], + [93.75013478, 101.43108127, 4.85916382], + [91.27842704, 104.13179098, 6.58025473], + [89.7844795, 105.2243694, 7.46164544], + [86.51953959, 106.74648291, 9.26611609], + [84.75002525, 107.1758931, 10.18919605], + [81.209659, 107.32794476, 12.07704534], + [79.44029924, 107.05076864, 13.04181475], + [76.16713956, 105.85446369, 15.01303942], + [74.66467989, 104.93579006, 16.01949469], + [73.40404256, 103.77584031, 17.04679174], + [67.13613493, 98.00855285, 22.15452797], + [60.8682273, 92.24126539, 27.26226419], + [54.60031967, 86.47397794, 32.37000042]]), + weights=np.array([1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., + 1., 1., 1., 1., 1., 1., 1.]) +) + +surface, curve3 = csx_cases[0] +surface=_nurbs_to_tuple(surface) +curve3=_nurbs_to_tuple(curve3) +inters = [] +overs = [] +pts = [] +s = time.time() +result1 = nurbs_csx(curve1, surface, tol=args.atol, ) +pth=Path(__file__).parent/'result1.pkl' +with open(pth, 'wb') as f: + pickle.dump([curve1,surface], f) +print(f"CSX v4 X 1 performed at: {time.time()-s} secs.") +print('isolated:') +if result1[0] is not None: + rich.print(result1[0]) +print('overlaps:') +if result1[1] is not None: + rich.print(result1[1]) + +s = time.time() +result2 = nurbs_csx(curve2, surface, tol=args.atol) +print(f"CSX v4 X 2 performed at: {time.time()-s} secs.") +print('isolated:') +if result2[0] is not None: + rich.print(result2[0]) +print('overlaps:') +if result2[1] is not None: + rich.print(result2[1]) + +s = time.time() +result3 = nurbs_csx(curve3, surface, tol=args.atol) + +print(f"CSX v4 X 3 performed at: {time.time()-s} secs.") +print('isolated:') +if result3[0] is not None: + rich.print(result3[0]) +print('overlaps:') +if result3[1] is not None: + rich.print(result3[1]) + +try: + if args.viewer: + from mmcore.extras.renderer.renderer3d import Viewer,OrbitCamera + + viewer=Viewer(camera=OrbitCamera(target= surface.control_points.reshape(-1,3).mean(axis=0))) + srf = viewer.add_nurbs_surface(surface, color=(0.7, 0.7, 0.7,1.), surface_color=(0.5, 0.5, 0.9, 0.1), v_count=4) + + def render_result(result,curve,surface=None): + if surface is not None: + srf = viewer.add_nurbs_surface(surface, color=(0.3, 0.3, 0.3, 0.05), v_count=4) + + crv= viewer.add(curve, color=(0.9, 0.9, 0.9, 1.0)) + isolated, overlaps,_ = result + if isolated is not None: + uvs=[] + for pt in isolated: + + viewer.add(pt['point'], color=(0.0, 1.0, 0.5,1.0),size_px=6) + + if overlaps is not None: + + for overlap in overlaps: + t0,t1=overlap['t_range'] + + viewer.add(evaluate_nurbs_curve(curve, t0,d_order=0)['C'], color=(0.0, 1.0, 0.5, 1.0), size_px=6) + viewer.add(evaluate_nurbs_curve(curve, t1,d_order=0)['C'], color=(0.0, 1.0, 0.5, 1.0), size_px=6) + + for o in overlaps: + + t0 = o["t_range"][0] + t1 = o["t_range"][-1] + + pts=np.linspace(t0,t1,800) + for t in pts: + + evl=evaluate_nurbs_curve(curve,t,d_order=0) + viewer.add_point3d(evl['C'],color=(0.0, 1.0, 0.5, 1.0), size_px=3) + render_result(result1,curve1) + render_result(result2, curve2) + render_result(result3, curve3) + viewer.run() + + +except ModuleNotFoundError as err: + print("mmcore.renderer is not installed, skip preview.") +except ImportError as err: + print("mmcore.renderer is not installed, skip preview.") +except Exception as err: + raise err +print(pth) \ No newline at end of file diff --git a/examples/csx/overlap_nurbs_intersection_4.py b/examples/csx/overlap_nurbs_intersection_4.py index 0f554f03..ffd435a9 100644 --- a/examples/csx/overlap_nurbs_intersection_4.py +++ b/examples/csx/overlap_nurbs_intersection_4.py @@ -15,7 +15,7 @@ def parse_args(): parser = argparse.ArgumentParser() ssx_params = parser.add_argument_group(title="CSX Parameters") ssx_params.add_argument("--atol", type=float, default=1e-3) - ssx_params.add_argument("--angle_tol", type=float, default=0.052) + general_params = parser.add_argument_group(title="General") general_params.add_argument('--viewer', action='store_true') @@ -101,11 +101,11 @@ def parse_args(): from mmcore.extras.renderer.renderer3d import Viewer,OrbitCamera viewer=Viewer(camera=OrbitCamera(near=1,far=1e+9)) primary_color=(*(np.array([250, 102, 166])/255).tolist(),1) - srf = viewer.add_nurbs_surface(surface, color=(0.7,0.7,0.7,1),surface_color=(0.5, 0.5, 0.9, 0.05),) + srf = viewer.add_nurbs_surface(surface, color=(0.7,0.7,0.7,1),surface_color=(0.5, 0.5, 0.9, 0.1),) if isolated is not None: for pt in isolated['point']: - viewer.add(pt, color=(0.0, 1.0, 0.5,1.0),size_px=13) + viewer.add(pt, color=(0.0, 1.0, 0.5,1.0),size_px=6) if overlaps is not None: for start,end in overlaps['point']: diff --git a/examples/csx/overlap_nurbs_intersection_4_new.py b/examples/csx/overlap_nurbs_intersection_4_new.py new file mode 100644 index 00000000..0dce2271 --- /dev/null +++ b/examples/csx/overlap_nurbs_intersection_4_new.py @@ -0,0 +1,143 @@ +import numpy as np +from mmcore.geom._nurbs_eval import NURBSCurveTuple, NURBSSurfaceTuple, _tuple_to_nurbs, evaluate_nurbs_curve + +import time + + +import rich + +from mmcore.geom._nurbs_knots import trim_curve +from mmcore.numeric import evaluate_curvature_vec +from mmcore.numeric.approx import adaptive_curve_sampler +from mmcore.numeric.intersection.csx import nurbs_csx_v2 +from mmcore.numeric.intersection.csx._ncsx4 import nurbs_csx +import argparse +def parse_args(): + parser = argparse.ArgumentParser() + ssx_params = parser.add_argument_group(title="CSX Parameters") + ssx_params.add_argument("--atol", type=float, default=1e-3) + + + general_params = parser.add_argument_group(title="General") + general_params.add_argument('--viewer', action='store_true') + + return parser.parse_args() + + +args = parse_args() + +curve = NURBSCurveTuple( + order=3, + knot=np.array([ 0. , 0. , 0. , 91.83300275, + 148.16516477, 206.24102425, 296.33032955, 296.33032955, + 296.33032955]), + control_points=np.array([[ 0.64617093, -137. , -24. ], + [ 0.38629333, -87.41587386, 0. ], + [ 35.63100531, -21.05150855, 0. ], + [ 41.52768692, 23.7340877 , 0. ], + [ 20.09543746, 74.05471129, 0. ], + [ 20.64617093, 101.40810621, 16. ]]), + weights=np.array([1., 1., 1., 1., 1., 1.]) +) + +surface = NURBSSurfaceTuple( + order_u=4, + order_v=3, + knot_u=np.array([ 0. , 0. , 0. , 0. , 40.22437072, + 40.22437072, 40.22437072, 40.22437072]), + knot_v=np.array([ 0. , 0. , 0. , 153.56627554, + 307.13255109, 307.13255109, 307.13255109]), + control_points=np.array([[[ -49.35382907, -137. , 0. ], + [ 30.89785834, -57. , 0. ], + [ 46.17096231, 59. , 0. ], + [ -20.35382907, 97.40810621, 0. ]], + + [[ -50.02049574, -137. , 11. ], + [ 23.23119168, -57. , 11. ], + [ 38.50429565, 59. , 11. ], + [ -21.02049574, 97.40810621, 11. ]], + + [[ -50.68716241, -137. , 22. ], + [ 15.56452501, -57. , 22. ], + [ 30.83762898, 59. , 22. ], + [ -21.68716241, 97.40810621, 22. ]], + + [[ -51.35382907, -137. , 33. ], + [ 7.89785834, -57. , 33. ], + [ 23.17096231, 59. , 33. ], + [ -22.35382907, 97.40810621, 33. ]]]), + weights=np.array([[1., 1., 1., 1.], + [1., 1., 1., 1.], + [1., 1., 1., 1.], + [1., 1., 1., 1.]]) +) + +#s = time.time() +#result = nurbs_csx(_tuple_to_nurbs(curve), _tuple_to_nurbs(surface)) +#print(f"CSX v1 performed at: {time.time()-s} secs.") +#over=[] +#isol=[] +#print(result) +#for tp,item,uv in result: +# if tp =='overlap': +# over.append(item) +# else: +# isol.append(item) +#print('isolated:') +#rich.print(isol) +#print('overlaps:') +#rich.print(over) + +s = time.time() +isolated, overlaps, _status = nurbs_csx(curve, surface, tol=args.atol) +print(f"CSX v4 performed at: {time.time()-s} secs.") + + +print('isolated:') + +if isolated is not None: + rich.print(isolated) +print('overlaps:') +if overlaps is not None: + rich.print(overlaps) +RENDERER=False +if args.viewer: + try: + from mmcore.extras.renderer.renderer3d import Viewer,OrbitCamera + viewer=Viewer(camera=OrbitCamera(near=1,far=1e+9)) + primary_color=(*(np.array([250, 102, 166])/255).tolist(),1) + + + if isolated is not None: + uvs = [] + for pt in isolated: + viewer.add_point3d(pt['point'], color=(0.0, 1.0, 0.5, 1.0), size_px=6) + + if overlaps is not None: + + for overlap in overlaps: + t0,t1=overlap['t_range'] + + viewer.add_point3d(evaluate_nurbs_curve(curve, t0,d_order=0)['C'], color=(0.0, 1.0, 0.5, 1.0), size_px=4) + viewer.add_point3d(evaluate_nurbs_curve(curve, t1,d_order=0)['C'], color=(0.0, 1.0, 0.5, 1.0), size_px=4) + + for o in overlaps: + + t0 = o["t_range"][0] + t1 = o["t_range"][-1] + + pts=np.linspace(t0,t1,800) + for t in pts: + + evl=evaluate_nurbs_curve(curve,t,d_order=0) + viewer.add_point3d(evl['C'],color=(0.0, 1.0, 0.5, 1.0), size_px=4) + srf = viewer.add_nurbs_surface(surface, color=(0.7, 0.7, 0.7, 1), surface_color=(0.5, 0.5, 0.9, 0.1), ) + viewer.run() + + + except ModuleNotFoundError as err: + print("mmcore.renderer is not installed, skip preview.") + except ImportError as err: + print("mmcore.renderer is not installed, skip preview.") + except Exception as err: + raise err diff --git a/examples/csx/overlap_nurbs_intersection_5.py b/examples/csx/overlap_nurbs_intersection_5.py index 1085a4d4..7b319908 100644 --- a/examples/csx/overlap_nurbs_intersection_5.py +++ b/examples/csx/overlap_nurbs_intersection_5.py @@ -16,7 +16,7 @@ def parse_args(): parser = argparse.ArgumentParser() ssx_params = parser.add_argument_group(title="CSX Parameters") ssx_params.add_argument("--atol", type=float, default=1e-3) - ssx_params.add_argument("--angle_tol", type=float, default=0.052) + general_params = parser.add_argument_group(title="General") general_params.add_argument('--viewer', action='store_true') @@ -253,7 +253,7 @@ def parse_args(): from mmcore.extras.renderer.renderer3d import Viewer,OrbitCamera viewer=Viewer(camera=OrbitCamera(target= surface.control_points.reshape(-1,3).mean(axis=0))) - srf = viewer.add_nurbs_surface(surface, color=(0.7, 0.7, 0.7, 1),surface_color=(0.5, 0.5, 0.9, 0.05), v_count=4) + srf = viewer.add_nurbs_surface(surface, color=(0.7, 0.7, 0.7, 1),surface_color=(0.5, 0.5, 0.9, 0.1), v_count=4) def render_result(result,curve,surface=None): if surface is not None: @@ -265,7 +265,7 @@ def render_result(result,curve,surface=None): uvs=[] for pt in isolated['point']: - viewer.add(pt, color=(0.0, 1.0, 0.5,1.0),size_px=13) + viewer.add(pt, color=(0.0, 1.0, 0.5,1.0),size_px=6) if overlaps is not None: diff --git a/examples/csx/overlap_nurbs_intersection_5_new.py b/examples/csx/overlap_nurbs_intersection_5_new.py new file mode 100644 index 00000000..6beef71f --- /dev/null +++ b/examples/csx/overlap_nurbs_intersection_5_new.py @@ -0,0 +1,292 @@ +import numpy as np +from mmcore.geom._nurbs_eval import NURBSCurveTuple, NURBSSurfaceTuple, _tuple_to_nurbs, evaluate_nurbs_curve + + + + +import rich + + + + +import numpy as np +from mmcore.geom._nurbs_eval import NURBSCurveTuple + +import argparse +def parse_args(): + parser = argparse.ArgumentParser() + ssx_params = parser.add_argument_group(title="CSX Parameters") + ssx_params.add_argument("--atol", type=float, default=1e-2) + + + general_params = parser.add_argument_group(title="General") + general_params.add_argument('--viewer', action='store_true') + + return parser.parse_args() +args = parse_args() +curve = NURBSCurveTuple( + order=4, + knot=np.array([ 0. , 0. , 0. , 0. , + 4.09559758, 4.09559758, 8.19119516, 8.19119516, + 12.28444691, 12.28444691, 16.37769866, 16.37769866, + 20.47169754, 20.47169754, 24.56569642, 24.56569642, + 28.65705491, 28.65705491, 32.7484134 , 32.7484134 , + 36.84393517, 36.84393517, 40.93945695, 40.93945695, + 45.03497872, 45.03497872, 49.13050049, 49.13050049, + 57.32154404, 57.32154404, 65.51258758, 65.51258758, + 65.51258758, 74.22314717, 74.22314717, 82.93372876, + 82.93372876, 91.64440961, 91.64440961, 95.99975003, + 95.99975003, 100.35509046, 100.35509046, 104.71076842, + 104.71076842, 109.06644638, 109.06644638, 113.42060811, + 113.42060811, 117.77476984, 117.77476984, 122.12820706, + 122.12820706, 126.48164428, 126.48164428, 130.83508151, + 130.83508151, 135.18851873, 135.18851873, 135.18851873, + 135.18851873]), + control_points=np.array([[ 84.07778644, -106.03781562, 10.64806753], + [ 85.50129643, -106.29987225, 10.57524781], + [ 86.97218294, -106.45209039, 10.47993656], + [ 89.96955052, -106.51367517, 10.24858066], + [ 91.49596457, -106.42299563, 10.11254177], + [ 94.55888578, -105.98413263, 9.80467975], + [ 96.09531079, -105.63607038, 9.63288302], + [ 99.13138243, -104.67746666, 9.25873518], + [ 100.63095037, -104.06691887, 9.05638975], + [ 103.54498882, -102.58816693, 8.62663262], + [ 104.95936756, -101.71992913, 8.39922168], + [ 107.65561503, -99.74079645, 7.92497873], + [ 108.93741517, -98.6299328 , 7.67815149], + [ 111.32430666, -96.19061235, 7.17078769], + [ 112.42945485, -94.86234231, 6.91026411], + [ 114.42626734, -92.02006833, 6.38069942], + [ 115.31788905, -90.50612013, 6.11166159], + [ 116.85773044, -87.32983412, 5.56982014], + [ 117.50567375, -85.66740825, 5.29701547], + [ 118.53422963, -82.23939698, 4.75255339], + [ 118.9148308 , -80.47387493, 4.48089763], + [ 119.39478393, -76.88652988, 3.942918 ], + [ 119.4941391 , -75.06476775, 3.67659495], + [ 119.40652816, -71.41286694, 3.15296862], + [ 119.21957782, -69.58278343, 2.89566541], + [ 118.23446665, -64.15082486, 2.14205202], + [ 117.00860556, -60.6091354 , 1.66427408], + [ 113.5379214 , -54.03081541, 0.77748389], + [ 111.29425832, -50.99507207, 0.36842041], + [ 108.64959313, -48.35040687, -0. ], + [ 105.83719033, -45.53800408, -0.39178744], + [ 102.57233318, -43.16832396, -0.73774195], + [ 95.47384964, -39.54662984, -1.38106435], + [ 91.64090447, -38.29493253, -1.67856795], + [ 83.79101261, -37.03099409, -2.26897066], + [ 79.77493103, -37.01909414, -2.5619779 ], + [ 73.91976265, -37.93140849, -3.02888142], + [ 71.996306 , -38.38956289, -3.18905109], + [ 68.25690838, -39.60193422, -3.52299623], + [ 66.44100038, -40.35615773, -3.69678045], + [ 62.96641137, -42.14040606, -4.06187106], + [ 61.3080081 , -43.1706093 , -4.25371361], + [ 58.19452872, -45.47677203, -4.6537583 ], + [ 56.73943071, -46.75262344, -4.86166962], + [ 54.0719309 , -49.51034979, -5.28851308], + [ 52.85950312, -50.99210797, -5.50743901], + [ 50.70805425, -54.11585052, -5.95132956], + [ 49.76908166, -55.75777312, -6.17629052], + [ 48.18670158, -59.14994839, -6.62688672], + [ 47.54327742, -60.90010702, -6.85251699], + [ 46.56322915, -64.45401311, -7.29901548], + [ 46.22661712, -66.25767553, -7.51987789], + [ 45.86042093, -69.86263843, -7.95137927], + [ 45.83082544, -71.66385138, -8.16201182], + [ 46.06763207, -75.20961664, -8.56778972], + [ 46.33399904, -76.95408641, -8.76292845], + [ 46.73756946, -78.64519304, -8.94788941]]), + weights=np.array([1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., + 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., + 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., + 1., 1., 1., 1., 1., 1.]) +) +import numpy as np +from mmcore.geom._nurbs_eval import NURBSSurfaceTuple + + +surface = NURBSSurfaceTuple( + order_u=3, + order_v=3, + knot_u=np.array([ 0. , 0. , 0. , 44.42882938, + 44.42882938, 88.85765876, 88.85765876, 133.28648814, + 133.28648814, 177.71531753, 177.71531753, 177.71531753]), + knot_v=np.array([ 0. , 0. , 0. , 16.99312339, 16.99312339, + 33.98624678, 33.98624678, 50.97937017, 50.97937017, 67.97249356, + 67.97249356, 67.97249356]), + control_points=np.array([[[ 108.64959313, -48.35040687, 0. ], + [ 108.64959313, -48.35040687, 10.81815834], + [ 101. , -56. , 10.81815834], + [ 93.35040687, -63.64959313, 10.81815834], + [ 93.35040687, -63.64959313, 0. ], + [ 93.35040687, -63.64959313, -10.81815834], + [ 101. , -56. , -10.81815834], + [ 108.64959313, -48.35040687, -10.81815834], + [ 108.64959313, -48.35040687, 0. ]], + + [[ 81. , -20.70081375, 0. ], + [ 81. , -20.70081375, 10.81815834], + [ 81. , -36. , 10.81815834], + [ 81. , -51.29918625, 10.81815834], + [ 81. , -51.29918625, 0. ], + [ 81. , -51.29918625, -10.81815834], + [ 81. , -36. , -10.81815834], + [ 81. , -20.70081375, -10.81815834], + [ 81. , -20.70081375, 0. ]], + + [[ 53.35040687, -48.35040687, 0. ], + [ 53.35040687, -48.35040687, 10.81815834], + [ 61. , -56. , 10.81815834], + [ 68.64959313, -63.64959313, 10.81815834], + [ 68.64959313, -63.64959313, 0. ], + [ 68.64959313, -63.64959313, -10.81815834], + [ 61. , -56. , -10.81815834], + [ 53.35040687, -48.35040687, -10.81815834], + [ 53.35040687, -48.35040687, 0. ]], + + [[ 25.70081375, -76. , 0. ], + [ 25.70081375, -76. , 10.81815834], + [ 41. , -76. , 10.81815834], + [ 56.29918625, -76. , 10.81815834], + [ 56.29918625, -76. , 0. ], + [ 56.29918625, -76. , -10.81815834], + [ 41. , -76. , -10.81815834], + [ 25.70081375, -76. , -10.81815834], + [ 25.70081375, -76. , 0. ]], + + [[ 53.35040687, -103.64959313, 0. ], + [ 53.35040687, -103.64959313, 10.81815834], + [ 61. , -96. , 10.81815834], + [ 68.64959313, -88.35040687, 10.81815834], + [ 68.64959313, -88.35040687, 0. ], + [ 68.64959313, -88.35040687, -10.81815834], + [ 61. , -96. , -10.81815834], + [ 53.35040687, -103.64959313, -10.81815834], + [ 53.35040687, -103.64959313, 0. ]], + + [[ 81. , -131.29918625, 0. ], + [ 81. , -131.29918625, 10.81815834], + [ 81. , -116. , 10.81815834], + [ 81. , -100.70081375, 10.81815834], + [ 81. , -100.70081375, 0. ], + [ 81. , -100.70081375, -10.81815834], + [ 81. , -116. , -10.81815834], + [ 81. , -131.29918625, -10.81815834], + [ 81. , -131.29918625, 0. ]], + + [[ 108.64959313, -103.64959313, 0. ], + [ 108.64959313, -103.64959313, 10.81815834], + [ 101. , -96. , 10.81815834], + [ 93.35040687, -88.35040687, 10.81815834], + [ 93.35040687, -88.35040687, 0. ], + [ 93.35040687, -88.35040687, -10.81815834], + [ 101. , -96. , -10.81815834], + [ 108.64959313, -103.64959313, -10.81815834], + [ 108.64959313, -103.64959313, 0. ]], + + [[ 136.29918625, -76. , 0. ], + [ 136.29918625, -76. , 10.81815834], + [ 121. , -76. , 10.81815834], + [ 105.70081375, -76. , 10.81815834], + [ 105.70081375, -76. , 0. ], + [ 105.70081375, -76. , -10.81815834], + [ 121. , -76. , -10.81815834], + [ 136.29918625, -76. , -10.81815834], + [ 136.29918625, -76. , 0. ]], + + [[ 108.64959313, -48.35040687, 0. ], + [ 108.64959313, -48.35040687, 10.81815834], + [ 101. , -56. , 10.81815834], + [ 93.35040687, -63.64959313, 10.81815834], + [ 93.35040687, -63.64959313, 0. ], + [ 93.35040687, -63.64959313, -10.81815834], + [ 101. , -56. , -10.81815834], + [ 108.64959313, -48.35040687, -10.81815834], + [ 108.64959313, -48.35040687, 0. ]]]), + weights=np.array([[1. , 0.70710678, 1. , 0.70710678, 1. , + 0.70710678, 1. , 0.70710678, 1. ], + [0.70710678, 0.5 , 0.70710678, 0.5 , 0.70710678, + 0.5 , 0.70710678, 0.5 , 0.70710678], + [1. , 0.70710678, 1. , 0.70710678, 1. , + 0.70710678, 1. , 0.70710678, 1. ], + [0.70710678, 0.5 , 0.70710678, 0.5 , 0.70710678, + 0.5 , 0.70710678, 0.5 , 0.70710678], + [1. , 0.70710678, 1. , 0.70710678, 1. , + 0.70710678, 1. , 0.70710678, 1. ], + [0.70710678, 0.5 , 0.70710678, 0.5 , 0.70710678, + 0.5 , 0.70710678, 0.5 , 0.70710678], + [1. , 0.70710678, 1. , 0.70710678, 1. , + 0.70710678, 1. , 0.70710678, 1. ], + [0.70710678, 0.5 , 0.70710678, 0.5 , 0.70710678, + 0.5 , 0.70710678, 0.5 , 0.70710678], + [1. , 0.70710678, 1. , 0.70710678, 1. , + 0.70710678, 1. , 0.70710678, 1. ]]) +) + +from mmcore.numeric.intersection.csx._ncsx4 import nurbs_csx +import time + +s=time.time() + +isolated, overlaps, _status = result = nurbs_csx(curve, surface, tol=args.atol) +print(f"CSX v4 performed at: {time.time()-s} secs.") + + +over=[] +isol=[] +print(result) + + + + +print('\n\n',result,'\n\n') +print('isolated:') + +if isolated is not None: + rich.print(isolated) +print('overlaps:') +if overlaps is not None: + rich.print(overlaps) +if args.viewer: + try: + from mmcore.extras.renderer.renderer3d import Viewer,OrbitCamera + viewer=Viewer(camera=OrbitCamera(near=1,far=1e+9)) + primary_color=(*(np.array([250, 102, 166])/255).tolist(),1) + + + if isolated is not None: + uvs = [] + for pt in isolated: + viewer.add_point3d(pt['point'], color=(0.0, 1.0, 0.5, 1.0), size_px=6) + + if overlaps is not None: + + for overlap in overlaps: + t0,t1=overlap['t_range'] + + viewer.add_point3d(evaluate_nurbs_curve(curve, t0,d_order=0)['C'], color=(0.0, 1.0, 0.5, 1.0), size_px=4) + viewer.add_point3d(evaluate_nurbs_curve(curve, t1,d_order=0)['C'], color=(0.0, 1.0, 0.5, 1.0), size_px=4) + + for o in overlaps: + + t0 = o["t_range"][0] + t1 = o["t_range"][-1] + + pts=np.linspace(t0,t1,800) + for t in pts: + + evl=evaluate_nurbs_curve(curve,t,d_order=0) + viewer.add_point3d(evl['C'],color=(0.0, 1.0, 0.5, 1.0), size_px=4) + srf = viewer.add_nurbs_surface(surface, color=(0.7, 0.7, 0.7, 1), surface_color=(0.5, 0.5, 0.9, 0.1), ) + viewer.run() + + + except ModuleNotFoundError as err: + print("mmcore.renderer is not installed, skip preview.") + except ImportError as err: + print("mmcore.renderer is not installed, skip preview.") + except Exception as err: + raise err diff --git a/examples/csx/overlap_nurbs_intersection_6.py b/examples/csx/overlap_nurbs_intersection_6.py index 7245d20b..a8e65357 100644 --- a/examples/csx/overlap_nurbs_intersection_6.py +++ b/examples/csx/overlap_nurbs_intersection_6.py @@ -31,7 +31,7 @@ def parse_args(): parser = argparse.ArgumentParser() ssx_params = parser.add_argument_group(title="CSX Parameters") ssx_params.add_argument("--atol", type=float, default=1e-3) - ssx_params.add_argument("--angle_tol", type=float, default=0.052) + general_params = parser.add_argument_group(title="General") general_params.add_argument('--viewer', action='store_true') @@ -185,7 +185,7 @@ def parse_args(): from mmcore.extras.renderer.renderer3d import Viewer,OrbitCamera viewer=Viewer(camera=OrbitCamera(distance=np.linalg.norm(s1.control_points.reshape(-1,3).mean(axis=0))**2,target= s1.control_points.reshape(-1,3).mean(axis=0))) - srf1 = viewer.add_nurbs_surface(s1, color=(0.7, 0.7, 0.7, 1),surface_color=(0.5, 0.5, 0.9, 0.05),u_count=1, v_count=1) + srf1 = viewer.add_nurbs_surface(s1, color=(0.7, 0.7, 0.7, 1),surface_color=(0.5, 0.5, 0.9, 0.1),u_count=1, v_count=1) srf2 = viewer.add_nurbs_surface(s2, color=(0.7, 0.7, 0.7, 1), surface_color=(0.5, 0.5, 0.5, 0.01), u_count=1,v_count=1) def render_result(result,curve,surface=None): @@ -198,7 +198,7 @@ def render_result(result,curve,surface=None): uvs=[] for pt in isolated['point']: - viewer.add(pt, color=(0.0, 1.0, 0.5,1.0),size_px=13) + viewer.add(pt, color=(0.0, 1.0, 0.5,1.0),size_px=6) if overlaps is not None: diff --git a/examples/csx/overlap_nurbs_intersection_6_new.py b/examples/csx/overlap_nurbs_intersection_6_new.py new file mode 100644 index 00000000..45254c3b --- /dev/null +++ b/examples/csx/overlap_nurbs_intersection_6_new.py @@ -0,0 +1,222 @@ +import numpy as np +from mmcore.geom._nurbs_eval import NURBSCurveTuple, NURBSSurfaceTuple, _tuple_to_nurbs, evaluate_nurbs_curve + +import time + + +import rich + +from mmcore.geom._nurbs_knots import trim_curve +from mmcore.numeric import evaluate_curvature_vec +from mmcore.numeric.approx import adaptive_curve_sampler +from mmcore.numeric.intersection.csx import nurbs_csx_v2, nurbs_csx + +import numpy as np +from mmcore.geom._nurbs_eval import NURBSCurveTuple, NURBSSurfaceTuple, _tuple_to_nurbs + +import time + + +import rich + +from mmcore.geom._nurbs_knots import trim_curve +from mmcore.numeric.intersection.csx._ncsx4 import nurbs_csx + + +import numpy as np +from mmcore.geom._nurbs_eval import NURBSCurveTuple + +import argparse +def parse_args(): + parser = argparse.ArgumentParser() + ssx_params = parser.add_argument_group(title="CSX Parameters") + ssx_params.add_argument("--atol", type=float, default=1e-3) + + + general_params = parser.add_argument_group(title="General") + general_params.add_argument('--viewer', action='store_true') + + return parser.parse_args() +args = parse_args() +curve = NURBSCurveTuple( + order=4, + knot=np.array([ 0. , 0. , 0. , 0. , + 4.09559758, 4.09559758, 8.19119516, 8.19119516, + 12.28444691, 12.28444691, 16.37769866, 16.37769866, + 20.47169754, 20.47169754, 24.56569642, 24.56569642, + 28.65705491, 28.65705491, 32.7484134 , 32.7484134 , + 36.84393517, 36.84393517, 40.93945695, 40.93945695, + 45.03497872, 45.03497872, 49.13050049, 49.13050049, + 57.32154404, 57.32154404, 65.51258758, 65.51258758, + 65.51258758, 74.22314717, 74.22314717, 82.93372876, + 82.93372876, 91.64440961, 91.64440961, 95.99975003, + 95.99975003, 100.35509046, 100.35509046, 104.71076842, + 104.71076842, 109.06644638, 109.06644638, 113.42060811, + 113.42060811, 117.77476984, 117.77476984, 122.12820706, + 122.12820706, 126.48164428, 126.48164428, 130.83508151, + 130.83508151, 135.18851873, 135.18851873, 135.18851873, + 135.18851873]), + control_points=np.array([[ 84.07778644, -106.03781562, 10.64806753], + [ 85.50129643, -106.29987225, 10.57524781], + [ 86.97218294, -106.45209039, 10.47993656], + [ 89.96955052, -106.51367517, 10.24858066], + [ 91.49596457, -106.42299563, 10.11254177], + [ 94.55888578, -105.98413263, 9.80467975], + [ 96.09531079, -105.63607038, 9.63288302], + [ 99.13138243, -104.67746666, 9.25873518], + [ 100.63095037, -104.06691887, 9.05638975], + [ 103.54498882, -102.58816693, 8.62663262], + [ 104.95936756, -101.71992913, 8.39922168], + [ 107.65561503, -99.74079645, 7.92497873], + [ 108.93741517, -98.6299328 , 7.67815149], + [ 111.32430666, -96.19061235, 7.17078769], + [ 112.42945485, -94.86234231, 6.91026411], + [ 114.42626734, -92.02006833, 6.38069942], + [ 115.31788905, -90.50612013, 6.11166159], + [ 116.85773044, -87.32983412, 5.56982014], + [ 117.50567375, -85.66740825, 5.29701547], + [ 118.53422963, -82.23939698, 4.75255339], + [ 118.9148308 , -80.47387493, 4.48089763], + [ 119.39478393, -76.88652988, 3.942918 ], + [ 119.4941391 , -75.06476775, 3.67659495], + [ 119.40652816, -71.41286694, 3.15296862], + [ 119.21957782, -69.58278343, 2.89566541], + [ 118.23446665, -64.15082486, 2.14205202], + [ 117.00860556, -60.6091354 , 1.66427408], + [ 113.5379214 , -54.03081541, 0.77748389], + [ 111.29425832, -50.99507207, 0.36842041], + [ 108.64959313, -48.35040687, -0. ], + [ 105.83719033, -45.53800408, -0.39178744], + [ 102.57233318, -43.16832396, -0.73774195], + [ 95.47384964, -39.54662984, -1.38106435], + [ 91.64090447, -38.29493253, -1.67856795], + [ 83.79101261, -37.03099409, -2.26897066], + [ 79.77493103, -37.01909414, -2.5619779 ], + [ 73.91976265, -37.93140849, -3.02888142], + [ 71.996306 , -38.38956289, -3.18905109], + [ 68.25690838, -39.60193422, -3.52299623], + [ 66.44100038, -40.35615773, -3.69678045], + [ 62.96641137, -42.14040606, -4.06187106], + [ 61.3080081 , -43.1706093 , -4.25371361], + [ 58.19452872, -45.47677203, -4.6537583 ], + [ 56.73943071, -46.75262344, -4.86166962], + [ 54.0719309 , -49.51034979, -5.28851308], + [ 52.85950312, -50.99210797, -5.50743901], + [ 50.70805425, -54.11585052, -5.95132956], + [ 49.76908166, -55.75777312, -6.17629052], + [ 48.18670158, -59.14994839, -6.62688672], + [ 47.54327742, -60.90010702, -6.85251699], + [ 46.56322915, -64.45401311, -7.29901548], + [ 46.22661712, -66.25767553, -7.51987789], + [ 45.86042093, -69.86263843, -7.95137927], + [ 45.83082544, -71.66385138, -8.16201182], + [ 46.06763207, -75.20961664, -8.56778972], + [ 46.33399904, -76.95408641, -8.76292845], + [ 46.73756946, -78.64519304, -8.94788941]]), + weights=np.array([1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., + 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., + 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., + 1., 1., 1., 1., 1., 1.]) +) +import numpy as np +from mmcore.geom._nurbs_eval import NURBSSurfaceTuple + + +s1 = NURBSSurfaceTuple( + order_u=2, + order_v=2, + knot_u=np.array([ 0. , 0. , 256.50009777, 256.50009777])*0.001, + knot_v=np.array([ 0. , 0. , 259.71657438, 259.71657438])*0.001, + control_points=np.array([[[-128.25004889, -129.85828719, 67.43742325], + [-128.25004889, 129.85828719, 0. ]], + + [[ 128.25004889, -46.98266257, 0. ], + [ 128.25004889, 129.85828719, 0. ]]]), + weights=np.array([[1., 1.], + [1., 1.]]) +) + + +s2 = NURBSSurfaceTuple( + order_u=2, + order_v=2, + knot_u=np.array([ 0. , 0. , 256.50009777, 256.50009777])*0.001, + knot_v=np.array([ 0. , 0. , 259.71657438, 259.71657438])*0.001, + control_points=np.array([[[-128.25004889, -129.85828719, 0. ], + [-128.25004889, 129.85828719, 0. ]], + + [[ 128.25004889, -129.85828719, 0. ], + [ 128.25004889, 129.85828719, 0. ]]]), + weights=np.array([[1., 1.], + [1., 1.]]) +) +from mmcore.geom.nurbs_iso import extract_surface_boundaries +from mmcore.geom._nurbs_knots import join_curves +results_all=[] +curves2=join_curves(extract_surface_boundaries(s2)) +curves1=join_curves(extract_surface_boundaries(s1)) +import time +s=time.time() +for curve in curves1: + + isolated, overlaps, _status = result = nurbs_csx(curve, s2, tol=args.atol) + + results_all.append((curve,isolated,overlaps)) + +for curve in curves2: + isolated, overlaps, _status = result = nurbs_csx(curve, s1, tol=args.atol) + + results_all.append((curve,isolated,overlaps)) +print(f"CSX v4 performed at: {time.time()-s} secs.") +isolated,overlaps=[],[] +for c,i,o in results_all: + if i is not None: + isolated.extend(i) + if o is not None: + overlaps.extend(o) +rich.print('\nisolated:') +rich.print(isolated) +rich.print('\noverlaps:') +rich.print(overlaps) + +if args.viewer: + try: + from mmcore.extras.renderer.renderer3d import Viewer,OrbitCamera + viewer=Viewer(camera=OrbitCamera(near=1,far=1e+9)) + primary_color=(*(np.array([250, 102, 166])/255).tolist(),1) + + + if isolated is not None: + uvs = [] + for pt in isolated: + viewer.add_point3d(pt['point'], color=(0.0, 1.0, 0.5, 1.0), size_px=6) + + if overlaps is not None: + + for overlap in overlaps: + t0,t1=overlap['t_range'] + + viewer.add_point3d(evaluate_nurbs_curve(curve, t0,d_order=0)['C'], color=(0.0, 1.0, 0.5, 1.0), size_px=4) + viewer.add_point3d(evaluate_nurbs_curve(curve, t1,d_order=0)['C'], color=(0.0, 1.0, 0.5, 1.0), size_px=4) + + for o in overlaps: + + t0 = o["t_range"][0] + t1 = o["t_range"][-1] + + pts=np.linspace(t0,t1,800) + for t in pts: + + evl=evaluate_nurbs_curve(curve,t,d_order=0) + viewer.add_point3d(evl['C'],color=(0.0, 1.0, 0.5, 1.0), size_px=4) + srf = viewer.add_nurbs_surface(s1 ,color=(0.7, 0.7, 0.7, 1), surface_color=(0.5, 0.5, 0.9, 0.1), ) + srf = viewer.add_nurbs_surface(s2, color=(0.7, 0.7, 0.7, 1), surface_color=(0.5, 0.5, 0.9, 0.1), ) + viewer.run() + + + except ModuleNotFoundError as err: + print("mmcore.renderer is not installed, skip preview.") + except ImportError as err: + print("mmcore.renderer is not installed, skip preview.") + except Exception as err: + raise err diff --git a/examples/csx/overlap_nurbs_intersection_new.py b/examples/csx/overlap_nurbs_intersection_new.py new file mode 100644 index 00000000..7107bc83 --- /dev/null +++ b/examples/csx/overlap_nurbs_intersection_new.py @@ -0,0 +1,188 @@ +import argparse +import time +from pathlib import Path + +import numpy as np +import rich + + +from mmcore.geom._nurbs_eval import NURBSCurveTuple, NURBSSurfaceTuple, _tuple_to_nurbs, evaluate_nurbs_curve +from mmcore.geom.nurbs_iso import extract_isocurve +from mmcore.numeric.closest_point import nurbs_surface_closest_point +from mmcore.numeric.intersection.csx._ncsx4 import nurbs_csx +import logging +logging.basicConfig(level=logging.DEBUG) +curve = NURBSCurveTuple( + order=4, + knot=np.array( + [ + -2.67615298, + -2.67615298, + -2.67615298, + -2.67615298, + 0.0, + 0.0, + 0.0, + 3.12101814, + 3.12101814, + 3.12101814, + 6.88039589, + 6.88039589, + 6.88039589, + 6.88039589, + ] + ), + control_points=np.array( + [ + [-48.0003111, 64.08408847, 0.0], + [-48.89236209, 64.08408847, 0.0], + [-49.78441309, 64.08408847, 0.0], + [-50.67646408, 64.08408847, 0.0], + [-51.1718386, 64.99891638, 0.0], + [-51.66721312, 65.91374429, 0.0], + [-52.16258764, 66.82857221, 0.0], + [-52.58835156, 67.61484744, 0.0], + [-53.36295339, 69.04533557, 0.0], + [-58.19051474, 67.75179441, 0.0], + ] + ), + weights=np.array([1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]), +) + +surface = NURBSSurfaceTuple( + order_u=4, + order_v=3, + knot_u=np.array([ 0. , 0. , 0. , 0. , 11.15682108, + 11.15682108, 11.15682108, 11.15682108]), + knot_v=np.array([0. , 0. , 0. , 1.57079633, 1.57079633, + 3.14159265, 3.14159265, 4.71238898, 4.71238898, 6.28318531, + 6.28318531, 6.28318531]), + control_points=np.array([[[-50.55017856, 62.5128825 , 0.93183021], + [-51.36958836, 62.06917638, 1.29472477], + [-51.6887016 , 61.89637824, 0.36289455], + [-52.00781484, 61.72358009, -0.56893565], + [-51.18840503, 62.1672862 , -0.93183021], + [-50.36899523, 62.61099232, -1.29472477], + [-50.04988199, 62.78379047, -0.36289456], + [-49.73076875, 62.95658861, 0.56893565], + [-50.55017856, 62.5128825 , 0.93183021]], + + [[-52.32101251, 65.78315229, 0.93183021], + [-53.14042231, 65.33944617, 1.29472477], + [-53.45953555, 65.16664803, 0.36289455], + [-53.77864879, 64.99384988, -0.56893566], + [-52.95923899, 65.43755599, -0.93183022], + [-52.13982919, 65.88126211, -1.29472477], + [-51.82071595, 66.05406025, -0.36289456], + [-51.50160271, 66.2268584 , 0.56893566], + [-52.32101251, 65.78315229, 0.93183021]], + + [[-54.09184647, 69.05342208, 0.93183021], + [-54.91125627, 68.60971596, 1.29472476], + [-55.2303695 , 68.43691782, 0.36289456], + [-55.54948274, 68.26411967, -0.56893566], + [-54.73007294, 68.70782578, -0.93183021], + [-53.91066314, 69.1515319 , -1.29472477], + [-53.5915499 , 69.32433004, -0.36289455], + [-53.27243666, 69.49712819, 0.56893565], + [-54.09184647, 69.05342208, 0.93183021]], + + [[-55.86268042, 72.32369187, 0.93183021], + [-56.68209022, 71.87998575, 1.29472477], + [-57.00120346, 71.70718761, 0.36289455], + [-57.3203167 , 71.53438946, -0.56893565], + [-56.50090689, 71.97809557, -0.93183021], + [-55.68149709, 72.42180169, -1.29472477], + [-55.36238385, 72.59459984, -0.36289456], + [-55.04327061, 72.76739798, 0.56893565], + [-55.86268042, 72.32369187, 0.93183021]]]), + weights=np.array([[1. , 0.70710678, 1. , 0.70710678, 1. , + 0.70710678, 1. , 0.70710678, 1. ], + [1. , 0.70710678, 1. , 0.70710678, 1. , + 0.70710678, 1. , 0.70710678, 1. ], + [1. , 0.70710678, 1. , 0.70710678, 1. , + 0.70710678, 1. , 0.70710678, 1. ], + [1. , 0.70710678, 1. , 0.70710678, 1. , + 0.70710678, 1. , 0.70710678, 1. ]]) +) +def parse_args(): + parser = argparse.ArgumentParser() + ssx_params=parser.add_argument_group(title="SSX Parameters") + ssx_params.add_argument("--atol", type=float, default=1e-3) + + + general_params=parser.add_argument_group(title="General") + general_params.add_argument('--viewer', action='store_true') + + + + return parser.parse_args() +args = parse_args() + +#s = time.time() +#result = nurbs_csx(_tuple_to_nurbs(curve), _tuple_to_nurbs(surface)) +#print(f"CSX v1 performed at: {time.time()-s} secs.") +#overlaps=[] +#isol=[] +#print(result) +#for tp,item,uv in result: +# if tp =='overlap': +# overlaps.append(item) +# else: +# isol.append(item) +#print('isolated:') +#rich.print(isol) +#print('overlaps:') +#rich.print(overlaps) +s = time.time() +isolated, overlaps, _status = nurbs_csx(curve, surface, tol=args.atol) +print(f"CSX v4 performed at: {time.time()-s} secs.") + +#print('\n\n',result,'\n\n') +print('isolated:') +rich.print(isolated) +print('overlaps:') +rich.print(overlaps) + +if args.viewer: + try: + + from mmcore.extras.renderer.renderer3d import Viewer,OrbitCamera + + + viewer=Viewer(camera=OrbitCamera(target= surface.control_points.reshape(-1,3).mean(axis=0))) + + srf = viewer.add_nurbs_surface(surface, color=(0.7,0.7,0.7,1),surface_color=(0.5, 0.5, 0.9, 0.1), v_count=4) + crv = viewer.add(curve, color=(0.9, 0.9, 0.9, 1.0)) + if isolated is not None: + uvs = [] + for pt in isolated: + viewer.add(pt['point'], color=(0.0, 1.0, 0.5, 1.0), size_px=6) + + if overlaps is not None: + + for overlap in overlaps: + t0, t1 = overlap['t_range'] + + viewer.add(evaluate_nurbs_curve(curve, t0, d_order=0)['C'], color=(0.0, 1.0, 0.5, 1.0), size_px=6) + viewer.add(evaluate_nurbs_curve(curve, t1, d_order=0)['C'], color=(0.0, 1.0, 0.5, 1.0), size_px=6) + + for o in overlaps: + + t0 = o["t_range"][0] + t1 = o["t_range"][-1] + + pts = np.linspace(t0, t1, 800) + for t in pts: + evl = evaluate_nurbs_curve(curve, t, d_order=0) + viewer.add_point3d(evl['C'], color=(0.0, 1.0, 0.5, 1.0), size_px=3) + + viewer.run() + + + except ModuleNotFoundError as err: + print("mmcore.renderer is not installed, skip preview.") + except ImportError as err: + print("mmcore.renderer is not installed, skip preview.") + except Exception as err: + raise err diff --git a/examples/ssx/bez_ssx5_budget_contract.py b/examples/ssx/bez_ssx5_budget_contract.py new file mode 100644 index 00000000..644a3b01 --- /dev/null +++ b/examples/ssx/bez_ssx5_budget_contract.py @@ -0,0 +1,120 @@ +"""Budget/status contract gate for bez_ssx5 (ledger L52; review doc §11 step 5). + +Institutionalizes the 2026-07-12 review probes: every registered coverage +case is solved TWICE and validated against the schema-v2 contract — + + 1. schema presence: ``branches / points / singularities / overlap_regions / + complete / status.reasons / status.work``; + 2. honesty invariant: ``complete == (not status.reasons)`` and every + reason is from the documented vocabulary; + 3. output sanity: all branch ``stuv`` finite and inside [0,1]^4 (+eps), + all xyz finite, and every branch vertex evaluates onto BOTH surfaces + within 6*atol (independent evaluation through eval_surface); + 4. determinism: the md5 digest of all branch xyz polylines is identical + across the two runs (no module-global PRNG, no dict-order leaks). + +Exit code 0 only if every case passes every check. Run: + python examples/ssx/bez_ssx5_budget_contract.py # all cases + python examples/ssx/bez_ssx5_budget_contract.py 5 15 # subset +""" +import hashlib +import sys + +import numpy as np + +from mmcore.numeric.intersection._bezier_common import eval_surface +from mmcore.numeric.intersection.ssx import _bez_ssx5 as engine +from bez_ssx5_coverage_check import ALL_CASES, load_case_surfaces + +ATOL = 1e-3 + +REASON_VOCABULARY = { + value for name, value in vars(engine).items() + if name.startswith("REASON_") and isinstance(value, str) +} + + +def _digest(result): + h = hashlib.md5() + for b in result["branches"]: + h.update(np.ascontiguousarray( + np.asarray(b.curve[1], dtype=np.float64)).tobytes()) + return h.hexdigest() + + +def check_case(case): + S1, S2, rational = load_case_surfaces(case) + runs = [engine.bez_ssx(S1, S2, ATOL, rational=rational) for _ in range(2)] + r = runs[0] + ok = True + + def fail(msg): + nonlocal ok + ok = False + print(f" CONTRACT VIOLATION: {msg}") + + for key in ("branches", "points", "singularities", "overlap_regions", + "complete", "status"): + if key not in r: + fail(f"missing result key {key!r}") + status = r.get("status", {}) + for key in ("reasons", "work"): + if key not in status: + fail(f"missing status key {key!r}") + + reasons = list(status.get("reasons", [])) + if bool(r.get("complete")) != (not reasons): + fail(f"complete={r.get('complete')} inconsistent with reasons={reasons}") + unknown = [x for x in reasons if x not in REASON_VOCABULARY] + if unknown: + fail(f"undocumented reasons {unknown}") + # §11.6 de-budget invariant (declared L52 slice 9, 2026-07-12): no + # registered GATE case may report work_budget — the gate geometries + # complete far below every allowance, so a work_budget here means a + # misbilled structural condition or a genuine headroom regression. + if "work_budget" in reasons: + fail(f"gate case reports work_budget: {reasons}") + work = status.get("work", {}) + for k, v in work.items(): + if isinstance(v, (int, float)) and not np.isfinite(v): + fail(f"non-finite work counter {k}={v}") + + if rational: + S1h, S2h = S1, S2 + else: + S1h = np.concatenate([S1, np.ones(S1.shape[:-1] + (1,))], axis=-1) + S2h = np.concatenate([S2, np.ones(S2.shape[:-1] + (1,))], axis=-1) + worst = 0.0 + for bi, b in enumerate(r["branches"]): + stuv = np.asarray(b.curve[0], dtype=np.float64) + xyz = np.asarray(b.curve[1], dtype=np.float64) + if not np.all(np.isfinite(stuv)) or not np.all(np.isfinite(xyz)): + fail(f"branch {bi}: non-finite samples") + continue + if np.any(stuv < -1e-9) or np.any(stuv > 1.0 + 1e-9): + fail(f"branch {bi}: stuv outside [0,1]^4 " + f"(min {stuv.min():.3g}, max {stuv.max():.3g})") + for q, p in zip(stuv, xyz): + p1 = eval_surface(S1h, float(q[0]), float(q[1]), rational=True) + p2 = eval_surface(S2h, float(q[2]), float(q[3]), rational=True) + worst = max(worst, + float(np.linalg.norm(p1 - p)), + float(np.linalg.norm(p2 - p))) + if worst > 6.0 * ATOL: + fail(f"branch sample residual {worst:.5f} > {6.0 * ATOL}") + + digests = {_digest(run) for run in runs} + if len(digests) != 1: + fail(f"nondeterministic branches across identical calls: {digests}") + + print(f"=== case {case}: complete={r.get('complete')} " + f"reasons={reasons} branches={len(r['branches'])} " + f"worst_resid={worst:.2e} " + f"{'OK' if ok else 'VIOLATIONS ABOVE'}") + return ok + + +if __name__ == "__main__": + cases = [int(a) for a in sys.argv[1:]] or list(ALL_CASES) + results = [check_case(c) for c in cases] + sys.exit(0 if all(results) else 1) diff --git a/examples/ssx/bez_ssx5_case12.py b/examples/ssx/bez_ssx5_case12.py new file mode 100644 index 00000000..78f70a17 --- /dev/null +++ b/examples/ssx/bez_ssx5_case12.py @@ -0,0 +1,43 @@ +"""case 12 (USER 2026-07-10): coplanar partial overlap — 2D overlap REGION. + +Two planar bilinear patches in z=0 sharing one full edge (the x=11.805 row) +and one corner; their interiors overlap in a 2D region. Both surfaces come +from single-span NURBS (knot spans irrelevant for Bezier extraction). + +REPORTED WRONG OUTPUT (two runs, same pair): + 1.1 two branches found on the coinciding edges — but the true answer is + an OVERLAP (2D region), not curve branches; + 1.2 a branch + a tangent_point singularity + an isolated point. +EXPECTED (semantics TBD with user): a surface-overlap REGION result — the +paper's C2 sub-case #(Delta)=inf, "partially overlap" (Fig. 8 bottom row). +Current output schema has no overlap-region concept: design decision needed +before coding. + +RESOLVED 2026-07-12 (ledger L28, approved Option C): the result now carries +result['overlap_regions'] — one SSXOverlapRegion with a closed rim loop of +4 kind='overlap' branches (2 shared-edge + 2 interior curved-preimage rims), +paired sample-synchronized uv loops on both surfaces, a certified interior +witness, and normal_agreement; complete=True with status.reasons == []. + +Note RATIONAL=False here (weights all 1); z=0 exactly for every control point. +""" +import numpy as np + +RATIONAL = False + +S1 = np.array([[[30.45075084, -31.4974516, 0.], [33.62638337, -51.52443823, 0.]], + [[11.8052607, -31.4974516, 0.], [11.8052607, -51.52443823, 0.]]]) + +S2 = np.array([[[30.45075084, -31.4974516, 0.], [27.67935951, -47.01458062, 0.]], + [[11.8052607, -31.4974516, 0.], [11.8052607, -51.52443823, 0.]]]) + + +if __name__ == "__main__": + from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx + res = bez_ssx(S1, S2, 1e-3, rational=False) + print(f"branches={len(res['branches'])} points={len(res['points'])} " + f"singularities={[g.kind for g in res.get('singularities', [])]}") + for b in res["branches"]: + xyz = np.asarray(b.curve[1]) + print(f" branch kind={getattr(b, 'kind', '?')} n={len(xyz)} " + f"{np.round(xyz[0], 3).tolist()} -> {np.round(xyz[-1], 3).tolist()}") diff --git a/examples/ssx/bez_ssx5_case13.py b/examples/ssx/bez_ssx5_case13.py new file mode 100644 index 00000000..b8ddc9ed --- /dev/null +++ b/examples/ssx/bez_ssx5_case13.py @@ -0,0 +1,35 @@ +"""case 13 (USER 2026-07-10): rational sphere patch vs bilinear patch — HANG. + +S1: degree (2,2) rational sphere segment (weights 0.707/0.5 pattern); +S2: bilinear ruled quad. User titled it "overlapping sphere segments"; +regardless of the true topology the observed behavior is: bez_ssx NEVER +TERMINATES — a violation of the budget-bounded no-hang invariant. + +Homogeneous nets provided (RATIONAL=True). Run under a watchdog only. +""" +import numpy as np + +RATIONAL = True + +_P1 = np.array([[[4.9056771, -3.79942682, -0.], [4.9056771, -3.79942682, 0.65971369], + [4.9056771, -4.45914051, 0.65971369]], + [[4.24596341, -3.79942682, -0.], [4.24596341, -3.79942682, 0.65971369], + [4.9056771, -4.45914051, 0.65971369]], + [[4.24596341, -4.45914051, -0.], [4.24596341, -4.45914051, 0.65971369], + [4.9056771, -4.45914051, 0.65971369]]]) +_W1 = np.array([[1., 0.70710678, 1.], [0.70710678, 0.5, 0.70710678], [1., 0.70710678, 1.]]) + +_P2 = np.array([[[5.09151335, -3.56518057, 0.04708861], [5.26654768, -3.91168979, 0.82282564]], + [[3.75042844, -4.24261104, 0.04708861], [3.92546278, -4.58912026, 0.82282564]]]) +_W2 = np.ones((2, 2)) + +# homogeneous form (P*w, w) — the bez_ssx rational convention +S1 = np.concatenate([_P1 * _W1[..., None], _W1[..., None]], axis=-1) +S2 = np.concatenate([_P2 * _W2[..., None], _W2[..., None]], axis=-1) + + +if __name__ == "__main__": + from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx + res = bez_ssx(S1, S2, 1e-3, rational=True) + print(f"branches={len(res['branches'])} points={len(res['points'])} " + f"singularities={[g.kind for g in res.get('singularities', [])]}") diff --git a/examples/ssx/bez_ssx5_case14.py b/examples/ssx/bez_ssx5_case14.py new file mode 100644 index 00000000..8751523e --- /dev/null +++ b/examples/ssx/bez_ssx5_case14.py @@ -0,0 +1,36 @@ +"""case 14 (USER 2026-07-10): two rational cone segments, single singular +tangent branch at their intersection — HANG. + +Both surfaces are degree (2,1) rational cone patches (apex rows of repeated +control points; 0.707 middle-row weights). The true SSI is one singular +tangent branch (cone-cone tangency along a line). Observed: bez_ssx NEVER +TERMINATES — no-hang invariant violated. Apex = a degenerate +parameterization row (Sigma = 0 at the apex: C1-adjacent territory) AND +rational weights: exercises L9/L26 rational paths + tangent-curve handling +at once. + +Homogeneous nets provided (RATIONAL=True). Run under a watchdog only. +""" +import numpy as np + +RATIONAL = True + +_P1 = np.array([[[26.44006464, -11.11732309, 46.51978915], [26.44006464, -24.15357802, 0.]], + [[26.44006464, -11.11732309, 46.51978915], [13.40380971, -24.15357802, 0.]], + [[26.44006464, -11.11732309, 46.51978915], [13.40380971, -11.11732309, 0.]]]) +_W1 = np.array([[1., 1.], [0.70710678, 0.70710678], [1., 1.]]) + +_P2 = np.array([[[21.61898362, -23.22934688, 0.], [28.29755124, -12.03378182, 46.51978915]], + [[21.61898362, -23.22934688, 0.], [17.10198618, -5.3552142, 46.51978915]], + [[21.61898362, -23.22934688, 0.], [10.42341856, -16.55077926, 46.51978915]]]) +_W2 = np.array([[1., 1.], [0.70710678, 0.70710678], [1., 1.]]) + +S1 = np.concatenate([_P1 * _W1[..., None], _W1[..., None]], axis=-1) +S2 = np.concatenate([_P2 * _W2[..., None], _W2[..., None]], axis=-1) + + +if __name__ == "__main__": + from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx + res = bez_ssx(S1, S2, 1e-3, rational=True) + print(f"branches={len(res['branches'])} points={len(res['points'])} " + f"singularities={[g.kind for g in res.get('singularities', [])]}") diff --git a/examples/ssx/bez_ssx5_case15.py b/examples/ssx/bez_ssx5_case15.py new file mode 100644 index 00000000..6ff0a93e --- /dev/null +++ b/examples/ssx/bez_ssx5_case15.py @@ -0,0 +1,89 @@ +"""case 15 (ledger L25, 2026-07-12): edge-graze — transversal arc hugging a domain edge. + +S1 is the graph of z(s,t) = h*s - c*(t - t_star)^2 - h*d0 over (s,t) in [0,1]^2 — +an exact degree-(1,2) Bezier patch. S2 is the plane z=0. The true SSI is the +parabola + + s(t) = (c/h) * (t - t_star)^2 + d0, xyz = (s, t, 0), t in [0,1] + +whose closest approach to S1's s=0 domain edge is d0 (in both parameter and xyz +units): d0 = 0 grazes the edge exactly (tangent at one point), d0 > 0 passes +just INSIDE without touching, d0 < 0 dips outside (two transversal s=0 boundary +crossings). The intersection is TRANSVERSAL everywhere in 3D (surface normals +at closest approach are 45 degrees apart) — the (near-)tangency is between the +SSI curve and the parameter-domain EDGE, not between the surfaces. + +The module-level pair is the L25 repro: an INSIDE near-graze (d0 = 1e-4, i.e. +0.1*atol clearance, off-lattice t_star). Before the L25 fix the marcher's +fixed-face exit Newton stalled at the closest-approach witness (residual = d0, +NOT a root — the face carries no Psi zero), committed it as an exit vertex at +the atol-scale acceptance bar, and the strict path certificate then rejected +every fragment: the ENTIRE arc vanished (0 branches, reasons=['trace_unverified']). +At d0 ~ atol the exit was refused instead and the march broke off mid-curve: +two truncated branches with a silent gap, falsely complete. + +EXPECTED: one transversal branch covering the whole parabola through the +near-graze; zero singularities; complete=True; full analytic coverage. +""" +import numpy as np + +RATIONAL = False + +# Default variant: off-lattice near-graze passing 0.1*atol inside the s=0 edge. +T_STAR = 0.37 +C_SHARP = 1.0 +H_SLOPE = 1.0 +D_CLEAR = 1e-4 + + +def build_graze_pair(t_star=T_STAR, c=C_SHARP, h=H_SLOPE, d0=D_CLEAR, scale=1.0): + """S1 = graph of h*s - c*(t-t_star)^2 - h*d0 (degree (1,2)); S2 = plane z=0.""" + r0 = np.array([-c * t_star ** 2, + c * t_star * (1.0 - t_star), + -c * (1.0 - t_star) ** 2]) - h * d0 + S1 = np.array([ + [[0.0, 0.0, r0[0]], [0.0, 0.5, r0[1]], [0.0, 1.0, r0[2]]], + [[1.0, 0.0, r0[0] + h], [1.0, 0.5, r0[1] + h], [1.0, 1.0, r0[2] + h]], + ]) * scale + S2 = np.array([ + [[-0.5, -0.5, 0.0], [-0.5, 1.5, 0.0]], + [[1.5, -0.5, 0.0], [1.5, 1.5, 0.0]], + ]) * scale + return S1, S2 + + +def analytic_curve(t_star=T_STAR, c=C_SHARP, h=H_SLOPE, d0=D_CLEAR, scale=1.0, + n=2001): + """Exact SSI samples, clipped to S1's domain (s in [0,1]).""" + t = np.linspace(0.0, 1.0, n) + s = (c / h) * (t - t_star) ** 2 + d0 + m = (s >= 0.0) & (s <= 1.0) + return np.column_stack([s[m], t[m], np.zeros(int(m.sum()))]) * scale + + +S1, S2 = build_graze_pair() + + +if __name__ == "__main__": + from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx + from bez_ssx5_coverage_check import point_to_polyline_dist + + atol = 1e-3 + res = bez_ssx(S1, S2, atol, rational=RATIONAL) + print(f"branches={len(res['branches'])} points={len(res['points'])} " + f"singularities={[g.kind for g in res.get('singularities', [])]} " + f"complete={res.get('complete')} " + f"reasons={res.get('status', {}).get('reasons', [])}") + polys = [] + for b in res["branches"]: + xyz = np.asarray(b.curve[1]) + polys.append(xyz) + print(f" branch kind={getattr(b, 'kind', '?')} n={len(xyz)} " + f"{np.round(xyz[0], 4).tolist()} -> {np.round(xyz[-1], 4).tolist()}") + truth = analytic_curve() + d = np.array([min((point_to_polyline_dist(p, poly) for poly in polys), + default=np.inf) for p in truth]) + missed = d > 5 * atol + print(f"analytic coverage {int((~missed).sum())}/{len(truth)}" + + (f"; MISSED t-ranges around {np.round(truth[missed][:, 1], 4).tolist()[:10]}" + f" worst {float(d[missed].max()):.5f}" if missed.any() else "")) diff --git a/examples/ssx/bez_ssx5_coverage_check.py b/examples/ssx/bez_ssx5_coverage_check.py index e2110016..ab470ac4 100644 --- a/examples/ssx/bez_ssx5_coverage_check.py +++ b/examples/ssx/bez_ssx5_coverage_check.py @@ -10,6 +10,8 @@ python examples/ssx/bez_ssx5_coverage_check.py 10 11 # check cases 10, 11 python examples/ssx/bez_ssx5_coverage_check.py # check all known cases """ +import json +import os import sys import numpy as np @@ -18,7 +20,7 @@ from mmcore.numeric.intersection.csx._bez_csx4 import bez_csx from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx -ALL_CASES = (5, 6, 7, 8, 9, 10, 11) +ALL_CASES = (5, 6, 7, 8, 9, 10, 11, 15) def _extract_isoline(S, axis, value): @@ -29,13 +31,20 @@ def _extract_isoline(S, axis, value): return left[:, -1, :] -def reference_cloud(S1, S2, atol=1e-3, n=200, axis=0): +def reference_cloud(S1, S2, atol=1e-3, n=200, axis=0, rational=False): """Slice S1 along `axis`, CSX each isoline against S2.""" - S1h = np.concatenate([S1, np.ones(S1.shape[:-1] + (1,))], axis=-1) - S2h = np.concatenate([S2, np.ones(S2.shape[:-1] + (1,))], axis=-1) + if rational: + S1h, S2h = S1, S2 + else: + S1h = np.concatenate([S1, np.ones(S1.shape[:-1] + (1,))], axis=-1) + S2h = np.concatenate([S2, np.ones(S2.shape[:-1] + (1,))], axis=-1) pts, params = [], [] for w in np.linspace(1e-9, 1.0 - 1e-9, n): r = bez_csx(_extract_isoline(S1h, axis, float(w)), S2h, atol=atol, rational=True) + if (r.get("budget_exhausted", False) + or not r.get("boundary_topology_complete", True)): + raise RuntimeError( + f"reference CSX incomplete at slice {float(w):.12g}") for p in r.get("isolated", []): pts.append(np.asarray(p["point"], dtype=float)) params.append(float(w)) @@ -73,19 +82,63 @@ def _capture(a, b, *args, **kw): fn = next(v for k, v in ns.items() if callable(v) and k.startswith("bez_ssx_case")) fn() s1, s2 = captured["s1"], captured["s2"] - return np.asarray(s1, dtype=float), np.asarray(s2, dtype=float) - - -def check_case(case, atol=1e-3, n_ref=200): + return (np.asarray(s1, dtype=float), np.asarray(s2, dtype=float), + bool(ns.get("RATIONAL", False))) + + +WORK_BASELINE_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "bez_ssx5_work_baseline.json") +# Review doc 2026-07-12 §11.5 (ledger L52 slice 11): the gate records +# per-case status.work and fails on >2x cells drift, so headroom +# regressions surface the day they happen — not the day a model freezes. +WORK_DRIFT_FACTOR = 2.0 + + +def load_work_baseline(): + try: + with open(WORK_BASELINE_PATH) as fh: + return json.load(fh) + except (OSError, ValueError): + return {} + + +def check_work_drift(case, res, baseline): + work = res.get("status", {}).get("work", {}) + cells = int(work.get("cells_processed", 0)) + csx_calls = int(work.get("csx_calls", 0)) + base = baseline.get(str(case)) + if base is None: + print(f" work cells={cells} csx_calls={csx_calls} " + f"(no baseline recorded — run --update-baseline)") + return True, cells, csx_calls + base_cells = max(1, int(base.get("cells_processed", 0))) + ratio = cells / base_cells + line = (f" work cells={cells} csx_calls={csx_calls} " + f"(baseline {base_cells}, x{ratio:.2f})") + if cells > WORK_DRIFT_FACTOR * base_cells: + print(line + " WORK DRIFT: exceeds " + f"{WORK_DRIFT_FACTOR}x baseline") + return False, cells, csx_calls + print(line) + return True, cells, csx_calls + + +def check_case(case, atol=1e-3, n_ref=200, work_baseline=None, + work_out=None): import time - S1, S2 = load_case_surfaces(case) + S1, S2, rational = load_case_surfaces(case) t0 = time.time() - res = bez_ssx(S1, S2, atol, rational=False) + res = bez_ssx(S1, S2, atol, rational=rational) dt = time.time() - t0 polys = [] print(f"=== case {case}: {dt:.2f}s, {len(res['branches'])} branches, " f"{len(res['points'])} points") + work_ok, cells, csx_calls = check_work_drift( + case, res, work_baseline if work_baseline is not None else {}) + if work_out is not None: + work_out[str(case)] = {"cells_processed": cells, + "csx_calls": csx_calls} for bi, b in enumerate(res["branches"]): xyz = np.asarray(b.curve[1]) seg = np.linalg.norm(np.diff(xyz, axis=0), axis=1) @@ -99,14 +152,24 @@ def check_case(case, atol=1e-3, n_ref=200): for g in res.get("singularities", []): print(f" singularity {g.kind}: stuv={np.round(g.stuv, 5).tolist()} " f"xyz={np.round(g.xyz, 5).tolist()} links={g.branch_links}") + if not res.get("complete", True): + reasons = res.get("status", {}).get("reasons", []) + print(f" INCOMPLETE SSX RESULT: reasons={reasons}") + return False # Ledger L24: the regular coverage cases must produce ZERO typed # singularities — enforced (exit code), not just printed, so CI # catches spurious-singularity regressions. - clean_singularities = not res.get("singularities", []) + clean_singularities = (case not in ALL_CASES + or not res.get("singularities", [])) if not clean_singularities: print(f" SPURIOUS SINGULARITIES: {len(res['singularities'])} (expected 0)") - ref, ws = reference_cloud(S1, S2, atol=atol, n=n_ref) + try: + ref, ws = reference_cloud( + S1, S2, atol=atol, n=n_ref, rational=rational) + except RuntimeError as exc: + print(f" INCOMPLETE REFERENCE: {exc}") + return False if not len(ref): print(" (no reference points)") return clean_singularities @@ -118,10 +181,22 @@ def check_case(case, atol=1e-3, n_ref=200): + (f"; MISSED {int(missed.sum())} " f"(worst {float(dists[missed].max()):.4f} at s={ws[missed][np.argmax(dists[missed])]:.4f})" if missed.any() else "")) - return (not missed.any()) and clean_singularities + return (not missed.any()) and clean_singularities and work_ok if __name__ == "__main__": - cases = [int(a) for a in sys.argv[1:]] or list(ALL_CASES) - ok = all([check_case(c) for c in cases]) + args = sys.argv[1:] + update_baseline = "--update-baseline" in args + cases = [int(a) for a in args if a != "--update-baseline"] + cases = cases or list(ALL_CASES) + baseline = load_work_baseline() + measured = {} + ok = all([check_case(c, work_baseline=baseline, work_out=measured) + for c in cases]) + if update_baseline: + baseline.update(measured) + with open(WORK_BASELINE_PATH, "w") as fh: + json.dump(baseline, fh, indent=2, sort_keys=True) + fh.write("\n") + print(f"work baseline updated: {WORK_BASELINE_PATH}") sys.exit(0 if ok else 1) diff --git a/examples/ssx/bez_ssx5_work_baseline.json b/examples/ssx/bez_ssx5_work_baseline.json new file mode 100644 index 00000000..212893ff --- /dev/null +++ b/examples/ssx/bez_ssx5_work_baseline.json @@ -0,0 +1,34 @@ +{ + "10": { + "cells_processed": 38960, + "csx_calls": 261 + }, + "11": { + "cells_processed": 48930, + "csx_calls": 283 + }, + "15": { + "cells_processed": 382, + "csx_calls": 8 + }, + "5": { + "cells_processed": 20210, + "csx_calls": 155 + }, + "6": { + "cells_processed": 40927, + "csx_calls": 292 + }, + "7": { + "cells_processed": 19623, + "csx_calls": 216 + }, + "8": { + "cells_processed": 7741, + "csx_calls": 50 + }, + "9": { + "cells_processed": 7333, + "csx_calls": 51 + } +} diff --git a/examples/ssx/bilinear_l_junction_user_cases.py b/examples/ssx/bilinear_l_junction_user_cases.py new file mode 100644 index 00000000..71f6a78e --- /dev/null +++ b/examples/ssx/bilinear_l_junction_user_cases.py @@ -0,0 +1,97 @@ +"""The user's three bilinear L-junction cases, VERBATIM (2026-07-12). + +Ledger L57/L61. Preserved in the exact NURBSSurfaceTuple form the user +posted — including the non-unit KNOT VECTORS, which the normalized test +fixtures (tests/test_csx_overlap_tier.py) deliberately drop because +single-span nets are Bezier-identical. If the user's still-failing run +(L61) goes through a NURBS-level driver, the knots and the stuv->knot +parameter mapping are the prime suspects, and this file is the ground +truth for them. + +History: cases 1 and 2 lost most of one branch (37%/11% survived, ledger +L57 — root-caused to the missing curve-on-surface overlap certification, +fixed by L59/L60); case 3 always worked (its planar quad is a +PARALLELOGRAM: twist = P11-P10-P01+P00 = 0, so the exact-affine +certificate applied). The twist discriminator is what identified the +parameterization as the problem. + +TRUTH for cases 1-2: val2's z(u,v) = 2.89525681*u*(1-v); zero set = the +u=0 edge UNION the v=1 edge of val2, meeting at val2's (0,1) corner where +grad z = 0 -> TWO full branches (xyz length ~9.763 each) joined at ONE +tangent_point, complete=True. +""" +import numpy as np + +from mmcore.geom._nurbs_eval import NURBSSurfaceTuple + +# --- Case 1 (user: "the second branch is not completely found") --------- +case1_val1 = NURBSSurfaceTuple( + order_u=2, order_v=2, + knot_u=np.array([0.0, 0.0, 17.82890302, 17.82890302]), + knot_v=np.array([0.0, 0.0, 17.82890302, 17.82890302]), + control_points=np.array([ + [[28.73565361, -57.3828431, 0.0], [41.34259183, -50.11361956, 0.0]], + [[41.34259183, -75.32749601, 0.0], [53.84239759, -62.72055778, 0.0]]]), + weights=np.array([[1.0, 1.0], [1.0, 1.0]])) + +case1_val2 = NURBSSurfaceTuple( + order_u=2, order_v=2, + knot_u=np.array([0.0, 0.0, 9.76292344, 9.76292344]), + knot_v=np.array([0.0, 0.0, 9.76292344, 9.76292344]), + control_points=np.array([ + [[35.58090097, -65.90568734, 0.0], [38.10773149, -56.47542745, 0.0]], + [[45.01116086, -68.43251786, 2.89525681], [47.53799138, -59.00225797, 0.0]]]), + weights=np.array([[1.0, 1.0], [1.0, 1.0]])) + +# --- Case 2 (user: "the second branch is simply lost; an isolated point +# with an enormous |S1(s,t)-S2(u,v)| was returned instead" — the invalid +# point did NOT reproduce at HEAD 1efe8c2; keep checking residual validity) +case2_val1 = NURBSSurfaceTuple( + order_u=2, order_v=2, + knot_u=np.array([0.0, 0.0, 17.82890302, 17.82890302]), + knot_v=np.array([0.0, 0.0, 17.82890302, 17.82890302]), + control_points=np.array([ + [[28.73565361, -62.66611014, 0.0], [41.34259183, -50.11361956, 0.0]], + [[41.34259183, -75.32749601, 0.0], [53.84239759, -62.72055778, 0.0]]]), + weights=np.array([[1.0, 1.0], [1.0, 1.0]])) + +case2_val2 = case1_val2 + +# --- Case 3 (user: "works perfectly fine, even though it looks the same") +case3_val1 = case1_val2 # the lifted bilinear, passed FIRST here + +case3_val2 = NURBSSurfaceTuple( + order_u=2, order_v=2, + knot_u=np.array([0.0, 0.0, 24.46650685, 24.46650685]), + knot_v=np.array([0.0, 0.0, 23.97376664, 23.97376664]), + control_points=np.array([ + [[46.82101, -80.30032742, 0.0], [26.05911906, -68.31344409, 0.0]], + [[59.05426342, -59.11171094, 0.0], [38.29237249, -47.12482762, 0.0]]]), + weights=np.array([[1.0, 1.0], [1.0, 1.0]])) + +CASES = { + 1: (case1_val1, case1_val2), + 2: (case2_val1, case2_val2), + 3: (case3_val1, case3_val2), +} + + +def run_bez_level(case): + """Bezier-level run (normalized [0,1] parameters; single-span nets).""" + from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx + v1, v2 = CASES[case] + return bez_ssx(np.asarray(v1.control_points, dtype=float), + np.asarray(v2.control_points, dtype=float), + 1e-3, rational=False) + + +if __name__ == "__main__": + for case in (1, 2, 3): + r = run_bez_level(case) + print(f"--- case {case}: branches={len(r['branches'])} " + f"sing={[g.kind for g in r['singularities']]} " + f"complete={r['complete']} reasons={r['status']['reasons']}") + for bi, b in enumerate(r["branches"]): + xyz = np.asarray(b.curve[1]) + seg = float(np.linalg.norm(np.diff(xyz, axis=0), axis=1).sum()) + print(f" branch {bi}: {len(xyz)} pts len={seg:.3f}") diff --git a/examples/ssx/common_helpers.py b/examples/ssx/common_helpers.py index 4bb4c895..5a795e6b 100644 --- a/examples/ssx/common_helpers.py +++ b/examples/ssx/common_helpers.py @@ -43,7 +43,7 @@ class WiresMaterial(CurveMaterial): @dataclass class SurfaceMaterial: - color:tuple[float,float,float,float]=field(default=(0.5, 0.5, 0.9, 0.05)) + color:tuple[float,float,float,float]=field(default=(0.5, 0.5, 0.9, 0.5)) show_wires:bool=field(default=True) wires_material: WiresMaterial = field( default_factory=lambda: WiresMaterial((1.0, 1.0, 1.0, 1.0), show_control_net=False, u_count=1, v_count=1) @@ -69,7 +69,7 @@ def parse_args(): parser = argparse.ArgumentParser() ssx_params=parser.add_argument_group(title="SSX Parameters") ssx_params.add_argument("--atol", type=float, default=1e-3) - ssx_params.add_argument("--angle_tol", type=float, default=0.052) + general_params=parser.add_argument_group(title="General") general_params.add_argument('--viewer', action='store_true') diff --git a/mmcore/extras/renderer/renderer3d.py b/mmcore/extras/renderer/renderer3d.py index 2eee48ab..38260ae4 100644 --- a/mmcore/extras/renderer/renderer3d.py +++ b/mmcore/extras/renderer/renderer3d.py @@ -687,8 +687,8 @@ class EdgeSettings: Widths are in window points; multiplied by the content scale at draw time, so 2.0 means 2 device-independent pixels on a retina display too.""" - inner_width: float = 1.75 # isocurves and other interior linework - outline_width: float = 3.0 # surface boundaries / B-rep edges + inner_width: float = 1. # isocurves and other interior linework + outline_width: float = 1.5 # surface boundaries / B-rep edges inner_color: tuple[float, float, float, float] = (0.055, 0.058, 0.070, 1.0) outline_color: tuple[float, float, float, float] = (0.030, 0.032, 0.040, 1.0) diff --git a/mmcore/geom/_nurbs_param_tol.py b/mmcore/geom/_nurbs_param_tol.py index e451934e..e7f09da4 100644 --- a/mmcore/geom/_nurbs_param_tol.py +++ b/mmcore/geom/_nurbs_param_tol.py @@ -86,8 +86,10 @@ def _bezier_curve_param_tol_conservative(P, w, tol, u0=0.0, u1=1.0): # Degree scaling + denominator lower bound, mirroring the OCC structure L = (p * Lmax) / min_w + if L == 0.0: + return float(tol) RealSmall = np.finfo(float).tiny - tol_u = float(tol) / max(L, RealSmall) + tol_u = min(float(tol), float(tol) / max(L, RealSmall)) return tol_u import numpy as np @@ -116,16 +118,29 @@ def _bezier_curve_param_tol_optimistic( P,w,tol, interval=(0.,1.))->float: W0 = w[:-1,None] W1 = w[1:,None] - s=(P1 * W0 - P0 * W1)/ (W0 * W0) + # ``P`` is already Euclidean (``from_homogeneous_1d`` divided the + # homogeneous numerators by ``w``). The quotient-rule numerator for + # an edge is therefore + # + # (P1*w1)*w0 - (P0*w0)*w1 = w0*w1*(P1-P0), + # + # not ``P1*w0 - P0*w1``. The old expression mixed Euclidean controls + # with homogeneous algebra, was not translation invariant, and gave a + # non-zero speed to a geometrically constant rational curve whenever + # adjacent weights differed (SSX case 14's collapsed cone-apex edge). + s=(P1 - P0) * (W1 / W0) dt=np.linalg.norm(s,axis=-1).max() - - - ddt=interval[1]-interval[0] - if dt<_TINY: + # `< _TINY`, not `== 0.0` (review 2026-07-12 §10): the same collapsed- + # speed guard as `_nurbs_curve_param_tol_optimistic`/the conservative + # variants. With the current squared-sum norm the two are equivalent + # (sub-1.5e-162 components flush to a norm of exactly 0.0), but a + # scaled-norm implementation would expose the quotient to denormal + # denominators; the guard must not depend on that accident. + if dt < _TINY: tol_u=tol else: @@ -147,7 +162,9 @@ def _bezier_surface_param_tol_optimistic(P,w,tol, interval_u=(0.,1.), interval_v W0 = w[:,:-1,None] W1 = w[:,1:,None] - s=(P1 * W0 - P0 * W1)/ (W0 * W0) + # P is Euclidean here; retain the weight ratio but use control-point + # differences so the derivative estimate is translation invariant. + s=(P1 - P0) * (W1 / W0) dv=np.linalg.norm(s,axis=-1).max() @@ -156,22 +173,21 @@ def _bezier_surface_param_tol_optimistic(P,w,tol, interval_u=(0.,1.), interval_v W0 = w[:-1,:,None] W1 = w[1:,:,None] - s=(P1 * W0 - P0 * W1)/ (W0 * W0) + s=(P1 - P0) * (W1 / W0) du=np.linalg.norm(s,axis=-1).max() - - ddu=interval_u[1]-interval_u[0] ddv=interval_v[1]-interval_v[0] - if du<_TINY: + # `< _TINY`, not `== 0.0` — see the curve variant's comment. + if du < _TINY: tol_u=tol else: tol_u = tol * (ddu/du) - if dv<_TINY: + if dv < _TINY: tol_v=tol else: tol_v = tol * (ddv/dv) @@ -303,8 +319,8 @@ def _bezier_surface_param_tol_conservative(P, w, tol, L_v = (q * Lmax_v) / min_w RealSmall = np.finfo(float).tiny - tol_u = tol / max(L_u, RealSmall) - tol_v = tol / max(L_v, RealSmall) + tol_u = tol if L_u == 0.0 else min(tol, tol / max(L_u, RealSmall)) + tol_v = tol if L_v == 0.0 else min(tol, tol / max(L_v, RealSmall)) return float(tol_u), float(tol_v) def _nurbs_curve_param_tol_conservative(P, w, U, p, tol): """ @@ -529,8 +545,7 @@ def bez_curve_param_tolerance(bez: NDArray, tol: float, rational:bool=False, int else: P,w=bez,np.ones_like(bez[...,0]) - if rational and np.any(bez[...,-1]<0): - P,w=from_homogeneous_1d(bez) + if rational and not np.all(w == w.flat[0]): return _bezier_curve_param_tol_conservative(P,w,tol=tol,u0=interval[0],u1=interval[1]) @@ -538,7 +553,8 @@ def bez_curve_param_tolerance(bez: NDArray, tol: float, rational:bool=False, int - return _bezier_curve_param_tol_optimistic(P,w,tol=tol,interval=interval) + return _bezier_curve_param_tol_optimistic( + P, np.ones_like(w), tol=tol, interval=interval) def bez_surface_param_tolerance(bez: NDArray, tol: float, rational:bool=False, interval_u=(0.,1.),interval_v=(0.,1.)) -> tuple[float,float]: @@ -553,10 +569,7 @@ def bez_surface_param_tolerance(bez: NDArray, tol: float, rational:bool=False, i P,w=from_homogeneous_2d(bez) else: P,w=bez,np.ones_like(bez[...,0]) - if rational and np.any(bez[...,-1]<0): - - - + if rational and not np.all(w == w.flat[0]): return _bezier_surface_param_tol_conservative(P,w,tol=tol,u0=interval_u[0],u1=interval_u[1],v0=interval_v[0],v1=interval_v[1]) @@ -565,7 +578,8 @@ def bez_surface_param_tolerance(bez: NDArray, tol: float, rational:bool=False, i - return _bezier_surface_param_tol_optimistic(P,w,tol,interval_u,interval_v) + return _bezier_surface_param_tol_optimistic( + P, np.ones_like(w), tol, interval_u, interval_v) def nurbs_curve_param_tolerance(curve: NURBSCurveTuple, tol: float, der:NURBSCurveTuple=None) -> float: if np.any(curve.weights<0): return _nurbs_curve_param_tol_conservative(curve.control_points, curve.weights, curve.knot, curve.order - 1, tol) @@ -671,4 +685,4 @@ def fun(t0, t1): tpl = NURBSCurveTuple(4, generate_knots(curve4.shape[0], 3), curve4, np.ones((curve4.shape[0]))) #test_approach(tpl) - test_approach(curve3) \ No newline at end of file + test_approach(curve3) diff --git a/mmcore/numeric/_bez_closest_point.py b/mmcore/numeric/_bez_closest_point.py index 47c76edf..028817c6 100644 --- a/mmcore/numeric/_bez_closest_point.py +++ b/mmcore/numeric/_bez_closest_point.py @@ -61,6 +61,16 @@ _STATIONARY_COS_TOL = 1e-4 +def _publish_work_stats(stats, cells_processed, budget_exhausted, **breakdown): + """Populate an optional mutable stats mapping without changing results.""" + if stats is None: + return + stats.clear() + stats.update(cells_processed=int(cells_processed), + budget_exhausted=bool(budget_exhausted), + **{key: int(value) for key, value in breakdown.items()}) + + # --------------------------------------------------------------------------- # Bernstein algebra # --------------------------------------------------------------------------- @@ -121,6 +131,7 @@ def _bernstein_product_nd(a, b): from mmcore.numeric import bern_sq_dist +from mmcore.numeric._work_budget import reconcile_reported from mmcore.numeric.bern import bernstein_partial_derivative_coeffs @@ -384,7 +395,7 @@ def _is_stationary_point(S, point, u, v, rational): # --------------------------------------------------------------------------- def bez_curve_closest_points(C, point, atol=1e-3, rational=False, - max_cells=20000, upper_bound=None): + max_cells=20000, upper_bound=None, stats=None): """Globally closest points of a Bézier curve (band semantics). Returns the set of entities within ``atol`` of the global minimum @@ -395,7 +406,8 @@ def bez_curve_closest_points(C, point, atol=1e-3, rational=False, provably all-or-nothing). ``upper_bound``: externally-known distance bound (e.g. from sibling - patches) used for band clipping only. + patches) used for band clipping only. When supplied, ``stats`` is updated + with ``cells_processed`` and ``budget_exhausted``. """ C = np.asarray(C, dtype=np.float64) point = np.asarray(point, dtype=np.float64) @@ -413,6 +425,7 @@ def bez_curve_closest_points(C, point, atol=1e-3, rational=False, if d_hi0 - d_lo0 < band: pt = eval_curve(C, 0.5, rational=rational) dist = float(np.linalg.norm(pt - point)) + _publish_work_stats(stats, 0, False) return [{"kind": "degenerate_segment", "t_range": (0.0, 1.0), "t": 0.5, "point": np.asarray(pt), "distance": dist}] @@ -433,6 +446,7 @@ def add_candidate(t, kind): return candidates.append((t, dist, kind)) + max_cells = max(int(max_cells), 0) cells = 0 stack = [(N, F, W2, 0.0, 1.0, 0)] while stack and cells < max_cells: @@ -455,7 +469,8 @@ def add_candidate(t, kind): stack.append((NL, FL, WL, t0, tm, depth + 1)) stack.append((NR, FR, WR, tm, t1, depth + 1)) - if cells >= max_cells and stack: + budget_exhausted = bool(stack and cells >= max_cells) + if budget_exhausted: warnings.warn( "bez_curve_closest_points: subdivision hit max_cells cap; " "result may be incomplete.") @@ -495,6 +510,7 @@ def add_candidate(t, kind): gmin = min(e["distance"] for e in results) results = [e for e in results if e["distance"] <= gmin + band] results.sort(key=lambda e: e["distance"]) + _publish_work_stats(stats, cells, budget_exhausted) return results @@ -548,7 +564,8 @@ def _hessian_eigs(S, point, u, v, rational): # --------------------------------------------------------------------------- def trace_equidistant_curve(S, point, u0, v0, *, rational=False, step=0.01, - max_steps=2000, dist_band=None): + max_steps=2000, dist_band=None, + max_total_steps=None, stats=None): """Trace the 1-D equidistant stationary curve of ``g = ||S - point||^2`` through the degenerate stationary seed ``(u0, v0)``. @@ -561,17 +578,33 @@ def trace_equidistant_curve(S, point, u0, v0, *, rational=False, step=0.01, Returns ``{"uv": (N,2), "points": (N,3), "distance", "closed"}`` or ``None`` when the trace is invalid (corrector failure right away, or the distance drifts out of ``dist_band`` — the seed was not on a genuine - equidistant curve; caller falls back to isolated handling). + equidistant curve; caller falls back to isolated handling). The optional + ``max_total_steps`` caps both directions together; ``stats`` reports + ``steps_processed`` and whether that cap (or a per-leg cap) truncated the + trace. A truncated trace is never returned as a complete curve. """ S = np.asarray(S, dtype=np.float64) point = np.asarray(point, dtype=np.float64) + max_steps = max(0, int(max_steps)) + if max_total_steps is None: + max_total_steps = 2 * max_steps + max_total_steps = max(0, int(max_total_steps)) + steps_processed = 0 + + def finish(result, budget_exhausted=False): + if stats is not None: + stats.clear() + stats.update(steps_processed=int(steps_processed), + budget_exhausted=bool(budget_exhausted)) + return result + lam0, lam1, _, g0 = _hessian_eigs(S, point, u0, v0, rational) d0 = np.sqrt(max(g0, 0.0)) if dist_band is None: dist_band = max(1e-9, 1e-6 * max(d0, 1.0)) scale = max(abs(lam1), 1e-30) if abs(lam1) < 1e-30 or abs(lam0) > _DEGEN_EIG_RATIO * scale: - return None # not rank-1 degenerate (rank-0 flat or isolated) + return finish(None) # not rank-1 degenerate (rank-0 flat or isolated) def on_boundary(u, v): eps = 1e-12 @@ -612,6 +645,7 @@ def transverse_correct(u, v): drift_tol = max(dist_band, 1e-7 * max(d0, 1.0)) closed = False + truncated = False legs = [] for direction in (1.0, -1.0): u, v = float(u0), float(v0) @@ -620,6 +654,10 @@ def transverse_correct(u, v): cum_arc = 0.0 stall_run = 0 for _k in range(max_steps): + if steps_processed >= max_total_steps: + truncated = True + break + steps_processed += 1 _, _, t, _ = _hessian_eigs(S, point, u, v, rational) t = t * direction if prev_t is not None and float(np.dot(t, prev_t)) < 0.0: @@ -651,25 +689,32 @@ def transverse_correct(u, v): if direction > 0 and cum_arc > 10.0 * step and np.hypot(u - u0, v - v0) < 0.75 * step: closed = True break + else: + # Reaching a continuation cap is not a geometric termination + # certificate, so the accumulated polyline is only partial. + truncated = True legs.append(leg) - if closed: + if closed or truncated: break + if truncated: + return finish(None, True) fwd, bwd = legs[0], (legs[1] if len(legs) > 1 else []) pts_uv = bwd[::-1] + [(float(u0), float(v0))] + fwd if len(pts_uv) < 3: - return None + return finish(None) uv = np.array(pts_uv, dtype=np.float64) # Minimum-extent validity: a trace that never achieved real arc length is # a failed trace (it must fall back to isolated handling and must NOT # consume other seeds or emit a micro-fragment entity). arc_len = float(np.sum(np.hypot(np.diff(uv[:, 0]), np.diff(uv[:, 1])))) if arc_len < 3.0 * step: - return None + return finish(None) xyz = np.array([eval_surface(S, uu, vv, rational=rational) for uu, vv in pts_uv]) dists = np.linalg.norm(xyz - point[None, :], axis=1) - return {"uv": uv, "points": xyz, "distance": float(np.mean(dists)), - "closed": bool(closed)} + return finish({"uv": uv, "points": xyz, + "distance": float(np.mean(dists)), + "closed": bool(closed)}) def _uv_dist_to_polyline(u, v, uv): @@ -720,7 +765,8 @@ def _certify_circle(points, query): def bez_surface_closest_points(S, point, atol=1e-3, rational=False, want_eval=False, max_cells=20000, - upper_bound=None, _interior_only=False): + upper_bound=None, _interior_only=False, + stats=None): """Globally closest entities of a Bézier surface patch (band semantics). Returns entities within ``atol`` of the global minimum distance, sorted @@ -732,6 +778,10 @@ def bez_surface_closest_points(S, point, atol=1e-3, rational=False, * ``{"kind":"degenerate_surface", ...}`` — the whole patch is equidistant. ``upper_bound``: externally-known distance bound used for band clipping. + The four boundary-curve searches, surface subdivision queue, and + degenerate-curve continuation share the single ``max_cells`` allowance. + When supplied, ``stats`` is updated with total, boundary, surface, and + trace-step counts plus ``budget_exhausted``. """ S = np.asarray(S, dtype=np.float64) point = np.asarray(point, dtype=np.float64) @@ -750,6 +800,8 @@ def bez_surface_closest_points(S, point, atol=1e-3, rational=False, if d_hi0 - d_lo0 < band: pt = eval_surface(S, 0.5, 0.5, rational=rational) dist = float(np.linalg.norm(pt - point)) + _publish_work_stats(stats, 0, False, boundary_cells=0, + surface_cells=0, trace_steps=0) return [{"kind": "degenerate_surface", "u_range": (0.0, 1.0), "v_range": (0.0, 1.0), "u": 0.5, "v": 0.5, "point": np.asarray(pt), "distance": dist}] @@ -761,15 +813,20 @@ def bez_surface_closest_points(S, point, atol=1e-3, rational=False, # Boundary first: cheap, seeds `best` early, and its entities join the # final band filter like everything else. + max_cells = max(int(max_cells), 0) boundary_points = [] boundary_curves = [] + boundary_stats = {"cells_processed": 0, "budget_exhausted": False} if not _interior_only: _surface_boundary_entities(S, point, boundary_points, boundary_curves, - rational, atol, ptol_u, ptol_v) + rational, atol, ptol_u, ptol_v, + max_cells=max_cells, stats=boundary_stats) for e in boundary_points: best = min(best, e["distance"]) for e in boundary_curves: best = min(best, e["distance"]) + boundary_cells = int(boundary_stats["cells_processed"]) + budget_exhausted = bool(boundary_stats["budget_exhausted"]) def try_add_point(u, v): nonlocal best @@ -783,9 +840,19 @@ def try_add_point(u, v): pq = [(d_lo0, next(counter), F, W2, Nu, Nv, 0.0, 1.0, 0.0, 1.0, 0)] pops = 0 capped = False + # Ledger L46: the interior best-first heap used to share one + # ``max_cells`` with the four boundary searches, so a boundary phase + # that burned the whole allowance starved the interior to ZERO pops + # and the true interior global minimum silently dropped out of the + # certified set (the band-semantics contract). The interior keeps its + # own pop allowance; boundary exhaustion still marks the RESULT capped + # below, and the published work counters report the true total. + interior_cap = max(1, int(max_cells)) + capped = capped or budget_exhausted while pq: - if pops >= max_cells: + if pops >= interior_cap: capped = True + budget_exhausted = True break lb, _, Fc, W2c, Nuc, Nvc, u0, u1, v0, v1, depth = heapq.heappop(pq) pops += 1 @@ -844,14 +911,47 @@ def try_add_point(u, v): "bez_surface_closest_points: hit max_cells cap; " "result may be incomplete.") - # Trace equidistant curves from unconsumed degenerate seeds. + # Trace equidistant curves from unconsumed degenerate seeds. Continuation + # iterations consume the same allowance as subdivision cells; a truncated + # polyline is never promoted to a complete closest-set entity. trace_step = 0.01 - for (su_, sv_) in degen_seeds: + trace_steps = 0 + trace_capped = False + if (degen_seeds and not budget_exhausted + and boundary_cells + pops >= max_cells): + budget_exhausted = True + trace_capped = True + trace_seeds = () if budget_exhausted else degen_seeds + for (su_, sv_) in trace_seeds: if any(_uv_dist_to_polyline(su_, sv_, c["uv"]) < 2.0 * trace_step for c in curves_out): continue # already covered by a traced curve + remaining = max_cells - boundary_cells - pops - trace_steps + if remaining <= 0: + budget_exhausted = True + trace_capped = True + break + trace_stats = {} tr = trace_equidistant_curve(S, point, su_, sv_, rational=rational, - step=trace_step, dist_band=band) + step=trace_step, dist_band=band, + max_steps=min(2000, remaining), + max_total_steps=remaining, + stats=trace_stats) + if 'steps_processed' not in trace_stats: + # Fail closed if a substituted/older tracer omits accounting. + charged_steps = remaining + trace_incomplete = True + else: + reported_steps = max(0, int(trace_stats['steps_processed'])) + charged_steps, overrun = reconcile_reported( + reported_steps, remaining) + trace_incomplete = ( + bool(trace_stats.get('budget_exhausted', False)) or overrun) + trace_steps += charged_steps + if trace_incomplete: + budget_exhausted = True + trace_capped = True + break if tr is None: try_add_point(su_, sv_) # fall back to isolated handling continue @@ -864,6 +964,11 @@ def try_add_point(u, v): tr["circle"] = cert # planar spherical curve == exact circle curves_out.append(tr) + if trace_capped and not capped: + warnings.warn( + "bez_surface_closest_points: degenerate continuation hit the " + "shared max_cells cap; result may be incomplete.") + # A traced curve consumes point candidates lying on it. def consumed_by_curve(e): return any(abs(e["distance"] - c["distance"]) <= band @@ -893,6 +998,11 @@ def consumed_by_curve(e): if e["kind"] in ("min", "boundary_min"): pt, su, sv, _, _, _ = eval_surface_d2(S, e["u"], e["v"], rational=rational) e["eval"] = {"S": pt, "Su": su, "Sv": sv, "normal": np.cross(su, sv)} + _publish_work_stats( + stats, boundary_cells + pops + trace_steps, budget_exhausted, + boundary_cells=boundary_cells, surface_cells=pops, + trace_steps=trace_steps, + ) return entities @@ -901,7 +1011,8 @@ def consumed_by_curve(e): # --------------------------------------------------------------------------- def _surface_boundary_entities(S, point, out_points, out_curves, - rational, atol, ptol_u, ptol_v): + rational, atol, ptol_u, ptol_v, + max_cells=20000, stats=None): """Collect KKT-valid minima on the 4 edges + 4 corners of the patch. Point results go to ``out_points``; a whole-edge equidistant segment @@ -915,8 +1026,20 @@ def _surface_boundary_entities(S, point, out_points, out_curves, (1, 0.0, S[:, 0, :]), # v = 0, runs along u (1, 1.0, S[:, -1, :]), # v = 1 ] + max_cells = max(int(max_cells), 0) + cells = 0 + budget_exhausted = False for fixed_axis, side, iso in edges: - iso_res = bez_curve_closest_points(iso, point, atol=atol, rational=rational) + remaining = max_cells - cells + if remaining <= 0: + budget_exhausted = True + break + curve_stats = {} + iso_res = bez_curve_closest_points( + iso, point, atol=atol, rational=rational, + max_cells=remaining, stats=curve_stats) + cells += int(curve_stats["cells_processed"]) + budget_exhausted = bool(curve_stats["budget_exhausted"]) for e in iso_res: if e["kind"] == "degenerate_segment": # Whole edge equidistant. KKT at the representative decides @@ -945,8 +1068,11 @@ def _surface_boundary_entities(S, point, out_points, out_curves, s = e["t"] u, v = (side, s) if fixed_axis == 0 else (s, side) _try_add_boundary(S, point, out_points, u, v, rational, atol, ptol_u, ptol_v) + if budget_exhausted: + break for u, v in [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)]: _try_add_boundary(S, point, out_points, u, v, rational, atol, ptol_u, ptol_v) + _publish_work_stats(stats, cells, budget_exhausted) def _boundary_kkt_ok(S, point, u, v, rational, atol, ptol_u, ptol_v): @@ -1009,7 +1135,8 @@ def _patch_surface_net(patch): return np.asarray(to_homogeneous_2d(patch.control_points, patch.weights), dtype=np.float64) -def nurbs_curve_closest_points(curve, point, atol=1e-3): +def nurbs_curve_closest_points(curve, point, atol=1e-3, *, + max_cells=None, stats=None): """Globally closest points of a NURBS curve (band semantics), in GLOBAL parameters, sorted ascending by distance. @@ -1017,6 +1144,13 @@ def nurbs_curve_closest_points(curve, point, atol=1e-3): Bézier segments are merged, e.g. a full circle about its center collapses to one entity spanning the domain). Internal seams are deduped; only the global domain ends are ``boundary_min``. + + Ledger L46: ``max_cells`` is ONE shared allowance across every Bézier + span (default: the per-span 20k scaled by the span count, so ordinary + input never truncates while a hog span may borrow from cheap ones); + ``stats`` publishes the aggregate ``cells_processed`` / + ``budget_exhausted`` — a capped sub-solve can return far-local-min + entities, so a consumer of the band-semantics contract must check it. """ if isinstance(curve, NURBSCurve): curve = _nurbs_to_tuple(curve) @@ -1024,6 +1158,10 @@ def nurbs_curve_closest_points(curve, point, atol=1e-3): g_lo, g_hi = _curve_interval(curve) patches = decompose_curve(curve) band = atol + remaining = (20_000 * max(1, len(patches)) if max_cells is None + else max(0, int(max_cells))) + agg_cells = 0 + agg_exhausted = False # Geometric closure (clamped NURBS: endpoints are the end control points). # On a closed curve the domain ends are a periodic seam, not a boundary. @@ -1035,10 +1173,20 @@ def nurbs_curve_closest_points(curve, point, atol=1e-3): pts = [] segs = [] for patch in patches: + if remaining <= 0: + agg_exhausted = True + break p_lo, p_hi = _curve_interval(patch) net = _patch_curve_net(patch) + patch_stats = {} local = bez_curve_closest_points(net, point, atol=atol, rational=True, - upper_bound=best) + upper_bound=best, + max_cells=remaining, + stats=patch_stats) + used = max(0, int(patch_stats.get("cells_processed", 0))) + remaining -= reconcile_reported(used, remaining)[0] + agg_cells += used + agg_exhausted |= bool(patch_stats.get("budget_exhausted", False)) for e in local: best = min(best, e["distance"]) if e["kind"] == "degenerate_segment": @@ -1078,6 +1226,7 @@ def inside_seg(e): and abs(e["distance"] - s["distance"]) <= band for s in merged_segs) + _publish_work_stats(stats, agg_cells, agg_exhausted) entities = [e for e in pts if not inside_seg(e)] + merged_segs if not entities: return [] @@ -1122,7 +1271,8 @@ def _merge_periodic_seam_1d(pts, g_lo, g_hi, atol, geo_scale): return out -def nurbs_surface_closest_points(surface, point, atol=1e-3, want_eval=False): +def nurbs_surface_closest_points(surface, point, atol=1e-3, want_eval=False, + *, max_cells=None, stats=None): """Globally closest entities of a NURBS surface (band semantics), in GLOBAL parameters, sorted ascending by distance. @@ -1130,6 +1280,13 @@ def nurbs_surface_closest_points(surface, point, atol=1e-3, want_eval=False): patch seams are chained; a full ring is marked ``closed``), and ``degenerate_surface`` entities. Internal seams are deduped; only the global domain border is ``boundary_min``. + + Ledger L46: ``max_cells`` is ONE shared allowance across every Bézier + patch (default: the per-patch 20k scaled by the patch count); + ``stats`` publishes the aggregate ``cells_processed`` / + ``budget_exhausted``. A capped sub-solve may return far-local-min + entities, so any consumer relying on the band-semantics guarantee + ("never far local minima") must check the exhaustion flag. """ if isinstance(surface, NURBSSurface): surface = _nurbs_to_tuple(surface) @@ -1137,6 +1294,10 @@ def nurbs_surface_closest_points(surface, point, atol=1e-3, want_eval=False): (gu_lo, gu_hi), (gv_lo, gv_hi) = _surface_interval(surface) patches = decompose_surface(surface) band = atol + remaining = (20_000 * max(1, len(patches)) if max_cells is None + else max(0, int(max_cells))) + agg_cells = 0 + agg_exhausted = False # Geometric closure per direction (clamped net: opposite border rows # coincide). A closed direction's domain ends are a periodic seam. @@ -1151,10 +1312,20 @@ def nurbs_surface_closest_points(surface, point, atol=1e-3, want_eval=False): curves = [] surfs = [] for patch in patches: + if remaining <= 0: + agg_exhausted = True + break (pu_lo, pu_hi), (pv_lo, pv_hi) = _surface_interval(patch) net = _patch_surface_net(patch) + patch_stats = {} local = bez_surface_closest_points(net, point, atol=atol, rational=True, - want_eval=want_eval, upper_bound=best) + want_eval=want_eval, upper_bound=best, + max_cells=remaining, + stats=patch_stats) + used = max(0, int(patch_stats.get("cells_processed", 0))) + remaining -= reconcile_reported(used, remaining)[0] + agg_cells += used + agg_exhausted |= bool(patch_stats.get("budget_exhausted", False)) def to_gu(u): return pu_lo + u * (pu_hi - pu_lo) @@ -1232,6 +1403,7 @@ def subsumed(e): return True return False + _publish_work_stats(stats, agg_cells, agg_exhausted) entities = [e for e in pts if not subsumed(e)] + curves + surfs if not entities: return [] diff --git a/mmcore/numeric/_work_budget.py b/mmcore/numeric/_work_budget.py new file mode 100644 index 00000000..f3c70f4d --- /dev/null +++ b/mmcore/numeric/_work_budget.py @@ -0,0 +1,415 @@ +"""Shared solver-level work budget (ledger L52 — the 8-way accounting merge). + +The 2026-07-12 budget review (§10 finding 15) found the same soft-budget +accounting hand-rolled EIGHT times with already-divergent charge semantics: +``_SSXSoftBudget``, ``BernsteinZeroBudget``, the closest-point inline +counters, the ``bez_ccx``/``bez_csx`` inline counters, the ``_nccx4``/ +``_ncsx4`` adapter twins (unified first, into +``mmcore/numeric/intersection/_adapter_status.py``), and the +``_ssx5_singular`` closures. Divergent budget accounting is where a large +fraction of the L4x findings came from; this module is the single home for +the solver-level mechanics, migrated one accounting at a time. + +Charge-semantics registry (the EXPLICIT reconciliation the ledger demands — +these families deliberately differ and must not be silently averaged): + +- **check-then-charge, all-or-nothing, latching** (``SoftWorkBudget``, + c3's ``_spend``): a denied amount is never partially spent; once + exhausted every later charge fails fast WITHOUT re-marking reasons. + Used where the charge protects the *next* unit of work. +- **clamp-and-charge with a min-1 floor** (`_adapter_status + .consume_bezier_status`, the closest-point NURBS aggregators): a + reported spend is billed at ``min(max(1, reported), allowance)`` — every + dispatch costs at least one unit so a large candidate set cannot bypass + the aggregate allowance, and overruns clamp rather than deny. Used + where the work has ALREADY happened and the ledger only reconciles it. +- **charge-at-completion after truncation** (``BernsteinZeroBudget`` + result accounting): results are counted once, at top level, after + clamping to the remaining allowance. Node work in the same object is + ordinary check-then-charge. +- **shared-remainder threading** (``bez_ccx``/``bez_csx`` locals): phases + and bounded sub-ledgers draw ``min(remaining, tier)`` from one + down-counter; a sub-phase never receives a fresh allowance. + +The schema-v2 REASON_* vocabulary lives here with the ledger that stamps +it. ``complete == (not reasons)`` by construction: ``mark_incomplete`` +requires a reason, and only root-cause transitions record one. + +Optional-budget threading disposition (L52 slice 8): the two shared +mechanisms are :func:`charge_hook` (explicit parameter threading — the +former guarded-lambda pattern) and the ContextVar scope +(:func:`bernstein_zero_budget` — budget reaches a leaf solver whose +public API must stay budget-free). The remaining ``budget is not None`` +guards at consumer sites are deliberate per-site policy (the None path +means "local caps bind alone") and are NOT to be mass-converted to a +null-object: a null budget that silently accepts every charge would turn +a forgotten wiring into an unbounded solver — the exact failure the +budgets exist to prevent. +""" +from __future__ import annotations + +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field +from typing import Callable, Optional + + +# Reason strings published in result['status']['reasons'] (schema v2, +# 2026-07-12 review doc §6). `complete` is the one bit consumers act on; +# `reasons` says WHY it is False and which reaction can help. +# +# Work family — a resource knob can help: +REASON_WORK_BUDGET = "work_budget" # shared cell/CSX ledger or a CSX per-call tier ran dry (max_cells / max_csx_calls / *_csx_max_cells) +REASON_OUTPUT_CAP = "output_cap" # max_output_items reached +REASON_POSTPROCESS_CAP = "postprocess_cap" # max_postprocess_work reached +REASON_DEPTH_LIMIT = "depth_limit" # max_depth ceiling left a crossing-bearing cell unresolved +# Structural family — raising budgets cannot help: +REASON_PARAMETER_FIBER = "parameter_fiber" # positive-dimensional preimage of a boundary point (collapsed edge) +REASON_OVERLAP_REGION = "overlap_region_unsupported" # 2-D coincidence region detected; retired by L28's SSXOverlapRegion +REASON_TANGENTIAL_ZONE = "unresolved_tangential_zone" # truncated Δ/Φ tangency enumeration or Φ-loop path not certified +REASON_MULTIPLICITY = "unresolved_multiplicity" # rank-deficient Δ-root / crossing cluster whose local dimension is unproven +REASON_TRACE_UNVERIFIED = "trace_unverified" # marched continuation failed the strict Ψ-zero path certificate +REASON_SINGULAR_SET = "unresolved_singular_set" # a positive-dimensional singular-set (Σ) enumeration truncated at an internal cap: the emitted typed singularities may under-cover the set, and no resource knob can finish a point enumeration of a curve (L52 slice 9 / §11.6 — the honest replacement for the former work_budget misbilling) + + +@dataclass +class SoftWorkBudget: + """One shared work budget for an entire solver call. + + Local solver limits are still useful backstops, but they do not compose: + SSX can invoke thousands of CSX/zero-dimensional searches and each search + used to receive a fresh allowance. This object is deliberately tiny and + callback-friendly so nested solvers spend from the same counter. + + Every transition into a partial state records one of the REASON_* + strings; ``result_fields`` publishes them as ``status['reasons']`` with + the invariant ``complete == (not reasons)``. Only root-cause transitions + record a reason — denials that merely echo an already-exhausted state do + not re-mark, so ``reasons`` stays a list of causes, not a cascade log. + """ + + max_cells: int + max_csx_calls: int + max_output_items: int = 1_024 + max_postprocess_work: Optional[int] = None + cells_processed: int = 0 + csx_calls: int = 0 + output_items: int = 0 + postprocess_work: int = 0 + postprocess_exhausted: bool = False + exhausted: bool = False + incomplete: bool = False + cell_counts: dict = field(default_factory=dict) + reasons: list = field(default_factory=list) + # (reason, stuv_global) records for STRUCTURAL marks whose location a + # later pass can re-examine — ledger L28: an `unresolved_multiplicity` + # ambiguity whose root lies INSIDE a certified overlap region is a + # region-interior sample of the 2-D C2 set (resolved by the region), + # so the assembler may retire it; marks outside any region stay. + structural_sites: list = field(default_factory=list) + + def __post_init__(self): + if self.max_postprocess_work is None: + self.max_postprocess_work = max(0, int(self.max_cells)) + else: + self.max_postprocess_work = max( + 0, int(self.max_postprocess_work)) + + def _add_reason(self, reason: str) -> None: + if reason not in self.reasons: + self.reasons.append(reason) + + def charge_cells(self, amount: int = 1, source: str = "nested") -> bool: + amount = max(0, int(amount)) + if self.exhausted: + return False + if self.cells_processed + amount > self.max_cells: + self.exhausted = True + self._add_reason(REASON_WORK_BUDGET) + return False + self.cells_processed += amount + self.cell_counts[source] = self.cell_counts.get(source, 0) + amount + return True + + def charge_csx_call(self) -> bool: + if self.exhausted: + return False + if self.csx_calls >= self.max_csx_calls: + self.exhausted = True + self._add_reason(REASON_WORK_BUDGET) + return False + self.csx_calls += 1 + return True + + def charge_postprocess(self, amount: int = 1) -> bool: + """Charge bounded assembly/filter work after the search phase. + + This counter is separate from subdivision cells so a hard-stopped + search can still assemble its certified partial fragments. It is + nevertheless call-wide and finite, preventing postprocessing from + becoming a second unbounded phase. + """ + amount = max(0, int(amount)) + if self.postprocess_exhausted: + return False + if self.postprocess_work + amount > self.max_postprocess_work: + self.postprocess_exhausted = True + self.exhausted = True + self.incomplete = True + self._add_reason(REASON_POSTPROCESS_CAP) + return False + self.postprocess_work += amount + return True + + @property + def remaining_cells(self) -> int: + return max(0, self.max_cells - self.cells_processed) + + @property + def remaining_postprocess_work(self) -> int: + return max(0, self.max_postprocess_work - self.postprocess_work) + + def mark_exhausted(self, reason: str = REASON_WORK_BUDGET) -> None: + self.exhausted = True + self._add_reason(reason) + + def mark_incomplete(self, reason: str) -> None: + """Record a partial local result without stopping independent work. + + ``reason`` is mandatory: the caller names the root cause (one of the + REASON_* strings) so ``status['reasons']`` can steer the consumer + (raise a knob / wait for typed machinery / accept the resolution + limit) instead of conflating everything into one budget flag. + """ + self.incomplete = True + self._add_reason(reason) + + def retire_reason(self, reason: str) -> None: + """Remove a structural reason that this same call has since RESOLVED. + + Reasons are published only at :meth:`result_fields`; a condition + recorded mid-search that later machinery fully represents (ledger + L28: `overlap_region_unsupported` once every piece of overlap + evidence is covered by a certified region) may be retired before + publication. Hard exhaustion is never retirable — `exhausted` + always keeps its `work_budget` reason, so `reasons` can only become + empty when the ledger never ran dry. + """ + if reason in self.reasons: + self.reasons.remove(reason) + if not self.reasons and not self.exhausted: + self.incomplete = False + + def append_output(self, target: list, value, source: str) -> bool: + """Append one intermediate/output entity under the global cap.""" + if self.output_items >= self.max_output_items: + self.mark_incomplete(REASON_OUTPUT_CAP) + return False + target.append(value) + self.output_items += 1 + return True + + def extend_output(self, target: list, values, source: str) -> bool: + complete = True + for value in values: + if not self.append_output(target, value, source): + complete = False + break + return complete + + def result_fields(self) -> dict: + return { + "complete": not (self.exhausted or self.incomplete), + "status": { + "reasons": list(self.reasons), + "work": { + "cells_processed": int(self.cells_processed), + "csx_calls": int(self.csx_calls), + "max_cells": int(self.max_cells), + "max_csx_calls": int(self.max_csx_calls), + "output_items": int(self.output_items), + "max_output_items": int(self.max_output_items), + "postprocess_work": int(self.postprocess_work), + "max_postprocess_work": int(self.max_postprocess_work), + "cell_counts": dict(self.cell_counts), + }, + }, + } + + +@dataclass +class BernsteinZeroBudget: + """Scoped work/result budget for nested Bernstein zero searches. + + ``classify_sq_dist_net`` calls the 1-D zero-finder without budget + arguments. A context-local budget lets CCX/CSX bound those Phase-1 + calls without a process-global mutable limit (and without changing the + public classifier API). ``nodes`` counts recursive solver invocations + (ordinary check-then-charge). Results are charged only when a + top-level boundary solve returns — the charge-at-completion-after- + truncation family in this module's registry — so shared subdivision + endpoints do not consume the result allowance repeatedly. The + remaining result allowance is nevertheless propagated through + recursion so a capped solve stops before materializing an unbounded + result list. + """ + + max_nodes: int + max_results: int + nodes: int = 0 + results: int = 0 + active_depth: int = 0 + exhausted: bool = False + + def enter(self) -> bool: + if self.nodes >= self.max_nodes: + self.exhausted = True + return False + self.nodes += 1 + self.active_depth += 1 + return True + + def leave(self) -> None: + self.active_depth -= 1 + + def remaining_results(self) -> int: + return max(0, self.max_results - self.results) + + def cap_top_level_results(self, values: list) -> list: + remaining = self.remaining_results() + if len(values) > remaining: + self.exhausted = True + values = values[:remaining] + self.results += len(values) + return values + + +@dataclass +class DownCounter: + """Paired remaining/processed cell counter (the bez_ccx/bez_csx family). + + The two locals that always had to move together (``cells_remaining -= + n; cells_processed += n``) live in one object so they cannot drift. + ``spend`` is deliberately UNCHECKED: this family keeps its denial + policy inline at each site (preflight returns, ``break`` on result + caps, honesty flags), and sub-phases draw bounded shared-remainder + tiers via :meth:`tier` — ``min(remaining, cap)`` — never a fresh + allowance. + """ + + remaining: int + processed: int = 0 + + def __post_init__(self): + self.remaining = max(0, int(self.remaining)) + + def spend(self, amount: int) -> None: + amount = int(amount) + self.remaining -= amount + self.processed += amount + + def tier(self, cap: int) -> int: + """A bounded sub-allowance drawn from the remainder.""" + return max(0, min(self.remaining, int(cap))) + + +@dataclass +class LatchingSpend: + """Check-then-charge, all-or-nothing, latching work ledger. + + Extraction of the ``_spend`` closure from ``c3_pass`` + (``_ssx5_singular``): a local cap plus an OPTIONAL external hook + (typically :func:`charge_hook` over a shared :class:`SoftWorkBudget`). + Semantics preserved exactly: + + - a denied amount is never partially spent; + - once exhausted, every later spend fails fast; + - the external hook is consulted only AFTER the local check passes, so + a local denial never phantom-charges the shared ledger — and a + hook denial leaves the LOCAL counter unspent (the shared ledger has + latched anyway, so the double-count is moot and matches the closure). + """ + + max_work: int + charge_external: Optional[Callable[[int], bool]] = None + work_processed: int = 0 + exhausted: bool = False + external_exhausted: bool = False + + def __post_init__(self): + self.max_work = max(0, int(self.max_work)) + + def spend(self, amount: int = 1) -> bool: + amount = max(0, int(amount)) + if self.exhausted: + return False + if self.work_processed + amount > self.max_work: + self.exhausted = True + return False + if (self.charge_external is not None + and not self.charge_external(amount)): + self.exhausted = True + self.external_exhausted = True + return False + self.work_processed += amount + return True + + +_ZERO_BUDGET: ContextVar[Optional[BernsteinZeroBudget]] = ContextVar( + "bernstein_zero_budget", default=None, +) + + +@contextmanager +def bernstein_zero_budget(max_nodes: int, max_results: int): + """Bound all nested ``find_bernstein_zeros_1d`` calls in a scope.""" + + budget = BernsteinZeroBudget( + max_nodes=max(0, int(max_nodes)), + max_results=max(0, int(max_results)), + ) + token = _ZERO_BUDGET.set(budget) + try: + yield budget + finally: + _ZERO_BUDGET.reset(token) + + +def reconcile_reported(reported: int, allowance: int, + floor: int = 1) -> tuple: + """Bill already-performed work against a remaining allowance. + + The clamp-and-charge family (`_adapter_status.consume_bezier_status`, + the closest-point NURBS aggregators and trace reconciliation): the + work has ALREADY happened, so the ledger reconciles rather than + denies. Every dispatch costs at least ``floor`` units — a + fast-rejected span reporting zero solver work must not let an + arbitrarily large candidate set bypass the aggregate allowance — and + an overrun clamps to the allowance while flagging it. + + Returns ``(billed, overrun)``. + """ + demanded = max(int(floor), int(reported)) + allowance = max(0, int(allowance)) + return min(demanded, allowance), demanded > allowance + + +def charge_hook(budget: Optional[SoftWorkBudget], + source: str) -> Optional[Callable[..., bool]]: + """Bind a cell-ledger charge callback to ``budget``, or None. + + Single implementation of the guarded-lambda pattern that was repeated at + every ``charge_box=``/``charge_work=`` wiring site:: + + (lambda n: b.charge_cells(n, src)) if b is not None else None + + Consumers accept ``Optional[Callable[[int], bool]]``; the None path + means "no shared ledger — local caps bind alone". + """ + if budget is None: + return None + + def _charge(amount: int = 1) -> bool: + return budget.charge_cells(amount, source) + + return _charge diff --git a/mmcore/numeric/intersection/_adapter_status.py b/mmcore/numeric/intersection/_adapter_status.py new file mode 100644 index 00000000..24845758 --- /dev/null +++ b/mmcore/numeric/intersection/_adapter_status.py @@ -0,0 +1,118 @@ +"""Shared status ledger for the NURBS-level intersection adapters (ledger L52). + +`_nccx4` and `_ncsx4` each carried a hand-rolled copy of the same aggregate +status machinery (~110 lines, copy-pasted and already drifting: comment +wording, key sets, and expression formatting had diverged while the intended +semantics stayed identical). Divergent budget accounting is where a large +fraction of the L4x review findings came from — this module is the single +implementation both adapters now delegate to. + +Contract (unchanged from the twins): +- one call-wide ledger dict tracks cells/results processed against the + aggregate allowances; `complete` / `budget_exhausted` / + `boundary_topology_complete` summarize honesty flags across span pairs; +- every candidate dispatch charges at least ONE cell — an AABB-fast-rejected + span reporting zero solver cells must not let an arbitrarily large BVH + candidate set bypass the aggregate allowance; +- overproduced results are truncated to the remaining result allowance and + the truncation marks the ledger exhausted; +- with ``return_status=False`` any incompleteness raises RuntimeError + (fail-fast opt-in; the default since L41 is always-return-status). + +Adapter-specific concerns stay in the adapters: CSX's ``parameter_fibers`` +list field and its fiber->global parameter mapping. +""" +from __future__ import annotations + +from mmcore.numeric._work_budget import reconcile_reported + + +def reject_unknown_kwargs(context, kwargs, allowed): + """Fail fast on unknown adapter kwargs (ledger L52 slice 8). + + The adapters' ``**kwargs`` used to swallow anything, so the natural + misspelling ``atol=`` (the BEZ-level name) of the adapter-level + ``tol=`` ran silently at the DEFAULT tolerance. + """ + unknown = sorted(set(kwargs) - set(allowed)) + if unknown: + raise TypeError( + f"{context}: unexpected keyword argument(s) {unknown}; " + f"accepted: {sorted(allowed)}. Note: the geometric tolerance " + "is 'tol' at the NURBS adapter level ('atol' is the bez-level " + "spelling).") + + +def new_status(max_cells, max_results, extra_list_fields=()): + status = { + 'complete': True, + 'budget_exhausted': False, + 'boundary_topology_complete': True, + 'cells_processed': 0, + 'max_cells': int(max_cells), + 'results_processed': 0, + 'max_results': int(max_results), + 'partial_results': 0, + } + for field in extra_list_fields: + status[field] = [] + return status + + +def mark_incomplete(status, context, return_status, message): + status['complete'] = False + status['budget_exhausted'] = True + status['partial_results'] += 1 + if not return_status: + raise RuntimeError(f"{context}: {message}; pass return_status=True " + "to receive explicit partial status") + + +def remaining_allowances(status): + return ( + max(0, status['max_cells'] - status['cells_processed']), + max(0, status['max_results'] - status['results_processed']), + ) + + +def consume_bezier_status( + result, status, *, incomplete_message, return_status, + cell_allowance, result_allowance, + list_keys=('isolated', 'overlaps'), +): + """Aggregate and sanitize one span result under call-wide allowances. + + Returns ``(sanitized_result, incomplete)``; raises RuntimeError with + ``incomplete_message`` when incomplete and ``return_status`` is False. + """ + result = dict(result) + reported_cells = max(0, int(result.get('cells_processed', 0))) + charged_cells, cells_overrun = reconcile_reported( + reported_cells, cell_allowance) + status['cells_processed'] += charged_cells + + kept = 0 + result_overrun = False + for key in list_keys: + values = list(result.get(key, ()) or ()) + remaining = max(0, result_allowance - kept) + if len(values) > remaining: + values = values[:remaining] + result_overrun = True + result[key] = values + kept += len(values) + status['results_processed'] += kept + + exhausted = (bool(result.get('budget_exhausted', False)) + or cells_overrun or result_overrun) + topology_complete = bool(result.get('boundary_topology_complete', True)) + incomplete = exhausted or not topology_complete + + status['budget_exhausted'] |= exhausted + status['boundary_topology_complete'] &= topology_complete + if incomplete: + status['complete'] = False + status['partial_results'] += 1 + if not return_status: + raise RuntimeError(incomplete_message) + return result, incomplete diff --git a/mmcore/numeric/intersection/_bern_zero_1d.py b/mmcore/numeric/intersection/_bern_zero_1d.py index 7c2945b3..eced414a 100644 --- a/mmcore/numeric/intersection/_bern_zero_1d.py +++ b/mmcore/numeric/intersection/_bern_zero_1d.py @@ -14,6 +14,16 @@ import numpy as np from numpy.typing import NDArray +# The scoped budget lives in the shared budget module (ledger L52 — the +# 8-way accounting merge). Re-imported here because CCX/CSX and the test +# suites construct it via this module's namespace, and the solver below +# reads the SAME ContextVar object. +from mmcore.numeric._work_budget import ( # noqa: F401 + BernsteinZeroBudget, + bernstein_zero_budget, + _ZERO_BUDGET, +) + def _count_sign_changes(coeffs: NDArray) -> int: """Count the number of sign changes in a 1D coefficient array. @@ -137,6 +147,55 @@ def _longest_positive_run_center(coeffs: NDArray) -> float: def find_bernstein_zeros_1d(coeffs: NDArray, atol: float, t_start: float = 0.0, t_end: float = 1.0, max_depth: int = 30) -> list[float]: + """Budget-aware wrapper around the univariate Bernstein zero finder.""" + + budget = _ZERO_BUDGET.get() + top_level = budget is not None and budget.active_depth == 0 + result_limit = None + if top_level: + result_limit = budget.remaining_results() + if result_limit <= 0: + budget.exhausted = True + return [] + + values = _find_bernstein_zeros_1d_node( + coeffs, atol, t_start=t_start, t_end=t_end, + max_depth=max_depth, result_limit=result_limit, + ) + if top_level: + values = budget.cap_top_level_results(values) + return values + + +def _find_bernstein_zeros_1d_node( + coeffs: NDArray, + atol: float, + *, + t_start: float, + t_end: float, + max_depth: int, + result_limit: int | None, +) -> list[float]: + """Run one recursive node while charging the active scoped budget.""" + + budget = _ZERO_BUDGET.get() + if budget is not None and not budget.enter(): + return [] + try: + return _find_bernstein_zeros_1d_impl( + coeffs, atol, t_start=t_start, t_end=t_end, + max_depth=max_depth, result_limit=result_limit, + ) + finally: + if budget is not None: + budget.leave() + + +def _find_bernstein_zeros_1d_impl(coeffs: NDArray, atol: float, + t_start: float = 0.0, + t_end: float = 1.0, + max_depth: int = 30, + result_limit: int | None = None) -> list[float]: """Find all parameter values where a 1D Bernstein polynomial touches zero. The polynomial is assumed to represent a squared-distance restriction, @@ -168,6 +227,11 @@ def find_bernstein_zeros_1d(coeffs: NDArray, atol: float, # Single coefficient (degree 0) if len(coeffs) == 1: if abs(coeffs[0]) < atol_sq: + if result_limit is not None and result_limit <= 0: + budget = _ZERO_BUDGET.get() + if budget is not None: + budget.exhausted = True + return [] return [0.5 * (t_start + t_end)] return [] @@ -177,6 +241,11 @@ def find_bernstein_zeros_1d(coeffs: NDArray, atol: float, if abs(coeffs[0]) < atol_sq: zeros.append(t_start) if abs(coeffs[-1]) < atol_sq: + if result_limit is not None and len(zeros) >= result_limit: + budget = _ZERO_BUDGET.get() + if budget is not None: + budget.exhausted = True + return zeros[:result_limit] zeros.append(t_end) # 2. Quick exit: all coefficients positive and above threshold → no interior zeros @@ -208,12 +277,23 @@ def find_bernstein_zeros_1d(coeffs: NDArray, atol: float, if (not zeros or (abs(t_global - t_start) > atol * 0.01 and abs(t_global - t_end) > atol * 0.01)): + if result_limit is not None and len(zeros) >= result_limit: + budget = _ZERO_BUDGET.get() + if budget is not None: + budget.exhausted = True + return zeros[:result_limit] zeros.append(t_global) return zeros # 4. 3+ sign changes: subdivide if max_depth <= 0: + # Multiple derivative sign changes leave this interval unresolved. + # Preserve the legacy Newton representative, but never advertise a + # scoped solve that reached this fallback as topologically complete. + budget = _ZERO_BUDGET.get() + if budget is not None: + budget.exhausted = True # Fallback: try Newton from argmin degree = len(coeffs) - 1 seed_idx = int(np.argmin(coeffs)) @@ -224,6 +304,8 @@ def find_bernstein_zeros_1d(coeffs: NDArray, atol: float, t_global = t_start + t_min * (t_end - t_start) if not zeros or (abs(t_global - t_start) > atol * 0.01 and abs(t_global - t_end) > atol * 0.01): + if result_limit is not None and len(zeros) >= result_limit: + return zeros[:result_limit] zeros.append(t_global) return zeros @@ -233,9 +315,32 @@ def find_bernstein_zeros_1d(coeffs: NDArray, atol: float, left, right = _de_casteljau_split_1d(coeffs, t_split) t_mid = t_start + t_split * (t_end - t_start) - # Recurse on both halves (but don't re-report the shared endpoint at t_mid) - left_zeros = find_bernstein_zeros_1d(left, atol, t_start, t_mid, max_depth - 1) - right_zeros = find_bernstein_zeros_1d(right, atol, t_mid, t_end, max_depth - 1) + # Recurse left-to-right while carrying the remaining result quota. Once a + # child fills it, the other child is deliberately left unresolved instead + # of constructing a large list that would only be truncated at top level. + left_zeros = _find_bernstein_zeros_1d_node( + left, atol, t_start=t_start, t_end=t_mid, + max_depth=max_depth - 1, result_limit=result_limit, + ) + budget = _ZERO_BUDGET.get() + if budget is not None and budget.exhausted: + return sorted(left_zeros) + + right_limit = result_limit + if right_limit is not None: + right_limit -= len(left_zeros) + if right_limit <= 0: + # The right child has not been classified, so the capped result is + # partial even when the left child happened to return exactly the + # configured number of roots. + if budget is not None: + budget.exhausted = True + return sorted(left_zeros[:result_limit]) + + right_zeros = _find_bernstein_zeros_1d_node( + right, atol, t_start=t_mid, t_end=t_end, + max_depth=max_depth - 1, result_limit=right_limit, + ) # Merge, deduplicating near the split point all_zeros = [] @@ -246,4 +351,9 @@ def find_bernstein_zeros_1d(coeffs: NDArray, atol: float, if not any(abs(z - ez) < atol * 0.01 for ez in all_zeros): all_zeros.append(z) + if result_limit is not None and len(all_zeros) > result_limit: + if budget is not None: + budget.exhausted = True + all_zeros = all_zeros[:result_limit] + return sorted(all_zeros) diff --git a/mmcore/numeric/intersection/_bezier_common.py b/mmcore/numeric/intersection/_bezier_common.py index 273bc60b..d1a3424e 100644 --- a/mmcore/numeric/intersection/_bezier_common.py +++ b/mmcore/numeric/intersection/_bezier_common.py @@ -395,3 +395,121 @@ def _compute_remaining_intervals(excludes, lo, hi): result.append((cursor, hi)) return result + + +# --------------------------------------------------------------------------- +# Exact Bernstein product (ledger L52 slice 6a) +# --------------------------------------------------------------------------- + +def bernstein_product_1d(A, B): + """Exact same-parameter Bernstein product with broadcast value axes. + + The single implementation of the degree-reduction identity + ``B_i^m * B_j^n = [C(m,i)C(n,j)/C(m+n,i+j)] * B_{i+j}^{m+n}`` that the + ccx/csx exact-affine overlap identity certificates both build on + (previously two diverged private copies). Shape handling follows the + csx generalization (axis 0 is the degree axis; trailing value axes + broadcast), factor arithmetic follows the ccx convention (every term + converted to longdouble BEFORE multiply/divide — on platforms with a + true 80-bit longdouble the ``int*int/int`` Python-float route rounds + the factor to float64 first, a last-ulp difference that matters to + eps-scale certificate envelopes). + """ + import math as _math + A = np.asarray(A, dtype=np.longdouble) + B = np.asarray(B, dtype=np.longdouble) + m = A.shape[0] - 1 + n = B.shape[0] - 1 + out = np.zeros((m + n + 1,) + np.broadcast_shapes( + A.shape[1:], B.shape[1:]), dtype=np.longdouble) + for i in range(m + 1): + for j in range(n + 1): + k = i + j + factor = (np.longdouble(_math.comb(m, i)) + * np.longdouble(_math.comb(n, j)) + / np.longdouble(_math.comb(m + n, k))) + out[k] += factor * A[i] * B[j] + return out + + +# --------------------------------------------------------------------------- +# Shared subdivision / restriction helpers (ledger L52 slice 7) +# --------------------------------------------------------------------------- +from mmcore.numeric.bern import de_casteljau_split_nd as _dc_split_nd + + +def subdivide_curve(ctrl, t=0.5): + """Split a Bezier curve at parameter t using de Casteljau. + + Parameters + ---------- + ctrl : ndarray, shape (n+1, D) + Control polygon of a degree-n Bezier curve. + t : float + Split parameter in [0, 1]. + + Returns + ------- + left, right : ndarray + Control polygons of the two halves. + """ + n = ctrl.shape[0] - 1 + tmp = ctrl.copy() + left = [tmp[0].copy()] + right_rev = [tmp[n].copy()] + for r in range(1, n + 1): + tmp[: n + 1 - r] = (1.0 - t) * tmp[: n + 1 - r] + t * tmp[1 : n + 2 - r] + left.append(tmp[0].copy()) + right_rev.append(tmp[n - r].copy()) + return np.array(left), np.array(right_rev[::-1]) + + +def subdivide_sq_dist_net(F, axis, t=0.5): + """Subdivide the scalar sq-dist Bernstein net along *axis*. + + ``de_casteljau_split_nd`` requires a trailing value dimension, so we + temporarily add one and squeeze it back off. + """ + Fv = F[..., np.newaxis] + left_v, right_v = _dc_split_nd(Fv, axis=axis, t=t) + return left_v[..., 0], right_v[..., 0] + + +def restrict_net_axis_v(Fv, axis, lo, hi, cell_lo, cell_hi): + """Restrict a Bernstein net WITH a trailing value dim along one axis.""" + span = cell_hi - cell_lo + if span < 1e-30: + return Fv + frac_lo = (lo - cell_lo) / span + frac_hi = (hi - cell_lo) / span + if frac_lo > 1e-12: + _, Fv = _dc_split_nd(Fv, axis=axis, t=frac_lo) + if frac_hi < 1.0 - 1e-12: + frac_hi_rescaled = (frac_hi - frac_lo) / (1.0 - frac_lo) if frac_lo > 1e-12 else frac_hi + Fv, _ = _dc_split_nd(Fv, axis=axis, t=frac_hi_rescaled) + return Fv + + +def restrict_net_axis(F, axis, lo, hi, cell_lo, cell_hi): + """Restrict a scalar Bernstein net (no value dim) along one axis.""" + return restrict_net_axis_v( + F[..., np.newaxis], axis, lo, hi, cell_lo, cell_hi)[..., 0] + + +def geometry_collapsed(points) -> bool: + """All Cartesian control points coincide within 128·ε of their extent. + + The single collapsed-geometry predicate (ledger L52 slice 7 — it was + hand-rolled three ways: ssx `_curve_geometry_collapsed`, plus two + inline csx sites for the collapsed-curve fiber path). Uses LOCAL + motion, not absolute coordinate magnitude: at x=1e15 a global-scale + epsilon is ~28 model units and misclassified a 10-unit line as a + point/fiber, deleting its isolated intersection (ssx ledger note). + Callers dehomogenize/flatten first; `points` is (..., dim) Cartesian. + """ + pts = np.asarray(points, dtype=np.float64) + pts = pts.reshape(-1, pts.shape[-1]) + delta = pts - pts[0] + scale = max(1.0, float(np.max(np.abs(delta)))) + eps = 128.0 * np.finfo(float).eps * scale + return float(np.max(np.linalg.norm(delta, axis=-1))) <= eps diff --git a/mmcore/numeric/intersection/ccx/_bez_ccx4.py b/mmcore/numeric/intersection/ccx/_bez_ccx4.py index a37949db..3d832914 100644 --- a/mmcore/numeric/intersection/ccx/_bez_ccx4.py +++ b/mmcore/numeric/intersection/ccx/_bez_ccx4.py @@ -11,9 +11,14 @@ from mmcore.numeric.aabb import aabb_offset from mmcore.numeric.aabb import aabb_intersect,aabb +from mmcore.numeric._work_budget import DownCounter from mmcore.numeric.bern import de_casteljau_split_nd from mmcore.numeric.bern_sq_dist import curve_curve_squared_net_homog -from mmcore.numeric.intersection._bezier_common import extract_weights, eval_curve, newton_ccx +from mmcore.numeric.intersection._bezier_common import ( + extract_weights, eval_curve, eval_curve_d1, newton_ccx, + bernstein_product_1d, subdivide_curve, subdivide_sq_dist_net, + restrict_net_axis, +) from mmcore.numeric.intersection._sq_dist_classify import ( classify_sq_dist_net, NO_INTERSECTION, @@ -29,41 +34,9 @@ # Helpers # --------------------------------------------------------------------------- -def _subdivide_curve(ctrl, t=0.5): - """Split a Bezier curve at parameter t using de Casteljau. - - Parameters - ---------- - ctrl : ndarray, shape (n+1, D) - Control polygon of a degree-n Bezier curve. - t : float - Split parameter in [0, 1]. - - Returns - ------- - left, right : ndarray - Control polygons of the two halves. - """ - n = ctrl.shape[0] - 1 - tmp = ctrl.copy() - left = [tmp[0].copy()] - right_rev = [tmp[n].copy()] - for r in range(1, n + 1): - tmp[: n + 1 - r] = (1.0 - t) * tmp[: n + 1 - r] + t * tmp[1 : n + 2 - r] - left.append(tmp[0].copy()) - right_rev.append(tmp[n - r].copy()) - return np.array(left), np.array(right_rev[::-1]) - - -def _subdivide_sq_dist_net(F, axis, t=0.5): - """Subdivide the scalar sq-dist Bernstein net along *axis*. - - ``de_casteljau_split_nd`` requires a trailing value dimension, so we - temporarily add one and squeeze it back off. - """ - Fv = F[..., np.newaxis] - left_v, right_v = de_casteljau_split_nd(Fv, axis=axis, t=t) - return left_v[..., 0], right_v[..., 0] +# L52 slice 7: shared implementations in _bezier_common (verbatim moves). +_subdivide_curve = subdivide_curve +_subdivide_sq_dist_net = subdivide_sq_dist_net def _subdivide_sq_dist_net_2d(F, u=0.5,v=0.5): """Subdivide the scalar sq-dist Bernstein net along *axis*. @@ -109,6 +82,567 @@ def _compute_param_tols(C1, C2, atol, rational): return float(tol_u), float(tol_v) +def _cartesian_curve_controls_for_exactness(C, rational): + C = np.asarray(C, dtype=np.float64) + if rational: + weights = C[:, -1:] + if (not np.all(np.isfinite(weights)) + or np.any(weights == 0.0)): + return None + points = C[:, :-1] / weights + else: + points = C + if not np.all(np.isfinite(points)): + return None + return points + + +def _center_curve_homogeneous_for_exactness(C, rational, origin): + """Return a stably normalized homogeneous curve translated by origin.""" + C = np.asarray(C, dtype=np.float64) + if rational: + H = C.copy() + else: + H = np.concatenate( + [C, np.ones((len(C), 1), dtype=np.float64)], axis=1) + scale = float(np.max(np.abs(H))) + if not np.isfinite(scale) or scale <= 0.0: + return None + H /= scale + H[:, :-1] -= np.asarray(origin, dtype=np.float64) * H[:, -1:] + local_scale = float(np.max(np.abs(H))) + if not np.isfinite(local_scale) or local_scale <= 0.0: + return None + return np.ascontiguousarray(H / local_scale) + + +def _ccx_exactness_context(C1, C2, rational): + """Common-origin context for translation-invariant equality tests.""" + points1 = _cartesian_curve_controls_for_exactness(C1, rational) + points2 = _cartesian_curve_controls_for_exactness(C2, rational) + if points1 is None or points2 is None: + return None + origin = points1[0].copy() + H1 = _center_curve_homogeneous_for_exactness(C1, rational, origin) + H2 = _center_curve_homogeneous_for_exactness(C2, rational, origin) + if H1 is None or H2 is None: + return None + local1 = H1[:, :-1] / H1[:, -1:] + local2 = H2[:, :-1] / H2[:, -1:] + component_scale = np.maximum( + np.max(np.abs(local1), axis=0), + np.max(np.abs(local2), axis=0)) + return H1, H2, component_scale + + +def _eval_curve_scaled_components(C, t, rational, component_scale): + """Evaluate Cartesian coordinates after normalizing each quiet axis. + + macOS aliases ``longdouble`` to float64, so multiplying the smallest + subnormal by a Bernstein basis value can underflow even in the helper + above. Dividing each numerator coordinate by its own nonzero control + scale *before* de Casteljau keeps that exactness information at O(1). + """ + C = np.asarray(C, dtype=np.float64) + scales = np.asarray(component_scale, dtype=np.float64) + if rational: + common = float(np.max(np.abs(C))) + if not np.isfinite(common) or common <= 0.0: + return None + H = C / common + weights = H[:, -1] + else: + H = C + weights = None + + def _eval_scalar(values): + work = np.asarray(values, dtype=np.float64).copy() + tt = float(t) + for _ in range(1, len(work)): + work = (1.0 - tt) * work[:-1] + tt * work[1:] + return float(work[0]) + + denominator = _eval_scalar(weights) if rational else 1.0 + if not np.isfinite(denominator) or denominator == 0.0: + return None + result = np.zeros(len(scales), dtype=np.float64) + for axis, scale in enumerate(scales): + if scale == 0.0: + continue + numerator = _eval_scalar(H[:, axis] / scale) + result[axis] = numerator / denominator + return result + + +def _strict_residual_ok(C1, C2, u, v, rational, component_scale=None): + """Accept a point equality only inside a floating roundoff envelope. + + ``atol`` is a search/resolution tolerance, not membership in the exact + intersection set. The envelope is tied to each coordinate's own control + scale and curve degrees, so every representable nonzero offset in an + otherwise constant coordinate remains nonzero for this predicate. + """ + p1 = eval_curve(C1, float(u), rational=rational) + p2 = eval_curve(C2, float(v), rational=rational) + if not (np.all(np.isfinite(p1)) and np.all(np.isfinite(p2))): + return False, p1, p2 + if component_scale is None: + component_scale = _ccx_exactness_context(C1, C2, rational) + if component_scale is None: + return False, p1, p2 + C1_centered, C2_centered, scales = component_scale + p1_scaled = _eval_curve_scaled_components( + C1_centered, u, True, scales) + p2_scaled = _eval_curve_scaled_components( + C2_centered, v, True, scales) + if (p1_scaled is None or p2_scaled is None + or not np.all(np.isfinite(p1_scaled)) + or not np.all(np.isfinite(p2_scaled))): + return False, p1, p2 + degree_factor = max(1, len(C1) + len(C2)) + bound = ((32.0 * degree_factor * np.finfo(np.float64).eps) + * np.maximum(1.0, np.maximum( + np.abs(p1_scaled), np.abs(p2_scaled)))) + return bool(np.all(np.abs(p1_scaled - p2_scaled) <= bound)), p1, p2 + + +def _strict_polish_ccx(C1, C2, u, v, rational, component_scale=None, + require_newton=False): + """Globally polish a candidate, then require exact-set membership. + + The Newton calls are intentionally unbounded by the current subdivision + cell (they retain only the public [0,1] curve domains). Cell bounds are + a search device and must not turn a root just across a cell seam into a + near-root. Neither Newton's step size nor ``atol`` can accept the result; + the component-wise residual certificate above is the sole membership + gate. + """ + u = float(np.clip(u, 0.0, 1.0)) + v = float(np.clip(v, 0.0, 1.0)) + ok, p1, p2 = _strict_residual_ok( + C1, C2, u, v, rational, component_scale) + if ok and not require_newton: + return u, v, p1 + + if component_scale is None: + polish_C1, polish_C2, polish_rational = C1, C2, rational + else: + polish_C1, polish_C2, _scales = component_scale + polish_rational = True + + def _reported_residual_ok(G): + if component_scale is None: + return False + _C1_centered, _C2_centered, scales = component_scale + G = np.asarray(G, dtype=np.float64) + scales = np.asarray(scales, dtype=np.float64) + if G.shape != scales.shape or not np.all(np.isfinite(G)): + return False + degree_factor = max(1, len(C1) + len(C2)) + limit = 32.0 * degree_factor * np.finfo(np.float64).eps + for value, scale in zip(G, scales): + if scale == 0.0: + if value != 0.0: + return False + elif abs(value / scale) > limit: + return False + return True + + # A damped pass is robust at tangencies; the almost-undamped pass removes + # the residual floor on well-conditioned transversal intersections. + for damp in (1e-12, 1e-18): + u, v, G, _last_step = newton_ccx( + polish_C1, polish_C2, u, v, rational=polish_rational, + tol=0.0, step_tol=8.0 * np.finfo(np.float64).eps, + max_it=64, lm_damp=damp, + ) + ok, p1, p2 = _strict_residual_ok( + C1, C2, u, v, rational, component_scale) + if ok and _reported_residual_ok(G): + return float(u), float(v), p1 + return None + + +def _restrict_curve_interval(C, lo, hi): + """Return control points reparameterized from [0,1] onto [lo,hi].""" + C = np.asarray(C) + reverse = hi < lo + a, b = (hi, lo) if reverse else (lo, hi) + if not (0.0 <= a < b <= 1.0): + return None + out = C.copy() + if a > 0.0: + _, out = _subdivide_curve(out, a) + if b < 1.0: + local_b = (b - a) / (1.0 - a) if a > 0.0 else b + out, _ = _subdivide_curve(out, local_b) + if reverse: + out = out[::-1].copy() + return out + + +# L52 slice 6a: the shared exact product (broadcast-generalized; same +# accumulation order and longdouble factor arithmetic as the old private +# copy — 1-D operands are the degenerate broadcast case). +_bernstein_product_1d = bernstein_product_1d + + +def _homogeneous_curve_for_identity(C, rational, origin): + H = _center_curve_homogeneous_for_exactness( + C, rational, origin) + if H is None: + return None + return np.asarray(H, dtype=np.longdouble) + + +def _overlap_mapping_is_identity(C1, C2, u_range, v_range, rational): + """Certify ``C1(u(t)) == C2(v(t))`` as a Bernstein identity. + + This is an all-parameter certificate, not a finite sample test. For + rational curves it forms every coefficient of + + P1_k(t) W2(t) - P2_k(t) W1(t) + + after restricting both curves to the proposed affine parameter ranges. + A coefficient is zero only within the roundoff accumulated by its two + product terms; a one-sided nonzero coordinate offset therefore cannot be + hidden by a model-scale tolerance or independent homogeneous scaling. + """ + points1 = _cartesian_curve_controls_for_exactness(C1, rational) + points2 = _cartesian_curve_controls_for_exactness(C2, rational) + if points1 is None or points2 is None: + return False + origin = points1[0] + H1 = _homogeneous_curve_for_identity(C1, rational, origin) + H2 = _homogeneous_curve_for_identity(C2, rational, origin) + if H1 is None or H2 is None or H1.shape[1] != H2.shape[1]: + return False + h1_xyz_scale = np.max(np.abs(H1[:, :-1]), axis=0) + h2_xyz_scale = np.max(np.abs(H2[:, :-1]), axis=0) + h1_weight_scale = np.max(np.abs(H1[:, -1])) + h2_weight_scale = np.max(np.abs(H2[:, -1])) + source_product_scale = ( + h1_xyz_scale * h2_weight_scale + + h2_xyz_scale * h1_weight_scale) + H1 = _restrict_curve_interval(H1, float(u_range[0]), float(u_range[1])) + H2 = _restrict_curve_interval(H2, float(v_range[0]), float(v_range[1])) + if H1 is None or H2 is None: + return False + w1 = H1[:, -1] + w2 = H2[:, -1] + if (np.any(w1 == 0.0) or np.any(w2 == 0.0) + or not np.all(np.isfinite(w1)) + or not np.all(np.isfinite(w2))): + return False + + eps = np.finfo(np.longdouble).eps + op_factor = np.longdouble( + 64 * max(1, len(H1)) * max(1, len(H2))) * eps + # Interval restriction is performed in the source floating precision + # before the long-double Bernstein products. Its cancellation error is + # tied to the un-restricted source products, not to a coefficient that + # may have cancelled to zero. The common-origin centering above is + # essential: a large world translation cannot inflate this floor and + # hide a real offset. + source_factor = (np.longdouble(8192 * max(1, len(H1) + len(H2))) + * np.longdouble(np.finfo(np.float64).eps)) + for axis in range(H1.shape[1] - 1): + lhs = _bernstein_product_1d(H1[:, axis], w2) + rhs = _bernstein_product_1d(H2[:, axis], w1) + residual = np.abs(lhs - rhs) + roundoff = ( + op_factor * (np.abs(lhs) + np.abs(rhs)) + + source_factor * source_product_scale[axis]) + if np.any(residual > roundoff): + return False + return True + + +def _invert_point_on_curve(C, pt, v0, rational, max_iter=40): + """Project *pt* onto curve ``C``: damped 1-D Gauss-Newton on + ``||C(v) - pt||`` with a monotone-decreasing line search, clamped to + [0,1]. Returns ``(v, residual)`` for the best point found.""" + pt = np.asarray(pt, dtype=np.float64) + v = float(min(1.0, max(0.0, float(v0)))) + p, d = eval_curve_d1(C, v, rational=rational) + r = p - pt + best_v, best_r = v, float(np.linalg.norm(r)) + for _ in range(max_iter): + denom = float(np.dot(d, d)) + if denom < 1e-30: + break + step = -float(np.dot(r, d)) / denom + if abs(step) < 1e-16: + break + scale = 1.0 + improved = False + for _ls in range(20): + cand = min(1.0, max(0.0, v + scale * step)) + p_c, d_c = eval_curve_d1(C, cand, rational=rational) + r_c = p_c - pt + n_c = float(np.linalg.norm(r_c)) + if n_c < best_r: + v, p, d, r = cand, p_c, d_c, r_c + best_v, best_r = cand, n_c + improved = True + break + scale *= 0.5 + if not improved: + break + return best_v, best_r + + +def _project_point_on_curve(C, pt, rational, seed=None): + """Coarse-scan (when unseeded) + Newton projection of *pt* onto ``C``.""" + if seed is None: + ts = np.linspace(0.0, 1.0, 17) + ds = [float(np.linalg.norm( + eval_curve(C, float(t), rational=rational) + - np.asarray(pt, dtype=np.float64))) for t in ts] + seed = float(ts[int(np.argmin(ds))]) + return _invert_point_on_curve(C, pt, seed, rational) + + +def _tolerance_overlap_certificate(C1, C2, atol, rational, ptol_u, ptol_v, + n_samples=65): + """L47 residual-certified overlap tier (user decision 2026-07-12). + + The exact-affine identity above certifies coefficient-identical span + pairs only. Near-coincident pairs (same path within ``atol`` but not + exactly) and same-locus non-affine reparameterizations are genuine + overlaps at modeling tolerance — the semantics CSX claims (L27) and SSX + regions (L28) already use. This certificate: + + 1. gates on the four domain-endpoint inversions (an admissible span + must be pinned by a domain end of one curve at each end; interior- + ended near-coincident bands stay unpromoted — typed partial); + 2. densely samples the candidate span, requiring every point of C1 to + invert onto C2 within ``atol`` with a monotone parameter pairing; + 3. refuses to merge sub-tolerance TOPOLOGY (the CSX invariant, 1-D + form): a transverse-direction flip between consecutive GAP samples + — bridging any root-like samples in between, so a crossing landing + exactly on a sample node is still seen — means the curves CROSS + inside the band; the certificate rejects and returns the crossing + brackets so the caller can certify them as isolated roots instead. + A non-flipping (tangential) touch inside the band is covered by + the overlap; root-like dips alone are not crossing evidence. + + Returns ``(overlap | None, brackets, band_evidence, span_evidence)``: + ``brackets`` is a list of ``(u, v)`` seeds for strict root polishing; + ``band_evidence`` is True when some qualifying domain end continues as a + within-``atol`` coincidence BAND into the domain interior (inward-probe + test) — a mere corner CONTACT (curves within atol at one endpoint but + diverging immediately, e.g. consecutive edges of a loop sharing a + vertex) is NOT band evidence and must not arm the bounded fallback; + ``span_evidence`` is the widest endpoint-qualified u-extent (or None). + """ + ends1 = [np.asarray(eval_curve(C1, t, rational=rational), dtype=np.float64) + for t in (0.0, 1.0)] + ends2 = [np.asarray(eval_curve(C2, t, rational=rational), dtype=np.float64) + for t in (0.0, 1.0)] + pts1 = _cartesian_curve_controls_for_exactness(C1, rational) + pts2 = _cartesian_curve_controls_for_exactness(C2, rational) + if pts1 is None or pts2 is None: + return None, [], 0, None + allpts = np.vstack([pts1, pts2]) + diag = float(np.linalg.norm(allpts.max(axis=0) - allpts.min(axis=0))) + # Roundoff floor separating "exact root at this sample" from "genuine + # gap": generous vs eval noise, far below any modeling tolerance. + tiny = max(4096.0 * float(np.finfo(np.float64).eps), 1e-12) * max(1.0, diag) + + # Sound AABB pre-filter: the curve lies inside its control-point box, so + # an endpoint farther than atol from the box cannot project within atol. + # This keeps the endpoint gate ~free on generic (non-coincident) pairs — + # CSX runs up to 4 nested CCX calls per cut face, so a Newton projection + # per endpoint on every call is real wall-clock on the SSX gates. + lo1 = pts1.min(axis=0) - atol + hi1 = pts1.max(axis=0) + atol + lo2 = pts2.min(axis=0) - atol + hi2 = pts2.max(axis=0) + atol + + def _outside(pt, lo, hi): + return bool(np.any(pt < lo) or np.any(pt > hi)) + + cands = [] + band_ends = [] # (curve_index, t_end) of qualifying domain ends + for t_end, pt in ((0.0, ends1[0]), (1.0, ends1[1])): + if _outside(pt, lo2, hi2): + continue + v_end, r_end = _project_point_on_curve(C2, pt, rational) + if r_end <= atol: + cands.append((t_end, v_end)) + band_ends.append((0, t_end)) + for t_end, pt in ((0.0, ends2[0]), (1.0, ends2[1])): + if _outside(pt, lo1, hi1): + continue + u_end, r_end = _project_point_on_curve(C1, pt, rational) + if r_end <= atol: + cands.append((u_end, t_end)) + band_ends.append((1, t_end)) + + # Inward-probe band test: a coincidence BAND continues within atol into + # the domain interior; a corner CONTACT (shared vertex of consecutive + # edges) diverges immediately. Only bands justify the bounded fallback. + band_evidence = False + for which, t_end in band_ends: + src, dst = (C1, C2) if which == 0 else (C2, C1) + for step in (0.02, 0.05): + t_in = step if t_end == 0.0 else 1.0 - step + pt_in = np.asarray(eval_curve(src, t_in, rational=rational), + dtype=np.float64) + _t_proj, r_in = _project_point_on_curve(dst, pt_in, rational) + if r_in <= atol: + band_evidence = True + break + if band_evidence: + break + + if len(cands) < 2: + return None, [], band_evidence, None + uniq = [] + for u, v in cands: + if not any(abs(u - uu) <= 4.0 * ptol_u and abs(v - vv) <= 4.0 * ptol_v + for uu, vv in uniq): + uniq.append((float(u), float(v))) + span_evidence = None + if len(uniq) >= 2: + u_ext = [u for u, _v in uniq] + span_evidence = (float(min(u_ext)), float(max(u_ext))) + + pairs = [(uniq[i], uniq[j]) + for i in range(len(uniq)) for j in range(i + 1, len(uniq))] + pairs.sort(key=lambda pr: -abs(pr[1][0] - pr[0][0])) + + brackets: list[tuple[float, float]] = [] + for (ua, va), (ub, vb) in pairs: + if (abs(ub - ua) <= max(4.0 * ptol_u, 1e-6) + or abs(vb - va) <= max(4.0 * ptol_v, 1e-6)): + continue + if ub < ua: + ua, ub, va, vb = ub, ua, vb, va + direction = 1.0 if vb >= va else -1.0 + us = np.linspace(ua, ub, n_samples) + res = np.empty(n_samples) + vs = np.empty(n_samples) + dvecs = np.empty((n_samples, ends1[0].shape[0])) + ok = True + v_prev = None + for k in range(n_samples): + u_k = float(us[k]) + pt = np.asarray(eval_curve(C1, u_k, rational=rational), + dtype=np.float64) + v_seed = va + (vb - va) * (k / (n_samples - 1.0)) + if v_prev is not None: + v_seed = v_prev + (vb - va) / (n_samples - 1.0) + v_k, r_k = _invert_point_on_curve(C2, pt, v_seed, rational) + if r_k > atol: + v_k2, r_k2 = _project_point_on_curve(C2, pt, rational) + if r_k2 < r_k: + v_k, r_k = v_k2, r_k2 + if r_k > atol: + ok = False + break + if (v_prev is not None + and direction * (v_k - v_prev) < -4.0 * ptol_v): + ok = False # folded pairing — not a functional overlap + break + vs[k] = v_k + res[k] = r_k + dvecs[k] = pt - np.asarray( + eval_curve(C2, float(v_k), rational=rational), + dtype=np.float64) + v_prev = v_k + if not ok: + continue + + root_like = res <= tiny + if not bool(np.all(root_like)): + # Sub-tolerance topology guard (CSX invariant, 1-D form): a + # transverse-direction FLIP between consecutive GAP samples + # means the curves cross inside the band — never merge; reject + # the promotion and hand the crossing brackets to strict root + # polishing. Root-like samples carry no direction information + # (a residual dip below roundoff scale alone is NOT crossing + # evidence — a y-offset of a curve is locally tangent to it + # wherever the tangent is parallel to the offset, dipping to + # (offset)^2·kappa with no root there), so the flip test + # BRIDGES root-like runs and compares each gap sample with the + # NEXT gap sample: a crossing landing exactly ON a sample node + # (dyadic-64 fractions — audit L54[A2-1]) shows as a root-like + # node between opposite-side gaps and must be caught, with the + # bracket at that node (the root is exactly there). A + # non-flipping exact touch inside the band stays legitimately + # covered by the tolerance overlap. Residual limit: an EVEN + # number of crossings inside one sample interval still aliases + # to no flip (both gap neighbours same side) — inherent to the + # fixed 65-sample grid, documented in the L54 ledger entry. + pair_brackets = [] + gap_idx = [k for k in range(n_samples) if not root_like[k]] + for a_i, b_i in zip(gap_idx, gap_idx[1:]): + if float(np.dot(dvecs[a_i], dvecs[b_i])) < 0.0: + if b_i - a_i > 1: + k_root = a_i + 1 + pair_brackets.append( + (float(us[k_root]), float(vs[k_root]))) + else: + pair_brackets.append( + (0.5 * float(us[a_i] + us[b_i]), + 0.5 * float(vs[a_i] + vs[b_i]))) + if pair_brackets: + brackets.extend(pair_brackets) + continue + return ({ + "boundary_zeros": [], + "overlap_endpoints": [], + "u_range": (float(ua), float(ub)), + "v_range": (float(va), float(vb)), + "certification": "tolerance", + "residual_max": float(res.max()), + }, brackets, band_evidence, span_evidence) + return None, brackets, band_evidence, span_evidence + + +def _vector_residual_hull_excludes_zero(C1, C2, rational, depth): + """Certify that one Cartesian residual component cannot be zero. + + For each component this constructs the full tensor-product Bernstein net + + P1_k(u) W2(v) - P2_k(v) W1(u). + + If every coefficient is strictly on the same side of zero, with a + subdivision- and product-roundoff margin, the two curve pieces cannot + intersect. Independent homogeneous scales cancel because each curve is + normalized as a whole before the cross product. + """ + points1 = _cartesian_curve_controls_for_exactness(C1, rational) + points2 = _cartesian_curve_controls_for_exactness(C2, rational) + if points1 is None or points2 is None: + return False + origin = points1[0] + H1 = _homogeneous_curve_for_identity(C1, rational, origin) + H2 = _homogeneous_curve_for_identity(C2, rational, origin) + if H1 is None or H2 is None or H1.shape[1] != H2.shape[1]: + return False + w1 = H1[:, -1] + w2 = H2[:, -1] + if (not np.all(np.isfinite(w1)) or not np.all(np.isfinite(w2)) + or np.any(w1 == 0.0) or np.any(w2 == 0.0)): + return False + + eps = np.finfo(np.longdouble).eps + op_factor = np.longdouble( + 1024 * max(1, depth + 1) * max(len(H1), len(H2))) * eps + for axis in range(H1.shape[1] - 1): + lhs = H1[:, axis, None] * w2[None, :] + rhs = w1[:, None] * H2[None, :, axis] + residual = lhs - rhs + roundoff = op_factor * (np.abs(lhs) + np.abs(rhs)) + if (np.all(residual > roundoff) + or np.all(residual < -roundoff)): + return True + return False + + from mmcore.numeric.bern import bernstein_partial_derivative_coeffs @@ -116,20 +650,8 @@ def _compute_param_tols(C1, C2, atol, rational): # Phase 2 helpers # --------------------------------------------------------------------------- -def _restrict_net_axis(F, axis, lo, hi, cell_lo, cell_hi): - """Restrict a bivariate net along one axis to [lo, hi] within [cell_lo, cell_hi].""" - span = cell_hi - cell_lo - if span < 1e-30: - return F - frac_lo = (lo - cell_lo) / span - frac_hi = (hi - cell_lo) / span - Fv = F[..., np.newaxis] - if frac_lo > 1e-12: - _, Fv = de_casteljau_split_nd(Fv, axis=axis, t=frac_lo) - if frac_hi < 1.0 - 1e-12: - frac_hi_rescaled = (frac_hi - frac_lo) / (1.0 - frac_lo) if frac_lo > 1e-12 else frac_hi - Fv, _ = de_casteljau_split_nd(Fv, axis=axis, t=frac_hi_rescaled) - return Fv[..., 0] +# L52 slice 7: shared implementation in _bezier_common (verbatim move). +_restrict_net_axis = restrict_net_axis def _split_intervals(cut, lo, hi, ptol): @@ -200,6 +722,7 @@ def _phase2_ccx(F, C1, C2, C1_orig, C2_orig, atol, rational, ptol_u, ptol_v, known_points=None, max_depth=50, max_cells=50_000, + max_results=4_096, initial_stack=None): """Phase 2: find isolated intersections via subdivision + Newton + cutout. @@ -213,6 +736,9 @@ def _phase2_ccx(F, C1, C2, C1_orig, C2_orig, isolated = list(known_points) if known_points else [] n_known = len(isolated) cells = 0 + exhausted = False + component_scale = _ccx_exactness_context( + C1_orig, C2_orig, rational) if initial_stack is not None: stack = list(initial_stack) @@ -223,7 +749,8 @@ def _phase2_ccx(F, C1, C2, C1_orig, C2_orig, u_lo, u_hi, v_lo, v_hi, 0)] while stack: - if cells >= max_cells: + if cells >= max_cells or len(isolated) - n_known >= max_results: + exhausted = True break cells += 1 @@ -237,6 +764,9 @@ def _phase2_ccx(F, C1, C2, C1_orig, C2_orig, else: pts1 = seg1 pts2 = seg2 + if _vector_residual_hull_excludes_zero( + seg1, seg2, rational, depth): + continue bb1 = np.array(aabb(pts1)); bb1[0] -= atol; bb1[1] += atol bb2 = np.array(aabb(pts2)); bb2[0] -= atol; bb2[1] += atol if not aabb_intersect(bb1, bb2): @@ -268,35 +798,40 @@ def _phase2_ccx(F, C1, C2, C1_orig, C2_orig, if (u1 - u0) <= ptol_u and (v1 - v0) <= ptol_v: u_mid = 0.5 * (u0 + u1) v_mid = 0.5 * (v0 + v1) - pt = eval_curve(C1_orig, u_mid, rational=rational) - - if not _is_duplicate(isolated, pt, atol): - isolated.append({"u": float(u_mid), "v": float(v_mid), "point": pt, "_micro": True}) + polished = _strict_polish_ccx( + C1_orig, C2_orig, u_mid, v_mid, rational, + component_scale=component_scale, require_newton=True) + if polished is not None: + u_sol, v_sol, pt = polished + if (u0 - 0.25 * ptol_u <= u_sol <= u1 + 0.25 * ptol_u + and v0 - 0.25 * ptol_v <= v_sol + <= v1 + 0.25 * ptol_v + and not _is_duplicate(isolated, pt, atol)): + isolated.append({ + "u": float(u_sol), "v": float(v_sol), + "point": pt, "_micro": True, + }) continue # Newton from cell center u_mid = 0.5 * (u0 + u1) v_mid = 0.5 * (v0 + v1) - uv_candidates=[(u0 , v0),(u0,v1),(u1,v1),(u1,v0)] - root_found=False - is_converged=False - for u_mid,v_mid in uv_candidates: + uv_candidates = [ + (u0, v0), (u0, v1), (u1, v1), (u1, v0), + (u_mid, v_mid), + ] + root_found = False + for u_mid, v_mid in uv_candidates: if root_found: break - u_sol, v_sol, G, last_step = newton_ccx( - C1_orig,C2_orig, u_mid, v_mid, rational=rational, - ) - step_norm = abs(last_step[0]) + abs(last_step[1]) - residual_ok = float(np.linalg.norm(G)) < atol - converged = ((step_norm > 0 or residual_ok) - and abs(last_step[0]) < ptol_u - and abs(last_step[1]) < ptol_v) - #print(f"CCX: {cells} cells NEWTON: {( u_sol, v_sol, G, last_step, ( u0, u1), (v0, v1), depth)}: {converged}") - if converged: - is_converged = True - if converged and u0 < u_sol < u1 and v0 < v_sol < v1 : - - pt = eval_curve(C1_orig, u_sol, rational=rational) + polished = _strict_polish_ccx( + C1_orig, C2_orig, u_mid, v_mid, rational, + component_scale=component_scale, require_newton=True) + if polished is not None: + u_sol, v_sol, pt = polished + else: + continue + if u0 < u_sol < u1 and v0 < v_sol < v1: is_new = not _is_duplicate(isolated, pt, atol) #print(f"CCX: is_new: {is_new}") if is_new: @@ -312,13 +847,11 @@ def _phase2_ccx(F, C1, C2, C1_orig, C2_orig, if root_found:continue - - if is_converged: - # Converged outside cell → prune - continue - - if depth >= max_depth: + # None of the certificates above proved this cell root-free and + # Newton did not resolve it. Dropping it at the depth guard is a + # partial search just like dropping queued cells at ``max_cells``. + exhausted = True continue # Subdivide @@ -342,7 +875,7 @@ def _phase2_ccx(F, C1, C2, C1_orig, C2_orig, stack.append((seg1_R, seg2_R, F_RR, pw_R, qw_R,u_mid_split, u1, v_mid_split, v1, depth+1)) stack.append((seg1_R, seg2_L, F_RL, pw_R, qw_L,u_mid_split, u1, v0, v_mid_split, depth+1)) - return isolated[n_known:] + return isolated[n_known:], exhausted, cells # --------------------------------------------------------------------------- @@ -358,6 +891,7 @@ def bez_ccx( rational=False, max_depth=50, max_cells=100_000, + max_results=4_096, ) -> dict: """Bezier curve-curve intersection via two-phase architecture. @@ -365,10 +899,39 @@ def bez_ccx( These can ONLY exist at the boundaries of the original objects. Phase 2: Search for isolated intersections on the remaining parameter intervals via subdivision + Newton + cutout. + + ``max_cells`` is shared by Phase 1 and every Phase-2 interval. + ``max_results`` also caps Phase-1 boundary roots before pairwise topology + checks. A capped return sets ``budget_exhausted``; callers must treat + ``boundary_topology_complete=False`` as diagnostic partial output. + + Overlap entries carry ``certification``: ``'exact'`` (Bernstein affine + identity) or ``'tolerance'`` (L47 residual tier: dense-sample inversion + pairing within ``atol`` + ``residual_max``, e.g. near-coincident twins + and non-affine same-locus reparameterizations). An overlap-class + structure that NEITHER certificate can promote and the bounded fallback + cannot discretize returns ``uncertified_overlap_span=(u_lo, u_hi)`` with + ``boundary_topology_complete=False`` — typed, not a bare budget flag. """ C1 = np.asarray(C1, dtype=np.float64) C2 = np.asarray(C2, dtype=np.float64) + cells = DownCounter(max_cells) + budget_exhausted = False + + def _result(isolated, overlaps, *, topology_complete=True): + return { + "isolated": isolated, + "overlaps": overlaps, + "budget_exhausted": bool(budget_exhausted), + "cells_processed": int(cells.processed), + "boundary_topology_complete": bool(topology_complete), + } + + if cells.remaining <= 0: + budget_exhausted = True + return _result([], [], topology_complete=False) + F = curve_curve_squared_net_homog(C1, C2, rational=rational) _, Pw = extract_weights(C1, rational=rational) @@ -378,6 +941,8 @@ def bez_ccx( C2_orig = C2 ptol_u, ptol_v = _compute_param_tols(C1, C2, atol, rational) + component_scale = _ccx_exactness_context( + C1_orig, C2_orig, rational) isolated = [] overlaps = [] @@ -385,69 +950,243 @@ def bez_ccx( # =================================================================== # PHASE 1: Boundary analysis + overlap (initial patch only) # =================================================================== - cls = classify_sq_dist_net(F, atol, Pw, Qw) + # Charge the classifier itself, then share the remaining allowance with + # every recursive 1-D boundary solve it invokes. The root cap is kept + # deliberately below the point where the classifier's pairwise valley + # check becomes a material O(B^2) operation. + cells.spend(1) + from mmcore.numeric.intersection._bern_zero_1d import bernstein_zero_budget + boundary_root_cap = min(max(0, int(max_results)), 128) + + # ``_check_overlap`` pairs every near-zero coefficient on opposite + # boundaries. Bound that candidate materialization before entering the + # legacy classifier; limiting only the later precise-root list would be + # too late to prevent this separate quadratic path. + w_scale = float(np.max(np.abs(Pw))) * float(np.max(np.abs(Qw))) + boundary_threshold = (atol * w_scale) ** 2 + boundary_candidate_counts = ( + int(np.count_nonzero(np.abs(F[0, :]) < boundary_threshold)), + int(np.count_nonzero(np.abs(F[-1, :]) < boundary_threshold)), + int(np.count_nonzero(np.abs(F[:, 0]) < boundary_threshold)), + int(np.count_nonzero(np.abs(F[:, -1]) < boundary_threshold)), + ) + if any(count > boundary_root_cap for count in boundary_candidate_counts): + budget_exhausted = True + return _result([], [], topology_complete=False) + + with bernstein_zero_budget(cells.remaining, boundary_root_cap) as zero_budget: + cls = classify_sq_dist_net(F, atol, Pw, Qw) + cells.spend(zero_budget.nodes) + + # A capped boundary solve is not a smaller-but-valid topology. In + # particular, using its endpoints to declare an overlap or cut Phase 2 + # could hide real roots. Discard it and mark the result explicitly + # partial so callers such as CSX cannot silently consume it. + if zero_budget.exhausted: + budget_exhausted = True + return _result([], [], topology_complete=False) + if any(not isinstance(bz, BoundaryZero) for bz in cls.precise_zeros): + budget_exhausted = True + return _result([], [], topology_complete=False) #print(f"CCX: {cls} (phase 1)") if cls.kind == NO_INTERSECTION: - return {"isolated": [], "overlaps": []} + return _result([], []) # 1a. Collect validated boundary zeros (don't add to isolated yet) - boundary_hits = [] # list of (u, v, point) + boundary_hits = [] # list of strictly validated (u, v, point) if cls.precise_zeros: for bz in cls.precise_zeros: - if not isinstance(bz, BoundaryZero): - continue u_bz, v_bz = _boundary_zero_to_uv(bz, 0.0, 1.0, 0.0, 1.0) - pt1 = eval_curve(C1, u_bz, rational=rational) - pt2 = eval_curve(C2, v_bz, rational=rational) - if float(np.linalg.norm(pt1 - pt2)) < atol: - boundary_hits.append((float(u_bz), float(v_bz), pt1)) + polished = _strict_polish_ccx( + C1_orig, C2_orig, u_bz, v_bz, rational, + component_scale=component_scale) + if polished is not None: + u_sol, v_sol, point = polished + boundary_hits.append((float(u_sol), float(v_sol), point)) # 1b. Check for overlap overlap_found = False if cls.kind == OVERLAP: - if cls.overlap_endpoints and isinstance(cls.overlap_endpoints[0], BoundaryZero): - ovl_pts = [] - for bz in cls.overlap_endpoints: - u_bz, v_bz = _boundary_zero_to_uv(bz, 0.0, 1.0, 0.0, 1.0) - u_sol, v_sol, G, last_step = newton_ccx( - C1_orig, C2_orig, u_bz, v_bz, rational=rational, - ) - if abs(last_step[0]) <= ptol_u and abs(last_step[1]) <= ptol_v: - ovl_pts.append((float(u_sol), float(v_sol))) - else: - ovl_pts.append((float(u_bz), float(v_bz))) - if len(ovl_pts) >= 2: - overlaps.append({ - "boundary_zeros": cls.boundary_zeros, - "overlap_endpoints": cls.overlap_endpoints, - "u_range": (ovl_pts[0][0], ovl_pts[1][0]), - "v_range": (ovl_pts[0][1], ovl_pts[1][1]), - }) - overlap_found = True - else: + # The legacy classifier's OVERLAP kind is intentionally tolerant and + # its ``overlap_endpoints`` payload can be only a valley witness. It + # is therefore a candidate generator, never a public topology claim. + # Build paired strict endpoint roots, then promote only an affine map + # whose full Bernstein cross-residual is the zero identity. + uv_roots = [] + uv_eps = 64.0 * np.finfo(np.float64).eps + for u_hit, v_hit, _point in boundary_hits: + if not any(abs(u_hit - u0) <= uv_eps + and abs(v_hit - v0) <= uv_eps + for u0, v0 in uv_roots): + uv_roots.append((u_hit, v_hit)) + + endpoint_pairs = [] + for i in range(len(uv_roots)): + for j in range(i + 1, len(uv_roots)): + endpoint_pairs.append((uv_roots[i], uv_roots[j])) + + # A classifier may summarize a full overlap without retaining typed + # endpoint roots. These two domain-spanning affine maps are still + # candidates, but their endpoints and coefficient identity must pass + # the same strict gates as every inferred sub-range. + for pair in ( + ((0.0, 0.0), (1.0, 1.0)), + ((0.0, 1.0), (1.0, 0.0))): + p0 = _strict_polish_ccx( + C1_orig, C2_orig, *pair[0], rational, + component_scale=component_scale) + p1 = _strict_polish_ccx( + C1_orig, C2_orig, *pair[1], rational, + component_scale=component_scale) + if p0 is not None and p1 is not None: + endpoint_pairs.append( + ((p0[0], p0[1]), (p1[0], p1[1]))) + + endpoint_pairs.sort( + key=lambda pair: -( + abs(pair[1][0] - pair[0][0]) + + abs(pair[1][1] - pair[0][1]))) + promoted = None + seen_ranges = set() + for (ua, va), (ub, vb) in endpoint_pairs: + if ub < ua: + ua, ub, va, vb = ub, ua, vb, va + if abs(ub - ua) <= uv_eps or abs(vb - va) <= uv_eps: + continue + key = tuple(round(x, 15) for x in (ua, ub, va, vb)) + if key in seen_ranges: + continue + seen_ranges.add(key) + if _overlap_mapping_is_identity( + C1_orig, C2_orig, (ua, ub), (va, vb), rational): + promoted = (ua, ub, va, vb) + break + + if promoted is not None: + ua, ub, va, vb = promoted overlaps.append({ "boundary_zeros": cls.boundary_zeros, "overlap_endpoints": cls.overlap_endpoints, - "u_range": (0.0, 1.0), - "v_range": (0.0, 1.0), + "u_range": (float(ua), float(ub)), + "v_range": (float(va), float(vb)), + "certification": "exact", }) overlap_found = True + # Ledger L47 (user decision 2026-07-12): residual-certified overlap tier. + # Coincidence at modeling tolerance is a real overlap even when no exact + # affine identity exists — near-coincident pairs (imported geometry) and + # same-locus non-affine reparameterizations. The certificate inverts + # dense samples across a domain-end-pinned span (residual <= atol, + # monotone pairing) and REFUSES to merge crossing structure inside the + # band (transverse-direction flips between gap-scale samples), + # returning those brackets for strict isolated-root certification. + residual_band_evidence = False + uncertified_span_evidence = None + interior_bracket_hits = [] + if not overlap_found and cells.remaining > 0: + cells.spend(1) + tol_overlap, tol_brackets, residual_band_evidence, \ + uncertified_span_evidence = _tolerance_overlap_certificate( + C1_orig, C2_orig, atol, rational, ptol_u, ptol_v) + if tol_overlap is not None: + overlaps.append(tol_overlap) + overlap_found = True + for u_seed, v_seed in tol_brackets: + polished = _strict_polish_ccx( + C1_orig, C2_orig, u_seed, v_seed, rational, + component_scale=component_scale) + if polished is None: + continue + u_sol, v_sol, point = polished + if overlap_found: + lo, hi = overlaps[-1]["u_range"] + if min(lo, hi) - ptol_u <= u_sol <= max(lo, hi) + ptol_u: + continue + interior_bracket_hits.append( + (float(u_sol), float(v_sol), point)) + + # A tolerant OVERLAP classification (or residual-gate coincidence + # evidence) whose certificates all failed is often a broad, near-zero + # distance valley. An unrestricted Phase-2 subdivision of that valley + # is both expensive and incapable of turning the rejected candidate + # into a sound public overlap. Give the isolated-root fallback a + # separate, bounded allowance and report a partial result if that + # allowance cannot certify the remaining cells. + non_affine_overlap_fallback = ( + (cls.kind == OVERLAP or residual_band_evidence) + and not overlap_found) + non_affine_overlap_cells_remaining = ( + cells.tier(2_000) + if non_affine_overlap_fallback else None + ) + + def _finalize(topology_complete=True): + # Typed L47 outcome, mirroring CSX's L42 export: when the overlap- + # class structure could not be certified AND the bounded fallback + # could not discretize it, name the span instead of billing the + # failure to the budget with topology claimed complete. + structural = (non_affine_overlap_fallback and budget_exhausted + and not overlap_found) + res = _result(isolated, overlaps, + topology_complete=topology_complete and not structural) + if structural: + span = uncertified_span_evidence or (0.0, 1.0) + res["uncertified_overlap_span"] = ( + float(span[0]), float(span[1])) + return res + # 1c. Classify boundary hits: overlap endpoints go into the overlap, # remaining boundary hits become isolated intersections. if overlap_found and overlaps: ovl = overlaps[-1] - u_lo_ovl = min(ovl["u_range"]) - u_hi_ovl = max(ovl["u_range"]) + u_start_ovl, u_end_ovl = ovl["u_range"] + v_start_ovl, v_end_ovl = ovl["v_range"] + u_lo_ovl = min(u_start_ovl, u_end_ovl) + u_hi_ovl = max(u_start_ovl, u_end_ovl) for u_bz, v_bz, pt in boundary_hits: - if not (u_lo_ovl-ptol_u <= u_bz <= u_hi_ovl + ptol_u): + on_overlap = False + if u_lo_ovl - ptol_u <= u_bz <= u_hi_ovl + ptol_u: + lam = ((u_bz - u_start_ovl) + / (u_end_ovl - u_start_ovl)) + v_expected = ((1.0 - lam) * v_start_ovl + + lam * v_end_ovl) + if ovl.get("certification") == "tolerance": + # The pairing of a residual-certified overlap need not + # be affine — locate the expected partner parameter by + # inversion (seeded by the affine guess). + v_expected, _r_inv = _invert_point_on_curve( + C2_orig, + eval_curve(C1_orig, float(u_bz), rational=rational), + v_expected, rational) + on_overlap = abs(v_bz - v_expected) <= 2.0 * ptol_v + if not on_overlap: if not _is_duplicate(isolated, pt, atol): + if len(isolated) >= max_results: + budget_exhausted = True + break isolated.append({"u": u_bz, "v": v_bz, "point": pt}) else: for u_bz, v_bz, pt in boundary_hits: if not _is_duplicate(isolated, pt, atol): + if len(isolated) >= max_results: + budget_exhausted = True + break isolated.append({"u": u_bz, "v": v_bz, "point": pt}) + # Interior crossings certified from the residual tier's rejected + # brackets (crossing structure inside a tolerance band is topology, + # never merged — CSX invariant, 1-D form). + for u_hit, v_hit, pt in interior_bracket_hits: + if not _is_duplicate(isolated, pt, atol): + if len(isolated) >= max_results: + budget_exhausted = True + break + isolated.append({"u": u_hit, "v": v_hit, "point": pt}) + + if budget_exhausted: + return _finalize() + # =================================================================== # PHASE 2: Isolated intersection search # =================================================================== @@ -491,20 +1230,37 @@ def bez_ccx( continue # Run Phase 2 on this sub-interval × full v - phase2_iso = _phase2_ccx( + if (cells.remaining <= 0 or len(isolated) >= max_results + or (non_affine_overlap_fallback + and non_affine_overlap_cells_remaining <= 0)): + budget_exhausted = True + break + phase2_cell_limit = cells.remaining + if non_affine_overlap_fallback: + phase2_cell_limit = min( + phase2_cell_limit, non_affine_overlap_cells_remaining) + phase2_iso, phase2_exhausted, cells_used = _phase2_ccx( F_sub, C1_sub, C2, C1_orig, C2_orig, u_lo, u_hi, 0.0, 1.0, atol, rational, ptol_u, ptol_v, known_points=isolated, - max_depth=max_depth, max_cells=max_cells, + max_depth=max_depth, max_cells=phase2_cell_limit, + max_results=max_results - len(isolated), ) + cells.spend(cells_used) + if non_affine_overlap_fallback: + non_affine_overlap_cells_remaining -= cells_used + budget_exhausted = budget_exhausted or phase2_exhausted for iso in phase2_iso: iso.pop('_micro', None) if not _is_duplicate(isolated, iso["point"], atol): isolated.append(iso) - return {"isolated": isolated, "overlaps": overlaps} + if non_affine_overlap_fallback and phase2_exhausted: + break + + return _finalize() def _is_duplicate(isolated, pt, atol): diff --git a/mmcore/numeric/intersection/ccx/_nccx4.py b/mmcore/numeric/intersection/ccx/_nccx4.py index ea873bde..74d107cc 100644 --- a/mmcore/numeric/intersection/ccx/_nccx4.py +++ b/mmcore/numeric/intersection/ccx/_nccx4.py @@ -41,6 +41,45 @@ def _map_local_to_global(u_loc, v_loc, u0, u1, v0, v1): return (u0 + (u1 - u0) * u_loc, v0 + (v1 - v0) * v_loc) +_BEZIER_LIMIT_KWARGS = ('max_depth',) +_DEFAULT_MAX_CELLS = 100_000 +_DEFAULT_MAX_RESULTS = 4_096 + + +def _bezier_limit_kwargs(kwargs): + """Forward only the bounded-solver controls understood by bez_ccx v4.""" + return {name: kwargs[name] for name in _BEZIER_LIMIT_KWARGS + if name in kwargs} + + +# Shared aggregate-status ledger (ledger L52): the implementation lives in +# `_adapter_status`; these thin wrappers keep the adapter's historical +# private names and message texts. +from mmcore.numeric.intersection._adapter_status import ( + consume_bezier_status as _shared_consume_bezier_status, + mark_incomplete as _mark_incomplete, + new_status as _new_status, + reject_unknown_kwargs as _reject_unknown_kwargs, + remaining_allowances as _remaining_allowances, +) + + +def _consume_bezier_status( + result, status, context, return_status, cell_allowance, result_allowance, +): + return _shared_consume_bezier_status( + result, status, + incomplete_message=( + f"{context}: incomplete Bezier CCX result " + "(budget exhausted or boundary topology incomplete); " + "pass return_status=True to receive explicit partial status"), + return_status=return_status, + cell_allowance=cell_allowance, + result_allowance=result_allowance, + list_keys=('isolated', 'overlaps'), + ) + + # --------------------------------------------------------------------------- # Parametric deduplication # --------------------------------------------------------------------------- @@ -149,7 +188,10 @@ def _dedup_isolated_pair(entries, curve1, curve2, tol): # nurbs_ccx: two-curve intersection # --------------------------------------------------------------------------- from mmcore.geom._nurbs_eval import evaluate_nurbs_curve -def nurbs_ccx(curve1, curve2, tol: float = 1e-3, **kwargs): +def nurbs_ccx( + curve1, curve2, tol: float = 1e-3, *, return_status: bool = True, + **kwargs, +): """Find all intersections between two NURBS curves. Parameters @@ -164,7 +206,17 @@ def nurbs_ccx(curve1, curve2, tol: float = 1e-3, **kwargs): Structured array with fields 'u', 'v', 'point'. overlaps : ndarray or None Structured array with fields 'u', 'v', 'point' (endpoint pairs). + status : dict + Third value, returned by default: aggregate bounded-solver + diagnostics; read ``status['complete']`` before trusting the + output as the whole truth (ledger L41 — the former + raise-on-incomplete default crashed production callers on + near-coincident input). Pass ``return_status=False`` for the + legacy two-value shape, which raises ``RuntimeError`` on any + incomplete sub-solve instead (fail-fast opt-in). """ + _reject_unknown_kwargs( + "nurbs_ccx", kwargs, ("max_cells", "max_results") + _BEZIER_LIMIT_KWARGS) dim = max(crv.control_points.shape[1] for crv in (curve1, curve2)) if isinstance(curve1, NURBSCurve): curve1 = _nurbs_to_tuple(curve1) @@ -182,8 +234,24 @@ def nurbs_ccx(curve1, curve2, tol: float = 1e-3, **kwargs): raw_isolated = [] raw_overlaps_u, raw_overlaps_v, raw_overlaps_xyz = [], [], [] - - for a, b in bvh_intersect(bvh1, bvh2, exact=False): + candidates = list(bvh_intersect(bvh1, bvh2, exact=False)) + # The DEFAULT aggregate allowance scales with the candidate-pair count: + # a flat per-call total that a handful of ordinary rational span pairs + # can exhaust (~25k cells each, ledger L41 / review finding 2) is a + # mispriced exchange rate, not a safety property. The scaled default + # matches the pre-aggregate per-pair budgets in the worst case while + # staying one SHARED ledger (a hog pair may borrow from cheap ones). + # An explicit ``max_cells`` remains an absolute promise. + aggregate_max_cells = kwargs.get('max_cells') + if aggregate_max_cells is None: + aggregate_max_cells = _DEFAULT_MAX_CELLS * max(1, len(candidates)) + aggregate_max_cells = max(0, int(aggregate_max_cells)) + aggregate_max_results = max( + 0, int(kwargs.get('max_results', _DEFAULT_MAX_RESULTS))) + status = _new_status(aggregate_max_cells, aggregate_max_results) + bezier_kwargs = _bezier_limit_kwargs(kwargs) + + for a, b in candidates: _c1 = curves1[a.object] _c2 = curves2[b.object] @@ -194,7 +262,24 @@ def nurbs_ccx(curve1, curve2, tol: float = 1e-3, **kwargs): pts1 = _c1.control_points pts2 = _c2.control_points - result = bez_ccx_v4(pts1, pts2, atol=tol, rational=rational) + context = (f"nurbs_ccx spans {_c1.interval()} x " + f"{_c2.interval()}") + remaining_cells, remaining_results = _remaining_allowances(status) + if remaining_cells <= 0 or remaining_results <= 0: + _mark_incomplete( + status, context, return_status, + "aggregate CCX cell/result budget exhausted") + break + call_kwargs = dict(bezier_kwargs) + call_kwargs['max_cells'] = remaining_cells + call_kwargs['max_results'] = remaining_results + result = bez_ccx_v4( + pts1, pts2, atol=tol, rational=rational, **call_kwargs, + ) + result, stop_after_span = _consume_bezier_status( + result, status, context, return_status, + remaining_cells, remaining_results, + ) for inter in result['isolated']: u_glob, v_glob = _map_local_to_global( @@ -219,6 +304,8 @@ def nurbs_ccx(curve1, curve2, tol: float = 1e-3, **kwargs): pt0 = eval_curve(pts1, ur[0], rational=rational) pt1 = eval_curve(pts1, ur[1], rational=rational) raw_overlaps_xyz.append([pt0, pt1]) + if stop_after_span: + break # Dedup isolated deduped = _dedup_isolated_pair(raw_isolated, curve1, curve2, tol) @@ -240,6 +327,8 @@ def nurbs_ccx(curve1, curve2, tol: float = 1e-3, **kwargs): overlaps['v'] = raw_overlaps_v overlaps['point'] = raw_overlaps_xyz + if return_status: + return isolated, overlaps, status return isolated, overlaps @@ -251,6 +340,8 @@ def nurbs_ccx_multiple( curves: list[NURBSCurveTuple], tol: float = 1e-3, self_intersections: bool = False, + *, + return_status: bool = True, **kwargs, ): """Find all pairwise intersections among multiple NURBS curves. @@ -269,7 +360,15 @@ def nurbs_ccx_multiple( Structured array with 'u', 'v', 'point', 'curve1_i', 'curve2_i'. overlaps : ndarray or None Structured array with 'u', 'v', 'point', 'curve1_i', 'curve2_i'. + status : dict + Third value, returned by default; read ``status['complete']`` + before trusting the output as the whole truth. Pass + ``return_status=False`` for the legacy two-value shape, which + raises ``RuntimeError`` on any incomplete Bezier pair instead. """ + _reject_unknown_kwargs( + "nurbs_ccx_multiple", kwargs, + ("max_cells", "max_results") + _BEZIER_LIMIT_KWARGS) dim = max(crv.control_points.shape[1] for crv in curves) # Decompose all curves into Bezier segments, build segment map @@ -295,6 +394,15 @@ def nurbs_ccx_multiple( raw_isolated = [] raw_overlaps = [] + # Candidate-scaled default allowance — see nurbs_ccx (ledger L41). + aggregate_max_cells = kwargs.get('max_cells') + if aggregate_max_cells is None: + aggregate_max_cells = _DEFAULT_MAX_CELLS * max(1, len(int_candidates)) + aggregate_max_cells = max(0, int(aggregate_max_cells)) + aggregate_max_results = max( + 0, int(kwargs.get('max_results', _DEFAULT_MAX_RESULTS))) + status = _new_status(aggregate_max_cells, aggregate_max_results) + bezier_kwargs = _bezier_limit_kwargs(kwargs) for first, second in int_candidates: segm1_i = bvh.nodes[first].object @@ -327,7 +435,25 @@ def nurbs_ccx_multiple( pts1 = segm1.control_points pts2 = segm2.control_points - result = bez_ccx_v4(pts1, pts2, atol=tol, rational=rational) + context = ( + f"nurbs_ccx_multiple curves {curve1_i} x {curve2_i}, " + f"spans {segm1.interval()} x {segm2.interval()}") + remaining_cells, remaining_results = _remaining_allowances(status) + if remaining_cells <= 0 or remaining_results <= 0: + _mark_incomplete( + status, context, return_status, + "aggregate CCX cell/result budget exhausted") + break + call_kwargs = dict(bezier_kwargs) + call_kwargs['max_cells'] = remaining_cells + call_kwargs['max_results'] = remaining_results + result = bez_ccx_v4( + pts1, pts2, atol=tol, rational=rational, **call_kwargs, + ) + result, stop_after_span = _consume_bezier_status( + result, status, context, return_status, + remaining_cells, remaining_results, + ) for inter in result['isolated']: u_glob, v_glob = _map_local_to_global( @@ -357,6 +483,8 @@ def nurbs_ccx_multiple( 'point': [pt0, pt1], 'curve1_i': curve1_i, 'curve2_i': curve2_i, }) + if stop_after_span: + break # Dedup isolated using parametric tolerances deduped = _dedup_isolated(raw_isolated, curves, tol) @@ -382,4 +510,6 @@ def nurbs_ccx_multiple( overlaps['curve1_i'] = [e['curve1_i'] for e in raw_overlaps] overlaps['curve2_i'] = [e['curve2_i'] for e in raw_overlaps] + if return_status: + return isolated, overlaps, status return isolated, overlaps diff --git a/mmcore/numeric/intersection/ccx/ccx.py b/mmcore/numeric/intersection/ccx/ccx.py index 93888482..ece81f5f 100644 --- a/mmcore/numeric/intersection/ccx/ccx.py +++ b/mmcore/numeric/intersection/ccx/ccx.py @@ -106,7 +106,19 @@ def ccx(curve1, curve2, tol: float = 0.001): """ if isinstance(curve1, (NURBSCurve,NURBSCurveTuple)) and isinstance(curve2, (NURBSCurve,NURBSCurveTuple)): - return nurbs_ccx(curve1, curve2, tol=tol) + # Ledger L41: nurbs_ccx now always returns status instead of + # raising on incomplete sub-solves (the raise crashed this public + # path on near-coincident input). ccx keeps its two-value shape; + # partiality is surfaced as a warning — call nurbs_ccx directly for + # the status dict. + isolated, overlaps, status = nurbs_ccx(curve1, curve2, tol=tol) + if not status['complete']: + import warnings + warnings.warn( + "ccx: incomplete NURBS CCX result (bounded solve was " + "truncated); call nurbs_ccx(...) for the status dict", + RuntimeWarning, stacklevel=2) + return isolated, overlaps if hasattr(curve1, "implicit") and hasattr(curve2, "evaluate"): diff --git a/mmcore/numeric/intersection/csx/_bez_csx4.py b/mmcore/numeric/intersection/csx/_bez_csx4.py index a812a4c8..176468d9 100644 --- a/mmcore/numeric/intersection/csx/_bez_csx4.py +++ b/mmcore/numeric/intersection/csx/_bez_csx4.py @@ -10,11 +10,14 @@ """ from __future__ import annotations +from math import comb + import numpy as np from .._bezier_common import _compute_remaining_intervals from mmcore.numeric.aabb import aabb_offset from mmcore.numeric._aabb import aabb_intersect, aabb +from mmcore.numeric._work_budget import DownCounter from mmcore.numeric.bern import ( de_casteljau_split_nd, bernstein_boundary_nd, bernstein_partial_derivative_coeffs, @@ -22,7 +25,8 @@ from mmcore.numeric.bern_sq_dist import curve_surface_distance_squared_net_homog from mmcore.numeric.intersection._bezier_common import ( extract_weights, eval_curve, eval_surface, eval_curve_d1, eval_surface_d1, - newton_csx, + newton_csx, bernstein_product_1d, subdivide_curve, subdivide_sq_dist_net, + restrict_net_axis, restrict_net_axis_v, geometry_collapsed, ) from mmcore.numeric.intersection.ccx._bez_ccx4 import bez_ccx as bez_ccx_v4 from mmcore.numeric.intersection._sq_dist_classify import ( @@ -35,16 +39,8 @@ # Helpers # --------------------------------------------------------------------------- -def _subdivide_curve(ctrl, t=0.5): - n = ctrl.shape[0] - 1 - tmp = ctrl.copy() - left = [tmp[0].copy()] - right_rev = [tmp[n].copy()] - for r in range(1, n + 1): - tmp[: n + 1 - r] = (1.0 - t) * tmp[: n + 1 - r] + t * tmp[1 : n + 2 - r] - left.append(tmp[0].copy()) - right_rev.append(tmp[n - r].copy()) - return np.array(left), np.array(right_rev[::-1]) +# L52 slice 7: shared implementation in _bezier_common (verbatim move). +_subdivide_curve = subdivide_curve def _subdivide_surface(ctrl, axis, t=0.5): @@ -61,10 +57,8 @@ def _subdivide_surface(ctrl, axis, t=0.5): return left, right -def _subdivide_sq_dist_net(F, axis, t=0.5): - Fv = F[..., np.newaxis] - left_v, right_v = de_casteljau_split_nd(Fv, axis=axis, t=t) - return left_v[..., 0], right_v[..., 0] +# L52 slice 7: shared implementation in _bezier_common (verbatim move). +_subdivide_sq_dist_net = subdivide_sq_dist_net def _subdivide_surface_weights(sw, axis, t=0.5): @@ -109,27 +103,61 @@ def _residual_vec_net(C, S, rational): def _residual_excludes_zero(G_cell): - """True if some component's Bernstein hull excludes 0 → no zero of G.""" + """True if some component's Bernstein hull excludes 0 → no zero of G. + + L52 slice 6c: exclusion only beyond the L1 roundoff margin + ``128·ε·max|coeff|`` (§4 invariant — margins make exclusion stricter, + the sound direction; ccx's identity-side twin carries a depth-scaled + margin, this was the one unmargined sign prune left). Fixture-first + measurement: 240 exact-Fraction restriction-chain comparisons on + tangential-graze nets produced 0 wrongful exclusions and 0 exclusions + within 1e-12·scale of the boundary, so the margin costs nothing in + practice — it is insurance for restriction chains outside that family. + """ + eps = float(np.finfo(np.float64).eps) for c in range(3): comp = G_cell[..., c] - if float(comp.min()) > 0.0 or float(comp.max()) < 0.0: + margin = 128.0 * eps * float(np.abs(comp).max()) + if float(comp.min()) > margin or float(comp.max()) < -margin: return True return False -def _restrict_net_axis_v(Fv, axis, lo, hi, cell_lo, cell_hi): - """Restrict a Bernstein net WITH a trailing value dim along one axis.""" - span = cell_hi - cell_lo - if span < 1e-30: - return Fv - frac_lo = (lo - cell_lo) / span - frac_hi = (hi - cell_lo) / span - if frac_lo > 1e-12: - _, Fv = de_casteljau_split_nd(Fv, axis=axis, t=frac_lo) - if frac_hi < 1.0 - 1e-12: - frac_hi_rescaled = (frac_hi - frac_lo) / (1.0 - frac_lo) if frac_lo > 1e-12 else frac_hi - Fv, _ = de_casteljau_split_nd(Fv, axis=axis, t=frac_hi_rescaled) - return Fv +def _residual_aligned_excludes_zero(G_cell): + """Geometry-ALIGNED zero exclusion (ledger L60). + + The axis-component test above converges linearly in clearance but only + along world axes: when the residual direction is diagonal, every axis + component mixes the LARGE tangential variation with the SMALL + clearance and nothing excludes until cells shrink to clearance scale + (measured: 28,961 cells proving one near-band pair empty). Projecting + the net onto its own mean direction asks the same question in the + geometry's frame: ``dot(G, n_hat)`` is an EXACT Bernstein net (a fixed + linear combination of the exactly-built component nets), so a + margined hull exclusion on it proves ``dot(G, n_hat) != 0`` and hence + ``G != 0`` everywhere in the cell — sound for ANY fixed direction; the + mean maximizes the chance of clearing (same pair: 511 nodes, ~1000x). + The 128·ε·max|coeff| L1 margin covers the build, combination and + restriction roundoff (the 6c insurance class; margins make exclusion + stricter, the sound direction). + """ + flat = G_cell.reshape(-1, 3) + n_hat = flat.mean(axis=0) + n_len = float(np.linalg.norm(n_hat)) + if n_len <= 0.0 or not np.isfinite(n_len): + return False + n_hat = n_hat / n_len + scalar = (G_cell[..., 0] * n_hat[0] + + G_cell[..., 1] * n_hat[1] + + G_cell[..., 2] * n_hat[2]) + margin = (128.0 * float(np.finfo(np.float64).eps) + * float(np.abs(scalar).max())) + return bool(float(scalar.min()) > margin + or float(scalar.max()) < -margin) + + +# L52 slice 7: shared implementation in _bezier_common (verbatim move). +_restrict_net_axis_v = restrict_net_axis_v def _compute_param_tols_csx(C, S, atol, rational): @@ -180,6 +208,479 @@ def _csx_eff_atol(C, S, t, u, v, atol, rational): return atol * max(sin_ang, _CSX_SIN_ANG_FLOOR) +def _cartesian_controls_for_exactness(ctrl, rational): + """Return finite Cartesian controls, or ``None`` for invalid weights.""" + ctrl = np.asarray(ctrl, dtype=np.float64) + if rational: + weights = ctrl[..., -1:] + if (not np.all(np.isfinite(weights)) + or np.any(weights == 0.0)): + return None + points = ctrl[..., :-1] / weights + else: + points = ctrl + if not np.all(np.isfinite(points)): + return None + return points + + +def _center_homogeneous_for_exactness(ctrl, rational, origin): + """Translate homogeneous controls by a common Cartesian origin. + + Normalize once before the homogeneous translation to avoid overflow in + ``origin * weight``, then normalize the translated net again for stable + evaluation. Both normalizations are one common nonzero homogeneous + factor and therefore leave the represented geometry unchanged. + """ + ctrl = np.asarray(ctrl, dtype=np.float64) + if rational: + homog = ctrl.copy() + else: + homog = np.concatenate( + [ctrl, np.ones(ctrl.shape[:-1] + (1,), dtype=np.float64)], + axis=-1) + scale = float(np.max(np.abs(homog))) + if not np.isfinite(scale) or scale <= 0.0: + return None + homog /= scale + homog[..., :-1] -= ( + np.asarray(origin, dtype=np.float64) * homog[..., -1:]) + local_scale = float(np.max(np.abs(homog))) + if not np.isfinite(local_scale) or local_scale <= 0.0: + return None + return np.ascontiguousarray(homog / local_scale) + + +def _strict_csx_root_tol(C, S, rational): + """Translation-invariant context for exact root certification.""" + c_pts = _cartesian_controls_for_exactness(C, rational) + s_pts = _cartesian_controls_for_exactness(S, rational) + if c_pts is None or s_pts is None: + return None + origin = np.asarray(c_pts).reshape(-1, 3)[0].copy() + c_centered = _center_homogeneous_for_exactness(C, rational, origin) + s_centered = _center_homogeneous_for_exactness(S, rational, origin) + if c_centered is None or s_centered is None: + return None + c_local = c_centered[..., :-1] / c_centered[..., -1:] + s_local = s_centered[..., :-1] / s_centered[..., -1:] + points = np.vstack([ + np.asarray(c_local).reshape(-1, 3), + np.asarray(s_local).reshape(-1, 3), + ]) + component_scale = np.max(np.abs(points), axis=0) + return c_centered, s_centered, component_scale + + +def _strict_csx_residual_ok(C, S, t, u, v, rational, strict_context): + """Componentwise floating envelope for exact curve/surface equality.""" + if strict_context is None: + pc = eval_curve(C, float(t), rational=rational) + ps = eval_surface(S, float(u), float(v), rational=rational) + return False, pc - ps + c_centered, s_centered, component_scale = strict_context + pc = eval_curve(c_centered, float(t), rational=True) + ps = eval_surface(s_centered, float(u), float(v), rational=True) + if not (np.all(np.isfinite(pc)) and np.all(np.isfinite(ps))): + return False, pc - ps + scale = np.maximum( + np.asarray(component_scale, dtype=np.float64), + np.maximum(np.abs(pc), np.abs(ps))) + degree_factor = max( + 1, len(C) + int(S.shape[0]) + int(S.shape[1])) + bound = (32.0 * degree_factor * np.finfo(np.float64).eps) * scale + residual = pc - ps + return bool(np.all(np.abs(residual) <= bound)), residual + + +def _polish_csx_root(C, S, t, u, v, rational, strict_tol): + """Unbounded polish plus a strict zero certificate. + + A cell-bounded Newton may stop against a cutout wall with a zero step + while merely lying inside the geometric tolerance valley. Such a stall + is useful for subdivision guidance but is not a distinct root. Re-polish + in the full parameter domain and only type the result when the actual + residual reaches the roundoff-scale root tolerance. + """ + root_ok, G0 = _strict_csx_residual_ok( + C, S, t, u, v, rational, strict_tol) + if root_ok: + return float(t), float(u), float(v), G0, True + + if strict_tol is None: + polish_C, polish_S, polish_rational = C, S, rational + else: + polish_C, polish_S, _component_scale = strict_tol + polish_rational = True + t, u, v, G, _ = newton_csx( + polish_C, polish_S, t, u, v, + rational=polish_rational, bounds=None) + ok, G = _strict_csx_residual_ok( + C, S, t, u, v, rational, strict_tol) + return float(t), float(u), float(v), G, bool(ok) + + +def _polish_csx_boundary_root( + C, S, bz, t, u, v, rational, strict_tol, max_iter=48, +): + """Polish a face candidate while preserving its certified boundary. + + A boundary zero has one parameter fixed exactly at 0 or 1. Letting the + ordinary three-variable Newton move that coordinate can turn an exterior + root into an in-domain endpoint (the former case13 false positive), and + it perturbs exact overlap endpoints just far enough to defeat their + coefficient identity. Solve the overdetermined three-residual/two-free- + parameter problem instead, then apply the same strict equality test. + """ + params = np.array([t, u, v], dtype=np.float64) + params[bz.axis] = 0.0 if bz.side == 0 else 1.0 + free = [axis for axis in (0, 1, 2) if axis != bz.axis] + if strict_tol is None: + polish_C, polish_S, polish_rational = C, S, rational + else: + polish_C, polish_S, _component_scale = strict_tol + polish_rational = True + + for _ in range(max(0, int(max_iter))): + ok, G = _strict_csx_residual_ok( + C, S, *params, rational, strict_tol) + if ok: + return (*map(float, params), G, True) + + _pc, cd = eval_curve_d1( + polish_C, params[0], rational=polish_rational) + _ps, su, sv = eval_surface_d1( + polish_S, params[1], params[2], rational=polish_rational) + J = np.column_stack([cd, -su, -sv])[:, free] + try: + delta = np.linalg.lstsq(J, -G, rcond=None)[0] + except np.linalg.LinAlgError: + break + if not np.all(np.isfinite(delta)): + break + + old_norm = float(np.linalg.norm(G)) + accepted = False + step = 1.0 + for _ls in range(16): + candidate = params.copy() + candidate[free] = np.clip( + candidate[free] + step * delta, 0.0, 1.0) + candidate[bz.axis] = 0.0 if bz.side == 0 else 1.0 + pc = eval_curve( + polish_C, candidate[0], rational=polish_rational) + ps = eval_surface( + polish_S, candidate[1], candidate[2], + rational=polish_rational) + if float(np.linalg.norm(pc - ps)) < old_norm: + params = candidate + accepted = True + break + step *= 0.5 + if not accepted: + break + + ok, G = _strict_csx_residual_ok( + C, S, *params, rational, strict_tol) + return (*map(float, params), G, bool(ok)) + + +def _restrict_bezier_axis_ordered(ctrl, axis, a, b): + """Restrict a tensor-product Bezier net to ordered interval a -> b.""" + a = float(np.clip(a, 0.0, 1.0)) + b = float(np.clip(b, 0.0, 1.0)) + reverse = b < a + lo, hi = (b, a) if reverse else (a, b) + out = np.asarray(ctrl, dtype=np.float64) + if hi < 1.0: + out, _ = de_casteljau_split_nd(out, axis=axis, t=hi) + if lo > 0.0: + rel = lo / max(hi, np.finfo(np.float64).tiny) + _, out = de_casteljau_split_nd(out, axis=axis, t=rel) + if reverse: + out = np.flip(out, axis=axis).copy() + return out + + +def _surface_affine_path_homogeneous(S_h, u0, v0, u1, v1): + """Homogeneous Bezier curve S(u(lambda), v(lambda)), affine in lambda.""" + patch = _restrict_bezier_axis_ordered(S_h, 0, u0, u1) + patch = _restrict_bezier_axis_ordered(patch, 1, v0, v1) + p = patch.shape[0] - 1 + q = patch.shape[1] - 1 + degree = p + q + out = np.zeros((degree + 1, patch.shape[-1]), dtype=np.float64) + for i in range(p + 1): + for j in range(q + 1): + k = i + j + out[k] += (comb(p, i) * comb(q, j) / comb(degree, k)) * patch[i, j] + return out + + +# L52 slice 6a: the shared exact product. NOTE: the shared version computes +# the comb factor fully in longdouble (the old copy here rounded it through +# a Python float64 first) — identical on macOS (longdouble aliases float64), +# a last-ulp factor upgrade on true-80-bit platforms, absorbed by the +# certificate envelope. +_bernstein_product_1d = bernstein_product_1d + + +def _certify_affine_csx_overlap(C, S, a, b, rational): + """Prove the affine endpoint correspondence is an exact overlap. + + The public overlap schema ships only endpoint ranges, so its implied + correspondence is affine in one common parameter. Certify that exact + path by checking every coefficient of the homogeneous residual + C_xyz*W_S - S_xyz*W_C, not a finite set of tolerance samples. + """ + c_pts = _cartesian_controls_for_exactness(C, rational) + s_pts = _cartesian_controls_for_exactness(S, rational) + if c_pts is None or s_pts is None: + return False + origin = np.asarray(c_pts).reshape(-1, 3)[0] + C_h = _center_homogeneous_for_exactness(C, rational, origin) + S_h = _center_homogeneous_for_exactness(S, rational, origin) + if C_h is None or S_h is None: + return False + # The restricted affine path can contain an exactly-zero coordinate + # produced by cancellation of nonzero source controls (for example the + # y=0 line through a plane whose y controls are -0.5 and 1.5). A + # roundoff bound based only on the already-cancelled product values is + # then identically zero and rejects the arithmetic residue of the + # restriction itself. Retain a per-coordinate source-operation scale; + # genuinely absent coordinates still keep a zero floor, so a one-sided + # offset cannot hide here. + c_xyz_scale = np.max(np.abs(C_h[:, :-1]), axis=0) + s_xyz_scale = np.max(np.abs(S_h[..., :-1]), axis=(0, 1)) + c_weight_scale = float(np.max(np.abs(C_h[:, -1]))) + s_weight_scale = float(np.max(np.abs(S_h[..., -1]))) + source_product_scale = ( + c_xyz_scale * s_weight_scale + + s_xyz_scale * c_weight_scale) + ta, ua, va = (float(x) for x in a) + tb, ub, vb = (float(x) for x in b) + curve_h = _restrict_bezier_axis_ordered(C_h, 0, ta, tb) + surf_h = _surface_affine_path_homogeneous(S_h, ua, va, ub, vb) + left = _bernstein_product_1d( + curve_h[:, :-1], surf_h[:, -1:]) + right = _bernstein_product_1d( + surf_h[:, :-1], curve_h[:, -1:]) + residual = np.abs(left - right) + # L52 slice 6b — the EXPLICIT envelope reconciliation (review §10 / + # ledger): this certificate and ccx's `_overlap_mapping_is_identity` + # certify the same class ("residual explainable by roundoff of + # exactly-coincident sources") and now share ccx's derived two-term + # structure instead of this module's former folded + # `4096·n₁n₂·ε_f64·(|l|+|r|+src)` bound: + # - the OPERATOR term scales with the eps of the dtype the products + # actually run in (longdouble; aliases float64 on macOS, where the + # computation genuinely is float64 — platform-consistent either way) + # and with n₁·n₂ accumulation terms; + # - the SOURCE term scales with ε_float64 (sources are float64 on + # every platform) and n₁+n₂ restriction/degree-reduction steps; the + # 8192 constant is the family ccx's float-built-subcurve fixture + # calibrated from below. + # Net effect is a measured tightening at gate degrees (in-axis + # acceptance boundary (3.0, 3.5]e-11 -> (2.6, 3.0]e-11 on the + # cubic/bilinear probe; single-axis offsets were and remain rejected + # at every magnitude by the per-coordinate source scale above) — the + # SOUND direction: borderline candidates fall to the L42 typed + # uncertified_overlap_span fallback instead of certifying. + op_factor = (np.longdouble(64) + * np.longdouble(max(1, len(curve_h))) + * np.longdouble(max(1, len(surf_h))) + * np.longdouble(np.finfo(np.longdouble).eps)) + source_factor = (np.longdouble(8192) + * np.longdouble(max(1, len(curve_h) + len(surf_h))) + * np.longdouble(np.finfo(np.float64).eps)) + roundoff = (op_factor * (np.abs(left) + np.abs(right)) + + source_factor * np.asarray( + source_product_scale, dtype=np.longdouble)[None, :]) + return bool(np.all(np.isfinite(residual)) + and np.all(residual <= roundoff)) + + +def _tolerance_csx_overlap_certificate(C, S, atol, rational, ptol_t, + strict_context, n_samples=65, + on_dense=None): + """Theorem-first curve-on-surface overlap certification (ledger L59). + + USER DECISION 2026-07-12 (rationale in the ledger): two polynomial/ + rational arcs coinciding on any open sub-arc lie on the same algebraic + curve, so a maximal overlap can only terminate at a DOMAIN boundary of + an operand. Numerics therefore only (1) arms on cheap endpoint + evidence, (2) verifies that the span's ends are domain-pinned — the + curve's t-ends on-surface, or points whose surface projection exits + the uv-domain — and (3) samples interior witnesses with a crossing + flip guard; the theorem, not floating point, carries the interior. + Tolerance-coincidence IS coincidence: within-atol membership counts, + and the certification field says 'exact' vs 'tolerance'. + + Returns a list of overlap dicts (possibly several spans: improper + reparameterizations and domain clipping legitimately produce more + than one), or None when nothing is armed/certifiable. The flip guard + preserves the never-merge invariant: a transverse (normal-side) sign + flip between consecutive gap samples is crossing structure and + refuses promotion of that span; sub-atol valley chains fail the + membership march before ever reaching a pinned second end. + """ + diag = float(np.linalg.norm( + np.max(S.reshape(-1, S.shape[-1]), axis=0) + - np.min(S.reshape(-1, S.shape[-1]), axis=0))) or 1.0 + tiny = max(4096.0 * float(np.finfo(np.float64).eps), 1e-12) * max(1.0, diag) + edge_pad = 1e-9 + + def _member(t, seed_uv): + p = eval_curve(C, float(t), rational=rational) + u, v, dist = _project_point_on_surface( + p, S, seed_uv[0], seed_uv[1], atol, rational) + u_cl = min(max(u, 0.0), 1.0) + v_cl = min(max(v, 0.0), 1.0) + if (u_cl, v_cl) != (u, v): + s_pt = eval_surface( + np.asarray(S, dtype=np.float64), u_cl, v_cl, + rational=rational) + dist = float(np.linalg.norm(p - s_pt)) + return p, u_cl, v_cl, float(dist) + + # --- (1) arming: coarse scan for ANY on-surface stretch ------------- + coarse = np.linspace(0.0, 1.0, 17) + seed = (0.5, 0.5) + hits = [] + for t in coarse: + _p, u, v, d = _member(float(t), seed) + if d <= atol: + hits.append(float(t)) + seed = (u, v) + if not hits: + return None + + # A no-hit arming scan costs 17 projections; the dense pass below is + # the expensive part and is billed separately by the caller (a flat + # combined price tripped the §11.5 work-drift gate on nested cut-face + # calls, which routinely arm-and-miss). + if on_dense is not None: + on_dense() + + # --- (2) dense membership + witnesses -------------------------------- + ts = np.linspace(0.0, 1.0, int(n_samples)) + inside = np.zeros(len(ts), dtype=bool) + res = np.zeros(len(ts)) + uvs = np.zeros((len(ts), 2)) + signed = np.zeros(len(ts)) + seed = (0.5, 0.5) + S64 = np.asarray(S, dtype=np.float64) + for k, t in enumerate(ts): + p, u, v, d = _member(float(t), seed) + inside[k] = d <= atol + res[k] = d + uvs[k] = (u, v) + if inside[k]: + seed = (u, v) + s_pt, s_du, s_dv = eval_surface_d1(S64, u, v, rational=rational) + n_vec = np.cross(s_du, s_dv) + n_len = float(np.linalg.norm(n_vec)) + signed[k] = (float(np.dot(p - s_pt, n_vec / n_len)) + if n_len > 0.0 else 0.0) + + # contiguous in-band runs + runs = [] + k = 0 + while k < len(ts): + if inside[k]: + j = k + while j + 1 < len(ts) and inside[j + 1]: + j += 1 + runs.append((k, j)) + k = j + 1 + else: + k += 1 + + def _refine(t_in, t_out, seed_uv): + """Bisect the tolerance boundary between an in-band and an + out-of-band sample.""" + for _ in range(40): + tm = 0.5 * (t_in + t_out) + _p, u, v, d = _member(tm, seed_uv) + if d <= atol: + t_in, seed_uv = tm, (u, v) + else: + t_out = tm + if abs(t_out - t_in) <= max(1e-12, 0.01 * ptol_t): + break + return t_in, seed_uv + + overlaps = [] + for k0, k1 in runs: + if k1 == k0: + # Single-sample runs are the CORNER-CONTACT signature (the + # CCX L47 guard's analog: a graze at one grid node whose + # bisection fringe can exceed 4*ptol_t is still not band + # evidence — measured: a ~4*atol stub at a shared corner + # shipped as a junk overlap branch). A genuine span must be + # in-band across at least one full grid interval. + continue + t_lo, t_hi = float(ts[k0]), float(ts[k1]) + if k0 > 0: + t_lo, _ = _refine(t_lo, float(ts[k0 - 1]), tuple(uvs[k0])) + if k1 < len(ts) - 1: + t_hi, _ = _refine(t_hi, float(ts[k1 + 1]), tuple(uvs[k1])) + if t_hi - t_lo < 4.0 * ptol_t: + continue # too short to claim a 1-D overlap + # (2b) domain pinning: each end must sit at the CURVE's domain end + # or at the uv-domain edge of the surface (projected pin) — an + # interior fade-out on both sides is the offset-twin signature and + # is never promoted (the CCX L47 rule). + def _pinned(t_end, outward): + if t_end <= edge_pad or t_end >= 1.0 - edge_pad: + return True + _p, u, v, _d = _member(t_end, (0.5, 0.5)) + if min(u, 1.0 - u) <= 1e-6 or min(v, 1.0 - v) <= 1e-6: + return True + # The tolerance boundary and the uv-domain exit can COINCIDE + # (measured on user data: d crosses atol at almost the same t + # where the projected path leaves through u=1, so the + # projection AT the refined boundary is still interior). + # Probe one grid step OUTWARD: a domain-clipped span clamps to + # an edge there; a genuine interior fade-out (the offset-twin + # signature, which must stay refused) does not. + t_probe = min(1.0, max(0.0, t_end + outward / (len(ts) - 1.0))) + _p, u, v, _d = _member(t_probe, (u, v)) + return (min(u, 1.0 - u) <= 1e-6 or min(v, 1.0 - v) <= 1e-6) + if not (_pinned(t_lo, -1.0) and _pinned(t_hi, +1.0)): + continue + # (3) flip guard on the gap samples INSIDE the span: root-like + # samples (res <= tiny) are bridged; consecutive gap samples with + # opposite normal-side signs = crossing structure -> refuse. + # END-ADJACENT flips are exempt: a genuine touch AT a pinned span + # end (real-world-inexact data sits above the roundoff floor, so + # bridging cannot cover it) is the span's own endpoint root — the + # theorem terminates the overlap there anyway. INTERIOR flips + # still refuse (the never-merge-crossings invariant). + idx = [k for k in range(k0, k1 + 1) if res[k] > tiny] + flip = any(signed[a] * signed[b] < 0.0 + for a, b in zip(idx, idx[1:]) + if a != k0 and b != k1) + if flip: + continue + span_res = float(res[k0:k1 + 1].max()) + cert = "exact" if span_res <= tiny else "tolerance" + u_rng = (float(uvs[k0:k1 + 1, 0].min()), + float(uvs[k0:k1 + 1, 0].max())) + v_rng = (float(uvs[k0:k1 + 1, 1].min()), + float(uvs[k0:k1 + 1, 1].max())) + overlaps.append({ + "boundary_zeros": [], + "overlap_endpoints": [], + "t_range": (t_lo, t_hi), + "u_range": u_rng, + "v_range": v_rng, + "certification": cert, + "residual_max": span_res, + }) + return overlaps or None + + def _boundary_zero_to_tuv(bz: BoundaryZero, t0: float, t1: float, u0: float, u1: float, @@ -212,11 +713,23 @@ def _boundary_zero_to_tuv(bz: BoundaryZero, return pt[0], pt[1], pt[2] +def _boundary_zero_from_tuv_like(bz: BoundaryZero, t, u, v) -> BoundaryZero: + """Rebuild a boundary record with strict-polished free parameters.""" + values = [float(t), float(u), float(v)] + free = [i for i in (0, 1, 2) if i != bz.axis] + return BoundaryZero( + axis=bz.axis, side=bz.side, + param=values[free[0]], param2=values[free[1]]) + + # --------------------------------------------------------------------------- # CSX boundary analysis: find zeros on the 6 faces of [0,1]^3 # --------------------------------------------------------------------------- -def _find_csx_boundary_zeros(F_3d, C, S, atol,ptol_t,ptol_u,ptol_v ,rational): +def _find_csx_boundary_zeros( + F_3d, C, S, atol, ptol_t, ptol_u, ptol_v, rational, + *, max_cells=50_000, max_results=4_096, +): """Find precise intersection points on the boundary faces of the CSX domain. The 6 faces of [0,1]^3 decompose into two types: @@ -230,14 +743,23 @@ def _find_csx_boundary_zeros(F_3d, C, S, atol,ptol_t,ptol_u,ptol_v ,rational): Extract the surface boundary isocurve (a Bezier curve in 3D) and call bez_ccx(C, iso_curve) to find intersections. - Returns list of BoundaryZero objects with (axis, side, param, param2). + Returns ``(zeros, budget_exhausted, cells_processed)``. When the flag + is true, ``zeros`` is diagnostic-only: the caller must not use a partial + face set to infer overlap topology or cut the Phase-2 domain. """ zeros = [] + cells = DownCounter(max_cells) + exhausted = False F_3d_v = F_3d[..., np.newaxis] # --- Type 1: Curve endpoints (t=0, t=1) --- # Restrict the 3D net to t=0 and t=1 → 2D nets for point-on-surface for t_side in (0, 1): + if cells.remaining <= 0: + exhausted = True + break + # Charge the fixed face analysis/Newton probe itself. + cells.spend(1) face_2d = bernstein_boundary_nd(F_3d_v, axis=0, side=t_side)[..., 0] # Use the 2D classifier (same as CCX) on this face @@ -255,8 +777,22 @@ def _find_csx_boundary_zeros(F_3d, C, S, atol,ptol_t,ptol_u,ptol_v ,rational): # Find precise zeros on the 2D face using the 1D solver # (finds zeros on edges of the face) - face_zeros = _find_precise_bz_2d(face_2d, atol, w_scale) + from mmcore.numeric.intersection._bern_zero_1d import bernstein_zero_budget + with bernstein_zero_budget( + cells.remaining, max(0, max_results - len(zeros)), + ) as zero_budget: + face_zeros = _find_precise_bz_2d(face_2d, atol, w_scale) + cells.spend(zero_budget.nodes) + if zero_budget.exhausted: + exhausted = True + break for bz_2d in face_zeros: + if not isinstance(bz_2d, BoundaryZero): + exhausted = True + break + if len(zeros) >= max_results: + exhausted = True + break # Map 2D face zero to 3D BoundaryZero # bz_2d has axis (0 or 1 in the 2D face) and param # In the 3D net, axis=0 is t, so the face's axes map to u (1) and v (2) @@ -267,6 +803,8 @@ def _find_csx_boundary_zeros(F_3d, C, S, atol,ptol_t,ptol_u,ptol_v ,rational): float(bz_2d.side) if bz_2d.axis == 1 else None ), )) + if exhausted: + break # Also check if the 2D face has a minimum near zero in its interior # (not just on its edges). Use Newton on the point-on-surface problem. @@ -299,7 +837,7 @@ def _find_csx_boundary_zeros(F_3d, C, S, atol,ptol_t,ptol_u,ptol_v ,rational): else: break G_final = eval_surface(S, u_s, v_s, rational=rational) - pt - if float(np.linalg.norm(G_final)) < atol: + if not exhausted and float(np.linalg.norm(G_final)) < atol: # Found a point-on-surface intersection — add as BoundaryZero bz = BoundaryZero(axis=0, side=t_side, param=u_s, param2=v_s) # Check not duplicate @@ -308,7 +846,13 @@ def _find_csx_boundary_zeros(F_3d, C, S, atol,ptol_t,ptol_u,ptol_v ,rational): for z in zeros if z.axis == 0 and z.side == t_side ) if not is_dup: - zeros.append(bz) + if len(zeros) >= max_results: + exhausted = True + else: + zeros.append(bz) + + if exhausted: + return zeros, True, cells.processed # --- Type 2: Surface boundaries (u=0, u=1, v=0, v=1) --- # Extract boundary isocurves and run CCX @@ -336,9 +880,26 @@ def _find_csx_boundary_zeros(F_3d, C, S, atol,ptol_t,ptol_u,ptol_v ,rational): continue # Run CCX between the original curve and this isocurve - ccx_result = bez_ccx_v4(C, iso_curve, atol=atol, rational=rational) + if cells.remaining <= 0: + exhausted = True + break + ccx_result = bez_ccx_v4( + C, iso_curve, atol=atol, rational=rational, + max_cells=cells.remaining, + max_results=max(0, max_results - len(zeros)), + ) + ccx_cells = int(ccx_result.get("cells_processed", 0)) + ccx_cells = min(ccx_cells, cells.remaining) + cells.spend(ccx_cells) + if (ccx_result.get("budget_exhausted", False) + or not ccx_result.get("boundary_topology_complete", True)): + exhausted = True + break for iso in ccx_result['isolated']: + if len(zeros) >= max_results: + exhausted = True + break t_val = iso['u'] # parameter on C s_val = iso['v'] # parameter along the isocurve @@ -358,6 +919,9 @@ def _find_csx_boundary_zeros(F_3d, C, S, atol,ptol_t,ptol_u,ptol_v ,rational): param=t_val, param2=s_val) zeros.append(bz) + if exhausted: + break + # Overlaps from CCX also produce boundary information for ovl in ccx_result['overlaps']: # Overlap endpoints are boundary zeros too @@ -365,11 +929,19 @@ def _find_csx_boundary_zeros(F_3d, C, S, atol,ptol_t,ptol_u,ptol_v ,rational): vr = ovl.get('v_range', (0.0, 1.0)) csx_axis = surf_axis + 1 for t_val, s_val in [(ur[0], vr[0]), (ur[1], vr[1])]: + if len(zeros) >= max_results: + exhausted = True + break bz = BoundaryZero(axis=csx_axis, side=surf_side, param=t_val, param2=s_val) zeros.append(bz) + if exhausted: + break + + if exhausted: + break - return zeros + return zeros, exhausted, cells.processed def _project_point_on_surface(pt, S, u_seed, v_seed, atol, rational, max_it=20): @@ -404,6 +976,47 @@ def _project_point_on_surface(pt, S, u_seed, v_seed, atol, rational, max_it=20): return u, v, dist +def _collapsed_point_surface_membership( + C, S, t, u, v, atol, rational, strict_context, +): + """Certify one representative of a collapsed curve parameter fiber. + + General CSX roots and overlaps use the roundoff-scale certificate in + :func:`_strict_csx_residual_ok`. Collapsed fibers need one additional, + deliberately narrower notion of numerical identity: independently + rounded CAD parameterizations can describe the same singular point with + a few ulps of the *source decimal data* between them (case 14's two + eight-decimal cone nets differ by 1.36e-9 at the common apex). + + The fallback is capped by both the public geometric tolerance and a + 2e-10 relative local-coordinate envelope. It therefore cannot turn an + ordinary tolerance-valley point (for example a 5e-4 offset at + ``atol=1e-3``) into a positive-dimensional solution. + """ + point = eval_curve(C, float(t), rational=rational) + u, v, _dist = _project_point_on_surface( + point, S, u, v, atol, rational, max_it=64) + strict, residual = _strict_csx_residual_ok( + C, S, t, u, v, rational, strict_context) + if strict: + return True, float(u), float(v), residual + + if strict_context is None: + return False, float(u), float(v), residual + c_centered, s_centered, component_scale = strict_context + point_local = eval_curve(c_centered, float(t), rational=True) + surface_local = eval_surface( + s_centered, float(u), float(v), rational=True) + if not (np.all(np.isfinite(point_local)) and + np.all(np.isfinite(surface_local))): + return False, float(u), float(v), residual + scale = np.maximum(1.0, np.asarray(component_scale, dtype=np.float64)) + identity_bound = np.minimum(float(atol), 2.0e-10 * scale) + residual = point_local - surface_local + return (bool(np.all(np.abs(residual) <= identity_bound)), + float(u), float(v), residual) + + def _check_csx_overlap_valley(C, S, boundary_zeros, atol, rational): """Valley check for CSX: step from each endpoint along the valley. @@ -516,22 +1129,8 @@ def _split_intervals(cut, lo, hi, ptol): return intervals -def _restrict_net_axis(F_cell, axis, lo, hi, cell_lo, cell_hi): - """Restrict a trivariate net along one axis to [lo, hi] within [cell_lo, cell_hi].""" - span = cell_hi - cell_lo - if span < 1e-30: - return F_cell - - frac_lo = (lo - cell_lo) / span - frac_hi = (hi - cell_lo) / span - - Fv = F_cell[..., np.newaxis] - if frac_lo > 1e-12: - _, Fv = de_casteljau_split_nd(Fv, axis=axis, t=frac_lo) - if frac_hi < 1.0 - 1e-12: - frac_hi_rescaled = (frac_hi - frac_lo) / (1.0 - frac_lo) if frac_lo > 1e-12 else frac_hi - Fv, _ = de_casteljau_split_nd(Fv, axis=axis, t=frac_hi_rescaled) - return Fv[..., 0] +# L52 slice 7: shared implementation in _bezier_common (verbatim move). +_restrict_net_axis = restrict_net_axis def _cutout_3d(F_cell, G_cell, seg_c, pw, sw, t0, t1, u0, u1, v0, v1, depth, @@ -594,7 +1193,7 @@ def _phase2_isolated_search( F_sub, G_sub, C_sub, S, C_orig, S_orig, t_lo, t_hi, atol, rational, ptol_t, ptol_u, ptol_v, known_points=None, - max_depth=50, max_cells=50_000, + max_depth=64, max_cells=50_000, max_results=4_096, ): """Phase 2: find isolated intersections via subdivision + Newton + cutout. @@ -609,17 +1208,21 @@ def _phase2_isolated_search( _, Pw = extract_weights(C_sub, rational=rational) _, Sw = extract_weights(S, rational=rational) + strict_root_tol = _strict_csx_root_tol(C_orig, S_orig, rational) # Start with known points from Phase 1 so pre-Newton dedup can skip them - isolated = known_points + isolated = known_points if known_points is not None else [] + initial_results = len(isolated) cells = 0 + exhausted = False stack = [(F_sub, G_sub, C_sub, Pw.copy(), Sw.copy(), t_lo, t_hi, 0.0, 1.0, 0.0, 1.0, 0)] while stack: - if cells >= max_cells: + if cells >= max_cells or len(isolated) - initial_results >= max_results: + exhausted = True break cells += 1 @@ -632,6 +1235,12 @@ def _phase2_isolated_search( if _residual_excludes_zero(G_cell): continue + # L60: the geometry-aligned exclusion — decisive on near-band + # cells whose residual direction is diagonal to the axes (the + # axis test converges only at clearance-scale cells there). + if _residual_aligned_excludes_zero(G_cell): + continue + # Quick prune: min-of-net sw_flat = sw.ravel() w_sc = _weight_max_product(pw, sw_flat) @@ -703,11 +1312,10 @@ def _phase2_isolated_search( if float(np.linalg.norm(pt_c - pt_s)) < _csx_eff_atol( C_orig, S_orig, t_mid, u_mid, v_mid, atol, rational): - t_r, u_r, v_r, G_r, _ = newton_csx( - C_orig, S_orig, t_mid, u_mid, v_mid, rational=rational, - ) - if float(np.linalg.norm(G_r)) < _csx_eff_atol( - C_orig, S_orig, t_r, u_r, v_r, atol, rational): + t_r, u_r, v_r, G_r, root_ok = _polish_csx_root( + C_orig, S_orig, t_mid, u_mid, v_mid, + rational, strict_root_tol) + if root_ok: pt_r = eval_curve(C_orig, t_r, rational=rational) if not _is_duplicate(isolated,t_r, u_r, v_r, pt_r, atol,ptol_t,ptol_u,ptol_v): isolated.append({ @@ -732,6 +1340,11 @@ def _phase2_isolated_search( residual_ok = float(np.linalg.norm(G)) < _csx_eff_atol( C_orig, S_orig, t_sol, u_sol, v_sol, atol, rational) + if residual_ok: + t_sol, u_sol, v_sol, G, residual_ok = _polish_csx_root( + C_orig, S_orig, t_sol, u_sol, v_sol, + rational, strict_root_tol) + # Small FP slack on the in-cell test: Newton can converge to a root # at the cell boundary that lands ~1 ULP outside the FP-computed # bound; without slack such a root is mis-classified as "outside". @@ -784,6 +1397,10 @@ def _phase2_isolated_search( if depth >= max_depth: + # This cell survived every exclusion certificate and Newton did + # not resolve it. The depth guard is therefore a soft-budget + # exhaustion, not evidence that the cell is root-free. + exhausted = True continue # Subdivide along the axis with the largest span @@ -821,7 +1438,7 @@ def _phase2_isolated_search( stack.append((F_R, G_R, seg_c.copy(), pw.copy(), sw_R, t0, t1, u0, u1, v_split, v1, depth+1)) # Return only NEW results (exclude the pre-loaded known points) - return isolated + return isolated, exhausted, cells # --------------------------------------------------------------------------- @@ -834,13 +1451,26 @@ def _phase2_isolated_search( ) +# L42: separate Phase-2 allowance once a valley-confirmed overlap pair has +# failed the exact affine certificate — an exact continuum cannot be turned +# into a sound public overlap by subdivision, so it must not burn the whole +# caller allowance failing to (CCX's non-affine fallback uses the same +# pattern; CSX cells price ~2x a curve pair's, hence the 2x number). +_NON_AFFINE_OVERLAP_FALLBACK_CELLS = 4_000 + + def bez_csx( C, S, atol=1e-3, rational=True, - max_depth=50, + # Three independently subdivided parameters can need more than 50 + # levels before every span reaches its computed resolution (an exact + # corner root in the legacy SSX overlap case needs 53). Cell and result + # budgets remain the termination backstops. + max_depth=64, max_cells=100_000, + max_results=4_096, ) -> dict: """Bezier curve-surface intersection via two-phase architecture. @@ -862,12 +1492,19 @@ def bez_csx( max_depth : int Maximum subdivision depth for Phase 2. max_cells : int - Maximum total cells processed in Phase 2 (safety limit). + Maximum total cells shared by boundary analysis, nested CCX calls, + and Phase 2 (safety limit). + max_results : int + Maximum isolated roots materialized before returning a partial + result. Positive-dimensional sets must be classified separately; + this cap prevents an unrecognized set from turning dedup quadratic. Returns ------- dict - ``{'isolated': [...], 'overlaps': [...]}`` + In addition to intersections, the result reports + ``budget_exhausted``, ``cells_processed``, and + ``boundary_topology_complete``. """ C = np.asarray(C, dtype=np.float64) S = np.asarray(S, dtype=np.float64) @@ -880,7 +1517,102 @@ def bez_csx( s_pts = S.reshape((-1,3)) if not aabb_intersect(aabb_offset( aabb(np.ascontiguousarray(c_pts)),atol/2), aabb_offset( aabb(np.ascontiguousarray(s_pts)),atol/2)): - return {"isolated": [], "overlaps": []} + return {"isolated": [], "overlaps": [], "parameter_fibers": [], + "budget_exhausted": False, "cells_processed": 0, + "boundary_topology_complete": True} + + if int(max_cells) <= 0: + return {"isolated": [], "overlaps": [], "parameter_fibers": [], + "budget_exhausted": True, "cells_processed": 0, + "boundary_topology_complete": False} + + strict_root_tol = _strict_csx_root_tol(C, S, rational) + + # A rational Bezier curve whose Euclidean control points all coincide + # is identically one point even when its homogeneous weights vary. If + # that point lies on the surface, the CSX solution set is + # positive-dimensional in ``t``: every curve parameter is a solution. + # Treating the fiber as isolated roots made Phase 2 cut out one tiny + # t-neighbourhood at a time (case 14: 16,385 pseudo-roots), then paid an + # O(n^2) duplicate scan. Classify the fiber explicitly and solve only + # the genuine point-on-surface problem. + if geometry_collapsed(c_pts): + from mmcore.numeric._bez_closest_point import bez_surface_closest_points + + query = eval_curve(C, 0.5, rational=rational) + closest_cells = max(1, min(int(max_cells), 2_000)) + closest_stats = {} + entities = bez_surface_closest_points( + S, query, atol=atol, rational=rational, + max_cells=closest_cells, stats=closest_stats, + ) + closest_used = int(closest_stats.get("cells_processed", 0)) + closest_capped = bool(closest_stats.get("budget_exhausted", False)) + fibers = [] + surface_collapsed = geometry_collapsed(s_pts) + topology_incomplete = bool(closest_capped) + result_limit = max(0, int(max_results)) + + def _append_fiber(candidate): + nonlocal topology_incomplete + if len(fibers) >= result_limit: + topology_incomplete = True + return False + fibers.append(candidate) + return True + + for entity in ([] if closest_capped else entities): + if float(entity.get("distance", np.inf)) > atol: + continue + kind = entity.get("kind", "min") + if kind == "degenerate_surface": + if surface_collapsed: + exact_member, u_member, v_member, _ = ( + _collapsed_point_surface_membership( + C, S, 0.5, 0.5, 0.5, atol, rational, + strict_root_tol)) + if not exact_member: + continue + if not _append_fiber({ + "t_range": (0.0, 1.0), + "u_range": (0.0, 1.0), + "v_range": (0.0, 1.0), + "point": np.asarray(query, dtype=np.float64), + "surface_kind": kind, + }): + break + else: + # A closest-point band can classify a nearly + # equidistant patch without proving the exact + # point-on-surface parameter region. A representative + # (u,v) would understate its dimension, so surface an + # honest partial result instead. + topology_incomplete = True + continue + if kind == "degenerate_curve" or "u" not in entity or "v" not in entity: + topology_incomplete = True + continue + u_entity = float(entity.get("u", 0.5)) + v_entity = float(entity.get("v", 0.5)) + exact_member, u_entity, v_entity, _ = ( + _collapsed_point_surface_membership( + C, S, 0.5, u_entity, v_entity, atol, rational, + strict_root_tol)) + if not exact_member: + continue + if not _append_fiber({ + "t_range": (0.0, 1.0), + "u": u_entity, + "v": v_entity, + "point": np.asarray(query, dtype=np.float64), + "surface_kind": kind, + }): + break + return {"isolated": [], "overlaps": [], + "parameter_fibers": fibers, + "budget_exhausted": bool(topology_incomplete), + "cells_processed": closest_used, + "boundary_topology_complete": not topology_incomplete} @@ -897,11 +1629,34 @@ def bez_csx( isolated = [] overlaps = [] + budget_exhausted = False + cells = DownCounter(max_cells) # =================================================================== # PHASE 1: Boundary analysis + overlap detection (initial patch only) # =================================================================== - csx_boundary_zeros = _find_csx_boundary_zeros(F, C, S, atol, ptol_t, ptol_u, ptol_v, rational) + boundary_result_cap = min(max(0, int(max_results)), 128) + (csx_boundary_zeros, boundary_exhausted, + boundary_cells) = _find_csx_boundary_zeros( + F, C, S, atol, ptol_t, ptol_u, ptol_v, rational, + max_cells=cells.remaining, max_results=boundary_result_cap, + ) + cells.spend(boundary_cells) + if boundary_exhausted: + # Partial face topology cannot safely drive OVERLAP classification + # (the valley pairing below needs the complete boundary-zero set, + # so it is gated on `not boundary_exhausted`), but each zero found + # so far is polished through the strict per-root certificate and is + # individually sound — keep them (ledger L51: discarding returned + # `{isolated: [], budget_exhausted: True}` with certified roots in + # hand and ~no Phase-2 budget left to re-find them; CCX keeps its + # validated hits in the same situation). The public flag still + # records that the topology is incomplete. + budget_exhausted = True + elif not all(isinstance(bz, BoundaryZero) for bz in csx_boundary_zeros): + budget_exhausted = True + boundary_exhausted = True + csx_boundary_zeros = [] t_exclude = [] # t-intervals to cut from the curve @@ -912,10 +1667,15 @@ def bez_csx( # boundary. Newton refinement converges to the real root if one # exists nearby; otherwise the candidate is rejected. for bz in csx_boundary_zeros: + if len(isolated) >= max_results: + budget_exhausted = True + break t_bz, u_bz, v_bz = _boundary_zero_to_tuv(bz, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0) - t_r, u_r, v_r, G_r, _ = newton_csx(C, S, t_bz, u_bz, v_bz, rational=rational) + t_r, u_r, v_r, G_r, root_ok = _polish_csx_boundary_root( + C, S, bz, t_bz, u_bz, v_bz, rational, + strict_root_tol) - if float(np.linalg.norm(G_r)) < _csx_eff_atol(C, S, t_r, u_r, v_r, atol, rational): + if root_ok: pt_r = eval_curve(C, t_r, rational=rational) if not _is_duplicate(isolated,t_r, u_r, v_r, pt_r, atol,ptol_t,ptol_u,ptol_v): isolated.append({ @@ -924,32 +1684,133 @@ def bez_csx( }) t_exclude.append((t_r - ptol_t, t_r + ptol_t)) - # Valley check for overlap - if len(csx_boundary_zeros) >= 2: + # Valley check for overlap — only on a COMPLETE boundary-zero set: a + # truncated set could pair the wrong endpoints into a false overlap + # claim (L51: the per-root keeps above are sound, this pairing is not). + non_affine_overlap_span = None + _valley_pair_seen = False + if not boundary_exhausted and len(csx_boundary_zeros) >= 2: overlap_pair = _check_csx_overlap_valley(C, S, csx_boundary_zeros, atol, rational) + _valley_pair_seen = overlap_pair is not None if overlap_pair is not None: bz_a, bz_b = overlap_pair t_a, u_a, v_a = _boundary_zero_to_tuv(bz_a, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0) t_b, u_b, v_b = _boundary_zero_to_tuv(bz_b, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0) - t_lo_ovl, t_hi_ovl = min(t_a, t_b), max(t_a, t_b) - overlaps.append({ - "boundary_zeros": [(bz_a.axis, bz_a.side), (bz_b.axis, bz_b.side)], - "overlap_endpoints": [bz_a, bz_b], - "t_range": (t_lo_ovl, t_hi_ovl), - "u_range": (min(u_a, u_b), max(u_a, u_b)), - "v_range": (min(v_a, v_b), max(v_a, v_b)), - }) - t_exclude.append((t_lo_ovl - ptol_t, t_hi_ovl + ptol_t)) - # Remove isolated points inside the overlap - isolated = [iso for iso in isolated - if not (t_lo_ovl - atol <= iso["t"] <= t_hi_ovl + atol)] + t_a, u_a, v_a, _Ga, root_a = _polish_csx_boundary_root( + C, S, bz_a, t_a, u_a, v_a, rational, + strict_root_tol) + t_b, u_b, v_b, _Gb, root_b = _polish_csx_boundary_root( + C, S, bz_b, t_b, u_b, v_b, rational, + strict_root_tol) + endpoint_a = (t_a, u_a, v_a) + endpoint_b = (t_b, u_b, v_b) + if (root_a and root_b and _certify_affine_csx_overlap( + C, S, endpoint_a, endpoint_b, rational)): + bz_a_strict = _boundary_zero_from_tuv_like( + bz_a, *endpoint_a) + bz_b_strict = _boundary_zero_from_tuv_like( + bz_b, *endpoint_b) + t_lo_ovl, t_hi_ovl = min(t_a, t_b), max(t_a, t_b) + overlaps.append({ + "boundary_zeros": [ + (bz_a.axis, bz_a.side), (bz_b.axis, bz_b.side)], + "overlap_endpoints": [bz_a_strict, bz_b_strict], + "t_range": (t_lo_ovl, t_hi_ovl), + "u_range": (min(u_a, u_b), max(u_a, u_b)), + "v_range": (min(v_a, v_b), max(v_a, v_b)), + "certification": "exact", + }) + t_exclude.append( + (t_lo_ovl - ptol_t, t_hi_ovl + ptol_t)) + # Remove isolated points inside the certified overlap. + isolated = [iso for iso in isolated + if not (t_lo_ovl - atol + <= iso["t"] <= t_hi_ovl + atol)] + elif root_a and root_b: + # Ledger L42: a valley-confirmed pair whose affine identity + # cannot be certified is either a curved-UV EXACT overlap + # (the public endpoint-range schema cannot represent it) or + # a broad near-tangent tolerance valley. Neither exclusion + # nor tolerance merging is sound here (sub-atol valleys + # carry real topology), and an unrestricted Phase-2 walk of + # an exact continuum floods thousands of ptol-lattice + # "isolated" roots REPORTED COMPLETE — silent wrong + # topology (measured: 1,679 roots @33,685 cells on a + # parabola lying on a bilinear patch). Port of the CCX + # non-affine fallback: Phase 2 keeps running so genuine + # isolated roots in a benign valley are still found, but + # under a separate bounded allowance, and a continuum + # signature in the outcome marks the topology incomplete. + non_affine_overlap_span = (min(t_a, t_b), max(t_a, t_b)) + + # L59 (USER DECISION: theorem-first tier): certify tolerance/exact + # overlap spans whenever cheap endpoint evidence arms — a curve t-end + # lying ON the surface (an axis-0 boundary zero) or a valley pair — + # INDEPENDENTLY of the exact-affine identity. The real-data class has + # only ONE boundary zero (the other span end pins on the uv-domain + # edge in the projected sense: measured 6.7e-4 clearance from the edge + # line — no exact 3-D root exists there for the boundary phase to + # find). Certified spans bypass the Phase-2 grind entirely. + # Arming: a curve-end zero on-surface, a valley pair, OR a pair with + # NO boundary zeros at all (measured on user data: a coincident + # stretch that enters AND exits through patch edges with sub-atol + # clearance produces zero exact boundary roots — the certificate must + # still get its 17-sample look; transversal SSX-nested calls always + # carry boundary zeros, so their cost profile is untouched). + if (not boundary_exhausted and not overlaps + and (_valley_pair_seen + or not csx_boundary_zeros + or any(bz.axis == 0 for bz in csx_boundary_zeros))): + # Split pricing (§11.5 drift-gate lesson): the 17-projection + # arming scan is billed always; the dense pass (65 witnesses + + # refines) only when the scan HITS — nested cut-face calls + # routinely arm-and-miss and must not pay the full tier. + _tier_price = 17 + _dense_price = 65 + 80 + if cells.remaining > _tier_price + _dense_price: + cells.spend(_tier_price) + _tol_overlaps = _tolerance_csx_overlap_certificate( + C, S, atol, rational, ptol_t, strict_root_tol, + on_dense=lambda: cells.spend( + min(_dense_price, max(0, cells.remaining)))) + if _tol_overlaps: + for _o in _tol_overlaps: + overlaps.append(_o) + _t_lo, _t_hi = _o["t_range"] + t_exclude.append((_t_lo - ptol_t, _t_hi + ptol_t)) + isolated = [iso for iso in isolated + if not (_t_lo - atol + <= iso["t"] <= _t_hi + atol)] + # the span(s) are certified: the L42 uncertified-span + # fallback no longer applies to them. + non_affine_overlap_span = None # =================================================================== # PHASE 2: Isolated intersection search on remaining curve intervals # =================================================================== t_intervals = _compute_remaining_intervals(t_exclude, 0.0, 1.0) - for t_lo, t_hi in t_intervals: + # L42 fallback allowance: with an uncertified valley pair on record, + # Phase 2 cannot turn the rejected candidate into a sound public + # overlap, so it must not burn the caller's whole allowance failing to. + fallback_cells_remaining = ( + cells.tier(_NON_AFFINE_OVERLAP_FALLBACK_CELLS) + if non_affine_overlap_span is not None else None) + + def _interval_hits_span(a, b): + if non_affine_overlap_span is None: + return False + s_lo, s_hi = non_affine_overlap_span + return not (b < s_lo - ptol_t or a > s_hi + ptol_t) + + # L28 attribution: retirement of the structural overlap reason at the + # SSX level is sound only if the truncation touched NOTHING outside the + # uncertified span — record whether any disjoint interval was truncated + # or skipped. + non_span_truncation = False + t_intervals = list(t_intervals) + + for _ii, (t_lo, t_hi) in enumerate(t_intervals): if (t_hi - t_lo) < ptol_t: continue @@ -977,22 +1838,149 @@ def bez_csx( if _check_min_of_net(F_sub, atol, w_sc): continue + # L60: whole-interval geometry-aligned exclusion — a near-band + # interval whose residual direction rotates slowly clears here in + # one test instead of thousands of phase-2 cells. + if (_residual_excludes_zero(G_sub) + or _residual_aligned_excludes_zero(G_sub)): + cells.spend(1) + continue + # Search for isolated intersections - _phase2_iso = _phase2_isolated_search( + _rest_has_disjoint = ( + non_affine_overlap_span is not None + and any(not _interval_hits_span(a, b) + for a, b in t_intervals[_ii:] if (b - a) >= ptol_t)) + if cells.remaining <= 0 or len(isolated) >= max_results: + budget_exhausted = True + if _rest_has_disjoint: + non_span_truncation = True + break + if (fallback_cells_remaining is not None + and fallback_cells_remaining <= 0): + budget_exhausted = True + if _rest_has_disjoint: + non_span_truncation = True + break + phase2_cell_limit = cells.remaining + if fallback_cells_remaining is not None: + phase2_cell_limit = min( + phase2_cell_limit, fallback_cells_remaining) + _phase2_iso, _phase2_exhausted, _cells_used = _phase2_isolated_search( F_sub, G_sub, C_sub, S, C_orig, S_orig, t_lo, t_hi, atol, rational, ptol_t, ptol_u, ptol_v, known_points=isolated, - max_depth=max_depth, max_cells=max_cells, + max_depth=max_depth, max_cells=phase2_cell_limit, + max_results=max_results - len(isolated), ) + cells.spend(_cells_used) + if fallback_cells_remaining is not None: + fallback_cells_remaining -= _cells_used + if _phase2_exhausted and not _interval_hits_span(t_lo, t_hi): + non_span_truncation = True + budget_exhausted = budget_exhausted or _phase2_exhausted + + + + # L42 outcome test: with an uncertified valley pair, either the bounded + # fallback ran dry, or the surviving roots inside the span form a + # ptol-lattice chain (the continuum signature — a short exact overlap + # can complete under the fallback cap and would otherwise revive the + # completeness lie). Both directions only ADD flags (conservative); + # a benign near-tangent valley with finitely many transversal roots + # completes under the cap, forms no chain, and stays complete. + overlap_topology_incomplete = False + if non_affine_overlap_span is not None: + span_lo, span_hi = non_affine_overlap_span + in_span = sorted( + iso["t"] for iso in isolated + if span_lo - ptol_t <= iso["t"] <= span_hi + ptol_t) + chain = (len(in_span) > 12 + and all(b - a <= 4.0 * ptol_t + for a, b in zip(in_span, in_span[1:]))) + if budget_exhausted or chain: + budget_exhausted = True + overlap_topology_incomplete = True + if not overlap_topology_incomplete and len(isolated) >= 3: + # L52 slice 10a (A2's confirmed lead): a SHORT exact overlap span + # survives only by DOMAIN CLIPPING (a polynomial curve exactly on + # the surface over an open sub-interval is on it everywhere, so + # only the uv-domain edge can end a genuine span), and a clipped + # span shorter than the >12-root chain bar produced a few lattice + # roots reported COMPLETE (measured: 4.2·ptol_t corner-clipped + # continuum → 3 isolated roots, complete=True, no span). Detect + # the lattice: a run of ≥3 roots with every consecutive gap + # ≤ 4·ptol_t whose GAP MIDPOINTS all pass the STRICT residual + # certificate — exact continuums verify at roundoff scale, while + # sub-atol-valley root pairs FAIL strict (their valley floors sit + # far above roundoff), so distinct zeros connected by sub-atol + # valleys are never merged (the CSX invariant). + entries = sorted( + ((float(e["t"]), float(e["u"]), float(e["v"])) + for e in isolated), key=lambda x: x[0]) + runs, run = [], [entries[0]] + for prev, cur in zip(entries, entries[1:]): + if cur[0] - prev[0] <= 4.0 * ptol_t: + run.append(cur) + else: + runs.append(run) + run = [cur] + runs.append(run) + + def _run_is_continuum(run_entries): + for (ta, ua, va), (tb, ub, vb) in zip(run_entries, + run_entries[1:]): + tm = 0.5 * (ta + tb) + pm = eval_curve(C, tm, rational=rational) + um, vm, _dist = _project_point_on_surface( + pm, S, 0.5 * (ua + ub), 0.5 * (va + vb), + atol, rational) + ok, _res = _strict_csx_residual_ok( + C, S, tm, um, vm, rational, strict_root_tol) + if not ok: + return False + return True - - - return {"isolated": isolated, "overlaps": overlaps} + verified = [r for r in runs if len(r) >= 3 and _run_is_continuum(r)] + if verified: + largest = max(verified, key=len) + non_affine_overlap_span = (largest[0][0], largest[-1][0]) + overlap_topology_incomplete = True + # more than one verified continuum: structure ALSO lives + # outside the exported span — the caller must not retire the + # incompleteness after representing the span alone. + non_span_truncation = len(verified) > 1 + + result = {"isolated": isolated, "overlaps": overlaps, + "parameter_fibers": [], + "budget_exhausted": bool(budget_exhausted), + "cells_processed": int(cells.processed), + "boundary_topology_complete": not ( + boundary_exhausted or overlap_topology_incomplete)} + if overlap_topology_incomplete: + # Typed L42 outcome for the SSX consumer (ledger L28): the span + # names WHERE the uncertifiable positive-dimensional structure + # lives, and `non_span_truncation` says whether anything OUTSIDE + # it was also truncated (in which case the caller must not retire + # its incompleteness even after representing the span as a region). + result["uncertified_overlap_span"] = ( + float(non_affine_overlap_span[0]), + float(non_affine_overlap_span[1])) + result["non_span_truncation"] = bool(non_span_truncation) + return result def _is_duplicate(isolated, t,u,v, pt, atol, ptol_t,ptol_u,ptol_v): for entry in isolated: - if (abs(entry['t'] -t ) int: + """Postprocess units for `_deduplicate_ssx_points` on n points. + + Ledger L44: the site used to precharge the PRE-rewrite O(n²) cost for + the O(n·108) bucketed algorithm — at the 1024 output cap that quoted + 1,048,576 units against the 250k default, the charge was denied, and + exactly the dense pseudo-root outputs the rewrite targeted shipped + UN-deduplicated with a spurious partial flag. Price the actual probe + count (3⁴ param + 3³ xyz neighbor bins per point) at the shared + per-128 exchange rate. + """ + return max(1, (int(n) * 108 + 127) // 128) + + +def _deduplicate_ssx_points(points, unify_tol, atol, stats=None): + """Stable both-guard SSXPoint dedup with a bounded spatial index. + + The legacy final pass compared every point with every previously kept + point. Dense pseudo-root output could therefore spend quadratic work + after the global solver budget had already stopped the search. Bin the + four parameters at ``unify_tol`` and xyz at ``2*atol``; an exact match + can only live in the 3^7 neighboring bins. Exact comparisons retain the + established matching-ladder predicate and insertion order. + """ + if not points: + if stats is not None: + stats["comparisons"] = 0 + stats["bucket_probes"] = 0 + return [] + + pstep = np.maximum(np.abs(np.asarray(unify_tol, dtype=np.float64)), + np.finfo(float).tiny) + xstep = max(2.0 * abs(float(atol)), np.finfo(float).tiny) + buckets = {} + unique = [] + comparisons = 0 + bucket_probes = 0 + + def _keys(p): + pvalues = np.asarray(p.stuv, dtype=np.float64) / pstep + xvalues = np.asarray(p.xyz, dtype=np.float64) / xstep + # Ledger L45: math.floor raises ValueError on NaN / OverflowError + # on inf — one non-finite point (a w→0 rational eval upstream) + # used to discard the WHOLE completed run after its budget was + # spent. The legacy comparison dedup was NaN-safe (comparisons + # all False → point kept); preserve that contract by keeping + # non-finite points unbinned and unique. + if not (np.all(np.isfinite(pvalues)) + and np.all(np.isfinite(xvalues))): + return None + # math.floor returns an arbitrary-precision Python int, avoiding the + # int64 overflow of an ndarray cast on large model coordinates. + return ( + tuple(math.floor(float(x)) for x in pvalues), + tuple(math.floor(float(x)) for x in xvalues), + ) + + for p in points: + keys = _keys(p) + if keys is None: + unique.append(p) + continue + pkey, xkey = keys + duplicate = False + for poffset in _POINT_PARAM_NEIGHBOR_OFFSETS: + pneighbor = tuple(k + d for k, d in zip(pkey, poffset)) + bucket_probes += 1 + xyz_buckets = buckets.get(pneighbor) + if xyz_buckets is None: + continue + for xoffset in _POINT_XYZ_NEIGHBOR_OFFSETS: + xneighbor = tuple(k + d for k, d in zip(xkey, xoffset)) + bucket_probes += 1 + for q in xyz_buckets.get(xneighbor, ()): + comparisons += 1 + if (np.all(np.abs(np.asarray(p.stuv) + - np.asarray(q.stuv)) <= pstep) + and float(np.linalg.norm(np.asarray(p.xyz) + - np.asarray(q.xyz))) + <= 2.0 * abs(float(atol))): + duplicate = True + break + if duplicate: + break + if duplicate: + break + if not duplicate: + unique.append(p) + buckets.setdefault(pkey, {}).setdefault(xkey, []).append(p) + + if stats is not None: + stats["comparisons"] = int(comparisons) + stats["bucket_probes"] = int(bucket_probes) + return unique # --------------------------------------------------------------------------- # Level 1: Pruning # --------------------------------------------------------------------------- -def _prune_ssx_cell(S1_h, S2_h, atol, rational=True): +def _ssx_control_aabbs_disjoint(S1_h, S2_h, rational=True): + """Cheap top-level hull rejection before any 4-D net allocation.""" + if rational: + pts1 = S1_h[..., :-1] / S1_h[..., -1:] + pts2 = S2_h[..., :-1] / S2_h[..., -1:] + else: + pts1 = S1_h + pts2 = S2_h + bb1 = np.array(aabb(pts1.reshape(-1, pts1.shape[-1]))) + bb2 = np.array(aabb(pts2.reshape(-1, pts2.shape[-1]))) + return not aabb_intersect(bb1, bb2) + + +def _prune_ssx_cell(S1_h, S2_h, atol, rational=True, F=None): """Return True if this patch pair provably does NOT intersect. Checks: @@ -158,22 +301,13 @@ def _prune_ssx_cell(S1_h, S2_h, atol, rational=True): _, S1w = extract_weights(S1_h, rational=rational) _, S2w = extract_weights(S2_h, rational=rational) - if rational: - pts1 = S1_h[..., :-1] / S1_h[..., -1:] - pts2 = S2_h[..., :-1] / S2_h[..., -1:] - else: - pts1 = S1_h - pts2 = S2_h - - bb1 = np.array(aabb(pts1.reshape(-1, pts1.shape[-1]))) - bb2 = np.array(aabb(pts2.reshape(-1, pts2.shape[-1]))) - #bb1[0] -= atol; bb1[1] += atol - #bb2[0] -= atol; bb2[1] += atol - if not aabb_intersect(bb1, bb2): + if _ssx_control_aabbs_disjoint(S1_h, S2_h, rational=rational): return True # Sq-dist net pruning - F = surface_surface_distance_squared_net_homog(S1_h, S2_h, rational=rational) + if F is None: + F = surface_surface_distance_squared_net_homog( + S1_h, S2_h, rational=rational) sw1 = S1w.ravel() sw2 = S2w.ravel() w_scale = _weight_max_product(sw1, sw2) @@ -276,27 +410,154 @@ def _invert_point_on_surface(S_h, P, rational=True, grid=4, iters=12): return u, v -def _find_ssx_boundary_zeros(S1_h, S2_h, atol, rational=True): +def _curve_geometry_collapsed(C, rational=True) -> bool: + # L52 slice 7: the shared predicate (local-motion 128·ε rule) lives in + # _bezier_common.geometry_collapsed; dehomogenization stays here. + pts = (C[..., :-1] / C[..., -1:]) if rational else np.asarray(C) + return geometry_collapsed(pts) + + +def _weight_net_uniform(S) -> bool: + """Exact rational-polynomial fast-path predicate.""" + w = np.asarray(S, dtype=np.float64)[..., -1] + return bool(w.size and np.all(w == w.flat[0])) + + +def _on_collapsed_boundary_fiber( + S, u, v, rational=True, *, param_tol: float = 1e-10) -> bool: + """True only for a parameter point on an identically collapsed edge. + + This is deliberately narrower than ``Sigma=0``: a C1 point can still + be regular on the 4D Psi curve and may be a required branch seed. We + suppress only a certified positive-dimensional boundary preimage. + """ + eps = max(0.0, float(param_tol)) + if abs(u) <= eps and _curve_geometry_collapsed(S[0, :, :], rational): + return True + if abs(u - 1.0) <= eps and _curve_geometry_collapsed(S[-1, :, :], rational): + return True + if abs(v) <= eps and _curve_geometry_collapsed(S[:, 0, :], rational): + return True + if abs(v - 1.0) <= eps and _curve_geometry_collapsed(S[:, -1, :], rational): + return True + return False + + +def _canonicalize_collapsed_fiber_params( + S, uv, anchor_uv, *, rational=True, param_tol=1e-10): + """Choose the limiting branch representative on collapsed edge fibers. + + A collapsed edge maps every value of its free parameter to one xyz + point. Boundary CSX may therefore return an arbitrary free value, which + is valid as a set member but not as the limit of the incident 4-D SSI + branch. ``anchor_uv`` is an interior Delta witness on that branch; its + free coordinate supplies a deterministic, continuation-consistent + representative without changing xyz. + """ + out = np.asarray(uv, dtype=np.float64).copy() + anchor = np.asarray(anchor_uv, dtype=np.float64) + eps = max(0.0, float(param_tol)) + if abs(out[0]) <= eps and _curve_geometry_collapsed(S[0, :, :], rational): + out[1] = anchor[1] + if (abs(out[0] - 1.0) <= eps + and _curve_geometry_collapsed(S[-1, :, :], rational)): + out[1] = anchor[1] + if abs(out[1]) <= eps and _curve_geometry_collapsed(S[:, 0, :], rational): + out[0] = anchor[0] + if (abs(out[1] - 1.0) <= eps + and _curve_geometry_collapsed(S[:, -1, :], rational)): + out[0] = anchor[0] + return out + + +def _find_ssx_boundary_zeros( + S1_h, S2_h, atol, rational=True, csx_fn=None, fiber_sink=None): """Find all intersection points and overlaps on the boundary of [0,1]⁴. Returns (crossings, overlaps). """ crossings = [] overlaps = [] + if csx_fn is None: + csx_fn = bez_csx + if fiber_sink is None: + fiber_sink = [] def _process_face(iso, other_surf, axis, side, owner_is_s1): - result = bez_csx(iso, other_surf, atol=atol, rational=rational) + result = csx_fn(iso, other_surf, atol=atol, rational=rational) + + # A collapsed owner edge produces a positive-dimensional CSX + # parameter fiber instead of isolated roots. Preserve one typed, + # unresolved boundary seed; the free edge parameter is chosen only + # after an interior Delta witness identifies the limiting 4-D SSI + # branch. Dropping this metadata deleted one end of case 14's cone + # generator, while choosing t=.5 here would be arbitrary/unsound. + for fiber in result.get('parameter_fibers', []): + u_oth = float(fiber.get('u', 0.5)) + v_oth = float(fiber.get('v', 0.5)) + stuv = _map_csx_to_stuv( + axis, side, 0.5, u_oth, v_oth, owner_is_s1) + xyz = np.asarray(fiber.get( + 'point', eval_curve(iso, 0.5, rational=rational)), + dtype=np.float64) + p1 = eval_surface(S1_h, stuv[0], stuv[1], rational=rational) + p2 = eval_surface(S2_h, stuv[2], stuv[3], rational=rational) + if (float(np.linalg.norm(p1 - xyz)) > 2.0 * atol + or float(np.linalg.norm(p2 - xyz)) > 2.0 * atol): + continue + face_id = axis if owner_is_s1 else axis + 2 + fiber_sink.append(BoundaryPoint( + stuv=stuv, xyz=xyz, face=(face_id, side), + tangent_raw=None, parameter_fiber=True)) for iso_pt in result.get('isolated', []): t_crv = float(iso_pt['t']) u_oth = float(iso_pt['u']) v_oth = float(iso_pt['v']) stuv = _map_csx_to_stuv(axis, side, t_crv, u_oth, v_oth, owner_is_s1) - xyz = np.asarray(iso_pt['point'], dtype=np.float64) + raw_stuv = stuv.copy() + # CSX's geometric tolerance can return several samples around + # one even-multiplicity boundary root. In SSX those samples + # would become registrations and residual-only continuation can + # trace q**d tolerance valleys many atol from the actual curve. + # The owning face makes Psi=0 square: polish with that parameter + # fixed (including the bounded multiplicity fallback), then admit + # only a roundoff-scale root. Distinct genuine roots remain + # distinct; repeated samples collapse in `_dedup_crossings`. + fixed_axis = axis if owner_is_s1 else axis + 2 + polished, pres, _ = _ssx_correct_fixed( + S1_h, S2_h, stuv, + fixed_axis=fixed_axis, fixed_value=float(side), + rational=rational, + ) + # Ledger L45: written accept-if — the reject-if-greater form + # (`if pres > tol: continue`) ACCEPTED a NaN residual (all NaN + # comparisons are False), turning one w→0 rational eval into a + # garbage certified crossing feeding the whole pipeline. + if not (np.isfinite(pres) + and pres <= _strict_ssx_root_tol( + S1_h, S2_h, rational=rational)): + continue + stuv = polished + multiplicity_polished = bool(np.max(np.abs( + stuv - raw_stuv)) > 64.0 * np.finfo(float).eps) + xyz = eval_surface( + S1_h, stuv[0], stuv[1], rational=rational) + # A certified collapsed EDGE is a positive-dimensional + # parameter fiber, not a regular crossing. Do not generalize + # this to every Sigma=0 point: ordinary C1 points can be regular + # on the 4D Psi curve and remain valid branch seeds. + if (_on_collapsed_boundary_fiber( + S1_h, stuv[0], stuv[1], rational=rational) + or _on_collapsed_boundary_fiber( + S2_h, stuv[2], stuv[3], rational=rational)): + continue face_id = axis if owner_is_s1 else axis + 2 tang, _, _ = _ssx_tangent_4d(S1_h, S2_h, stuv[0], stuv[1], stuv[2], stuv[3], rational=rational) - crossings.append(BoundaryPoint(stuv=stuv, xyz=xyz, face=(face_id, side), - tangent_raw=tang)) + crossings.append(BoundaryPoint( + stuv=stuv, xyz=xyz, face=(face_id, side), + tangent_raw=tang, + multiplicity_polished=multiplicity_polished)) for ovl in result.get('overlaps', []): tr = ovl.get('t_range', (0.0, 1.0)) @@ -341,10 +602,43 @@ def _process_face(iso, other_surf, axis, side, owner_is_s1): if float(np.linalg.norm(_p1 - _p2)) > 2.0 * atol: chord_ok = False break + overlap_path = None + if not chord_ok and ovl.get('certification') in ( + 'exact', 'tolerance'): + # L59: a CERTIFIED overlap whose straight stuv chord fails + # the residual bar has a CURVED correspondence (non-affine + # uv path — the user-fixture class: a straight edge on a + # non-parallelogram planar quad). Dropping it silently + # left the truncated crossing-built fragment FALSELY + # complete. Build the true path instead: sample the iso + # curve over t_range, invert each sample on the other + # surface, and verify every sample on BOTH surfaces. + _n = 33 + _path = [] + _path_ok = True + for _lam in np.linspace(0.0, 1.0, _n): + _t = (1.0 - _lam) * tr[0] + _lam * tr[1] + _cpt = eval_curve(iso, float(_t), rational=rational) + _uo, _vo = _invert_point_on_surface( + other_surf, _cpt, rational=rational) + _s4 = _map_csx_to_stuv( + axis, side, float(_t), _uo, _vo, owner_is_s1) + _p1 = eval_surface( + S1_h, _s4[0], _s4[1], rational=rational) + _p2 = eval_surface( + S2_h, _s4[2], _s4[3], rational=rational) + if float(np.linalg.norm(_p1 - _p2)) > 2.0 * atol: + _path_ok = False + break + _path.append(_s4) + if _path_ok: + overlap_path = np.asarray(_path, dtype=np.float64) + chord_ok = True if not chord_ok: continue overlaps.append(BoundaryOverlap(stuv_start=stuv_s, stuv_end=stuv_e, - face=(face_id, side))) + face=(face_id, side), + stuv_path=overlap_path)) # Also add endpoints as crossings (they connect to interior branches) xyz_s = eval_surface(S1_h, stuv_s[0], stuv_s[1], rational=rational) xyz_e = eval_surface(S1_h, stuv_e[0], stuv_e[1], rational=rational) @@ -405,9 +699,17 @@ def _dedup_crossings(crossings, atol): deduped = [] for c in crossings: - is_dup = any(np.linalg.norm(c.stuv - d.stuv) < atol for d in deduped) - if not is_dup: + duplicate = next((d for d in deduped + if np.linalg.norm(c.stuv - d.stuv) < atol), None) + if duplicate is None: deduped.append(c) + else: + # Preserve evidence that CSX returned a tolerance cluster around + # this repeated root even when the exact member was inserted + # first. If no strict branch later leaves the root, topology is + # still explicitly partial (positive-gap endpoint-touch control). + duplicate.multiplicity_polished = bool( + duplicate.multiplicity_polished or c.multiplicity_polished) return deduped @@ -470,7 +772,8 @@ def _check_monotonicity(T1, T2, T3, T4): return False, None -def _check_tangency(T1, T2, T3, T4, P1_cart, P2_cart, box): +def _check_tangency( + T1, T2, T3, T4, S1, S2, box, *, rational=False, atol=1e-8): """Check if TΨ=0 has a simultaneous solution in the box. Uses the Krawczyk interval-Newton operator from _deflate.py to certify @@ -482,6 +785,26 @@ def _check_tangency(T1, T2, T3, T4, P1_cart, P2_cart, box): False if no tangent point exists in the box. None if undetermined (Krawczyk couldn't decide). """ + # The interval-capable DeflatedSystem below historically accepted + # Euclidean polynomial control nets. For genuinely rational surfaces, + # per-control-point P/w is the wrong surface, so use the exact float + # quotient evaluator. Failure remains inconclusive (sound fall-through + # to subdivision); only a converged zero returns True. + if rational: + try: + from mmcore.numeric.intersection.ssx._ssx5_singular import ( + hull_excludes_zero) + if any(hull_excludes_zero(np.asarray(T, dtype=np.float64)) + for T in (T1, T2, T3, T4)): + return None + gn, _ = _delta_float_gn( + T1, T2, T3, T4, S1, S2, rational=True, atol=atol) + mid = np.array([0.5 * (lo + hi) for lo, hi in box], + dtype=np.float64) + return True if gn(mid) is not None else None + except (np.linalg.LinAlgError, FloatingPointError): + return None + from mmcore.numeric.bern import bern_eval as _bern_eval from mmcore.numeric.ndinterval import interval as iv_interval, get_iarray from mmcore.numeric.intersection._deflate import ( @@ -491,8 +814,8 @@ def _check_tangency(T1, T2, T3, T4, P1_cart, P2_cart, box): try: # Convert control nets to interval arrays - P1_iv = get_iarray(P1_cart, P1_cart) - P2_iv = get_iarray(P2_cart, P2_cart) + P1_iv = get_iarray(S1, S1) + P2_iv = get_iarray(S2, S2) T1i = np.asarray(T1, dtype=iv_interval) T2i = np.asarray(T2, dtype=iv_interval) T3i = np.asarray(T3, dtype=iv_interval) @@ -575,30 +898,20 @@ def _tangency_witness(cell, atol, *, enumerate_all=True): witness. Cost is confined to genuine crossing-less tangent cells (zero across coverage cases 5–11 and the legacy tangential case). """ - from mmcore.numeric.bern import bern_eval as _bern_eval - from mmcore.numeric.ndinterval import interval as iv_interval, get_iarray - from mmcore.numeric.intersection._deflate import ( - DeflatedSystem, gauss_newton_witness, _box_from_any, - ) from mmcore.numeric.intersection.ssx._ssx5_singular import ( BoxNet, psi_vector_net, solve_zero_dim, ) from mmcore.geom._nurbs_param_tol import bez_surface_param_tolerance - P1c = cell.g1.surface[..., :-1] / cell.g1.surface[..., -1:] - P2c = cell.g2.surface[..., :-1] / cell.g2.surface[..., -1:] try: - sys_ = DeflatedSystem( - P1=get_iarray(P1c, P1c), P2=get_iarray(P2c, P2c), - T=tuple(np.asarray(T, dtype=iv_interval) - for T in (cell.T1, cell.T2, cell.T3, cell.T4)), - bern_eval=_bern_eval, interval_ctor=iv_interval, - ) - Bf = _box_from_any(tuple(iv_interval(0.0, 1.0) for _ in range(4))) + gn, _ = _delta_float_gn( + cell.T1, cell.T2, cell.T3, cell.T4, + cell.g1.surface, cell.g2.surface, rational=True, atol=atol) def _gn(x0): - ok_, xw_, _fn = gauss_newton_witness(sys_, Bf, x0=x0, - tol_f=1e-10, max_iter=24) - return np.asarray(xw_, dtype=np.float64) if ok_ else None + seed = np.full(4, 0.5) if x0 is None else x0 + xw_ = gn(seed) + return (np.asarray(xw_, dtype=np.float64) + if xw_ is not None else None) def _xyz(x): return eval_surface(cell.g1.surface, x[0], x[1], rational=True) @@ -622,7 +935,9 @@ def _xyz(x): # max_cells=2000: interim cost cap; 1-dim Δ-sets (tangent curves/loops) are Task 5's territory sols, exhausted = solve_zero_dim(nets, _gn, ptol, max_cells=2000, dedup_xyz=_xyz, - atol=atol) + atol=atol, max_results=64, + charge_box=_charge_hook( + cell.work_budget, "singular")) for sol in sols: # same destructive-dedup rule as solve_zero_dim's own _dup: # 1·ptol per-axis box AND xyz <= atol @@ -631,7 +946,7 @@ def _xyz(x): and float(np.linalg.norm(sol_xyz - _xyz(r_))) <= atol for r_ in roots): roots.append(sol) - best_fn = min((float(np.linalg.norm(sys_.delta_point(r_))) + best_fn = min((float(np.linalg.norm(gn.residual(r_))) for r_ in roots), default=np.inf) return bool(roots), roots, best_fn, exhausted except (np.linalg.LinAlgError, FloatingPointError): @@ -750,7 +1065,8 @@ def _ends_match(pa, pxyz, qa, qxyz): def _emit_tangent_roots(cell, atol, unify_tol, all_singularities, - *, enumerate_all=True, overlap_boxes=None): + *, enumerate_all=True, overlap_boxes=None, + defer_inconclusive=False): """Run the Δ = Ψ ∩ TΨ witness on a tangent cell and emit every distinct root as a 'tangent_point' singularity into `all_singularities`. @@ -789,12 +1105,72 @@ def _emit_tangent_roots(cell, atol, unify_tol, all_singularities, """ ok, roots, _fn, exhausted = _tangency_witness( cell, atol, enumerate_all=enumerate_all) - if enumerate_all and _delta_roots_curve_like(roots, exhausted): - return ok, roots - for xw in roots: + if exhausted and cell.work_budget is not None: + # A locally capped 0/1-root enumeration is still partial; only the + # root itself is certified, not the absence of another Delta root. + cell.work_budget.mark_incomplete(REASON_TANGENTIAL_ZONE) + local_ptol4 = _cell_ptol4(cell, atol) + typed_roots = list(roots) + inconclusive_roots = [] + if roots: + try: + dimension_gn, _ = _delta_float_gn( + cell.T1, cell.T2, cell.T3, cell.T4, + cell.g1.surface, cell.g2.surface, + rational=True, atol=atol) + except (np.linalg.LinAlgError, FloatingPointError): + dimension_gn = None + classified = [] + for root in roots: + if dimension_gn is None: + local_dimension = None + else: + local_dimension = _delta_root_local_dimension( + dimension_gn, root, local_ptol4, + charge_work=_charge_hook( + cell.work_budget, "singular_dimension")) + if local_dimension is False: + classified.append(root) # full rank => locally isolated + elif local_dimension is None: + if defer_inconclusive: + # A loop-free crossing cell can resolve this ambiguity + # immediately below by tracing a strict tangential path + # through the root. Delay (do not erase) the incomplete + # status until that stronger geometric evidence exists. + inconclusive_roots.append( + np.asarray(root, dtype=np.float64).copy()) + elif cell.work_budget is not None: + # Inside a detected coplanar overlap region the + # ambiguous root is a sample of the 2-D C2 set that the + # result schema cannot represent yet (case-12 class); + # elsewhere it is a genuine multiplicity ambiguity. + _root_g = _local_to_global(np.asarray(root), cell.box) + _r_reason = ( + REASON_OVERLAP_REGION if _stuv_in_overlap_boxes( + _root_g, overlap_boxes) + else REASON_MULTIPLICITY) + cell.work_budget.structural_sites.append( + (_r_reason, _root_g.copy())) + cell.work_budget.mark_incomplete(_r_reason) + # local_dimension=True is a certified curve sample: preserve it + # in the returned roots for Phi seeding, but do not type it as an + # isolated tangent point. + typed_roots = classified + for xw in typed_roots: stuv_g = _local_to_global(np.asarray(xw), cell.box) if _stuv_in_overlap_boxes(stuv_g, overlap_boxes): continue + if (_on_collapsed_boundary_fiber( + cell.g1.surface, xw[0], xw[1], rational=True, + param_tol=float(max(local_ptol4[0], local_ptol4[1]))) + or _on_collapsed_boundary_fiber( + cell.g2.surface, xw[2], xw[3], rational=True, + param_tol=float(max(local_ptol4[2], local_ptol4[3])))): + # Every value of the collapsed edge's free parameter maps to + # this endpoint. It belongs to a C1/parameter-fiber set, never + # an isolated C2 tangent point; relative normal tests are + # unreliable here because both derivatives can be roundoff-size. + continue if _normals_degenerate_at(cell.g1.surface, cell.g2.surface, xw): continue # L15: Sigma=0 root — C1 candidate, not a C2 touch xyz_w = eval_surface(cell.g1.surface, xw[0], xw[1], rational=True) @@ -802,8 +1178,15 @@ def _emit_tangent_roots(cell, atol, unify_tol, all_singularities, and np.all(np.abs(g.stuv - stuv_g) <= unify_tol) and float(np.linalg.norm(g.xyz - xyz_w)) <= 2.0 * atol for g in all_singularities): - all_singularities.append(SSXSingularity( - kind="tangent_point", stuv=stuv_g, xyz=xyz_w)) + item = SSXSingularity( + kind="tangent_point", stuv=stuv_g, xyz=xyz_w) + if cell.work_budget is None: + all_singularities.append(item) + else: + cell.work_budget.append_output( + all_singularities, item, "singularity") + if defer_inconclusive: + cell._deferred_delta_roots = inconclusive_roots return ok, roots @@ -823,12 +1206,34 @@ def _normals_degenerate_at(S1h, S2h, x4) -> bool: n2 = float(np.linalg.norm(np.cross(du2, dv2))) s1 = float(np.linalg.norm(du1)) * float(np.linalg.norm(dv1)) s2 = float(np.linalg.norm(du2)) * float(np.linalg.norm(dv2)) - return n1 <= 1e-6 * s1 + 1e-300 or n2 <= 1e-6 * s2 + 1e-300 + # This predicate is a topological type test (Sigma_i == 0), not a + # conditioning heuristic. A geometric-looking tolerance such as 1e-6 + # silently retypes perfectly regular, merely ill-conditioned surface + # patches as cusps. Compare the scale-free sine of the derivative angle + # only at roundoff scale; a zero derivative is degenerate outright. + roundoff = 128.0 * np.finfo(np.float64).eps -def _delta_float_gn(T1, T2, T3, T4, P1c, P2c): + def _is_degenerate(normal_norm, derivative_scale): + if not np.isfinite(normal_norm) or not np.isfinite(derivative_scale): + return True + if derivative_scale == 0.0: + return True + return normal_norm <= roundoff * derivative_scale + + return _is_degenerate(n1, s1) or _is_degenerate(n2, s2) + + +def _delta_float_gn( + T1, T2, T3, T4, S1, S2, *, rational=False, atol=1e-8): """Plain-float Gauss-Newton factory on Δ = {Ψ(3), TΨ1..4} over a cell's - LOCAL [0,1]⁴, from its cartesian control nets and T tensors. + LOCAL [0,1]⁴, from its surface control nets and T tensors. + + With ``rational=True``, ``S1``/``S2`` are homogeneous nets and every + Ψ/Jacobian evaluation uses the true quotient surfaces. Per-control-point + dehomogenization is not a Bezier representation of a non-uniformly + weighted rational surface (ledger L26; case 14 missed the true generator + by 240-640·atol on that false polynomial pair). Same control flow and acceptance ladder as _deflate.gauss_newton_witness (tol_f=1e-10 convergence; step < 1e-12 or max_iter=24 accept at @@ -890,6 +1295,25 @@ def _elev_mat(n, m): axes=(1, ax)), 0, ax) Tstack[..., k] = A + # The rational T-Psi numerator minors can be O(1e4) while Psi is O(10). + # Positive per-equation scaling preserves the zero set and prevents the + # least-squares solve from satisfying the large minor rows at the expense + # of the actual surface residual. Keep the returned Tstack unscaled: its + # Bernstein hulls remain the caller's exact exclusion certificates. + _Tmax = np.max(np.abs(Tstack), axis=(0, 1, 2, 3)) + _Tscale = np.where(_Tmax > 0.0, _Tmax, 1.0) + if rational: + _P1 = S1[..., :-1] / S1[..., -1:] + _P2 = S2[..., :-1] / S2[..., -1:] + _joint = np.vstack([_P1.reshape(-1, 3), _P2.reshape(-1, 3)]) + _psi_scale = max( + 1.0, + float(np.linalg.norm(_joint.max(axis=0) - _joint.min(axis=0))), + ) + else: + _psi_scale = 1.0 + _physical_tol = max(0.0, float(atol)) + def _basis_pair(n, xval): # Bernstein basis row B_{i,n}(x) and its derivative row # B'_{i,n} = n * (B_{i-1,n-1} - B_{i,n-1}). @@ -907,28 +1331,36 @@ def _basis_pair(n, xval): return b, d def _delta_F(x): - p1 = eval_surface(P1c, x[0], x[1], rational=False) - p2 = eval_surface(P2c, x[2], x[3], rational=False) + p1 = eval_surface(S1, x[0], x[1], rational=rational) + p2 = eval_surface(S2, x[2], x[3], rational=rational) bb = [_basis_pair(_degs[ax], x[ax])[0] for ax in range(4)] F = np.empty(7) - F[:3] = p1 - p2 - F[3:] = np.einsum(_ES, bb[0], bb[1], bb[2], bb[3], Tstack) + F[:3] = (p1 - p2) / _psi_scale + F[3:] = (np.einsum( + _ES, bb[0], bb[1], bb[2], bb[3], Tstack) / _Tscale) return F def _delta_F_J(x): - p1, du1, dv1 = eval_surface_d1(P1c, x[0], x[1], rational=False) - p2, du2, dv2 = eval_surface_d1(P2c, x[2], x[3], rational=False) + p1, du1, dv1 = eval_surface_d1( + S1, x[0], x[1], rational=rational) + p2, du2, dv2 = eval_surface_d1( + S2, x[2], x[3], rational=rational) pairs = [_basis_pair(_degs[ax], x[ax]) for ax in range(4)] bb = [p[0] for p in pairs] F = np.empty(7) J = np.zeros((7, 4)) - F[:3] = p1 - p2 - J[:3, 0], J[:3, 1], J[:3, 2], J[:3, 3] = du1, dv1, -du2, -dv2 - F[3:] = np.einsum(_ES, bb[0], bb[1], bb[2], bb[3], Tstack) + F[:3] = (p1 - p2) / _psi_scale + J[:3, 0], J[:3, 1], J[:3, 2], J[:3, 3] = ( + du1 / _psi_scale, dv1 / _psi_scale, + -du2 / _psi_scale, -dv2 / _psi_scale, + ) + F[3:] = (np.einsum( + _ES, bb[0], bb[1], bb[2], bb[3], Tstack) / _Tscale) for ax in range(4): rows = [pairs[q][1] if q == ax else bb[q] for q in range(4)] - J[3:, ax] = np.einsum(_ES, rows[0], rows[1], rows[2], - rows[3], Tstack) + J[3:, ax] = (np.einsum( + _ES, rows[0], rows[1], rows[2], rows[3], Tstack) + / _Tscale) return F, J def gn(x0): @@ -938,13 +1370,20 @@ def gn(x0): F, J = _delta_F_J(x) fnorm = float(np.linalg.norm(F)) if fnorm < 1e-10: - return x + if (_physical_psi_residual(x) <= _physical_tol + and float(np.linalg.norm(F[3:])) <= 1e-8 + and _physical_tangency_residual(x) <= 1e-3): + return x + return None try: dx, *_ = np.linalg.lstsq(J, -F, rcond=None) except np.linalg.LinAlgError: return None if float(np.linalg.norm(dx)) < 1e-12: - return x if fnorm < 1e-8 else None + return (x if fnorm < 1e-8 + and _physical_psi_residual(x) <= _physical_tol + and float(np.linalg.norm(F[3:])) <= 1e-8 + and _physical_tangency_residual(x) <= 1e-3 else None) alpha = 1.0 for _ls in range(12): xn = np.clip(x + alpha * dx, 0.0, 1.0) @@ -954,11 +1393,118 @@ def gn(x0): fnorm_prev = fnorm_n break alpha *= 0.5 - return x if float(np.linalg.norm(_delta_F(x))) < 1e-8 else None + F_final = _delta_F(x) + return (x if float(np.linalg.norm(F_final)) < 1e-8 + and _physical_psi_residual(x) <= _physical_tol + and float(np.linalg.norm(F_final[3:])) <= 1e-8 + and _physical_tangency_residual(x) <= 1e-3 else None) + + gn.residual = _delta_F + gn.jacobian = lambda x: _delta_F_J( + np.asarray(x, dtype=np.float64))[1] + gn.roundoff_scale = float(_psi_scale) + def _physical_psi_residual(x): + p1 = eval_surface(S1, x[0], x[1], rational=rational) + p2 = eval_surface(S2, x[2], x[3], rational=rational) + return float(np.linalg.norm(p1 - p2)) + + def _physical_tangency_residual(x): + """Scale-free rank residual for the true quotient surfaces.""" + if _normals_degenerate_at(S1, S2, x): + return np.inf + _, du1, dv1 = eval_surface_d1( + S1, x[0], x[1], rational=rational) + _, du2, dv2 = eval_surface_d1( + S2, x[2], x[3], rational=rational) + n1 = np.cross(du1, dv1) + n2 = np.cross(du2, dv2) + denom = float(np.linalg.norm(n1) * np.linalg.norm(n2)) + if denom <= 1e-300: + return np.inf + return float(np.linalg.norm(np.cross(n1, n2)) / denom) + gn.physical_residual = _physical_psi_residual + gn.physical_tangency_residual = _physical_tangency_residual return gn, Tstack +def _delta_root_local_dimension(gn, root, ptol, charge_work=None): + """Classify a regular Delta root as isolated or locally one-dimensional. + + Returns ``False`` for a full-rank (locally isolated by IFT) root, + ``True`` only after a rank-3 root continues to two distinct, strict- + residual rank-3 roots on opposite sides of its null direction, and + ``None`` when rank/continuation is inconclusive or its shared work charge + is denied. In particular, rank deficiency alone is *not* a curve proof: + an isolated multiple root may also have a deficient Jacobian. + """ + x = np.asarray(root, dtype=np.float64) + ptol = np.maximum(np.asarray(ptol, dtype=np.float64), 1e-12) + + def _rank3(xq): + try: + _, svals, Vt = np.linalg.svd( + np.asarray(gn.jacobian(xq), dtype=np.float64), + full_matrices=True) + except (np.linalg.LinAlgError, FloatingPointError): + return None, None + if len(svals) < 4 or not np.all(np.isfinite(svals)): + return None, None + scale = float(svals[0]) + if scale <= 0.0: + return None, None + # Empirical conditioning gap on the committed adversaries: + # tangent ring s4/s1 <= 6e-9, isolated blind-band touch >= 8e-5. + # The continuation proof below is still mandatory, so this bar only + # selects roots worth probing; it never alone deletes output. + tol = 1e-7 * scale + rank = int(np.count_nonzero(svals > tol)) + if rank == 4: + return False, None + if rank != 3: + return None, None + return True, np.asarray(Vt[-1], dtype=np.float64) + + rank3, null = _rank3(x) + if rank3 is False: + return False + if rank3 is None: + return None + n = float(np.linalg.norm(null)) + if n <= 1e-30: + return None + null = null / n + weighted = float(np.linalg.norm(null / ptol)) + if weighted <= 1e-30: + return None + alpha = 8.0 / weighted + strict_psi = (4096.0 * np.finfo(float).eps + * max(1.0, float(getattr(gn, "roundoff_scale", 1.0)))) + + for sign in (-1.0, 1.0): + seed = x + sign * alpha * null + if np.any(seed <= 0.0) or np.any(seed >= 1.0): + return None + # gn has a fixed 24-iteration cap. Reserve that whole allowance so + # the shared budget is a hard upper bound even when Newton exits early. + if charge_work is not None and not charge_work(24): + return None + neighbour = gn(seed) + if neighbour is None: + return None + neighbour = np.asarray(neighbour, dtype=np.float64) + delta = neighbour - x + if (float(np.max(np.abs(delta) / ptol)) <= 2.0 + or sign * float(np.dot(delta, null)) <= 0.0 + or float(gn.physical_residual(neighbour)) > strict_psi + or float(np.linalg.norm(gn.residual(neighbour))) > 1e-8): + return None + neighbour_rank3, _ = _rank3(neighbour) + if neighbour_rank3 is not True: + return None + return True + + def _dist_point_polyline_nd(p, poly): """Min distance from an n-D point to a polyline's segments (poly: (N,n)).""" p = np.asarray(p, dtype=np.float64) @@ -1023,15 +1569,27 @@ def _emit_offcurve_tangent_roots(cell, fragments_local, atol, unify_tol, ShiftedPositiveNet, VectorBoxNet, psi_vector_net, solve_zero_dim, ) - P1c = cell.g1.surface[..., :-1] / cell.g1.surface[..., -1:] - P2c = cell.g2.surface[..., :-1] / cell.g2.surface[..., -1:] ptol4 = _cell_ptol4(cell, atol) scale = 4.0 * ptol4 # tube radius: matching ladder - stuv_polys = [np.asarray(f.stuv_path, dtype=np.float64) / scale[None, :] - for f in fragments_local if len(f.stuv_path)] - xyz_polys = [np.asarray(f.xyz_path, dtype=np.float64) - for f in fragments_local if len(f.xyz_path)] + # Only a fragment measured tangent along its WHOLE path defines a tube + # of positive-dimensional Delta roots. Deflation also traces the arms + # through an isolated saddle touch and tags them ``tangential`` by + # provenance; treating those transversal-away-from-the-touch arms as a + # curve tube suppresses the very isolated root this pass must recover. + curve_fragments = [ + f for f in fragments_local + if len(f.stuv_path) >= 2 + and _fragment_normals_aligned(cell, f, stuv_local=True) + ] + stuv_polys = [ + np.asarray(f.stuv_path, dtype=np.float64) / scale[None, :] + for f in curve_fragments + ] + xyz_polys = [ + np.asarray(f.xyz_path, dtype=np.float64) + for f in curve_fragments + ] def _scaled_param_dist(p4): if not stuv_polys: @@ -1090,7 +1648,8 @@ def priority(bx): # per box instead of four BoxNets (the elevated hulls are subsets # of the originals — exclusion only tightens). _gn, Tstack = _delta_float_gn(cell.T1, cell.T2, cell.T3, cell.T4, - P1c, P2c) + cell.g1.surface, cell.g2.surface, + rational=True, atol=atol) def _xyz(x): return eval_surface(cell.g1.surface, x[0], x[1], rational=True) @@ -1117,28 +1676,68 @@ def _xyz(x): nets.append(VectorBoxNet(Tstack, axes=(0, 1, 2, 3))) sols, _exhausted = solve_zero_dim( nets, _gn, ptol4, max_cells=max_cells, dedup_xyz=_xyz, atol=atol, - skip_newton=skip_newton, priority=priority) + max_results=64, + skip_newton=skip_newton, priority=priority, + charge_box=_charge_hook(cell.work_budget, "singular")) except (np.linalg.LinAlgError, FloatingPointError): return - if _delta_roots_curve_like(sols, _exhausted): + # A local solver cap is still a completeness boundary even when it + # returns zero or one valid roots. Previously only the curve-like + # (>1-root) signature reacted to ``_exhausted``; a truncated empty + # frontier was therefore reported as a complete absence of off-curve + # tangencies. Preserve any certified roots, but surface partial status + # through the one shared SSX budget. + if _exhausted and cell.work_budget is not None: + cell.work_budget.mark_incomplete(REASON_TANGENTIAL_ZONE) + + curve_signature = _delta_roots_curve_like(sols, _exhausted) + if curve_signature: # Ledger L6(i): the off-tube "roots" carry the 1-dim signature — # this happens when the Φ-tracer failed (no fragments, no tube, so # this degraded to a plain full enumeration of the tangent CURVE: # measured 33- and 17-point floods with nfrags=0 on the off-lattice # tangent ring) or when the tube only partially covers the curve. - # Emit nothing: the curve's typing belongs to tracing, and unlike - # the crossing-less arm there is no subdivision fall-through here - # (the arm `continue`s — the 1349x note), so suppressed samples are - # simply dropped. A genuine multi-touch cell is unaffected (its - # enumeration terminates: 1-2 roots, exhausted=False, measured on - # the blind-band delta-sweep and the line-plus-touch fixtures). - return + # Each root is still classified below. The old blanket return was + # unsafe for a mixed cell: a dense tangent-curve sample cloud can + # coexist with a full-rank isolated touch, which must survive as an + # outlier. Rank+continuation suppresses only the locally 1-D roots. + pass + + isolated_sols = [] + for sol in sols: + local_dimension = _delta_root_local_dimension( + _gn, sol, ptol4, + charge_work=_charge_hook(cell.work_budget, "singular_dimension")) + if local_dimension is True: + continue # certified local tangent-curve sample + if local_dimension is False: + isolated_sols.append(sol) # full-rank Delta root: locally isolated + continue + # Rank-deficient but no certified two-sided continuation (or shared + # budget denial): never turn ambiguity into a false isolated point. + if cell.work_budget is not None: + _sol_g = _local_to_global(np.asarray(sol), cell.box) + _s_reason = ( + REASON_OVERLAP_REGION if _stuv_in_overlap_boxes( + _sol_g, overlap_boxes) + else REASON_MULTIPLICITY) + cell.work_budget.structural_sites.append( + (_s_reason, _sol_g.copy())) + cell.work_budget.mark_incomplete(_s_reason) + sols = isolated_sols for xw in sols: stuv_g = _local_to_global(np.asarray(xw), cell.box) if _stuv_in_overlap_boxes(stuv_g, overlap_boxes): continue + if (_on_collapsed_boundary_fiber( + cell.g1.surface, xw[0], xw[1], rational=True, + param_tol=float(max(ptol4[0], ptol4[1]))) + or _on_collapsed_boundary_fiber( + cell.g2.surface, xw[2], xw[3], rational=True, + param_tol=float(max(ptol4[2], ptol4[3])))): + continue if _normals_degenerate_at(cell.g1.surface, cell.g2.surface, xw): continue # L15: Sigma=0 root — C1 candidate, not a C2 touch xyz_w = eval_surface(cell.g1.surface, xw[0], xw[1], rational=True) @@ -1146,8 +1745,13 @@ def _xyz(x): and np.all(np.abs(g.stuv - stuv_g) <= unify_tol) and float(np.linalg.norm(g.xyz - xyz_w)) <= 2.0 * atol for g in all_singularities): - all_singularities.append(SSXSingularity( - kind="tangent_point", stuv=stuv_g, xyz=xyz_w)) + item = SSXSingularity( + kind="tangent_point", stuv=stuv_g, xyz=xyz_w) + if cell.work_budget is None: + all_singularities.append(item) + else: + cell.work_budget.append_output( + all_singularities, item, "singularity") def _dist_point_polyline(pxyz, poly): @@ -1287,11 +1891,16 @@ def _ssx_tangent_4d(S1, S2, s, t, u, v, rational=True, direction_hint=None): return tangent, pt1, pt2 -def _ssx_correct(S1, S2, s, t, u, v, rational=True, max_iter=5, tol=1e-14): +def _ssx_correct(S1, S2, s, t, u, v, rational=True, max_iter=32, tol=1e-14): """Newton corrector: project (s,t,u,v) back onto the intersection curve. Minimizes ||S1(s,t) - S2(u,v)|| using damped pseudoinverse steps. - Only a few iterations — the predictor should be close. + Tangential intersections converge only linearly in the singular normal + direction. The former 12-step cap left otherwise valid continuation + samples at residuals around 1e-8 (the cylinder/plane tangent-line + control), which is neither a certified root nor evidence that the curve + is absent. Thirty-two bounded steps reach the roundoff certificate for + that family while preserving a hard per-correction termination bound. Returns (s, t, u, v, residual, sin_ang) where: - residual = ||S1(s,t) - S2(u,v)|| at the corrected point @@ -1311,7 +1920,10 @@ def _ssx_correct(S1, S2, s, t, u, v, rational=True, max_iter=5, tol=1e-14): pt2, du2, dv2 = eval_surface_d1(S2, u, v, rational=rational) G = pt1 - pt2 g2 = float(np.dot(G, G)) - if g2 < tol: + # `g2` is a SQUARED norm. Comparing it directly to a length + # tolerance made the actual stopping threshold sqrt(tol)=1e-7 and + # let near-tangent tolerance valleys masquerade as corrected roots. + if g2 < tol * tol: break J = np.column_stack([du1, dv1, -du2, -dv2]) # (3, 4) @@ -1343,6 +1955,21 @@ def _ssx_correct(S1, S2, s, t, u, v, rational=True, max_iter=5, tol=1e-14): return s, t, u, v, float(np.linalg.norm(G)), sin_ang +def _strict_ssx_root_tol(S1, S2, rational=True): + """Translation-invariant roundoff scale for an exact Psi zero.""" + if rational: + p1 = S1[..., :-1] / S1[..., -1:] + p2 = S2[..., :-1] / S2[..., -1:] + else: + p1, p2 = S1, S2 + pts = np.vstack([np.asarray(p1).reshape(-1, 3), + np.asarray(p2).reshape(-1, 3)]) + diag = float(np.linalg.norm(pts.max(axis=0) - pts.min(axis=0))) + scale = max(1.0, diag) + return max(1024.0 * np.finfo(np.float64).eps * scale, + 1e-12 * scale) + + def _march_intersection_curve( S1, S2, stuv_start, stuv_end, @@ -1354,6 +1981,7 @@ def _march_intersection_curve( max_step=0.25, angle_threshold=0.1, # radians — target angle between consecutive tangents max_points=2000, + stats=None, ): """March the intersection curve from stuv_start toward stuv_end. @@ -1405,6 +2033,8 @@ def _march_intersection_curve( # Get initial tangent, using hint to resolve null-space ambiguity tang_prev, _, _ = _ssx_tangent_4d(S1, S2, *current, rational=rational, direction_hint=hint) if tang_prev is None: + if stats is not None: + stats["iterations"] = 0 return np.array(stuv_pts), np.array(xyz_pts) # Orient tangent toward the target @@ -1414,7 +2044,9 @@ def _march_intersection_curve( t3_prev, speed = _tangent_3d(S1, current, tang_prev, rational=rational) rejects = 0 + iterations = 0 for _ in range(max_points): + iterations += 1 # xyz-target → stuv step via the local speed (see _march_to_boundary) step = max(min_step, min(max_step, h / max(speed, 1e-12))) @@ -1529,9 +2161,349 @@ def _march_intersection_curve( stuv_pts.append(current.copy()) xyz_pts.append(pt1.copy()) + if stats is not None: + stats["iterations"] = int(iterations) return np.array(stuv_pts), np.array(xyz_pts) +def _promote_transversal_boundary_fiber_pair( + S1, S2, fibers, ordinary_crossings, overlaps, *, + atol, unify_tol, h_max, max_points=512): + """Turn one certified collapsed-fiber pair into regular branch seeds. + + A collapsed boundary edge has infinitely many parameter preimages for + one xyz endpoint. Two such fibers can nevertheless bound a regular, + transversal SSI component. This helper is deliberately conservative: + it only promotes the pair after a corrected interior witness and a + contiguous, residual-checked march reaches both canonical endpoints. + + The returned crossings are *candidate seeds*, not a topology-completeness + proof. The caller therefore keeps the public result explicitly partial + whenever boundary fibers participated. + """ + stats = {"iterations": 0} + if not fibers: + return [], stats, None + if ordinary_crossings or overlaps: + return [], stats, None + + tol4 = np.maximum(np.asarray(unify_tol, dtype=np.float64), 1e-12) + unique = [] + for fiber in fibers: + duplicate = any( + np.all(np.abs(fiber.stuv - other.stuv) <= tol4) + and float(np.linalg.norm(fiber.xyz - other.xyz)) <= 2.0 * atol + for other in unique + ) + if not duplicate: + unique.append(fiber) + if len(unique) != 2: + return [], stats, None + + first, second = unique + xyz_separation = float(np.linalg.norm(first.xyz - second.xyz)) + if not np.isfinite(xyz_separation) or xyz_separation <= 16.0 * atol: + return [], stats, None + + midpoint = 0.5 * (first.stuv + second.stuv) + corrected = _ssx_correct( + S1, S2, *midpoint, rational=True, max_iter=20) + anchor = np.asarray(corrected[:4], dtype=np.float64) + residual, sin_angle = float(corrected[4]), float(corrected[5]) + guard = np.minimum(np.maximum(tol4, 1e-10), 0.1) + if (not np.all(np.isfinite(anchor)) + or not np.isfinite(residual) or not np.isfinite(sin_angle) + or np.any(anchor <= guard) or np.any(anchor >= 1.0 - guard) + or residual > atol * max(sin_angle, 1e-3)): + return [], stats, None + + # A tangential interior belongs to the regulated Phi/C2 arm (case 14). + # Promotion here is solely for a regular Psi curve with singular endpoint + # parameterizations. + if sin_angle <= 1e-3: + return [], stats, None + if (_on_collapsed_boundary_fiber( + S1, anchor[0], anchor[1], rational=True, + param_tol=float(max(tol4[0], tol4[1]))) + or _on_collapsed_boundary_fiber( + S2, anchor[2], anchor[3], rational=True, + param_tol=float(max(tol4[2], tol4[3])))): + return [], stats, None + + promoted = [] + canonical_stuv = [] + for fiber, other in ((first, second), (second, first)): + stuv = np.asarray(fiber.stuv, dtype=np.float64).copy() + stuv[:2] = _canonicalize_collapsed_fiber_params( + S1, stuv[:2], anchor[:2], rational=True, + param_tol=float(max(tol4[0], tol4[1]))) + stuv[2:] = _canonicalize_collapsed_fiber_params( + S2, stuv[2:], anchor[2:], rational=True, + param_tol=float(max(tol4[2], tol4[3]))) + if (not np.all(np.isfinite(stuv)) + or np.any(stuv < -tol4) or np.any(stuv > 1.0 + tol4)): + return [], stats, None + + fixed_axis, fixed_side = fiber.face + fixed_value = float(fixed_side) + if abs(float(stuv[fixed_axis]) - fixed_value) > tol4[fixed_axis]: + return [], stats, None + + p1 = eval_surface(S1, stuv[0], stuv[1], rational=True) + p2 = eval_surface(S2, stuv[2], stuv[3], rational=True) + xyz = 0.5 * (p1 + p2) + if (not np.all(np.isfinite(xyz)) + or float(np.linalg.norm(p1 - p2)) > 2.0 * atol + or float(np.linalg.norm(xyz - fiber.xyz)) > 2.0 * atol): + return [], stats, None + + hint = np.asarray(other.stuv - fiber.stuv, dtype=np.float64) + # Canonical free coordinates and the corrected interior anchor give a + # better limiting direction than the arbitrary representatives stored + # by boundary CSX. + hint = anchor - stuv if np.linalg.norm(anchor - stuv) > 1e-14 else hint + tangent, _, _ = _ssx_tangent_4d( + S1, S2, *stuv, rational=True, direction_hint=hint) + if tangent is None or not np.all(np.isfinite(tangent)): + return [], stats, None + tangent = np.asarray(tangent, dtype=np.float64) + tangent_norm = float(np.linalg.norm(tangent)) + if tangent_norm <= 1e-14: + return [], stats, None + if float(np.dot(tangent, hint)) < 0.0: + tangent = -tangent + if float(np.dot(tangent, hint)) <= 1e-8 * tangent_norm * np.linalg.norm(hint): + return [], stats, None + + # Every active boundary component must point into the parameter box. + for axis in range(4): + if stuv[axis] <= tol4[axis] and tangent[axis] < -1e-10: + return [], stats, None + if stuv[axis] >= 1.0 - tol4[axis] and tangent[axis] > 1e-10: + return [], stats, None + + _, du1, dv1 = eval_surface_d1( + S1, stuv[0], stuv[1], rational=True) + _, du2, dv2 = eval_surface_d1( + S2, stuv[2], stuv[3], rational=True) + vel1 = tangent[0] * du1 + tangent[1] * dv1 + vel2 = tangent[2] * du2 + tangent[3] * dv2 + speed_floor = max(1e-12, 1e-10 * xyz_separation) + if (float(np.linalg.norm(vel1)) <= speed_floor + or float(np.linalg.norm(vel2)) <= speed_floor + or float(np.linalg.norm(vel1 - vel2)) > 2.0 * atol): + return [], stats, None + + canonical_stuv.append(stuv) + promoted.append(BoundaryPoint( + stuv=stuv, xyz=xyz, face=fiber.face, + tangent_raw=tangent, parameter_fiber=True)) + + # Connectivity is not inferred from a midpoint alone: require one + # bounded continuation to reach the second canonical endpoint and check + # every retained vertex against the physical quotient surfaces. + path_stuv, path_xyz = _march_intersection_curve( + S1, S2, canonical_stuv[0], canonical_stuv[1], + atol=atol, rational=True, h_max=h_max, + min_step=max(float(np.max(tol4)), 1e-9), + max_points=max(1, int(max_points)), stats=stats) + if len(path_stuv) < 2: + return [], stats, None + if (np.any(np.abs(path_stuv[-1] - canonical_stuv[1]) > 2.0 * tol4) + or float(np.linalg.norm(path_xyz[-1] - promoted[1].xyz)) > 2.0 * atol): + return [], stats, None + + saw_regular_interior = False + for idx, stuv in enumerate(np.asarray(path_stuv, dtype=np.float64)): + if not np.all(np.isfinite(stuv)) or np.any(stuv < -tol4) or np.any(stuv > 1.0 + tol4): + return [], stats, None + p1, du1, dv1 = eval_surface_d1( + S1, stuv[0], stuv[1], rational=True) + p2, du2, dv2 = eval_surface_d1( + S2, stuv[2], stuv[3], rational=True) + if (float(np.linalg.norm(p1 - p2)) > 2.0 * atol + or float(np.linalg.norm(p1 - path_xyz[idx])) > 2.0 * atol): + return [], stats, None + n1, n2 = np.cross(du1, dv1), np.cross(du2, dv2) + denom = float(np.linalg.norm(n1) * np.linalg.norm(n2)) + if denom > 1e-30: + path_sin = float(np.linalg.norm(np.cross(n1, n2))) / denom + if path_sin > 1e-3: + saw_regular_interior = True + if not saw_regular_interior: + return [], stats, None + path_stuv = np.asarray(path_stuv, dtype=np.float64) + path_xyz = np.asarray(path_xyz, dtype=np.float64) + path_stuv[0], path_stuv[-1] = promoted[0].stuv, promoted[1].stuv + path_xyz[0], path_xyz[-1] = promoted[0].xyz, promoted[1].xyz + return promoted, stats, (path_stuv, path_xyz) + + +def _ssx_correct_fixed_multiplicity( + S1, S2, stuv_init, fixed_axis, fixed_value, *, rational, max_iter): + """High-precision Newton fallback for a multiple boundary root. + + With one parameter fixed, ``Psi=0`` is a square 3-by-3 system. The + ordinary damped normal equations intentionally suppress singular values + below the LM floor; at a multiplicity-d contact that parks at + ``distance**d ~= roundoff`` -- many geometric tolerances from the root. + Decimal Bernstein evaluation plus pivoted square Newton retains the + small *correlated* residual/Jacobian ratio (``q**d / (d*q**(d-1))``) + without relaxing any residual certificate. Every loop is statically + bounded and line-searched, and failure simply returns the best input so + callers can reject it with their existing strict residual gate. + + This is deliberately a rare fallback, invoked only when the float64 + square Jacobian is numerically rank deficient. + """ + from decimal import Decimal, localcontext + + S1a = np.asarray(S1, dtype=np.float64) + S2a = np.asarray(S2, dtype=np.float64) + total_degree = max( + (S1a.shape[0] - 1) + (S1a.shape[1] - 1), + (S2a.shape[0] - 1) + (S2a.shape[1] - 1), + ) + + with localcontext() as ctx: + ctx.prec = min(160, max(64, 6 * total_degree + 24)) + zero = Decimal(0) + one = Decimal(1) + + def _dec(value): + return Decimal.from_float(float(value)) + + def _basis_pair(n, x): + if n == 0: + return [one], [zero] + def _pow(value, exponent): + return one if exponent == 0 else value ** exponent + + b = [Decimal(math.comb(n, i)) * _pow(x, i) + * _pow(one - x, n - i) + for i in range(n + 1)] + bm = [Decimal(math.comb(n - 1, i)) * _pow(x, i) + * _pow(one - x, n - 1 - i) for i in range(n)] + db = [] + for i in range(n + 1): + left = bm[i - 1] if i > 0 else zero + right = bm[i] if i < n else zero + db.append(Decimal(n) * (left - right)) + return b, db + + control_cache = {} + + def _controls(S): + key = id(S) + out = control_cache.get(key) + if out is None: + arr = np.asarray(S, dtype=np.float64) + out = [[[ _dec(arr[i, j, k]) for k in range(arr.shape[2])] + for j in range(arr.shape[1])] + for i in range(arr.shape[0])] + control_cache[key] = out + return out + + def _surface_d1(S, u, v): + arr = np.asarray(S, dtype=np.float64) + bu, dbu = _basis_pair(arr.shape[0] - 1, u) + bv, dbv = _basis_pair(arr.shape[1] - 1, v) + C = _controls(S) + dim = arr.shape[2] + h = [zero for _ in range(dim)] + hu = [zero for _ in range(dim)] + hv = [zero for _ in range(dim)] + for i in range(arr.shape[0]): + for j in range(arr.shape[1]): + w = bu[i] * bv[j] + wu = dbu[i] * bv[j] + wv = bu[i] * dbv[j] + for k in range(dim): + c = C[i][j][k] + h[k] += w * c + hu[k] += wu * c + hv[k] += wv * c + if not rational: + return h[:3], hu[:3], hv[:3] + W = h[-1] + if W == zero: + raise ZeroDivisionError("zero rational weight") + W2 = W * W + p = [h[k] / W for k in range(3)] + du = [(hu[k] * W - h[k] * hu[-1]) / W2 for k in range(3)] + dv = [(hv[k] * W - h[k] * hv[-1]) / W2 for k in range(3)] + return p, du, dv + + def _solve3(A, b): + A = [list(row) + [b[i]] for i, row in enumerate(A)] + for col in range(3): + pivot = max(range(col, 3), key=lambda r: abs(A[r][col])) + if A[pivot][col] == zero: + return None + if pivot != col: + A[col], A[pivot] = A[pivot], A[col] + piv = A[col][col] + for j in range(col, 4): + A[col][j] /= piv + for r in range(3): + if r == col: + continue + factor = A[r][col] + if factor == zero: + continue + for j in range(col, 4): + A[r][j] -= factor * A[col][j] + return [A[i][3] for i in range(3)] + + x = [_dec(value) for value in stuv_init] + x[fixed_axis] = _dec(fixed_value) + free = [i for i in range(4) if i != fixed_axis] + limit = min(160, max(1, int(max_iter))) + + for _ in range(limit): + p1, du1, dv1 = _surface_d1(S1, x[0], x[1]) + p2, du2, dv2 = _surface_d1(S2, x[2], x[3]) + G = [p1[k] - p2[k] for k in range(3)] + old = sum(g * g for g in G) + if old == zero: + break + cols = (du1, dv1, + [-value for value in du2], + [-value for value in dv2]) + A = [[cols[idx][row] for idx in free] for row in range(3)] + delta = _solve3(A, [-g for g in G]) + if delta is None: + break + dmax = max(abs(value) for value in delta) + if not dmax.is_finite(): + break + # Stay in the local Newton basin. Scaling preserves direction; + # the line search below still owns monotone residual decrease. + if dmax > Decimal("0.25"): + factor = Decimal("0.25") / dmax + delta = [value * factor for value in delta] + + accepted = False + step = one + for _ls in range(24): + cand = list(x) + for k, axis in enumerate(free): + cand[axis] = min(one, max(zero, x[axis] + step * delta[k])) + cand[fixed_axis] = _dec(fixed_value) + q1, _, _ = _surface_d1(S1, cand[0], cand[1]) + q2, _, _ = _surface_d1(S2, cand[2], cand[3]) + new = sum((q1[k] - q2[k]) ** 2 for k in range(3)) + if new < old: + x = cand + accepted = True + break + step *= Decimal("0.5") + if not accepted: + break + + return np.array([float(value) for value in x], dtype=np.float64) + + def _ssx_correct_fixed(S1, S2, stuv_init, fixed_axis: int, fixed_value: float, rational: bool = True, max_iter: int = 60, tol: float = 1e-14): """Damped Newton with line search solving `Ψ(s,t,u,v) = 0` with @@ -1548,7 +2520,7 @@ def _ssx_correct_fixed(S1, S2, stuv_init, fixed_axis: int, fixed_value: float, Newton can need dozens of iterations to reach the same precision. The function therefore: - line-searches each step so ||G|| is monotone-decreasing; - - exits as soon as ||G||² < `tol` (a true zero in machine precision); + - exits as soon as ||G||² < `tol`² (a true zero in machine precision); - else returns the best point found within `max_iter`. Returns `(params, residual, sin_ang)`: @@ -1569,7 +2541,7 @@ def _ssx_correct_fixed(S1, S2, stuv_init, fixed_axis: int, fixed_value: float, g2 = float(np.dot(G, G)) for _ in range(max_iter): - if g2 < tol: + if g2 < tol * tol: break J = np.column_stack([du1, dv1, -du2, -dv2]) # (3, 4) @@ -1634,6 +2606,42 @@ def _ssx_correct_fixed(S1, S2, stuv_init, fixed_axis: int, fixed_value: float, pt2, du2, dv2 = new_pt2, new_du2, new_dv2 residual = float(g2 ** 0.5) + + # A small square singular value plus a small residual is the repeated- + # root trap: LM damping has stopped moving, but residual magnitude alone + # says nothing about root distance. Re-polish from the caller's seed in + # high precision, then recompute every returned measurement in float64. + J = np.column_stack([du1, dv1, -du2, -dv2]) + J_free = J[:, free_cols] + try: + svals = np.linalg.svd(J_free, compute_uv=False) + except np.linalg.LinAlgError: + svals = np.empty(0) + ill_conditioned = bool( + len(svals) == 3 and svals[0] > 0.0 + and svals[-1] <= 1e-7 * svals[0]) + if ill_conditioned: + degree_bound = max( + (S1.shape[0] - 1) + (S1.shape[1] - 1), + (S2.shape[0] - 1) + (S2.shape[1] - 1), + ) + try: + polished = _ssx_correct_fixed_multiplicity( + S1, S2, stuv_init, fixed_axis, fixed_value, + rational=rational, + max_iter=max(64, 8 * degree_bound), + ) + except (ArithmeticError, FloatingPointError, ValueError): + polished = None + if polished is not None: + params = polished.tolist() + pt1, du1, dv1 = eval_surface_d1( + S1, params[0], params[1], rational=rational) + pt2, du2, dv2 = eval_surface_d1( + S2, params[2], params[3], rational=rational) + G = pt1 - pt2 + residual = float(np.linalg.norm(G)) + N1 = np.cross(du1, dv1) N2 = np.cross(du2, dv2) n1m = float(np.linalg.norm(N1)) @@ -1758,6 +2766,7 @@ def _march_to_boundary( max_points=400, direction_hint=None, sag_tol=None, + stats=None, ): """March from stuv_start until the curve hits a domain boundary [0,1]⁴. @@ -1777,12 +2786,20 @@ def _march_to_boundary( (axis, value) of the boundary face the march exited through, or None if the march ended in the interior (failure/truncation). """ + iterations = 0 if sag_tol is None: sag_tol = 2.0 * atol if h_max is None: h_max = max(0.05 * _local_diag(S1, rational=rational), 4.0 * atol) h = h_init if h_init is not None else 0.25 * h_max h_floor = 1e-6 * h_max + # Exit commitment bar (L25): the SAME roundoff-scale root certificate the + # tracer applies to every path vertex. An exit vertex accepted at any + # looser scale is not necessarily a Psi zero — an arc hugging a domain + # face makes the fixed-face Newton stall at the closest-approach WITNESS + # (residual = clearance, no root on the face at all), and committing it + # poisoned the whole fragment under the strict path certificate. + strict_exit_tol = _strict_ssx_root_tol(S1, S2, rational=rational) stuv_pts = [stuv_start.copy()] xyz_pts = [eval_surface(S1, stuv_start[0], stuv_start[1], rational=rational)] @@ -1794,6 +2811,8 @@ def _march_to_boundary( tang_prev, _, _ = _ssx_tangent_4d(S1, S2, *current, rational=rational, direction_hint=direction_hint) if tang_prev is None: + if stats is not None: + stats["iterations"] = 0 return np.array(stuv_pts), np.array(xyz_pts), exit_info # Orient tangent using hint if provided @@ -1804,6 +2823,7 @@ def _march_to_boundary( rejects = 0 for iter_num in range(max_points): + iterations += 1 step = max(min_step, min(max_step, h / max(speed, 1e-12))) predicted = current + step * tang_prev @@ -1817,43 +2837,66 @@ def _march_to_boundary( # boundary-crossing point. stuv_init = current + crossed_alpha * (predicted - current) stuv_init[crossed_axis] = crossed_val - final, fres, fsin = _ssx_correct_fixed( + final, fres, _fsin = _ssx_correct_fixed( S1, S2, stuv_init, fixed_axis=crossed_axis, fixed_value=crossed_val, rational=rational, ) - # Angle-aware acceptance: only commit a converged exit. A - # refused exit leaves exit_info=None and the caller decides - # what to do with the partial trace. - eff_atol = atol * max(fsin, 1e-3) - if fres > eff_atol: - break - - final_xyz = eval_surface(S1, final[0], final[1], rational=rational) - # The exit chord gets the same deviation scrutiny as any other - # step: the fixed-axis Newton can slide along the face far - # from the interpolated guess (case 10: a 0.47-long exit chord - # cutting the corner by 6.5mm). Halving h alone CANNOT shrink - # this chord — the ray-face intersection init (and hence the - # deterministic fixed-axis Newton result) is independent of - # the step length. Instead retarget the predictor at the - # interior halfway point toward the face and run the normal - # corrector on it: the march makes real progress toward the - # boundary and the eventual exit chord shrinks geometrically. interior_len = 0.5 * crossed_alpha * step - if (h > 2.0 * h_floor - and interior_len > 0.25 * min_step - and _mid_chord_deviates(S1, S2, current, final, - xyz_pts[-1], final_xyz, - atol, sag_tol, rational)): + can_retarget = (h > 2.0 * h_floor + and interior_len > 0.25 * min_step) + # Accept-if (L45 convention; audit L54[A1 lead 1]): a NaN fres + # from a degenerate fixed-face solve must land in the REFUSE + # branch, not slip past a reject-if-greater comparison into a + # committed poisoned exit vertex (previously masked only by the + # tracer's downstream finite-guard in a different function). + if not (np.isfinite(fres) and fres <= strict_exit_tol): + # No certified Psi zero on this face: the curve does NOT + # exit here within this step — it hugs the face and stays + # inside (L25 edge-graze), or Newton stalled short on a + # genuine exit. Either way, retarget the predictor at the + # interior halfway point and keep marching: a graze is + # walked PAST (the tangent's face component flips sign at + # the closest approach), while a genuine exit is re-tried + # from a geometrically closer point where Newton certifies. + # The old behavior — commit at atol-scale, else abandon the + # march — either poisoned the fragment with a non-root exit + # vertex (total arc loss under the strict path certificate) + # or silently truncated the branch at the graze. + if not can_retarget: + break + rejects += 1 + if rejects >= 25: + break h = max(h_floor, h * 0.5) predicted = current + interior_len * tang_prev # fall through to the interior corrector below else: - stuv_pts.append(final) - xyz_pts.append(final_xyz) - exit_info = (crossed_axis, crossed_val) - break + final_xyz = eval_surface(S1, final[0], final[1], + rational=rational) + # The exit chord gets the same deviation scrutiny as any + # other step: the fixed-axis Newton can slide along the face + # far from the interpolated guess (case 10: a 0.47-long exit + # chord cutting the corner by 6.5mm). Halving h alone CANNOT + # shrink this chord — the ray-face intersection init (and + # hence the deterministic fixed-axis Newton result) is + # independent of the step length. Instead retarget the + # predictor at the interior halfway point toward the face + # and run the normal corrector on it: the march makes real + # progress toward the boundary and the eventual exit chord + # shrinks geometrically. + if (can_retarget + and _mid_chord_deviates(S1, S2, current, final, + xyz_pts[-1], final_xyz, + atol, sag_tol, rational)): + h = max(h_floor, h * 0.5) + predicted = current + interior_len * tang_prev + # fall through to the interior corrector below + else: + stuv_pts.append(final) + xyz_pts.append(final_xyz) + exit_info = (crossed_axis, crossed_val) + break # Interior corrector path (also handles the retargeted predictor # from a rejected exit chord above). @@ -1932,6 +2975,8 @@ def _march_to_boundary( stuv_pts.append(current.copy()) xyz_pts.append(pt1.copy()) + if stats is not None: + stats["iterations"] = int(iterations) return np.array(stuv_pts), np.array(xyz_pts), exit_info @@ -2014,6 +3059,20 @@ def _pair_crossings_for_tracing(crossings, originals=None, cell=None): # Level 4b: Φ-tracer for C₂ tangent cells # --------------------------------------------------------------------------- +def _normalize_t_net_numeric(T): + """Positive per-equation scaling for numerical T-Psi consumers. + + Homogeneous rescaling of either rational surface multiplies the four + numerator minors by different positive powers. Their zeros/signs are + unchanged, but raw magnitudes make equation ranking and least-squares + correction representation-dependent. Numerical Φ/Δ paths use this + max-abs-normalized view; proof-oriented hull nets remain untouched. + """ + arr = np.asarray(T, dtype=np.float64) + scale = float(np.max(np.abs(arr))) if arr.size else 0.0 + return arr / scale if np.isfinite(scale) and scale > 0.0 else arr.copy() + + def _choose_phi_equations(S1, S2, T_arrs, seed_stuv, rational, ranked=False): """Choose the best 2 Ψ equations + 1 TΨ equation for the regulated system Φ. @@ -2036,7 +3095,7 @@ def _choose_phi_equations(S1, S2, T_arrs, seed_stuv, rational, ranked=False): scored: list[tuple[float, tuple[int, int], int]] = [] for ti in range(4): - Tv = T_arrs[ti] + Tv = _normalize_t_net_numeric(T_arrs[ti]) grad = np.zeros(4) for axis in range(4): dT = bernstein_partial_derivative_coeffs(Tv, axis=axis) @@ -2100,6 +3159,7 @@ def _march_phi_curve( max_step=0.25, angle_threshold=0.1, max_points=2000, + stats=None, ): """March along the Φ-curve from stuv_start toward stuv_end. @@ -2160,10 +3220,30 @@ def _phi_dir_speed(x, tang): sp2 = float(np.linalg.norm(du2 * tang[2] + dv2 * tang[3])) return d1, max(sp1, sp2) + def _null_tangent(J, hint): + """Project a continuation hint into the full numerical nullspace.""" + _, svals, Vt = np.linalg.svd(J, full_matrices=True) + scale = float(svals[0]) if len(svals) else 0.0 + tol = max(J.shape) * np.finfo(float).eps * scale + rank = int(np.count_nonzero(svals > tol)) if scale > 0.0 else 0 + null_rows = Vt[rank:, :] + if not len(null_rows): + return None + hint = np.asarray(hint, dtype=np.float64) + tang = null_rows.T @ (null_rows @ hint) + n = float(np.linalg.norm(tang)) + if n <= 1e-14: + tang = null_rows[-1].copy() + n = float(np.linalg.norm(tang)) + return tang / n if n > 0.0 else None + # Get tangent direction from Φ Jacobian J = _jac_phi(S1, S2, T_arr, psi_rows, *current, rational=rational) - _, _, Vt = np.linalg.svd(J, full_matrices=True) - tang_prev = Vt[-1] + tang_prev = _null_tangent(J, target - current) + if tang_prev is None: + if stats is not None: + stats["iterations"] = 0 + return np.asarray(stuv_pts), np.asarray(xyz_pts) if np.dot(tang_prev, target - current) < 0: tang_prev = -tang_prev @@ -2171,7 +3251,9 @@ def _phi_dir_speed(x, tang): t3_prev, speed = _phi_dir_speed(current, tang_prev) rejects = 0 + iterations = 0 for _ in range(max_points): + iterations += 1 step = max(min_step, min(max_step, h / max(speed, 1e-12))) dist_to_end = float(np.linalg.norm(current - target)) @@ -2206,8 +3288,9 @@ def _phi_dir_speed(x, tang): # New tangent J = _jac_phi(S1, S2, T_arr, psi_rows, *x, rational=rational) try: - _, _, Vt = np.linalg.svd(J, full_matrices=True) - tang_new = Vt[-1] + tang_new = _null_tangent(J, tang_prev) + if tang_new is None: + raise np.linalg.LinAlgError("empty Phi nullspace") except np.linalg.LinAlgError: rejects += 1 if rejects >= 25: @@ -2254,6 +3337,8 @@ def _phi_dir_speed(x, tang): stuv_pts.append(current.copy()) xyz_pts.append(pt1.copy()) + if stats is not None: + stats["iterations"] = int(iterations) return np.array(stuv_pts), np.array(xyz_pts) @@ -2272,7 +3357,7 @@ def _cell_ptol4(cell, atol): def _march_closed_from_seed(seed, correct, tangent, midcheck, atol, h_max, displace=0.02, min_step=1e-6, max_step=0.25, angle_threshold=0.1, max_points=2000, - sag_tol=None): + sag_tol=None, stats=None): """Closed-loop predictor-corrector engine: displace one step off `seed` along the curve tangent, then march AWAY until the path returns to the seed. System-agnostic — the Ψ and Φ marchers supply the callbacks: @@ -2299,26 +3384,33 @@ def _march_closed_from_seed(seed, correct, tangent, midcheck, atol, h_max, engine — at the size-gated terminal cells where the seeding runs, such loops are within tolerance of the emitted tangent point itself. """ + iterations = 0 + + def _finish(value): + if stats is not None: + stats["iterations"] = int(iterations) + return value + seed = np.asarray(seed, dtype=np.float64) x0, xyz0, ok = correct(seed) if not ok: - return None + return _finish(None) seed = x0 # snap the seed onto the curve tang, _d3, speed0 = tangent(seed, None) if tang is None or speed0 <= 0.0: - return None + return _finish(None) step0 = abs(float(displace)) if displace < 0: tang = -tang x1_pred = np.clip(seed + step0 * tang, 0.0, 1.0) x1, xyz1, ok = correct(x1_pred) if not ok or float(np.linalg.norm(x1 - seed)) < 0.25 * step0: - return None # displacement collapsed back + return _finish(None) # displacement collapsed back away = x1 - seed away /= float(np.linalg.norm(away)) tang_prev, t3_prev, speed = tangent(x1, away) if tang_prev is None: - return None + return _finish(None) stuv_pts = [seed.copy(), x1.copy()] xyz_pts = [np.asarray(xyz0, dtype=np.float64), @@ -2334,6 +3426,7 @@ def _march_closed_from_seed(seed, correct, tangent, midcheck, atol, h_max, rejects = 0 for _ in range(max_points): + iterations += 1 step = max(min_step, min(max_step, h / max(speed, 1e-12))) dist = float(np.linalg.norm(current - seed)) if not armed and dist > arm_radius: @@ -2368,7 +3461,7 @@ def _march_closed_from_seed(seed, correct, tangent, midcheck, atol, h_max, # The loop leaves this cell — not an interior loop; the size-gated # subdivision fall-through owns boundary-crossing features. if np.any(x <= 1e-9) or np.any(x >= 1.0 - 1e-9): - return None + return _finish(None) tang_new, t3_new, speed_new = tangent(x, tang_prev) if tang_new is None: @@ -2418,11 +3511,13 @@ def _march_closed_from_seed(seed, correct, tangent, midcheck, atol, h_max, xyz_pts.append(np.asarray(xyz, dtype=np.float64)) if not closed: - return None - return np.array(stuv_pts), np.array(xyz_pts) + return _finish(None) + return _finish((np.array(stuv_pts), np.array(xyz_pts))) -def _march_psi_closed(cell, seed_local, atol, h_max, displace=0.02): +def _march_psi_closed( + cell, seed_local, atol, h_max, displace=0.02, *, + max_points=2000, stats=None): """Closed-loop march of the ordinary (transversal) Ψ system from a full-Ψ seed strictly inside the cell. Backend for Φ∩L seeds that refine onto a TRANSVERSAL loop point (Ψ-Jacobian rank 3): the Φ curve @@ -2464,7 +3559,8 @@ def midcheck(a4, b4, a3, b3): res = _march_closed_from_seed(seed_local, correct, tangent, midcheck, atol, h_max, displace=displace, - sag_tol=0.5 * atol) + sag_tol=0.5 * atol, + max_points=max_points, stats=stats) if res is None: return None stuv_path, xyz_path = res @@ -2479,7 +3575,7 @@ def midcheck(a4, b4, a3, b3): def _march_phi_closed(cell, seed_local, psi_rows, t_idx, atol, h_max, - displace=0.02): + displace=0.02, *, max_points=2000, stats=None): """March Φ = {Ψ_a, Ψ_b, TΨ_k} from a seed with no known endpoint until the path returns to its start (closed loop) or exits the cell. Keeps only Ψ-valid samples (|S1-S2| < atol); requires >= 6 valid samples @@ -2496,19 +3592,19 @@ def _march_phi_closed(cell, seed_local, psi_rows, t_idx, atol, h_max, Returns a GLOBAL closed `_Fragment` (start/end = None) or None. """ - T_arrs = [np.asarray(T, dtype=np.float64)[..., None] + T_arrs = [_normalize_t_net_numeric(T)[..., None] for T in (cell.T1, cell.T2, cell.T3, cell.T4)] T_arr = T_arrs[t_idx] - P1c = cell.g1.surface[..., :-1] / cell.g1.surface[..., -1:] - P2c = cell.g2.surface[..., :-1] / cell.g2.surface[..., -1:] + S1h = cell.g1.surface + S2h = cell.g2.surface def correct(x0): x = np.asarray(x0, dtype=np.float64).copy() for _ in range(5): - f = _eval_phi(P1c, P2c, T_arr, psi_rows, *x, rational=False) + f = _eval_phi(S1h, S2h, T_arr, psi_rows, *x, rational=True) if float(np.dot(f, f)) < 1e-20: break - Jc = _jac_phi(P1c, P2c, T_arr, psi_rows, *x, rational=False) + Jc = _jac_phi(S1h, S2h, T_arr, psi_rows, *x, rational=True) A = Jc.T @ Jc + 1e-12 * np.eye(4) try: delta = np.linalg.solve(A, -Jc.T @ f) @@ -2516,13 +3612,13 @@ def correct(x0): break x = np.clip(x + delta, 0.0, 1.0) res = float(np.linalg.norm( - _eval_phi(P1c, P2c, T_arr, psi_rows, *x, rational=False))) + _eval_phi(S1h, S2h, T_arr, psi_rows, *x, rational=True))) # Same acceptance as _march_phi_curve's corrector (T-component units # are not xyz units — the 100x slack absorbs the scale mismatch). - return x, eval_surface(P1c, x[0], x[1], rational=False), res <= atol * 100.0 + return x, eval_surface(S1h, x[0], x[1], rational=True), res <= atol * 100.0 def tangent(x, prev): - J = _jac_phi(P1c, P2c, T_arr, psi_rows, *x, rational=False) + J = _jac_phi(S1h, S2h, T_arr, psi_rows, *x, rational=True) try: _, _, Vt = np.linalg.svd(J, full_matrices=True) except np.linalg.LinAlgError: @@ -2530,8 +3626,8 @@ def tangent(x, prev): tang = Vt[-1] if prev is not None and float(np.dot(tang, prev)) < 0.0: tang = -tang - d3, sp1 = _tangent_3d(P1c, x, tang, rational=False) - _, du2, dv2 = eval_surface_d1(P2c, x[2], x[3], rational=False) + d3, sp1 = _tangent_3d(S1h, x, tang, rational=True) + _, du2, dv2 = eval_surface_d1(S2h, x[2], x[3], rational=True) sp2 = float(np.linalg.norm(du2 * tang[2] + dv2 * tang[3])) return tang, d3, max(sp1, sp2) @@ -2539,7 +3635,7 @@ def midcheck(a4, b4, a3, b3): xm, _, okm = correct(0.5 * (np.asarray(a4) + np.asarray(b4))) if not okm: return False - pm = eval_surface(P1c, xm[0], xm[1], rational=False) + pm = eval_surface(S1h, xm[0], xm[1], rational=True) a3 = np.asarray(a3, dtype=np.float64) b3 = np.asarray(b3, dtype=np.float64) ab = b3 - a3 @@ -2553,7 +3649,8 @@ def midcheck(a4, b4, a3, b3): res = _march_closed_from_seed(seed_local, correct, tangent, midcheck, atol, h_max, displace=displace, - sag_tol=0.5 * atol) + sag_tol=0.5 * atol, + max_points=max_points, stats=stats) if res is None: return None stuv_path, xyz_path = res @@ -2564,23 +3661,22 @@ def midcheck(a4, b4, a3, b3): # the actual intersection. keep = [] for k in range(len(stuv_path)): - p1 = eval_surface(P1c, stuv_path[k, 0], stuv_path[k, 1], rational=False) - p2 = eval_surface(P2c, stuv_path[k, 2], stuv_path[k, 3], rational=False) + p1 = eval_surface(S1h, stuv_path[k, 0], stuv_path[k, 1], rational=True) + p2 = eval_surface(S2h, stuv_path[k, 2], stuv_path[k, 3], rational=True) if float(np.linalg.norm(p1 - p2)) < atol: keep.append(k) if len(keep) < 6: return None - # Connectivity re-check: the fragment is emitted as CLOSED (start/end - # None), but the keep-filter runs post-closure and can cut samples out - # of the middle — e.g. a through-the-singularity march is Ψ-valid near - # the seed and near the tangency but not between them. Kept samples - # must form ONE contiguous run on the cycle (path[0] == path[-1], so - # indices live on a ring); otherwise Ψ-validity has fragmented the - # loop and this attempt fails — which is exactly what the caller's - # ranked-equation retry exists for (_phi_slice_loop_fragments). - kept_mask = np.zeros(len(stuv_path), dtype=bool) - kept_mask[keep] = True - if int(np.sum(kept_mask & ~np.roll(kept_mask, 1))) > 1: + # A CLOSED output must retain the whole closed march. The older + # "one contiguous cyclic run" test was unsound because ``stuv_path`` + # stores the seed twice (indices 0 and -1): removing one connected arc + # could still look like one run under ``np.roll`` and then ship an OPEN + # half-loop with ``start_point=end_point=None``. Besides corrupting the + # geometry, that open fragment failed to subsume the Delta samples and + # revived the tangent-point flood. Any Psi-invalid sample invalidates + # the closure claim; let the ranked-equation/sign retry find a fully + # valid regulated curve instead. + if len(keep) != len(stuv_path): return None stuv_g = np.array([_local_to_global(stuv_path[k], cell.box) for k in keep]) xyz_g = xyz_path[np.asarray(keep)] @@ -2588,7 +3684,8 @@ def midcheck(a4, b4, a3, b3): stuv_path=stuv_g, xyz_path=xyz_g, tangential=True) -def _fragment_normals_aligned(cell, frag, bar=1e-3): +def _fragment_normals_aligned( + cell, frag, bar=1e-3, *, stuv_local=False): """Tangency-by-MEASUREMENT test on a fragment's kept samples: the two surface normals must stay aligned, sin(angle) <= the pipeline's own 1e-3 transversality bar, at EVERY sample. Two consumers: @@ -2623,7 +3720,8 @@ def _fragment_normals_aligned(cell, frag, bar=1e-3): """ S1h, S2h = cell.g1.surface, cell.g2.surface for x_g in np.asarray(frag.stuv_path): - x = _global_to_local(x_g, cell.box) + x = (np.asarray(x_g, dtype=np.float64) if stuv_local + else _global_to_local(x_g, cell.box)) _, du1, dv1 = eval_surface_d1(S1h, x[0], x[1], rational=True) _, du2, dv2 = eval_surface_d1(S2h, x[2], x[3], rational=True) N1 = np.cross(du1, dv1) @@ -2684,10 +3782,9 @@ def _fragment_on_tangent_locus(cell, frag, atol): S1h = cell.g1.surface xyz_path = np.asarray(frag.xyz_path) try: - P1c = S1h[..., :-1] / S1h[..., -1:] - P2c = cell.g2.surface[..., :-1] / cell.g2.surface[..., -1:] gn, _Tstack = _delta_float_gn(cell.T1, cell.T2, cell.T3, cell.T4, - P1c, P2c) + S1h, cell.g2.surface, rational=True, + atol=atol) for k, x_g in enumerate(np.asarray(frag.stuv_path)): x = _global_to_local(x_g, cell.box) xw = gn(x) @@ -2751,23 +3848,41 @@ def _phi_slice_loop_fragments(cell, roots, atol, h_max, all_singularities): """ from mmcore.numeric.intersection.ssx._ssx5_singular import phi_loop_seeds - P1c = cell.g1.surface[..., :-1] / cell.g1.surface[..., -1:] - P2c = cell.g2.surface[..., :-1] / cell.g2.surface[..., -1:] - T_arrs = [np.asarray(T, dtype=np.float64)[..., None] - for T in (cell.T1, cell.T2, cell.T3, cell.T4)] + S1h = cell.g1.surface + S2h = cell.g2.surface + T_numeric = tuple(_normalize_t_net_numeric(T) + for T in (cell.T1, cell.T2, cell.T3, cell.T4)) + T_arrs = [T[..., None] for T in T_numeric] seed_pt = np.asarray(roots[0]) if roots else np.full(4, 0.5) - ranked = _choose_phi_equations(P1c, P2c, T_arrs, seed_pt, - rational=False, ranked=True) + ranked = _choose_phi_equations(S1h, S2h, T_arrs, seed_pt, + rational=True, ranked=True) if not ranked: return [] psi_rows, t_idx = ranked[0] ptol4 = _cell_ptol4(cell, atol) + _phi_stats = {} try: - seeds = phi_loop_seeds(cell.g1.surface, cell.g2.surface, - (cell.T1, cell.T2, cell.T3, cell.T4), - psi_rows, t_idx, atol, ptol=ptol4) + seeds = phi_loop_seeds( + cell.g1.surface, cell.g2.surface, + T_numeric, + psi_rows, t_idx, atol, ptol=ptol4, + charge_box=_charge_hook(cell.work_budget, "phi"), + stats=_phi_stats) except (np.linalg.LinAlgError, FloatingPointError): return [] + if (cell.work_budget is not None + and (_phi_stats.get("budget_exhausted", False) + or _phi_stats.get("external_budget_exhausted", False))): + # Mid-plane seeds are partial evidence when any slice enumeration + # truncates: a missed seed can own an otherwise boundary-free loop. + # Keep already certified seeds/fragments, but never call the result + # globally complete. + cell.work_budget.mark_incomplete(REASON_TANGENTIAL_ZONE) + if cell.work_budget.exhausted: + # A denied shared charge cannot recover during this synchronous + # call. Do not spend uncharged corrector/dedup work on partial + # seeds that cannot be marched under the exhausted budget. + return [] if not seeds: return [] @@ -2778,6 +3893,23 @@ def _near_tangent_point(path_xyz): return any(float(np.linalg.norm(np.asarray(p) - txyz)) <= 2.0 * atol for p in np.asarray(path_xyz) for txyz in tangent_xyz) + def _bounded_closed_march(marcher, *args, **kwargs): + work_budget = cell.work_budget + if work_budget is None: + return marcher(*args, **kwargs) + limit = min(2000, work_budget.remaining_cells) + if limit <= 0: + work_budget.mark_exhausted() + return None + march_stats = {} + frag = marcher( + *args, **kwargs, max_points=limit, stats=march_stats) + if not work_budget.charge_cells( + int(march_stats.get("iterations", 0)), + "singular_trace"): + return None + return frag + refined: list[tuple] = [] for seed in seeds: s, t, u, v, res, sin_ang = _ssx_correct( @@ -2792,20 +3924,74 @@ def _near_tangent_point(path_xyz): continue # the tangency itself — nothing to march if any(np.all(np.abs(x - rx) <= ptol4) and float(np.linalg.norm(xyz - rxyz)) <= atol - for rx, rxyz, _ in refined): + for rx, rxyz, _, _ in refined): continue # destructive dedup: 1·ptol AND atol xyz - refined.append((x, xyz, sin_ang)) + # Equation conditioning is local along a singular curve. Reusing + # the ranking at ``roots[0]`` for every loop seed selected a minor + # whose regulating gradient collapsed at an off-lattice ring's + # cardinal point; its null tangent pointed radially inward and made + # a short, Psi-invalid pseudo-loop. Rank again at the corrected seed + # and keep the existing two-candidate retry and full-Psi gates. + seed_ranked = _choose_phi_equations( + S1h, S2h, T_arrs, x, rational=True, ranked=True) + if not seed_ranked: + continue + # Backend selection starts from topological rank. A perfectly regular + # transversal curve may meet at a very small angle (the eps=1e-3 + # touch-plus-loop ring has sin(angle)=1.26e-4); the old 1e-3 angle + # heuristic routed it through Phi and lost the real loop. Conversely + # an exact tangent curve has rank(Psi') <= 2, but a seed a few ulps off + # that curve can look numerically rank-3 (off-lattice tangent ring: + # sin=8.1e-6). Require both numerical rank 3 and a much narrower + # 1e-5 conditioning guard. More ill-conditioned genuine transversal + # cases conservatively take the regulated/partial path. + _p1, _ds1, _dt1 = eval_surface_d1( + S1h, x[0], x[1], rational=True) + _p2, _du2, _dv2 = eval_surface_d1( + S2h, x[2], x[3], rational=True) + _Jpsi = np.column_stack([_ds1, _dt1, -_du2, -_dv2]) + try: + _svals = np.linalg.svd(_Jpsi, compute_uv=False) + except np.linalg.LinAlgError: + _svals = np.empty(0) + _psi_rank3 = bool( + len(_svals) == 3 and _svals[0] > 0.0 + and _svals[-1] > max(_Jpsi.shape) * _svals[0] * 1e-10 + and sin_ang > 1e-5) + refined.append((x, xyz, _psi_rank3, seed_ranked)) fragments: list[_Fragment] = [] - for x, xyz, sin_ang in refined: + for refined_idx, (x, xyz, psi_rank3, seed_ranked) in enumerate(refined): if any(len(fr.xyz_path) >= 2 and _dist_point_polyline(xyz, np.asarray(fr.xyz_path)) <= 2.0 * atol for fr in fragments): continue # this loop is already marched + # The closed-loop engine arms only after travelling 3*displace. + # A fixed 0.02 local displacement cannot arm on a smaller genuine + # loop (the eps=1e-3 transversal ring has 0.0316 parameter diameter), + # even though deterministic Phi slices provide several distinct + # seeds around it. Use their nearest-neighbour spacing to size a + # bounded local displacement, floored at four resolution cells. + _other_seed_dist = [ + float(np.linalg.norm(x - other[0])) + for j, other in enumerate(refined) if j != refined_idx + ] + if _other_seed_dist: + _seed_displace = min( + 0.02, + max(4.0 * float(np.max(ptol4)), + 0.2 * min(_other_seed_dist))) + else: + _seed_displace = 0.02 frag = None - if sin_ang > 1e-3: - for disp in (0.02, -0.02): - frag = _march_psi_closed(cell, x, atol, h_max, displace=disp) + if psi_rank3: + for disp in (_seed_displace, -_seed_displace): + if (cell.work_budget is not None + and cell.work_budget.exhausted): + break + frag = _bounded_closed_march( + _march_psi_closed, cell, x, atol, h_max, + displace=disp) if frag is not None and not _near_tangent_point(frag.xyz_path): break frag = None @@ -2818,10 +4004,14 @@ def _near_tangent_point(path_xyz): # march starts from a TRANSVERSAL seed, which the Ψ branch # above owns.) phantom = False - for pr, ti in ranked[:2]: - for disp in (0.02, -0.02): - frag = _march_phi_closed(cell, x, pr, ti, atol, h_max, - displace=disp) + for pr, ti in seed_ranked[:2]: + for disp in (_seed_displace, -_seed_displace): + if (cell.work_budget is not None + and cell.work_budget.exhausted): + break + frag = _bounded_closed_march( + _march_phi_closed, cell, x, pr, ti, atol, h_max, + displace=disp) if (frag is not None and not _fragment_normals_aligned(cell, frag)): # Ledger L2: transversal-normal somewhere along the @@ -2837,6 +4027,9 @@ def _near_tangent_point(path_xyz): break if frag is not None: break + if (cell.work_budget is not None + and cell.work_budget.exhausted): + break if frag is not None or phantom: break if frag is not None: @@ -2844,8 +4037,9 @@ def _near_tangent_point(path_xyz): return fragments -def _deflate_tangent_cell(P1_cart, P2_cart, T1, T2, T3, T4, box, crossings, atol, - *, originals=None, cell=None, h_max=None): +def _deflate_tangent_cell(S1, S2, T1, T2, T3, T4, box, crossings, atol, + *, rational=True, originals=None, cell=None, + h_max=None): """Handle a confirmed-tangent cell by tracing the regulated Φ curve. 1. Choose the best Φ = {Ψ_i, Ψ_j, TΨ_k} equations @@ -2858,9 +4052,8 @@ def _deflate_tangent_cell(P1_cart, P2_cart, T1, T2, T3, T4, box, crossings, atol provided, otherwise None — so the §9 assembly can chain Φ-fragments alongside Ψ-fragments (design §8). """ - from mmcore.numeric.bern import bernstein_partial_derivative_coeffs - - T_arrs = [np.asarray(T, dtype=np.float64)[..., np.newaxis] for T in [T1, T2, T3, T4]] + T_arrs = [_normalize_t_net_numeric(T)[..., np.newaxis] + for T in (T1, T2, T3, T4)] fragments: list[_Fragment] = [] points: list[SSXPoint] = [] @@ -2870,37 +4063,167 @@ def _deflate_tangent_cell(P1_cart, P2_cart, T1, T2, T3, T4, box, crossings, atol points.append(SSXPoint(stuv=c.stuv, xyz=c.xyz)) return fragments, points - # Choose Φ equations from the first crossing - psi_rows, t_idx = _choose_phi_equations( - P1_cart, P2_cart, T_arrs, crossings[0].stuv, rational=False, - ) - T_chosen = T_arrs[t_idx] - pairs, unpaired = _pair_crossings_for_tracing(crossings, originals=originals, cell=cell) + if cell is not None: + end_tol = 4.0 * _cell_ptol4(cell, atol) + else: + from mmcore.geom._nurbs_param_tol import bez_surface_param_tolerance + ps, pt = bez_surface_param_tolerance(S1, atol, rational=rational) + pu, pv = bez_surface_param_tolerance(S2, atol, rational=rational) + end_tol = 4.0 * np.maximum( + np.array([ps, pt, pu, pv], dtype=np.float64), 1e-9) + + def _pair_alignment(candidate, start, target): + psi_rows, ti = candidate + J = _jac_phi(S1, S2, T_arrs[ti], psi_rows, + *start, rational=rational) + try: + _, svals, Vt = np.linalg.svd(J, full_matrices=True) + except np.linalg.LinAlgError: + return -np.inf + scale = float(svals[0]) if len(svals) else 0.0 + tol = max(J.shape) * np.finfo(float).eps * scale + rank = int(np.count_nonzero(svals > tol)) if scale > 0.0 else 0 + null_rows = Vt[rank:, :] + hint = np.asarray(target) - np.asarray(start) + hnorm = float(np.linalg.norm(hint)) + if not len(null_rows) or hnorm <= 1e-30: + return -np.inf + projected = null_rows.T @ (null_rows @ hint) + return float(np.linalg.norm(projected) / hnorm) + for i, j in pairs: - stuv_path, xyz_path = _march_phi_curve( - P1_cart, P2_cart, T_chosen, psi_rows, - crossings[i].stuv, crossings[j].stuv, - atol=atol, rational=False, h_max=h_max, - ) - if len(stuv_path) < 2: - continue - # Check that points lie on the actual intersection (full Ψ=0). - valid_mask = np.zeros(len(stuv_path), dtype=bool) - for k in range(len(stuv_path)): - p1 = eval_surface(P1_cart, stuv_path[k, 0], stuv_path[k, 1], rational=False) - p2 = eval_surface(P2_cart, stuv_path[k, 2], stuv_path[k, 3], rational=False) - if np.linalg.norm(p1 - p2) < atol: - valid_mask[k] = True - if not np.any(valid_mask): + accepted = None + accepted_indices = None + forward_start = np.asarray(crossings[i].stuv, dtype=np.float64) + forward_target = np.asarray(crossings[j].stuv, dtype=np.float64) + + def _valid_endpoint_path(stuv_path, xyz_path, target): + if len(stuv_path) < 2: + return False + if any(float(np.linalg.norm( + eval_surface(S1, x[0], x[1], rational=rational) + - eval_surface( + S2, x[2], x[3], rational=rational))) >= atol + for x in stuv_path): + return False + target_xyz = eval_surface( + S1, target[0], target[1], rational=rational) + return bool( + np.all(np.abs( + np.asarray(stuv_path[-1]) - target) <= end_tol) + and float(np.linalg.norm( + np.asarray(xyz_path[-1]) - target_xyz)) + <= 2.0 * atol) + + # Prefer the full physical Psi system. With a continuation hint, + # its full-nullspace projection can trace many tangent curves even + # though rank(Psi') < 3; every vertex is then constrained by all + # three surface-residual equations. The regulated Phi fallback is + # still required for harder singularities (notably case 14), where + # the rank-deficient Psi continuation cannot choose the branch. + for start_idx, target_idx in ((i, j), (j, i)): + start = np.asarray( + crossings[start_idx].stuv, dtype=np.float64) + target = np.asarray( + crossings[target_idx].stuv, dtype=np.float64) + trace_limit = 512 + if cell is not None and cell.work_budget is not None: + trace_limit = min( + trace_limit, cell.work_budget.remaining_cells) + if trace_limit <= 0: + cell.work_budget.mark_exhausted() + break + trace_stats = {} + stuv_path, xyz_path = _march_intersection_curve( + S1, S2, start, target, + atol=atol, rational=rational, h_max=h_max, + max_points=trace_limit, stats=trace_stats, + ) + if cell is not None and cell.work_budget is not None: + if not cell.work_budget.charge_cells( + int(trace_stats.get("iterations", 0)), + "singular_trace"): + break + if _valid_endpoint_path(stuv_path, xyz_path, target): + accepted = (stuv_path, xyz_path) + accepted_indices = (start_idx, target_idx) + break + + # Continuation of a rank-deficient Phi curve can be numerically + # directional even though the geometric endpoint pair is not. On a + # closed tangent ring split into two cells, one half reached its + # target only when marched out->in; forcing the registration's + # in->out orientation dropped that half, shipped an open semicircle, + # and left dozens of Delta samples unsubsumed. Try the reverse + # orientation immediately after each ranked equation. Every attempt + # still has to pass the full-Psi and endpoint-reach checks below, so + # this adds no optimistic connectivity assumption. + if (accepted is None + and not (cell is not None + and cell.work_budget is not None + and cell.work_budget.exhausted)): + candidates = _choose_phi_equations( + S1, S2, T_arrs, forward_start, + rational=rational, ranked=True) + candidates = sorted( + enumerate(candidates), + key=lambda entry: ( + -_pair_alignment( + entry[1], forward_start, forward_target), + entry[0]), + ) + for _, (psi_rows, t_idx) in candidates: + if (cell is not None and cell.work_budget is not None + and cell.work_budget.exhausted): + break + for start_idx, target_idx in ((i, j), (j, i)): + trace_limit = 512 + if cell is not None and cell.work_budget is not None: + trace_limit = min( + trace_limit, + cell.work_budget.remaining_cells) + if trace_limit <= 0: + cell.work_budget.mark_exhausted() + break + start = np.asarray( + crossings[start_idx].stuv, dtype=np.float64) + target = np.asarray( + crossings[target_idx].stuv, dtype=np.float64) + trace_stats = {} + stuv_path, xyz_path = _march_phi_curve( + S1, S2, T_arrs[t_idx], psi_rows, start, target, + atol=atol, rational=rational, h_max=h_max, + max_points=trace_limit, stats=trace_stats, + ) + if cell is not None and cell.work_budget is not None: + if not cell.work_budget.charge_cells( + int(trace_stats.get("iterations", 0)), + "singular_trace"): + break + if _valid_endpoint_path( + stuv_path, xyz_path, target): + accepted = (stuv_path, xyz_path) + accepted_indices = (start_idx, target_idx) + break + if (cell is not None and cell.work_budget is not None + and cell.work_budget.exhausted): + break + if accepted is not None: + break + if accepted is None: + if cell is not None and cell.work_budget is not None: + cell.work_budget.mark_incomplete(REASON_TANGENTIAL_ZONE) continue - start_pt = originals[i] if originals is not None else None - end_pt = originals[j] if originals is not None else None + stuv_path, xyz_path = accepted + start_idx, target_idx = accepted_indices + start_pt = originals[start_idx] if originals is not None else None + end_pt = originals[target_idx] if originals is not None else None fragments.append(_Fragment( start_point=start_pt, end_point=end_pt, - stuv_path=stuv_path[valid_mask], - xyz_path=xyz_path[valid_mask], + stuv_path=stuv_path, + xyz_path=xyz_path, tangential=True, )) @@ -3031,8 +4354,16 @@ def _overlaps_to_branches(boundary_overlaps, S1, atol, rational): if is_dup: continue - stuv_path = np.stack([ovl.stuv_start, ovl.stuv_end], axis=0) - xyz_path = np.stack([xyz_start, xyz_end], axis=0) + if getattr(ovl, "stuv_path", None) is not None: + # L59: curved correspondence — ship the residual-verified + # sampled polyline, not a straight chord that misrepresents it. + stuv_path = np.asarray(ovl.stuv_path, dtype=np.float64) + xyz_path = np.stack([ + eval_surface(S1, s4[0], s4[1], rational=rational) + for s4 in stuv_path], axis=0) + else: + stuv_path = np.stack([ovl.stuv_start, ovl.stuv_end], axis=0) + xyz_path = np.stack([xyz_start, xyz_end], axis=0) branches.append(SSXBranch(curve=(stuv_path, xyz_path), overlap=True, kind="overlap")) return branches @@ -3096,37 +4427,6 @@ def _global_to_local(stuv_global, box): # Registration-based tracing (design §7) # --------------------------------------------------------------------------- -def _find_exit_registration(cell, stuv_end, tol_param=1e-4): - """Design §7 Invariant D: locate the unique unconsumed "out" registration - owned by `cell` that matches the marcher's stopping point. - - The marcher is guaranteed to stop on the cell's boundary (it clamps to - `[0,1]⁴` in local coords); therefore `stuv_end` must have at least one - on-boundary axis for this cell. We walk every matching partition on - every on-boundary axis and return the first unconsumed out-registration - whose `param` matches `stuv_end[free_axis]` within `tol_param`. - """ - best: Optional[IsolineRegistration] = None - best_residual = float("inf") - for i in range(4): - local = _on_axis_local(stuv_end[i], cell.box[i][0], cell.box[i][1]) - if local is None: - continue - target_value = cell.box[i][local] - for p in cell.partitions: - if p.axis != i or abs(p.value - target_value) > 1e-8: - continue - target_param = float(stuv_end[p.free_axis]) - for reg in p.registrations: - if reg.consumed or reg.owner is not cell or reg.direction != "out": - continue - r = abs(reg.param - target_param) - if r < tol_param and r < best_residual: - best = reg - best_residual = r - return best - - @dataclass class _Fragment: """A partial branch produced by one cell's tracer. @@ -3181,6 +4481,20 @@ def _trace_cell_by_registrations(cell, atol, h_max=None): fragments: list[_Fragment] = [] points: list = [] used: set[int] = set() + work_budget = getattr(cell, "work_budget", None) + + def _deny_trace_work(): + if work_budget is not None: + # `charge_cells` is what turns a zero remaining allowance into a + # hard exhaustion flag. The additional incomplete bit records + # that registrations still existed when continuation stopped. + work_budget.charge_cells(1, "branch_trace") + work_budget.mark_incomplete(REASON_WORK_BUDGET) + + if (work_budget is not None + and (work_budget.exhausted or work_budget.remaining_cells <= 0)): + _deny_trace_work() + return fragments, points # Per-axis parametric tolerance for the cell's local sub-surfaces. # Sizes the marcher's initial/minimal steps and the endpoint matching @@ -3191,12 +4505,17 @@ def _trace_cell_by_registrations(cell, atol, h_max=None): ptol_local = np.array([float(ptol_s), float(ptol_t), float(ptol_u), float(ptol_v)]) ptol_local = np.maximum(ptol_local, 1e-12) ptol_min = max(float(ptol_local.max()), 1e-9) + strict_root_tol = _strict_ssx_root_tol( + cell.g1.surface, cell.g2.surface, rational=True) spans = np.array([cell.box[ax][1] - cell.box[ax][0] for ax in range(4)]) # Global per-axis matching radius: CSX roots and marcher exits are each # accurate to ~ptol, so 4x covers both ends with headroom. match_tol_global = 4.0 * ptol_local * np.maximum(spans, 1e-15) for i, start_cx in enumerate(cell.crossings): + if work_budget is not None and work_budget.exhausted: + work_budget.mark_incomplete(REASON_WORK_BUDGET) + break if i in used: continue @@ -3208,6 +4527,7 @@ def _trace_cell_by_registrations(cell, atol, h_max=None): cell_h_max = h_max if h_max is not None else max( 0.05 * _local_diag(cell.g1.surface, rational=True), 4.0 * atol) nearest_xyz = float('inf') + nearest_hint_local = None for j, cx in enumerate(cell.crossings): if j == i or j in used: continue @@ -3215,6 +4535,8 @@ def _trace_cell_by_registrations(cell, atol, h_max=None): - np.asarray(start_cx.xyz, dtype=np.float64))) if d < nearest_xyz: nearest_xyz = d + nearest_hint_local = ( + _global_to_local(cx.stuv, cell.box) - start_local) if nearest_xyz == float('inf'): h_init = 0.25 * cell_h_max @@ -3238,14 +4560,24 @@ def _trace_cell_by_registrations(cell, atol, h_max=None): candidates = [] # (arc_xyz, stuv_global, xyz_local, matched_j) tang_seed = None for attempt in range(4): - hint = None + # At a rank-deficient tangent crossing, ker(Psi') contains both + # the actual curve direction and a singular transverse direction. + # Picking SVD's last vector with no hint is arbitrary: on + # z=(t-1/2)^d it walks the q^d tolerance valley instead of the + # tangent line, and a roundoff-small residual is then many atol + # from the root for large d. A certified partner registration + # supplies the missing topological direction. Project its LOCAL + # 4-D displacement into the full nullspace on the first attempt; + # rank-3 curves are unchanged except for orientation. + hint = nearest_hint_local if attempt == 0 else None seed_local = start_local prepend_crossing = False if attempt >= 1: if tang_seed is None: tang_seed, _, _ = _ssx_tangent_4d( cell.g1.surface, cell.g2.surface, - *start_local, rational=True) + *start_local, rational=True, + direction_hint=nearest_hint_local) if tang_seed is None: break if attempt == 1: @@ -3260,6 +4592,11 @@ def _trace_cell_by_registrations(cell, atol, h_max=None): sign = 1.0 if attempt == 2 else -1.0 seed_local = None for alpha in (0.02, 0.05, 0.1): + if (work_budget is not None + and not work_budget.charge_cells( + 1, "branch_trace")): + work_budget.mark_incomplete(REASON_WORK_BUDGET) + break cand = np.clip(start_local + sign * alpha * tang_seed, 1e-6, 1.0 - 1e-6) cs, ct, cu, cv, res, sin_ang = _ssx_correct( @@ -3276,16 +4613,39 @@ def _trace_cell_by_registrations(cell, atol, h_max=None): prepend_crossing = True break if seed_local is None: + if work_budget is not None and work_budget.exhausted: + break continue + trace_limit = 400 + if work_budget is not None: + if work_budget.exhausted or work_budget.remaining_cells <= 0: + _deny_trace_work() + break + trace_limit = min(trace_limit, work_budget.remaining_cells) + trace_stats = {} stuv_local, xyz_local, exit_info = _march_to_boundary( cell.g1.surface, cell.g2.surface, seed_local, atol=atol, rational=True, direction_hint=hint, h_init=h_init, h_max=cell_h_max, min_step=ptol_min, + max_points=trace_limit, stats=trace_stats, ) + trace_iterations = int(trace_stats.get("iterations", 0)) + if (work_budget is not None and trace_iterations + and not work_budget.charge_cells( + trace_iterations, "branch_trace")): + work_budget.mark_incomplete(REASON_WORK_BUDGET) + break + if (trace_iterations >= trace_limit and exit_info is None): + if work_budget is not None: + work_budget.mark_incomplete(REASON_WORK_BUDGET) + if work_budget.remaining_cells <= 0: + work_budget.mark_exhausted() if len(stuv_local) < 2: + if work_budget is not None and work_budget.exhausted: + break continue if prepend_crossing: stuv_local = np.vstack([np.asarray(start_local)[None, :], @@ -3294,6 +4654,34 @@ def _trace_cell_by_registrations(cell, atol, h_max=None): dtype=np.float64)[None, :], np.asarray(xyz_local)]) + # `atol` controls chord accuracy and matching; it is not an + # equality certificate. Every continuation vertex must be a + # roundoff-scale Psi zero before it can justify a branch or a + # synthesized endpoint. Otherwise retain only the already + # certified registration and surface the topology as partial. + if (work_budget is not None + and not work_budget.charge_cells( + len(stuv_local), "branch_trace_verify")): + work_budget.mark_incomplete(REASON_WORK_BUDGET) + break + strict_path = True + for q in np.asarray(stuv_local, dtype=np.float64): + p1 = eval_surface( + cell.g1.surface, q[0], q[1], rational=True) + p2 = eval_surface( + cell.g2.surface, q[2], q[3], rational=True) + _vres = float(np.linalg.norm(p1 - p2)) + # Ledger L45: accept-if — the reject-if-greater form let a + # NaN vertex residual certify the path (see the boundary + # polish gate above for the same inversion). + if not (np.isfinite(_vres) and _vres <= strict_root_tol): + strict_path = False + break + if not strict_path: + if work_budget is not None: + work_budget.mark_incomplete(REASON_TRACE_UNVERIFIED) + continue + # Bounce/degenerate detector, in XYZ over the WHOLE path: a # march made no real progress only if EVERY sample stayed # within the geometric tolerance of the seed (outward bounce @@ -3411,17 +4799,41 @@ def _trace_cell_by_registrations(cell, atol, h_max=None): xyz_path=xyz_local, )) - if not candidates and i not in used: + if (not candidates and i not in used + and not (work_budget is not None and work_budget.exhausted)): # Both directions failed (genuine corner touch or marcher # failure). Surface the crossing as an isolated point instead # of silently dropping it. + if (start_cx.multiplicity_polished + and work_budget is not None): + # High-precision polishing proves this point is a root but + # collapsing a CSX tolerance cluster does not prove whether + # a branch leaves it. A successful strict trace above would + # resolve that ambiguity; a point-only fallback remains + # explicitly partial (positive-gap endpoint-touch control). + work_budget.structural_sites.append( + (REASON_MULTIPLICITY, + np.asarray(start_cx.stuv, dtype=np.float64).copy())) + work_budget.mark_incomplete(REASON_MULTIPLICITY) points.append(SSXPoint(stuv=start_cx.stuv, xyz=start_cx.xyz)) return fragments, points +def _assembly_spend(work_budget, amount: int = 1, + source: str = "assembly") -> bool: + """Spend bounded post-processing work from the call-wide allowance.""" + if work_budget is None: + return True + if work_budget.charge_postprocess(amount): + return True + work_budget.mark_incomplete(REASON_POSTPROCESS_CAP) + return False + + def _unify_fragment_endpoints(fragments: list[_Fragment], unify_tol, - unify_atol: float = 1e-3) -> None: + unify_atol: float = 1e-3, + work_budget=None) -> None: """Replace fragment endpoint objects that represent the same physical crossing with one canonical `BoundaryPoint` (in place). @@ -3458,8 +4870,12 @@ def _find(a: int) -> int: box_lo = [objs[k].stuv.astype(np.float64).copy() for k in range(n)] box_hi = [objs[k].stuv.astype(np.float64).copy() for k in range(n)] + stopped = False for a in range(n): for b in range(a + 1, n): + if not _assembly_spend(work_budget): + stopped = True + break if not np.all(np.abs(objs[a].stuv - objs[b].stuv) <= tol): continue # xyz guard: a parametric box is not a metric ball — where @@ -3478,6 +4894,8 @@ def _find(a: int) -> int: parent[rb] = ra box_lo[ra] = merged_lo box_hi[ra] = merged_hi + if stopped: + break canon = {id(objs[k]): objs[_find(k)] for k in range(n)} for f in fragments: @@ -3487,8 +4905,9 @@ def _find(a: int) -> int: f.end_point = canon[id(f.end_point)] -def _fragment_contained_in(f: _Fragment, g: _Fragment, tol: float) -> bool: - """True if EVERY xyz sample of `f` lies within `tol` of `g`'s polyline.""" +def _fragment_contained_in(f: _Fragment, g: _Fragment, tol: float, + work_budget=None) -> Optional[bool]: + """Return containment, or ``None`` when its bounded scan was denied.""" poly = np.asarray(g.xyz_path, dtype=np.float64) if len(poly) < 2: return False @@ -3498,6 +4917,11 @@ def _fragment_contained_in(f: _Fragment, g: _Fragment, tol: float) -> bool: denom = np.einsum("ij,ij->i", ab, ab) denom = np.where(denom < 1e-30, 1e-30, denom) for p in np.asarray(f.xyz_path, dtype=np.float64): + # The vectorized point/segment scan does O(len(a)) arithmetic. Charge + # that amount before allocating/processing it, so a long duplicate + # family cannot hide quadratic work behind one Python loop turn. + if not _assembly_spend(work_budget, len(a)): + return None ap = p[None, :] - a tt = np.clip(np.einsum("ij,ij->i", ap, ab) / denom, 0.0, 1.0) proj = a + tt[:, None] * ab @@ -3506,7 +4930,8 @@ def _fragment_contained_in(f: _Fragment, g: _Fragment, tol: float) -> bool: return True -def _drop_duplicate_fragments(fragments: list[_Fragment], atol: float) -> list[_Fragment]: +def _drop_duplicate_fragments(fragments: list[_Fragment], atol: float, + work_budget=None) -> list[_Fragment]: """Remove fragments whose geometry is contained in another fragment. Duplicates arise when a partner seed re-traces a segment that was @@ -3525,9 +4950,29 @@ def _arc_len(fr: _Fragment) -> float: return 0.0 return float(np.linalg.norm(np.diff(xyz, axis=0), axis=1).sum()) + arc_work = sum(max(0, len(fr.xyz_path) - 1) for fr in fragments) + if arc_work and not _assembly_spend(work_budget, arc_work): + # Failure-safe direction: an unproved duplicate remains visible in + # the explicitly partial result; no certified fragment is deleted. + return list(fragments) + keep: list[_Fragment] = [] - for f in sorted(fragments, key=_arc_len, reverse=True): - if any(_fragment_contained_in(f, g, 2.0 * atol) for g in keep): + ordered = sorted(fragments, key=_arc_len, reverse=True) + for pos, f in enumerate(ordered): + duplicate = False + for g in keep: + contained = _fragment_contained_in( + f, g, 2.0 * atol, work_budget=work_budget) + if contained is True: + duplicate = True + break + if contained is None: + # No allowance remains. Keep the current and all following + # fragments without further containment scans. + keep.append(f) + keep.extend(ordered[pos + 1:]) + return keep + if duplicate: continue keep.append(f) return keep @@ -3541,6 +4986,7 @@ def _assemble_fragments( unify_tol=None, h_max=None, barrier_xyz=None, + work_budget=None, ) -> list[SSXBranch]: """Design §9: chain fragments that share a `BoundaryPoint` endpoint into full branches. Two fragments touching the same `BoundaryPoint` object @@ -3564,8 +5010,11 @@ def _assemble_fragments( from collections import defaultdict if unify_tol is not None and len(fragments) > 1: - _unify_fragment_endpoints(fragments, unify_tol, unify_atol=atol_full) - fragments = _drop_duplicate_fragments(fragments, atol_full) + _unify_fragment_endpoints( + fragments, unify_tol, unify_atol=atol_full, + work_budget=work_budget) + fragments = _drop_duplicate_fragments( + fragments, atol_full, work_budget=work_budget) barrier = None if barrier_xyz is not None and len(barrier_xyz): @@ -3574,6 +5023,10 @@ def _assemble_fragments( def _at_barrier(pt: BoundaryPoint) -> bool: if barrier is None or pt is None: return False + if not _assembly_spend(work_budget, len(barrier)): + # Unknown means "at a barrier" in the failure-safe direction: + # do not stitch independently certified arms through it. + return True d = np.linalg.norm(barrier - np.asarray(pt.xyz, dtype=np.float64)[None, :], axis=1) return bool(d.min() <= 2.0 * atol_full) @@ -3593,6 +5046,8 @@ def _pop_neighbour(pt: BoundaryPoint, self_idx: int) -> Optional[tuple[int, str] block_transversal = (_at_barrier(pt) and not fragments[self_idx].tangential) for j, role in pool: + if not _assembly_spend(work_budget): + return None if j == self_idx or consumed[j]: continue if block_transversal and not fragments[j].tangential: @@ -3704,19 +5159,65 @@ def _pop_neighbour(pt: BoundaryPoint, self_idx: int) -> Optional[tuple[int, str] and median_step > 0 and gap < 10.0 * median_step and path_len > 3.0 * gap): - closing_stuv, closing_xyz = _march_intersection_curve( - S1_full, S2_full, - stuv_full[-1], stuv_full[0], - atol=atol_full, rational=rational_full, - h_max=h_max, - ) + close_limit = 2000 + if work_budget is not None: + close_limit = min( + close_limit, + work_budget.remaining_postprocess_work) + close_stats = {} + if (close_limit <= 0 + or not _assembly_spend(work_budget, 0)): + # Spend one denied unit to publish hard exhaustion. + _assembly_spend(work_budget, 1, "assembly_trace") + closing_stuv = np.empty((0, 4)) + closing_xyz = np.empty((0, 3)) + else: + closing_stuv, closing_xyz = _march_intersection_curve( + S1_full, S2_full, + stuv_full[-1], stuv_full[0], + atol=atol_full, rational=rational_full, + h_max=h_max, max_points=close_limit, + stats=close_stats, + ) + close_iterations = int( + close_stats.get("iterations", 0)) + if close_iterations: + _assembly_spend( + work_budget, close_iterations, + "assembly_trace") + reached_start = ( + len(closing_xyz) >= 2 + and float(np.linalg.norm( + np.asarray(closing_xyz[-1]) + - np.asarray(xyz_full[0]))) + <= 2.0 * atol_full) + if (close_iterations >= close_limit + and not reached_start + and work_budget is not None): + work_budget.mark_incomplete( + REASON_POSTPROCESS_CAP) + if (work_budget.remaining_postprocess_work + <= 0): + work_budget.postprocess_exhausted = True + work_budget.mark_exhausted() + if not reached_start: + closing_stuv = np.empty((0, 4)) + closing_xyz = np.empty((0, 3)) is_retrace = False if len(closing_xyz) > 3: interior = np.asarray(closing_xyz[1:-1], dtype=np.float64) - is_retrace = all( - _dist_point_polyline(p, xyz_full) - <= 2.0 * atol_full for p in interior) + is_retrace = True + for p in interior: + if not _assembly_spend( + work_budget, max(1, len(xyz_full) - 1)): + # Unknown retrace: reject the optional close. + is_retrace = True + break + if (_dist_point_polyline(p, xyz_full) + > 2.0 * atol_full): + is_retrace = False + break if len(closing_stuv) >= 2 and not is_retrace: # Skip the first sample (duplicates xyz_full[-1]). stuv_full = np.concatenate( @@ -3752,6 +5253,8 @@ def _interior(p4): def _near_barrier_xyz(p3): if barrier is None: return False + if not _assembly_spend(work_budget, len(barrier)): + return True return bool(np.linalg.norm( barrier - np.asarray(p3)[None, :], axis=1).min() <= 2.0 * atol_full) @@ -3787,8 +5290,11 @@ def _chord_is_real(pa4, pc4): changed = True while changed: + if not _assembly_spend(work_budget): + break changed = False open_ends = [] # (branch_idx, end_is_start, stuv4, xyz3, out_dir) + scan_denied = False for bi, b in enumerate(branches): stuv, xyz = _ends(b) if len(xyz) < 2: @@ -3803,6 +5309,10 @@ def _chord_is_real(pa4, pc4): # junk blobs and frankenjoined junk onto genuine ring arcs # in sub-tolerance clusters (eps=1e-3 touch+loop), after # which containment ate the clean ring. + if not _assembly_spend( + work_budget, max(1, len(xyz) - 1)): + scan_denied = True + break arc_b = float(np.linalg.norm( np.diff(xyz, axis=0), axis=1).sum()) if arc_b <= 16.0 * atol_full: @@ -3814,9 +5324,14 @@ def _chord_is_real(pa4, pc4): continue open_ends.append((bi, at_start, p4, p3, _dir(xyz, at_start))) + if scan_denied: + break best = None for a in range(len(open_ends)): for c in range(a + 1, len(open_ends)): + if not _assembly_spend(work_budget): + scan_denied = True + break ia, sa, _, pa, da = open_ends[a] ic, sc, _, pc, dc = open_ends[c] if ia == ic and sa == sc: @@ -3847,6 +5362,10 @@ def _chord_is_real(pa4, pc4): continue if best is None or gap < best[0]: best = (gap, a, c) + if scan_denied: + break + if scan_denied: + break if best is None: break _, a, c = best @@ -3892,14 +5411,27 @@ def _chord_is_real(pa4, pc4): for idx in order: xyz = np.asarray(branches[idx].curve[1], dtype=np.float64) contained = False + containment_unknown = False for kidx in kept_idx: poly = np.asarray(branches[kidx].curve[1], dtype=np.float64) if len(poly) < 2 or not len(xyz): continue - if all(_dist_point_polyline(p, poly) <= 2.0 * atol_full - for p in xyz): + inside = True + for p in xyz: + if not _assembly_spend( + work_budget, max(1, len(poly) - 1)): + containment_unknown = True + inside = False + break + if (_dist_point_polyline(p, poly) + > 2.0 * atol_full): + inside = False + break + if inside: contained = True break + if containment_unknown: + break if not contained: kept_idx.append(idx) branches = [branches[k] for k in sorted(kept_idx)] @@ -3924,6 +5456,12 @@ def _chord_is_real(pa4, pc4): continue all_bad = True for k in range(len(stuv_b) - 1): + if not _assembly_spend(work_budget): + # This is a soundness filter. Without allowance to + # verify at least one real chord, omit the branch from + # the explicitly partial result rather than publish a + # possible grazing-valley connector. + break mid = 0.5 * (stuv_b[k] + stuv_b[k + 1]) p1, du1, dv1 = eval_surface_d1(S1_full, mid[0], mid[1], rational=rational_full) @@ -3967,14 +5505,27 @@ def _chord_is_real(pa4, pc4): for idx in order: xyz = branches[idx].curve[1] is_sliver = False + sliver_unknown = False if len(xyz) <= SLIVER_MAX_PTS: for big in kept_xyz: if len(big) < 2: continue - if all(_dist_point_polyline(np.asarray(p), big) <= sliver_tol - for p in xyz): + inside = True + for p in xyz: + if not _assembly_spend( + work_budget, max(1, len(big) - 1)): + sliver_unknown = True + inside = False + break + if (_dist_point_polyline(np.asarray(p), big) + > sliver_tol): + inside = False + break + if inside: is_sliver = True break + if sliver_unknown: + break if not is_sliver: keep.append(branches[idx]) kept_xyz.append(xyz) @@ -3987,83 +5538,6 @@ def _chord_is_real(pa4, pc4): # Domain decomposition helpers # --------------------------------------------------------------------------- -def _choose_multi_cut(crossings_global, box, min_margin: float = 0.05): - """Design §6.5 (Krishnan & Manocha 1997) — multi-crossing cut. - - Choose a subdivision axis and the *complete set* of distinct interior - crossing parameter values on that axis. The cell is then split into - `len(cuts) + 1` strips by sequential de Casteljau. - - Axis selection: prefer the axis with the most valid cut candidates - (more strips ⇒ each strip's TΨᵢ coefficient hull is tighter and the - cheap loop-free certificate fires sooner). Tiebreak: widest spread - of cut values on that axis. - - A candidate cut is *valid* iff its local position is strictly in - `(min_margin, 1 − min_margin)` — too close to a cell boundary would - produce a near-zero-width strip. - - Returns `(axis, sorted_cut_values_global)` or `(None, None)` if no - axis has any valid interior cut. - """ - if not crossings_global: - return None, None - - best_axis: Optional[int] = None - best_cuts: list[float] = [] - best_spread = -1.0 - - for axis in range(4): - lo, hi = box[axis] - span = hi - lo - if span <= 0: - continue - seen: dict[float, float] = {} # rounded key → actual global val - for c in crossings_global: - val = float(c.stuv[axis]) - local = (val - lo) / span - if local <= min_margin or local >= 1.0 - min_margin: - continue - key = round(val, 10) - if key not in seen: - seen[key] = val - if not seen: - continue - cuts = sorted(seen.values()) - spread = cuts[-1] - cuts[0] if len(cuts) > 1 else 0.0 - if (len(cuts) > len(best_cuts) - or (len(cuts) == len(best_cuts) and spread > best_spread)): - best_axis = axis - best_cuts = cuts - best_spread = spread - - if best_axis is None: - return None, None - return best_axis, best_cuts - - -def _choose_cut(crossings_global, box, min_margin: float = 0.05): - """Back-compat single-cut selector: returns the first crossing index on - the best multi-cut axis. Kept only for callers that still expect the - (cx_idx, axis) tuple; new code should use `_choose_multi_cut`. - """ - axis, cuts = _choose_multi_cut(crossings_global, box, min_margin) - if axis is None or not cuts: - return None, None - # Pick the crossing whose value is closest to the cell centre on `axis`. - lo, hi = box[axis] - target = 0.5 * (lo + hi) - best_idx = None - best_dist = float("inf") - for ci, c in enumerate(crossings_global): - if any(abs(c.stuv[axis] - cv) < 1e-12 for cv in cuts): - d = abs(c.stuv[axis] - target) - if d < best_dist: - best_dist = d - best_idx = ci - return best_idx, axis - - def _extract_isoline(S, axis, value): """Extract isoline from Bezier surface at parameter value along axis.""" from mmcore.numeric.bern import de_casteljau_split_nd @@ -4075,76 +5549,6 @@ def _extract_isoline(S, axis, value): return left[:, -1, :] -def _isoline_csx_to_global(csx_result, cut_axis, cut_global_val, cell_box, surf_to_split, - S1_local=None, S2_local=None, rational=True): - """Convert CSX results on an isoline to global BoundaryPoint objects. - - The isoline is in the cell's local coords. CSX returns local params. - We convert everything to global using the cell's box. If the cell's local - surface nets are provided we also compute the raw 4D tangent at the - crossing (design §4 / §5) so downstream classification can use it. - """ - crossings = [] - local_axis = cut_axis if cut_axis < 2 else cut_axis - 2 - - for iso_pt in csx_result.get('isolated', []): - t_crv = float(iso_pt['t']) - u_oth = float(iso_pt['u']) - v_oth = float(iso_pt['v']) - - # Build LOCAL stuv first - stuv_local = np.zeros(4, dtype=np.float64) - if surf_to_split == 1: - # Isoline on S1: local_axis param is the cut value (in local) - # t_crv is param along the other S1 axis (in local) - # u_oth, v_oth are S2 params (in local) - local_cut = (cut_global_val - cell_box[cut_axis][0]) / max(cell_box[cut_axis][1] - cell_box[cut_axis][0], 1e-15) - if local_axis == 0: - stuv_local[0] = local_cut - stuv_local[1] = t_crv - else: - stuv_local[0] = t_crv - stuv_local[1] = local_cut - stuv_local[2] = u_oth - stuv_local[3] = v_oth - else: - stuv_local[0] = u_oth - stuv_local[1] = v_oth - local_cut = (cut_global_val - cell_box[cut_axis][0]) / max(cell_box[cut_axis][1] - cell_box[cut_axis][0], 1e-15) - if local_axis == 0: - stuv_local[2] = local_cut - stuv_local[3] = t_crv - else: - stuv_local[2] = t_crv - stuv_local[3] = local_cut - - # Convert to global - stuv_global = _local_to_global(stuv_local, cell_box) - # Force the cut axis to exact global value (avoid rounding) - stuv_global[cut_axis] = cut_global_val - - xyz = np.asarray(iso_pt['point'], dtype=np.float64) - - # Raw 4D tangent (design §4) computed in the cell's local frame; - # signs are invariant under the positive affine global↔local rescale. - tang = None - if S1_local is not None and S2_local is not None: - tang, _, _ = _ssx_tangent_4d( - S1_local, S2_local, - stuv_local[0], stuv_local[1], stuv_local[2], stuv_local[3], - rational=rational, - ) - - crossings.append(BoundaryPoint(stuv=stuv_global, xyz=xyz, face=(cut_axis, -1), - tangent_raw=tang)) - - return crossings - - -# --------------------------------------------------------------------------- -# Main entry point -# --------------------------------------------------------------------------- - def _partition_free_axis(fixed_axis: int) -> int: """Return the isoline's free parameter axis for an SSX partition. @@ -4286,7 +5690,8 @@ def _is_pinned(val, lo, hi, tol=1e-8): return abs(val - lo) < tol or abs(val - hi) < tol # FIXME: Previously, `min_margin=0.05` was used here. This resulted in some segments of certain intersecting branches being omitted in certain cases. I reduced `min_margin` to 1e-5, and this helped in the specific cases I tested. But this is still a rough approach. We need to calculate `min_margin` based on the parametric tolerance or something similar. To be honest, I still don’t really understand why a high min_margin led to candidates being lost -def _compute_split_plan(crossings, cell_box, min_margin=1e-8): +def _compute_split_plan(crossings, cell_box, min_margin=1e-8, + cut_tol=None, max_cuts=8): """Determine per-surface split axes and values from productive crossings. For each crossing, check S1 pair (s,t) and S2 pair (u,v): @@ -4336,6 +5741,21 @@ def _pick_best(candidates, box): if min_margin < (v - lo) / span < 1 - min_margin) if not cuts: return None, None + # CSX/Newton re-finds the same cut coordinate with a few ULPs of + # variation. Exact-float sets turned those into dozens of distinct + # planes and a Cartesian child explosion. Coalescing GUIDE planes + # is non-destructive (all crossings remain on the cells); if the + # remaining fanout is still large, use the ordinary midpoint split, + # which is always a sound subdivision fallback. + tol = (float(cut_tol[best_axis]) if cut_tol is not None + else 128.0 * np.finfo(float).eps * max(1.0, abs(lo), abs(hi))) + clustered = [] + for value in cuts: + if not clustered or abs(value - clustered[-1]) > tol: + clustered.append(value) + cuts = clustered + if len(cuts) > max_cuts: + return None, None return best_axis, cuts s1_axis, s1_cuts = _pick_best(s1_candidates, cell_box) @@ -4377,150 +5797,21 @@ def _split_tensor_multi(T, axis_4d, cut_values, cell_box): return pieces -def _csx_on_cut_face(cell, cut_axis: int, cut_global_val: float, atol: float): - """Run boundary CSX on one cut face of a cell. - - Extracts the isoline of the surface that owns `cut_axis` at the local - parameter corresponding to `cut_global_val`, runs `bez_csx` against the - other surface, and returns the results as global `BoundaryPoint` objects. - - This is the paper §5.1 "compute new xsection points on each dividing line". - """ - surf_to_split = 1 if cut_axis < 2 else 2 - local_axis = cut_axis if cut_axis < 2 else cut_axis - 2 - cell_lo, cell_hi = cell.box[cut_axis] - cell_span = cell_hi - cell_lo - if cell_span < 1e-15: - return [] - cut_local = (cut_global_val - cell_lo) / cell_span - - if surf_to_split == 1: - isoline = _extract_isoline(cell.g1.surface, local_axis, cut_local) - csx_result = bez_csx(isoline, cell.g2.surface, atol=atol, rational=True) - else: - isoline = _extract_isoline(cell.g2.surface, local_axis, cut_local) - csx_result = bez_csx(isoline, cell.g1.surface, atol=atol, rational=True) - csx_result['isolated'] = list((lambda x: not (((1 - x['t']) < 1e-6) or (x['t'] < 1e-6)), csx_result['isolated'])) - return _isoline_csx_to_global( - csx_result, cut_axis, cut_global_val, cell.box, surf_to_split, - S1_local=cell.g1.surface, S2_local=cell.g2.surface, rational=True, - ) - - -def _pick_midpoint_axis(cell) -> int: - """Pick the axis to split at 0.5 when no productive cuts exist.""" - return cell.depth % 4 +def _surface_cut_face_fibers(csx_result, work_budget): + """Ledger L49: name positive-dimensional cut-face preimages, never drop them. - -def _midpoint_split(cell, axis: int, atol: float): - """Split cell at midpoint of `axis`. Returns (left, right, mid_global, new_crossings). - - Performs a single binary cut at local 0.5 on the chosen axis, runs CSX on - the new cut face, deduplicates against inherited crossings, and returns the - two sub-cells with their crossings and partitions fully set up. + A cut-face CSX on a pinched isoline (the cut collapses to a point lying + on the other surface — the interior analogue of case 14's apex edge) + returns `parameter_fibers` with zero isolated roots and + `budget_exhausted=False`. The cut-face consumers read only `isolated`, + so before this the fiber vanished without any status trace, while the + boundary path marks the SAME structure (`REASON_PARAMETER_FIBER`, see + the boundary-fiber block in `bez_ssx`): a fiber's incident SSI branch + multiplicity is unproved, so a complete-topology claim must be refused. + Mirror that policy here; the isolated-root flow is unchanged. """ - surf_to_split = 1 if axis < 2 else 2 - local_axis = axis if axis < 2 else axis - 2 - lo, hi = cell.box[axis] - mid_global = 0.5 * (lo + hi) - - # Split surface / Gauss map - if surf_to_split == 1: - left_g1, right_g1 = (cell.g1.split_u(0.5) if local_axis == 0 - else cell.g1.split_v(0.5)) - left_g2, right_g2 = cell.g2, cell.g2 - else: - left_g2, right_g2 = (cell.g2.split_u(0.5) if local_axis == 0 - else cell.g2.split_v(0.5)) - left_g1, right_g1 = cell.g1, cell.g1 - - # Split TΨᵢ tensors - left_T1, right_T1 = _split_bern_scalar_tensor(cell.T1, axis=axis, t=0.5) - left_T2, right_T2 = _split_bern_scalar_tensor(cell.T2, axis=axis, t=0.5) - left_T3, right_T3 = _split_bern_scalar_tensor(cell.T3, axis=axis, t=0.5) - left_T4, right_T4 = _split_bern_scalar_tensor(cell.T4, axis=axis, t=0.5) - - left_box = list(cell.box) - left_box[axis] = (lo, mid_global) - left_box = tuple(left_box) - - right_box = list(cell.box) - right_box[axis] = (mid_global, hi) - right_box = tuple(right_box) - - # CSX on the new cut face - new_crossings = _csx_on_cut_face(cell, axis, mid_global, atol) - - - # Invariant C dedup: unify with inherited crossings - deduped_new: list = [] - for nc in new_crossings: - assert abs(nc.stuv[axis] - mid_global) < 1e-8, \ - f"CSX crossing must be on cut face: stuv[{axis}]={nc.stuv[axis]}, expected {mid_global}" - match = None - for ec in cell.crossings: - if np.linalg.norm(ec.stuv - nc.stuv) < atol: - match = ec - break - if match is None: - pc = _pinned_count(nc, left_box if nc.stuv[axis] <= mid_global + 1e-10 else right_box) - assert pc == 1, \ - f"New crossing must be 1-pinned in its strip, got {pc}: stuv={nc.stuv}" - deduped_new.append(nc) - else: - match.stuv[axis] = mid_global - - # Pool = inherited + genuinely new - all_cx = list(cell.crossings) + deduped_new - - # Distribute crossings to strips - left_cx = [c for c in all_cx if c.stuv[axis] <= mid_global + 1e-10] - right_cx = [c for c in all_cx if c.stuv[axis] >= mid_global - 1e-10] - - # Separate genuinely new crossings per strip (for next-level cut decisions) - left_new = [c for c in deduped_new if c.stuv[axis] <= mid_global + 1e-10] - right_new = [c for c in deduped_new if c.stuv[axis] >= mid_global - 1e-10] - - # Propagate F_sq alongside TΨᵢ - if cell.F_sq is not None: - left_F, right_F = _split_bern_scalar_tensor(cell.F_sq, axis=axis, t=0.5) - else: - left_F = right_F = None - - left_cell = _Cell(g1=left_g1, g2=left_g2, crossings=left_cx, box=left_box, - depth=cell.depth + 1, - T1=left_T1, T2=left_T2, T3=left_T3, T4=left_T4, - new_crossings=left_new, - F_sq=left_F, w_scale=cell.w_scale) - right_cell = _Cell(g1=right_g1, g2=right_g2, crossings=right_cx, box=right_box, - depth=cell.depth + 1, - T1=right_T1, T2=right_T2, T3=right_T3, T4=right_T4, - new_crossings=right_new, - F_sq=right_F, w_scale=cell.w_scale) - - # Partitions: skip the cut-axis face, splice in shared internal partition - shared_free = _partition_free_axis(axis) - shared_extent = cell.box[shared_free] - shared_partition = PartitionCurve( - axis=axis, value=float(mid_global), - free_axis=shared_free, - global_extent=(float(shared_extent[0]), float(shared_extent[1])), - adjacents=[left_cell, right_cell], registrations=[], - ) - - left_cell.partitions = _build_cell_partitions(left_cell, skip=(axis, 1)) - left_cell.partitions.append(shared_partition) - - right_cell.partitions = _build_cell_partitions(right_cell, skip=(axis, 0)) - right_cell.partitions.append(shared_partition) - - # Classify all crossings per sub-cell - for c in left_cx: - _classify_boundary_point(c, left_cell) - for c in right_cx: - _classify_boundary_point(c, right_cell) - - return left_cell, right_cell + if work_budget is not None and csx_result.get('parameter_fibers'): + work_budget.mark_incomplete(REASON_PARAMETER_FIBER) @dataclass @@ -4532,7 +5823,7 @@ class _Cell: per the design, crossings are derivable from `[r.point for p in partitions for r in p.registrations]` (deduped by identity). The `crossings` field is kept here as scaffolding used by - `_choose_cut` and the subdivision's L/R distribution step — removing it + `_compute_split_plan` and the subdivision's L/R distribution step — removing it requires rewriting those in terms of partition registrations, which is a separate refactor. """ @@ -4569,6 +5860,11 @@ class _Cell: # (the parent already traced its geometry — that is what keeps the # descent duplication-free) and never runs cut-face CSX. probe_only: bool = False + # Every nested solver and every descendant spends from the top-level + # call's shared budget. ``csx_fn`` is the bounded adapter installed by + # bez_ssx; keeping it on the cell also covers helper subdivision paths. + work_budget: Optional[_SSXSoftBudget] = None + csx_fn: Optional[object] = None def _probe_children(cell): @@ -4619,7 +5915,8 @@ def _split2(T): T1=Ts[0][i1][i2], T2=Ts[1][i1][i2], T3=Ts[2][i1][i2], T4=Ts[3][i1][i2], new_crossings=[], F_sq=Fs[i1][i2], w_scale=cell.w_scale, - probe_only=True, + probe_only=True, work_budget=cell.work_budget, + csx_fn=cell.csx_fn, )) return out @@ -4629,8 +5926,15 @@ def bez_ssx( S2, atol=1e-3, rational=True, - max_depth=12, + max_depth=13, max_xyz_step=None, + max_cells=250_000, + max_csx_calls=10_000, + csx_max_cells=100_000, + boundary_csx_max_cells=20_000, + csx_max_results=128, + max_output_items=1_024, + max_postprocess_work=None, ) -> dict: """Bezier surface-surface intersection v5. @@ -4639,10 +5943,198 @@ def bez_ssx( Surfaces in sub-cells are in LOCAL [0,1]² (De Casteljau reparameterized). Conversion between local and global uses the cell's box. - Returns dict with 'branches', 'points', and 'singularities'. + ``max_cells`` and ``max_csx_calls`` are shared by the entire search, + including nested singular solvers and CSX searches. Top-level independent + boundary probes use ``boundary_csx_max_cells``; topology-critical internal + cuts use ``csx_max_cells`` directly, so they never repay a discarded + smaller attempt. Assembly and + containment use one separate call-wide ``max_postprocess_work`` cap + (default: ``max_cells``), so a stopped search can still assemble its + certified partial fragments without opening an unbounded second phase. + Exhaustion is a soft stop: already-certified output is always returned. + + Returns dict with 'branches', 'points', 'singularities', plus the + schema-v2 status fields (2026-07-12 review doc §6): + + - ``complete`` (bool) — the one bit consumers act on; ``True`` iff the + returned topology is proven complete. + - ``status['reasons']`` (list[str]) — empty iff complete; each entry is + one of the REASON_* strings at the top of this module and names a + root cause (work/output/postprocess caps, depth ceiling, or the + structural parameter-fiber / overlap-region / tangential-zone / + multiplicity / unverified-trace partialities). + - ``status['work']`` — usage counters (cells, CSX calls, output items, + postprocess work, per-source ``cell_counts``) against their caps. + + The kwargs stay expert knobs with safe defaults: read ``complete`` (and + ``reasons`` if you care why); never tune knobs to get correctness. """ S1 = np.asarray(S1, dtype=np.float64) S2 = np.asarray(S2, dtype=np.float64) + budget = _SSXSoftBudget( + max_cells=max(0, int(max_cells)), + max_csx_calls=max(0, int(max_csx_calls)), + max_output_items=max(0, int(max_output_items)), + max_postprocess_work=(None if max_postprocess_work is None + else max(0, int(max_postprocess_work))), + ) + + def _result(branches=None, points=None, singularities=None, + overlap_regions=None, unresolved_regions=None): + result = { + 'branches': [] if branches is None else branches, + 'points': [] if points is None else points, + 'singularities': [] if singularities is None else singularities, + 'overlap_regions': ([] if overlap_regions is None + else overlap_regions), + # L52 slice 9b (§7.3): typed diagnostic complement — 'partial' + # names WHAT is unresolved (each entry = a 4-D stuv AABB + the + # reason it was left behind), the parameter_fibers pattern. + 'unresolved_regions': ([] if unresolved_regions is None + else unresolved_regions), + } + result.update(budget.result_fields()) + return result + + # A zero public allowance is a hard promise that no expensive solver + # setup runs. In particular, the 4-D squared-distance Bernstein net can + # allocate work quadratic in the tensor-product control count before the + # first subdivision cell exists. + if budget.max_cells <= 0 or budget.max_csx_calls <= 0: + budget.mark_exhausted() + return _result() + + # Reject disjoint control hulls before preflighting the distance net. + if _ssx_control_aabbs_disjoint(S1, S2, rational=rational): + return _result() + + if rational: + S1_h_top, S2_h_top = S1, S2 + else: + S1_h_top = np.concatenate( + [S1, np.ones(S1.shape[:-1] + (1,))], axis=-1) + S2_h_top = np.concatenate( + [S2, np.ones(S2.shape[:-1] + (1,))], axis=-1) + + # The current distance-net constructor forms a pairwise Gram tensor over + # the full four-axis control product. Charge one shared work unit per + # 128 pair coefficients before entering it; denial returns immediately + # instead of allowing a high-degree input to freeze or exhaust memory. + control_product = ( + int(S1.shape[0]) * int(S1.shape[1]) + * int(S2.shape[0]) * int(S2.shape[1])) + pair_coefficients = control_product * control_product + precompute_units = max(1, (pair_coefficients + 127) // 128) + if not budget.charge_cells(precompute_units, "precompute"): + # L52 slice 9b: a bail before the queue even exists leaves the + # WHOLE domain unresolved — name it (the typed-complement contract: + # partial always says what is unresolved). + return _result(unresolved_regions=[ + {'stuv_min': (0.0, 0.0, 0.0, 0.0), + 'stuv_max': (1.0, 1.0, 1.0, 1.0), + 'reason': REASON_WORK_BUDGET}]) + F_sq_top = surface_surface_distance_squared_net_homog( + S1_h_top, S2_h_top, rational=True) + + # Uncertified curved-UV overlap spans surfaced by nested CSX calls + # (ledger L42's typed fallback): the L28 region assembler consumes them + # as rim evidence, and reason attribution below distinguishes this + # STRUCTURAL truncation from a genuine work-budget one. + uncertified_overlap_spans = [] + + def _run_csx( + curve, surface, *, local_truncation_is_soft=False, **kwargs, + ): + """Bounded adapter used by every boundary and cut-face CSX call.""" + if budget.remaining_cells <= 0 or not budget.charge_csx_call(): + budget.mark_exhausted() + return { + 'isolated': [], 'overlaps': [], 'parameter_fibers': [], + 'budget_exhausted': True, 'cells_processed': 0, + } + def _attempt(allowance): + attempt_kwargs = dict(kwargs) + attempt_kwargs['max_cells'] = allowance + attempt_kwargs['max_results'] = int(csx_max_results) + attempt_result = bez_csx(curve, surface, **attempt_kwargs) + attempt_used = max( + 0, int(attempt_result.get('cells_processed', 0))) + if not budget.charge_cells(attempt_used, "csx"): + budget.mark_exhausted() + return attempt_result, attempt_used + + per_call_cap = (boundary_csx_max_cells + if local_truncation_is_soft else csx_max_cells) + allowance = min(max(0, int(per_call_cap)), budget.remaining_cells) + if allowance <= 0 or int(csx_max_results) <= 0: + # A zero per-call allowance (`csx_max_cells`, + # `boundary_csx_max_cells`, `csx_max_results`) is a hard promise + # that bez_csx never runs — its distance-net build precedes its + # first charge, so even a "1-cell" call does superlinear setup + # work. Synthesize the truncated result and reuse the ordinary + # truncation handling below (soft: face discarded; hard: stop). + result, used = { + 'isolated': [], 'overlaps': [], 'parameter_fibers': [], + 'budget_exhausted': True, 'cells_processed': 0, + }, 0 + else: + result, used = _attempt(allowance) + if result.get('budget_exhausted', False): + # A locally truncated CSX root set is not safe input for SSX + # topology decisions, even if the outer allowance has room. + # The eight TOP-LEVEL boundary faces are independent, however: + # discard this face's partial entities, mark the public result + # incomplete, and continue to the remaining faces while shared + # work remains. This preserves certified output from later + # faces (case 14's second collapsed apex fiber). Internal cut + # faces are dependencies of child topology and keep the hard-stop + # behavior below. + # + # Attribution (L28): a truncation whose ONLY cause is the L42 + # uncertified-overlap fallback is STRUCTURAL — the curve lies on + # the surface over the span and the endpoint-range schema cannot + # certify it — so it feeds `overlap_region_unsupported` (which + # the region assembler can retire), not `work_budget`. + span = result.get('uncertified_overlap_span') + span_only = (span is not None + and not result.get('non_span_truncation', False)) + if span_only: + uncertified_overlap_spans.append( + (np.array(curve, dtype=np.float64, copy=True), + (float(span[0]), float(span[1])), + bool(kwargs.get('rational', True)))) + if local_truncation_is_soft and not budget.exhausted: + budget.mark_incomplete( + REASON_OVERLAP_REGION if span_only + else REASON_WORK_BUDGET) + return { + 'isolated': [], 'overlaps': [], + 'parameter_fibers': [], + 'budget_exhausted': True, + 'boundary_topology_complete': False, + 'cells_processed': used, + } + budget.mark_exhausted( + REASON_OVERLAP_REGION if span_only else REASON_WORK_BUDGET) + # L59: CERTIFIED overlap spans are rim evidence for the L28 region + # assembler, exactly like the L42 typed spans (the assembler + # SELF-SAMPLES the rim curve over the span, so curved rational + # rims — which the boundary path's straight 2-point chord + # verification rightly rejects — still become region loops). + # Certified spans are strictly stronger evidence; same channel. + for _ovl in result.get('overlaps', ()): + _cert = _ovl.get('certification') + _tr = _ovl.get('t_range') + if _cert in ('exact', 'tolerance') and _tr is not None: + uncertified_overlap_spans.append( + (np.array(curve, dtype=np.float64, copy=True), + (float(_tr[0]), float(_tr[1])), + bool(kwargs.get('rational', True)))) + return result + + def _run_top_boundary_csx(curve, surface, **kwargs): + return _run_csx( + curve, surface, local_truncation_is_soft=True, **kwargs) # Reproducibility: the Gauss-separability witness search draws from # module-global PRNGs; without a per-call reset, bit-identical inputs @@ -4651,8 +6143,9 @@ def bez_ssx( reset_witness_rng() # --- Level 1: Pruning --- - if _prune_ssx_cell(S1, S2, atol, rational=rational): - return {'branches': [], 'points': [], 'singularities': []} + if _prune_ssx_cell( + S1, S2, atol, rational=rational, F=F_sq_top): + return _result() # --- Build GaussMapBern ONCE --- if rational: @@ -4668,8 +6161,31 @@ def bez_ssx( # Crossings are already in global [0,1]⁴ coords (top-level box is [0,1]⁴). # _find_ssx_boundary_zeros already filters crossings that coincide with # overlap endpoints by stuv (design §10.6); nothing more to do here. - crossings, boundary_overlaps = _find_ssx_boundary_zeros(S1, S2, atol, rational=rational) - overlap_branches = _overlaps_to_branches(boundary_overlaps, S1, atol, rational) + boundary_parameter_fibers = [] + crossings, boundary_overlaps = _find_ssx_boundary_zeros( + S1, S2, atol, rational=rational, csx_fn=_run_top_boundary_csx, + fiber_sink=boundary_parameter_fibers) + _capped_boundary_fibers = [] + budget.extend_output( + _capped_boundary_fibers, boundary_parameter_fibers, + "boundary_parameter_fiber") + boundary_parameter_fibers = _capped_boundary_fibers + if boundary_parameter_fibers: + # A fiber is certified output as a set, but its incident SSI branch + # multiplicity is not. Preserve useful branches while refusing a + # complete-topology claim until that limiting topology is proved. + budget.mark_incomplete(REASON_PARAMETER_FIBER) + _overlap_candidates = _overlaps_to_branches( + boundary_overlaps, S1, atol, rational) + overlap_branches = [] + budget.extend_output(overlap_branches, _overlap_candidates, "branch") + if budget.exhausted: + # L52 slice 9b: the interior search never ran — the whole domain + # is the unresolved complement (typed, not just flagged). + return _result(branches=overlap_branches, unresolved_regions=[ + {'stuv_min': (0.0, 0.0, 0.0, 0.0), + 'stuv_max': (1.0, 1.0, 1.0, 1.0), + 'reason': REASON_WORK_BUDGET}]) # --- Level 3: TΨᵢ (once at top level) --- if rational: @@ -4692,9 +6208,9 @@ def bez_ssx( # cases + every polynomial test) take the exact polynomial path, # BIT-IDENTICAL to pre-L9. (`S1[..., -1]` is a genuine weight column # only when `rational`; the short-circuit keeps it unread otherwise.) - if rational and not ( - np.allclose(S1[..., -1], 1.0, rtol=0.0, atol=1e-12) - and np.allclose(S2[..., -1], 1.0, rtol=0.0, atol=1e-12)): + _w1_uniform = not rational or _weight_net_uniform(S1) + _w2_uniform = not rational or _weight_net_uniform(S2) + if rational and not (_w1_uniform and _w2_uniform): from mmcore.numeric.intersection.ssx._ssx5_singular import ( minors_Tpsi_rational) T1, T2, T3, T4 = minors_Tpsi_rational(S1, S2) @@ -4708,15 +6224,7 @@ def bez_ssx( T3_arr = _tpsi_to_numpy(T3) T4_arr = _tpsi_to_numpy(T4) - # Build sq-dist net once at top level; propagated by de Casteljau split. - if rational: - S1_h_top = S1 - S2_h_top = S2 - else: - S1_h_top = np.concatenate([S1, np.ones(S1.shape[:-1]+(1,))], axis=-1) - S2_h_top = np.concatenate([S2, np.ones(S2.shape[:-1]+(1,))], axis=-1) - F_sq_top = surface_surface_distance_squared_net_homog( - S1_h_top, S2_h_top, rational=True) + # The preflighted top-level net is propagated by De Casteljau split. _, S1w_top = extract_weights(S1_h_top, rational=True) _, S2w_top = extract_weights(S2_h_top, rational=True) w_scale_top = _weight_max_product(S1w_top.ravel(), S2w_top.ravel()) @@ -4756,11 +6264,38 @@ def bez_ssx( _diag = float(np.linalg.norm(_pts_joint.max(axis=0) - _pts_joint.min(axis=0))) h_max = max_xyz_step if max_xyz_step is not None else max(0.05 * _diag, 4.0 * atol) + promoted_fiber_fragment = None + if boundary_parameter_fibers and not budget.exhausted: + promotion_limit = min(512, budget.remaining_cells) + if promotion_limit <= 0: + budget.mark_exhausted() + else: + promoted_fibers, promotion_stats, promoted_path = ( + _promote_transversal_boundary_fiber_pair( + S1_h_top, S2_h_top, boundary_parameter_fibers, + crossings, boundary_overlaps, + atol=atol, unify_tol=unify_tol, h_max=h_max, + max_points=promotion_limit)) + promotion_work = max( + 0, int(promotion_stats.get("iterations", 0))) + if not budget.charge_cells(promotion_work, "fiber_promotion"): + promoted_fibers = [] + promoted_path = None + if promoted_fibers and promoted_path is not None: + promoted_fiber_fragment = _Fragment( + start_point=promoted_fibers[0], + end_point=promoted_fibers[1], + stuv_path=promoted_path[0], + xyz_path=promoted_path[1], + tangential=False, + ) + top_cell = _Cell( g1=g1, g2=g2, crossings=crossings, box=box, depth=0, T1=T1_arr, T2=T2_arr, T3=T3_arr, T4=T4_arr, new_crossings=list(crossings), F_sq=F_sq_top, w_scale=w_scale_top, + work_budget=budget, csx_fn=_run_csx, ) top_cell.partitions = _build_outer_partitions(top_cell) @@ -4787,6 +6322,17 @@ def bez_ssx( all_fragments: list[_Fragment] = [] all_points = [] all_singularities: list[SSXSingularity] = [] + unresolved_regions: list[dict] = [] + if promoted_fiber_fragment is not None: + budget.append_output( + all_fragments, promoted_fiber_fragment, "fragment") + # Crossing-less Phi seeding is complete for the whole cell it slices; + # descendant reconfirmations must not repay the 4-plane search. Keep + # ancestor boxes rather than keying on emitted points: a 1-D tangent + # loop deliberately emits no isolated tangent_point but still needs one + # Phi seed pass (the case13 dedup fix must not suppress that path). + phi_seeded_boxes: list[tuple] = [] + phi_seed_attempts: list[NDArray[np.float64]] = [] # NOTE no per-cell C3 gate here (ledger L8): Theorem 3 is a PER-BOX # injectivity certificate — a C3 whose two preimages lie in DIFFERENT # traced cells passes every per-cell check (each branch-carrying cell @@ -4798,7 +6344,22 @@ def bez_ssx( hull_excludes_zero, psi_vector_net, ) + def _available_queue_slots() -> int: + # Queued cells are already allocated future pop work. Reserving + # their slots prevents a producer-heavy tree from materializing + # O(fanout*max_cells) cells while only popped cells are charged. + return max(0, budget.remaining_cells - len(queue)) + + def _enqueue_probe_children(parent) -> bool: + if _available_queue_slots() < 4: + budget.mark_exhausted() + return False + queue.extend(_probe_children(parent)) + return True + while queue: + if not budget.charge_cells(1, "ssx"): + break cell = queue.popleft() # Cheap AABB pruning first: if control-point bounding boxes don't @@ -4877,7 +6438,7 @@ def bez_ssx( if not (ok and roots) and not np.all( np.array([hi - lo for (lo, hi) in cell.box]) <= 4.0 * unify_tol): - queue.extend(_probe_children(cell)) + _enqueue_probe_children(cell) continue # Loop-absence on this sub-cell — TΨᵢ monotonicity (cheap) tried first, @@ -4921,7 +6482,9 @@ def bez_ssx( ok, roots = _emit_tangent_roots(cell, atol, unify_tol, all_singularities, enumerate_all=False, - overlap_boxes=overlap_boxes) + overlap_boxes=overlap_boxes, + defer_inconclusive=bool( + cell.crossings)) # Ledger L4: the hull gate says a touch is POSSIBLE but # the center-only witness failed (GN diverged or landed # outside — e.g. an off-lattice touch sitting at this @@ -4944,9 +6507,48 @@ def bez_ssx( if not (ok and roots) and not np.all( np.array([hi - lo for (lo, hi) in cell.box]) <= 4.0 * unify_tol): - queue.extend(_probe_children(cell)) + _enqueue_probe_children(cell) if cell.crossings: - fr, pt = _trace_cell_by_registrations(cell, atol, h_max=h_max) + # A loop-free cell whose registrations all collapse onto + # one isolated tangent witness has no certified through-arc. + # In this configuration the ordinary tracer's displaced-seed + # recovery can walk the sub-atol tolerance valley and invent + # an unregistered partner endpoint (regular isolated touch: + # two complete-looking 20*atol "transversal" branches). + # Require a crossing distinct from the tangent cluster before + # regular tracing. Both the parameter and xyz guards follow + # the module's matching ladder; failure stays explicitly + # partial until a second-order isolation certificate exists. + tangent_cluster_only = False + if tangent_gate and ok and roots: + root_clusters = [] + for root in roots: + root = np.asarray(root, dtype=np.float64) + root_clusters.append(( + _local_to_global(root, cell.box), + eval_surface( + cell.g1.surface, root[0], root[1], + rational=True), + )) + tangent_cluster_only = all( + any( + np.all(np.abs(np.asarray(c.stuv) - rg) + <= unify_tol) + and float(np.linalg.norm( + np.asarray(c.xyz) - rx)) <= 2.0 * atol + for rg, rx in root_clusters) + for c in cell.crossings) + + if tangent_cluster_only: + for rg, _rx in root_clusters: + budget.structural_sites.append( + (REASON_MULTIPLICITY, + np.asarray(rg, dtype=np.float64).copy())) + budget.mark_incomplete(REASON_MULTIPLICITY) + fr, pt = [], [] + else: + fr, pt = _trace_cell_by_registrations( + cell, atol, h_max=h_max) if tangent_gate: # Ledger L5: a tangent CURVE traces fine through this # loop-free path (non-strict monotone T-hulls) but @@ -4964,8 +6566,50 @@ def bez_ssx( and _fragment_on_tangent_locus(cell, f, atol)): f.tangential = True - all_fragments.extend(fr) - all_points.extend(pt) + + # A high-multiplicity tangent curve can leave Delta' at + # rank 2 everywhere, so the local rank-3 continuation test + # is intentionally inconclusive. A strict traced tangent + # path through that same 4-D root is a stronger direct + # certificate of local one-dimensionality. Resolve only + # against the SAME-LENGTH-SCALE stuv and xyz location; + # otherwise preserve the earlier conservative partial flag + # (isolated high-order touch beside an unrelated branch). + deferred = getattr(cell, "_deferred_delta_roots", []) + if deferred: + scale4 = np.maximum( + np.asarray(unify_tol, dtype=np.float64), 1e-12) + for root in deferred: + root_g = _local_to_global(root, cell.box) + root_xyz = eval_surface( + cell.g1.surface, root[0], root[1], + rational=True) + covered = False + for f in fr: + if not f.tangential or len(f.stuv_path) < 2: + continue + if (_dist_point_polyline( + root_xyz, + np.asarray(f.xyz_path, + dtype=np.float64)) + > 2.0 * atol): + continue + scaled_poly = (np.asarray( + f.stuv_path, dtype=np.float64) + / scale4[None, :]) + if (_dist_point_polyline_nd( + root_g / scale4, scaled_poly) <= 2.0): + covered = True + break + if not covered: + budget.structural_sites.append( + (REASON_MULTIPLICITY, + np.asarray(root_g, + dtype=np.float64).copy())) + budget.mark_incomplete(REASON_MULTIPLICITY) + break + budget.extend_output(all_fragments, fr, "fragment") + budget.extend_output(all_points, pt, "point") continue # §6 step 3: Krawczyk-based tangency certification. If TΨ = 0 has a @@ -4983,8 +6627,11 @@ def bez_ssx( # threshold, so genuine tangents were misclassified as transversal. # The dot/cross-product test scales linearly (slope ~1) with the # offset, so a 1e-3 threshold (≈0.06°) gives ~10⁴× headroom. + _cell_fibers = (boundary_parameter_fibers + if cell.depth == 0 else []) + _has_boundary_seeds = bool(cell.crossings or _cell_fibers) is_clearly_transversal = False - if not cell.crossings: + if not _has_boundary_seeds: # An isolated tangency or interior tangent loop lives in exactly # this kind of cell (no boundary crossings). Whether tangency is # even possible is already known for free: _check_monotonicity @@ -4996,6 +6643,22 @@ def bez_ssx( else: for c in cell.crossings: s, t, u, v = c.stuv # global stuv on the original surfaces + # Near a collapsed apex edge the normal direction is + # ill-conditioned and can approach any limiting direction. + # Such a crossing cannot CERTIFY transversality. Case 14's + # true tangent generator ended at two apex fibers; CSX placed + # their representatives ~1.7e-5 inside the face, and the + # arbitrary near-apex normals falsely sent the top cell down + # the transversal subdivision path. Use the same parametric + # matching ladder as crossing unification and let the exact + # rational Delta witness decide the cell instead. + if (_on_collapsed_boundary_fiber( + S1_h_top, s, t, rational=True, + param_tol=float(max(unify_tol[0], unify_tol[1]))) + or _on_collapsed_boundary_fiber( + S2_h_top, u, v, rational=True, + param_tol=float(max(unify_tol[2], unify_tol[3])))): + continue _, du1, dv1 = eval_surface_d1(S1, s, t, rational=rational) _, du2, dv2 = eval_surface_d1(S2, u, v, rational=rational) N1 = np.cross(du1, dv1) @@ -5014,14 +6677,20 @@ def bez_ssx( if is_clearly_transversal: tangency = False else: - P1_cart_local = cell.g1.surface[..., :-1] / cell.g1.surface[..., -1:] - P2_cart_local = cell.g2.surface[..., :-1] / cell.g2.surface[..., -1:] local_box = ((0.0, 1.0),) * 4 + if rational: + _tan_S1, _tan_S2, _tan_rat = ( + cell.g1.surface, cell.g2.surface, True) + else: + _tan_S1 = cell.g1.surface[..., :-1] + _tan_S2 = cell.g2.surface[..., :-1] + _tan_rat = False tangency = _check_tangency( cell.T1, cell.T2, cell.T3, cell.T4, - P1_cart_local, P2_cart_local, local_box, + _tan_S1, _tan_S2, local_box, rational=_tan_rat, + atol=atol, ) - if tangency is True and not cell.crossings: + if tangency is True and not _has_boundary_seeds: # Isolated tangent point (or tangent feature with no boundary # contact). The Gauss-Newton witness from _check_tangency is the # point — recompute it here tighter to get coordinates (cheap: @@ -5039,7 +6708,23 @@ def bez_ssx( all_singularities, enumerate_all=True, overlap_boxes=overlap_boxes) - if ok: + _root_globals = [ + _local_to_global(np.asarray(r, dtype=np.float64), cell.box) + for r in roots] + _seed_cell = top_cell if cell.depth > 0 else cell + _seed_roots = (_root_globals if cell.depth > 0 else roots) + _seed_anchor = (np.asarray(_root_globals[0], dtype=np.float64) + if _root_globals else np.array( + [0.5 * (lo + hi) for lo, hi in cell.box])) + _phi_already_seeded = any(all( + parent[ax][0] <= cell.box[ax][0] + and cell.box[ax][1] <= parent[ax][1] + for ax in range(4)) for parent in phi_seeded_boxes) + _phi_already_attempted = any( + np.all(np.abs(_seed_anchor - prior) <= unify_tol) + for prior in phi_seed_attempts) + if (ok and not _phi_already_seeded + and not _phi_already_attempted and not budget.exhausted): # Paper §5.3.2: slice the regulated Φ curve with the four # deterministic axis mid-planes to seed loops around the # tangency that have no boundary crossings, then march each @@ -5049,8 +6734,17 @@ def bez_ssx( # containment — the seeding can add geometry, never # duplicate it; loops inside the size-gated blind window # (cells that `continue` below) are found ONLY here. - all_fragments.extend(_phi_slice_loop_fragments( - cell, roots, atol, h_max, all_singularities)) + _phi_fragments = _phi_slice_loop_fragments( + _seed_cell, _seed_roots, atol, h_max, all_singularities) + phi_seed_attempts.append(_seed_anchor) + budget.extend_output( + all_fragments, _phi_fragments, "fragment") + # An empty coarse-cell slice is inconclusive: a small loop + # can miss all four mid-planes until a descendant tightens + # the box. Cache only a productive pass; shared solver + # budgets bound unsuccessful retries. + if _phi_fragments: + phi_seeded_boxes.append(_seed_cell.box) # Emitting the tangency does NOT resolve the cell: the same # crossing-less cell can hold coexisting transversal features — # z = q(q-1/2) (Mexican hat) has the touch at the center AND a @@ -5071,7 +6765,7 @@ def bez_ssx( if ok and np.all(spans <= 4.0 * unify_tol): continue - if tangency is True and cell.crossings: + if tangency is True and _has_boundary_seeds: # C2 with transversal branches THROUGH the touch (saddle # X-crossing): a cell holding such a tangent point is crossing- # BEARING (the arms pierce its boundary), so the crossing-less @@ -5083,38 +6777,86 @@ def bez_ssx( # burned the whole solve_zero_dim budget to emit ptol-spaced # curve samples (measured: 69 tangent_points, 2.43 s vs 0.15 s # for the case — a >2x slowdown gate per the Task 4 plan). The - # center witness costs ~ms; a witness landing ON a traced - # tangential/overlap branch is dropped by the post-assembly - # subsumption filter, so the curve case emits nothing. - _emit_tangent_roots(cell, atol, unify_tol, all_singularities, - enumerate_all=False, - overlap_boxes=overlap_boxes) + # center witness costs ~ms. Do NOT emit it yet: on a tangent + # curve this is one arbitrary curve sample per descendant cell, + # and relying on a later globally-complete branch to subsume the + # samples revived a 29-point flood whenever tracing was partial. + # `_emit_offcurve_tangent_roots` below is the single emission + # owner for crossing-bearing cells: it suppresses roots in tubes + # measured tangent along their whole path, but enumerates and + # emits an isolated saddle/off-curve touch. + _ok_curve, _curve_roots, _curve_fn, _curve_exhausted = ( + _tangency_witness(cell, atol, enumerate_all=False)) + if _curve_exhausted and cell.work_budget is not None: + cell.work_budget.mark_incomplete(REASON_TANGENTIAL_ZONE) + # Boundary roots on a collapsed apex edge carry an arbitrary + # free parameter. Canonicalize it to the interior Delta + # witness before Phi tracing; otherwise identical physical + # endpoints can be paired as a sub-atol micro-fragment while + # the actual generator is never marched (case 14 was + # nondeterministic between those two outcomes). + _needs_fiber_canonicalization = bool(_cell_fibers) or any( + _on_collapsed_boundary_fiber( + S1_h_top, c.stuv[0], c.stuv[1], rational=True, + param_tol=float(max(unify_tol[0], unify_tol[1]))) + or _on_collapsed_boundary_fiber( + S2_h_top, c.stuv[2], c.stuv[3], rational=True, + param_tol=float(max(unify_tol[2], unify_tol[3]))) + for c in cell.crossings) + if not _needs_fiber_canonicalization: + # Preserve the established object/registration topology on + # ordinary tangent cells; only apex fibers need rewriting. + trace_crossings = list(cell.crossings) + else: + _anchor_local = ( + np.asarray(_curve_roots[0], dtype=np.float64) + if _curve_roots else np.full(4, 0.5)) + _anchor_global = _local_to_global(_anchor_local, cell.box) + trace_crossings = [] + for c in list(cell.crossings) + list(_cell_fibers): + stuv_c = np.asarray(c.stuv, dtype=np.float64).copy() + stuv_c[:2] = _canonicalize_collapsed_fiber_params( + S1_h_top, stuv_c[:2], _anchor_global[:2], rational=True, + param_tol=float(max(unify_tol[0], unify_tol[1]))) + stuv_c[2:] = _canonicalize_collapsed_fiber_params( + S2_h_top, stuv_c[2:], _anchor_global[2:], rational=True, + param_tol=float(max(unify_tol[2], unify_tol[3]))) + canonical = BoundaryPoint( + stuv=stuv_c, xyz=np.asarray(c.xyz, dtype=np.float64), + face=c.face, tangent_raw=c.tangent_raw) + if any(np.all(np.abs(stuv_c - q.stuv) <= unify_tol) + and float(np.linalg.norm(canonical.xyz - q.xyz)) + <= 2.0 * atol for q in trace_crossings): + continue + trace_crossings.append(canonical) # Convert crossings to the cell's local stuv for the Φ tracer. crossings_local = [ BoundaryPoint( stuv=_global_to_local(c.stuv, cell.box), xyz=c.xyz, face=c.face, tangent_raw=c.tangent_raw, ) - for c in cell.crossings + for c in trace_crossings ] fr_local, pt_local = _deflate_tangent_cell( - P1_cart_local, P2_cart_local, + cell.g1.surface, cell.g2.surface, cell.T1, cell.T2, cell.T3, cell.T4, local_box, crossings_local, atol, - originals=cell.crossings, cell=cell, h_max=h_max, + rational=True, originals=trace_crossings, cell=cell, + h_max=h_max, ) for f in fr_local: stuv_glob = np.empty_like(f.stuv_path) for k in range(len(f.stuv_path)): stuv_glob[k] = _local_to_global(f.stuv_path[k], cell.box) - all_fragments.append(_Fragment( + _fragment = _Fragment( start_point=f.start_point, end_point=f.end_point, stuv_path=stuv_glob, xyz_path=f.xyz_path, tangential=f.tangential, - )) + ) + budget.append_output(all_fragments, _fragment, "fragment") # pt_local's SSXPoint.stuv is already global — we passed # `originals` so _deflate_tangent_cell copied from them. - all_points.extend(pt_local) + budget.extend_output(all_points, pt_local, "point") # Tracing the Φ curve between this cell's crossings does NOT by # itself resolve the cell: the deflation only reaches features # ON Φ through the boundary crossings, and the center witness @@ -5151,7 +6893,21 @@ def bez_ssx( if cell.depth >= max_depth: for c in cell.crossings: - all_points.append(SSXPoint(stuv=c.stuv, xyz=c.xyz)) + budget.append_output( + all_points, SSXPoint(stuv=c.stuv, xyz=c.xyz), "point") + # Reaching the caller's depth ceiling after all sound + # certificates above failed leaves this cell unresolved. The + # boundary samples are useful partial output, not a proof that + # no interior component exists (case 7 at max_depth=0). + # L52 slice 9b: name the complement — the cell's 4-D box is a + # typed diagnostic entity, not just a flag. + budget.append_output( + unresolved_regions, + {'stuv_min': tuple(float(cell.box[i][0]) for i in range(4)), + 'stuv_max': tuple(float(cell.box[i][1]) for i in range(4)), + 'reason': REASON_DEPTH_LIMIT}, + "unresolved_region") + budget.mark_incomplete(REASON_DEPTH_LIMIT) continue # --- Dual-surface subdivision --- @@ -5160,7 +6916,7 @@ def bez_ssx( # a midpoint cut on its longest-span axis. s1_axis, s1_cuts, s2_axis, s2_cuts = _compute_split_plan( - cell.new_crossings, cell.box) + cell.new_crossings, cell.box, cut_tol=dedup_tol, max_cuts=8) #print(s1_axis, s1_cuts, s2_axis, s2_cuts,cell.box,[nc.xyz.tolist() for nc in cell.new_crossings]) # Midpoint fallback per surface when no guided cuts if s1_axis is None: @@ -5173,6 +6929,18 @@ def bez_ssx( s2_span_v = cell.box[3][1] - cell.box[3][0] s2_axis = 2 if s2_span_u >= s2_span_v else 3 s2_cuts = [0.5 * (cell.box[s2_axis][0] + cell.box[s2_axis][1])] + # Guard the Cartesian product BEFORE allocating split nets/grids. + # Productive crossings are only guides; midpoint subdivision is a + # sound fallback when noisy guides would consume the remaining call + # budget in one cell. If even the 2x2 fallback cannot fit, return + # the partial result instead of allocating work that cannot run. + projected_children = (len(s1_cuts) + 1) * (len(s2_cuts) + 1) + if projected_children > _available_queue_slots(): + if _available_queue_slots() < 4: + budget.mark_exhausted() + break + s1_cuts = [0.5 * (cell.box[s1_axis][0] + cell.box[s1_axis][1])] + s2_cuts = [0.5 * (cell.box[s2_axis][0] + cell.box[s2_axis][1])] #print(s1_axis, s1_cuts, s2_axis, s2_cuts, cell.box,[nc.xyz.tolist() for nc in cell.new_crossings]) # Split S1 (Gauss map) along s1_axis g1_pieces = _split_surface_multi(cell.g1, s1_axis, s1_cuts, cell.box) @@ -5224,14 +6992,19 @@ def bez_ssx( # a/b: CSX(cut_line_s1, S2_piece) for each S1 cut × each S2 piece for cut_idx, cv in enumerate(s1_cuts): + if budget.exhausted: + break s1_lo_box, s1_hi_box = cell.box[s1_axis] cut_local_s1 = (cv - s1_lo_box) / (s1_hi_box - s1_lo_box) isoline_s1 = _extract_isoline(cell.g1.surface, s1_local_axis, cut_local_s1) for s2_idx in range(n2): + if budget.exhausted: + break s2_piece_surf = g2_pieces[s2_idx].surface - csx_r = bez_csx(isoline_s1, s2_piece_surf, atol=atol, rational=True) - #print(csx_r) + csx_r = _run_csx( + isoline_s1, s2_piece_surf, atol=atol, rational=True) + _surface_cut_face_fibers(csx_r, budget) csx_r['isolated'] = list(filter( lambda x: not (((1 - x['t']) < 1e-6) or (x['t'] < 1e-6)), csx_r['isolated'])) @@ -5261,15 +7034,24 @@ def bez_ssx( new_cx_grid[cut_idx][s2_idx].append(bp) new_cx_grid[cut_idx + 1][s2_idx].append(bp) + if budget.exhausted: + break + # c/d: CSX(cut_line_s2, S1_piece) for each S2 cut × each S1 piece for cut_idx, cv in enumerate(s2_cuts): + if budget.exhausted: + break s2_lo_box, s2_hi_box = cell.box[s2_axis] cut_local_s2 = (cv - s2_lo_box) / (s2_hi_box - s2_lo_box) isoline_s2 = _extract_isoline(cell.g2.surface, s2_local_axis, cut_local_s2) for s1_idx in range(n1): + if budget.exhausted: + break s1_piece_surf = g1_pieces[s1_idx].surface - csx_r = bez_csx(isoline_s2, s1_piece_surf, atol=atol, rational=True) + csx_r = _run_csx( + isoline_s2, s1_piece_surf, atol=atol, rational=True) + _surface_cut_face_fibers(csx_r, budget) csx_r['isolated'] = list(filter( lambda x: not (((1 - x['t']) < 1e-6) or (x['t'] < 1e-6)), csx_r['isolated'])) @@ -5299,6 +7081,16 @@ def bez_ssx( new_cx_grid[s1_idx][cut_idx].append(bp) new_cx_grid[s1_idx][cut_idx + 1].append(bp) + if budget.exhausted: + break + + # Cut-face CSX runs after the first fanout check and spends from the + # same global allowance. Re-check immediately before allocating the + # child cells so already-queued work plus this product still fits. + if n1 * n2 > _available_queue_slots(): + budget.mark_exhausted() + break + # Build Cartesian product of S1 pieces × S2 pieces for i1 in range(n1): s1_lo = cell.box[s1_axis][0] if i1 == 0 else s1_cuts[i1 - 1] @@ -5351,12 +7143,26 @@ def bez_ssx( new_crossings=sub_new, F_sq=F_sq_pieces[i1][i2] if F_sq_pieces[i1] else None, w_scale=cell.w_scale, + work_budget=budget, csx_fn=_run_csx, ) scell.partitions = _build_cell_partitions(scell) for c in sub_cx: _classify_boundary_point(c, scell) queue.append(scell) + # L52 slice 9b: cells abandoned by a work-exhausted queue are the other + # half of the unresolved complement — name them the same way the depth + # dump does (typed 4-D boxes; the output cap bounds the list and marks + # honestly if it saturates). + for cell in queue: + if not budget.append_output( + unresolved_regions, + {'stuv_min': tuple(float(cell.box[i][0]) for i in range(4)), + 'stuv_max': tuple(float(cell.box[i][1]) for i in range(4)), + 'reason': REASON_WORK_BUDGET}, + "unresolved_region"): + break + # --- §9 assembly: chain fragments by shared BoundaryPoint endpoints --- # Pass the original surfaces so the assembly can march any small chain # gap that arises when a closed-loop intersection ends up with the same @@ -5373,9 +7179,74 @@ def bez_ssx( unify_tol=unify_tol, h_max=h_max, barrier_xyz=[g.xyz for g in all_singularities if g.kind == "tangent_point"], + work_budget=budget, ) all_branches.extend(overlap_branches) + # Everything below is post-assembly classification/filtering. With no + # remaining allowance, do not enter its endpoint-pair, point/branch, or + # linkage scans. Certified exact overlap claims are independently safe + # to return; ordinary assembled fragments and unfiltered point-like + # candidates are omitted from the explicit partial result. + if not _assembly_spend(budget): + return _result(branches=list(overlap_branches)) + + # A certified boundary overlap owns every ordinary traced fragment whose + # entire 4-D path is a subset of that same overlap. Boundary endpoints + # remain regular registrations, so the loop-free tracer can otherwise + # re-march half of the edge and publish both ``transversal`` and + # ``overlap`` copies. Require BOTH the xyz and same-location stuv guards + # at every vertex and segment midpoint; xyz proximity alone would delete + # legitimate parameter-far sheets (ledger L3). On postprocess-budget + # denial, conservatively keep the fragment and leave the result partial. + if overlap_branches and len(all_branches) > len(overlap_branches): + _without_overlap_duplicates = [] + for branch in all_branches: + if branch.kind == "overlap": + _without_overlap_duplicates.append(branch) + continue + branch_stuv = np.asarray(branch.curve[0], dtype=np.float64) + branch_xyz = np.asarray(branch.curve[1], dtype=np.float64) + if len(branch_stuv) >= 2: + mid_stuv = 0.5 * (branch_stuv[:-1] + branch_stuv[1:]) + mid_xyz = np.array([ + eval_surface( + S1_h_top, x[0], x[1], rational=True) + for x in mid_stuv + ]) + sample_stuv = np.vstack([branch_stuv, mid_stuv]) + sample_xyz = np.vstack([branch_xyz, mid_xyz]) + else: + sample_stuv, sample_xyz = branch_stuv, branch_xyz + + contained = False + denied = False + for overlap in overlap_branches: + overlap_stuv = np.asarray( + overlap.curve[0], dtype=np.float64) + overlap_xyz = np.asarray( + overlap.curve[1], dtype=np.float64) + inside = True + for pstuv, pxyz in zip(sample_stuv, sample_xyz): + if not _assembly_spend( + budget, max(1, len(overlap_xyz) - 1)): + denied = True + inside = False + break + if not _point_on_branch_both_guards( + pxyz, pstuv, overlap_xyz, overlap_stuv, + atol, unify_tol, S1_h_top, S2_h_top): + inside = False + break + if inside: + contained = True + break + if denied: + break + if not contained: + _without_overlap_duplicates.append(branch) + all_branches = _without_overlap_duplicates + # --- Overlap-curve JUNCTION singularities --- # Two 1-dimensional overlap/tangential features meeting at a point are # a structural singularity of the SSI, and at such a junction the @@ -5417,11 +7288,15 @@ def _sin_ang_inward(b, at_start, d_iso): return _sin_ang_at(stuv[-1]) _ends = [] # (branch_idx, xyz, stuv, out_dir, vertex, branch, at_start) + _junction_scan_denied = False for bi, b in enumerate(all_branches): if b.kind not in ("overlap", "tangential"): continue xyz = np.asarray(b.curve[1], dtype=np.float64) stuv = np.asarray(b.curve[0], dtype=np.float64) + if not _assembly_spend(budget, max(1, len(xyz))): + _junction_scan_denied = True + break if len(xyz) < 2: continue if float(np.linalg.norm(xyz[0] - xyz[-1])) <= 2.0 * atol: @@ -5435,8 +7310,11 @@ def _sin_ang_inward(b, at_start, d_iso): continue _ends.append((bi, p3, p4, v / n, 0 if at_start else len(xyz) - 1, b, at_start)) - for a in range(len(_ends)): + for a in range(0 if _junction_scan_denied else len(_ends)): for c in range(a + 1, len(_ends)): + if not _assembly_spend(budget): + _junction_scan_denied = True + break ba, pa, sa4, da, ka, bra, sta = _ends[a] bc, pc, sc4, dc, kc, brc, stc = _ends[c] if ba == bc: @@ -5458,12 +7336,16 @@ def _sin_ang_inward(b, at_start, d_iso): # whole neighborhood (tangency is 1- or 2-dimensional # there) and is exactly the class the L6 suppression # exists for — skip it. + _bra_xyz = np.asarray(bra.curve[1], dtype=np.float64) + _brc_xyz = np.asarray(brc.curve[1], dtype=np.float64) + if not _assembly_spend( + budget, max(1, len(_bra_xyz) + len(_brc_xyz))): + _junction_scan_denied = True + break _arc_a = float(np.linalg.norm( - np.diff(np.asarray(bra.curve[1], dtype=np.float64), - axis=0), axis=1).sum()) + np.diff(_bra_xyz, axis=0), axis=1).sum()) _arc_c = float(np.linalg.norm( - np.diff(np.asarray(brc.curve[1], dtype=np.float64), - axis=0), axis=1).sum()) + np.diff(_brc_xyz, axis=0), axis=1).sum()) d_iso_a = max(8.0 * atol, 0.05 * _arc_a) d_iso_c = max(8.0 * atol, 0.05 * _arc_c) if (_sin_ang_inward(bra, sta, d_iso_a) <= 1e-3 @@ -5473,10 +7355,12 @@ def _sin_ang_inward(b, at_start, d_iso): and np.all(np.abs(g.stuv - sa4) <= unify_tol) and float(np.linalg.norm(np.asarray(g.xyz) - pa)) <= 2.0 * atol for g in all_singularities): - all_singularities.append(SSXSingularity( + budget.append_output(all_singularities, SSXSingularity( kind="tangent_point", stuv=np.asarray(sa4, dtype=np.float64), xyz=np.asarray(pa, dtype=np.float64), - branch_links=[(ba, ka), (bc, kc)])) + branch_links=[(ba, ka), (bc, kc)]), "singularity") + if _junction_scan_denied: + break # A tangent_point ON a 1-dimensional tangential feature is not an # isolated C2 touch: overlap regions and traced tangent curves (branch @@ -5510,7 +7394,8 @@ def _sin_ang_inward(b, at_start, d_iso): # (junk kept, singularity lost). With the floor, micro-branches can # never subsume a tangent_point, so the two filters are # order-independent for micro-branches. - if all_singularities: + if (all_singularities + and _assembly_spend(budget, max(1, len(all_branches)))): _one_dim_polys = [] for b in all_branches: if b.kind not in ("overlap", "tangential"): @@ -5522,7 +7407,13 @@ def _sin_ang_inward(b, at_start, d_iso): if _arc > 16.0 * atol: _one_dim_polys.append( (_poly, np.asarray(b.curve[0], dtype=np.float64))) - if _one_dim_polys: + _subsumption_cost = ( + len(all_singularities) + * sum(max(1, len(poly_xyz) - 1) + for poly_xyz, _ in _one_dim_polys)) + if (_one_dim_polys + and _assembly_spend( + budget, max(1, _subsumption_cost))): def _interior_subsumed(g): # JUNCTION EXCEPTION (corner-sharing bilinear repro): a # tangent_point at the ENDPOINT of an overlap/tangential @@ -5574,17 +7465,26 @@ def _interior_subsumed(g): if _tangent_xyz and all_branches: _tp = np.asarray(_tangent_xyz, dtype=np.float64) # (K, 3) _kept_branches = [] - for b in all_branches: - xyz = np.asarray(b.curve[1], dtype=np.float64) - if len(xyz): - d_min = np.linalg.norm( - xyz[:, None, :] - _tp[None, :, :], axis=2).min(axis=1) - if np.all(d_min <= 4.0 * atol): - arc = (float(np.linalg.norm(np.diff(xyz, axis=0), axis=1).sum()) - if len(xyz) > 1 else 0.0) - if arc <= 16.0 * atol: - continue - _kept_branches.append(b) + _micro_cost = len(_tp) * sum( + max(1, len(np.asarray(b.curve[1]))) for b in all_branches) + if not _assembly_spend(budget, max(1, _micro_cost)): + # This pass is a soundness filter. Exact overlap branches were + # independently certified; omit unchecked ordinary fragments. + _kept_branches = [ + b for b in all_branches if b.kind == "overlap"] + else: + for b in all_branches: + xyz = np.asarray(b.curve[1], dtype=np.float64) + if len(xyz): + d_min = np.linalg.norm( + xyz[:, None, :] - _tp[None, :, :], axis=2).min(axis=1) + if np.all(d_min <= 4.0 * atol): + arc = (float(np.linalg.norm( + np.diff(xyz, axis=0), axis=1).sum()) + if len(xyz) > 1 else 0.0) + if arc <= 16.0 * atol: + continue + _kept_branches.append(b) all_branches = _kept_branches # --- C1 pass (paper Fig. 5): parameterization cusps ON the SSI --- @@ -5600,47 +7500,102 @@ def _interior_subsumed(g): ptol4_global = np.maximum(np.array( [float(_gp_s), float(_gp_t), float(_gp_u), float(_gp_v)]), 1e-9) - c1_hits, _c1_curve = c1_pass(S1_h_top, S2_h_top, atol, ptol4_global) + if budget.exhausted: + c1_hits, _c1_curve = [], False + else: + _c1_stats = {} + # The 20k tier is a per-call C1 allowance clamped by the shared + # remainder (L38 pattern). Justification kept WITH a measurement + # (§7.3): on positive-dimensional Σ sets the enumeration truncates + # on its RESULT cap long before the tier (interior-pinch fixture: + # 509 of 20,000 tier cells, 5.7k of 1M shared cells), so a larger + # tier buys nothing there; regular isolated-cusp enumerations on + # every gate case finish far below 20k. + _c1_tier = min(20_000, budget.remaining_cells) + # Review finding (slices-6-9a adversarial pass): when the tier is + # CLAMPED by the shared remainder, a local truncation is shared- + # budget scarcity in disguise (c1_pass's per-surface fair share is + # always <= the remainder, so charge_box never gets to deny and + # external_budget_exhausted stays False) — and c1's curve test has + # a documented blind spot (curve_like fires for a budget-truncated + # multi-cusp census). Structural classification is only trusted + # when the FULL tier was available. + _c1_tier_clamped = _c1_tier < 20_000 + c1_hits, _c1_curve = c1_pass( + S1_h_top, S2_h_top, atol, ptol4_global, + max_cells=_c1_tier, + charge_box=_charge_hook(budget, "c1"), + stats=_c1_stats) + # Reason attribution (L52 slice 9 / §11.6 de-budget — the L49 + # misbilling): c1_pass deliberately does NOT set `incomplete` when + # the detected cusp curve explains a truncated enumeration; the + # old wiring OR-ed all three flags into work_budget and erased + # that distinction (measured: reasons=['work_budget'] persisted at + # max_cells=1e6 with 5.7k cells spent — no knob could help). + if _c1_stats.get("external_budget_exhausted", False): + # the SHARED ledger ran dry mid-pass — genuinely budgetary. + budget.mark_incomplete(REASON_WORK_BUDGET) + elif (_c1_stats.get("budget_exhausted", False) + or _c1_stats.get("incomplete", False)): + if _c1_curve and not _c1_tier_clamped: + # A positive-dimensional Σ component was DETECTED this + # call: point enumeration of a curve saturates any finite + # cap (pinch fixture: truncated at 509 cells on the result + # cap; case 14: tier drained at 20,000 and a 5x tier + # measured >2 min without finishing). The typed cusp_curve + # carries the structure; the honesty flag is structural. + budget.mark_incomplete(REASON_SINGULAR_SET) + else: + # no curve found — the tier itself was the limit and more + # cells could genuinely finish an isolated-cusp census. + budget.mark_incomplete(REASON_WORK_BUDGET) for hit in c1_hits: + if not _assembly_spend(budget): + break if "curve_samples" in hit: samples = np.asarray(hit["curve_samples"], dtype=np.float64) anchor = samples[0] if len(samples) else np.full(4, np.nan) xyz_anchor = (eval_surface(S1_h_top, anchor[0], anchor[1], rational=True) if len(samples) else np.full(3, np.nan)) - all_singularities.append(SSXSingularity( + budget.append_output(all_singularities, SSXSingularity( kind="cusp_curve", stuv=anchor, xyz=xyz_anchor, - samples=samples, surface=hit.get("surface"))) + samples=samples, surface=hit.get("surface")), "singularity") continue links = [] - for bi, b in enumerate(all_branches): - xyz = np.asarray(b.curve[1], dtype=np.float64) - if len(xyz) < 2: - continue - # Ledger L12: linkage must use point-to-SEGMENT distance — a - # cusp exactly ON a coarse low-curvature span sits up to - # half a chord (~h_max/2 >> 4·atol) from every VERTEX while - # the polyline passes through it. Anchor the link at the - # nearer endpoint of the nearest segment (same vertex - # contract as C3's branch_links, ledger L11). - if _dist_point_polyline(hit["xyz"], xyz) > 4.0 * atol: - continue - a, bseg = xyz[:-1], xyz[1:] - ab = bseg - a - den = np.einsum("ij,ij->i", ab, ab) - den = np.where(den < 1e-30, 1e-30, den) - tt = np.clip(np.einsum("ij,ij->i", - hit["xyz"][None, :] - a, ab) / den, 0.0, 1.0) - dseg = np.linalg.norm(a + tt[:, None] * ab - hit["xyz"][None, :], - axis=1) - kseg = int(dseg.argmin()) - k = kseg if (np.linalg.norm(xyz[kseg] - hit["xyz"]) - <= np.linalg.norm(xyz[kseg + 1] - hit["xyz"])) else kseg + 1 - links.append((bi, k)) - all_singularities.append(SSXSingularity( + _link_cost = sum(max(1, len(np.asarray(b.curve[1])) - 1) + for b in all_branches) + if _assembly_spend(budget, max(1, _link_cost)): + for bi, b in enumerate(all_branches): + xyz = np.asarray(b.curve[1], dtype=np.float64) + if len(xyz) < 2: + continue + # Ledger L12: linkage must use point-to-SEGMENT distance — a + # cusp exactly ON a coarse low-curvature span sits up to + # half a chord (~h_max/2 >> 4·atol) from every VERTEX while + # the polyline passes through it. Anchor the link at the + # nearer endpoint of the nearest segment (same vertex + # contract as C3's branch_links, ledger L11). + if _dist_point_polyline(hit["xyz"], xyz) > 4.0 * atol: + continue + a, bseg = xyz[:-1], xyz[1:] + ab = bseg - a + den = np.einsum("ij,ij->i", ab, ab) + den = np.where(den < 1e-30, 1e-30, den) + tt = np.clip(np.einsum( + "ij,ij->i", hit["xyz"][None, :] - a, ab) + / den, 0.0, 1.0) + dseg = np.linalg.norm( + a + tt[:, None] * ab - hit["xyz"][None, :], axis=1) + kseg = int(dseg.argmin()) + k = (kseg if np.linalg.norm(xyz[kseg] - hit["xyz"]) + <= np.linalg.norm(xyz[kseg + 1] - hit["xyz"]) + else kseg + 1) + links.append((bi, k)) + budget.append_output(all_singularities, SSXSingularity( kind="cusp", stuv=np.asarray(hit["stuv"], dtype=np.float64), xyz=np.asarray(hit["xyz"], dtype=np.float64), - branch_links=links, surface=hit.get("surface"))) + branch_links=links, surface=hit.get("surface")), "singularity") # --- C3 pass (paper §5.4): 3D self-intersections of the SSI image --- # Runs AFTER tracing (branch geometry drives the candidate search). @@ -5657,18 +7612,27 @@ def _interior_subsumed(g): # case 10's 115 segments, ~0.3 ms; the old gate fired on every regular # coverage case anyway — top-cell T-hulls touch zero at domain edges — # so this was already the de-facto hot path). - if all_branches and (len(all_branches) >= 2 or any( - len(np.asarray(b.curve[1])) >= 9 for b in all_branches)): + if (not budget.exhausted and all_branches + and (len(all_branches) >= 2 or any( + len(np.asarray(b.curve[1])) >= 9 for b in all_branches))): from mmcore.numeric.intersection.ssx._ssx5_singular import c3_pass - for hit in c3_pass(S1_h_top, S2_h_top, all_branches, atol, - ptol4_global): - all_singularities.append(SSXSingularity( + _c3_stats = {} + _c3_hits = c3_pass( + S1_h_top, S2_h_top, all_branches, atol, ptol4_global, + max_work=budget.remaining_cells, + charge_work=_charge_hook(budget, "c3"), + stats=_c3_stats, + ) + for hit in _c3_hits: + budget.append_output(all_singularities, SSXSingularity( kind="self_intersection", stuv=np.asarray(hit["stuv"], dtype=np.float64), stuv_mate=np.asarray(hit["stuv_mate"], dtype=np.float64), xyz=np.asarray(hit["xyz"], dtype=np.float64), - branch_links=hit["links"])) + branch_links=hit["links"]), "singularity") + if _c3_stats.get("incomplete", False): + budget.mark_incomplete(REASON_WORK_BUDGET) # A reported point within 2·atol (xyz) of an emitted tangent_point is # not a separate intersection — it is the certified tangency itself, @@ -5678,7 +7642,9 @@ def _interior_subsumed(g): # typed singularity. Matching-ladder xyz guard only (2·atol); no param # guard needed — any Ψ-point that close to the certified tangency is # indistinguishable from it at tolerance. - if all_points and _tangent_xyz: + if (all_points and _tangent_xyz + and _assembly_spend( + budget, max(1, len(all_points) * len(_tangent_xyz)))): _tp_pts = np.asarray(_tangent_xyz, dtype=np.float64) # (K, 3) all_points = [ p for p in all_points @@ -5690,7 +7656,11 @@ def _interior_subsumed(g): # A reported point that lies ON a found branch is not an isolated # intersection — it is a corner-touch seed whose curve was traced by a # neighboring cell. Keep only genuinely isolated points. - if all_points and all_branches: + _point_branch_cost = (len(all_points) * sum( + max(1, len(np.asarray(b.curve[1])) - 1) + for b in all_branches)) + if (all_points and all_branches + and _assembly_spend(budget, max(1, _point_branch_cost))): kept_points = [] for p in all_points: pxyz = np.asarray(p.xyz, dtype=np.float64) @@ -5710,19 +7680,75 @@ def _interior_subsumed(g): # touch-plus-loop at eps=0.02 surfaced two coincident SSXPoints ~2·atol # from the touch). Standard matching-ladder dedup: unify_tol per-axis # stuv box AND xyz <= 2·atol. - if all_points: - _uniq_points = [] - for p in all_points: - if not any( - np.all(np.abs(np.asarray(p.stuv) - np.asarray(q.stuv)) - <= unify_tol) - and float(np.linalg.norm(np.asarray(p.xyz) - - np.asarray(q.xyz))) <= 2.0 * atol - for q in _uniq_points - ): - _uniq_points.append(p) - all_points = _uniq_points - - return {'branches': all_branches, 'points': all_points, 'singularities': all_singularities} - - + if (all_points + and _assembly_spend( + budget, _point_dedup_charge(len(all_points)))): + all_points = _deduplicate_ssx_points(all_points, unify_tol, atol) + + # ------------------------------------------------------------------ + # L28: 2-D overlap regions (approved Option C, review doc §8). + # Assembled last so region loops can reference the FINAL branch list. + # Only runs when overlap evidence exists; rims referenced by a region + # replace their L27 2-point chords with properly sampled paths, and a + # region that covers every piece of evidence retires the structural + # `overlap_region_unsupported` reason (case 12 ships reasons=[]). + # ------------------------------------------------------------------ + overlap_regions = [] + if (boundary_overlaps or uncertified_overlap_spans + or REASON_OVERLAP_REGION in budget.reasons): + from mmcore.numeric.intersection.ssx._ssx5_overlap import ( + assemble_overlap_regions) + _non_ovl = [b for b in all_branches if b.kind != "overlap"] + _ovl = [b for b in all_branches if b.kind == "overlap"] + asm = assemble_overlap_regions( + S1_h_top, S2_h_top, atol=atol, ptol4=dedup_tol, + existing_overlap_branches=_ovl, + uncertified_spans=uncertified_overlap_spans, + overlap_boxes=overlap_boxes, + charge=lambda n: _assembly_spend(budget, n, "overlap_region"), + ) + if asm["regions"]: + base = len(_non_ovl) + for reg in asm["regions"]: + reg.boundary = [[(base + k, rev) for (k, rev) in loop] + for loop in reg.boundary] + all_branches = (_non_ovl + asm["rim_branches"] + + asm["unmatched_branches"]) + overlap_regions = asm["regions"] + if asm["covered"] and not budget.exhausted: + budget.retire_reason(REASON_OVERLAP_REGION) + # An `unresolved_multiplicity` ambiguity whose Δ-root lies + # INSIDE a certified region is a region-interior sample of + # the represented 2-D C2 set — resolved. Retire the reason + # only when EVERY recorded multiplicity site is explained; + # any site outside all regions keeps it (case-14 class). + from mmcore.numeric.intersection.ssx._ssx5_overlap import ( + _point_in_polygon, _dist_point_polyline_2d) + _mult_sites = [g for (rn, g) in budget.structural_sites + if rn == REASON_MULTIPLICITY] + _p12 = 8.0 * max(float(dedup_tol[0]), float(dedup_tol[1])) + _p34 = 8.0 * max(float(dedup_tol[2]), float(dedup_tol[3])) + + def _site_in_regions(g): + for reg in overlap_regions: + st, uv = g[:2], g[2:] + in1 = (_point_in_polygon(st, reg.uv1_loops[0]) + and not any(_point_in_polygon(st, h) + for h in reg.uv1_loops[1:])) + near1 = min(_dist_point_polyline_2d(st, lp) + for lp in reg.uv1_loops) <= _p12 + in2 = (_point_in_polygon(uv, reg.uv2_loops[0]) + and not any(_point_in_polygon(uv, h) + for h in reg.uv2_loops[1:])) + near2 = min(_dist_point_polyline_2d(uv, lp) + for lp in reg.uv2_loops) <= _p34 + if (in1 or near1) and (in2 or near2): + return True + return False + + if _mult_sites and all(_site_in_regions(g) + for g in _mult_sites): + budget.retire_reason(REASON_MULTIPLICITY) + + return _result(all_branches, all_points, all_singularities, + overlap_regions, unresolved_regions) diff --git a/mmcore/numeric/intersection/ssx/_bez_ssx6.py b/mmcore/numeric/intersection/ssx/_bez_ssx6.py index fa4be4f0..e61d8311 100644 --- a/mmcore/numeric/intersection/ssx/_bez_ssx6.py +++ b/mmcore/numeric/intersection/ssx/_bez_ssx6.py @@ -1,4 +1,24 @@ -"""Bezier surface-surface intersection v5. +"""Bezier surface-surface intersection v5 — STALE PRE-BUDGET FORK (ledger L53). + +.. warning:: + **Not maintained. Superseded by `_bez_ssx5.py`** (user decision + 2026-07-12, ledger L53: repair + document). This module is a frozen + comparison fork from before the budget/status/singularity work: + + - NO work budgets: every nested CSX call gets a fresh 100k-cell + allowance; there is no call-wide no-hang guarantee, no ``complete`` / + ``status.reasons`` schema, and exhaustion semantics differ from the + maintained engine. + - NO typed singularities (``result['singularities']`` / ``SSXBranch.kind`` + do not exist here); none of the C1/C2/C3 machinery, the L-series + soundness fixes, or the overlap-region contract ever landed. + - Its CSX-contract guard (``_require_complete_csx_result``) hard-RAISES + on any incomplete or fiber-bearing nested result instead of degrading + honestly. + + Use it only as a historical comparison baseline; do not extend it, and + do not file review findings against it beyond keeping it importable + (its contract test pins the guard + the interior cut-face path). Combines three approaches: 1. Sq-dist Bernstein net for Lipschitz pruning (from CCX/CSX v4) @@ -194,6 +214,21 @@ def _aabb_disjoint(S1_h, S2_h, atol): # Level 2: Boundary analysis (8 CSX problems) # --------------------------------------------------------------------------- +def _require_complete_csx_result(result, context): + """Reject CSX output that is unsafe to use as SSX boundary topology.""" + if (bool(result.get('budget_exhausted', False)) or + not bool(result.get('boundary_topology_complete', True))): + raise RuntimeError( + f"{context}: incomplete Bezier CSX result cannot drive SSX " + "topology (budget exhausted or boundary topology incomplete)" + ) + if result.get('parameter_fibers'): + raise RuntimeError( + f"{context}: positive-dimensional parameter fiber requires " + "explicit SSX overlap-region handling" + ) + return result + def _map_csx_to_stuv(s1_axis, side, t_crv, u_other, v_other, owner_is_s1): """Map CSX result parameters to stuv in [0,1]⁴.""" stuv = np.zeros(4, dtype=np.float64) @@ -230,6 +265,7 @@ def _find_ssx_boundary_zeros(S1_h, S2_h, atol, rational=True): ptol=np.array([ptol_s,ptol_t,ptol_u,ptol_v]) def _process_face(iso, other_surf, axis, side, owner_is_s1): result = bez_csx(iso, other_surf, atol=atol, rational=rational) + _require_complete_csx_result(result, 'SSX boundary face') for iso_pt in result.get('isolated', []): t_crv = float(iso_pt['t']) @@ -1882,7 +1918,13 @@ def _csx_on_cut_face(cell, cut_axis: int, cut_global_val: float, atol: float): else: isoline = _extract_isoline(cell.g2.surface, local_axis, cut_local) csx_result = bez_csx(isoline, cell.g1.surface, atol=atol, rational=True) - csx_result['isolated'] = list((lambda x: not (((1 - x['t']) < 1e-6) or (x['t'] < 1e-6)), csx_result['isolated'])) + _require_complete_csx_result(csx_result, 'SSX cut face') + # Ledger L53: this was `list((lambda, seq))` — a 2-element list, not a + # filter call — crashing the first interior cut of any run. Same + # t-endpoint filter as the maintained v5 engine. + csx_result['isolated'] = list(filter( + lambda x: not (((1 - x['t']) < 1e-6) or (x['t'] < 1e-6)), + csx_result['isolated'])) return _isoline_csx_to_global( csx_result, cut_axis, cut_global_val, cell.box, surf_to_split, S1_local=cell.g1.surface, S2_local=cell.g2.surface, rational=True, @@ -3592,6 +3634,7 @@ def bez_ssx( for s2_idx in range(n2): s2_piece_surf = g2_pieces[s2_idx].surface csx_r = bez_csx(isoline_s1, s2_piece_surf, atol=atol, rational=True) + _require_complete_csx_result(csx_r, 'SSX S1 multi-cut face') ##print(csx_r) csx_r['isolated'] = list(csx_r['isolated']) @@ -3632,6 +3675,7 @@ def bez_ssx( for s1_idx in range(n1): s1_piece_surf = g1_pieces[s1_idx].surface csx_r = bez_csx(isoline_s2, s1_piece_surf, atol=atol, rational=True) + _require_complete_csx_result(csx_r, 'SSX S2 multi-cut face') csx_r['isolated'] = list(csx_r['isolated']) s1_lo = cell.box[s1_axis][0] if s1_idx == 0 else s1_cuts[s1_idx - 1] diff --git a/mmcore/numeric/intersection/ssx/_ssx4.py b/mmcore/numeric/intersection/ssx/_ssx4.py index f367ce8a..ba5539fd 100644 --- a/mmcore/numeric/intersection/ssx/_ssx4.py +++ b/mmcore/numeric/intersection/ssx/_ssx4.py @@ -396,10 +396,12 @@ def from_surf(cls, surf: NDArray[np.float64], rational: bool = False) -> "GaussM # build gauss map control net once maph = compute_gauss_map_rational(surf) - # normalize dehomogenized xyz directions onto unit sphere (matches your original approach) - mp_xyz, mp_w = _from_homogeneous(maph) - mp_xyz = np.array(unit(mp_xyz.reshape(-1, 3))).reshape(mp_xyz.shape) - maph = _to_homogeneous(mp_xyz, mp_w) + # Keep the exact homogeneous normal-numerator net. Normalizing + # each control ray is a harmless positive rescaling for the TOP + # cone, but de Casteljau subdivision does not commute with those + # independent rescalings: child cones can then exclude a true + # normal of the restricted surface. Split the exact net and only + # normalize rays on read in ``map_dirs_net``. return cls(maph, surf) diff --git a/mmcore/numeric/intersection/ssx/_ssx5_overlap.py b/mmcore/numeric/intersection/ssx/_ssx5_overlap.py new file mode 100644 index 00000000..f2feef4a --- /dev/null +++ b/mmcore/numeric/intersection/ssx/_ssx5_overlap.py @@ -0,0 +1,647 @@ +"""2-D surface-overlap regions for bez_ssx (ledger L28, approved Option C). + +A C2 positive-dimensional component — S1 coincides with S2 (within atol) +over a 2-D region (Cheng et al. 2023 Fig. 8, the ``#(Δ_B)=∞`` / +2-dimensional full- or partial-overlap sub-case) — cannot be represented by +the branch/point schema. This module assembles the approved structured +region: closed rim loops that REFERENCE ``kind='overlap'`` branches, paired +sample-synchronized parameter-space loops on both surfaces, a certified +interior witness, and the normal agreement booleans/trimming need. + +Rim discovery is deliberately self-contained: each of the eight domain +edges is SAMPLED and point-inverted onto the opposite surface, and a rim +span is kept only where every sample's residual stays within ``atol`` (the +certification records the worst one in atol units). This uniformly covers +both the affine rims that CSX already claims and the curved-UV rims whose +exact-affine certificate correctly fails (ledger L42) — the L42 fallback +stops the completeness lie at the CSX level, and this assembler is what +finally REPRESENTS the rim. + +Tolerance ladder (review doc §8; also the L25 hinge): a coincidence band +admits a region only if an interior witness exists at ≥ 4·ptol (per axis, +in both parameter planes) from every rim loop with residual ≤ atol; +anything thinner stays curve-only (L27's shared-edge fixture is the +negative control). With ptol ≈ atol/|dS| this is the "band of width +< atol stays a curve" rule, a factor ~4 stricter — the sound direction. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np +from numpy.typing import NDArray + +from mmcore.numeric.intersection._bezier_common import ( + eval_curve, eval_surface, eval_surface_d1, +) +from mmcore.numeric.intersection.ssx._ssx4 import SSXBranch + +__all__ = ["SSXOverlapRegion", "assemble_overlap_regions"] + + +@dataclass +class SSXOverlapRegion: + """C2 positive-dimensional component: S1 ≡ S2 (within atol) over a + 2-D region (Cheng et al. Fig. 8, #(Δ_B)=∞ / 2-dimensional). + + ``boundary`` holds one inner list per closed rim loop; entries + ``(branch_index, reversed)`` reference ``result['branches']`` + (kind='overlap' rim curves), ordered head-to-tail; loop 0 is the outer + loop, later loops are holes (islands where the surfaces depart). + ``uv1_loops[i][k]`` and ``uv2_loops[i][k]`` are preimages of the same + 3-D point (sample-synchronized closed polylines, first == last). + ``normal_agreement`` is +1 for aligned normals over the region, −1 for + opposed (constant over a connected coincidence region). + ``interior_stuv`` is a certified interior witness (point-in-region + seed); ``certification`` records {'boundary_resid_max', + 'interior_resid', 'n_samples', 'orientation_consistent'} with the + residuals in atol units. + """ + + boundary: list = field(default_factory=list) + uv1_loops: list = field(default_factory=list) + uv2_loops: list = field(default_factory=list) + normal_agreement: int = 1 + interior_stuv: NDArray[np.float64] = None + certification: dict = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Point inversion (2x2 Gauss-Newton, seeded from a coarse grid) +# --------------------------------------------------------------------------- + +def _invert_point(S_h, xyz, seed=None, max_iter=30): + """Project ``xyz`` onto the surface: returns (u, v, residual). + + Plain damped Gauss-Newton on ||S(u,v) - xyz||², clamped to [0,1]². + A 5x5 seed grid keeps the start inside the right monotone basin for + the low-degree patches this assembler certifies; the caller judges + acceptance purely by the returned residual, so a failed inversion is + always safe (it only loses a rim sample). + """ + xyz = np.asarray(xyz, dtype=np.float64) + if seed is None: + best = None + for u in np.linspace(0.0, 1.0, 5): + for v in np.linspace(0.0, 1.0, 5): + d = float(np.linalg.norm( + eval_surface(S_h, u, v, rational=True) - xyz)) + if best is None or d < best[0]: + best = (d, u, v) + u, v = best[1], best[2] + else: + u, v = float(seed[0]), float(seed[1]) + + for _ in range(max_iter): + p, du, dv = eval_surface_d1(S_h, u, v, rational=True) + r = p - xyz + J = np.stack([du, dv], axis=1) # (3, 2) + JtJ = J.T @ J + rhs = J.T @ r + try: + step = np.linalg.solve(JtJ + 1e-14 * np.eye(2), rhs) + except np.linalg.LinAlgError: + break + u_new = min(1.0, max(0.0, u - float(step[0]))) + v_new = min(1.0, max(0.0, v - float(step[1]))) + if abs(u_new - u) < 1e-15 and abs(v_new - v) < 1e-15: + u, v = u_new, v_new + break + u, v = u_new, v_new + resid = float(np.linalg.norm( + eval_surface(S_h, u, v, rational=True) - xyz)) + return u, v, resid + + +# --------------------------------------------------------------------------- +# Rim discovery: domain edges sampled onto the opposite surface +# --------------------------------------------------------------------------- + +_EDGES = ( + # (owner, axis, side): owner surface index, fixed axis (0=first param), + # fixed value. Edge parameter runs over the free axis. + (1, 0, 0.0), (1, 0, 1.0), (1, 1, 0.0), (1, 1, 1.0), + (2, 0, 0.0), (2, 0, 1.0), (2, 1, 0.0), (2, 1, 1.0), +) + + +def _edge_own_uv(axis, side, w): + return (side, w) if axis == 0 else (w, side) + + +def _stuv_sample(owner, own_uv, other_uv): + if owner == 1: + return (own_uv[0], own_uv[1], other_uv[0], other_uv[1]) + return (other_uv[0], other_uv[1], own_uv[0], own_uv[1]) + + +def _sample_edge_rims(S_own_h, S_other_h, owner, axis, side, atol, + coarse=33, dense=17): + """On-surface spans of one domain edge, as fully-sampled rim paths. + + Returns a list of rims, each a dict with synchronized arrays: + ``stuv`` (N,4) global parameters, ``xyz`` (N,3), ``resid_max``. + """ + ws = np.linspace(0.0, 1.0, coarse) + onsurf = np.zeros(coarse, dtype=bool) + inv = np.zeros((coarse, 2), dtype=np.float64) + seed = None + for k, w in enumerate(ws): + uo, vo = _edge_own_uv(axis, side, float(w)) + xyz = eval_surface(S_own_h, uo, vo, rational=True) + u, v, resid = _invert_point(S_other_h, xyz, seed=seed) + # A wandering warm start can hand the next sample a foreign basin + # after an off-surface stretch; only propagate on-surface seeds. + seed = (u, v) if resid <= atol else None + onsurf[k] = resid <= atol + inv[k] = (u, v) + + rims = [] + k = 0 + while k < coarse: + if not onsurf[k]: + k += 1 + continue + j = k + while j + 1 < coarse and onsurf[j + 1]: + j += 1 + w_lo, w_hi = float(ws[k]), float(ws[j]) + # Refine both ends by bisection against the on-surface predicate. + w_lo = _refine_end(S_own_h, S_other_h, axis, side, atol, + w_lo, w_lo - (ws[1] - ws[0]), inv[k]) + w_hi = _refine_end(S_own_h, S_other_h, axis, side, atol, + w_hi, w_hi + (ws[1] - ws[0]), inv[j]) + rim = _resample_rim(S_own_h, S_other_h, owner, axis, side, + atol, w_lo, w_hi, dense) + if rim is not None: + rims.append(rim) + k = j + 1 + return rims + + +def _refine_end(S_own_h, S_other_h, axis, side, atol, w_in, w_out, seed): + """Bisect the on-surface span end between an inside and outside sample.""" + w_out = min(1.0, max(0.0, w_out)) + if w_in == w_out: + return w_in + lo, hi = (w_in, w_out) + for _ in range(24): + mid = 0.5 * (lo + hi) + uo, vo = _edge_own_uv(axis, side, mid) + xyz = eval_surface(S_own_h, uo, vo, rational=True) + _, _, resid = _invert_point(S_other_h, xyz, seed=tuple(seed)) + if resid <= atol: + lo = mid + else: + hi = mid + return lo + + +def _resample_rim(S_own_h, S_other_h, owner, axis, side, atol, + w_lo, w_hi, dense): + if w_hi - w_lo <= 1e-12: + return None + ws = np.linspace(w_lo, w_hi, dense) + stuv = np.zeros((dense, 4), dtype=np.float64) + xyz = np.zeros((dense, 3), dtype=np.float64) + resid_max = 0.0 + seed = None + for k, w in enumerate(ws): + uo, vo = _edge_own_uv(axis, side, float(w)) + p = eval_surface(S_own_h, uo, vo, rational=True) + u, v, resid = _invert_point(S_other_h, p, seed=seed) + if resid > atol: + return None # the refined span must certify end-to-end + seed = (u, v) + resid_max = max(resid_max, resid) + stuv[k] = _stuv_sample(owner, (uo, vo), (u, v)) + xyz[k] = p + if float(np.linalg.norm(xyz[-1] - xyz[0])) < atol and dense > 2: + return None # degenerate (corner-touch) span + return {"stuv": stuv, "xyz": xyz, "resid_max": resid_max, + "owner": owner, "axis": axis, "side": side} + + +# --------------------------------------------------------------------------- +# Loop assembly +# --------------------------------------------------------------------------- + +def _dist_point_polyline(p, poly): + a, b = poly[:-1], poly[1:] + ab = b - a + denom = np.einsum("ij,ij->i", ab, ab) + denom = np.where(denom < 1e-30, 1e-30, denom) + t = np.clip(np.einsum("ij,ij->i", p[None, :] - a, ab) / denom, 0.0, 1.0) + proj = a + t[:, None] * ab + return float(np.linalg.norm(proj - p[None, :], axis=1).min()) + + +def _dedup_rims(rims, atol): + """Drop rims geometrically contained in an earlier rim (shared edges + are discovered from both surfaces' boundaries).""" + kept = [] + for rim in sorted(rims, key=lambda r: -len(r["xyz"])): + dup = False + for other in kept: + if len(other["xyz"]) < 2: + continue + if all(_dist_point_polyline(p, other["xyz"]) <= 2.0 * atol + for p in rim["xyz"]): + dup = True + break + if not dup: + kept.append(rim) + return kept + + +def _peel_dangling_rims(rims, atol): + """Drop rims that can never participate in a closed loop. + + Cluster rim endpoints into nodes (2*atol) and iteratively remove rims + touching a degree-1 node. This peels the tolerance-band corner stubs + (a stretch of one edge passing WITHIN atol of the other surface near a + shared corner without lying on the region rim — measured residual ≈ + atol vs ≤ 1e-13 on genuine exact rims): their far end has no + continuation, so they are graph-theoretically incapable of closing, + yet a first-match walk would happily wander into them and dead-end. + """ + alive = list(range(len(rims))) + while True: + pts = [] + keys = [] + for i in alive: + for e in (rims[i]["xyz"][0], rims[i]["xyz"][-1]): + node = None + for k, p in enumerate(pts): + if float(np.linalg.norm(p - e)) <= 2.0 * atol: + node = k + break + if node is None: + pts.append(np.asarray(e, dtype=np.float64)) + node = len(pts) - 1 + keys.append((i, node)) + degree = {} + for _i, node in keys: + degree[node] = degree.get(node, 0) + 1 + drop = {i for i, node in keys if degree[node] < 2} + if not drop: + return [rims[i] for i in alive] + alive = [i for i in alive if i not in drop] + if not alive: + return [] + + +def _assemble_loops(rims, atol): + """Connect rims head-to-tail into closed loops (xyz endpoint match).""" + n = len(rims) + if n == 0: + return [] + ends = [(rim["xyz"][0], rim["xyz"][-1]) for rim in rims] + used = [False] * n + loops = [] + for start in range(n): + if used[start]: + continue + chain = [(start, False)] + used[start] = True + loop_start = ends[start][0] + cur = ends[start][1] + closed = False + while True: + if float(np.linalg.norm(cur - loop_start)) <= 2.0 * atol and ( + len(chain) > 1 or float(np.linalg.norm( + ends[chain[0][0]][1] - ends[chain[0][0]][0])) + <= 2.0 * atol): + closed = True + break + found = None + for j in range(n): + if used[j]: + continue + if float(np.linalg.norm(ends[j][0] - cur)) <= 2.0 * atol: + found = (j, False) + elif float(np.linalg.norm(ends[j][1] - cur)) <= 2.0 * atol: + found = (j, True) + if found is not None: + break + if found is None: + break + j, rev = found + used[j] = True + chain.append((j, rev)) + cur = ends[j][0 if rev else 1] + if closed and len(chain) >= 1: + loops.append(chain) + # non-closing chains simply stay unreferenced rims (curve-only) + return loops + + +def _loop_paths(rims, loop): + """Concatenate a loop's rims into closed synchronized stuv/xyz paths.""" + stuv_parts, xyz_parts = [], [] + for idx, (ri, rev) in enumerate(loop): + stuv = rims[ri]["stuv"][::-1] if rev else rims[ri]["stuv"] + xyz = rims[ri]["xyz"][::-1] if rev else rims[ri]["xyz"] + if idx > 0: + stuv, xyz = stuv[1:], xyz[1:] + stuv_parts.append(stuv) + xyz_parts.append(xyz) + stuv = np.concatenate(stuv_parts, axis=0) + xyz = np.concatenate(xyz_parts, axis=0) + # close exactly + stuv = np.concatenate([stuv, stuv[:1]], axis=0) + xyz = np.concatenate([xyz, xyz[:1]], axis=0) + return stuv, xyz + + +def _signed_area(poly): + x, y = poly[:, 0], poly[:, 1] + return 0.5 * float(np.sum(x[:-1] * y[1:] - x[1:] * y[:-1])) + + +def _point_in_polygon(pt, poly): + x, y = float(pt[0]), float(pt[1]) + inside = False + for k in range(len(poly) - 1): + x1, y1 = poly[k] + x2, y2 = poly[k + 1] + if (y1 > y) != (y2 > y): + xin = x1 + (y - y1) * (x2 - x1) / (y2 - y1) + if x < xin: + inside = not inside + return inside + + +def _dist_point_polyline_2d(p, poly): + a, b = poly[:-1], poly[1:] + ab = b - a + denom = np.einsum("ij,ij->i", ab, ab) + denom = np.where(denom < 1e-30, 1e-30, denom) + t = np.clip(np.einsum("ij,ij->i", p[None, :] - a, ab) / denom, 0.0, 1.0) + proj = a + t[:, None] * ab + return float(np.linalg.norm(proj - p[None, :], axis=1).min()) + + +def _interior_witness(S1_h, S2_h, uv1_loops, uv2_loops, atol, ptol4): + """Certified interior seed at >= 4*ptol from every rim loop (§8).""" + outer1 = uv1_loops[0] + holes1 = uv1_loops[1:] + lo = outer1.min(axis=0) + hi = outer1.max(axis=0) + p_bar12 = 4.0 * max(float(ptol4[0]), float(ptol4[1])) + p_bar34 = 4.0 * max(float(ptol4[2]), float(ptol4[3])) + + candidates = [outer1[:-1].mean(axis=0)] + for gu in np.linspace(0.15, 0.85, 8): + for gv in np.linspace(0.15, 0.85, 8): + candidates.append(lo + np.array([gu, gv]) * (hi - lo)) + + best = None + for cand in candidates: + if not _point_in_polygon(cand, outer1): + continue + if any(_point_in_polygon(cand, h) for h in holes1): + continue + d1 = min(_dist_point_polyline_2d(cand, lp) for lp in uv1_loops) + if d1 < p_bar12: + continue + p1 = eval_surface(S1_h, float(cand[0]), float(cand[1]), + rational=True) + u, v, resid = _invert_point(S2_h, p1) + if resid > atol: + continue + d2 = min(_dist_point_polyline_2d(np.array([u, v]), lp) + for lp in uv2_loops) + if d2 < p_bar34: + continue + score = min(d1 / max(p_bar12, 1e-15), d2 / max(p_bar34, 1e-15)) + if best is None or score > best[0]: + best = (score, np.array([cand[0], cand[1], u, v]), resid) + if best is None: + return None, None + return best[1], best[2] + + +# --------------------------------------------------------------------------- +# Public assembler +# --------------------------------------------------------------------------- + +def assemble_overlap_regions( + S1_h, S2_h, *, atol, ptol4, + existing_overlap_branches=(), + uncertified_spans=(), + overlap_boxes=(), + charge=None, +): + """Assemble certified SSXOverlapRegion entities from rim evidence. + + Returns a dict: ``regions`` (boundary indices are RELATIVE to the + returned ``rim_branches`` list), ``rim_branches`` (canonical, properly + sampled kind='overlap' SSXBranch objects), ``unmatched_branches`` + (pre-existing overlap branches not part of any region rim — curve-only + overlaps, kept verbatim), and ``covered`` (True iff every piece of + overlap evidence — parametric overlap boxes and uncertified CSX spans — + is explained by a certified region, so the caller may retire the + structural incompleteness reason). + """ + S1_h = np.asarray(S1_h, dtype=np.float64) + S2_h = np.asarray(S2_h, dtype=np.float64) + ptol4 = np.asarray(ptol4, dtype=np.float64) + existing = list(existing_overlap_branches) + + def _charge(n): + return charge(n) if charge is not None else True + + empty = {"regions": [], "rim_branches": [], + "unmatched_branches": existing, "covered": False} + + # 8 edges x (coarse + dense) inversions, each a bounded GN solve. + if not _charge(8 * 33 + 8 * 17): + return empty + + rims = [] + for owner, axis, side in _EDGES: + own = S1_h if owner == 1 else S2_h + other = S2_h if owner == 1 else S1_h + rims.extend(_sample_edge_rims(own, other, owner, axis, side, atol)) + rims = _dedup_rims(rims, atol) + rims = _peel_dangling_rims(rims, atol) + if not rims: + return empty + + loops_raw = _assemble_loops(rims, atol) + if not loops_raw: + return empty + + # Build loop paths + orientation bookkeeping in S1's (u,v). + loops = [] + for chain in loops_raw: + stuv, xyz = _loop_paths(rims, chain) + area1 = _signed_area(stuv[:, :2]) + loops.append({"chain": chain, "stuv": stuv, "xyz": xyz, + "area1": area1}) + loops.sort(key=lambda L: -abs(L["area1"])) + + def _contains(La, Lb): + return _point_in_polygon(Lb["stuv"][0, :2], La["stuv"][:, :2]) + + # Outer loops = loops contained in no other loop; each hole is + # assigned to its smallest containing outer. Disjoint coincidence + # patches therefore become SEPARATE regions instead of silently + # dropping (no-silent-caps). + outer_ids = [i for i, L in enumerate(loops) + if not any(_contains(loops[j], L) + for j in range(len(loops)) if j != i)] + hole_map = {i: [] for i in outer_ids} + for i, L in enumerate(loops): + if i in outer_ids: + continue + containers = [j for j in outer_ids if _contains(loops[j], L)] + if containers: + hole_map[containers[-1]].append(i) # smallest (sorted by area) + + def _oriented(L, ccw): + if (L["area1"] > 0) != ccw: + chain = [(ri, not rev) for (ri, rev) in reversed(L["chain"])] + stuv = L["stuv"][::-1].copy() + xyz = L["xyz"][::-1].copy() + return {"chain": chain, "stuv": stuv, "xyz": xyz, + "area1": -L["area1"]} + return L + + regions = [] + referenced = [] # rim ids in first-reference order + for oi in outer_ids: + # Region on the LEFT in S1's (u,v): outer CCW, holes CW. + region_loops = ([_oriented(loops[oi], ccw=True)] + + [_oriented(loops[h], ccw=False) + for h in hole_map[oi]]) + uv1_loops = [L["stuv"][:, :2].copy() for L in region_loops] + uv2_loops = [L["stuv"][:, 2:].copy() for L in region_loops] + + if not _charge(64 + 16): + return empty + witness, w_resid = _interior_witness( + S1_h, S2_h, uv1_loops, uv2_loops, atol, ptol4) + if witness is None: + # Band rule: no interior clear of every rim by 4*ptol — this + # candidate stays curve-only (L27 negative control). + continue + + # Normal agreement at the witness (constant over a connected + # coincidence region). + _, du1, dv1 = eval_surface_d1(S1_h, witness[0], witness[1], + rational=True) + _, du2, dv2 = eval_surface_d1(S2_h, witness[2], witness[3], + rational=True) + n1 = np.cross(du1, dv1) + n2 = np.cross(du2, dv2) + agreement = 1 if float(np.dot(n1, n2)) >= 0.0 else -1 + # Redundant orientation check: with agreeing normals the uv2 loop + # turns the same way as uv1 (§8 assert-consistency). + area2 = _signed_area(uv2_loops[0]) + orientation_consistent = ( + (area2 > 0) == ((region_loops[0]["area1"] > 0) + == (agreement == 1))) + + resid_max = max(rims[ri]["resid_max"] + for L in region_loops for (ri, _rev) in L["chain"]) + n_samples = sum(len(rims[ri]["xyz"]) + for L in region_loops for (ri, _rev) in L["chain"]) + for L in region_loops: + for ri, _rev in L["chain"]: + if ri not in referenced: + referenced.append(ri) + regions.append((region_loops, SSXOverlapRegion( + boundary=[], # filled below once rim indices exist + uv1_loops=uv1_loops, + uv2_loops=uv2_loops, + normal_agreement=agreement, + interior_stuv=np.asarray(witness, dtype=np.float64), + certification={ + "boundary_resid_max": resid_max / max(atol, 1e-300), + "interior_resid": w_resid / max(atol, 1e-300), + "n_samples": int(n_samples), + "orientation_consistent": bool(orientation_consistent), + }, + ))) + + if not regions: + return empty + + # Canonical rim branches: every rim referenced by a region loop, in + # first-reference order; their sampled paths REPLACE the L27 2-point + # chords (the §8 sampling upgrade). Existing overlap branches that + # match a rim are absorbed; the rest stay verbatim (curve-only). + rim_index = {ri: k for k, ri in enumerate(referenced)} + rim_branches = [ + SSXBranch(curve=(rims[ri]["stuv"].copy(), rims[ri]["xyz"].copy()), + overlap=True, kind="overlap") + for ri in referenced + ] + for region_loops, region in regions: + region.boundary = [[(rim_index[ri], rev) + for (ri, rev) in L["chain"]] + for L in region_loops] + + unmatched = [] + for b in existing: + bxyz = np.asarray(b.curve[1], dtype=np.float64) + absorbed = any( + all(_dist_point_polyline(p, rims[ri]["xyz"]) <= 2.0 * atol + for p in bxyz) + for ri in referenced) + if not absorbed: + unmatched.append(b) + + # Evidence coverage: every overlap box and every uncertified CSX span + # must be explained by some certified region before the caller may + # retire the structural reason. + covered = True + all_rim_xyz = [rims[ri]["xyz"] for ri in referenced] + p_bar12 = 8.0 * max(float(ptol4[0]), float(ptol4[1])) + p_bar34 = 8.0 * max(float(ptol4[2]), float(ptol4[3])) + + def _half_explained(pt2, loops, bar): + in_region = (_point_in_polygon(pt2, loops[0]) + and not any(_point_in_polygon(pt2, h) + for h in loops[1:])) + near_rim = min(_dist_point_polyline_2d(pt2, lp) + for lp in loops) <= bar + return in_region or near_rim + + for box in overlap_boxes or (): + b = np.asarray(box, dtype=np.float64) + center = 0.5 * (b[:, 0] + b[:, 1]) + st, uv = center[:2], center[2:] + explained = False + for _loops, region in regions: + # BOTH parameter planes must be explained (adversarial-review + # confirmed finding, 2026-07-12): a box on a DIFFERENT S2 + # sheet sharing an S1 footprint (folded/self-overlapping S2) + # must not count as covered by the sheet the region actually + # represents — same two-sided rule as `_site_in_regions`. + if (_half_explained(st, region.uv1_loops, p_bar12) + and _half_explained(uv, region.uv2_loops, p_bar34)): + explained = True + break + if not explained: + covered = False + break + if covered: + for curve_ctrl, (t_lo, t_hi), span_rational in ( + uncertified_spans or ()): + for t in np.linspace(t_lo, t_hi, 9): + p = eval_curve(np.asarray(curve_ctrl, dtype=np.float64), + float(t), rational=span_rational) + if min(_dist_point_polyline( + np.asarray(p, dtype=np.float64), rx) + for rx in all_rim_xyz) > 2.0 * atol: + covered = False + break + if not covered: + break + + return {"regions": [r for _loops, r in regions], + "rim_branches": rim_branches, + "unmatched_branches": unmatched, "covered": covered} diff --git a/mmcore/numeric/intersection/ssx/_ssx5_singular.py b/mmcore/numeric/intersection/ssx/_ssx5_singular.py index a6f57ca5..950f2e88 100644 --- a/mmcore/numeric/intersection/ssx/_ssx5_singular.py +++ b/mmcore/numeric/intersection/ssx/_ssx5_singular.py @@ -19,7 +19,12 @@ import numpy as np from numpy.typing import NDArray -from mmcore.numeric.bern import de_casteljau_split_nd, bernstein_partial_derivative_coeffs +from mmcore.numeric._work_budget import LatchingSpend +from mmcore.numeric.bern import ( + bernstein_eval_nd, + bernstein_partial_derivative_coeffs, + de_casteljau_split_nd, +) from mmcore.numeric.intersection._deflate import ( bernstein_patch_derivative_s, bernstein_patch_derivative_t, @@ -375,8 +380,10 @@ def solve_zero_dim( atol: float = 1e-3, skip_newton: Optional[Callable] = None, # (box) -> True to skip the Newton attempt priority: Optional[Callable] = None, # (box) -> float; HIGHER pops first (heap) - max_boxes: Optional[int] = None, # hard backstop on TOTAL processed boxes stats: Optional[dict] = None, # out-param: solver-side counters (see below) + charge_box: Optional[Callable[[int], bool]] = None, + # shared outer budget: charge_box(1) must approve BEFORE a box is processed + max_results: Optional[int] = None, ): """All isolated solutions of {net_i = 0} in `box`. @@ -384,9 +391,11 @@ def solve_zero_dim( ------- (sols, exhausted) : tuple[list, bool] `sols` — list of (4,) solutions found. `exhausted` — True iff a - budget (EITHER `max_cells` or `max_boxes`, see below) ran out with - boxes still pending, i.e. the enumeration may be INCOMPLETE and - `sols` is only a lower bound. Callers must check it (a + budget (`max_cells`, the `16 * max_cells` traversal backstop, or + the optional shared + `charge_box`, see below) ran out with boxes still pending, i.e. the + enumeration may be INCOMPLETE and `sols` is only a lower bound. + Callers must check it (a silently-truncated list is indistinguishable from a complete one otherwise). Never raise `max_cells` to chase `exhausted=False` on a hang — a blown budget usually means the solution set isn't @@ -395,8 +404,8 @@ def solve_zero_dim( Budget contract --------------- - `max_cells` bounds the CHARGED units, `max_boxes` (default - `16 * max_cells`) bounds ALL processed boxes: + `max_cells` bounds the CHARGED units; a fixed `16 * max_cells` + backstop bounds ALL processed boxes: - Without `skip_newton`, every processed box charges one unit — the historical semantics, unchanged (both new bounds below can then @@ -420,10 +429,16 @@ def solve_zero_dim( (measured on the blind-band family: the off-curve touch is accepted at box 1.5-7.2k with the box count at 29-60% of the bound at that moment, i.e. >= 1.7x margin). - - `max_boxes` is the hard termination backstop on top of that: a - pathological flood whose frontier keeps attempting Newtons still - stops there. Stopping at ANY bound with work pending returns - `exhausted=True`. + - The `16 * max_cells` box count is the hard termination backstop on + top of that: a pathological flood whose frontier keeps attempting + Newtons still stops there. Stopping at ANY bound with work pending + returns `exhausted=True`. + - `charge_box`, when supplied, is a shared outer-budget callback. It is + invoked as `charge_box(1)` immediately before each box is popped. A + false result stops the solve without processing that box and returns + `exhausted=True`; the local `max_cells` / backstop limits remain in + force independently. This lets one SSX-level allowance cover several + nested zero-dimensional solves instead of resetting at every call. Hull-exclusion subdivision + center-seeded Newton. Newton runs in GLOBAL coordinates on smooth evaluators; the nets are only used for @@ -468,6 +483,11 @@ def solve_zero_dim( # handful (measured: cusp curve at 200x resolution -> 11 sols but # hundreds of floor boxes; a lone cusp -> a few dozen). floor_boxes = 0 + # A surviving box at the parametric resolution floor is not by itself + # a proof that a zero exists or that none exists. Record the subset for + # which Newton produced no in-box root witness so callers making a + # topological type claim can surface honest partial status. + unresolved_floor_boxes = 0 def _dup(x): for s in sols: @@ -494,18 +514,26 @@ def _pop(): e = heapq.heappop(pending) return e[2], e[3] - if max_boxes is None: - max_boxes = 16 * max_cells + max_boxes = 16 * max_cells cells = 0 # charged units (see "Budget contract" above) boxes = 0 # every processed box — bounded by the backstops + external_budget_exhausted = False while (pending and cells < max_cells - and boxes < min(max_boxes, max_cells + 16 * cells)): + and boxes < min(max_boxes, max_cells + 16 * cells) + and (max_results is None or len(sols) < max_results)): + # The shared allowance is charged before the pop: denial must leave + # the next box unprocessed so `exhausted=True` faithfully means the + # returned solution list is only partial. + if charge_box is not None and not charge_box(1): + external_budget_exhausted = True + break boxes += 1 bx, bnets = _pop() if any(n.excludes_zero() for n in bnets): if skip_newton is None: cells += 1 continue + box_has_root_witness = False if skip_newton is None or not skip_newton(bx): cells += 1 mid = np.array([0.5 * (lo + hi) for lo, hi in bx]) @@ -513,8 +541,10 @@ def _pop(): if sol is not None: sol = np.asarray(sol, dtype=np.float64) inside = all(bx[i][0] - 1e-12 <= sol[i] <= bx[i][1] + 1e-12 for i in range(4)) - if inside and not _dup(sol): - sols.append(sol) + if inside: + box_has_root_witness = True + if not _dup(sol): + sols.append(sol) # split the axis with the largest span in units of its OWN ptol — # with heterogeneous per-axis ptols the absolutely-widest axis can # already be resolved while a tighter-ptol axis is still orders of @@ -523,6 +553,8 @@ def _pop(): widest = int(np.argmax(ratios)) if ratios[widest] <= 1.0: floor_boxes += 1 + if not box_has_root_witness: + unresolved_floor_boxes += 1 continue # resolution floor: every axis at/below its ptol # nets and box split in lockstep so the net's local 0.5 is exactly # the box's global midpoint @@ -535,13 +567,22 @@ def _pop(): br = list(bx); br[widest] = (m, bx[widest][1]) _push((tuple(bl), left_nets)) _push((tuple(br), right_nets)) + exhausted = bool(pending) + result_limit_reached = bool( + pending and max_results is not None and len(sols) >= max_results) if stats is not None: stats["floor_boxes"] = floor_boxes - return sols, bool(pending) + stats["unresolved_floor_boxes"] = unresolved_floor_boxes + stats["cells_processed"] = cells + stats["boxes_processed"] = boxes + stats["budget_exhausted"] = exhausted + stats["external_budget_exhausted"] = external_budget_exhausted + stats["result_limit_reached"] = result_limit_reached + return sols, exhausted def phi_loop_seeds(S1_h, S2_h, T_nets, psi_rows, t_idx, atol, ptol, - max_cells=4000): + max_cells=4000, charge_box=None, stats=None): """Seed points of the regulated curve Phi = {Psi_a, Psi_b, T_k} sliced by deterministic mid-planes (paper 5.3.2; axis-aligned L instead of random hyperplanes — random L can miss small features, admitted in their 7.1). @@ -567,7 +608,11 @@ def phi_loop_seeds(S1_h, S2_h, T_nets, psi_rows, t_idx, atol, ptol, (`exhausted=True` from `solve_zero_dim`) only degrades seeding redundancy (4 mid-planes, each meeting a loop >= 2x by Lemma 2) — seeds already found stay valid, so exhaustion is NOT an error and - `max_cells` must not be raised to chase it. + `max_cells` must not be raised to chase it. `charge_box`, when supplied, + is passed unchanged to every plane solve so an SSX-level allowance is + shared across them. The return value stays backward-compatible; callers + that need to surface truncation may pass `stats` and inspect + `budget_exhausted` / `external_budget_exhausted`. """ from mmcore.numeric.intersection._bezier_common import eval_surface_d1 @@ -612,14 +657,29 @@ def newton(x0): ptol = np.asarray(ptol, dtype=np.float64) seeds: list = [] seed_xyz: list = [] + any_exhausted = False + external_budget_exhausted = False + cells_processed = 0 + boxes_processed = 0 + solve_calls = 0 for axis in range(4): nets = [BoxNet(G[..., k:k + 1], axes=(0, 1, 2, 3)) for k in range(3)] nets.append(BoxNet(Tk, axes=(0, 1, 2, 3))) nets.append(BoxNet(linear_net_4d(-0.5, tuple(np.eye(4)[axis])), axes=(0, 1, 2, 3))) - ax_sols, _ax_exhausted = solve_zero_dim( + ax_stats = {} + ax_sols, ax_exhausted = solve_zero_dim( nets, newton_factory(axis, 0.5), ptol, - max_cells=max_cells, atol=atol) + max_cells=max_cells, max_results=256, + atol=atol, charge_box=charge_box, + stats=ax_stats) + solve_calls += 1 + any_exhausted |= bool(ax_exhausted) + external_budget_exhausted |= bool( + ax_stats.get("external_budget_exhausted", False) + ) + cells_processed += int(ax_stats.get("cells_processed", 0)) + boxes_processed += int(ax_stats.get("boxes_processed", 0)) for s in ax_sols: # Destructive dedup ladder (ledger L21): a parametric box is # not a metric ball — merge cross-plane seeds only when BOTH @@ -636,6 +696,17 @@ def newton(x0): if not dup: seeds.append(s) seed_xyz.append(s_xyz) + # A denied shared charge cannot become available again during this + # synchronous call. Preserve the partial seeds and avoid three more + # futile solve invocations. + if external_budget_exhausted: + break + if stats is not None: + stats["solve_calls"] = solve_calls + stats["cells_processed"] = cells_processed + stats["boxes_processed"] = boxes_processed + stats["budget_exhausted"] = any_exhausted + stats["external_budget_exhausted"] = external_budget_exhausted return seeds @@ -682,7 +753,8 @@ def _connected_one_dim(sols, newton, ptol) -> bool: return tested > 0 and 2 * connected >= tested -def c1_pass(S1_h, S2_h, atol, ptol4, max_cells=20000): +def c1_pass(S1_h, S2_h, atol, ptol4, max_cells=20000, + charge_box=None, stats=None): """Global C1 detection (paper Fig. 5): parameterization cusps ON the SSI. A C1 singularity is a point of the intersection curve where one @@ -700,6 +772,13 @@ def c1_pass(S1_h, S2_h, atol, ptol4, max_cells=20000): emitted; absence is NOT proof in that case — the enumeration may have been truncated mid-search, e.g. Newton failing near a degenerate spot). + `max_cells` is one allowance for the whole C1 pass, shared by the two + possible surface solves (it is not reset per surface). `charge_box`, if + supplied, additionally shares an outer SSX-level allowance with other + singularity work. The historical `(hits, curve_flag)` return is retained; + pass `stats` to inspect `budget_exhausted` and distinguish an incomplete + C1 pass from a complete empty result. + Cheap global precheck (the common case): Sigma_i = 0 needs ALL THREE components zero, so ONE component whose Bernstein hull excludes zero over the whole [0,1]^2 proves the normal never vanishes on surface i — @@ -713,6 +792,13 @@ def c1_pass(S1_h, S2_h, atol, ptol4, max_cells=20000): out: list = [] curve_flag = False G = psi_vector_net(S1_h, S2_h) + cells_remaining = max(0, int(max_cells)) + cells_processed = 0 + boxes_processed = 0 + solve_calls = 0 + any_exhausted = False + external_budget_exhausted = False + incomplete = False for which, (Sh, axes2) in enumerate(((S1_h, (0, 1)), (S2_h, (2, 3))), start=1): # bez_ssx always passes homogeneous nets (w == 1 for polynomial # input): unit weights take the exact polynomial branch on the @@ -743,6 +829,33 @@ def c1_pass(S1_h, S2_h, atol, ptol4, max_cells=20000): out.append({"surface": which, "curve_samples": np.empty((0, 4))}) continue + sigma_roundoff_tol = _HULL_MARGIN_K_EPS * nscale + + def _sigma_numerator_is_roundoff_zero( + x, _N=N, _axes=axes2, _tol=sigma_roundoff_tol): + """Certify the exact polynomial Sigma numerator as numerical zero. + + The Cartesian normal is suitable for conditioning the GN system, + but not for a topological ``Sigma == 0`` claim: a fixed local-angle + or sampled-normal ratio can accept a merely small regular normal, + while derivative-zero rational cusps suffer cancellation in the + quotient-rule Cartesian evaluation. The Bernstein numerator net + is the algebraic system being subdivided and has the same zero set + for valid positive weights. Evaluate that net at the candidate and + allow only its coefficient-scale roundoff envelope. + """ + value = np.asarray( + bernstein_eval_nd( + _N, + (float(x[_axes[0]]), float(x[_axes[1]])), + ), + dtype=np.float64, + ) + return bool( + np.all(np.isfinite(value)) + and float(np.linalg.norm(value)) <= _tol + ) + # L13: weight-INVARIANT acceptance scale for the GN below. The GN # normalizes the CARTESIAN normal Nv = cross(du,dv) (from rational # eval_surface_d1 — invariant under a uniform weight rescale) by a @@ -765,7 +878,8 @@ def c1_pass(S1_h, S2_h, atol, ptol4, max_cells=20000): float(np.linalg.norm(np.cross(_gdu, _gdv)))) cart_nscale = max(cart_nscale, 1e-12) - def newton(x0, _Sh=Sh, _axes=axes2, _ns=cart_nscale): + def newton(x0, _Sh=Sh, _axes=axes2, _ns=cart_nscale, + _sigma_zero=_sigma_numerator_is_roundoff_zero): # Gauss-Newton (lstsq) on the overdetermined {Psi(3), Sigma(3)}. # Sigma rows' Jacobian by forward differences on the two owning # axes — exact d(cross) is verbose; 1e-7 FD is adequate for a @@ -779,7 +893,7 @@ def newton(x0, _Sh=Sh, _axes=axes2, _ns=cart_nscale): rational=True) Nv = np.cross(dua, dvb) if (np.linalg.norm(psi) < 1e-10 - and np.linalg.norm(Nv) < 1e-8 * _ns): + and _sigma_zero(x)): return np.clip(x, 0.0, 1.0) J = np.zeros((6, 4)) J[:3, 0], J[:3, 1], J[:3, 2], J[:3, 3] = du1, dv1, -du2, -dv2 @@ -799,19 +913,52 @@ def newton(x0, _Sh=Sh, _axes=axes2, _ns=cart_nscale): break p1 = eval_surface_d1(S1_h, x[0], x[1], rational=True)[0] p2 = eval_surface_d1(S2_h, x[2], x[3], rational=True)[0] - _, dua, dvb = eval_surface_d1(_Sh, x[_axes[0]], x[_axes[1]], - rational=True) if (np.linalg.norm(p1 - p2) < atol - and np.linalg.norm(np.cross(dua, dvb)) < 1e-6 * _ns): + and _sigma_zero(x)): return np.clip(x, 0.0, 1.0) return None def _xyz(sol): return eval_surface(S1_h, sol[0], sol[1], rational=True) + if cells_remaining <= 0: + # This surface still needs enumeration, so a consumed shared + # local allowance makes the overall C1 result incomplete. + any_exhausted = True + incomplete = True + break + # Reserve a fair share for every not-yet-visited surface. A + # positive-dimensional C1 set can consume any allowance; handing it + # the whole remainder starved the second surface on two-cone apex + # cases even though both sets were readily classifiable. + solve_allowance = max(1, cells_remaining // (3 - which)) + solve_stats = {} sols, exhausted = solve_zero_dim(nets, newton, ptol4, - max_cells=max_cells, - dedup_xyz=_xyz, atol=atol) + max_cells=solve_allowance, + max_results=64, + dedup_xyz=_xyz, atol=atol, + charge_box=charge_box, + stats=solve_stats) + solve_calls += 1 + used_cells = int(solve_stats.get("cells_processed", 0)) + cells_processed += used_cells + boxes_processed += int(solve_stats.get("boxes_processed", 0)) + cells_remaining = max(0, cells_remaining - used_cells) + any_exhausted |= bool(exhausted) + external_budget_exhausted |= bool( + solve_stats.get("external_budget_exhausted", False) + ) + if int(solve_stats.get("unresolved_floor_boxes", 0)) > 0: + # These boxes survived every Bernstein exclusion test but had + # no in-box Newton witness at the requested resolution. They + # are ambiguous: neither an empty C1 set nor a cusp is proven. + incomplete = True + if external_budget_exhausted: + incomplete = True + # A shared-budget denial can truncate the solution cloud in a + # way that mimics either isolated cusps or a curve. Keep prior + # certified hits, but make no schema claim for this surface. + break # 1-dimensional-set detection (ledger L14): raw count and the # exhausted flag miss a REALISTIC cusp curve whose xyz-dedup'd # solutions land in the 2..12 window without budget exhaustion @@ -826,17 +973,59 @@ def _xyz(sol): # consecutive solutions; a curve yields new on-segment roots, # isolated cusps yield dups or divergence. curve_like = (len(sols) > 12 - or (exhausted and len(sols) > 1) - or _connected_one_dim(sols, newton, ptol4)) + or (exhausted and len(sols) > 1)) + connection_denied = False + + def _charged_connection_newton(x0): + nonlocal cells_remaining, cells_processed + nonlocal any_exhausted, external_budget_exhausted, incomplete + nonlocal connection_denied + if cells_remaining <= 0: + any_exhausted = True + incomplete = True + connection_denied = True + return None + if charge_box is not None and not charge_box(1): + any_exhausted = True + external_budget_exhausted = True + incomplete = True + connection_denied = True + return None + cells_remaining -= 1 + cells_processed += 1 + return newton(x0) + + if not curve_like: + curve_like = _connected_one_dim( + sols, _charged_connection_newton, ptol4) + if connection_denied: + # The gray-zone dimension test was truncated. Its roots are + # certified C1 members, but we cannot safely choose between the + # isolated-cusp and cusp-curve output schemas. + break if curve_like: curve_flag = True out.append({"surface": which, "curve_samples": np.asarray(sols)}) - continue - # NOTE: exhausted with 0-1 solutions means the enumeration may be - # incomplete; the found root (if any) is still emitted — absence is - # not proof in that case (documented blind spot, plan risk 3). - for s in sols: - out.append({"surface": which, "stuv": np.asarray(s), "xyz": _xyz(s)}) + else: + # NOTE: exhausted with 0-1 solutions means the enumeration may be + # incomplete; the found root (if any) is still emitted — absence is + # not proof in that case (documented blind spot, plan risk 3). + for s in sols: + out.append({"surface": which, "stuv": np.asarray(s), "xyz": _xyz(s)}) + if exhausted: + incomplete = True + if external_budget_exhausted: + incomplete = True + if external_budget_exhausted or (exhausted and cells_remaining <= 0): + break + if stats is not None: + stats["solve_calls"] = solve_calls + stats["cells_processed"] = cells_processed + stats["boxes_processed"] = boxes_processed + stats["cells_remaining"] = cells_remaining + stats["budget_exhausted"] = any_exhausted + stats["external_budget_exhausted"] = external_budget_exhausted + stats["incomplete"] = incomplete return out, curve_flag @@ -887,7 +1076,8 @@ def _c3_same_hit(stuv_a, mate_a, xyz_a, stuv_b, mate_b, xyz_b, atol, ptol4): return False -def c3_pass(S1_h, S2_h, branches, atol, ptol4): +def c3_pass(S1_h, S2_h, branches, atol, ptol4, *, + max_work=250_000, charge_work=None, stats=None): """Post-trace C3 detection: crossing branch segments -> square 6-var Newton on BOTH role assignments -> certified pairs. @@ -920,6 +1110,12 @@ class with the umbrella as S1) only solves the first system — its Dedup: `_c3_same_hit` (both-guards, ledger L16); a duplicate re-find contributes any NEW branch links to the kept hit. + ``max_work`` bounds segment setup, every unordered AABB comparison, and + all downstream exact/Newton/anchor/dedup work. Blocks are streamed and + processed immediately, so dense input cannot materialize an unbounded + O(M^2) pair array. ``charge_work`` optionally spends the same work from + an enclosing SSX allowance; ``stats`` reports local/external exhaustion. + Returns a list of dicts {"stuv": (4,), "stuv_mate": (4,), "xyz": (3,), "links": [(branch_i, vertex_k), (branch_j, vertex_l)]}. `stuv` is the primary 4D preimage (s,t,u,v); `stuv_mate` differs from it in the @@ -932,6 +1128,24 @@ class with the umbrella as S1) only solves the first system — its eval_surface, eval_surface_d1, ) ptol4 = np.asarray(ptol4, dtype=np.float64) + pairs_processed = 0 + candidate_pairs = 0 + # check-then-charge / all-or-nothing / latching ledger with the shared + # budget as the external hook — the L52 shared implementation of the + # former hand-rolled ``_spend`` closure. + _ledger = LatchingSpend(max_work=max_work, charge_external=charge_work) + _spend = _ledger.spend + + def _publish_stats(): + if stats is not None: + stats.update( + work_processed=int(_ledger.work_processed), + pairs_processed=int(pairs_processed), + candidate_pairs=int(candidate_pairs), + budget_exhausted=bool(_ledger.exhausted), + external_budget_exhausted=bool(_ledger.external_exhausted), + incomplete=bool(_ledger.exhausted), + ) def seg_dist(p1, p2, q1, q2): d1 = p2 - p1; d2 = q2 - q1; r = p1 - q1 @@ -958,6 +1172,8 @@ def newton6(z0, Sa_h, Sb_h, guard_ptol): # (S1_h, S2_h) and once with the roles swapped (ledger L7). z = np.asarray(z0, dtype=np.float64).copy() for _ in range(40): + if not _spend(1): + return None ra, dua, dva = eval_surface_d1(Sa_h, z[0], z[1], rational=True) rb, dub, dvb = eval_surface_d1(Sa_h, z[2], z[3], rational=True) rc, duc, dvc = eval_surface_d1(Sb_h, z[4], z[5], rational=True) @@ -971,6 +1187,8 @@ def newton6(z0, Sa_h, Sb_h, guard_ptol): z = np.clip(z - np.linalg.solve(J, F), 0.0, 1.0) except np.linalg.LinAlgError: return None + if not _spend(1): + return None ra = eval_surface(Sa_h, z[0], z[1], rational=True) rb = eval_surface(Sa_h, z[2], z[3], rational=True) rc = eval_surface(Sb_h, z[4], z[5], rational=True) @@ -1019,11 +1237,15 @@ def solve_candidate(a4, b4): stuv = np.asarray(b.curve[0], dtype=np.float64) if len(xyz) < 2: continue + if not _spend(len(xyz) - 1): + _publish_stats() + return found segs_a.append(xyz[:-1]); segs_b.append(xyz[1:]) seg_s4a.append(stuv[:-1]); seg_s4b.append(stuv[1:]) seg_branch.append(np.full(len(xyz) - 1, bi)) seg_idx.append(np.arange(len(xyz) - 1)) if not segs_a: + _publish_stats() return found A = np.concatenate(segs_a); B = np.concatenate(segs_b) S4a = np.concatenate(seg_s4a); S4b = np.concatenate(seg_s4b) @@ -1031,20 +1253,6 @@ def solve_candidate(a4, b4): lo = np.minimum(A, B) - 2.5 * atol hi = np.maximum(A, B) + 2.5 * atol M = len(A) - pairs = [] - block = 1024 # bound the broadcast to blocks of M x block - for r0 in range(0, M, block): - r1 = min(r0 + block, M) - ov = np.all((lo[r0:r1, None, :] <= hi[None, :, :]) - & (lo[None, :, :] <= hi[r0:r1, None, :]), axis=2) - ki, li = np.nonzero(ov) - ki = ki + r0 - keep = li > ki # unordered pairs once - ki, li = ki[keep], li[keep] - same = br[ki] == br[li] # same-branch adjacency: index gap >= 3 - keep = ~same | (np.abs(ix[ki] - ix[li]) >= 3) - pairs.append(np.stack([ki[keep], li[keep]], axis=1)) - pairs = np.concatenate(pairs) if pairs else np.empty((0, 2), dtype=int) def _anchor_vertex(bi, seg_k, xyz): # Ledger L11: links carry VERTEX indices — the polyline vertex @@ -1056,6 +1264,8 @@ def _anchor_vertex(bi, seg_k, xyz): # would collapse both links onto one location. poly = np.asarray(branches[bi].curve[1], dtype=np.float64) v = int(seg_k) + if not _spend(2): + return None d = float(np.linalg.norm(poly[v] - xyz)) d2 = float(np.linalg.norm(poly[v + 1] - xyz)) if d2 < d: @@ -1065,6 +1275,8 @@ def _anchor_vertex(bi, seg_k, xyz): improved = False for w in (v - 1, v + 1): if 0 <= w < len(poly): + if not _spend(1): + return None dw = float(np.linalg.norm(poly[w] - xyz)) if dw < d: v, d = w, dw @@ -1072,18 +1284,31 @@ def _anchor_vertex(bi, seg_k, xyz): break return v - for k, l in pairs: + def _process_pair(k, l): + if not _spend(1): + return d, s_, t_ = seg_dist(A[k], B[k], A[l], B[l]) if d > 5.0 * atol: # ledger L23 (was 2*atol, below the - continue # 4*atol worst-case chord-pair gap) + return # 4*atol worst-case chord-pair gap) a4 = (1 - s_) * S4a[k] + s_ * S4b[k] b4 = (1 - t_) * S4a[l] + t_ * S4b[l] - for stuv, mate, xyz in solve_candidate(a4, b4): - links = [(int(br[k]), _anchor_vertex(int(br[k]), int(ix[k]), xyz)), - (int(br[l]), _anchor_vertex(int(br[l]), int(ix[l]), xyz))] - dup = next((h for h in found - if _c3_same_hit(h["stuv"], h["stuv_mate"], h["xyz"], - stuv, mate, xyz, atol, ptol4)), None) + candidate_hits = solve_candidate(a4, b4) + if _ledger.exhausted: + return + for stuv, mate, xyz in candidate_hits: + ak = _anchor_vertex(int(br[k]), int(ix[k]), xyz) + al = _anchor_vertex(int(br[l]), int(ix[l]), xyz) + if _ledger.exhausted or ak is None or al is None: + return + links = [(int(br[k]), ak), (int(br[l]), al)] + dup = None + for h in found: + if not _spend(1): + return + if _c3_same_hit(h["stuv"], h["stuv_mate"], h["xyz"], + stuv, mate, xyz, atol, ptol4): + dup = h + break if dup is not None: for ln in links: if ln not in dup["links"]: @@ -1091,4 +1316,60 @@ def _anchor_vertex(bi, seg_k, xyz): continue found.append({"stuv": stuv, "stuv_mate": mate, "xyz": xyz, "links": links}) + + # Stream bounded square tiles of the upper triangle. The old code + # appended every surviving pair from every Mx1024 broadcast and only + # then began exact work, so a dense polyline could allocate O(M^2) + # memory before any soft limit had a chance to fire. Each tile is + # charged *before* its AABB broadcast; denial leaves it wholly + # unprocessed and therefore makes the returned hit set explicitly + # partial. + block = 256 + stop = False + for r0 in range(0, M, block): + r1 = min(r0 + block, M) + for c0 in range(r0, M, block): + c1 = min(c0 + block, M) + nr, nc = r1 - r0, c1 - c0 + pair_count = (nr * (nr - 1) // 2 + if c0 == r0 else nr * nc) + if pair_count <= 0: + continue + # Ledger L43: one vectorized AABB pair test costs ~ns; charging + # it 1:1 against subdivision-cell work (~ms) let ~350k raw + # pairs burn the whole default allowance on ~10 ms of numpy. + # Price per-128 like the SSX `precompute` convention; the + # downstream seg_dist/Newton/anchor/dedup work (the real cost) + # keeps its 1:1 pricing in `_process_pair`. + if not _spend(max(1, (pair_count + 127) // 128)): + stop = True + break + pairs_processed += pair_count + + ov = np.all( + (lo[r0:r1, None, :] <= hi[None, c0:c1, :]) + & (lo[None, c0:c1, :] <= hi[r0:r1, None, :]), + axis=2, + ) + ki, li = np.nonzero(ov) + ki = ki + r0 + li = li + c0 + if c0 == r0: + keep = li > ki # unordered pairs once on diagonal tile + ki, li = ki[keep], li[keep] + same = br[ki] == br[li] + keep = ~same | (np.abs(ix[ki] - ix[li]) >= 3) + ki, li = ki[keep], li[keep] + candidate_pairs += len(ki) + for k, l in zip(ki, li): + _process_pair(int(k), int(l)) + if _ledger.exhausted: + stop = True + break + if stop: + break + if stop: + break + + _publish_stats() return found diff --git a/mmcore/topo/brep/boolean2d.py b/mmcore/topo/brep/boolean2d.py index e9fcda16..897a0470 100644 --- a/mmcore/topo/brep/boolean2d.py +++ b/mmcore/topo/brep/boolean2d.py @@ -77,7 +77,18 @@ def point_in_region( weights=np.array([1.0, 1.0], dtype=float), ) - isolated, overlaps = nurbs_ccx_multiple([seg] + list(region_curves), tol=tol) + isolated, overlaps, ccx_status = nurbs_ccx_multiple( + [seg] + list(region_curves), tol=tol) + if not ccx_status['complete']: + # Ledger L41: an incomplete ray-casting CCX must not crash the + # boolean (the former adapter default raised RuntimeError here); + # a truncated crossing count can misclassify containment, so + # surface it loudly while returning the best available answer. + import warnings + warnings.warn( + "boolean2d point-in-region: incomplete CCX ray cast " + "(bounded solve truncated) — containment may be unreliable", + RuntimeWarning, stacklevel=2) endpoint_eps = 1/np.linalg.norm(seg.control_points[-1]-seg.control_points[0]) * tol @@ -417,7 +428,16 @@ def _split_curves_at_intersections( Returns (sub_segments, sub_sources) where each source tag is 'A', 'B', or 'AB' (the last indicates a segment produced by merging an overlap pair). """ - isolated, overlaps = nurbs_ccx_multiple(curves, tol=tol) + isolated, overlaps, ccx_status = nurbs_ccx_multiple(curves, tol=tol) + if not ccx_status['complete']: + # Ledger L41: proceed with the certified subset instead of raising — + # missed split points degrade the arrangement locally; the warning + # is the honest signal until boolean2d grows its own status channel. + import warnings + warnings.warn( + "boolean2d split: incomplete CCX result (bounded solve " + "truncated) — some intersection splits may be missing", + RuntimeWarning, stacklevel=2) # Per-curve list of split parameters (including the overlap range # endpoints — overlaps must cause splits at both ends). diff --git a/tests/test_adapter_kwargs.py b/tests/test_adapter_kwargs.py new file mode 100644 index 00000000..a41a2b95 --- /dev/null +++ b/tests/test_adapter_kwargs.py @@ -0,0 +1,53 @@ +"""L52 slice 8: NURBS adapters reject unknown kwargs. + +`tol=` is the adapter-level spelling and `atol=` the bez-level one; the +former silent `**kwargs` swallow meant `nurbs_ccx(..., atol=1e-6)` ran at +the DEFAULT tolerance without a word (review §10 kwarg-hygiene finding). +""" +import numpy as np +import pytest + +from mmcore.geom._nurbs_eval import NURBSCurveTuple, NURBSSurfaceTuple +from mmcore.numeric.intersection.ccx._nccx4 import nurbs_ccx +from mmcore.numeric.intersection.csx._ncsx4 import nurbs_csx + + +def _line(p0, p1): + return NURBSCurveTuple( + control_points=np.array([p0, p1], dtype=float), + weights=np.ones(2), knot=np.array([0.0, 0.0, 1.0, 1.0]), order=2) + + +def _plane(): + return NURBSSurfaceTuple( + control_points=np.array( + [[[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 0.0]]]), + weights=np.ones((2, 2)), + knot_u=np.array([0.0, 0.0, 1.0, 1.0]), + knot_v=np.array([0.0, 0.0, 1.0, 1.0]), + order_u=2, order_v=2) + + +def test_nurbs_ccx_rejects_unknown_kwargs(): + c1 = _line([0.0, -0.5, 0.0], [0.0, 0.5, 0.0]) + c2 = _line([-0.5, 0.0, 0.0], [0.5, 0.0, 0.0]) + with pytest.raises(TypeError, match="atol"): + nurbs_ccx(c1, c2, atol=1e-6) + # the accepted knobs still pass through + isolated, overlaps, status = nurbs_ccx(c1, c2, max_cells=10_000) + assert status["complete"] is not None + + +def test_nurbs_csx_rejects_unknown_kwargs_and_uses_tol(): + # L52 slice 8 unification: nurbs_csx's tolerance parameter was `atol=` + # while its sibling nurbs_ccx used `tol=` — passing either spelling to + # the other adapter was silently swallowed. Both adapters now spell it + # `tol=` (the adapter-level convention; `atol` stays the bez level's). + c = _line([0.5, 0.5, -1.0], [0.5, 0.5, 1.0]) + s = _plane() + with pytest.raises(TypeError, match="atol"): + nurbs_csx(c, s, atol=1e-6) + isolated, overlaps, status = nurbs_csx(c, s, tol=1e-3, max_depth=32) + assert status["complete"] is not None + assert len(isolated) == 1 diff --git a/tests/test_bez_ccx4.py b/tests/test_bez_ccx4.py index 433d8343..9d555a3a 100644 --- a/tests/test_bez_ccx4.py +++ b/tests/test_bez_ccx4.py @@ -41,6 +41,75 @@ def test_no_intersection(): assert len(result["overlaps"]) == 0 +def test_soft_cell_budget_covers_phase1_and_all_phase2_intervals(): + """The public cap is one shared allowance, not a per-interval reset.""" + C1 = np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 0.0]]) + C2 = np.array([[0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]) + + empty = bez_ccx(C1, C2, rational=False, max_cells=0) + assert empty["budget_exhausted"] is True + assert empty["boundary_topology_complete"] is False + assert empty["cells_processed"] == 0 + + partial = bez_ccx(C1, C2, rational=False, max_cells=5) + assert partial["budget_exhausted"] is True + assert partial["cells_processed"] <= 5 + + complete = bez_ccx(C1, C2, rational=False, max_cells=32) + assert complete["budget_exhausted"] is False + assert complete["cells_processed"] <= 32 + assert len(complete["isolated"]) == 1 + + +def test_phase2_max_depth_reports_unresolved_cell(monkeypatch): + """Reaching ``max_depth`` is an incomplete search, not a clean prune.""" + import mmcore.numeric.intersection._sq_dist_classify as classify + import mmcore.numeric.intersection.ccx._bez_ccx4 as ccx_mod + + monkeypatch.setattr(classify, "_check_min_of_net", lambda *args: False) + monkeypatch.setattr(classify, "_check_lipschitz", lambda *args: False) + monkeypatch.setattr( + ccx_mod, "bernstein_partial_derivative_coeffs", + lambda *args, **kwargs: np.array([[-1.0], [1.0]]), + ) + monkeypatch.setattr( + ccx_mod, "newton_ccx", + lambda *args, **kwargs: (0.5, 0.5, np.ones(3), np.ones(2)), + ) + + C = np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 0.0]]) + roots, exhausted, cells = ccx_mod._phase2_ccx( + np.zeros((2, 2)), C, C, C, C, + 0.0, 1.0, 0.0, 1.0, + 1e-6, False, 1e-12, 1e-12, + max_depth=0, max_cells=10, + ) + + assert roots == [] + assert cells == 1 + assert exhausted is True + + +def test_boundary_root_cap_never_becomes_partial_overlap_topology(): + C = np.array([ + [0.0, 0.0, 0.0], + [0.5, 1.0, 0.0], + [1.0, 0.0, 0.0], + ]) + + # Identical curves put roots on all four parameter-square boundaries. + # A cap below that count must yield an explicit partial result, not an + # overlap inferred from whichever endpoints happened to fit. + partial = bez_ccx( + C, C, atol=1e-3, rational=False, + max_cells=100, max_results=2, + ) + assert partial["budget_exhausted"] is True + assert partial["boundary_topology_complete"] is False + assert partial["isolated"] == [] + assert partial["overlaps"] == [] + + def test_identical_curves_overlap(): C1 = np.array([[0.0, 0.0, 0.0], [0.5, 1.0, 0.0], [1.0, 0.0, 0.0]]) C2 = C1.copy() @@ -61,15 +130,23 @@ def test_rational_arc_line(): # --------------------------------------------------------------------------- def test_compare_case1_overlap(): - """Old finds 1 overlap for curve1 vs curve2. New should also find overlap.""" + """The legacy tolerance trace must not force an exact overlap claim. + + These 8-decimal fixtures follow the same path to about 3e-9 but are not + coefficient-identical. V4 may return strict roots/an exact certified + overlap, or conservatively stop partial; it must not grind indefinitely + trying to discretize the near-overlap valley. + """ old = bez_ccx_old(curve1, curve2) new = bez_ccx(curve1, curve2, atol=1e-3, rational=False) assert len(old["overlaps"]) == 1 # New should find overlap (or at worst, multiple isolated points along the overlap) has_overlap = len(new["overlaps"]) >= 1 has_many_isolated = len(new["isolated"]) >= 2 - assert has_overlap or has_many_isolated, ( - f"New found: {len(new['isolated'])} isolated, {len(new['overlaps'])} overlaps" + conservative_partial = new.get("budget_exhausted", False) + assert has_overlap or has_many_isolated or conservative_partial, ( + f"New found: {len(new['isolated'])} isolated, " + f"{len(new['overlaps'])} overlaps, partial={conservative_partial}" ) @@ -101,3 +178,219 @@ def test_tangent_touching(): result = bez_ccx(C1, C2, atol=1e-3, rational=False) # Should find exactly 1 intersection point (tangent) assert len(result["isolated"]) == 1 + + +# --------------------------------------------------------------------------- +# Ledger L47: near-coincident / non-affine coincident overlap semantics +# (USER DECISION 2026-07-12: residual-certified tier alongside the exact one) +# --------------------------------------------------------------------------- + +def _monomial_to_bernstein(a): + """Exact monomial -> Bernstein-n coefficient conversion (1-D).""" + from math import comb + n = len(a) - 1 + return [sum(comb(i, k) / comb(n, k) * a[k] for k in range(i + 1)) + for i in range(n + 1)] + + +@pytest.mark.parametrize("reverse", [False, True], ids=["same-dir", "reversed"]) +def test_near_coincident_pair_ships_tolerance_overlap(reverse): + """Monotone-x cubic vs itself offset 1e-9 in y (atol=1e-3): a crossing- + free coincidence at tolerance (no tangent of the curve is parallel to + the offset, so the offset twin never crosses the original). The exact- + affine narrowing lost the overlap AND misbilled the failure to the + budget; the residual tier must certify it (dense-sample inversion + pairing, residual <= atol) and ship it complete.""" + C1 = np.array([[0.0, 0.0, 0.0], [1.0, 0.6, 0.0], + [2.0, -0.2, 0.0], [3.0, 0.4, 0.0]]) + C2 = C1.copy() + C2[:, 1] += 1e-9 + if reverse: + C2 = C2[::-1].copy() + r = bez_ccx(C1, C2, atol=1e-3, rational=False) + assert len(r["overlaps"]) == 1, r + ov = r["overlaps"][0] + assert ov["certification"] == "tolerance" + assert ov["u_range"] == pytest.approx((0.0, 1.0), abs=1e-6) + v0, v1 = ov["v_range"] + if reverse: + assert (v0, v1) == pytest.approx((1.0, 0.0), abs=1e-6) + else: + assert (v0, v1) == pytest.approx((0.0, 1.0), abs=1e-6) + assert float(ov["residual_max"]) <= 1e-3 + assert r["isolated"] == [] + assert r["budget_exhausted"] is False + assert r["boundary_topology_complete"] is True + + +def test_offset_twin_with_vertical_tangent_is_not_cleanly_promoted(): + """L54[A2-1] corollary, measured during the on-node fix: a y-offset of a + curve whose tangent turns PARALLEL to the offset (curve1 has a vertical + tangent near u=0.75) genuinely CROSSES the original there — the offset + slides the curve along itself locally, so 'offset pair' does NOT imply + crossing-free. The bridged flip test detects it (the transverse + direction reverses through the tangent zone) and promotion is refused: + the honest outcome is the woven-family typed partial, not a clean + overlap that would merge real crossing structure.""" + C2 = curve1.copy() + C2[:, 1] += 1e-9 + r = bez_ccx(curve1, C2, atol=1e-3, rational=False) + assert len(r["overlaps"]) == 0 + assert r["budget_exhausted"] is True + assert r["boundary_topology_complete"] is False + assert "uncertified_overlap_span" in r, sorted(r) + + +def test_exact_affine_overlap_certification_is_exact(): + """Coefficient-identical curves keep the exact certificate (unchanged + semantics; the new field just names it).""" + C = np.array([[0.0, 0.0, 0.0], [0.5, 1.0, 0.0], [1.0, 0.0, 0.0]]) + r = bez_ccx(C, C.copy(), atol=1e-3, rational=False) + assert len(r["overlaps"]) == 1 + assert r["overlaps"][0]["certification"] == "exact" + assert r["budget_exhausted"] is False + + +def test_non_affine_reparameterized_exact_overlap_certifies(): + """Same locus, non-affine parameter map q(s) = (s^2+s)/2: a genuine + exact overlap that no affine identity can certify. The residual tier + must ship it (residual ~ roundoff) instead of flooding isolated roots + or stopping partial.""" + # C1(t) = (2t, 2t(1-t), 0), degree 2 + C1 = np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 0.0], [2.0, 0.0, 0.0]]) + # C2(s) = C1(q(s)) with q(s) = (s^2+s)/2 (monotone [0,1]->[0,1]): + # x = s^2 + s; y = (s^2+s) - (s^4 + 2 s^3 + s^2)/2 + bx = _monomial_to_bernstein([0.0, 1.0, 1.0, 0.0, 0.0]) + by = _monomial_to_bernstein([0.0, 1.0, 0.5, -1.0, -0.5]) + C2 = np.column_stack([bx, by, np.zeros(5)]) + r = bez_ccx(C1, C2, atol=1e-3, rational=False) + assert len(r["overlaps"]) == 1, r + ov = r["overlaps"][0] + assert ov["certification"] == "tolerance" + assert ov["u_range"] == pytest.approx((0.0, 1.0), abs=1e-6) + assert ov["v_range"] == pytest.approx((0.0, 1.0), abs=1e-6) + assert float(ov["residual_max"]) <= 1e-9 + assert r["isolated"] == [] + assert r["budget_exhausted"] is False + + +def test_realistic_woven_near_coincident_reports_typed_span(): + """curve1 vs curve2 follow the same path to ~3e-9 but WEAVE across each + other — genuine crossings at fitting-noise amplitude. Crossing evidence + blocks tolerance promotion (the approved no-distinct-roots guard: never + merge crossing structure), yet the crossings sit below the strict + certification scale (the curves are ~1e-9-parallel there), so they + cannot ship as isolated roots either. The honest outcome is the typed + uncertified span with topology incomplete, at bounded fallback cost — + not a silent bare-budget grind.""" + r = bez_ccx(curve1, curve2, atol=1e-3, rational=False) + assert len(r["overlaps"]) == 0 + assert r["budget_exhausted"] is True + assert r["boundary_topology_complete"] is False + lo, hi = r["uncertified_overlap_span"] + assert (lo, hi) == pytest.approx((0.0, 0.8276), abs=1e-3) + assert r["cells_processed"] < 5_000 + + +def test_interior_crossings_inside_tolerance_band_stay_isolated(): + """Sub-atol-topology invariant (the L42/CSX negative result, 1-D form): + a pair whose ENDS are within tolerance but whose interior CROSSES twice + must never be merged into a tolerance overlap — the two transversal + roots are the topology. The residual tier's transverse-direction flip + test is the guard.""" + # C1 = flat segment y=0 (degree 2); C2 = (t, f(t)) with + # f(t) = 1e-9 + 5e-4 (t-0.4)(t-0.6): f(0)=f(1)=1.2e-4 (within atol), + # two sign changes near t=0.4 and t=0.6, |f| <= 1.2e-4 everywhere. + C1 = np.array([[0.0, 0.0, 0.0], [0.5, 0.0, 0.0], [1.0, 0.0, 0.0]]) + f = _monomial_to_bernstein([1e-9 + 5e-4 * 0.24, -5e-4, 5e-4]) + C2 = np.column_stack([[0.0, 0.5, 1.0], f, np.zeros(3)]) + r = bez_ccx(C1, C2, atol=1e-3, rational=False) + assert len(r["overlaps"]) == 0, r["overlaps"] + assert len(r["isolated"]) == 2, r + us = sorted(float(i["u"]) for i in r["isolated"]) + assert us[0] == pytest.approx(0.4, abs=5e-3) + assert us[1] == pytest.approx(0.6, abs=5e-3) + assert r["budget_exhausted"] is False + + +def test_uncertifiable_overlap_class_reports_typed_span_not_bare_budget(): + """A valley-confirmed pair that NEITHER certificate can promote must + name the structure — uncertified_overlap_span + topology incomplete — + instead of a bare budget_exhausted with topology claimed complete.""" + # C2 = C1 + (0, f(t), 0) on the curved cubic fixture, with + # f(t) = 1e-9 + 2e-3 t^8: a LONG 1e-9-coincident band near u=0 (an + # undiscretizable diagonal valley — the curved y makes the residual + # net straddle zero along it), sub-atol until t ~ 0.92, 2e-3 > atol at + # t=1 — so only the u=0 end is pairable and no span candidate exists. + # Same side everywhere (no crossings to lose). + def _elevate_once(ctrl): + n = len(ctrl) - 1 + out = [ctrl[0]] + for i in range(1, n + 1): + a = i / (n + 1) + out.append(a * ctrl[i - 1] + (1.0 - a) * ctrl[i]) + out.append(ctrl[-1]) + return np.asarray(out) + + C1 = curve1 + C2 = curve1.copy() + for _ in range(5): # degree 3 -> 8, exact + C2 = _elevate_once(C2) + C2[:, 1] += _monomial_to_bernstein([1e-9] + [0.0] * 7 + [2e-3]) + r = bez_ccx(C1, C2, atol=1e-3, rational=False) + assert len(r["overlaps"]) == 0 + assert r["budget_exhausted"] is True, r + # the sub-atol band could not be discretized: the typed span must name + # the uncertifiable structure and topology must not be claimed complete + assert r["boundary_topology_complete"] is False + assert "uncertified_overlap_span" in r, sorted(r) + lo, hi = r["uncertified_overlap_span"] + assert 0.0 <= lo < hi <= 1.0 + + +@pytest.mark.parametrize("a, b, t_star", [ + (3e-4, 3e-4, 0.50), # crossing exactly on sample node 32/64 + (3e-4, 9e-4, 0.25), # crossing exactly on sample node 16/64 + (3e-4, 7e-4, 0.30), # off-node control (worked before the fix) +], ids=["node-0.5", "node-0.25", "off-node-0.3"]) +def test_on_node_interior_crossing_is_never_merged(a, b, t_star): + """L54[A2-1] (audit-confirmed): a transversal crossing landing exactly ON + one of the 65 sample nodes made that sample root-like, and the flip loop + skipped BOTH straddling pairs — the crossing was silently absorbed into a + certification='tolerance' overlap reported complete. The flip test must + bridge across root-like runs (compare consecutive GAP samples), with the + bracket at the intervening root-like node — the root is exactly there. + Dyadic-64 fractions (0.25, 0.5, 0.75...) are the most common crossing + locations in CAD by symmetry.""" + line = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) + # y(t) = 3t(1-t)[a(1-t) - b t]: shares both endpoints with the line and + # crosses it transversally at t* = a/(a+b), all within a sub-atol band. + cubic = np.array([[0.0, 0.0, 0.0], [1.0 / 3.0, a, 0.0], + [2.0 / 3.0, -b, 0.0], [1.0, 0.0, 0.0]]) + r = bez_ccx(line, cubic, atol=1e-3, rational=False) + assert len(r["overlaps"]) == 0, r["overlaps"] + us = sorted(float(i["u"]) for i in r["isolated"]) + assert any(abs(u - t_star) < 5e-3 for u in us), (t_star, us) + + +def test_zero_allowance_preflights_before_net_build(monkeypatch): + """L52 pin: bez_ccx already refuses a zero allowance before building the + squared-distance net — lock that ordering so a refactor cannot regress + it to the pre-preflight behavior the CSX twin had.""" + import mmcore.numeric.intersection.ccx._bez_ccx4 as ccx_mod + + calls = {"n": 0} + orig = ccx_mod.curve_curve_squared_net_homog + + def counting(*a, **k): + calls["n"] += 1 + return orig(*a, **k) + + monkeypatch.setattr( + ccx_mod, "curve_curve_squared_net_homog", counting) + C1 = np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 0.0]]) + C2 = np.array([[0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]) + r = bez_ccx(C1, C2, rational=False, max_cells=0) + assert r["budget_exhausted"] is True + assert r["cells_processed"] == 0 + assert calls["n"] == 0, "net built despite zero allowance" diff --git a/tests/test_bez_closest_point.py b/tests/test_bez_closest_point.py index 54b22251..53a25b64 100644 --- a/tests/test_bez_closest_point.py +++ b/tests/test_bez_closest_point.py @@ -246,6 +246,199 @@ def test_surface_closest_plane_interior(): assert res[0]["kind"] == "min" +def test_surface_closest_shares_one_seven_cell_budget(monkeypatch): + """Boundary searches spend through one allowance; the interior heap + keeps its OWN pop allowance (ledger L46 — the former single shared + total let the boundary phase starve the interior to zero pops and + silently drop the true interior minimum). Work counters report the + honest total (boundary + interior <= 2*max_cells).""" + import mmcore.numeric._bez_closest_point as closest + + curve_allowances = [] + + def fake_curve_solver(C, point, atol=1e-3, rational=False, + max_cells=20000, upper_bound=None, stats=None): + curve_allowances.append(max_cells) + stats.update(cells_processed=1, budget_exhausted=False) + return [] + + monkeypatch.setattr(closest, "bez_curve_closest_points", fake_curve_solver) + S = np.array([[[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 0.0]]]) + stats = {} + with _warnings.catch_warnings(): + _warnings.simplefilter("ignore") + closest.bez_surface_closest_points( + S, np.array([0.5, 0.5, 5.0]), atol=1e-6, + max_cells=7, stats=stats) + + assert curve_allowances == [7, 6, 5, 4] + assert stats == { + "cells_processed": 11, + "boundary_cells": 4, + "surface_cells": 7, + "trace_steps": 0, + "budget_exhausted": True, + } + + +def test_surface_closest_does_not_trace_after_budget_exhaustion(monkeypatch): + """Degenerate tracing must not run outside the shared cell allowance.""" + import mmcore.numeric._bez_closest_point as closest + + ratio_calls = 0 + + def fake_ratio_bounds(*args): + nonlocal ratio_calls + ratio_calls += 1 + # Initial/root/child-heap bounds remain unresolved. The first child + # popped from the heap is a flat rank-one cell and creates a seed; + # the sibling then remains queued when the two-cell cap is reached. + return (0.0, 0.0) if ratio_calls == 5 else (0.0, 1.0) + + trace_calls = [] + + def forbidden_trace(*args, **kwargs): + trace_calls.append((args, kwargs)) + raise AssertionError("degenerate tracing ran after max_cells") + + monkeypatch.setattr(closest, "_ratio_dist_bounds", fake_ratio_bounds) + monkeypatch.setattr(closest, "_hull_excludes_zero", lambda *args: False) + monkeypatch.setattr( + closest, "newton_surface_closest_point", + lambda *args, **kwargs: (0.5, 0.5, None, None), + ) + monkeypatch.setattr( + closest, "_classify_surface_min", + lambda *args, **kwargs: ( + True, 1.0, np.array([0.5, 0.5, 0.0]), + ), + ) + monkeypatch.setattr( + closest, "_hessian_eigs", + lambda *args, **kwargs: (0.0, 1.0, None, 1.0), + ) + monkeypatch.setattr(closest, "_is_stationary_point", lambda *args: True) + monkeypatch.setattr(closest, "trace_equidistant_curve", forbidden_trace) + + S = np.array([ + [[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], + ]) + stats = {} + result = closest.bez_surface_closest_points( + S, np.array([0.5, 0.5, 1.0]), atol=1e-3, + max_cells=2, _interior_only=True, stats=stats, + ) + + assert result + assert trace_calls == [] + assert stats["surface_cells"] == 2 + assert stats["budget_exhausted"] is True + + +def test_surface_closest_does_not_trace_after_exactly_spending_allowance( + monkeypatch): + """An empty queue does not make continuation work free.""" + import mmcore.numeric._bez_closest_point as closest + + ratio_calls = 0 + + def fake_ratio_bounds(*_args): + nonlocal ratio_calls + ratio_calls += 1 + return (0.0, 1.0) if ratio_calls == 1 else (0.0, 0.0) + + monkeypatch.setattr(closest, "_ratio_dist_bounds", fake_ratio_bounds) + monkeypatch.setattr(closest, "_hull_excludes_zero", lambda *_a: False) + monkeypatch.setattr( + closest, "newton_surface_closest_point", + lambda *_a, **_k: (0.5, 0.5, None, None), + ) + monkeypatch.setattr( + closest, "_hessian_eigs", + lambda *_a, **_k: (0.0, 1.0, None, 1.0), + ) + monkeypatch.setattr(closest, "_is_stationary_point", lambda *_a: True) + monkeypatch.setattr( + closest, "_classify_surface_min", + lambda *_a, **_k: (True, 1.0, np.array([0.5, 0.5, 0.0])), + ) + + def forbidden_trace(*_args, **_kwargs): + raise AssertionError("continuation ran after the sole cell was spent") + + monkeypatch.setattr(closest, "trace_equidistant_curve", forbidden_trace) + surface = np.array([ + [[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 1.0]], + ]) + stats = {} + closest.bez_surface_closest_points( + surface, np.array([0.0, 0.0, 2.0]), atol=1e-3, + max_cells=1, _interior_only=True, stats=stats, + ) + + assert stats == { + "cells_processed": 1, + "boundary_cells": 0, + "surface_cells": 1, + "trace_steps": 0, + "budget_exhausted": True, + } + + +def test_surface_closest_charges_degenerate_trace_steps(monkeypatch): + import mmcore.numeric._bez_closest_point as closest + + ratio_calls = 0 + + def fake_ratio_bounds(*_args): + nonlocal ratio_calls + ratio_calls += 1 + return (0.0, 1.0) if ratio_calls == 1 else (0.0, 0.0) + + monkeypatch.setattr(closest, "_ratio_dist_bounds", fake_ratio_bounds) + monkeypatch.setattr(closest, "_hull_excludes_zero", lambda *_a: False) + monkeypatch.setattr( + closest, "newton_surface_closest_point", + lambda *_a, **_k: (0.5, 0.5, None, None), + ) + monkeypatch.setattr( + closest, "_hessian_eigs", + lambda *_a, **_k: (0.0, 1.0, None, 1.0), + ) + monkeypatch.setattr(closest, "_is_stationary_point", lambda *_a: True) + monkeypatch.setattr( + closest, "_classify_surface_min", + lambda *_a, **_k: (True, 1.0, np.array([0.5, 0.5, 0.0])), + ) + + def truncated_trace(*_args, max_total_steps=None, stats=None, **_kwargs): + assert max_total_steps == 4 + stats.update(steps_processed=4, budget_exhausted=True) + return None + + monkeypatch.setattr(closest, "trace_equidistant_curve", truncated_trace) + surface = np.array([ + [[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 1.0]], + ]) + stats = {} + closest.bez_surface_closest_points( + surface, np.array([0.0, 0.0, 2.0]), atol=1e-3, + max_cells=5, _interior_only=True, stats=stats, + ) + + assert stats == { + "cells_processed": 5, + "boundary_cells": 0, + "surface_cells": 1, + "trace_steps": 4, + "budget_exhausted": True, + } + + def test_surface_closest_curved_patch_matches_dense_grid(): # Non-planar biquadratic-ish patch (bilinear with a bump via z) S = np.array([[[0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 2.0, 0.0]], @@ -608,6 +801,28 @@ def test_trace_equidistant_curve_direct(): assert tr["uv"][:, 0].max() - tr["uv"][:, 0].min() > 0.9 +def test_trace_equidistant_curve_marks_shared_total_step_cap(monkeypatch): + import mmcore.numeric._bez_closest_point as closest + + monkeypatch.setattr( + closest, "_hessian_eigs", + lambda *_a, **_k: (0.0, 1.0, np.array([1.0, 0.0]), 1.0), + ) + monkeypatch.setattr( + closest, "_surface_g_derivs", + lambda *_a, **_k: (1.0, 0.0, 0.0, 0.0, 0.0, 1.0), + ) + surface = np.zeros((2, 2, 3), dtype=np.float64) + stats = {} + result = closest.trace_equidistant_curve( + surface, np.zeros(3), 0.5, 0.5, step=0.01, + max_steps=100, max_total_steps=3, stats=stats, + ) + + assert result is None + assert stats == {"steps_processed": 3, "budget_exhausted": True} + + def test_trace_rejects_isolated_seed(): # At a well-conditioned isolated minimum the tracer must decline. S = np.array([[[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], @@ -793,3 +1008,92 @@ def test_rhino_rotated_cone_single_closed_ring(): def test_rhino_paraboloid_single_closed_ring(): from examples.closest_point.paraboloid import val, query _assert_single_closed_ring(val, query) + + +# --------------------------------------------------------------------------- +# L46 — NURBS aggregators propagate the budget signal; interior not starved +# --------------------------------------------------------------------------- + +def _flat_square_surface(): + from mmcore.geom._nurbs_eval import NURBSSurfaceTuple + axis = np.array([0.0, 0.0, 1.0, 1.0]) + pts = np.zeros((2, 2, 3)) + pts[..., 0] = [[0.0, 0.0], [1.0, 1.0]] + pts[..., 1] = [[0.0, 1.0], [0.0, 1.0]] + return NURBSSurfaceTuple(order_u=2, order_v=2, knot_u=axis, knot_v=axis, + control_points=pts, weights=np.ones((2, 2))) + + +def test_nurbs_surface_aggregator_propagates_budget_signal(monkeypatch): + # Ledger L46: the NURBS aggregator passed no stats= and no shared + # allowance — a capped patch only warned and its far-local-min + # entities were merged as the certified globally-closest set. The + # aggregator now takes stats=/max_cells= and publishes aggregate + # exhaustion. + import mmcore.numeric._bez_closest_point as cp + + calls = [] + + def fake_bez(S, point, atol=1e-3, rational=False, want_eval=False, + max_cells=20000, stats=None, upper_bound=None): + calls.append(int(max_cells)) + if stats is not None: + stats.update(cells_processed=int(max_cells), + budget_exhausted=True) + return [{"kind": "min", "u": 0.5, "v": 0.5, + "point": np.zeros(3), "distance": 1.0}] + + monkeypatch.setattr(cp, "bez_surface_closest_points", fake_bez) + stats = {} + res = cp.nurbs_surface_closest_points( + _flat_square_surface(), np.array([0.5, 0.5, 1.0]), + atol=1e-3, max_cells=7_000, stats=stats) + + assert res + assert stats["budget_exhausted"] is True + assert stats["cells_processed"] >= 7_000 + assert calls and calls[0] == 7_000 # the shared allowance reached the patch + + +def test_interior_search_not_starved_by_boundary(monkeypatch): + # Ledger L46 (starvation half): the interior best-first heap shared + # one max_cells with the 4 boundary searches — a boundary phase that + # burned the whole allowance left the interior ZERO pops and the + # global interior minimum was silently absent. The interior now keeps + # its own pop allowance. + import mmcore.numeric._bez_closest_point as cp + + def hungry_boundary(S, point, out_points, out_curves, rational, atol, + ptol_u, ptol_v, max_cells=20000, stats=None): + # burns the whole allowance AND reports one far boundary corner — + # so the post-loop paranoia fallback (which only fires on an empty + # entity set) cannot mask the starved interior. + from mmcore.numeric.intersection._bezier_common import eval_surface + S_h = np.concatenate([S, np.ones(S.shape[:-1] + (1,))], axis=-1) + pt = eval_surface(S_h, 0.0, 0.0, rational=True) + out_points.append({"u": 0.0, "v": 0.0, "point": np.asarray(pt), + "distance": float(np.linalg.norm(pt - point)), + "kind": "boundary_min"}) + if stats is not None: + stats.update(cells_processed=int(max_cells), + budget_exhausted=False) + + monkeypatch.setattr(cp, "_surface_boundary_entities", hungry_boundary) + + # bowl z = 4(u-.5)^2 + 4(v-.5)^2 (Bernstein net f[i]+g[j] with + # f = g = [1,-1,1]); query point BELOW the bowl bottom: the unique + # global minimum is INTERIOR at (.5,.5,0), distance 0.5 — every + # boundary point is > 1.5 away. + S = np.zeros((3, 3, 3)) + f = np.array([1.0, -1.0, 1.0]) + for i, u in enumerate([0.0, 0.5, 1.0]): + for j, v in enumerate([0.0, 0.5, 1.0]): + S[i, j] = (u, v, f[i] + f[j]) + res = cp.bez_surface_closest_points( + S, np.array([0.5, 0.5, -0.5]), atol=1e-3, rational=False) + + mins = [e for e in res if e["kind"] == "min"] + assert mins, "interior minimum lost to boundary starvation" + u, v = mins[0]["u"], mins[0]["v"] + assert abs(u - 0.5) < 1e-3 and abs(v - 0.5) < 1e-3 + assert abs(mins[0]["distance"] - 0.5) < 1e-3 diff --git a/tests/test_bez_csx4.py b/tests/test_bez_csx4.py index c0b9ebf5..6feba969 100644 --- a/tests/test_bez_csx4.py +++ b/tests/test_bez_csx4.py @@ -46,6 +46,308 @@ def test_line_two_crossings(): import time +def test_bernstein_zero_result_budget_stops_recursive_materialization(): + """A full result quota must stop before materializing child roots.""" + from mmcore.numeric.intersection._bern_zero_1d import ( + bernstein_zero_budget, + find_bernstein_zeros_1d, + ) + + # Both endpoints are exact roots and the derivative has three sign + # changes, so the uncapped algorithm would recurse into both children. + coeffs = np.array([0.0, -1.0, 1.0, -1.0, 0.0]) + with bernstein_zero_budget(max_nodes=100, max_results=1) as budget: + roots = find_bernstein_zeros_1d( + coeffs, atol=1e-3, max_depth=4, + ) + + assert roots == [0.0] + assert budget.exhausted is True + assert budget.nodes == 1 + + +def test_bernstein_zero_unresolved_depth_limit_exhausts_scoped_budget(): + """A Newton fallback cannot certify a multi-minimum interval complete.""" + from mmcore.numeric.intersection._bern_zero_1d import ( + bernstein_zero_budget, + find_bernstein_zeros_1d, + ) + + coeffs = np.array([1.0, -1.0, 1.0, -1.0, 1.0]) + with bernstein_zero_budget(max_nodes=100, max_results=10) as budget: + roots = find_bernstein_zeros_1d( + coeffs, atol=1e-3, max_depth=0, + ) + + assert len(roots) <= 1 + assert budget.exhausted is True + + +def test_csx_boundary_ccx_calls_share_one_remaining_budget(monkeypatch): + """The four surface-edge CCX calls must not each reset max_cells.""" + import mmcore.numeric.intersection.csx._bez_csx4 as csx_mod + + C = np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 0.0]]) + S = np.array([ + [[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], + ]) + F = np.zeros((3, 3, 3), dtype=float) + allowances = [] + + # Skip the two endpoint-face root searches so the test isolates the four + # nested CCX calls. The diagonal curve's AABB touches every patch edge. + monkeypatch.setattr(csx_mod, "_check_min_of_net", lambda *args: True) + + def fake_ccx(*args, max_cells, **kwargs): + allowances.append(max_cells) + used = min(2, max_cells) + return { + "isolated": [], "overlaps": [], + "budget_exhausted": False, "cells_processed": used, + "boundary_topology_complete": True, + } + + monkeypatch.setattr(csx_mod, "bez_ccx_v4", fake_ccx) + zeros, exhausted, cells = csx_mod._find_csx_boundary_zeros( + F, C, S, 1e-3, 1e-3, 1e-3, 1e-3, False, + max_cells=10, max_results=32, + ) + + assert zeros == [] + assert exhausted is False + assert cells == 10 + assert allowances == [8, 6, 4, 2] + + +def test_phase2_max_depth_reports_unresolved_cell(monkeypatch): + """Reaching ``max_depth`` must propagate as a partial CSX result.""" + import mmcore.numeric.intersection._sq_dist_classify as classify + import mmcore.numeric.intersection.csx._bez_csx4 as csx_mod + + monkeypatch.setattr(classify, "_check_min_of_net", lambda *args: False) + monkeypatch.setattr(classify, "_check_lipschitz", lambda *args: False) + monkeypatch.setattr(csx_mod, "_residual_excludes_zero", lambda *args: False) + monkeypatch.setattr( + csx_mod, "bernstein_partial_derivative_coeffs", + lambda *args, **kwargs: np.array([[-1.0], [1.0]]), + ) + monkeypatch.setattr( + csx_mod, "newton_csx", + lambda *args, **kwargs: ( + 0.5, 0.5, 0.5, np.ones(3), np.ones(3), + ), + ) + + C = np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]) + S = np.array([ + [[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], + ]) + roots, exhausted, cells = csx_mod._phase2_isolated_search( + np.zeros((2, 2, 2)), np.zeros((2, 2, 2, 3)), C, + S, C, S, + 0.0, 1.0, 1e-6, False, 1e-12, 1e-12, 1e-12, + max_depth=0, max_cells=10, + ) + + assert roots == [] + assert cells == 1 + assert exhausted is True + + +def test_partial_boundary_topology_is_discarded(monkeypatch): + import mmcore.numeric.intersection.csx._bez_csx4 as csx_mod + from mmcore.numeric.intersection._sq_dist_classify import BoundaryZero + + C = np.array([[0.5, 0.5, -1.0], [0.5, 0.5, 1.0]]) + S = np.array([ + [[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], + ]) + + def partial_boundary(*args, **kwargs): + return [BoundaryZero(axis=0, side=0, param=0.5, param2=0.5)], True, 1 + + monkeypatch.setattr(csx_mod, "_find_csx_boundary_zeros", partial_boundary) + monkeypatch.setattr( + csx_mod, "_check_csx_overlap_valley", + lambda *args, **kwargs: pytest.fail("partial topology reached overlap check"), + ) + + result = csx_mod.bez_csx( + C, S, atol=1e-3, rational=False, max_cells=1, + ) + assert result["budget_exhausted"] is True + assert result["boundary_topology_complete"] is False + assert result["isolated"] == [] + + +def test_constant_rational_curve_on_surface_is_parameter_fiber(): + """A collapsed rational boundary is a parameter fiber, not 16k roots. + + Case 14's cone apex edge is geometrically one point although its + homogeneous weights vary with ``t``. When that point lies on the other + surface, the CSX zero set contains every curve parameter. Enumerating + the fiber as isolated roots is both topologically wrong and quadratic in + the number of reported samples. + """ + from examples.ssx.bez_ssx5_case14 import S1, S2 + from mmcore.numeric.intersection._bezier_common import eval_curve, eval_surface + + C = S1[:, 0, :] + t0 = time.perf_counter() + result = bez_csx(C, S2, atol=1e-3, rational=True) + elapsed = time.perf_counter() - t0 + + assert elapsed < 2.0 + assert result["isolated"] == [] + assert result["overlaps"] == [] + fibers = result.get("parameter_fibers", []) + assert len(fibers) == 1 + fiber = fibers[0] + assert fiber["t_range"] == (0.0, 1.0) + p = eval_curve(C, 0.37, rational=True) + q = eval_surface(S2, fiber["u"], fiber["v"], rational=True) + assert np.linalg.norm(p - q) <= 1e-3 + + +def test_constant_curve_on_constant_surface_reports_full_parameter_region(): + """A representative (u,v) cannot stand in for the full [0,1]^3 set.""" + point = np.array([2.0, -3.0, 5.0]) + C = np.tile(np.r_[point, 1.0], (2, 1)) + S = np.tile(np.r_[point, 1.0], (2, 2, 1)) + + result = bez_csx(C, S, atol=1e-3, rational=True) + assert result["budget_exhausted"] is False + assert result["boundary_topology_complete"] is True + assert result["isolated"] == [] and result["overlaps"] == [] + assert len(result["parameter_fibers"]) == 1 + region = result["parameter_fibers"][0] + assert region["t_range"] == (0.0, 1.0) + assert region["u_range"] == (0.0, 1.0) + assert region["v_range"] == (0.0, 1.0) + assert np.array_equal(region["point"], point) + assert region["surface_kind"] == "degenerate_surface" + + +def test_constant_curve_parameter_fibers_respect_result_cap(monkeypatch): + """The collapsed-curve fast path shares the public result budget.""" + import mmcore.numeric._bez_closest_point as closest_mod + + curve = np.array([ + [0.0, 0.0, 0.0, 1.0], + [0.0, 0.0, 0.0, 2.0], + ]) + surface = np.array([ + [[-1.0, -1.0, 0.0, 1.0], [-1.0, 1.0, 0.0, 1.0]], + [[1.0, -1.0, 0.0, 1.0], [1.0, 1.0, 0.0, 1.0]], + ]) + + def many_closest(_surface, _query, *, stats, **_kwargs): + stats.update(cells_processed=1, budget_exhausted=False) + return [ + { + # Duplicate exact representatives exercise the public result + # cap without relying on a lying closest-point witness. + "u": 0.5, + "v": 0.5, + "point": np.zeros(3), + "distance": 0.0, + "kind": "min", + } + for i in range(6) + ] + + monkeypatch.setattr( + closest_mod, "bez_surface_closest_points", many_closest) + result = bez_csx( + curve, surface, atol=1e-3, rational=True, + max_cells=100, max_results=2, + ) + assert len(result["parameter_fibers"]) == 2 + assert result["budget_exhausted"] is True + assert result["boundary_topology_complete"] is False + + zero = bez_csx( + curve, surface, atol=1e-3, rational=True, + max_cells=100, max_results=0, + ) + assert zero["parameter_fibers"] == [] + assert zero["budget_exhausted"] is True + assert zero["boundary_topology_complete"] is False + + +def test_collapsed_curve_detection_is_translation_invariant(): + """Large world coordinates must not turn a moving curve into a fiber.""" + x0 = 1.0e15 + curve = np.array([ + [x0, 0.0, 0.0, 1.0], + [x0 + 10.0, 0.0, 0.0, 1.0], + ]) + plane_x = x0 + 5.0 + surface = np.array([ + [[plane_x, -1.0, -1.0, 1.0], [plane_x, -1.0, 1.0, 1.0]], + [[plane_x, 1.0, -1.0, 1.0], [plane_x, 1.0, 1.0, 1.0]], + ]) + + result = bez_csx(curve, surface, atol=1e-3, rational=True) + assert result["parameter_fibers"] == [] + assert len(result["isolated"]) == 1 + assert abs(result["isolated"][0]["t"] - 0.5) <= 1e-6 + + +def test_rational_param_tolerance_is_translation_invariant_for_constant_geometry(): + from mmcore.geom._nurbs_param_tol import ( + bez_curve_param_tolerance, bez_surface_param_tolerance, + ) + + weights = np.array([1.0, np.sqrt(0.5), 1.0]) + p = np.array([26.0, -11.0, 46.0]) + C = np.concatenate([weights[:, None] * p, weights[:, None]], axis=1) + shift = np.array([1000.0, -2000.0, 500.0]) + Ct = C.copy() + Ct[:, :3] += weights[:, None] * shift + + pc = bez_curve_param_tolerance(C, 1e-3, rational=True) + pct = bez_curve_param_tolerance(Ct, 1e-3, rational=True) + assert pc == pytest.approx(1e-3) + assert pct == pytest.approx(pc) + + W = np.array([[1.0, 0.8], [0.7, 1.2]]) + S = np.concatenate([W[..., None] * p, W[..., None]], axis=-1) + St = S.copy() + St[..., :3] += W[..., None] * shift + ps = bez_surface_param_tolerance(S, 1e-3, rational=True) + pst = bez_surface_param_tolerance(St, 1e-3, rational=True) + assert ps == pytest.approx((1e-3, 1e-3)) + assert pst == pytest.approx(ps) + + # Reversing a rational Bezier curve is a pure reparameterization and + # must not change its resolution. The endpoint with the small weight + # is the high-speed side of this degree-one example (max |C'| = 10), + # so a sound tolerance is at most atol/10. + Pe = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) + we = np.array([10.0, 1.0]) + Ce = np.concatenate([Pe * we[:, None], we[:, None]], axis=1) + pe = bez_curve_param_tolerance(Ce, 1e-3, rational=True) + per = bez_curve_param_tolerance(Ce[::-1].copy(), 1e-3, rational=True) + assert pe == pytest.approx(per) + assert pe <= 1.01e-4 + + # A coordinate-scale roundoff floor must not erase genuine local + # motion at large world coordinates. + huge = 1.0e15 + moving = np.array([ + [[huge, 0.0, 0.0, 1.0], [huge, 1.0, 0.0, 1.0]], + [[huge + 10.0, 0.0, 0.0, 1.0], + [huge + 10.0, 1.0, 0.0, 1.0]], + ]) + pu, _ = bez_surface_param_tolerance( + moving, 1e-3, rational=True) + assert pu <= 1.01e-4 + + def test_tangent_curve_on_surface(): """Curve tangent to surface at one point -- should find exactly 1 intersection.""" S = np.array([ @@ -196,14 +498,16 @@ def test_degree_one_line_no_false_positive_variants(): ) def test_case_13(): - """missing second isolated intersection""" - # may vary slightly (values taken from third-party software) + """A tolerance-only endpoint near miss must not become topology. + The formerly expected ``t=0`` item came from third-party tolerance + matching. With ``t`` fixed at zero, the closest point on this surface + remains 2.328e-8 away from the curve endpoint, so it is not a root of + the supplied floating-point coefficients. The interior root is real. + """ - excepted=[ - { 't':0.0, 'u':0.326507, 'v':0.356348}, - { 't':0.654374, 'u':0.633137, 'v':0.163511} - ] + + expected = {'t': 0.654374, 'u': 0.633137, 'v': 0.163511} @@ -216,11 +520,25 @@ def test_case_13(): S =np.array( [[[7.4968198, -34.44808135, 6.627417], [4.89170045910665, -39.13729615771332, -4.42516829776066], [-0.016883173357102876, -44.594332395950104, 0.8101986397153593]], [[11.989753624247342, -35.42881907275406, 6.6274169999999994], [7.443691937074275, -40.76501466713547, -4.882652796843839], [3.454490070369776, -44.96214894393917, 0.5694145013516874]], [[14.847212913305142, -36.95471497775948, 6.6274169999999994], [9.56529033335222, -42.536197535294875, -4.488016026845241], [5.924649611832621, -46.255670751572566, 0.7771204997432491]], [[16.23843504869012, -39.53348121435317, 6.6274169999999994], [11.830092620085596, -44.58445479257182, -3.241257987764865], [8.72332454261908, -47.47050603984768, -0.38590063418195103]]] ) result = bez_csx(C, S, atol=1e-3, rational=False) - assert (len(result["isolated"]) == 2) and (len(result["overlaps"]) == 0), f"expected 2 isolated intersections, {len(result['isolated'])} found {result}" - for i, inter in enumerate( sorted(result["isolated"], key=lambda x: x["t"])): - assert np.allclose( - [inter[key] for key in ["t", "u", "v"]], - [excepted[i][key] for key in ["t", "u", "v"]]), f"expected {excepted[i]}, got {inter}" + assert len(result["isolated"]) == 1, result + assert result["overlaps"] == [] + inter = result["isolated"][0] + assert np.allclose( + [inter[key] for key in ["t", "u", "v"]], + [expected[key] for key in ["t", "u", "v"]], + ), f"expected {expected}, got {inter}" + + # Exact-set membership and polishing must not depend on a common world + # translation: preserve the real interior root without reviving the + # tolerance-only endpoint near miss. + translated = bez_csx( + C + 1.0e6, S + 1.0e6, atol=1e-3, rational=False) + assert len(translated["isolated"]) == 1, translated + translated_inter = translated["isolated"][0] + assert np.allclose( + [translated_inter[key] for key in ["t", "u", "v"]], + [expected[key] for key in ["t", "u", "v"]], + ), f"expected {expected}, got {translated_inter}" def test_case_14(): @@ -264,6 +582,33 @@ def test_case_14(): f"expected {excepted[i]}, got {inter}" +def test_exact_corner_root_reaches_resolution_before_depth_stop(): + """A known endpoint root must not leave a false partial Phase-2 tail. + + The remaining interval ends one parameter tolerance before the root. + Three subdivision axes need 53 levels to reach the same resolution; + the former depth-50 default stopped just before that certificate. + """ + curve = np.array([ + [-128.25, -129.86, 0.0], + [-128.25, 129.86, 0.0], + ]) + surface = np.array([ + [[-128.25, -129.86, 67.44], [-128.25, 129.86, 0.0]], + [[128.25, -46.98, 0.0], [128.25, 129.86, 0.0]], + ]) + + result = bez_csx(curve, surface, atol=1e-3, rational=False) + + assert not result["budget_exhausted"], result + assert result["boundary_topology_complete"] + assert len(result["isolated"]) == 1 + assert np.allclose( + [result["isolated"][0][key] for key in ("t", "u", "v")], + [1.0, 0.0, 1.0], atol=1e-12, + ) + + def test_case_15(): """spurious _micro near-duplicate — degree-2 curve vs bilinear patch. @@ -483,3 +828,239 @@ def test_case_19_interior_root_not_pruned_by_outside_basin(): assert len(ts) == 2, f"expected 2 isolated roots (t=0.0 and t~0.5356), got {result['isolated']}" assert abs(ts[0] - 0.0) < 1e-3, f"boundary root at t=0 missing: {ts}" assert abs(ts[1] - 0.535593) < 1e-3, f"interior root at t~0.5356 missing: {ts}" + + +def test_bounded_newton_stall_near_tangent_is_not_a_distinct_root(): + """A cutout-wall stall in a tangent valley must polish to one root.""" + eps = 5e-7 + curve = np.array([ + [0.5, -0.125, 0.25], + [0.5 + 1.0 / 3.0, 0.125 + eps / 3.0, -1.0 / 12.0], + [0.5 + 2.0 / 3.0, -0.125 + 2.0 * eps / 3.0, -1.0 / 12.0], + [1.5, 0.125 + eps, 0.25], + ]) + plane = np.array([ + [[-0.5, -1.0, 0.0], [-0.5, 1.0, 0.0]], + [[1.5, -1.0, 0.0], [1.5, 1.0, 0.0]], + ]) + curve_h = np.column_stack([curve, np.ones(len(curve))]) + plane_h = np.concatenate( + [plane, np.ones(plane.shape[:-1] + (1,))], axis=-1) + + result = bez_csx( + curve_h, plane_h, atol=1e-3, rational=True, + max_cells=20_000, max_results=128) + + assert result["budget_exhausted"] is False + assert len(result["isolated"]) == 1 + root = result["isolated"][0] + assert root["t"] == pytest.approx(0.5, abs=1e-7) + assert root["u"] == pytest.approx(0.75, abs=1e-7) + + +def test_curved_uv_exact_overlap_is_certified_complete(): + """Ledger L42 → L59: the curved-UV EXACT overlap's full history. + + Parabola lying exactly on the bilinear z=0 patch: the uv-preimage is + curved, so the exact-AFFINE identity correctly refuses. At 5d05ddc + this flooded Phase 2 (1,679 lattice roots @33,685 cells, claimed + complete); L42 bounded it into an honest typed-partial; the L59 + theorem-first tier now CERTIFIES it — domain-pinned span ends, + roundoff-level witnesses (=> 'exact'), no flips — with no Phase-2 + grind at all. Neither wrong topology nor a partial flag remains. + """ + curve = np.array([[0.2, 0.2, 0.], [1., 1.8, 0.], [1.8, 0.2, 0.]]) + surf = np.array([[[0., 0., 0.], [0., 2., 0.]], + [[2., 0., 0.], [2., 2., 0.]]]) + result = bez_csx(curve, surf, atol=1e-3, rational=False) + + assert result["boundary_topology_complete"] is True + assert result["budget_exhausted"] is False + assert result["isolated"] == [] + assert len(result["overlaps"]) == 1 + o = result["overlaps"][0] + assert o["certification"] == "exact" + assert o["t_range"][0] == pytest.approx(0.0, abs=1e-9) + assert o["t_range"][1] == pytest.approx(1.0, abs=1e-9) + assert result["cells_processed"] <= 1_000 + + +def test_boundary_exhaustion_keeps_certified_roots(monkeypatch): + """Ledger L51: on boundary-phase exhaustion the certified-partial contract + must keep the strictly certified roots already in hand (CCX keeps its + validated hits in the same situation). Dropping them returned + {isolated: [], budget_exhausted: True} with a certified root found and + ~no Phase-2 budget left to re-find it. Partial boundary topology still + must not drive overlap classification, and the result stays flagged.""" + import mmcore.numeric.intersection.csx._bez_csx4 as csx_mod + from mmcore.numeric.intersection._sq_dist_classify import BoundaryZero + + # cubic with its t=0 endpoint exactly ON the plane z=0 at (0.25, 0.25): + # z(t) = t (2 (t - 0.6)^2 + 0.001) — the ONLY root is t=0, and the + # sub-atol valley (6e-4 deep at t=0.6) makes a 1-cell Phase 2 provably + # unable to re-find/exclude anything (sub-atol valleys must be resolved, + # never merged). + def _mono2bern3(a): + from math import comb + return [sum(comb(i, k) / comb(3, k) * a[k] for k in range(i + 1)) + for i in range(4)] + + x = _mono2bern3([0.25, 0.5, 0.0, 0.0]) + y = _mono2bern3([0.25, 0.5, 0.0, 0.0]) + z = _mono2bern3([0.0, 0.721, -2.4, 2.0]) + curve = np.column_stack([x, y, z]) + surf = np.array([[[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 0.0]]]) + + bz = BoundaryZero(axis=0, side=0, param=0.25, param2=0.25) + # the exhausted boundary phase consumed ~all of the allowance: Phase 2 + # has 1 cell left and cannot re-find the discarded root + monkeypatch.setattr( + csx_mod, "_find_csx_boundary_zeros", + lambda *a, **k: ([bz], True, 99)) + + result = bez_csx(curve, surf, atol=1e-3, rational=False, max_cells=100) + + assert result["budget_exhausted"] is True + assert result["boundary_topology_complete"] is False + assert len(result["isolated"]) == 1, result["isolated"] + iso = result["isolated"][0] + assert iso["t"] == pytest.approx(0.0, abs=1e-9) + assert iso["u"] == pytest.approx(0.25, abs=1e-6) + assert iso["v"] == pytest.approx(0.25, abs=1e-6) + assert len(result["overlaps"]) == 0 + + +def test_zero_allowance_preflights_before_net_build(monkeypatch): + """L52 (zero-allowance preflight): bez_csx(max_cells=0) must refuse + BEFORE building the superlinear distance/residual nets — the SSX top + entry has preflighted since L32, but the CSX entry still paid the full + net construction for an allowance it did not have.""" + import mmcore.numeric.intersection.csx._bez_csx4 as csx_mod + + calls = {"n": 0} + orig = csx_mod.curve_surface_distance_squared_net_homog + + def counting(*a, **k): + calls["n"] += 1 + return orig(*a, **k) + + monkeypatch.setattr( + csx_mod, "curve_surface_distance_squared_net_homog", counting) + curve = np.array([[0.25, 0.25, -1.0], [0.75, 0.75, 1.0]]) + surf = np.array([[[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 0.0]]]) + r = bez_csx(curve, surf, atol=1e-3, rational=False, max_cells=0) + assert r["budget_exhausted"] is True + assert r["boundary_topology_complete"] is False + assert r["cells_processed"] == 0 + assert r["isolated"] == [] and r["overlaps"] == [] + assert calls["n"] == 0, "net built despite zero allowance" + + +def test_short_clipped_overlap_span_is_certified(): + """L52 slice 10a → L59: the domain-clipped short span's full history. + + A coincident span of 4.2*ptol_t ended by DOMAIN CLIPPING at u=1 used + to ship as 3 lattice roots claimed complete; slice 10a's lattice- + cluster detection made it a typed uncertified span; the L59 tier now + CERTIFIES it as one tolerance overlap. The span's upper end extends + past the exact-coincidence limit t=0.5 to the within-atol fringe of + the patch edge (~0.58) — tolerance-coincidence semantics (USER + DECISION 2026-07-12).""" + S = np.array([[[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 0.0]]]) + C = np.array([[0.994, 0.5, 0.0], + [1.000, 0.5, 0.0], + [1.006, 0.506, 0.0]]) + + r = bez_csx(C, S, atol=1e-3, rational=False) + + assert r["boundary_topology_complete"] is True + assert r["budget_exhausted"] is False + assert "uncertified_overlap_span" not in r + assert r["isolated"] == [] + assert len(r["overlaps"]) == 1 + o = r["overlaps"][0] + assert o["certification"] == "tolerance" + assert o["t_range"][0] == pytest.approx(0.0, abs=1e-9) + assert 0.5 <= o["t_range"][1] <= 0.62 + assert r["cells_processed"] <= 500 + + +def test_sub_atol_valley_root_chain_is_never_merged_by_the_cluster(): + """Invariant control for the lattice-cluster detection: three strict- + distinct roots connected by SUB-ATOL valleys (depth ~5e-4 between + roots ~0.15 apart at ptol_t ~0.04) form a gap-qualified cluster, but + the STRICT gap-midpoint certificate fails on the valley floors — the + roots must ship isolated with complete topology (never merged into a + span: sub-tolerance topology is preserved, the CSX invariant).""" + S = np.array([[[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 0.0]]]) + # cubic z(t) = 0.25*(t-0.35)(t-0.5)(t-0.65): roots 0.35/0.5/0.65, + # valley depth ~4.3e-4 (sub-atol, far above strict roundoff scale); + # x spans 0.02 so ptol_t ~ 0.045 and the 0.15 gaps qualify (< 4*ptol). + from numpy.polynomial import polynomial as P + coeffs = 0.25 * np.array(P.polyfromroots([0.35, 0.5, 0.65])) + # convert power basis -> Bernstein-3 control values for z(t) + M = np.array([[1.0, 0.0, 0.0, 0.0], + [1.0, 1.0 / 3.0, 0.0, 0.0], + [1.0, 2.0 / 3.0, 1.0 / 3.0, 0.0], + [1.0, 1.0, 1.0, 1.0]]) + zc = M @ coeffs + C = np.column_stack([ + np.array([0.49, 0.4967, 0.5033, 0.51]), # x affine, speed 0.02 + np.full(4, 0.5), + zc]) + + r = bez_csx(C, S, atol=1e-3, rational=False) + + ts = sorted(x["t"] for x in r["isolated"]) + assert len(ts) == 3, r["isolated"] + assert ts == pytest.approx([0.35, 0.5, 0.65], abs=1e-3) + assert r["boundary_topology_complete"] is True + assert "uncertified_overlap_span" not in r + assert r["budget_exhausted"] is False + + +def test_near_band_pair_certifies_cheaply(): + """L60: the worst profiled pair (28,961 cells) certifies cheaply. + + Real user geometry (overlap_nurbs_intersection_3_new): CORRECTED + diagnosis — the curve TOUCHES the patch at its t=0 end (1.6e-9) and + runs sub-atol (2.5e-5..7.7e-4) until the projected path exits through + the u=1 patch edge at t~0.44, then departs. A one-side-pinned + tolerance band whose far end is domain-clipped: under the L59 + theorem-first semantics this is ONE tolerance overlap (the endpoint + touch is the span's own end; end-adjacent sign flips are the endpoint + root, not interior crossing structure). Phase 2 then only scans the + far, empty remainder — with the L60 geometry-aligned exclusion + (scalar Bernstein net dot(G, n_mean), exact linear combination, + L1-margined) handling diagonal-residual cells the axis test cannot + clear until clearance-scale depth.""" + C_NEAR = np.array([ + [92.51428091, 102.781436125, 5.719709275], + [91.27842704, 104.13179098, 6.58025473], + [89.7844795, 105.2243694, 7.46164544], + [88.15200982059918, 105.9854260265158, 8.363880612681537]]) + S_NEAR = np.array([ + [[92.51539241324699, 102.7802214952655, 41.41100531879138], + [92.51539241324699, 102.7802214952655, 0.10498601801243446]], + [[91.96619518531281, 103.38044393393321, 41.41100531879138], + [91.96619518531281, 103.38044393393321, 0.10498601801243446]], + [[91.36636523847328, 103.93038701100394, 41.41100531879138], + [91.36636523847328, 103.93038701100394, 0.10498601801243446]], + [[90.72572752430331, 104.42241106003442, 41.41100531879138], + [90.72572752430331, 104.42241106003442, 0.10498601801243446]]]) + + r = bez_csx(C_NEAR, S_NEAR, atol=1e-3, rational=False) + + assert r["budget_exhausted"] is False + assert r["boundary_topology_complete"] is True + assert r["isolated"] == [] # the t=0 touch is the span's end + assert len(r["overlaps"]) == 1 + o = r["overlaps"][0] + assert o["certification"] == "tolerance" + assert o["t_range"][0] == pytest.approx(0.0, abs=1e-9) + assert 0.40 <= o["t_range"][1] <= 0.50 + assert r["cells_processed"] <= 2_000, r["cells_processed"] diff --git a/tests/test_bez_ssx5_singular.py b/tests/test_bez_ssx5_singular.py index ff7dd7a6..0dedaec7 100644 --- a/tests/test_bez_ssx5_singular.py +++ b/tests/test_bez_ssx5_singular.py @@ -15,6 +15,30 @@ def test_result_has_singularities_key_and_branch_kind(): assert all(b.kind in ("transversal", "tangential", "overlap") for b in r["branches"]) +def test_case11_default_nested_csx_budget_preserves_closed_loop(): + """A sound global budget must not make the historical case 11 partial. + + One internal line/surface cut needs just over 20k CSX cells. The former + per-call default stopped at 20k even though the call-wide SSX allowance + still had more than 200k cells available, discarded the two certified + cut roots, and returned zero branches flagged incomplete. + """ + from examples.ssx.bez_ssx5_case11 import S1, S2 + + # This is deliberately tight enough that paying for a discarded 20k + # attempt and then restarting cannot complete, while one topology-critical + # CSX call with the established allowance finishes the whole SSX solve. + result = bez_ssx( + S1, S2, 1e-3, rational=False, max_cells=60_000) + + assert result["complete"], result["status"] + assert len(result["branches"]) == 1, result + xyz = np.asarray(result["branches"][0].curve[1], dtype=float) + assert np.linalg.norm(xyz[0] - xyz[-1]) <= 2e-3 + assert len(xyz) >= 32 + assert np.linalg.norm(np.diff(xyz, axis=0), axis=1).sum() > 1.0 + + # --------------------------------------------------------------------------- # Task 2: _ssx5_singular.py — nets + zero-dimensional Bernstein solver # --------------------------------------------------------------------------- @@ -723,6 +747,10 @@ def test_param_far_touch_near_overlap_not_subsumed(): # branch passing within 4*atol (xyz) of the touch overlaps = [b for b in r["branches"] if b.kind == "overlap"] assert overlaps, "u=0 overlap branch lost — fixture no longer tests L3" + assert len(overlaps) == 1 + assert len(r["branches"]) == 1, ( + "a regular tracer duplicated a subset of the certified overlap: " + f"{[b.kind for b in r['branches']]}") touch_xyz = np.array([0.5, 0.003, 0.0]) assert min(_pt_poly(touch_xyz, np.asarray(b.curve[1])) for b in overlaps) <= 4e-3 @@ -813,6 +841,8 @@ def _assert_links_nearest_vertex(g, branches): def test_self_intersection_point(): S1, S2 = _umbrella_case() r = bez_ssx(S1, S2, 1e-3, rational=False) + assert r["complete"] is True + assert r["status"]["work"]["cell_counts"].get("c3", 0) > 0 c3 = [g for g in r["singularities"] if g.kind == "self_intersection"] assert len(c3) == 1 g = c3[0] @@ -878,7 +908,7 @@ def test_cusp_curve_on_split_plane_not_knifed_out(): assert [g for g in r["singularities"] if g.kind == "cusp"] == [], \ "cusp curve mistyped as isolated cusp(s)" samples = np.concatenate([np.asarray(g.samples) for g in curves]) - assert len(samples) >= 13 # the >12-sols curve_flag path fired + assert len(samples) >= 13 # every sample on the true singular curve {s = 0.5}, covering most of t assert np.allclose(samples[:, 0], 0.5, atol=1e-6) assert samples[:, 1].max() - samples[:, 1].min() > 0.8 @@ -1501,6 +1531,37 @@ def test_cone_apex_no_phantom_tangent_point(): assert r["branches"][0].kind == "transversal" +def test_small_nonzero_surface_normal_is_not_retyped_as_degenerate(): + """A poorly conditioned but regular parametrization is still regular.""" + from mmcore.numeric.monomial import monomial_to_bezier + from mmcore.numeric.intersection.ssx._bez_ssx5 import ( + _normals_degenerate_at) + + eps = 5e-7 + mono = np.zeros((3, 4, 3), dtype=np.float64) + mono[1, 0, 0] = 1.0 + mono[0, 1, 0] = 1.0 # x = s + t + mono[0, 0, 1] = -0.125 + mono[0, 1, 1] = 0.75 + eps + mono[0, 2, 1] = -1.5 + mono[0, 3, 1] = 1.0 # y = (t-.5)^3 + eps*t + mono[2, 0, 2] = 1.0 + mono[1, 0, 2] = -1.0 + mono[0, 2, 2] = 1.0 + mono[0, 1, 2] = -1.0 + mono[0, 0, 2] = 0.5 # z = (s-.5)^2 + (t-.5)^2 + surface = _homog(monomial_to_bezier(mono)) + plane = _homog(np.array([ + [[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]], + ])) + + _, ds, dt = eval_surface_d1(surface, 0.5, 0.5, rational=True) + assert np.linalg.norm(np.cross(ds, dt)) == pytest.approx(eps) + assert not _normals_degenerate_at( + surface, plane, np.full(4, 0.5)) + + def test_short_cusp_curve_typed_as_curve_not_isolated(): # Ledger L14 regression: a cusp curve clipped to t-extent 0.2 (200x # resolution) yielded 11 xyz-dedup-sparse solutions — under the raw @@ -1654,3 +1715,1648 @@ def test_shared_edges_junction_tangent_point(flatten_s2): assert (ends[0] == want_AB and ends[1] == want_AC) or \ (ends[0] == want_AC and ends[1] == want_AB) assert r["points"] == [] + + +# --------------------------------------------------------------------------- +# Ledger L30 / L26: rational cone tangent branch and no-hang budgets +# --------------------------------------------------------------------------- + +def test_rational_delta_witness_evaluates_true_surfaces(): + """The Delta refiner must not use per-control-point dehomogenization. + + On case 14's exact rational cone generator, the false polynomial + ``P_i / w_i`` surfaces miss by hundreds of ``atol``. The rational + witness must converge on the true homogeneous quotient surfaces. + """ + from examples.ssx.bez_ssx5_case14 import S1, S2 + from mmcore.numeric.intersection.ssx._bez_ssx5 import _delta_float_gn + from mmcore.numeric.intersection.ssx._ssx5_singular import minors_Tpsi_rational + + T = minors_Tpsi_rational(S1, S2) + gn, _ = _delta_float_gn(*T, S1, S2, rational=True, atol=1e-3) + root = gn(np.full(4, 0.5)) + assert root is not None + p1 = eval_surface(S1, root[0], root[1], rational=True) + p2 = eval_surface(S2, root[2], root[3], rational=True) + assert np.linalg.norm(p1 - p2) < 1e-7 + assert abs(root[0] - 0.251168868) < 1e-5 + assert abs(root[2] - 0.109103476) < 1e-5 + + P1_wrong = S1[..., :-1] / S1[..., -1:] + P2_wrong = S2[..., :-1] / S2[..., -1:] + wrong_res = np.linalg.norm( + eval_surface(P1_wrong, root[0], root[1], rational=False) + - eval_surface(P2_wrong, root[2], root[3], rational=False)) + assert wrong_res > 0.1 + + +def test_rational_delta_scaling_does_not_accept_physical_gap(): + """Conditioning scale must not become the physical root tolerance.""" + span = 1.0e9 + p1 = np.array([ + [[0.0, 0.0, 0.0], [0.0, span, 0.0]], + [[span, 0.0, 0.0], [span, span, 0.0]], + ]) + p2 = p1.copy() + p2[..., 2] = 1.0e-2 + S1 = np.concatenate([p1, np.ones((2, 2, 1))], axis=-1) + S2 = np.concatenate([p2, np.ones((2, 2, 1))], axis=-1) + zero_minor = np.zeros((1, 1, 1, 1), dtype=np.float64) + + from mmcore.numeric.intersection.ssx._bez_ssx5 import _delta_float_gn + gn, _ = _delta_float_gn( + zero_minor, zero_minor, zero_minor, zero_minor, + S1, S2, rational=True, atol=1e-3) + assert gn(np.full(4, 0.5)) is None + + +def test_rational_delta_scaling_does_not_retype_transversal_loop(): + """A large remote T coefficient cannot hide physical normal mismatch.""" + from scipy.signal import convolve2d + from mmcore.numeric.monomial import monomial_to_bezier + from mmcore.numeric.intersection._deflate import ( + minors_Tpsi_from_control_nets) + from mmcore.numeric.intersection.ssx._bez_ssx5 import ( + _check_tangency, _delta_float_gn) + + radius, scale = 0.2, 1.0e7 + delta = 0.01 / scale + q = np.zeros((3, 3)) + q[2, 0] = 1.0 + q[1, 0] = -2.0 * (0.5 + radius) + q[0, 0] = (0.5 + radius) ** 2 + 0.25 - radius ** 2 + q[0, 2] = 1.0 + q[0, 1] = -1.0 + h = np.zeros((3, 3)) + h[2, 0] = 1.0 + h[1, 0] = -1.0 + h[0, 0] = 0.5 + delta + h[0, 2] = 1.0 + h[0, 1] = -1.0 + f = scale * convolve2d(q, h) + + m1 = np.zeros((5, 5, 3)) + m1[1, 0, 0] = 1.0 + m1[0, 1, 1] = 1.0 + m1[..., 2] = f + m2 = np.zeros((5, 5, 3)) + m2[1, 0, 0] = 1.0 + m2[0, 1, 1] = 1.0 + p1, p2 = monomial_to_bezier(m1), monomial_to_bezier(m2) + s1 = np.concatenate([p1, np.ones(p1.shape[:2] + (1,))], axis=-1) + s2 = np.concatenate([p2, np.ones(p2.shape[:2] + (1,))], axis=-1) + T = minors_Tpsi_from_control_nets(p1.tolist(), p2.tolist()) + x = np.full(4, 0.5) + + _, du1, dv1 = eval_surface_d1(s1, x[0], x[1], rational=True) + _, du2, dv2 = eval_surface_d1(s2, x[2], x[3], rational=True) + n1, n2 = np.cross(du1, dv1), np.cross(du2, dv2) + sin_angle = float(np.linalg.norm(np.cross(n1, n2)) + / (np.linalg.norm(n1) * np.linalg.norm(n2))) + assert sin_angle > 3e-3 + + gn, _ = _delta_float_gn( + *T, s1, s2, rational=True, atol=1e-3) + assert gn(x) is None + assert _check_tangency( + *T, s1, s2, ((0.0, 1.0),) * 4, + rational=True, atol=1e-3) is not True + + +def test_rational_minor_fast_path_requires_exact_uniform_weights(): + from mmcore.numeric.intersection.ssx._bez_ssx5 import ( + _weight_net_uniform) + + surface = np.zeros((2, 2, 4), dtype=np.float64) + surface[..., -1] = 3.0 + assert _weight_net_uniform(surface) + surface[1, 1, -1] += 5e-13 + assert not _weight_net_uniform(surface) + + +def test_transversal_branch_between_two_collapsed_rational_fibers_is_preserved(): + """Collapsed endpoint fibers must still seed a regular interior branch. + + Each surface below is a rational quadratic cone wedge with one collapsed + boundary edge. The two wedges meet transversally along the z axis, but + both ends of that SSI are represented by positive-dimensional boundary + parameter fibers. Treating fibers only as C2/tangency metadata drops the + entire regular branch. + """ + a = np.sqrt(0.5) + weights = np.array([[1.0, 1.0], [a, a], [1.0, 1.0]]) + A = np.array([0.0, 0.0, 0.0]) + B = np.array([0.0, 0.0, 1.0]) + + s1_euclidean = np.empty((3, 2, 3), dtype=np.float64) + s1_euclidean[:, 0, :] = A + s1_euclidean[:, 1, :] = np.array([ + B + [1.0, 0.0, 0.0], B, B - [1.0, 0.0, 0.0], + ]) + s2_euclidean = np.empty((3, 2, 3), dtype=np.float64) + s2_euclidean[:, 0, :] = B + s2_euclidean[:, 1, :] = np.array([ + A + [0.0, 1.0, 0.0], A, A - [0.0, 1.0, 0.0], + ]) + + S1 = np.concatenate( + [s1_euclidean * weights[..., None], weights[..., None]], axis=-1) + S2 = np.concatenate( + [s2_euclidean * weights[..., None], weights[..., None]], axis=-1) + + r = bez_ssx( + S1, S2, 1e-3, rational=True, + max_cells=60_000, max_csx_calls=2_000, + ) + assert r["complete"] is False + assert r["status"]["reasons"] + assert r["status"]["work"]["cells_processed"] <= 60_000 + assert len(r["branches"]) == 1 + assert r["points"] == [] + assert [g for g in r["singularities"] + if g.kind == "tangent_point"] == [] + regular = [b for b in r["branches"] if b.kind == "transversal"] + assert len(regular) == 1 + stuv = np.asarray(regular[0].curve[0]) + xyz = np.asarray(regular[0].curve[1]) + expected_ends = np.array([ + [0.5, 0.0, 0.5, 1.0], + [0.5, 1.0, 0.5, 0.0], + ]) + direct = max(np.linalg.norm(stuv[0] - expected_ends[0]), + np.linalg.norm(stuv[-1] - expected_ends[1])) + reverse = max(np.linalg.norm(stuv[0] - expected_ends[1]), + np.linalg.norm(stuv[-1] - expected_ends[0])) + assert min(direct, reverse) <= 2e-3 + assert np.linalg.norm(xyz[-1] - xyz[0]) > 0.99 + assert np.linalg.norm(np.diff(xyz, axis=0), axis=1).sum() > 0.99 + for q in np.linspace(0.0, 1.0, 21): + assert _pt_poly(np.array([0.0, 0.0, q]), xyz) <= 5e-3 + + interior_sines = [] + for i, x in enumerate(stuv): + p1 = eval_surface(S1, x[0], x[1], rational=True) + p2 = eval_surface(S2, x[2], x[3], rational=True) + assert np.linalg.norm(p1 - p2) <= 2e-3 + if 0 < i < len(stuv) - 1: + _, ds1, dt1 = eval_surface_d1( + S1, x[0], x[1], rational=True) + _, ds2, dt2 = eval_surface_d1( + S2, x[2], x[3], rational=True) + n1, n2 = np.cross(ds1, dt1), np.cross(ds2, dt2) + interior_sines.append( + np.linalg.norm(np.cross(n1, n2)) + / (np.linalg.norm(n1) * np.linalg.norm(n2))) + assert interior_sines and min(interior_sines) >= 0.9 + + swapped = bez_ssx( + S2, S1, 1e-3, rational=True, + max_cells=60_000, max_csx_calls=2_000, + ) + swapped_regular = [ + b for b in swapped["branches"] if b.kind == "transversal"] + assert len(swapped_regular) == 1 + swapped_xyz = np.asarray(swapped_regular[0].curve[1]) + for q in np.linspace(0.0, 1.0, 21): + assert _pt_poly(np.array([0.0, 0.0, q]), swapped_xyz) <= 5e-3 + + +def test_case14_rational_cones_return_certified_branch_and_explicit_partial_status(): + from examples.ssx.bez_ssx5_case14 import S1, S2 + + r = bez_ssx( + S1, S2, 1e-3, rational=True, + max_cells=60_000, max_csx_calls=2_000, + ) + # The tangent generator is certified, but the positive-dimensional + # Delta complement search cannot prove that no additional isolated root + # exists before its local frontier cap. That is useful partial output, + # never a complete topology claim. + assert r["complete"] is False + assert r["status"]["work"]["cells_processed"] <= 60_000 + assert len(r["branches"]) == 1 + assert [g for g in r["singularities"] + if g.kind == "tangent_point"] == [] + branch = r["branches"][0] + assert branch.kind == "tangential" + xyz = np.asarray(branch.curve[1]) + + # Ground truth is the shared cone generator between the two apices. + a = eval_surface(S1, 0.251168868, 0.0, rational=True) + b = eval_surface(S1, 0.251168868, 1.0, rational=True) + for q in np.linspace(0.0, 1.0, 21): + assert _pt_poly((1.0 - q) * a + q * b, xyz) <= 5e-3 + + stuv = np.asarray(branch.curve[0]) + for x in stuv: + p1 = eval_surface(S1, x[0], x[1], rational=True) + p2 = eval_surface(S2, x[2], x[3], rational=True) + assert np.linalg.norm(p1 - p2) <= 2e-3 + + +def test_case14_tangent_generator_is_homogeneous_scale_invariant(): + """Independent common weight factors cannot change rational geometry.""" + from examples.ssx.bez_ssx5_case14 import S1, S2 + + r = bez_ssx( + S1 * 1e-6, S2 * 1e6, 1e-3, rational=True, + max_cells=60_000, max_csx_calls=2_000, + ) + tangential = [b for b in r["branches"] if b.kind == "tangential"] + assert len(tangential) == 1 + assert [g for g in r["singularities"] + if g.kind == "tangent_point"] == [] + for x in np.asarray(tangential[0].curve[0]): + p1 = eval_surface(S1, x[0], x[1], rational=True) + p2 = eval_surface(S2, x[2], x[3], rational=True) + assert np.linalg.norm(p1 - p2) <= 2e-3 + xyz = np.asarray(tangential[0].curve[1]) + a = eval_surface(S1, 0.251168868, 0.0, rational=True) + b = eval_surface(S1, 0.251168868, 1.0, rational=True) + for q in np.linspace(0.0, 1.0, 21): + assert _pt_poly((1.0 - q) * a + q * b, xyz) <= 5e-3 + + +def test_case13_rational_tangency_terminates_with_explicit_partial_status(): + """A deduplicated tangency must not re-run Phi seeding per descendant.""" + from examples.ssx.bez_ssx5_case13 import S1, S2 + + r = bez_ssx( + S1, S2, 1e-3, rational=True, + max_cells=30_000, max_csx_calls=2_000, + ) + + # A residual near-tangent cell reaches a depth/CSX uncertainty frontier. + # The point below is certified, but absence of more topology is not. + assert r["complete"] is False + assert r["status"]["work"]["cells_processed"] <= 30_000 + assert r["branches"] == [] and r["points"] == [] + tangencies = [g for g in r["singularities"] + if g.kind == "tangent_point"] + assert len(tangencies) == 1 + x = np.asarray(tangencies[0].stuv) + assert np.allclose( + x, [0.3066527232, 0.3043305321, 0.3786701630, 0.3198906377], + atol=2e-3) + p1 = eval_surface(S1, x[0], x[1], rational=True) + p2 = eval_surface(S2, x[2], x[3], rational=True) + assert np.linalg.norm(p1 - p2) <= 2e-3 + assert np.allclose(p1, [4.639676354, -3.932548333, 0.295239623], + atol=2e-3) + _, du1, dv1 = eval_surface_d1(S1, x[0], x[1], rational=True) + _, du2, dv2 = eval_surface_d1(S2, x[2], x[3], rational=True) + n1, n2 = np.cross(du1, dv1), np.cross(du2, dv2) + sin_angle = float(np.linalg.norm(np.cross(n1, n2)) + / (np.linalg.norm(n1) * np.linalg.norm(n2))) + assert sin_angle <= 1e-6 + + +def test_bez_ssx_global_soft_budget_returns_partial_result(monkeypatch): + import mmcore.numeric.intersection.ssx._bez_ssx5 as ssx5 + + s1 = np.array([[[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]]]) + s2 = np.array([[[0., 0., -1.], [0., 1., 1.]], + [[1., 0., -1.], [1., 1., 1.]]]) + def forbidden(*_args, **_kwargs): + pytest.fail("the 4-D distance net was built with zero allowance") + + monkeypatch.setattr( + ssx5, "surface_surface_distance_squared_net_homog", forbidden) + r = ssx5.bez_ssx( + s1, s2, 1e-3, rational=False, + max_cells=0, max_csx_calls=8, + ) + assert r["complete"] is False + assert r["status"]["reasons"] == ["work_budget"] + assert set(("branches", "points", "singularities")) <= set(r) + + +def test_bez_ssx_preflights_distance_net_work(monkeypatch): + """A tiny global allowance cannot start a superlinear 4-D net build.""" + import mmcore.numeric.intersection.ssx._bez_ssx5 as ssx5 + + surface = np.zeros((8, 8, 3), dtype=np.float64) + surface[..., 0] = np.linspace(0.0, 1.0, 8)[:, None] + surface[..., 1] = np.linspace(0.0, 1.0, 8)[None, :] + + def forbidden(*_args, **_kwargs): + pytest.fail("distance-net construction bypassed its preflight charge") + + monkeypatch.setattr( + ssx5, "surface_surface_distance_squared_net_homog", forbidden) + result = ssx5.bez_ssx( + surface, surface, 1e-3, rational=False, + max_cells=1, max_csx_calls=1, + ) + assert result["complete"] is False + assert result["status"]["reasons"] == ["work_budget"] + assert result["status"]["work"]["cells_processed"] == 0 + + +def test_bez_ssx_output_budget_bounds_postprocessing(): + """A finite cell queue must not feed unbounded quadratic assembly.""" + s1 = np.array([[[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]]]) + s2 = np.array([[[0., 0., -1.], [0., 1., 1.]], + [[1., 0., -1.], [1., 1., 1.]]]) + r = bez_ssx( + s1, s2, 1e-3, rational=False, + max_output_items=0, + ) + assert r["complete"] is False + assert "output_cap" in r["status"]["reasons"] + assert r["status"]["work"]["output_items"] == 0 + assert r["branches"] == [] and r["points"] == [] + + +def test_zero_postprocess_budget_skips_all_postassembly_scans(): + """Certified overlaps may survive, but junction/filter scans may not run.""" + s1, s2 = _shared_edge_pair(False) + result = bez_ssx( + s1, s2, 1e-3, rational=False, + max_postprocess_work=0, + ) + + work = result["status"]["work"] + assert result["complete"] is False + assert "postprocess_cap" in result["status"]["reasons"] + assert work["postprocess_work"] == 0 + assert result["singularities"] == [] + assert result["points"] == [] + assert result["branches"] + assert all(branch.kind == "overlap" for branch in result["branches"]) + + +def test_bez_ssx_depth_cap_reports_unresolved_partial_result(): + """A depth limit is a soft stop, not a proof that an empty cell is done.""" + from examples.ssx.bez_ssx5_coverage_check import load_case_surfaces + + s1, s2, rational = load_case_surfaces(7) + r = bez_ssx( + s1, s2, 1e-3, rational=rational, + max_depth=0, max_cells=20_000, max_csx_calls=100, + ) + assert r["complete"] is False + assert "depth_limit" in r["status"]["reasons"] + + +def test_bez_ssx_surfaces_c1_local_truncation(monkeypatch): + """A locally capped C1 enumeration must not masquerade as complete.""" + import mmcore.numeric.intersection.ssx._ssx5_singular as singular + + s1 = np.array([[[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]]]) + s2 = np.array([[[0., 0., -1.], [0., 1., 1.]], + [[1., 0., -1.], [1., 1., 1.]]]) + + def fake_c1(*_args, stats=None, **_kwargs): + stats.update(budget_exhausted=True, + external_budget_exhausted=False, + incomplete=False) + return [], False + + monkeypatch.setattr(singular, "c1_pass", fake_c1) + r = bez_ssx(s1, s2, 1e-3, rational=False) + assert r["complete"] is False + assert "work_budget" in r["status"]["reasons"] + + +def test_phi_seed_local_truncation_marks_shared_budget(monkeypatch): + """Missing a capped Phi slice can omit a whole closed component.""" + from types import SimpleNamespace + import mmcore.numeric.intersection.ssx._bez_ssx5 as ssx + import mmcore.numeric.intersection.ssx._ssx5_singular as singular + + surface = _homog(np.array( + [[[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]]])) + budget = ssx._SSXSoftBudget(max_cells=100, max_csx_calls=10) + zero = np.zeros((1, 1, 1, 1), dtype=np.float64) + cell = SimpleNamespace( + g1=SimpleNamespace(surface=surface), + g2=SimpleNamespace(surface=surface), + T1=zero, T2=zero, T3=zero, T4=zero, + work_budget=budget, + ) + + monkeypatch.setattr( + ssx, "_choose_phi_equations", + lambda *_a, **_k: [((0, 1), 0)]) + + def fake_phi(*_args, stats=None, **_kwargs): + if stats is not None: + stats.update(budget_exhausted=True, + external_budget_exhausted=False) + return [] + + monkeypatch.setattr(singular, "phi_loop_seeds", fake_phi) + assert ssx._phi_slice_loop_fragments( + cell, [np.full(4, 0.5)], 1e-3, 0.1, []) == [] + assert budget.incomplete is True and budget.exhausted is False + assert budget.result_fields()["complete"] is False + assert "unresolved_tangential_zone" in budget.reasons + + +def test_deflate_tangent_cell_does_not_march_after_shared_budget_exhaustion( + monkeypatch): + from types import SimpleNamespace + import mmcore.numeric.intersection.ssx._bez_ssx5 as ssx5 + + surface = _homog(np.array([ + [[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]], + ])) + crossings = [ + ssx5.BoundaryPoint(np.zeros(4), np.zeros(3), (0, 0)), + ssx5.BoundaryPoint( + np.ones(4), np.array([1., 1., 0.]), (0, 1)), + ] + budget = ssx5._SSXSoftBudget(max_cells=0, max_csx_calls=1) + cell = SimpleNamespace( + g1=SimpleNamespace(surface=surface), + g2=SimpleNamespace(surface=surface), + work_budget=budget, + ) + monkeypatch.setattr( + ssx5, "_pair_crossings_for_tracing", + lambda *_a, **_k: ([(0, 1)], [])) + + def forbidden(*_args, **_kwargs): + pytest.fail("a tangent marcher ran after hard budget exhaustion") + + monkeypatch.setattr(ssx5, "_march_intersection_curve", forbidden) + monkeypatch.setattr(ssx5, "_march_phi_curve", forbidden) + fragments, points = ssx5._deflate_tangent_cell( + surface, surface, 0., 0., 0., 0., ((0., 1.),) * 4, + crossings, 1e-3, cell=cell, originals=crossings) + + assert fragments == [] and points == [] + assert budget.exhausted and budget.incomplete + assert budget.cell_counts.get("singular_trace", 0) == 0 + + +def test_registration_trace_stops_before_march_when_shared_budget_is_empty( + monkeypatch): + from types import SimpleNamespace + import mmcore.numeric.intersection.ssx._bez_ssx5 as ssx5 + + surface = _homog(np.array([ + [[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]], + ])) + crossings = [ + ssx5.BoundaryPoint( + np.array([0., 0., 0., 0.]), np.array([0., 0., 0.]), (0, 0)), + ssx5.BoundaryPoint( + np.array([1., 1., 1., 1.]), np.array([1., 1., 0.]), (0, 1)), + ] + budget = ssx5._SSXSoftBudget(max_cells=0, max_csx_calls=1) + cell = SimpleNamespace( + g1=SimpleNamespace(surface=surface), + g2=SimpleNamespace(surface=surface), + crossings=crossings, + box=((0., 1.),) * 4, + work_budget=budget, + ) + + def forbidden(*_args, **_kwargs): + pytest.fail("registration tracing ran after hard budget exhaustion") + + monkeypatch.setattr(ssx5, "_march_to_boundary", forbidden) + fragments, points = ssx5._trace_cell_by_registrations( + cell, atol=1e-3) + + assert fragments == [] and points == [] + assert budget.exhausted and budget.incomplete + assert budget.cell_counts.get("branch_trace", 0) == 0 + + +def test_fragment_dedup_stops_when_postprocess_budget_is_empty(): + import mmcore.numeric.intersection.ssx._bez_ssx5 as ssx5 + + xyz = np.column_stack([ + np.linspace(0., 1., 1000), np.zeros(1000), np.zeros(1000)]) + stuv = np.column_stack([ + np.linspace(0., 1., 1000), np.zeros((1000, 3))]) + fragments = [ + ssx5._Fragment(None, None, stuv.copy(), xyz.copy()), + ssx5._Fragment(None, None, stuv.copy(), xyz.copy()), + ] + budget = ssx5._SSXSoftBudget(max_cells=0, max_csx_calls=1) + + kept = ssx5._drop_duplicate_fragments( + fragments, 1e-3, work_budget=budget) + + # Unknown containment is never used to delete a certified fragment. + assert kept == fragments + assert budget.exhausted and budget.incomplete + assert budget.cell_counts.get("assembly", 0) == 0 + + +def test_assembly_does_not_close_or_scan_pairs_after_budget_denial(monkeypatch): + import mmcore.numeric.intersection.ssx._bez_ssx5 as ssx5 + + stuv = np.array([ + [0.20, 0.20, 0.20, 0.20], + [0.80, 0.20, 0.80, 0.20], + [0.80, 0.80, 0.80, 0.80], + [0.21, 0.21, 0.21, 0.21], + ]) + xyz = np.array([ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [1.0, 1.0, 0.0], + [0.01, 0.01, 0.0], + ]) + fragment = ssx5._Fragment(None, None, stuv, xyz) + surface = _homog(np.array([ + [[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]], + ])) + budget = ssx5._SSXSoftBudget(max_cells=0, max_csx_calls=1) + + def forbidden(*_args, **_kwargs): + pytest.fail("closing march ran without shared-budget allowance") + + monkeypatch.setattr(ssx5, "_march_intersection_curve", forbidden) + branches = ssx5._assemble_fragments( + [fragment], S1_full=surface, S2_full=surface, + atol_full=1e-3, rational_full=True, work_budget=budget) + + assert len(branches) <= 1 + assert budget.exhausted and budget.incomplete + assert budget.cell_counts.get("assembly", 0) == 0 + + +def test_assembly_pair_join_stops_at_postprocess_budget(): + import mmcore.numeric.intersection.ssx._bez_ssx5 as ssx5 + + fragments = [] + for i in range(40): + x0 = 0.1 + 0.01 * i + xyz = np.array([[x0, 0., 0.], [x0 + 0.02, 0., 0.]]) + stuv = np.array([ + [0.2 + 1e-4 * i] * 4, + [0.3 + 1e-4 * i] * 4, + ]) + fragments.append(ssx5._Fragment(None, None, stuv, xyz)) + budget = ssx5._SSXSoftBudget( + max_cells=100, max_csx_calls=1, max_postprocess_work=8) + + branches = ssx5._assemble_fragments( + fragments, atol_full=1e-3, unify_tol=None, + work_budget=budget) + + assert branches + assert budget.postprocess_work <= 8 + assert budget.postprocess_exhausted + assert budget.result_fields()["complete"] is False + + +def test_phi_slice_closed_march_stops_on_external_budget_exhaustion( + monkeypatch): + from types import SimpleNamespace + import mmcore.numeric.intersection.ssx._bez_ssx5 as ssx5 + import mmcore.numeric.intersection.ssx._ssx5_singular as singular + + surface = _homog(np.array([ + [[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]], + ])) + budget = ssx5._SSXSoftBudget(max_cells=0, max_csx_calls=1) + cell = SimpleNamespace( + g1=SimpleNamespace(surface=surface), + g2=SimpleNamespace(surface=surface), + T1=0., T2=0., T3=0., T4=0., work_budget=budget, + ) + monkeypatch.setattr( + ssx5, "_choose_phi_equations", + lambda *_a, **_k: [((0, 1), 0)]) + + def exhausted_seed_search( + *_args, charge_box=None, stats=None, **_kwargs): + assert charge_box(1) is False + stats.update( + budget_exhausted=True, external_budget_exhausted=True) + return [np.full(4, 0.25)] + + monkeypatch.setattr( + singular, "phi_loop_seeds", exhausted_seed_search) + + def forbidden(*_args, **_kwargs): + pytest.fail("continuation work ran after external budget denial") + + monkeypatch.setattr(ssx5, "_ssx_correct", forbidden) + monkeypatch.setattr(ssx5, "_march_psi_closed", forbidden) + monkeypatch.setattr(ssx5, "_march_phi_closed", forbidden) + fragments = ssx5._phi_slice_loop_fragments( + cell, [np.full(4, 0.25)], 1e-3, 0.1, []) + + assert fragments == [] + assert budget.exhausted and budget.incomplete + + +def test_tangent_marchers_report_zero_iterations_on_initial_failure( + monkeypatch): + import mmcore.numeric.intersection.ssx._bez_ssx5 as ssx5 + + surface = _homog(np.array([ + [[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]], + ])) + monkeypatch.setattr( + ssx5, "_ssx_tangent_4d", + lambda *_a, **_k: (None, None, None)) + stats = {"iterations": 99} + path, _ = ssx5._march_intersection_curve( + surface, surface, np.zeros(4), np.ones(4), + max_points=7, stats=stats) + assert len(path) == 1 and stats["iterations"] == 0 + + monkeypatch.setattr(ssx5, "_jac_phi", lambda *_a, **_k: np.eye(4)) + stats = {"iterations": 99} + path, _ = ssx5._march_phi_curve( + surface, surface, np.zeros((1, 1, 1, 1, 1)), (0, 1), + np.zeros(4), np.ones(4), max_points=7, stats=stats) + assert len(path) == 1 and stats["iterations"] == 0 + + +def test_delta_local_dimension_requires_rank_and_two_sided_continuation(): + from mmcore.numeric.intersection.ssx._bez_ssx5 import ( + _delta_root_local_dimension) + + class FakeGN: + roundoff_scale = 1.0 + + def __init__(self, jacobian, mode): + self.jacobian = lambda _x: np.asarray(jacobian, dtype=float) + self.mode = mode + + def __call__(self, seed): + if self.mode == "line": + return np.asarray(seed, dtype=float) + return np.full(4, 0.5) + + @staticmethod + def physical_residual(_x): + return 0.0 + + @staticmethod + def residual(_x): + return np.zeros(7) + + root = np.full(4, 0.5) + ptol = np.full(4, 1e-3) + full_rank = FakeGN(np.eye(4), "isolated") + assert _delta_root_local_dimension(full_rank, root, ptol) is False + + rank3 = np.diag([1.0, 1.0, 1.0, 0.0]) + regular_curve = FakeGN(rank3, "line") + charged = [] + assert _delta_root_local_dimension( + regular_curve, root, ptol, + charge_work=lambda n: charged.append(n) or True) is True + assert charged == [24, 24] + + singular_isolated = FakeGN(rank3, "isolated") + assert _delta_root_local_dimension( + singular_isolated, root, ptol) is None + assert _delta_root_local_dimension( + regular_curve, root, ptol, + charge_work=lambda _n: False) is None + + +def test_offcurve_delta_local_truncation_marks_shared_budget(monkeypatch): + """Zero or one roots from a capped Delta solve are still only partial.""" + from types import SimpleNamespace + import mmcore.numeric.intersection.ssx._bez_ssx5 as ssx + import mmcore.numeric.intersection.ssx._ssx5_singular as singular + + surface = _homog(np.array( + [[[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]]])) + budget = ssx._SSXSoftBudget(max_cells=100, max_csx_calls=10) + zero = np.zeros((1, 1, 1, 1), dtype=np.float64) + cell = SimpleNamespace( + g1=SimpleNamespace(surface=surface), + g2=SimpleNamespace(surface=surface), + T1=zero, T2=zero, T3=zero, T4=zero, + F_sq=None, w_scale=1.0, + box=((0.0, 1.0),) * 4, + work_budget=budget, + ) + + def fake_delta(*_args, **_kwargs): + return (lambda _x: None), np.zeros((1, 1, 1, 1, 4)) + + def fake_solve(*_args, **_kwargs): + assert _kwargs["max_results"] == 64 + return [], True + + monkeypatch.setattr(ssx, "_delta_float_gn", fake_delta) + monkeypatch.setattr(singular, "solve_zero_dim", fake_solve) + ssx._emit_offcurve_tangent_roots( + cell, [], 1e-3, np.full(4, 4e-3), [], max_cells=2) + assert budget.incomplete is True and budget.exhausted is False + assert budget.result_fields()["complete"] is False + + +def test_primary_delta_witness_local_truncation_marks_shared_budget(monkeypatch): + """A capped one-root tangency witness is valid partial evidence only.""" + from types import SimpleNamespace + import mmcore.numeric.intersection.ssx._bez_ssx5 as ssx + + surface = _homog(np.array( + [[[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]]])) + budget = ssx._SSXSoftBudget(max_cells=100, max_csx_calls=10) + zero = np.zeros((1, 1, 1, 1), dtype=np.float64) + cell = SimpleNamespace( + g1=SimpleNamespace(surface=surface), + g2=SimpleNamespace(surface=surface), + T1=zero, T2=zero, T3=zero, T4=zero, + box=((0.0, 1.0),) * 4, + work_budget=budget, + ) + + monkeypatch.setattr( + ssx, "_tangency_witness", + lambda *_a, **_k: (True, [np.full(4, 0.5)], None, True)) + ssx._emit_tangent_roots( + cell, 1e-3, np.full(4, 4e-3), [], enumerate_all=True) + assert budget.incomplete is True and budget.exhausted is False + assert budget.result_fields()["complete"] is False + + +def test_solve_zero_dim_result_cap_stops_positive_dimensional_flood(): + net = BoxNet( + np.zeros((2, 2, 2, 2, 1), dtype=np.float64), + axes=(0, 1, 2, 3)) + stats = {} + sols, exhausted = solve_zero_dim( + [net], lambda x: np.asarray(x), ptol=np.full(4, 1e-6), + max_cells=10_000, max_results=5, stats=stats) + assert len(sols) == 5 and exhausted + assert stats["result_limit_reached"] is True + assert stats["boxes_processed"] < 100 + + +def test_c3_pair_search_has_a_local_soft_budget(): + """The vectorized broadphase must not materialize an unbounded M^2 set.""" + from types import SimpleNamespace + from mmcore.numeric.intersection.ssx._ssx5_singular import c3_pass + + surface = _homog(np.array( + [[[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]]])) + xyz = np.column_stack([ + np.linspace(0.0, 1.0, 5), + np.zeros(5), + np.zeros(5), + ]) + stuv = np.column_stack([ + np.linspace(0.0, 1.0, 5), + np.zeros(5), + np.linspace(0.0, 1.0, 5), + np.zeros(5), + ]) + branches = [SimpleNamespace(curve=(stuv, xyz)), + SimpleNamespace(curve=(stuv, xyz))] + stats = {} + hits = c3_pass( + surface, surface, branches, atol=1e-3, ptol4=np.full(4, 1e-3), + max_work=3, stats=stats) + assert hits == [] + assert stats["budget_exhausted"] is True + assert stats["pairs_processed"] <= 3 + + +def test_ssx_point_dedup_uses_bounded_spatial_buckets(): + """Final duplicate cleanup must scale with points, not all point pairs.""" + from mmcore.numeric.intersection.ssx._ssx4 import SSXPoint + from mmcore.numeric.intersection.ssx._bez_ssx5 import ( + _deduplicate_ssx_points) + + points = [] + for i in range(200): + stuv = np.array([10.0 * i, 0.0, 0.0, 0.0]) + xyz = np.array([10.0 * i, 0.0, 0.0]) + points.append(SSXPoint(stuv=stuv, xyz=xyz)) + points.append(SSXPoint( + stuv=stuv + np.array([5e-4, 0.0, 0.0, 0.0]), + xyz=xyz + np.array([5e-4, 0.0, 0.0]))) + + stats = {} + unique = _deduplicate_ssx_points( + points, unify_tol=np.full(4, 1e-3), atol=1e-3, stats=stats) + assert len(unique) == 200 + assert stats["comparisons"] < 4 * len(points) + assert stats["bucket_probes"] < 120 * len(points) + + +def test_solve_zero_dim_shared_budget_denial_stops_before_box(): + net = BoxNet( + np.zeros((2, 2, 2, 2, 1), dtype=np.float64), + axes=(0, 1, 2, 3)) + calls = {"charge": 0, "newton": 0} + + def charge_box(units=1): + calls["charge"] += units + return calls["charge"] <= 3 + + def newton(_x0): + calls["newton"] += 1 + return None + + stats = {} + sols, exhausted = solve_zero_dim( + [net], newton, ptol=np.full(4, 1e-3), max_cells=100, + charge_box=charge_box, stats=stats) + assert sols == [] and exhausted + assert calls == {"charge": 4, "newton": 3} + assert stats["boxes_processed"] == 3 + assert stats["external_budget_exhausted"] + + +def test_c1_pass_fair_shares_one_budget_across_surfaces(monkeypatch): + import mmcore.numeric.intersection.ssx._ssx5_singular as singular + + surface = _homog(np.array( + [[[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 0.0]]])) + normal = np.empty((2, 2, 3), dtype=np.float64) + normal[..., 0] = [[-1.0, 1.0], [-1.0, 1.0]] + normal[..., 1] = [[1.0, -1.0], [1.0, -1.0]] + normal[..., 2] = [[-1.0, -1.0], [1.0, 1.0]] + monkeypatch.setattr(singular, "sigma_normal_net", lambda *_a, **_k: normal) + + limits = [] + sentinel_charge = lambda _units=1: True + + def fake_solve(*_args, max_cells, charge_box=None, stats=None, **_kwargs): + assert charge_box is sentinel_charge + assert _kwargs["max_results"] == 64 + limits.append(max_cells) + used = min(3, max_cells) + stats.update(cells_processed=used, boxes_processed=used, + budget_exhausted=False, + external_budget_exhausted=False) + return [], False + + monkeypatch.setattr(singular, "solve_zero_dim", fake_solve) + stats = {} + hits, curve_flag = singular.c1_pass( + surface, surface, atol=1e-3, ptol4=np.full(4, 1e-3), + max_cells=5, charge_box=sentinel_charge, stats=stats) + assert hits == [] and not curve_flag + assert limits == [2, 3] and sum(limits) == 5 + assert stats["cells_processed"] == 5 + + +def test_c1_connectivity_probe_respects_shared_budget(monkeypatch): + import mmcore.numeric.intersection.ssx._ssx5_singular as singular + + surface = _homog(np.array( + [[[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 0.0]]])) + normal = np.empty((2, 2, 3), dtype=np.float64) + normal[..., 0] = [[-1.0, 1.0], [-1.0, 1.0]] + normal[..., 1] = [[1.0, -1.0], [1.0, -1.0]] + normal[..., 2] = [[-1.0, -1.0], [1.0, 1.0]] + monkeypatch.setattr( + singular, "sigma_normal_net", lambda *_a, **_k: normal) + + sols = [np.full(4, 0.25), np.full(4, 0.75)] + + def fake_solve(*_args, stats=None, **_kwargs): + stats.update( + cells_processed=1, boxes_processed=1, + budget_exhausted=False, + external_budget_exhausted=False) + return list(sols), False + + def fake_connected(_sols, newton, _ptol): + assert newton(np.full(4, 0.5)) is None + return False + + charges = [] + monkeypatch.setattr(singular, "solve_zero_dim", fake_solve) + monkeypatch.setattr(singular, "_connected_one_dim", fake_connected) + stats = {} + hits, curve_flag = singular.c1_pass( + surface, surface, atol=1e-3, ptol4=np.full(4, 1e-3), + max_cells=100, + charge_box=lambda n=1: charges.append(n) or False, + stats=stats) + + assert hits == [] and curve_flag is False + assert charges == [1] + assert stats["external_budget_exhausted"] is True + assert stats["incomplete"] is True + assert stats["solve_calls"] == 1 + + +def test_subdivided_gauss_cone_contains_true_surface_normal(): + from mmcore.numeric.intersection.ssx._ssx4 import ( + GaussMapBern, separate_gauss_maps) + + points = np.array([ + [[0.0, 0.0, 0.12590804260840438], + [0.0, 0.5, 0.4045619445326789], + [0.0, 1.0, 0.43568455289279767]], + [[0.5, 0.0, 0.41487198131264286], + [0.5, 0.5, 0.32849583092501017], + [0.5, 1.0, 0.3231481813148787]], + [[1.0, 0.0, 0.6622262078328403], + [1.0, 0.5, -0.5856500620605981], + [1.0, 1.0, 0.6529671930891621]], + ]) + surface = _homog(points) + left = GaussMapBern.from_surf(surface, rational=True).split_u(0.5)[0] + _, du, dv = eval_surface_d1(surface, 0.485, 0.01, rational=True) + normal = np.cross(du, dv) + normal /= np.linalg.norm(normal) + w_left, w_sample = separate_gauss_maps( + left.map_dirs(), np.repeat(normal[None, :], 4, axis=0)) + assert w_left is None or w_sample is None + + +def test_coverage_harness_preserves_case_rational_flag(): + from examples.ssx.bez_ssx5_coverage_check import load_case_surfaces + + p1, p2, polynomial_rational = load_case_surfaces(5) + r1, r2, rational = load_case_surfaces(13) + assert polynomial_rational is False and p1.shape[-1] == p2.shape[-1] == 3 + assert rational is True and r1.shape[-1] == r2.shape[-1] == 4 + + +def test_coverage_check_propagates_rational_flag(monkeypatch): + import examples.ssx.bez_ssx5_coverage_check as coverage + + surface = _homog(np.array( + [[[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]]])) + seen = {} + monkeypatch.setattr( + coverage, "load_case_surfaces", + lambda _case: (surface, surface, True)) + + def fake_ssx(*_args, rational, **_kwargs): + seen["ssx"] = rational + return {"branches": [], "points": [], "singularities": [], + "budget_exhausted": False} + + def fake_reference(*_args, rational, **_kwargs): + seen["reference"] = rational + return np.empty((0, 3)), np.empty(0) + + monkeypatch.setattr(coverage, "bez_ssx", fake_ssx) + monkeypatch.setattr(coverage, "reference_cloud", fake_reference) + assert coverage.check_case(999, n_ref=1) is True + assert seen == {"ssx": True, "reference": True} + + +def test_coverage_check_rejects_partial_ssx(monkeypatch): + import examples.ssx.bez_ssx5_coverage_check as coverage + + surface = np.array( + [[[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]]]) + monkeypatch.setattr( + coverage, "load_case_surfaces", + lambda _case: (surface, surface, False)) + monkeypatch.setattr( + coverage, "bez_ssx", + lambda *_a, **_k: { + "branches": [], "points": [], "singularities": [], + "complete": False, + "status": {"reasons": ["work_budget"], "work": {}}, + }) + monkeypatch.setattr( + coverage, "reference_cloud", + lambda *_a, **_k: pytest.fail("partial SSX reached reference oracle")) + assert coverage.check_case(999, n_ref=1) is False + + +def test_reference_cloud_rejects_partial_csx(monkeypatch): + import examples.ssx.bez_ssx5_coverage_check as coverage + + surface = np.array( + [[[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]]]) + monkeypatch.setattr( + coverage, "bez_csx", + lambda *_a, **_k: { + "isolated": [], "overlaps": [], + "budget_exhausted": True, + "boundary_topology_complete": False, + }) + with pytest.raises(RuntimeError, match="reference CSX incomplete"): + coverage.reference_cloud(surface, surface, n=1, rational=False) + + +# --------------------------------------------------------------------------- +# L41 — schema v2: result carries complete / status.reasons / status.work +# (review doc 2026-07-12 §6; replaces budget_exhausted / budget_usage) +# --------------------------------------------------------------------------- + +def _transversal_planes(): + s1 = np.array([[[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]]]) + s2 = np.array([[[0., 0., -1.], [0., 1., 1.]], + [[1., 0., -1.], [1., 1., 1.]]]) + return s1, s2 + + +def _assert_schema_v2(r): + assert isinstance(r["complete"], bool) + status = r["status"] + assert isinstance(status["reasons"], list) + work = status["work"] + for key in ("cells_processed", "csx_calls", "max_cells", "max_csx_calls", + "output_items", "max_output_items", "postprocess_work", + "max_postprocess_work", "cell_counts"): + assert key in work, key + # The one consumer contract: complete iff no reasons. + assert r["complete"] == (not status["reasons"]), ( + r["complete"], status["reasons"]) + # Schema v2 REPLACES the old flags (rename, not duplication). + assert "budget_exhausted" not in r + assert "budget_usage" not in r + # The unread publications are gone (ledger L41). + assert "hard_exhausted" not in work + assert "output_counts" not in work + + +def test_schema_v2_complete_run_has_empty_reasons(): + s1, s2 = _transversal_planes() + r = bez_ssx(s1, s2, 1e-3, rational=False) + _assert_schema_v2(r) + assert r["complete"] is True + assert r["status"]["reasons"] == [] + assert len(r["branches"]) == 1 + + +def test_schema_v2_curve_only_overlap_stays_complete(): + # L27's shared-edge pair: the coincidence set is 1-D (two edges), fully + # represented by overlap branches + the junction tangent_point — the + # region schema is NOT needed and the result must claim completeness. + s1, s2 = _shared_edge_pair(False) + r = bez_ssx(s1, s2, 1e-3, rational=False) + _assert_schema_v2(r) + assert r["complete"] is True, r["status"]["reasons"] + + +def test_schema_v2_zero_allowance_reports_work_budget(): + s1, s2 = _transversal_planes() + r = bez_ssx(s1, s2, 1e-3, rational=False, max_cells=0) + _assert_schema_v2(r) + assert r["complete"] is False + assert r["status"]["reasons"] == ["work_budget"] + assert r["status"]["work"]["cells_processed"] == 0 + + +def test_schema_v2_depth_ceiling_reports_depth_limit(): + from examples.ssx.bez_ssx5_coverage_check import load_case_surfaces + + s1, s2, rational = load_case_surfaces(7) + r = bez_ssx(s1, s2, 1e-3, rational=rational, + max_depth=0, max_cells=20_000, max_csx_calls=100) + _assert_schema_v2(r) + assert r["complete"] is False + assert "depth_limit" in r["status"]["reasons"] + + +def test_schema_v2_2d_overlap_resolved_by_region(): + # L41 originally pinned this strip as complete=False with the + # structural `overlap_region_unsupported` reason (the old schema billed + # it to the budget at 1.9% usage — review doc §5 probe 1). L28's + # region assembler now RESOLVES it — the reason is "retired by L28" + # per review doc §6: one certified region ships and the result is + # complete with an empty reason list. + r = bez_ssx(_plane_patch(0, 2), _plane_patch(1, 3), 1e-3, rational=False) + _assert_schema_v2(r) + assert len(r["overlap_regions"]) == 1 + assert r["complete"] is True + assert r["status"]["reasons"] == [] + + +def test_schema_v2_collapsed_edge_reports_parameter_fiber(): + # Collapsed v=0 edge (a point-curve) lying ON the plane: boundary CSX + # types it as a parameter fiber (case-14 class at unit-test size); the + # limiting branch multiplicity is unproven, so the result is partial + # for the structural reason, not a budget one. + s1 = np.array([[[0., 0., 0.], [0., 0., 0.]], + [[0., 1., 1.], [1., 1., 1.]]]) + s2 = np.array([[[-1., -1., 0.], [-1., 1., 0.]], + [[1., -1., 0.], [1., 1., 0.]]]) + r = bez_ssx(s1, s2, 1e-3, rational=False) + _assert_schema_v2(r) + assert r["complete"] is False + assert "parameter_fiber" in r["status"]["reasons"] + + +def test_zero_csx_allowance_knobs_are_honored(monkeypatch): + # Ledger L41 / review finding 11: csx_max_cells=0, + # boundary_csx_max_cells=0 and csx_max_results=0 were silently promoted + # to 1 via max(1, ...) while max_cells=0/max_csx_calls=0 were honored — + # a zero allowance is a hard promise that bez_csx never runs. + import mmcore.numeric.intersection.ssx._bez_ssx5 as ssx5 + + s1, s2 = _transversal_planes() + calls = [] + real_bez_csx = ssx5.bez_csx + + def recording(*args, **kwargs): + calls.append(int(kwargs.get("max_cells", -1))) + return real_bez_csx(*args, **kwargs) + + monkeypatch.setattr(ssx5, "bez_csx", recording) + + r = ssx5.bez_ssx(s1, s2, 1e-3, rational=False, + boundary_csx_max_cells=0, csx_max_cells=0) + assert calls == [], "bez_csx ran despite a zero per-call cell allowance" + assert r["complete"] is False + assert "work_budget" in r["status"]["reasons"] + + calls.clear() + r = ssx5.bez_ssx(s1, s2, 1e-3, rational=False, csx_max_results=0) + assert calls == [], "bez_csx ran despite a zero result allowance" + assert r["complete"] is False + assert "work_budget" in r["status"]["reasons"] + + +# --------------------------------------------------------------------------- +# L28 — 2-D overlap regions (approved Option C, review doc 2026-07-12 §8) +# --------------------------------------------------------------------------- + +def _case12_pair(): + from examples.ssx.bez_ssx5_case12 import S1, S2 + return S1.copy(), S2.copy() + + +def _region_loop_checks(r, reg, atol=1e-3): + """Shared §8 invariants for one assembled region.""" + # boundary references result['branches'] overlap rims, head-to-tail + assert reg.boundary and all(loop for loop in reg.boundary) + for loop in reg.boundary: + for idx, reversed_ in loop: + b = r["branches"][idx] + assert b.kind == "overlap" + # rims are properly sampled, not L27's 2-point chords + assert len(np.asarray(b.curve[1])) >= 9 + # uv loops closed, paired, sample-synchronized + assert len(reg.uv1_loops) == len(reg.uv2_loops) == len(reg.boundary) + for uv1, uv2 in zip(reg.uv1_loops, reg.uv2_loops): + assert uv1.shape == uv2.shape and uv1.shape[1] == 2 + assert np.allclose(uv1[0], uv1[-1], atol=1e-9) + assert np.allclose(uv2[0], uv2[-1], atol=1e-9) + # certification is in atol units and within tolerance + cert = reg.certification + assert cert["boundary_resid_max"] <= 1.0 + assert cert["interior_resid"] <= 1.0 + assert cert["n_samples"] >= 8 + # interior witness is a certified coincidence point + w = np.asarray(reg.interior_stuv, dtype=float) + assert w.shape == (4,) + + +def _sync_residual(S1, S2, uv1, uv2, rational=False): + worst = 0.0 + for k in range(len(uv1)): + p1 = eval_surface(_homog(S1), uv1[k, 0], uv1[k, 1], rational=True) + p2 = eval_surface(_homog(S2), uv2[k, 0], uv2[k, 1], rational=True) + worst = max(worst, float(np.linalg.norm(p1 - p2))) + return worst + + +def test_overlap_region_case12_partial_overlap(): + S1, S2 = _case12_pair() + r = bez_ssx(S1, S2, 1e-3, rational=False) + + regions = r["overlap_regions"] + assert len(regions) == 1, [b.kind for b in r["branches"]] + reg = regions[0] + _region_loop_checks(r, reg) + # this geometry: one loop of 4 rim pieces (2 shared-edge + 2 interior) + assert len(reg.boundary) == 1 + assert len(reg.boundary[0]) == 4 + assert _sync_residual(S1, S2, reg.uv1_loops[0], reg.uv2_loops[0]) <= 2e-3 + assert reg.normal_agreement == 1 + # the structural flag is retired by the region (review doc §8): + # case 12 ships reasons=[] and a complete result + assert r["status"]["reasons"] == [] + assert r["complete"] is True + + +def test_overlap_region_full_containment(): + # S2 strictly inside a genuinely non-affine bilinear S1 (st-coefficient + # nonzero), no shared edges: the single rim loop consists entirely of + # S2 domain-edge images, all four with CURVED uv1 preimages (the L42 + # class — sourced by the assembler's own sampling, not affine claims). + S1 = np.array([[[0., 0., 0.], [0.3, 2.2, 0.]], + [[2.1, -0.2, 0.], [2.9, 2.6, 0.]]]) + S2 = np.array([[[0.6, 0.5, 0.], [0.7, 1.4, 0.]], + [[1.5, 0.55, 0.], [1.45, 1.5, 0.]]]) + r = bez_ssx(S1, S2, 1e-3, rational=False) + + regions = r["overlap_regions"] + assert len(regions) == 1, [b.kind for b in r["branches"]] + reg = regions[0] + _region_loop_checks(r, reg) + assert len(reg.boundary) == 1 + assert len(reg.boundary[0]) == 4 + assert _sync_residual(S1, S2, reg.uv1_loops[0], reg.uv2_loops[0]) <= 2e-3 + assert r["status"]["reasons"] == [] + assert r["complete"] is True + + +def test_overlap_region_identical_patches(): + S1 = np.array([[[0., 0., 0.], [0.3, 2.2, 0.]], + [[2.1, -0.2, 0.], [2.9, 2.6, 0.]]]) + r = bez_ssx(S1, S1.copy(), 1e-3, rational=False) + + regions = r["overlap_regions"] + assert len(regions) == 1 + reg = regions[0] + _region_loop_checks(r, reg) + # region = whole domain: witness in the interior, identical preimages + assert np.allclose(reg.interior_stuv[:2], reg.interior_stuv[2:], atol=1e-6) + assert reg.normal_agreement == 1 + assert r["status"]["reasons"] == [] + assert r["complete"] is True + + +def test_overlap_region_skew_curve_only_negative_control(): + # L27's shared-edge pair: 1-D coincidence only — NO region may be + # invented (the §8 band rule), the junction tangent_point and both + # overlap branches stay, and the result stays complete. + s1, s2 = _shared_edge_pair(False) + r = bez_ssx(s1, s2, 1e-3, rational=False) + + assert r["overlap_regions"] == [] + assert sum(1 for b in r["branches"] if b.kind == "overlap") >= 2 + assert [g.kind for g in r["singularities"]] == ["tangent_point"] + assert r["complete"] is True + + +def test_overlap_region_rational_twin_case12(): + # Exactly-rationalized case 12 (weights 1..2, geometry unchanged): + # same single region, same completeness. + S1, S2 = _case12_pair() + w1 = np.array([[1.0, 1.5], [2.0, 1.25]]) + w2 = np.array([[1.25, 2.0], [1.5, 1.0]]) + S1_h = np.concatenate([S1 * w1[..., None], w1[..., None]], axis=-1) + S2_h = np.concatenate([S2 * w2[..., None], w2[..., None]], axis=-1) + r = bez_ssx(S1_h, S2_h, 1e-3, rational=True) + + regions = r["overlap_regions"] + assert len(regions) == 1, [b.kind for b in r["branches"]] + _region_loop_checks(r, regions[0]) + # The region is delivered and certified, and the region-attributable + # reasons are retired. At L28-landing this fixture legitimately kept + # `work_budget` (rational CSX burned its whole 20k boundary tier on two + # faces with 0 results and result-cap-flooded a third — ledger L55); the + # L47 residual-certified CCX overlap tier closed that gap: the coincident + # rational boundary isocurve pairs now promote as tolerance overlaps in a + # handful of cells and the whole call is honestly COMPLETE (measured: + # reasons=[], ~15.3k cells, 2.2 s — was work_budget at ~40k+ cells). + assert "overlap_region_unsupported" not in r["status"]["reasons"] + assert "unresolved_multiplicity" not in r["status"]["reasons"] + assert r["status"]["reasons"] == [] + + +def test_overlap_region_opposed_orientation(): + # S2 with its u direction flipped: same region, opposed normals. + S1, S2 = _case12_pair() + S2_flipped = S2[::-1].copy() + r = bez_ssx(S1, S2_flipped, 1e-3, rational=False) + + regions = r["overlap_regions"] + assert len(regions) == 1 + assert regions[0].normal_agreement == -1 + assert r["complete"] is True + + +def test_c3_broadphase_pair_pricing_is_batched(): + # Ledger L43: every vectorized AABB pair test was charged one shared + # work unit (~ns of numpy priced like ~ms of subdivision), so ~840 + # polyline segments (~350k unordered pairs) burned the entire default + # 250k allowance on ~10 ms of numpy and flagged spurious exhaustion. + # Pair tests are now priced at the `precompute` convention (per-128); + # the Newton/exact work keeps its 1:1 pricing. + from types import SimpleNamespace + from mmcore.numeric.intersection.ssx._ssx5_singular import c3_pass + + plane = _homog(np.array([[[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]]])) + n = 420 + t = np.linspace(0.0, 1.0, n) + + def straight_branch(y, z): + stuv = np.column_stack([t, np.full(n, 0.5), t, np.full(n, 0.5)]) + xyz = np.column_stack([t, np.full(n, y), np.full(n, z)]) + return SimpleNamespace(curve=(stuv, xyz), kind="transversal") + + branches = [straight_branch(0.2, 0.0), straight_branch(0.8, 5.0)] + stats = {} + hits = c3_pass(plane, plane, branches, 1e-3, np.full(4, 1e-4), + max_work=250_000, stats=stats) + + assert hits == [] + assert stats["budget_exhausted"] is False, stats + # ~350k raw pair tests must charge ~/128, not 1:1. + assert stats["work_processed"] < 20_000, stats + + +def test_point_dedup_charge_is_linear_and_dedup_runs_at_output_cap(): + # Ledger L44: the site precharged the pre-rewrite O(n²) cost for the + # O(n·108) bucketed dedup — at the 1024 output cap that quoted + # 1,048,576 units against the 250k default, was denied, and dense + # pseudo-root output shipped UN-deduplicated with a spurious partial + # flag. The charge now prices the actual probe count. + from mmcore.numeric.intersection.ssx._bez_ssx5 import ( + _SSXSoftBudget, _point_dedup_charge, _deduplicate_ssx_points, + SSXPoint) + + charge = _point_dedup_charge(1024) + assert charge <= 1024, charge # was 1,048,576 + budget = _SSXSoftBudget(max_cells=250_000, max_csx_calls=10_000) + assert budget.charge_postprocess(charge) is True + assert budget.postprocess_exhausted is False + + # and the bucketed dedup actually collapses a dense duplicated cloud + rng = np.random.default_rng(7) + base = rng.uniform(0.2, 0.8, (512, 4)) + pts = [] + for row in base: + xyz = np.array([row[0], row[1], 0.0]) + pts.append(SSXPoint(stuv=row.copy(), xyz=xyz.copy())) + pts.append(SSXPoint(stuv=row + 1e-9, xyz=xyz + 1e-9)) + out = _deduplicate_ssx_points( + pts, np.full(4, 1e-4), 1e-3) + assert len(out) == 512 + + +def test_point_dedup_is_nan_safe(): + # Ledger L45 (crash half): math.floor raised ValueError on a NaN + # coordinate inside the binned dedup, discarding a whole completed run + # after its budget was spent; the legacy comparison dedup was NaN-safe. + from mmcore.numeric.intersection.ssx._bez_ssx5 import ( + _deduplicate_ssx_points, SSXPoint) + + pts = [ + SSXPoint(stuv=np.array([0.5, 0.5, 0.5, 0.5]), + xyz=np.array([1.0, 1.0, 0.0])), + SSXPoint(stuv=np.array([0.5, np.nan, 0.5, 0.5]), + xyz=np.array([1.0, np.nan, 0.0])), + SSXPoint(stuv=np.array([0.5, 0.5, 0.5, 0.5]), + xyz=np.array([np.inf, 1.0, 0.0])), + SSXPoint(stuv=np.array([0.5, 0.5, 0.5, 0.5]) + 1e-9, + xyz=np.array([1.0, 1.0, 0.0]) + 1e-9), + ] + out = _deduplicate_ssx_points(pts, np.full(4, 1e-4), 1e-3) + # no crash; the finite duplicate collapses; non-finite points survive + assert len(out) == 3 + + +def test_boundary_polish_gate_rejects_nan_residual(monkeypatch): + # Ledger L45 (soundness half): the boundary polish gate was written + # reject-if-greater (`if pres > tol: continue`), so a NaN residual — + # every NaN comparison is False — was ACCEPTED as a certified + # crossing. The gate is now accept-if with a finiteness guard. + import mmcore.numeric.intersection.ssx._bez_ssx5 as ssx5 + + s1, s2 = _transversal_planes() + + def nan_polish(*_a, **_k): + return np.array([0.5, 0.5, 0.5, 0.5]), float("nan"), None + + monkeypatch.setattr(ssx5, "_ssx_correct_fixed", nan_polish) + r = ssx5.bez_ssx(s1, s2, 1e-3, rational=False) + # Every boundary crossing polishes to NaN residual -> none may certify + # (pre-fix: all were accepted and traced into garbage branches). + assert r["points"] == [] + assert all(len(np.asarray(b.curve[1])) == 0 or b.kind == "overlap" + for b in r["branches"]) or r["branches"] == [] + + +def test_overlap_box_coverage_requires_both_parameter_planes(): + # Adversarial-review confirmed finding (2026-07-12): the evidence- + # coverage check tested only the box's S1 (s,t) center against + # uv1_loops — a box on a DIFFERENT S2 sheet sharing an S1 footprint + # (folded / self-overlapping S2) was falsely marked explained and the + # structural reason wrongly retired. Coverage now requires BOTH + # parameter planes, like `_site_in_regions`. + from mmcore.numeric.intersection.ssx._ssx5_overlap import ( + assemble_overlap_regions) + + S1 = _homog(_plane_patch(0, 2)) + S2 = _homog(_plane_patch(0, 2)) + ptol4 = np.full(4, 1e-4) + + # identical patches: one whole-domain region, uv1 == uv2 + in_box = np.array([[0.4, 0.6]] * 4).T.reshape(4, 2) # center .5^4 + asm = assemble_overlap_regions( + S1, S2, atol=1e-3, ptol4=ptol4, overlap_boxes=[in_box]) + assert asm["regions"] and asm["covered"] is True + + # same (s,t) footprint, but the (u,v) half far OUTSIDE the region's + # uv2 loops (a phantom second S2 sheet): must NOT count as covered. + sheet2_box = np.array([[0.45, 0.55], [0.45, 0.55], + [3.45, 3.55], [3.45, 3.55]]) + asm2 = assemble_overlap_regions( + S1, S2, atol=1e-3, ptol4=ptol4, overlap_boxes=[sheet2_box]) + assert asm2["regions"] + assert asm2["covered"] is False + + +# --------------------------------------------------------------------------- +# Ledger L25: edge-graze — a transversal arc hugging a domain edge must survive +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "d0, expected_branches", + [ + (1e-5, 1), # inside, tiny clearance: false exit vertex (residual=d0) + (1e-4, 1), # passed the atol-scale commit bar, strict path check + # then killed EVERY fragment -> 0 branches, + # reasons=['trace_unverified'] (total arc loss) + (1e-3, 1), # inside, ~atol clearance: exit refused -> marcher broke + # off mid-curve -> 2 truncated branches with a silent + # ~0.14 t-gap at the graze, falsely complete + (0.0, 1), # exact graze: control (worked before the fix) + (-1e-4, 2), # outside: two genuine s=0 crossings: control + ], + ids=["inside-1e-5", "inside-1e-4", "inside-atol", "exact", "outside"], +) +def test_edge_graze_transversal_arc_survives(d0, expected_branches): + """L25: the SSI dips to within d0 of S1's s=0 domain edge (transversal in + 3D everywhere). The arc must ship whole: an exit may only be committed on + a certified on-face root; a refused exit must march PAST the graze, not + truncate. Coverage is judged against the exact analytic parabola.""" + from examples.ssx.bez_ssx5_case15 import analytic_curve, build_graze_pair + from examples.ssx.bez_ssx5_coverage_check import point_to_polyline_dist + + atol = 1e-3 + S1, S2 = build_graze_pair(d0=d0) + r = bez_ssx(S1, S2, atol, rational=False) + + assert r["complete"], r["status"] + assert r["status"]["reasons"] == [] + assert r["singularities"] == [] + assert r["points"] == [] + assert len(r["branches"]) == expected_branches, ( + [(b.kind, len(b.curve[1])) for b in r["branches"]]) + assert all(b.kind == "transversal" for b in r["branches"]) + + polys = [np.asarray(b.curve[1], dtype=float) for b in r["branches"]] + truth = analytic_curve(d0=d0, n=801) + dists = np.array([min(point_to_polyline_dist(p, poly) for poly in polys) + for p in truth]) + worst = float(dists.max()) + assert worst <= 5 * atol, ( + f"analytic arc not covered: worst={worst:.5f} at " + f"t={float(truth[int(dists.argmax()), 1]):.4f}") + + +# --------------------------------------------------------------------------- +# Ledger L49: cut-face CSX parameter_fibers must be surfaced, not dropped +# --------------------------------------------------------------------------- + +def test_interior_pinch_cut_face_fibers_are_surfaced(): + """An interior pinched isoline (middle control row R1 = 2P - (R0+R2)/2 + makes the s=0.5 isoline collapse EXACTLY to the point P, with S_t = 0 + along it) lying ON the other surface: guided cuts through the pinch hand + the collapsed isoline to cut-face CSX, which returns a parameter_fiber + and zero isolated roots with budget_exhausted=False. The consumer loops + read only 'isolated' — before the L49 fix the fiber vanished without a + trace (the boundary path marks REASON_PARAMETER_FIBER for the same + structure). The SSI here is two curves crossing at P (an X junction at + the fiber image): the geometry must ship whole AND the positive- + dimensional preimage must be named in the reasons.""" + from examples.ssx.bez_ssx5_coverage_check import point_to_polyline_dist + from mmcore.numeric.intersection._bezier_common import eval_surface + + P = np.array([0.5, 0.5, 0.0]) + a, b = 0.6, 0.15 + r0z = np.array([b, b - a, b]) # Bernstein-2: b - 2at(1-t) + R0 = np.column_stack([np.zeros(3), [0.0, 0.5, 1.0], r0z]) + R2 = np.column_stack([np.ones(3), [0.0, 0.5, 1.0], -r0z]) + R1 = 2.0 * P[None, :] - 0.5 * (R0 + R2) + S1 = np.stack([R0, R1, R2], axis=0) # (3,3,3), degree (2,2) + S2 = np.array([[[-1.0, -1.0, 0.0], [-1.0, 2.0, 0.0]], + [[2.0, -1.0, 0.0], [2.0, 2.0, 0.0]]]) + + r = bez_ssx(S1, S2, 1e-3, rational=False) + + # the positive-dimensional cut-face structure is NAMED, not silent + assert "parameter_fiber" in r["status"]["reasons"], r["status"]["reasons"] + + # geometry: both parameter lines t = 1/2 (1 ± sqrt(1 - 2b/a)) ship whole + kinds = [g.kind for g in r.get("singularities", [])] + assert "cusp_curve" in kinds, kinds + polys = [np.asarray(br.curve[1], dtype=float) for br in r["branches"]] + assert polys, r + S1_h = np.concatenate([S1, np.ones(S1.shape[:-1] + (1,))], axis=-1) + root_off = 0.5 * np.sqrt(1.0 - 2.0 * b / a) + for t_root in (0.5 - root_off, 0.5 + root_off): + pts = np.array([eval_surface(S1_h, s, t_root, rational=True) + for s in np.linspace(0.0, 1.0, 101)]) + d = np.array([min(point_to_polyline_dist(p, poly) for poly in polys) + for p in pts]) + assert float(d.max()) <= 5e-3, (t_root, float(d.max())) + + +def test_exit_commit_refuses_nonfinite_fixed_face_residual(monkeypatch): + """L54[A1 lead 1] hardening: a NaN residual from the fixed-face exit + Newton fell into the COMMIT branch of _march_to_boundary (NaN > tol is + False) and shipped a poisoned exit vertex — masked only by the tracer's + downstream finite-guard in a DIFFERENT function. The commit condition + must be accept-if (finite AND <= strict), the L45 convention every other + gate follows.""" + import mmcore.numeric.intersection.ssx._bez_ssx5 as m + from examples.ssx.bez_ssx5_case15 import build_graze_pair + + S1, S2 = build_graze_pair(d0=1e-4) + S1h = np.concatenate([S1, np.ones(S1.shape[:-1] + (1,))], axis=-1) + S2h = np.concatenate([S2, np.ones(S2.shape[:-1] + (1,))], axis=-1) + garbage = np.array([0.0, 0.5, 0.25, 0.5]) + monkeypatch.setattr( + m, "_ssx_correct_fixed", + lambda *a, **k: (garbage.copy(), float("nan"), 0.7)) + + seed = np.array([0.2501, 0.0, 0.37505, 0.25]) + stuv, xyz, exit_info = m._march_to_boundary( + S1h, S2h, seed, atol=1e-3, rational=True, + direction_hint=np.array([-0.5, 1.0, -0.25, 0.5])) + + # pre-fix: the first wall poke committed the NaN-residual garbage vertex + # as a boundary exit; post-fix every such attempt is refused (the march + # retargets interior and ends without a certified exit). + committed_garbage = (exit_info is not None + and np.allclose(np.asarray(stuv)[-1], garbage)) + assert not committed_garbage, (exit_info, np.asarray(stuv)[-1]) + + +def test_positive_dim_sigma_truncation_is_structural_not_work_budget(): + """L52 slice 9 (§11.6 de-budget; the L49-found misbilling): enumerating + a positive-dimensional Σ line as points saturates ANY finite cap — the + interior-pinch fixture reported reasons=['parameter_fiber', + 'work_budget'] at max_cells=1,000,000 with only ~5.7k cells spent + (c1 tally 509, tier remainder 19,491, external_budget_exhausted=False; + measured). By the schema's own definition work_budget means "a resource + knob can help" — none does here. c1_pass already refuses to set its + `incomplete` flag when the detected cusp curve explains the truncated + enumeration; the wiring must not erase that distinction.""" + P = np.array([0.5, 0.5, 0.0]) + a, b = 0.6, 0.15 + r0z = np.array([b, b - a, b]) + R0 = np.column_stack([np.zeros(3), [0.0, 0.5, 1.0], r0z]) + R2 = np.column_stack([np.ones(3), [0.0, 0.5, 1.0], -r0z]) + R1 = 2.0 * P[None, :] - 0.5 * (R0 + R2) + S1 = np.stack([R0, R1, R2], axis=0) + S2 = np.array([[[-1.0, -1.0, 0.0], [-1.0, 2.0, 0.0]], + [[2.0, -1.0, 0.0], [2.0, 2.0, 0.0]]]) + + r = bez_ssx(S1, S2, 1e-3, rational=False, max_cells=1_000_000) + reasons = r["status"]["reasons"] + assert "work_budget" not in reasons, reasons + assert "unresolved_singular_set" in reasons, reasons + assert "parameter_fiber" in reasons, reasons + assert r["complete"] == (not reasons) + # the typed structure that EXPLAINS the truncation is still emitted + assert "cusp_curve" in [g.kind for g in r.get("singularities", [])] + + +def test_unresolved_complement_is_typed_with_boxes(): + """L52 slice 9b (§7.3 item 2, the typed case-13 complement): 'partial' + must NAME what is unresolved. Cells dumped at the depth ceiling and + cells abandoned on work exhaustion emit typed diagnostic entities + (their 4-D stuv AABB + the reason) in result['unresolved_regions'], + the parameter_fibers pattern — instead of only a bare reason flag. + Case 13 is the canonical depth-limit case (reasons=['depth_limit'], + measured 5 complement boxes at HEAD).""" + from examples.ssx.bez_ssx5_coverage_check import load_case_surfaces + + S1, S2, rational = load_case_surfaces(13) + r = bez_ssx(S1, S2, 1e-3, rational=rational) + reasons = r["status"]["reasons"] + assert "depth_limit" in reasons, reasons + regions = r["unresolved_regions"] + assert regions, "depth-dumped cells must be named" + for reg in regions: + lo = np.asarray(reg["stuv_min"], dtype=float) + hi = np.asarray(reg["stuv_max"], dtype=float) + assert lo.shape == (4,) and hi.shape == (4,) + assert np.all(lo >= -1e-12) and np.all(hi <= 1.0 + 1e-12) + assert np.all(lo <= hi + 1e-15) + assert reg["reason"] == "depth_limit" + + # work-exhaustion complement: an abandoned queue is named the same way + r2 = bez_ssx(S1, S2, 1e-3, rational=rational, max_cells=100) + assert not r2["complete"] + assert "work_budget" in r2["status"]["reasons"] + assert any(reg["reason"] == "work_budget" + for reg in r2["unresolved_regions"]), r2["unresolved_regions"] diff --git a/tests/test_bez_ssx6_contract.py b/tests/test_bez_ssx6_contract.py new file mode 100644 index 00000000..e93417b1 --- /dev/null +++ b/tests/test_bez_ssx6_contract.py @@ -0,0 +1,81 @@ +import numpy as np +import pytest + +import mmcore.numeric.intersection.ssx._bez_ssx6 as ssx6 + + +def _plane_h(): + points = np.array([ + [[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]], + ]) + return np.concatenate([points, np.ones((2, 2, 1))], axis=-1) + + +@pytest.mark.parametrize('result, reason', [ + ({ + 'isolated': [], 'overlaps': [], 'parameter_fibers': [], + 'budget_exhausted': True, + 'boundary_topology_complete': False, + }, 'incomplete Bezier CSX'), + ({ + 'isolated': [], 'overlaps': [], + 'parameter_fibers': [{'t_range': (0., 1.), 'u': 0.5, 'v': 0.5}], + 'budget_exhausted': False, + 'boundary_topology_complete': True, + }, 'positive-dimensional parameter fiber'), +]) +def test_ssx6_boundary_analysis_aborts_on_unsafe_csx(monkeypatch, result, reason): + monkeypatch.setattr(ssx6, 'bez_csx', lambda *args, **kwargs: result) + + with pytest.raises(RuntimeError, match=reason): + ssx6._find_ssx_boundary_zeros(_plane_h(), _plane_h(), 1e-3) + + +def test_ssx6_csx_contract_accepts_complete_result(): + result = { + 'isolated': [], 'overlaps': [], 'parameter_fibers': [], + 'budget_exhausted': False, + 'boundary_topology_complete': True, + } + assert ssx6._require_complete_csx_result(result, 'test') is result + + +def _tilted_plane_h(): + points = np.array([ + [[0., 0., -0.5], [0., 1., -0.5]], + [[1., 0., 0.5], [1., 1., 0.5]], + ]) + return np.concatenate([points, np.ones((2, 2, 1))], axis=-1) + + +def test_ssx6_interior_cut_face_filter_is_a_filter(monkeypatch): + """Ledger L53 (user decision 2026-07-12: repair + document): the interior + cut-face path wrote ``list((lambda x: ..., seq))`` — a 2-element list + ``[, seq]``, not a filter call — so the FIRST interior cut of any + run raised TypeError inside ``_isoline_csx_to_global``. The repaired + filter must drop t-endpoint roots and convert the rest.""" + from types import SimpleNamespace + + result = { + 'isolated': [ + {'t': 0.5, 'u': 0.5, 'v': 0.5, + 'point': np.array([0.5, 0.5, 0.0])}, + # endpoint root re-found on the cut line: must be filtered out + {'t': 1.0 - 1e-9, 'u': 0.9, 'v': 0.9, + 'point': np.array([0.9, 0.9, 0.0])}, + ], + 'overlaps': [], 'parameter_fibers': [], + 'budget_exhausted': False, + 'boundary_topology_complete': True, + } + monkeypatch.setattr(ssx6, 'bez_csx', lambda *a, **k: result) + cell = SimpleNamespace( + g1=SimpleNamespace(surface=_plane_h()), + g2=SimpleNamespace(surface=_tilted_plane_h()), + box=((0.0, 1.0), (0.0, 1.0), (0.0, 1.0), (0.0, 1.0))) + + crossings = ssx6._csx_on_cut_face(cell, 0, 0.5, 1e-3) + + assert len(crossings) == 1 + assert float(crossings[0].stuv[0]) == pytest.approx(0.5) diff --git a/tests/test_bezier_common.py b/tests/test_bezier_common.py index 881d7070..92857c9d 100644 --- a/tests/test_bezier_common.py +++ b/tests/test_bezier_common.py @@ -197,3 +197,60 @@ def test_newton_csx_no_intersection(): [[0.0, 1.0, 0.0], [1.0, 1.0, 0.0]]], dtype=np.float64) t, u, v, G, last_step = newton_csx(C, S, 0.5, 0.5, 0.5, rational=False) assert np.linalg.norm(G) > 1.0 + + +# --- bernstein_product_1d (ledger L52 slice 6a: the shared exact product) --- + +def _reference_product(a, b): + """The ccx-arithmetic reference: all factor terms in longdouble.""" + import math as _math + a = np.asarray(a, dtype=np.longdouble) + b = np.asarray(b, dtype=np.longdouble) + p = a.shape[0] - 1 + q = b.shape[0] - 1 + out = np.zeros((p + q + 1,) + np.broadcast_shapes( + a.shape[1:], b.shape[1:]), dtype=np.longdouble) + for i in range(p + 1): + for j in range(q + 1): + k = i + j + factor = (np.longdouble(_math.comb(p, i)) + * np.longdouble(_math.comb(q, j)) + / np.longdouble(_math.comb(p + q, k))) + out[k] += factor * a[i] * b[j] + return out + + +def test_bernstein_product_1d_scalar_operands_bit_identical_to_reference(): + from mmcore.numeric.intersection._bezier_common import bernstein_product_1d + + rng = np.random.default_rng(7) + for p, q in ((1, 1), (2, 3), (3, 5), (7, 2)): + a = rng.standard_normal(p + 1) + b = rng.standard_normal(q + 1) + got = bernstein_product_1d(a, b) + ref = _reference_product(a, b) + # exact equality: same accumulation order, same longdouble factor + # arithmetic — certificate envelopes compare against eps-scale + # bounds, so a last-ulp drift here is a behavior change. + assert got.dtype == np.longdouble + assert np.array_equal(got, ref) + + +def test_bernstein_product_1d_broadcasts_trailing_value_axes(): + from mmcore.numeric.intersection._bezier_common import bernstein_product_1d + + rng = np.random.default_rng(11) + A = rng.standard_normal((4, 3)) # degree-3 curve, 3 components + B = rng.standard_normal((3, 1)) # degree-2 weights column + got = bernstein_product_1d(A, B) + assert got.shape == (6, 3) + assert np.array_equal(got, _reference_product(A, B)) + + +def test_bernstein_product_1d_delegations_are_the_same_object(): + from mmcore.numeric.intersection._bezier_common import bernstein_product_1d + from mmcore.numeric.intersection.ccx import _bez_ccx4 + from mmcore.numeric.intersection.csx import _bez_csx4 + + assert _bez_ccx4._bernstein_product_1d is bernstein_product_1d + assert _bez_csx4._bernstein_product_1d is bernstein_product_1d diff --git a/tests/test_ccx4_exactness_contract.py b/tests/test_ccx4_exactness_contract.py new file mode 100644 index 00000000..fc2abcc9 --- /dev/null +++ b/tests/test_ccx4_exactness_contract.py @@ -0,0 +1,283 @@ +"""Exact-set contracts for the public Bezier CCX result schema. + +Re-pinned 2026-07-12 (ledger L56): this suite predated the L47 residual- +certified overlap tier and still asserted the pre-L47 semantics +("sub-tolerance sets are never reported as overlaps"). Under the +USER-APPROVED L47 contract a crossing-free pair whose dense inversion +pairs at residual <= atol ships as ONE overlap with +``certification='tolerance'`` — the exactness property survives in +sharpened form: such pairs are NEVER certified ``'exact'`` (measured: +even a 5e-324 offset stays 'tolerance'), and the exact-affine identity +tier remains magnitude/weight-scale independent. The suite went +stale-red silently because it was not in the kickoff §3 gate list; it is +now part of the unit-batch gate. +""" + +import numpy as np +import pytest + +from mmcore.numeric.intersection.ccx._bez_ccx4 import ( + _overlap_mapping_is_identity, + bez_ccx, +) + + +ATOL = 1e-3 + + +def _subcurve(control, lo, hi): + """Exact de Casteljau restriction used only to build overlap controls.""" + control = np.asarray(control, dtype=np.float64) + + def split(curve, t): + work = curve.copy() + left = [work[0].copy()] + right = [work[-1].copy()] + for level in range(1, len(curve)): + work = (1.0 - t) * work[:-1] + t * work[1:] + left.append(work[0].copy()) + right.append(work[-1].copy()) + return np.asarray(left), np.asarray(right[::-1]) + + if lo > 0.0: + _, control = split(control, lo) + if hi < 1.0: + local_hi = (hi - lo) / (1.0 - lo) if lo > 0.0 else hi + control, _ = split(control, local_hi) + return control + + +def _homogeneous(points, weights, scale=1.0): + points = np.asarray(points, dtype=np.float64) + weights = np.asarray(weights, dtype=np.float64) + return np.concatenate( + [points * weights[:, None], weights[:, None]], axis=1) * scale + + +@pytest.mark.parametrize( + "dz", + [0.5 * ATOL, 1e-12, np.nextafter(0.0, 1.0)], +) +def test_sub_tolerance_parallel_polynomial_lines_are_tolerance_never_exact(dz): + first = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) + second = first.copy() + second[:, 2] = dz + + result = bez_ccx(first, second, atol=ATOL, rational=False) + + assert result["isolated"] == [] + assert result["budget_exhausted"] is False + assert result["boundary_topology_complete"] is True + assert len(result["overlaps"]) == 1 + overlap = result["overlaps"][0] + # The exactness contract proper: a nonzero offset must never be + # certified 'exact', no matter how far below atol it sits. + assert overlap["certification"] == "tolerance" + assert overlap["residual_max"] == pytest.approx(dz, abs=1e-15) + assert np.allclose(overlap["u_range"], (0.0, 1.0), atol=0.0) + assert np.allclose(overlap["v_range"], (0.0, 1.0), atol=0.0) + + +@pytest.mark.parametrize("scale1,scale2", [(1.0, 1.0), (1e-30, 1e30)]) +def test_sub_tolerance_parallel_rational_lines_are_weight_scale_invariant( + scale1, scale2): + dz = 0.5 * ATOL + weights = np.array([1.0, 2.0]) + first_xyz = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) + second_xyz = first_xyz.copy() + second_xyz[:, 2] = dz + first = _homogeneous(first_xyz, weights, scale1) + second = _homogeneous(second_xyz, weights, scale2) + + result = bez_ccx(first, second, atol=ATOL, rational=True) + + # Weight-scale invariance: the (1,1) and (1e-30,1e30) parametrizations + # must produce the SAME classification — one tolerance overlap whose + # residual is the geometric gap, never an 'exact' claim. + assert result["isolated"] == [] + assert result["budget_exhausted"] is False + assert result["boundary_topology_complete"] is True + assert len(result["overlaps"]) == 1 + overlap = result["overlaps"][0] + assert overlap["certification"] == "tolerance" + assert overlap["residual_max"] == pytest.approx(dz, rel=1e-6) + + +def test_sub_tolerance_quadratic_hump_is_one_tolerance_overlap(): + # The hump touches the line exactly at both ends and deviates 0.25*atol + # at its apex with no transverse sign flip — under the L47 contract a + # non-flipping in-band pair is ONE tolerance overlap whose endpoints + # carry the exact touches (they must not double-report as isolated). + line = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) + hump = np.array([ + [0.0, 0.0, 0.0], + [0.5, 0.5 * ATOL, 0.0], + [1.0, 0.0, 0.0], + ]) + + result = bez_ccx(line, hump, atol=ATOL, rational=False) + + assert result["budget_exhausted"] is False + assert result["boundary_topology_complete"] is True + assert result["isolated"] == [] + assert len(result["overlaps"]) == 1 + overlap = result["overlaps"][0] + assert overlap["certification"] == "tolerance" + # apex of the quadratic = ctrl_y / 2 + assert overlap["residual_max"] == pytest.approx(0.25 * ATOL, rel=1e-9) + # The span itself IS where the exact endpoint touches live (slice-5 + # review finding): a mislocated/partial span (e.g. (0, 0.5)) would + # silently drop coincident range that boolean2d's shared-edge merge + # relies on, while passing every assertion above. + assert np.allclose(overlap["u_range"], (0.0, 1.0), atol=0.0) + assert np.allclose(overlap["v_range"], (0.0, 1.0), atol=0.0) + + +def test_exact_coincident_straight_curves_remain_an_overlap(): + line = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) + + result = bez_ccx(line, line.copy(), atol=ATOL, rational=False) + + assert result["isolated"] == [] + assert result["budget_exhausted"] is False + assert len(result["overlaps"]) == 1 + overlap = result["overlaps"][0] + assert np.allclose(overlap["u_range"], (0.0, 1.0), atol=0.0) + assert np.allclose(overlap["v_range"], (0.0, 1.0), atol=0.0) + + +def test_exact_curved_affine_parameter_subcurve_remains_an_overlap(): + whole = np.array([ + [0.0, 0.0, 0.0], + [0.5, 1.0, 0.0], + [1.0, 0.0, 0.0], + ]) + part = _subcurve(whole, 0.25, 0.75) + + result = bez_ccx(whole, part, atol=ATOL, rational=False) + + assert result["isolated"] == [] + assert result["budget_exhausted"] is False + assert len(result["overlaps"]) == 1 + overlap = result["overlaps"][0] + assert np.allclose(overlap["u_range"], (0.25, 0.75), atol=1e-12) + assert np.allclose(overlap["v_range"], (0.0, 1.0), atol=1e-12) + + +@pytest.mark.parametrize("rational", [False, True]) +def test_translated_sub_tolerance_parallel_lines_never_certify_exact(rational): + """Neither strict roots nor overlap identity may use world magnitude. + + The exact-affine identity must keep refusing the 5e-4 gap at a 2e10 + origin (no world-magnitude envelope); the pair then promotes through + the L47 tolerance tier with the geometric gap as its residual, exactly + as it does at the origin — translation changes nothing. + """ + origin = 2.0e10 + gap = 5.0e-4 + first_xyz = np.array([ + [origin, 0.0, 0.0], + [origin, 1.0, 0.0], + ]) + second_xyz = first_xyz.copy() + second_xyz[:, 0] += gap + if rational: + weights = np.array([1.0, 2.0]) + first = _homogeneous(first_xyz, weights, 1.0e-30) + second = _homogeneous(second_xyz, weights, 1.0e30) + else: + first, second = first_xyz, second_xyz + + assert not _overlap_mapping_is_identity( + first, second, (0.0, 1.0), (0.0, 1.0), rational) + result = bez_ccx(first, second, atol=ATOL, rational=rational) + + assert result["isolated"] == [] + assert result["budget_exhausted"] is False + assert result["boundary_topology_complete"] is True + assert len(result["overlaps"]) == 1 + overlap = result["overlaps"][0] + assert overlap["certification"] == "tolerance" + # Inversion at a 2e10 origin costs a few float64 ulps of the gap, not + # more (poly measured 4.997e-4, rational 5.035e-4). + assert overlap["residual_max"] == pytest.approx(gap, rel=2e-2) + + +def test_large_translated_exact_lines_remain_an_overlap(): + origin = 2.0e10 + line = np.array([ + [origin, 0.0, 0.0], + [origin, 1.0, 0.0], + ]) + + result = bez_ccx(line, line.copy(), atol=ATOL, rational=False) + + assert result["isolated"] == [] + assert result["budget_exhausted"] is False + assert len(result["overlaps"]) == 1 + + +def test_large_translated_exact_crossing_remains_an_isolated_root(): + origin = 1.0e9 + first = np.array([ + [origin, 0.5, 0.0], + [origin + 1.0, 0.5, 0.0], + ]) + second = np.array([ + [origin + 0.5, 0.0, 0.0], + [origin + 0.5, 1.0, 0.0], + ]) + + result = bez_ccx(first, second, atol=ATOL, rational=False) + + assert result["overlaps"] == [] + assert result["budget_exhausted"] is False + assert len(result["isolated"]) == 1 + assert result["isolated"][0]["u"] == pytest.approx(0.5) + assert result["isolated"][0]["v"] == pytest.approx(0.5) + + +def test_float_built_quadratic_subcurve_remains_an_overlap(): + """Restriction roundoff needs a source-operation identity floor.""" + whole = np.array([ + [0.251562918778363, -0.03577202263613945, + -1941.6385335538441], + [0.6475957171083957, 0.0150553739084055, + -1941.6390899194341], + [0.9438893741924533, 0.00627637665906201, + -1941.6256997294456], + ]) + lo = 0.11162826139544185 + hi = 0.7826689880851946 + part = _subcurve(whole, lo, hi) + + assert _overlap_mapping_is_identity( + whole, part, (lo, hi), (0.0, 1.0), rational=False) + + +def test_tolerant_non_affine_overlap_candidate_returns_typed_partial(): + # L47: an overlap-class candidate the tolerance certificate cannot + # promote exhausts its bounded fallback and ships the TYPED span — + # never a bare budget flag with a complete-looking topology claim. + first = np.array([ + [-19.77608536, 23.10065701, 0.0], + [-14.86834768, 28.69713066, 0.0], + [-5.85685250, 25.12677787, 0.0], + [-12.62581769, 15.26478654, 0.0], + ]) + second = np.array([ + [-22.03153620, 18.75969713, 0.0], + [-19.42270945, 28.25028670, 0.0], + [-8.46791623, 27.56878356, 0.0], + [-10.43007782, 19.78973126, 0.0], + ]) + + result = bez_ccx(first, second, atol=ATOL, rational=False) + + assert result["overlaps"] == [] + assert result["budget_exhausted"] is True + assert result["boundary_topology_complete"] is False + assert result["cells_processed"] < 5_000 + span = result["uncertified_overlap_span"] + assert span[0] == pytest.approx(0.0, abs=1e-9) + assert span[1] == pytest.approx(0.8276, abs=5e-3) diff --git a/tests/test_csx4_exactness_contract.py b/tests/test_csx4_exactness_contract.py new file mode 100644 index 00000000..d641a9e7 --- /dev/null +++ b/tests/test_csx4_exactness_contract.py @@ -0,0 +1,280 @@ +"""Exact-topology contracts for curve/surface intersections.""" + +import numpy as np +import pytest + +from mmcore.numeric.intersection.csx._bez_csx4 import bez_csx + + +def _homogeneous_curve(points, weights=None): + points = np.asarray(points, dtype=np.float64) + if weights is None: + weights = np.ones(len(points)) + weights = np.asarray(weights, dtype=np.float64) + return np.column_stack([points * weights[:, None], weights]) + + +def _homogeneous_surface(points): + points = np.asarray(points, dtype=np.float64) + return np.concatenate( + [points, np.ones(points.shape[:-1] + (1,))], axis=-1) + + +def _plane(): + return np.array([ + [[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], + ]) + + +@pytest.mark.parametrize("weights", [None, [1.0, 3.0]]) +def test_offset_curve_is_a_tolerance_overlap_never_exact(weights): + # L59 (USER DECISION 2026-07-12, theorem-first tier — the CCX-L56 + # twin): a 5e-4-offset line with both ends domain-pinned and no + # crossing flips IS one tolerance-coincident span. The exactness + # property survives sharpened: it must never certify 'exact'. + curve = _homogeneous_curve( + [[0.0, 0.5, 5e-4], [1.0, 0.5, 5e-4]], weights) + result = bez_csx( + curve, _homogeneous_surface(_plane()), + atol=1e-3, rational=True) + + assert result["isolated"] == [] + assert result["parameter_fibers"] == [] + assert result["budget_exhausted"] is False + assert len(result["overlaps"]) == 1 + o = result["overlaps"][0] + assert o["certification"] == "tolerance" + assert o["residual_max"] == pytest.approx(5e-4, rel=1e-6) + assert o["t_range"][0] == pytest.approx(0.0, abs=1e-9) + assert o["t_range"][1] == pytest.approx(1.0, abs=1e-9) + + +def test_sub_tolerance_hump_is_one_tolerance_overlap(): + # L59 / the CCX-L56 hump twin: apex deviation 5e-4 (ctrl 1e-3 / 2) + # with exact end touches and no transverse flip — one tolerance + # overlap whose span carries the endpoint touches. + curve = _homogeneous_curve([ + [0.0, 0.5, 0.0], + [0.5, 0.5, 1e-3], + [1.0, 0.5, 0.0], + ]) + result = bez_csx( + curve, _homogeneous_surface(_plane()), + atol=1e-3, rational=True) + + assert result["budget_exhausted"] is False + assert result["isolated"] == [] + assert len(result["overlaps"]) == 1 + o = result["overlaps"][0] + assert o["certification"] == "tolerance" + assert o["residual_max"] == pytest.approx(5e-4, rel=1e-6) + + +def test_exact_straight_overlap_is_preserved(): + curve = _homogeneous_curve([ + [0.0, 0.5, 0.0], [1.0, 0.5, 0.0]]) + result = bez_csx( + curve, _homogeneous_surface(_plane()), + atol=1e-3, rational=True) + + assert len(result["overlaps"]) == 1 + assert result["budget_exhausted"] is False + + +def test_exact_curved_affine_parameter_overlap_is_preserved(): + curve = _homogeneous_curve([ + [0.0, 0.5, 0.0], + [0.5, 0.5, 0.0], + [1.0, 0.5, 1.0], + ]) + surface = np.array([ + [[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[0.5, 0.0, 0.0], [0.5, 1.0, 0.0]], + [[1.0, 0.0, 1.0], [1.0, 1.0, 1.0]], + ]) + result = bez_csx( + curve, _homogeneous_surface(surface), + atol=1e-3, rational=True) + + assert len(result["overlaps"]) == 1 + assert result["budget_exhausted"] is False + + +def test_collapsed_rational_curve_requires_exact_surface_membership(): + point = np.array([0.5, 0.5, 5e-4]) + curve = _homogeneous_curve([point, point], weights=[1.0, 2.0]) + result = bez_csx( + curve, _homogeneous_surface(_plane()), + atol=1e-3, rational=True) + + assert result["parameter_fibers"] == [] + assert result["isolated"] == [] + assert result["budget_exhausted"] is False + + exact = point.copy() + exact[2] = 0.0 + exact_curve = _homogeneous_curve([exact, exact], weights=[1.0, 2.0]) + exact_result = bez_csx( + exact_curve, _homogeneous_surface(_plane()), + atol=1e-3, rational=True) + assert len(exact_result["parameter_fibers"]) == 1 + assert exact_result["budget_exhausted"] is False + + +def test_exterior_boundary_root_rejection_is_translation_invariant(): + """A large common origin must not turn an exterior root into a root.""" + origin = 1.0e6 + surface = np.array([ + [[origin, 0.0, 0.0], [origin, 1.0, 0.0]], + [[origin + 1.0, 0.0, 0.0], [origin + 1.0, 1.0, 0.0]], + ]) + # The polynomial continuation meets the plane at u=-1e-8, outside the + # surface domain. On u=0 the residual remains exactly nonzero. + curve = np.array([ + [origin - 1.0e-8, 0.5, -1.0], + [origin - 1.0e-8, 0.5, 1.0], + ]) + + result = bez_csx(curve, surface, atol=1e-3, rational=False) + + assert result["isolated"] == [] + assert result["overlaps"] == [] + assert result["budget_exhausted"] is False + + +def test_translated_sub_tolerance_line_certification_is_translation_invariant(): + """L59: certification must not use world magnitude (the CCX-L56 twin). + + The 5e-4-gap line promotes as 'tolerance' (never 'exact') with the + SAME residual at the origin and at 2e10 (measured identical to the + digit: 0.00049973); the exactly-coincident variant stays a certified + overlap. + """ + origin = 2.0e10 + gap = 5.0e-4 + surface = np.array([ + [[origin, 0.0, 0.0], [origin, 0.0, 1.0]], + [[origin, 1.0, 0.0], [origin, 1.0, 1.0]], + ]) + curve = np.array([ + [origin + gap, 0.0, 0.5], + [origin + gap, 1.0, 0.5], + ]) + + result = bez_csx(curve, surface, atol=1e-3, rational=False) + assert result["isolated"] == [] + assert result["parameter_fibers"] == [] + assert result["budget_exhausted"] is False + assert len(result["overlaps"]) == 1 + far = result["overlaps"][0] + assert far["certification"] == "tolerance" + + curve0 = curve.copy(); curve0[:, 0] -= origin + surface0 = surface.copy(); surface0[..., 0] -= origin + near = bez_csx(curve0, surface0, atol=1e-3, rational=False)["overlaps"][0] + assert near["certification"] == "tolerance" + assert far["residual_max"] == pytest.approx(near["residual_max"], rel=1e-3) + + exact_curve = curve.copy() + exact_curve[:, 0] = origin + exact_result = bez_csx( + exact_curve, surface, atol=1e-3, rational=False) + assert len(exact_result["overlaps"]) == 1 + assert exact_result["budget_exhausted"] is False + + +def test_collapsed_fiber_identity_is_translation_invariant(): + """A relative-to-world-origin envelope cannot type a tolerance gap.""" + origin = 1.0e8 + gap = 5.0e-4 + surface_xyz = np.array([ + [[origin, 0.0, 0.0], [origin, 1.0, 0.0]], + [[origin, 0.0, 1.0], [origin, 1.0, 1.0]], + ]) + point = np.array([origin + gap, 0.5, 0.5]) + curve = _homogeneous_curve([point, point], weights=[1.0, 2.0]) + + result = bez_csx( + curve, _homogeneous_surface(surface_xyz), + atol=1e-3, rational=True) + + assert result["parameter_fibers"] == [] + assert result["isolated"] == [] + assert result["budget_exhausted"] is False + + +def test_in_axis_drift_beyond_float_built_floor_is_not_certified(): + """L52 slice 6b: the shared two-term envelope (ccx structure). + + The former folded ``4096*n1*n2*eps_f64`` envelope certified a 3.0e-11 + in-axis control drift (~135k ulps of O(1) data — real geometry, not + roundoff) as an EXACT affine overlap on this cubic/bilinear pair; the + reconciled envelope refuses it. The 1e-12 companion pins the floor + from below: legitimate float-built restriction roundoff (the + 8192*(n1+n2)*eps_f64 source family, calibrated by ccx's + float-built-subcurve fixture) must keep certifying. + """ + from mmcore.numeric.intersection.csx._bez_csx4 import ( + _certify_affine_csx_overlap) + + S = np.array([[[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 0.0]]]) + + def drifted(dx): + return np.array([[0.0, 0.5, 0.0], + [1.0 / 3.0 + dx, 0.5, 0.0], + [2.0 / 3.0 + dx, 0.5, 0.0], + [1.0, 0.5, 0.0]]) + + a, b = (0.0, 0.0, 0.5), (1.0, 1.0, 0.5) + assert _certify_affine_csx_overlap(drifted(0.0), S, a, b, rational=False) + assert _certify_affine_csx_overlap(drifted(1e-12), S, a, b, + rational=False) + assert not _certify_affine_csx_overlap(drifted(3.0e-11), S, a, b, + rational=False) + # single-axis offsets stay rejected at every magnitude (the + # per-coordinate source scale keeps a dz-sized floor on the z axis) + dz_off = np.array([[0.0, 0.5, 1e-14], + [1.0 / 3.0, 0.5, 1e-14], + [2.0 / 3.0, 0.5, 1e-14], + [1.0, 0.5, 1e-14]]) + assert not _certify_affine_csx_overlap(dz_off, S, a, b, rational=False) + + +def test_exclusion_prune_carries_the_L1_roundoff_margin(): + """L52 slice 6c: `_residual_excludes_zero` refuses sub-margin clearance. + + §4 invariant: sign/hull exclusion only beyond k*eps*max|coeff|. A + wrongful exclusion was NOT reached in practice (240 exact-Fraction + restriction-chain comparisons: 0 flips, 0 exclusions within + 1e-12*scale of the boundary) — the margin is invariant compliance + with measured zero practical impact, insurance against restriction + chains this probe family does not cover. + """ + from mmcore.numeric.intersection.csx._bez_csx4 import ( + _residual_excludes_zero) + + eps = np.finfo(np.float64).eps + # One component clears zero by well over the 128*eps margin: excludes. + clear = np.zeros((2, 2, 2, 3)) + clear[..., 0] = 1.0 + clear[..., 1] = np.array([[[1.0, 2.0], [1.0, 2.0]], + [[1.0, 2.0], [1.0, 2.0]]]) + clear[..., 2] = -1.0 + assert _residual_excludes_zero(clear) + + # Every component's clearance sits INSIDE the margin (min = 10*eps of + # a max-magnitude-1 net): roundoff of the restriction chain could hide + # a true sign change, so the prune must refuse. + hairline = np.zeros((2, 2, 2, 3)) + for c in range(3): + hairline[..., c] = 1.0 + hairline[0, 0, 0, c] = 10.0 * eps + assert not _residual_excludes_zero(hairline) + + # Straddling hulls never exclude, margin or not. + straddle = np.zeros((2, 2, 2, 3)) + straddle[0, 0, 0, :] = -1.0 + straddle[1, 1, 1, :] = 1.0 + assert not _residual_excludes_zero(straddle) diff --git a/tests/test_csx_overlap_tier.py b/tests/test_csx_overlap_tier.py new file mode 100644 index 00000000..11bd27a7 --- /dev/null +++ b/tests/test_csx_overlap_tier.py @@ -0,0 +1,120 @@ +"""L59 — CSX theorem-first overlap certification (USER DECISION 2026-07-12). + +Two rational/polynomial arcs coinciding on any open sub-arc lie on the same +algebraic curve, so a maximal overlap can only terminate at a DOMAIN +boundary of one operand: certify domain-pinned span ends + interior +witnesses numerically and let the theorem carry the interior. Tolerance- +coincidence IS coincidence (endpoint-pinned + witnessed + no crossing +flips => 'tolerance' overlap; 'exact' when the algebraic identity holds). + +Fixture 1 is REAL USER DATA (overlap_nurbs_intersection_3_new.py, curve2 +span 2 x surface patch 17): the curve lies on the extruded patch for +t in [0.78, 1.0] — pinned at the curve's domain end and the patch +boundary. At the pre-L59 HEAD this pair ground 12,234 cells and returned +EMPTY-COMPLETE (lost geometry, no partial flag). On tiny the full model +certified in 2.7s via the (unsound) valley rule this tier replaces. +""" +import numpy as np +import pytest + +from mmcore.numeric.intersection.csx._bez_csx4 import bez_csx + +C_REAL = np.array([ + [9.483507654421916e+01, 8.704233407815154e+01, -1.462358240532153e+00], + [9.565026587000000e+01, 8.877912870999999e+01, -7.268909099999999e-01], + [9.611300253000000e+01, 9.066371219000000e+01, 2.942412000000000e-02], + [9.619901324878532e+01, 9.255100227104691e+01, 8.065860404502375e-01]]) + +S_REAL = np.array([ + [[96.08645179069646, 91.2936352248758, 41.41100531879138], + [96.08645179069646, 91.2936352248758, 0.10498601801243446]], + [[96.19819878977371, 92.12868261443225, 41.41100531879138], + [96.19819878977371, 92.12868261443225, 0.10498601801243446]], + [[96.23638809593152, 92.97063253198961, 41.41100531879138], + [96.23638809593152, 92.97063253198961, 0.10498601801243446]], + [[96.20144519121484, 93.80660802431686, 41.41100531879138], + [96.20144519121484, 93.80660802431686, 0.10498601801243446]]]) + + +def test_real_data_partial_overlap_span_is_certified(): + r = bez_csx(C_REAL, S_REAL, atol=1e-3, rational=False) + + assert r["budget_exhausted"] is False + assert r["boundary_topology_complete"] is True + assert len(r["overlaps"]) >= 1, ( + "the coincident span t in [0.78, 1.0] must ship as an overlap " + f"(got isolated={len(r['isolated'])}, overlaps=0)") + spans = [o["t_range"] for o in r["overlaps"]] + covering = [s for s in spans if s[0] <= 0.80 and s[1] >= 0.98] + assert covering, spans + for o in r["overlaps"]: + assert o["certification"] in ("exact", "tolerance") + # the arming evidence is endpoint membership — certified spans must + # BYPASS the subdivision grind (pre-L59: 12,234 cells for nothing) + assert r["cells_processed"] <= 4000, r["cells_processed"] + + +def test_planar_quad_edge_overlap_is_certified(): + # L57 (absorbed): the u=0 edge isocurve of the lifted bilinear lies in + # the plane of a NON-PARALLELOGRAM planar quad (twist != 0) — the + # exact-affine identity rightly refuses, and pre-L59 the case ground + # 4k fallback cells into ~250 lattice pseudo-roots with a typed span. + # Whole-domain coincidence: both curve ends on the surface. + quad = np.array([[[28.73565361, -57.3828431, 0.], [41.34259183, -50.11361956, 0.]], + [[41.34259183, -75.32749601, 0.], [53.84239759, -62.72055778, 0.]]]) + iso_u0 = np.array([[35.58090097, -65.90568734, 0.], + [38.10773149, -56.47542745, 0.]]) + + r = bez_csx(iso_u0, quad, atol=1e-3, rational=False) + + assert r["budget_exhausted"] is False + assert r["boundary_topology_complete"] is True + assert len(r["overlaps"]) == 1, (r["overlaps"], len(r["isolated"])) + o = r["overlaps"][0] + assert o["certification"] in ("exact", "tolerance") + assert o["t_range"][0] == pytest.approx(0.0, abs=1e-6) + assert o["t_range"][1] == pytest.approx(1.0, abs=1e-6) + assert r["cells_processed"] <= 500, r["cells_processed"] + + +def _fixture_A(): + val1 = np.array([[[28.73565361, -57.3828431, 0.], [41.34259183, -50.11361956, 0.]], + [[41.34259183, -75.32749601, 0.], [53.84239759, -62.72055778, 0.]]]) + val2 = np.array([[[35.58090097, -65.90568734, 0.], [38.10773149, -56.47542745, 0.]], + [[45.01116086, -68.43251786, 2.89525681], [47.53799138, -59.00225797, 0.]]]) + return val1, val2 + + +def test_planar_quad_L_junction_ships_both_full_branches(): + """L57 acceptance (user fixtures A vs C): the L of val2's u=0 and v=1 + edges meeting at the corner tangent point must ship as two FULL + branches (the parallelogram variant C always did). Pre-L59 the u=0 + branch was truncated to 37% WITH an honesty flag; the tier's first + integration certified the boundary overlap but the straight-chord + conversion dropped it, leaving the truncation FALSELY complete.""" + from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx + from mmcore.numeric.intersection._bezier_common import eval_surface + + val1, val2 = _fixture_A() + r = bez_ssx(val1, val2, 1e-3, rational=False) + + assert len(r["branches"]) == 2 + tps = [g for g in r["singularities"] if g.kind == "tangent_point"] + assert len(tps) == 1 + corner = tps[0].xyz + + lengths = [] + reaches_corner = 0 + for b in r["branches"]: + xyz = np.asarray(b.curve[1]) + seg = float(np.linalg.norm(np.diff(xyz, axis=0), axis=1).sum()) + lengths.append(seg) + d_end = min(np.linalg.norm(xyz[0] - corner), + np.linalg.norm(xyz[-1] - corner)) + if d_end <= 5e-3: + reaches_corner += 1 + # both edge branches are ~9.76 long and BOTH terminate at the corner + assert all(l >= 9.0 for l in sorted(lengths)), lengths + assert reaches_corner == 2, (lengths, reaches_corner) + # and the completeness claim must be TRUE, not vacuous + assert r["complete"] is True, r["status"]["reasons"] diff --git a/tests/test_nccx4.py b/tests/test_nccx4.py index 26256592..bc77bba8 100644 --- a/tests/test_nccx4.py +++ b/tests/test_nccx4.py @@ -9,6 +9,7 @@ from mmcore.geom._nurbs_eval import NURBSCurveTuple, evaluate_nurbs_curve from mmcore.numeric.intersection.ccx._nccx4 import nurbs_ccx, nurbs_ccx_multiple +import mmcore.numeric.intersection.ccx._nccx4 as nccx4 # --------------------------------------------------------------------------- @@ -84,7 +85,9 @@ class TestNurbsCCXMultiple2D: def test_no_false_positives(self): """Every reported intersection must have dist < atol between the curves.""" - iso, ovl = nurbs_ccx_multiple(CURVES_2D, tol=0.001, rational=True) + # `rational=` removed (L52 slice 8): the adapter derives rationality per + # pair; the kwarg was silently swallowed before rejection landed. + iso, ovl, _status = nurbs_ccx_multiple(CURVES_2D, tol=0.001) assert iso is not None for entry in iso: c1i, c2i = int(entry['curve1_i']), int(entry['curve2_i']) @@ -98,7 +101,9 @@ def test_no_false_positives(self): def test_count(self): """Should find exactly 11 intersections (matching the old algorithm).""" - iso, ovl = nurbs_ccx_multiple(CURVES_2D, tol=0.001, rational=True) + # `rational=` removed (L52 slice 8): the adapter derives rationality per + # pair; the kwarg was silently swallowed before rejection landed. + iso, ovl, _status = nurbs_ccx_multiple(CURVES_2D, tol=0.001) assert iso is not None assert len(iso) == 11 @@ -121,11 +126,14 @@ def expected(self): @pytest.fixture(scope="class") def result(self, curves_3d): - return nurbs_ccx_multiple(curves_3d, tol=0.001, rational=True) + # `rational=` removed + 3-tuple unpack (L52 slice 8 review finding: + # this fixture errors at setup on a pre-existing import today, but + # both latent breakages would fire the day that import is repaired). + return nurbs_ccx_multiple(curves_3d, tol=0.001) def test_ground_truth(self, result, expected): """All 25 known intersections (excl curve 0) must be found.""" - iso, ovl = result + iso, ovl, _status = result assert iso is not None iso_filt = [i for i in iso if i['curve1_i'] != 0 and i['curve2_i'] != 0] for exp in expected: @@ -140,13 +148,13 @@ def test_ground_truth(self, result, expected): def test_no_span_boundary_duplicates(self, result, expected): """Excluding curve 0, raw count should equal unique count (no duplicates).""" - iso, ovl = result + iso, ovl, _status = result iso_filt = [i for i in iso if i['curve1_i'] != 0 and i['curve2_i'] != 0] assert len(iso_filt) == 25 def test_no_false_positives(self, result, curves_3d): """Every reported intersection must have dist < atol.""" - iso, ovl = result + iso, ovl, _status = result for entry in iso: c1i, c2i = int(entry['curve1_i']), int(entry['curve2_i']) pt1 = evaluate_nurbs_curve(curves_3d[c1i], float(entry['u']), 0)['C'] @@ -167,7 +175,7 @@ class TestNurbsCCXPair: def test_two_ellipses(self): """Two rational ellipses that intersect.""" - iso, ovl = nurbs_ccx(CURVES_2D[0], CURVES_2D[1], tol=0.001) + iso, ovl, _status = nurbs_ccx(CURVES_2D[0], CURVES_2D[1], tol=0.001) assert iso is not None assert len(iso) >= 1 # Verify all results @@ -190,5 +198,188 @@ def test_no_intersection(self): control_points=np.array([[0., 10., 0.], [1., 10., 0.]]), weights=np.array([1., 1.]), ) - iso, ovl = nurbs_ccx(c1, c2, tol=0.001) + iso, ovl, _status = nurbs_ccx(c1, c2, tol=0.001) assert iso is None + + +# --------------------------------------------------------------------------- +# Tests: bounded Bezier solver status must not be lost at the NURBS boundary +# --------------------------------------------------------------------------- + +def _overlapping_lines(): + c1 = NURBSCurveTuple( + order=2, + knot=np.array([0., 0., 1., 1.]), + control_points=np.array([[0., 0., 0.], [1., 0., 0.]]), + weights=np.ones(2), + ) + c2 = NURBSCurveTuple( + order=2, + knot=np.array([0., 0., 1., 1.]), + control_points=np.array([[0., 0., 0.], [1., 0., 0.]]), + weights=np.ones(2), + ) + return c1, c2 + + +def _partial_ccx_result(*args, **kwargs): + return { + 'isolated': [], + 'overlaps': [], + 'budget_exhausted': True, + 'cells_processed': 7, + 'boundary_topology_complete': False, + } + + +def test_nurbs_ccx_default_returns_status_on_partial_result(monkeypatch): + # Ledger L41 (review finding 2): the raise-on-incomplete default crashed + # production callers (boolean2d, public ccx) on near-coincident input. + # The default is now always-return-status, ssx5-core philosophy: partial + # output ships with an explicit status instead of a RuntimeError. + c1, c2 = _overlapping_lines() + monkeypatch.setattr(nccx4, 'bez_ccx_v4', _partial_ccx_result) + + isolated, overlaps, status = nurbs_ccx(c1, c2) + assert isolated is None + assert overlaps is None + assert status['complete'] is False + assert status['budget_exhausted'] is True + assert status['boundary_topology_complete'] is False + assert status['cells_processed'] == 7 + assert status['partial_results'] == 1 + + +def test_nurbs_ccx_fail_fast_optin_still_raises(monkeypatch): + c1, c2 = _overlapping_lines() + monkeypatch.setattr(nccx4, 'bez_ccx_v4', _partial_ccx_result) + + with pytest.raises(RuntimeError, match='incomplete Bezier CCX'): + nurbs_ccx(c1, c2, return_status=False) + + +def test_nurbs_ccx_multiple_default_returns_status_on_partial_result(monkeypatch): + c1, c2 = _overlapping_lines() + monkeypatch.setattr(nccx4, 'bez_ccx_v4', _partial_ccx_result) + + isolated, overlaps, status = nurbs_ccx_multiple([c1, c2]) + assert isolated is None + assert overlaps is None + assert status['complete'] is False + assert status['budget_exhausted'] is True + assert status['partial_results'] >= 1 + + with pytest.raises(RuntimeError, match='incomplete Bezier CCX'): + nurbs_ccx_multiple([c1, c2], return_status=False) + + +def test_nurbs_ccx_shares_max_cells_across_all_span_pairs(monkeypatch): + calls = [] + + def complete_but_spends_allowance(*_args, **kwargs): + allowance = int(kwargs['max_cells']) + calls.append(allowance) + return { + 'isolated': [], + 'overlaps': [], + 'budget_exhausted': False, + 'cells_processed': allowance, + 'boundary_topology_complete': True, + } + + monkeypatch.setattr(nccx4, 'bez_ccx_v4', complete_but_spends_allowance) + isolated, overlaps, status = nurbs_ccx( + CURVES_2D[0], CURVES_2D[0], max_cells=3, + return_status=True, + ) + + assert isolated is None and overlaps is None + assert calls == [3] + assert status['cells_processed'] == 3 + assert status['max_cells'] == 3 + assert status['complete'] is False + assert status['budget_exhausted'] is True + + +def test_nurbs_ccx_multiple_shares_max_cells_across_candidates(monkeypatch): + calls = [] + + def complete_but_spends_allowance(*_args, **kwargs): + allowance = int(kwargs['max_cells']) + calls.append(allowance) + return { + 'isolated': [], + 'overlaps': [], + 'budget_exhausted': False, + 'cells_processed': allowance, + 'boundary_topology_complete': True, + } + + monkeypatch.setattr(nccx4, 'bez_ccx_v4', complete_but_spends_allowance) + isolated, overlaps, status = nurbs_ccx_multiple( + [CURVES_2D[0], CURVES_2D[0]], max_cells=2, + return_status=True, + ) + + assert isolated is None and overlaps is None + assert calls == [2] + assert status['cells_processed'] == 2 + assert status['complete'] is False + assert status['budget_exhausted'] is True + + +def test_nurbs_ccx_shares_max_results_across_span_pairs(monkeypatch): + calls = [] + + def one_overlap(*_args, **kwargs): + calls.append(int(kwargs['max_results'])) + return { + 'isolated': [], + 'overlaps': [{ + 'u_range': (0.0, 1.0), + 'v_range': (0.0, 1.0), + }], + 'budget_exhausted': False, + 'cells_processed': 0, + 'boundary_topology_complete': True, + } + + monkeypatch.setattr(nccx4, 'bez_ccx_v4', one_overlap) + _isolated, overlaps, status = nurbs_ccx( + CURVES_2D[0], CURVES_2D[0], max_cells=100, + max_results=2, return_status=True, + ) + + assert calls == [2, 1] + assert overlaps is not None and len(overlaps) == 2 + assert status['results_processed'] == 2 + assert status['max_results'] == 2 + assert status['complete'] is False + assert status['budget_exhausted'] is True + + +def test_nurbs_ccx_sanitizes_one_span_to_remaining_result_budget(monkeypatch): + c1, c2 = _overlapping_lines() + + def overproduces(*_args, **_kwargs): + return { + 'isolated': [], + 'overlaps': [ + {'u_range': (0.0, 0.2), 'v_range': (0.0, 0.2)}, + {'u_range': (0.3, 0.5), 'v_range': (0.3, 0.5)}, + {'u_range': (0.6, 0.8), 'v_range': (0.6, 0.8)}, + ], + 'budget_exhausted': False, + 'cells_processed': 1, + 'boundary_topology_complete': True, + } + + monkeypatch.setattr(nccx4, 'bez_ccx_v4', overproduces) + _isolated, overlaps, status = nurbs_ccx( + c1, c2, max_cells=10, max_results=2, return_status=True, + ) + + assert overlaps is not None and len(overlaps) == 2 + assert status['results_processed'] == 2 + assert status['complete'] is False + assert status['budget_exhausted'] is True diff --git a/tests/test_ncsx4.py b/tests/test_ncsx4.py new file mode 100644 index 00000000..960b3538 --- /dev/null +++ b/tests/test_ncsx4.py @@ -0,0 +1,235 @@ +import numpy as np +import pytest + +from mmcore.geom._nurbs_eval import NURBSCurveTuple, NURBSSurfaceTuple +from mmcore.numeric.intersection.csx._ncsx4 import nurbs_csx +import mmcore.numeric.intersection.csx._ncsx4 as ncsx4 + + +def _curve_and_surface(): + curve = NURBSCurveTuple( + order=2, + knot=np.array([2., 2., 4., 4.]), + control_points=np.array([[0., 0., 0.], [1., 0., 0.]]), + weights=np.ones(2), + ) + surface = NURBSSurfaceTuple( + order_u=2, + order_v=2, + knot_u=np.array([10., 10., 20., 20.]), + knot_v=np.array([30., 30., 50., 50.]), + control_points=np.array([ + [[0., -1., 0.], [0., 1., 0.]], + [[1., -1., 0.], [1., 1., 0.]], + ]), + weights=np.ones((2, 2)), + ) + return curve, surface + + +def test_nurbs_csx_default_returns_status_on_partial_result(monkeypatch): + # Ledger L41 (review finding 3): the raise-on-incomplete default turned + # collapsed-edge geometry (cone apex / sphere pole on-surface) into a + # RuntimeError for callers that never opted into status. The default is + # now always-return-status; fail-fast stays available as an explicit + # return_status=False opt-in. + curve, surface = _curve_and_surface() + + def partial(*args, **kwargs): + return { + 'isolated': [], + 'overlaps': [], + 'parameter_fibers': [], + 'budget_exhausted': True, + 'cells_processed': 11, + 'boundary_topology_complete': False, + } + + monkeypatch.setattr(ncsx4, 'bez_csx_v4', partial) + + isolated, overlaps, status = nurbs_csx(curve, surface) + assert isolated is None + assert overlaps is None + assert status['complete'] is False + assert status['budget_exhausted'] is True + assert status['boundary_topology_complete'] is False + assert status['cells_processed'] == 11 + assert status['partial_results'] == 1 + + with pytest.raises(RuntimeError, match='incomplete Bezier CSX'): + nurbs_csx(curve, surface, return_status=False) + + +def test_nurbs_csx_maps_positive_dimensional_fibers_when_requested(monkeypatch): + curve, surface = _curve_and_surface() + + def fiber(*args, **kwargs): + return { + 'isolated': [], + 'overlaps': [], + 'parameter_fibers': [{ + 't_range': (0.25, 0.75), + 'u': 0.5, + 'v': 0.25, + 'point': np.array([0.5, 0., 0.]), + 'surface_kind': 'min', + }], + 'budget_exhausted': False, + 'cells_processed': 3, + 'boundary_topology_complete': True, + } + + monkeypatch.setattr(ncsx4, 'bez_csx_v4', fiber) + + with pytest.raises(RuntimeError, match='positive-dimensional parameter fiber'): + nurbs_csx(curve, surface, return_status=False) + + isolated, overlaps, status = nurbs_csx(curve, surface) + assert isolated is None + assert overlaps is None + assert status['budget_exhausted'] is False + assert status['boundary_topology_complete'] is True + assert status['cells_processed'] == 3 + assert len(status['parameter_fibers']) == 1 + mapped = status['parameter_fibers'][0] + assert mapped['t_range'] == pytest.approx((2.5, 3.5)) + assert mapped['u'] == pytest.approx(15.) + assert mapped['v'] == pytest.approx(35.) + + +def test_nurbs_csx_complete_result_return_shapes(monkeypatch): + curve, surface = _curve_and_surface() + + monkeypatch.setattr(ncsx4, 'bez_csx_v4', lambda *args, **kwargs: { + 'isolated': [], + 'overlaps': [], + 'parameter_fibers': [], + 'budget_exhausted': False, + 'cells_processed': 1, + 'boundary_topology_complete': True, + }) + + # Default: always-return-status three-value shape, complete. + isolated, overlaps, status = nurbs_csx(curve, surface) + assert (isolated, overlaps) == (None, None) + assert status['complete'] is True + # Explicit fail-fast opt-out keeps the legacy two-value shape. + result = nurbs_csx(curve, surface, return_status=False) + assert result == (None, None) + + +def _multi_span_curve_and_surface(): + knots = np.array([0., 0., 0.25, 0.5, 0.75, 1., 1.]) + axis = np.linspace(0.0, 1.0, 5) + curve = NURBSCurveTuple( + order=2, + knot=knots, + control_points=np.column_stack([ + axis, np.full(5, 0.5), np.zeros(5)]), + weights=np.ones(5), + ) + points = np.empty((5, 5, 3), dtype=np.float64) + for i, x in enumerate(axis): + for j, y in enumerate(axis): + points[i, j] = [x, y, 0.0] + surface = NURBSSurfaceTuple( + order_u=2, + order_v=2, + knot_u=knots, + knot_v=knots, + control_points=points, + weights=np.ones((5, 5)), + ) + return curve, surface + + +def test_nurbs_csx_shares_max_cells_across_all_span_pairs(monkeypatch): + curve, surface = _multi_span_curve_and_surface() + calls = [] + + def complete_but_spends_allowance(*_args, **kwargs): + allowance = int(kwargs['max_cells']) + calls.append(allowance) + return { + 'isolated': [], + 'overlaps': [], + 'parameter_fibers': [], + 'budget_exhausted': False, + 'cells_processed': allowance, + 'boundary_topology_complete': True, + } + + monkeypatch.setattr(ncsx4, 'bez_csx_v4', complete_but_spends_allowance) + isolated, overlaps, status = nurbs_csx( + curve, surface, max_cells=2, return_status=True, + ) + + assert isolated is None and overlaps is None + assert calls == [2] + assert status['cells_processed'] == 2 + assert status['max_cells'] == 2 + assert status['complete'] is False + assert status['budget_exhausted'] is True + + +def test_nurbs_csx_shares_max_results_across_span_pairs(monkeypatch): + curve, surface = _multi_span_curve_and_surface() + calls = [] + + def one_overlap(*_args, **kwargs): + calls.append(int(kwargs['max_results'])) + return { + 'isolated': [], + 'overlaps': [{ + 't_range': (0.0, 1.0), + 'u_range': (0.0, 1.0), + 'v_range': (0.0, 1.0), + }], + 'parameter_fibers': [], + 'budget_exhausted': False, + 'cells_processed': 0, + 'boundary_topology_complete': True, + } + + monkeypatch.setattr(ncsx4, 'bez_csx_v4', one_overlap) + _isolated, overlaps, status = nurbs_csx( + curve, surface, max_cells=100, max_results=2, + return_status=True, + ) + + assert calls == [2, 1] + assert overlaps is not None + assert status['results_processed'] == 2 + assert status['max_results'] == 2 + assert status['complete'] is False + assert status['budget_exhausted'] is True + + +def test_nurbs_csx_sanitizes_fibers_to_remaining_result_budget(monkeypatch): + curve, surface = _curve_and_surface() + + def overproduces(*_args, **_kwargs): + return { + 'isolated': [], + 'overlaps': [], + 'parameter_fibers': [ + {'t': 0.1, 'u': 0.2, 'v': 0.3}, + {'t': 0.4, 'u': 0.5, 'v': 0.6}, + {'t': 0.7, 'u': 0.8, 'v': 0.9}, + ], + 'budget_exhausted': False, + 'cells_processed': 1, + 'boundary_topology_complete': True, + } + + monkeypatch.setattr(ncsx4, 'bez_csx_v4', overproduces) + isolated, overlaps, status = nurbs_csx( + curve, surface, max_cells=10, max_results=2, + return_status=True, + ) + + assert isolated is None and overlaps is None + assert len(status['parameter_fibers']) == 2 + assert status['results_processed'] == 2 + assert status['complete'] is False + assert status['budget_exhausted'] is True diff --git a/tests/test_nurbs_param_tol_regression.py b/tests/test_nurbs_param_tol_regression.py new file mode 100644 index 00000000..1ccccfbf --- /dev/null +++ b/tests/test_nurbs_param_tol_regression.py @@ -0,0 +1,138 @@ +"""Ledger L48: regression pins for the 2026-07-12 `_nurbs_param_tol` semantic shift. + +The budget-review commit changed the `bez_curve_param_tolerance` / +`bez_surface_param_tolerance` dispatch: ALL non-uniform-weight rational inputs +now take the conservative (OCC-style, control-leg) bound — previously only +negative-weight inputs did, and the optimistic bound's homogeneous-derivative +scaling made ptol spuriously TINY at large world coordinates (review finding +10 measured ~7.6e-7 for a radius-1 rational quarter-circle translated to +(1000, 2000) versus 2.7e-4 at the origin — a ~350x acceptance/dedup radius +jump for consumers). The conservative bound is translation-INVARIANT: that is +the new semantics these tests pin, at the function level and at the two +previously zero-coverage legacy consumers (`_bez_csx3`'s tol adapters on the +`nurbs_ssx` path, and the Bez-tree closest point), at the origin AND far from +it — so the next tolerance-ladder change trips loudly here instead of +silently shifting legacy acceptance/dedup radii. +""" +import numpy as np +import pytest + +from mmcore.geom._nurbs_param_tol import ( + bez_curve_param_tolerance, + bez_surface_param_tolerance, +) + +W = np.sqrt(0.5) +FAR = np.array([1000.0, 2000.0, 0.0]) + + +def _quarter_circle_homog(offset=(0.0, 0.0, 0.0)): + """Radius-1 rational quarter circle (weights 1, sqrt(1/2), 1), homogeneous.""" + off = np.asarray(offset, dtype=float) + pc = np.array([[1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]]) + off + ws = np.array([1.0, W, 1.0]) + return np.concatenate([pc * ws[:, None], ws[:, None]], axis=-1) + + +def _quarter_cylinder_homog(offset=(0.0, 0.0, 0.0)): + """Quarter-cylinder patch: the arc extruded one unit in z, homogeneous.""" + arc = _quarter_circle_homog(offset) + top = arc.copy() + top[:, 2] += arc[:, 3] * 1.0 # z*w column shifts by w + return np.stack([arc, top], axis=1) # (3, 2, 4) + + +def test_nonuniform_rational_curve_bound_is_conservative_and_translation_invariant(): + t_origin = bez_curve_param_tolerance( + _quarter_circle_homog(), 1e-3, rational=True) + t_far = bez_curve_param_tolerance( + _quarter_circle_homog(FAR), 1e-3, rational=True) + # conservative control-leg bound: 2.7346e-4 for the radius-1 arc + assert t_origin == pytest.approx(2.7345908e-4, rel=1e-5) + # translation must not change the bound (the old optimistic dispatch + # shrank it ~350x at (1000, 2000) via homogeneous-derivative magnitudes) + assert t_far == pytest.approx(t_origin, rel=1e-9) + + +def test_uniform_weight_curve_bound_is_optimistic_and_translation_invariant(): + pc = np.array([[1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]]) + for off in (np.zeros(3), FAR): + homog = np.concatenate([pc + off, np.ones((3, 1))], axis=-1) + t = bez_curve_param_tolerance(homog, 1e-3, rational=True) + assert t == pytest.approx(1e-3, rel=1e-6), off + + +def test_nonuniform_rational_surface_bound_is_translation_invariant(): + p_origin = bez_surface_param_tolerance( + _quarter_cylinder_homog(), 1e-3, rational=True) + p_far = bez_surface_param_tolerance( + _quarter_cylinder_homog(FAR), 1e-3, rational=True) + assert p_origin[0] == pytest.approx(2.2295145e-4, rel=1e-5) + assert p_origin[1] == pytest.approx(7.0710678e-4, rel=1e-5) + assert p_far[0] == pytest.approx(p_origin[0], rel=1e-9) + assert p_far[1] == pytest.approx(p_origin[1], rel=1e-9) + + +def test_legacy_csx3_rational_root_is_translation_stable(): + """`_bez_csx3.bez_csx` (the `nurbs_ssx` public path) sizes its acceptance + and dedup radii from the shifted tol adapters — the same rational arc + against the same plane must yield the same single transversal root at the + origin and at (1000, 2000).""" + from mmcore.numeric.intersection.csx._bez_csx3 import bez_csx as bez_csx3 + + results = [] + for off in (np.zeros(3), FAR): + arc = _quarter_circle_homog(off) + # bilinear plane y = off_y + 0.5 spanning the arc's footprint + x0, y0 = off[0], off[1] + plane = np.array([ + [[x0 - 1.0, y0 + 0.5, -1.0], [x0 - 1.0, y0 + 0.5, 1.0]], + [[x0 + 2.0, y0 + 0.5, -1.0], [x0 + 2.0, y0 + 0.5, 1.0]], + ]) + plane_h = np.concatenate( + [plane, np.ones(plane.shape[:-1] + (1,))], axis=-1) + r = bez_csx3(arc, plane_h, atol=1e-3, rational=True) + assert len(r["isolated"]) == 1, (off, r["isolated"]) + assert len(r["overlaps"]) == 0 + results.append(float(r["isolated"][0]["t"])) + # the arc's y = 0.5 crossing: same parameter at both world positions + assert results[0] == pytest.approx(results[1], abs=1e-6) + + +def test_legacy_beztree_closest_point_is_translation_stable(): + """`closest_point.bez_curve_closest_point`'s tree nodes size ptol from the + shifted bound; the returned parameter must match at the origin and far.""" + from mmcore.numeric.closest_point import bez_curve_closest_point + + ts = [] + for off in (np.zeros(3), FAR): + arc = _quarter_circle_homog(off) + probe = np.array([1.2, 1.2, 0.0]) + off + t_best, _sq_dist = bez_curve_closest_point( + arc, probe, atol=1e-3, rational=True) + ts.append(float(t_best)) + assert ts[0] == pytest.approx(0.5, abs=1e-6) + assert ts[1] == pytest.approx(ts[0], abs=1e-6) + + +@pytest.mark.parametrize("scale", [0.0, 5e-324, 1e-200, 2.5e-162, 1e-150]) +def test_collapsed_speed_tolerances_stay_finite(scale): + """L52 (review §10): the optimistic dt/du/dv guards use `< _TINY`. + + NOTE (fixture-first honesty): with numpy's squared-sum norm this cannot + go RED today — components below ~1.5e-162 flush the norm to exactly + 0.0, so the old `== 0.0` guard was rescued by an underflow accident + (measured worst reachable tol is ~4.5e158, finite). This pin exists so + a future norm implementation (e.g. scaled/hypot-style) cannot expose + the quotient to denormal denominators and ship an inf/NaN ptol — an + inf ptol would make every destructive dedup box-test true downstream. + """ + C = np.array([[0.0, 0.0, 0.0], [scale, 0.0, 0.0]]) + tol_u = bez_curve_param_tolerance(C, 1e-3, rational=False) + assert np.isfinite(tol_u) and tol_u > 0.0 + + S = np.array([[[0.0, 0.0, 0.0], [scale, 0.0, 0.0]], + [[0.0, scale, 0.0], [scale, scale, 0.0]]]) + tol_su, tol_sv = bez_surface_param_tolerance(S, 1e-3, rational=False) + assert np.isfinite(tol_su) and tol_su > 0.0 + assert np.isfinite(tol_sv) and tol_sv > 0.0 diff --git a/tests/test_ssx5_c1_regular_normal.py b/tests/test_ssx5_c1_regular_normal.py new file mode 100644 index 00000000..9f78b572 --- /dev/null +++ b/tests/test_ssx5_c1_regular_normal.py @@ -0,0 +1,310 @@ +"""C1 typing controls for small, nonzero surface normals.""" + +from math import comb + +import numpy as np +import pytest + +from mmcore.geom._nurbs_param_tol import bez_surface_param_tolerance +from mmcore.numeric.intersection._bezier_common import eval_surface_d1 +from mmcore.numeric.intersection.ssx._ssx5_singular import c1_pass + + +def _homogeneous(surface): + surface = np.asarray(surface, dtype=np.float64) + return np.concatenate( + [surface, np.ones(surface.shape[:-1] + (1,), dtype=np.float64)], + axis=-1, + ) + + +def _monomial_to_bernstein_1d(coefficients, degree): + coefficients = np.asarray(coefficients, dtype=np.float64) + return np.array([ + sum( + coefficients[j] * comb(i, j) / comb(degree, j) + for j in range(min(i, len(coefficients) - 1) + 1) + ) + for i in range(degree + 1) + ]) + + +def _regular_sheared_touch(eps=5e-7): + """Everywhere-regular injective patch touching its z=0 image plane. + + x = s + t, y = (t - 1/2)^3 + eps*t is injective because dy/dt is + strictly positive. Its normal has the certified lower bound eps, so + the touch at (s,t)=(1/2,1/2) is C2 and never C1. + """ + s_nodes = np.arange(3, dtype=np.float64) / 2.0 + t_nodes = np.arange(4, dtype=np.float64) / 3.0 + y_nodes = _monomial_to_bernstein_1d( + [-0.125, 0.75 + eps, -1.5, 1.0], 3, + ) + fs = _monomial_to_bernstein_1d([0.25, -1.0, 1.0], 2) + ft = _monomial_to_bernstein_1d([0.25, -1.0, 1.0], 3) + surface = np.array([ + [ + [s_nodes[i] + t_nodes[j], y_nodes[j], fs[i] + ft[j]] + for j in range(4) + ] + for i in range(3) + ]) + plane = np.array([ + [[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], + ]) + return surface, plane + + +def _ptol4(surface1_h, surface2_h, atol): + ps, pt = bez_surface_param_tolerance( + surface1_h, atol, rational=True, + ) + pu, pv = bez_surface_param_tolerance( + surface2_h, atol, rational=True, + ) + return np.maximum( + np.array([float(ps), float(pt), float(pu), float(pv)]), 1e-9, + ) + + +@pytest.mark.parametrize("scale", [1.0, 10.0, 100.0]) +def test_regular_small_normal_is_never_typed_as_cusp(scale): + surface, plane = _regular_sheared_touch() + surface_h = _homogeneous(surface * scale) + plane_h = _homogeneous(plane * scale) + atol = 1e-8 * scale + + _, ds, dt = eval_surface_d1( + surface_h, 0.5, 0.5, rational=True, + ) + # Uniform coordinate scaling multiplies the normal by scale^2. + assert np.linalg.norm(np.cross(ds, dt)) == pytest.approx( + 5e-7 * scale * scale, + ) + + stats = {} + hits, curve_flag = c1_pass( + surface_h, + plane_h, + atol, + _ptol4(surface_h, plane_h, atol), + max_cells=20_000, + stats=stats, + ) + assert hits == [] + assert curve_flag is False + assert stats["incomplete"] is False + + +def test_resolution_floor_without_zero_certificate_is_partial(monkeypatch): + """A surviving unresolved normal box cannot prove regularity or a cusp.""" + import mmcore.numeric.intersection.ssx._ssx5_singular as singular + + surface, plane = _regular_sheared_touch() + surface_h = _homogeneous(surface) + plane_h = _homogeneous(plane) + + normal = np.empty((2, 2, 3), dtype=np.float64) + normal[..., 0] = [[-1.0, 1.0], [-1.0, 1.0]] + normal[..., 1] = [[1.0, -1.0], [1.0, -1.0]] + normal[..., 2] = [[-1.0, -1.0], [1.0, 1.0]] + monkeypatch.setattr( + singular, "sigma_normal_net", lambda *_args, **_kwargs: normal, + ) + + def unresolved(*_args, stats=None, **_kwargs): + stats.update( + floor_boxes=1, + unresolved_floor_boxes=1, + cells_processed=1, + boxes_processed=1, + budget_exhausted=False, + external_budget_exhausted=False, + ) + return [], False + + monkeypatch.setattr(singular, "solve_zero_dim", unresolved) + stats = {} + hits, curve_flag = c1_pass( + surface_h, + plane_h, + 1e-8, + np.full(4, 1e-4), + max_cells=10, + stats=stats, + ) + assert hits == [] + assert curve_flag is False + assert stats["incomplete"] is True + + +def test_exact_cone_cusp_control_is_preserved(): + cone = np.array([ + [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + [[0.0, 1.0, -1.0], [1.0, 1.0, 1.0]], + ]) + plane = np.array([ + [[-1.5, -1.5, 0.0], [-1.5, 1.5, 0.0]], + [[1.5, -1.5, 0.0], [1.5, 1.5, 0.0]], + ]) + cone_h, plane_h = _homogeneous(cone), _homogeneous(plane) + stats = {} + hits, curve_flag = c1_pass( + cone_h, + plane_h, + 1e-3, + _ptol4(cone_h, plane_h, 1e-3), + stats=stats, + ) + assert hits + assert any(hit["surface"] == 1 for hit in hits) + assert curve_flag or any("stuv" in hit for hit in hits) + + +def test_interior_regular_touch_does_not_publish_tolerance_valley_branches(): + """An isolated touch may be partial, but never a complete 1D SSI.""" + from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx + + surface, _ = _regular_sheared_touch() + plane = np.array([ + [[-0.5, -1.0, 0.0], [-0.5, 1.0, 0.0]], + [[1.5, -1.0, 0.0], [1.5, 1.0, 0.0]], + ]) + result = bez_ssx( + _homogeneous(surface), _homogeneous(plane), + atol=1e-3, rational=True, + max_postprocess_work=2_000_000, + ) + + assert result["branches"] == [] + assert any(g.kind == "tangent_point" + for g in result["singularities"]) + # A second-order isolation certificate is not yet part of the regular + # loop-free tracer. Refuse a complete topology claim in that ambiguity. + assert result["complete"] is False + + +def _positive_gap_between_two_endpoint_touches(h): + s_coeff = np.array([0.0, 0.5, 1.0]) + t_coeff = np.array([0.0, 0.5, 1.0]) + z_s = np.array([0.25, -0.25, 0.25]) + z_t = np.array([0.0, 2.0 * h, 0.0]) + surface = np.array([ + [[s_coeff[i], t_coeff[j], z_s[i] + z_t[j]] + for j in range(3)] + for i in range(3) + ]) + plane = np.array([ + [[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], + ]) + return surface, plane + + +def _regular_high_order_tangent_line(degree): + """Everywhere-regular cylinder tangent to z=0 with multiplicity d. + + ``S(s,t) = (s, t, (t - 1/2)**degree)`` is injective and its parameter + normal never vanishes because the x/y coordinates are the identity. + For even ``degree`` its exact SSI with the plane is the single tangent + line ``t=1/2``. This separates root LOCATION from residual size: + ``|t-1/2|**degree`` reaches roundoff many geometric tolerances away. + """ + mono = np.array([ + comb(degree, k) * (-0.5) ** (degree - k) + for k in range(degree + 1) + ], dtype=np.float64) + z_nodes = _monomial_to_bernstein_1d(mono, degree) + t_nodes = np.arange(degree + 1, dtype=np.float64) / degree + surface = np.array([ + [[float(i), t_nodes[j], z_nodes[j]] + for j in range(degree + 1)] + for i in range(2) + ]) + plane = np.array([ + [[-0.5, -0.5, 0.0], [-0.5, 1.5, 0.0]], + [[1.5, -0.5, 0.0], [1.5, 1.5, 0.0]], + ]) + return surface, plane + + +@pytest.mark.parametrize("h", [1e-7, 1e-2]) +def test_positive_gap_between_endpoint_touches_is_not_a_branch(h): + from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx + + surface, plane = _positive_gap_between_two_endpoint_touches(h) + result = bez_ssx(surface, plane, atol=1e-3, rational=False) + + assert result["branches"] == [] + assert len(result["points"]) == 2 + assert result["complete"] is False + + +def test_zero_gap_control_remains_a_tangent_line(): + from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx + + surface, plane = _positive_gap_between_two_endpoint_touches(0.0) + result = bez_ssx(surface, plane, atol=1e-3, rational=False) + + assert result["complete"] is True + assert len(result["branches"]) == 1 + assert result["branches"][0].kind == "tangential" + + +def test_ssx_correctors_compare_squared_residual_to_squared_tolerance(): + from mmcore.numeric.intersection.ssx._bez_ssx5 import ( + _ssx_correct, _ssx_correct_fixed) + + t_coeff = np.array([0.0, 0.5, 1.0]) + z_coeff = np.array([0.25, -0.25, 0.25]) + surface = np.array([ + [[float(i), t_coeff[j], z_coeff[j]] for j in range(3)] + for i in range(2) + ]) + plane = np.array([ + [[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], + ]) + start = np.array([0.5, 0.5001, 0.5, 0.5001]) + + corrected = _ssx_correct( + surface, plane, *start, rational=False) + assert corrected[4] < 1e-12 + + fixed, residual, _ = _ssx_correct_fixed( + surface, plane, start, fixed_axis=0, fixed_value=0.5, + rational=False) + assert residual < 1e-12 + assert abs(fixed[1] - 0.5) < 1e-5 + + +def test_quartic_regular_tangent_line_is_complete(): + from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx + + surface, plane = _regular_high_order_tangent_line(4) + result = bez_ssx(surface, plane, atol=1e-3, rational=False) + + assert result["complete"] is True + assert len(result["branches"]) == 1 + branch = result["branches"][0] + assert branch.kind == "tangential" + xyz = np.asarray(branch.curve[1]) + assert xyz[:, 0].min() < 0.01 and xyz[:, 0].max() > 0.99 + assert np.allclose(xyz[:, 1], 0.5, atol=1e-3) + + +@pytest.mark.parametrize("degree", [8, 10, 12]) +def test_high_order_tangent_never_publishes_off_locus_branches(degree): + from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx + + surface, plane = _regular_high_order_tangent_line(degree) + result = bez_ssx(surface, plane, atol=1e-3, rational=False) + + # A partial result may omit topology it cannot certify, but every + # published branch is certified geometry. Residual-only verification + # used to emit 4--8 false copies up to 48*atol from the exact line. + for branch in result["branches"]: + xyz = np.asarray(branch.curve[1]) + assert np.max(np.abs(xyz[:, 1] - 0.5)) <= 2e-3 diff --git a/tests/test_work_budget.py b/tests/test_work_budget.py new file mode 100644 index 00000000..06628f7f --- /dev/null +++ b/tests/test_work_budget.py @@ -0,0 +1,356 @@ +"""Contract tests for mmcore.numeric._work_budget (ledger L52, slice 5). + +The shared budget module is the single implementation of the solver-level +work-ledger mechanics that were previously hand-rolled 8 ways (review doc +2026-07-12 §10 finding 15). These tests pin the EXACT semantics extracted +from `_bez_ssx5._SSXSoftBudget` — the migration must not shift any of them: + +- charge_cells is check-then-charge and all-or-nothing (a denied amount is + not partially spent); +- denials that echo an already-exhausted ledger do NOT re-mark reasons + (reasons is a cause list, not a cascade log); +- zero-amount charges are true no-ops that succeed; +- postprocess has its own latch so a hard-stopped search can still afford + assembly, and its cap defaults to max_cells; +- retire_reason never clears hard exhaustion; +- complete == (not reasons) holds through mark/retire cycles; +- charge_hook reproduces the guarded-lambda Optional threading pattern. +""" +import pytest + +from mmcore.numeric._work_budget import ( + SoftWorkBudget, + charge_hook, + REASON_WORK_BUDGET, + REASON_OUTPUT_CAP, + REASON_POSTPROCESS_CAP, + REASON_DEPTH_LIMIT, + REASON_PARAMETER_FIBER, + REASON_OVERLAP_REGION, + REASON_TANGENTIAL_ZONE, + REASON_MULTIPLICITY, + REASON_TRACE_UNVERIFIED, +) + + +def test_charge_cells_is_all_or_nothing(): + b = SoftWorkBudget(max_cells=10, max_csx_calls=5) + assert b.charge_cells(8, "a") + # 8 + 5 > 10: the whole charge is denied, nothing is spent. + assert not b.charge_cells(5, "a") + assert b.cells_processed == 8 + assert b.exhausted + assert b.reasons == [REASON_WORK_BUDGET] + + +def test_echo_denials_do_not_cascade_reasons(): + b = SoftWorkBudget(max_cells=1, max_csx_calls=5) + assert b.charge_cells(1, "a") + assert not b.charge_cells(1, "a") # root-cause denial + assert not b.charge_cells(1, "b") # echo — must not re-mark + assert not b.charge_csx_call() # echo through the other ledger + assert b.reasons == [REASON_WORK_BUDGET] + + +def test_zero_amount_charge_is_a_successful_noop(): + b = SoftWorkBudget(max_cells=0, max_csx_calls=1) + assert b.charge_cells(0, "a") + assert b.cells_processed == 0 + assert not b.exhausted + + +def test_cell_counts_ledger_tallies_per_source(): + b = SoftWorkBudget(max_cells=100, max_csx_calls=5) + b.charge_cells(3, "ssx") + b.charge_cells(4, "csx") + b.charge_cells(2, "ssx") + assert b.cell_counts == {"ssx": 5, "csx": 4} + + +def test_csx_call_ledger_is_separate_from_cells(): + b = SoftWorkBudget(max_cells=100, max_csx_calls=2) + assert b.charge_csx_call() + assert b.charge_csx_call() + assert not b.charge_csx_call() + assert b.exhausted + assert b.cells_processed == 0 + assert b.reasons == [REASON_WORK_BUDGET] + + +def test_postprocess_latch_survives_search_exhaustion(): + b = SoftWorkBudget(max_cells=4, max_csx_calls=1) + b.mark_exhausted() + # Search phase is dead, but assembly still has its own allowance + # (defaulting to max_cells). + assert b.charge_postprocess(3) + assert not b.charge_postprocess(3) + assert b.postprocess_exhausted + assert REASON_POSTPROCESS_CAP in b.reasons + # And the latch fails fast afterwards without re-marking. + reasons_after = list(b.reasons) + assert not b.charge_postprocess(1) + assert b.reasons == reasons_after + + +def test_postprocess_cap_defaults_to_max_cells(): + b = SoftWorkBudget(max_cells=7, max_csx_calls=1) + assert b.max_postprocess_work == 7 + b2 = SoftWorkBudget(max_cells=7, max_csx_calls=1, max_postprocess_work=3) + assert b2.max_postprocess_work == 3 + + +def test_retire_reason_never_clears_hard_exhaustion(): + b = SoftWorkBudget(max_cells=1, max_csx_calls=1) + b.mark_incomplete(REASON_OVERLAP_REGION) + assert not b.result_fields()["complete"] + b.retire_reason(REASON_OVERLAP_REGION) + assert b.result_fields()["complete"] + + b.mark_incomplete(REASON_OVERLAP_REGION) + b.mark_exhausted() # hard exhaustion + b.retire_reason(REASON_OVERLAP_REGION) + fields = b.result_fields() + assert not fields["complete"] + assert fields["status"]["reasons"] == [REASON_WORK_BUDGET] + + +def test_complete_iff_no_reasons_through_mark_retire_cycle(): + b = SoftWorkBudget(max_cells=100, max_csx_calls=5) + for step in range(3): + fields = b.result_fields() + assert fields["complete"] == (not fields["status"]["reasons"]) + b.mark_incomplete(REASON_MULTIPLICITY) + fields = b.result_fields() + assert fields["complete"] == (not fields["status"]["reasons"]) + b.retire_reason(REASON_MULTIPLICITY) + + +def test_output_cap_denies_and_marks_incomplete(): + b = SoftWorkBudget(max_cells=100, max_csx_calls=5, max_output_items=2) + out = [] + assert b.append_output(out, "x", "point") + assert b.append_output(out, "y", "point") + assert not b.append_output(out, "z", "point") + assert out == ["x", "y"] + assert REASON_OUTPUT_CAP in b.reasons + assert not b.result_fields()["complete"] + + +def test_extend_output_stops_at_first_denial(): + b = SoftWorkBudget(max_cells=100, max_csx_calls=5, max_output_items=2) + out = [] + assert not b.extend_output(out, ["a", "b", "c"], "fragment") + assert out == ["a", "b"] + + +def test_result_fields_schema_v2_shape(): + b = SoftWorkBudget(max_cells=10, max_csx_calls=3) + b.charge_cells(2, "ssx") + b.charge_csx_call() + fields = b.result_fields() + assert fields["complete"] is True + work = fields["status"]["work"] + assert work == { + "cells_processed": 2, + "csx_calls": 1, + "max_cells": 10, + "max_csx_calls": 3, + "output_items": 0, + "max_output_items": 1024, + "postprocess_work": 0, + "max_postprocess_work": 10, + "cell_counts": {"ssx": 2}, + } + + +def test_remaining_properties_clamp_at_zero(): + b = SoftWorkBudget(max_cells=2, max_csx_calls=1) + b.charge_cells(2, "a") + assert b.remaining_cells == 0 + b.mark_exhausted() + assert b.remaining_cells == 0 + assert b.remaining_postprocess_work == 2 + + +def test_charge_hook_binds_source_and_none_path(): + assert charge_hook(None, "phi") is None + b = SoftWorkBudget(max_cells=3, max_csx_calls=1) + hook = charge_hook(b, "phi") + assert hook(2) + assert b.cell_counts == {"phi": 2} + assert not hook(2) + assert b.exhausted + # default amount is 1, matching charge_cells + b2 = SoftWorkBudget(max_cells=3, max_csx_calls=1) + hook2 = charge_hook(b2, "singular") + assert hook2() + assert b2.cells_processed == 1 + + +def test_reason_vocabulary_is_stable(): + # The budget-contract gate scans REASON_* names; the vocabulary is part + # of the public schema and must not drift silently. + assert REASON_WORK_BUDGET == "work_budget" + assert REASON_OUTPUT_CAP == "output_cap" + assert REASON_POSTPROCESS_CAP == "postprocess_cap" + assert REASON_DEPTH_LIMIT == "depth_limit" + assert REASON_PARAMETER_FIBER == "parameter_fiber" + assert REASON_OVERLAP_REGION == "overlap_region_unsupported" + assert REASON_TANGENTIAL_ZONE == "unresolved_tangential_zone" + assert REASON_MULTIPLICITY == "unresolved_multiplicity" + assert REASON_TRACE_UNVERIFIED == "trace_unverified" + + +def test_bernstein_zero_budget_nodes_are_check_then_charge(): + from mmcore.numeric._work_budget import BernsteinZeroBudget + + b = BernsteinZeroBudget(max_nodes=2, max_results=10) + assert b.enter() and b.enter() + assert b.nodes == 2 and b.active_depth == 2 + assert not b.enter() # denied, nothing spent + assert b.nodes == 2 + assert b.exhausted + b.leave() + b.leave() + assert b.active_depth == 0 + + +def test_bernstein_zero_budget_results_charge_at_completion_after_clamp(): + from mmcore.numeric._work_budget import BernsteinZeroBudget + + b = BernsteinZeroBudget(max_nodes=10, max_results=3) + kept = b.cap_top_level_results([1.0, 2.0]) + assert kept == [1.0, 2.0] + assert b.results == 2 and not b.exhausted + # Overproduction truncates silently and charges only what was kept. + kept = b.cap_top_level_results([3.0, 4.0, 5.0]) + assert kept == [3.0] + assert b.results == 3 + assert b.exhausted + assert b.remaining_results() == 0 + + +def test_bernstein_zero_budget_scope_sets_and_resets_contextvar(): + from mmcore.numeric._work_budget import ( + bernstein_zero_budget, _ZERO_BUDGET) + + assert _ZERO_BUDGET.get() is None + with bernstein_zero_budget(5, 5) as outer: + assert _ZERO_BUDGET.get() is outer + with bernstein_zero_budget(1, 1) as inner: + assert _ZERO_BUDGET.get() is inner + assert _ZERO_BUDGET.get() is outer + assert _ZERO_BUDGET.get() is None + + +def test_latching_spend_is_all_or_nothing_and_latches(): + from mmcore.numeric._work_budget import LatchingSpend + + led = LatchingSpend(max_work=5) + assert led.spend(3) + assert not led.spend(3) # would overflow: denied, latched + assert led.work_processed == 3 + assert led.exhausted + assert not led.spend(1) # latched: fails fast + assert not led.external_exhausted + + +def test_latching_spend_zero_amount_is_a_noop(): + from mmcore.numeric._work_budget import LatchingSpend + + led = LatchingSpend(max_work=0) + assert led.spend(0) + assert led.work_processed == 0 and not led.exhausted + + +def test_latching_spend_external_hook_charged_after_local_check(): + from mmcore.numeric._work_budget import LatchingSpend + + calls = [] + + def hook(n): + calls.append(n) + return len(calls) < 2 # second external charge denied + + led = LatchingSpend(max_work=100, charge_external=hook) + assert led.spend(4) + assert calls == [4] + assert not led.spend(5) # external denial + assert led.work_processed == 4 # local spend NOT recorded on denial + assert led.exhausted and led.external_exhausted + # local denial must never consult the external hook + led2 = LatchingSpend(max_work=2, charge_external=hook) + calls.clear() + assert not led2.spend(3) + assert calls == [] + + +def test_down_counter_pairs_remaining_and_processed(): + from mmcore.numeric._work_budget import DownCounter + + c = DownCounter(10) + c.spend(3) + c.spend(4) + assert c.remaining == 3 and c.processed == 7 + # spend is deliberately unchecked (the bez_ccx/bez_csx family keeps + # denial policy at each site); overdraw drives remaining negative and + # the site's own guard reacts. + c.spend(5) + assert c.remaining == -2 and c.processed == 12 + + +def test_down_counter_clamps_construction_and_tiers(): + from mmcore.numeric._work_budget import DownCounter + + c = DownCounter(-5) + assert c.remaining == 0 and c.processed == 0 + c2 = DownCounter(10_000) + assert c2.tier(2_000) == 2_000 # bounded sub-allowance + c2.spend(9_500) + assert c2.tier(2_000) == 500 # drawn from the remainder + c2.spend(600) + assert c2.tier(2_000) == 0 # never negative + + +def test_reconcile_reported_bills_at_least_the_floor(): + from mmcore.numeric._work_budget import reconcile_reported + + # A fast-rejected span reporting zero work still costs one unit — an + # arbitrarily large candidate set must not bypass an aggregate + # allowance. + assert reconcile_reported(0, 100) == (1, False) + assert reconcile_reported(7, 100) == (7, False) + + +def test_reconcile_reported_clamps_overruns_instead_of_denying(): + from mmcore.numeric._work_budget import reconcile_reported + + # The work already happened; the ledger only reconciles it. + assert reconcile_reported(150, 100) == (100, True) + assert reconcile_reported(1, 0) == (0, True) + # floor=0 disables the minimum charge (for ledgers without the + # anti-starvation rule). + assert reconcile_reported(0, 100, floor=0) == (0, False) + + +def test_bern_zero_1d_reexports_are_the_same_objects(): + # ccx/csx and the tests import these via _bern_zero_1d; the move must + # preserve identity (the solver reads the SAME ContextVar object). + from mmcore.numeric.intersection import _bern_zero_1d as bz + from mmcore.numeric import _work_budget as wb + + assert bz.BernsteinZeroBudget is wb.BernsteinZeroBudget + assert bz.bernstein_zero_budget is wb.bernstein_zero_budget + assert bz._ZERO_BUDGET is wb._ZERO_BUDGET + + +def test_bez_ssx5_reexports_are_the_same_objects(): + # Behavior-preservation contract: existing consumers access these via + # the _bez_ssx5 module namespace (tests, budget-contract gate). + from mmcore.numeric.intersection.ssx import _bez_ssx5 as ssx5 + from mmcore.numeric import _work_budget as wb + + assert ssx5._SSXSoftBudget is wb.SoftWorkBudget + for name in dir(wb): + if name.startswith("REASON_"): + assert getattr(ssx5, name) == getattr(wb, name)