From 9cea23cf0a95979bbd1983b8567fa0504ecdbd61 Mon Sep 17 00:00:00 2001 From: sth-v Date: Mon, 14 Sep 2026 20:07:30 +0300 Subject: [PATCH 1/8] Restore CAD-tolerance surface intersections and repair continuation --- .../2026-09-09-ssx-reliability-report.md | 7 + .../2026-09-14-ssx-tolerance-repair.md | 105 + .../ssx/csx_ccx_completeness_benchmark.py | 12 +- examples/ssx/source_world_mix_benchmark.py | 9 +- mmcore/numeric/_bern_roots.py | 108 + mmcore/numeric/intersection/ccx/_bez_ccx4.py | 72 +- mmcore/numeric/intersection/csx/_bez_csx4.py | 95 +- mmcore/numeric/intersection/ssx/_bez_ssx5.py | 5674 ++++++++--------- mmcore/numeric/intersection/ssx/_nssx5.py | 799 +-- .../numeric/intersection/ssx/_ssx5_overlap.py | 567 +- .../ssx/_ssx_affine_constraints.py | 402 -- .../intersection/ssx/_ssx_affine_path.py | 464 -- .../intersection/ssx/_ssx_arc_ownership.py | 57 - .../intersection/ssx/_ssx_boundary_fibers.py | 253 - .../ssx/_ssx_boundary_identity.py | 52 - .../intersection/ssx/_ssx_boundary_strata.py | 298 - .../intersection/ssx/_ssx_cofactor_bounds.py | 330 - .../intersection/ssx/_ssx_exact_region.py | 225 - .../intersection/ssx/_ssx_extrusion.py | 263 - .../intersection/ssx/_ssx_face_residual.py | 97 - .../numeric/intersection/ssx/_ssx_isolines.py | 363 ++ .../intersection/ssx/_ssx_matched_isolines.py | 164 + .../intersection/ssx/_ssx_opposed_strata.py | 219 - .../intersection/ssx/_ssx_planar_cut.py | 303 - .../ssx/_ssx_positive_dimensional.py | 181 - .../ssx/_ssx_quadratic_constraints.py | 781 --- .../ssx/_ssx_residual_contract.py | 133 - .../intersection/ssx/_ssx_root_identity.py | 778 --- .../ssx/_ssx_singular_candidate.py | 142 - .../intersection/ssx/_ssx_source_cut_cache.py | 102 - .../intersection/ssx/_ssx_source_residual.py | 84 - tests/test_bern_roots_numeric.py | 65 + tests/test_bez_ssx5_invariance.py | 1 - tests/test_bez_ssx5_search_completeness.py | 300 +- tests/test_bez_ssx5_singular.py | 205 +- tests/test_ccx4_constant_curve_cad.py | 88 + tests/test_ccx_cad_endpoint_tangency.py | 55 + tests/test_csx_ccx_parameter_completeness.py | 4 +- tests/test_csx_overlap_tier.py | 3 - tests/test_csx_surface_fiber_tolerance.py | 107 + tests/test_nssx5.py | 312 +- tests/test_nssx5_certified_seams.py | 114 - tests/test_nssx5_parameter_mapping.py | 134 - tests/test_nssx5_practical_representation.py | 139 + .../test_nssx5_source_singularity_identity.py | 41 +- tests/test_ssx5_analytic_coverage.py | 7 + tests/test_ssx5_arc_secant_bound.py | 135 - tests/test_ssx5_boundary_continuation.py | 89 + tests/test_ssx5_boundary_strata.py | 251 - tests/test_ssx5_c1_regular_normal.py | 95 +- tests/test_ssx5_cad_strata.py | 257 + tests/test_ssx5_exact_extrusion.py | 152 - tests/test_ssx5_exact_tangent_path.py | 92 - tests/test_ssx5_fast_singularity_merge.py | 28 + tests/test_ssx5_isolines.py | 190 + tests/test_ssx5_lifted_assembly.py | 71 +- tests/test_ssx5_matched_isolines.py | 82 + tests/test_ssx5_overlap_identity.py | 97 - tests/test_ssx5_practical_assembly.py | 100 + tests/test_ssx5_probe_split.py | 43 +- tests/test_ssx5_registered_points.py | 106 - tests/test_ssx5_root_identity.py | 243 - tests/test_ssx5_singular_source_candidates.py | 82 - tests/test_ssx5_trace_ownership.py | 30 +- tests/test_ssx_active_owner_budget.py | 53 - tests/test_ssx_affine_constraints.py | 248 - tests/test_ssx_affine_domain.py | 84 - tests/test_ssx_arc_ownership.py | 36 - tests/test_ssx_balanced_partition.py | 35 - tests/test_ssx_boundary_child_ownership.py | 84 - tests/test_ssx_exact_region.py | 113 - tests/test_ssx_exact_root_separation.py | 114 - tests/test_ssx_exact_source_planar_cut.py | 189 - tests/test_ssx_face_residual.py | 78 - tests/test_ssx_fixed_source_face_census.py | 129 - tests/test_ssx_global_injectivity_c3.py | 42 - tests/test_ssx_known_port_ownership.py | 49 - tests/test_ssx_local_boundary_obligations.py | 74 - tests/test_ssx_overlap_endpoint_polish.py | 60 + tests/test_ssx_overlap_union_cleanup.py | 106 + tests/test_ssx_owned_root_transport.py | 61 - tests/test_ssx_partial_boundary_witnesses.py | 29 + tests/test_ssx_planar_overlap_clipping.py | 139 + tests/test_ssx_positive_dimensional_owner.py | 100 - tests/test_ssx_quadratic_constraints.py | 374 -- tests/test_ssx_regular_cell_ownership.py | 92 - tests/test_ssx_regular_empty_separators.py | 77 - tests/test_ssx_residual_contraction.py | 69 - tests/test_ssx_source_alias_assembly.py | 68 - tests/test_ssx_source_boundary_fibers.py | 214 - ...sx_source_boundary_point_representation.py | 87 - tests/test_ssx_source_branch_incidence.py | 143 - tests/test_ssx_source_cofactor_bounds.py | 155 - tests/test_ssx_source_continuum_cut.py | 24 - tests/test_ssx_source_cut_cache.py | 89 - .../test_ssx_source_enclosure_intersection.py | 116 - tests/test_ssx_source_point_incidence.py | 84 - tests/test_ssx_source_residual_exclusion.py | 56 - tests/test_work_budget.py | 4 +- 99 files changed, 6056 insertions(+), 14276 deletions(-) create mode 100644 docs/superpowers/reviews/2026-09-14-ssx-tolerance-repair.md create mode 100644 mmcore/numeric/_bern_roots.py delete mode 100644 mmcore/numeric/intersection/ssx/_ssx_affine_constraints.py delete mode 100644 mmcore/numeric/intersection/ssx/_ssx_affine_path.py delete mode 100644 mmcore/numeric/intersection/ssx/_ssx_arc_ownership.py delete mode 100644 mmcore/numeric/intersection/ssx/_ssx_boundary_fibers.py delete mode 100644 mmcore/numeric/intersection/ssx/_ssx_boundary_identity.py delete mode 100644 mmcore/numeric/intersection/ssx/_ssx_boundary_strata.py delete mode 100644 mmcore/numeric/intersection/ssx/_ssx_cofactor_bounds.py delete mode 100644 mmcore/numeric/intersection/ssx/_ssx_exact_region.py delete mode 100644 mmcore/numeric/intersection/ssx/_ssx_extrusion.py delete mode 100644 mmcore/numeric/intersection/ssx/_ssx_face_residual.py create mode 100644 mmcore/numeric/intersection/ssx/_ssx_isolines.py create mode 100644 mmcore/numeric/intersection/ssx/_ssx_matched_isolines.py delete mode 100644 mmcore/numeric/intersection/ssx/_ssx_opposed_strata.py delete mode 100644 mmcore/numeric/intersection/ssx/_ssx_planar_cut.py delete mode 100644 mmcore/numeric/intersection/ssx/_ssx_positive_dimensional.py delete mode 100644 mmcore/numeric/intersection/ssx/_ssx_quadratic_constraints.py delete mode 100644 mmcore/numeric/intersection/ssx/_ssx_residual_contract.py delete mode 100644 mmcore/numeric/intersection/ssx/_ssx_root_identity.py delete mode 100644 mmcore/numeric/intersection/ssx/_ssx_singular_candidate.py delete mode 100644 mmcore/numeric/intersection/ssx/_ssx_source_cut_cache.py delete mode 100644 mmcore/numeric/intersection/ssx/_ssx_source_residual.py create mode 100644 tests/test_bern_roots_numeric.py create mode 100644 tests/test_ccx4_constant_curve_cad.py create mode 100644 tests/test_ccx_cad_endpoint_tangency.py create mode 100644 tests/test_csx_surface_fiber_tolerance.py delete mode 100644 tests/test_nssx5_certified_seams.py delete mode 100644 tests/test_nssx5_parameter_mapping.py create mode 100644 tests/test_nssx5_practical_representation.py delete mode 100644 tests/test_ssx5_arc_secant_bound.py create mode 100644 tests/test_ssx5_boundary_continuation.py delete mode 100644 tests/test_ssx5_boundary_strata.py create mode 100644 tests/test_ssx5_cad_strata.py delete mode 100644 tests/test_ssx5_exact_extrusion.py delete mode 100644 tests/test_ssx5_exact_tangent_path.py create mode 100644 tests/test_ssx5_fast_singularity_merge.py create mode 100644 tests/test_ssx5_isolines.py create mode 100644 tests/test_ssx5_matched_isolines.py delete mode 100644 tests/test_ssx5_overlap_identity.py create mode 100644 tests/test_ssx5_practical_assembly.py delete mode 100644 tests/test_ssx5_registered_points.py delete mode 100644 tests/test_ssx5_root_identity.py delete mode 100644 tests/test_ssx5_singular_source_candidates.py delete mode 100644 tests/test_ssx_active_owner_budget.py delete mode 100644 tests/test_ssx_affine_constraints.py delete mode 100644 tests/test_ssx_affine_domain.py delete mode 100644 tests/test_ssx_arc_ownership.py delete mode 100644 tests/test_ssx_balanced_partition.py delete mode 100644 tests/test_ssx_boundary_child_ownership.py delete mode 100644 tests/test_ssx_exact_region.py delete mode 100644 tests/test_ssx_exact_root_separation.py delete mode 100644 tests/test_ssx_exact_source_planar_cut.py delete mode 100644 tests/test_ssx_face_residual.py delete mode 100644 tests/test_ssx_fixed_source_face_census.py delete mode 100644 tests/test_ssx_global_injectivity_c3.py delete mode 100644 tests/test_ssx_known_port_ownership.py delete mode 100644 tests/test_ssx_local_boundary_obligations.py create mode 100644 tests/test_ssx_overlap_endpoint_polish.py create mode 100644 tests/test_ssx_overlap_union_cleanup.py delete mode 100644 tests/test_ssx_owned_root_transport.py create mode 100644 tests/test_ssx_partial_boundary_witnesses.py create mode 100644 tests/test_ssx_planar_overlap_clipping.py delete mode 100644 tests/test_ssx_positive_dimensional_owner.py delete mode 100644 tests/test_ssx_quadratic_constraints.py delete mode 100644 tests/test_ssx_regular_cell_ownership.py delete mode 100644 tests/test_ssx_regular_empty_separators.py delete mode 100644 tests/test_ssx_residual_contraction.py delete mode 100644 tests/test_ssx_source_alias_assembly.py delete mode 100644 tests/test_ssx_source_boundary_fibers.py delete mode 100644 tests/test_ssx_source_boundary_point_representation.py delete mode 100644 tests/test_ssx_source_branch_incidence.py delete mode 100644 tests/test_ssx_source_cofactor_bounds.py delete mode 100644 tests/test_ssx_source_continuum_cut.py delete mode 100644 tests/test_ssx_source_cut_cache.py delete mode 100644 tests/test_ssx_source_enclosure_intersection.py delete mode 100644 tests/test_ssx_source_point_incidence.py delete mode 100644 tests/test_ssx_source_residual_exclusion.py diff --git a/docs/superpowers/reviews/2026-09-09-ssx-reliability-report.md b/docs/superpowers/reviews/2026-09-09-ssx-reliability-report.md index 67059afd..e8e18163 100644 --- a/docs/superpowers/reviews/2026-09-09-ssx-reliability-report.md +++ b/docs/superpowers/reviews/2026-09-09-ssx-reliability-report.md @@ -1,5 +1,12 @@ # SSX branch preservation and intersection search audit +> Historical report of the withdrawn exact-topology implementation. Its +> mandatory algebraic acceptance rules regressed working CAD intersections. +> SSX has returned to numerical acceptance at the caller's geometric tolerance; +> rational source certificates and `unresolved_regions` are not its public +> result contract. The passing subsets recorded below did not establish a +> passing integration: the separate singular suite had known failures. + Work branch: `codex/ssx-completeness`, based on `76735f9`. The main checkout and its unrelated `mmcore/numeric/bern.py` edit are preserved. diff --git a/docs/superpowers/reviews/2026-09-14-ssx-tolerance-repair.md b/docs/superpowers/reviews/2026-09-14-ssx-tolerance-repair.md new file mode 100644 index 00000000..acd73d08 --- /dev/null +++ b/docs/superpowers/reviews/2026-09-14-ssx-tolerance-repair.md @@ -0,0 +1,105 @@ +# SSX tolerance repair + +Status: implementation and validation in progress; not yet integrated into `tiny`. + +## Regression and responsibility + +The working reference is `76735f9fc11a5e8149fb60d171f46cacd0ff4ec9`. +The subsequent changes imposed algebraic root-existence and ownership conditions +on a numerical CAD intersection API. This was an implementation decision, not a +requirement agreed with the user. It rejected useful tangent points and overlap +geometry at the requested modeling tolerance. Returning rational diagnostic +regions did not repair the missing geometry. + +The relevant changes, in order, were: + +| Commit | Change and consequence | +| --- | --- | +| `a6d8770` | Added exact parameter-root handling to CCX/CSX; introduced the first pinched-surface regression. | +| `9684760`, `65b0665` | Extended exact ownership and original-residual requirements through the nested curve solvers. | +| `ee5f73a` | Applied the source-topology redesign to SSX and assembly; tangent publication and overlap output regressed. | +| `65dff9d` | Recorded the audit without resolving the failing integration tests. The validation used for integration was insufficient. | +| `ec5c958`, `87bab06`, `4662fab` | Later example, Bernstein sorting, and version changes; preserved by this repair. | + +The reported GitHub failures were not expected or acceptable. The earlier +focused passing runs did not establish that the full suite passed. + +## Repair + +The Bézier and NURBS SSX implementation starts again from the working numerical +baseline. Tangent points, cusp geometry, overlaps, and ordinary branches use the +requested CAD tolerance. Public `unresolved_regions` and the unused exact SSX +machinery are removed. Actual caller-imposed resource limits still retain the +geometry already found and report the existing status fields. + +The repair also addresses numerical defects independently of the withdrawn +algebraic policy: + +- Paired surface parameters and spatial distance both participate in joining and + deduplication, preserving separate sheets that happen to occupy the same place. +- Supplied NURBS weights are retained, including values close to one. Periodic + joins use the actual surface seam and preserve paired parameter paths. +- A failed continuation keeps its boundary seeds available for recovery. Face + tangency uses curvature as well as the first derivative. A collapsed surface + parameter direction is distinguished from a missing spatial branch. +- Recovery prefixes are sampled adaptively on the original surfaces instead of + inserting a long straight displacement chord. +- Planar overlap rims are clipped together and their inverse parameter curves + are refined against both source surfaces. +- Numerical isoline paths avoid redundant four-parameter searches when the + surface structure supports them; unsupported inputs use the general solver. +- Default CAD CCX/CSX no longer wait for optional exact-root certificates. + Constant curves and collapsed target isolines no longer fill the root list + with duplicate representatives of one geometric contact. + +## Test contract + +The original geometric assertions for the 25 failing singular/overlap tests are +retained. Restored cusp assertions also replace the later assertion that a +roundoff-sized gap should erase the cusp. + +Forty test files for the withdrawn internal exact SSX implementation are +retired; none existed at the working baseline commit. Their meaningful public +geometry cases are carried into the CAD strata, +analytic coverage, lifted assembly, and practical NURBS tests. These include +nested circles, clipped boundary lines, high-order rulings, curved tangential +paths, multiple parameter preimages, saddle arms, overlap rims, and seam joins. +The four CI assertions about exact rational enclosures or bit-identical rounded +Sturm roots are not requirements of the restored numerical API. + +The suite still checks complete reference-curve coverage, missing sectors, +retracing, geometric chord error, surface-order reversal, coordinate transforms, +nonuniform weights, and preservation of previously found geometry at a work +limit. Tests of intentionally interrupted internal paths explicitly select that +path; they do not require a faster supported path to perform unnecessary work. + +## Performance evidence + +The call chain is SSX to CSX to boundary CCX. CCX itself does not call CSX. +The repairs address repeated work at both levels: redundant regular-arc seeds +in SSX and duplicate free-parameter representatives in CSX. + +Measured on the same local machine against `4662fab`, using three raw-solver +runs per fixture: + +| Case | Before | Repair | +| --- | --- | --- | +| Endpoint tangency, CCX | 83 cells, about 70 ms, stopped incomplete | 12 cells, about 4.5 ms, complete | +| Pinched isoline, CSX | 301 cells, 127 representatives, about 40 ms | 43 cells, 2 representatives, about 4 ms, complete | +| Regular plane, CSX | 191 cells | 191 cells | +| Fold with two preimages, CSX | 94 cells, both preimages | 94 cells, both preimages | +| User partial-overlap example, CSX | 188 cells, about 32 ms | 198 cells, about 42 ms | + +These are targeted measurements, not a claim that every case became faster. +The endpoint-CSX example also does more work after repair because it now +continues past the former early stop. Source hashes and call/cell measurements +are recorded by the benchmark scripts; elapsed times depend on the machine. + +## Validation + +Final full-suite results, clean Linux build results, and integration commit are +pending. Focused results are diagnostic evidence only until those gates finish. + +The local clean Linux environment is Python 3.12 on Linux aarch64, not the +GitHub-hosted x86_64 runner. A local pass must not be described as a successful +GitHub Actions run. diff --git a/examples/ssx/csx_ccx_completeness_benchmark.py b/examples/ssx/csx_ccx_completeness_benchmark.py index 8c3f9a64..f3b49b84 100644 --- a/examples/ssx/csx_ccx_completeness_benchmark.py +++ b/examples/ssx/csx_ccx_completeness_benchmark.py @@ -187,14 +187,11 @@ def counted(*call_args, **call_kwargs): from examples.ssx.bez_ssx5_case11 import S1, S2 signal.alarm(args.timeout) started = time.perf_counter() - ssx_row = {'name': 'case11-end-to-end', 'mode': 'current-exact', + ssx_row = {'name': 'case11-end-to-end', 'mode': 'current-cad', 'raw_csx_calls': 0, 'raw_csx_cells': 0, 'raw_csx_seconds': 0., - 'nested_ccx_calls': 0, 'nested_ccx_cells': 0, 'nested_ccx_seconds': 0., - 'source_cut_calls': 0, 'source_cut_cells': 0, 'source_cut_seconds': 0.} - from mmcore.numeric.intersection.ssx import _ssx_planar_cut as source_cut_module + 'nested_ccx_calls': 0, 'nested_ccx_cells': 0, 'nested_ccx_seconds': 0.} original_raw_csx = ssx.bez_csx original_nested_ccx = current.bez_ccx_v4 - original_source_cut = source_cut_module.exact_source_planar_cut def count_engine(function, prefix): def measured(*call_args, **call_kwargs): @@ -211,7 +208,6 @@ def measured(*call_args, **call_kwargs): ssx.bez_csx = count_engine(original_raw_csx, 'raw_csx') current.bez_ccx_v4 = count_engine(original_nested_ccx, 'nested_ccx') - source_cut_module.exact_source_planar_cut = count_engine(original_source_cut, 'source_cut') try: result = ssx.bez_ssx(S1, S2, atol=1e-3, rational=False, max_cells=60_000, max_csx_calls=10_000) @@ -224,7 +220,6 @@ def measured(*call_args, **call_kwargs): signal.alarm(0) ssx.bez_csx = original_raw_csx current.bez_ccx_v4 = original_nested_ccx - source_cut_module.exact_source_planar_cut = original_source_cut ssx_row['seconds'] = time.perf_counter()-started print(json.dumps(ssx_row), flush=True) final_hashes = source_hashes() @@ -249,8 +244,7 @@ def measured(*call_args, **call_kwargs): output = previous output.setdefault('ssx_history', []).append(output['ssx']) output['ssx'] = ssx_row - output['call_graph'] = ['SSX -> exact source cut or source-residual CSX', - 'public/default CSX -> boundary CCX', 'CCX does not call CSX'] + output['call_graph'] = ['SSX -> CSX -> boundary CCX', 'CCX does not call CSX'] if args.output: args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(output, indent=2)+'\n') diff --git a/examples/ssx/source_world_mix_benchmark.py b/examples/ssx/source_world_mix_benchmark.py index c304a033..d6358430 100644 --- a/examples/ssx/source_world_mix_benchmark.py +++ b/examples/ssx/source_world_mix_benchmark.py @@ -34,7 +34,6 @@ def main(): args = parser.parse_args() from mmcore.numeric.intersection.ssx import _bez_ssx5 as ssx from mmcore.numeric.intersection.csx import _bez_csx4 as csx - from mmcore.numeric.intersection.ssx import _ssx_planar_cut as planar from examples.ssx.ssx5_analytic_audit import distances_to_polylines files = sorted(set((ROOT/'mmcore/numeric/intersection').rglob('*.py')) @@ -53,7 +52,7 @@ def hashes(): matrices = [('identity', np.eye(3)), ('mixed', np.array([[1., 1., 0.], [0., 1., 1.], [1., 0., 1.]])), ('sheared', np.array([[1., 2., 1.], [0., 1., 1.], [1., 1., 1.]]))] - original = (ssx.bez_csx, csx.bez_ccx_v4, planar.exact_source_planar_cut) + original = (ssx.bez_csx, csx.bez_ccx_v4) def expired(*_): raise TimeoutError signal.signal(signal.SIGALRM, expired) @@ -64,8 +63,7 @@ def expired(*_): first, second = second, first row = dict(world_transform=label, matrix=matrix.tolist(), swapped=swap, raw_csx_calls=0, raw_csx_cells=0, raw_csx_seconds=0., - nested_ccx_calls=0, nested_ccx_cells=0, nested_ccx_seconds=0., - source_cut_calls=0, source_cut_cells=0, source_cut_seconds=0.) + nested_ccx_calls=0, nested_ccx_cells=0, nested_ccx_seconds=0.) def count(function, prefix): def measured(*a, **kw): row[prefix+'_calls'] += 1 @@ -80,7 +78,6 @@ def measured(*a, **kw): return measured ssx.bez_csx = count(original[0], 'raw_csx') csx.bez_ccx_v4 = count(original[1], 'nested_ccx') - planar.exact_source_planar_cut = count(original[2], 'source_cut') started = time.perf_counter() signal.alarm(args.timeout) try: @@ -94,7 +91,7 @@ def measured(*a, **kw): row['timeout'] = True finally: signal.alarm(0) - ssx.bez_csx, csx.bez_ccx_v4, planar.exact_source_planar_cut = original + ssx.bez_csx, csx.bez_ccx_v4 = original row['solve_seconds'] = time.perf_counter()-started if result is not None: paths = [np.asarray(branch.curve[1]) for branch in result['branches']] diff --git a/mmcore/numeric/_bern_roots.py b/mmcore/numeric/_bern_roots.py new file mode 100644 index 00000000..51cbccbe --- /dev/null +++ b/mmcore/numeric/_bern_roots.py @@ -0,0 +1,108 @@ +"""Numerical real roots of a scalar Bernstein polynomial on its domain. + +Derivative roots divide the domain into monotone intervals. This avoids +enumerating a flat multiple root as hundreds of unrelated small residuals. +The caller still checks the resulting geometric candidate at its own +modeling tolerance. +""" +from dataclasses import dataclass + +import numpy as np +from scipy.optimize import brentq + + +@dataclass(frozen=True) +class BernsteinRoots: + roots: tuple[float, ...] + constant_zero: bool + complete: bool + work: int + + +def bernstein_roots(coeffs, *, parameter_tol=1e-12, charge=None): + """Find numerical roots, including stationary roots, in ``[0, 1]``. + + ``parameter_tol`` controls root location, not a polynomial residual + band. ``charge`` optionally accepts one work unit and returns whether + it was available. An incomplete result must not replace a general + intersection search. Coefficient roundoff can still limit root + separation; this routine does not return algebraic certificates. + """ + coefficients = np.asarray(coeffs, dtype=float) + if coefficients.ndim != 1 or not len(coefficients): + raise ValueError("coeffs must be a nonempty vector") + if not np.isfinite(coefficients).all(): + raise ValueError("coeffs must be finite") + if not np.isfinite(parameter_tol) or parameter_tol <= 0: + raise ValueError("parameter_tol must be finite and positive") + work = 0 + machine_eps = np.finfo(float).eps + + class Exhausted(Exception): + pass + + def spend(): + nonlocal work + if charge is not None and not charge(1): + raise Exhausted + work += 1 + + def evaluate(net, parameter): + spend() + values = net.copy() + for length in range(len(values)-1, 0, -1): + values[:length] = ((1-parameter)*values[:length] + + parameter*values[1:length+1]) + return float(values[0]) + + def solve(net): + spend() + scale = float(np.max(np.abs(net))) + if scale == 0: + return [] + net = net / scale + degree = len(net)-1 + if degree == 0: + return [] + if degree == 1: + slope = net[1]-net[0] + if slope == 0: + return [] + root = -net[0]/slope + return [float(root)] if 0 <= root <= 1 else [] + + derivative = degree*np.diff(net) + # Degree elevation introduces small differences in an otherwise + # constant net. It cannot create a zero far from this constant. + error = 16*degree*machine_eps + if float(np.max(np.abs(derivative))) <= error: + return [] + critical = solve(derivative) + knots = [0.] + [x for x in critical if 0 < x < 1] + [1.] + values = [evaluate(net, x) for x in knots] + roots = [] + bracketed = set() + for index, (left, right, fleft, fright) in enumerate( + zip(knots, knots[1:], values, values[1:])): + if fleft == 0 or fright == 0 or np.signbit(fleft) == np.signbit(fright): + continue + roots.append(float(brentq( + lambda x: evaluate(net, x), left, right, + xtol=parameter_tol, rtol=4*machine_eps))) + bracketed.update((index, index+1)) + for index, (x, value) in enumerate(zip(knots, values)): + # A shallow extremum must not replace its two sign-changing + # roots. They can be far apart in model space even when the + # height between them is close to arithmetic roundoff. + interior_touch = (0 < index < len(knots)-1 + and index not in bracketed and abs(value) <= error) + if value == 0 or interior_touch: + roots.append(x) + roots.sort() + return roots + + try: + roots = solve(coefficients) + except Exhausted: + return BernsteinRoots((), False, False, work) + return BernsteinRoots(tuple(roots), bool(np.all(coefficients == 0)), True, work) diff --git a/mmcore/numeric/intersection/ccx/_bez_ccx4.py b/mmcore/numeric/intersection/ccx/_bez_ccx4.py index 2c30aef7..ef654b12 100644 --- a/mmcore/numeric/intersection/ccx/_bez_ccx4.py +++ b/mmcore/numeric/intersection/ccx/_bez_ccx4.py @@ -1335,10 +1335,9 @@ def _phase2_ccx(F, C1, C2, C1_orig, C2_orig, (exact) tier cannot accept contribute net-certified minimum candidates ``(d_hat, eps_d, u, v)`` to the pool. The pool is drained ONCE by the caller (component merge + membership), so this function never decides - tolerance membership on its own. With ``tol_pool=None`` the legacy - exact-only behavior is preserved bit-for-bit (nested engine callers - consume exact boundary zeros; their own tolerance semantics are a - separate contract). + tolerance membership on its own. With ``tol_pool=None`` the caller + requests the separate exact-only acceptance path. CAD callers also + accept polished roots without an algebraic existence certificate. """ from mmcore.numeric.intersection._sq_dist_classify import ( _check_min_of_net, _check_lipschitz, _weight_max_product, @@ -1350,6 +1349,28 @@ def _phase2_ccx(F, C1, C2, C1_orig, C2_orig, exhausted = False component_scale = _ccx_exactness_context( C1_orig, C2_orig, rational) + # A constant input curve contributes a free parameter, so its contacts + # cannot be isolated in the two-parameter product. In the modeling + # tier, retain the ordinary point representatives and resolve the + # varying curve by subdivision and distance measurement. Requiring a + # square root certificate here turns every valid fiber representative + # into a spurious resolution failure. Test the ORIGINAL control + # polygon, not a small current piece: a folded nonconstant curve still + # needs its separate parameter preimages searched and retained. + constant_parameter = False + if tol_pool is not None: + for source in (C1_orig, C2_orig): + points = _cartesian_curve_controls_for_exactness(source, rational) + if (points is not None and len(points) + and np.all(points == points[0])): + constant_parameter = True + break + # CAD contacts are validated by numerical polishing and the + # modeling distance test. An optional exact-root certificate is + # not a prerequisite for a tangential or rounded endpoint contact. + # Keep these obligations only for the explicitly exact-only API; + # cell, output and depth limits still apply to both modes. + unresolved_root_boxes = None if rational: left = C1_orig[:, None, :-1] * C2_orig[None, :, -1:] right = C2_orig[None, :, :-1] * C1_orig[:, None, -1:] @@ -1361,6 +1382,8 @@ def _phase2_ccx(F, C1, C2, C1_orig, C2_orig, residual_error = residual_roundoff_bound(root_net, depth=4, source_scale=root_source) def root_neighborhood_resolved(u, v, record=True): + if tol_pool is not None: + return None, None, None box = unique_root_box(root_net, (u, v), (ptol_u, ptol_v), root_source) certificate = root_existence_certificate( C1_orig, C2_orig, (u, v), box, root_net, rational, root_source) @@ -1401,8 +1424,9 @@ def attach_certificate(entry, box, certificate, known): if box is not None: entry['parameter_uniqueness_box'] = box - # Known boundary roots own only proved product neighborhoods. Their - # first-curve parameter alone says nothing about other partner sheets. + # Attach optional exact-mode certificates to known boundary roots. + # Neither mode removes the entire first-curve parameter slab: other + # partner parameters can contain separate contacts. for entry in isolated: box, certificate, known = root_neighborhood_resolved(entry['u'], entry['v'], record=False) attach_certificate(entry, box, certificate, known) @@ -1423,9 +1447,8 @@ def attach_certificate(entry, box, certificate, known): seg1, seg2, F_cell, pw, qw, u0, u1, v0, v1, depth = stack.pop() #print(f"CCX: {cells} cells: {( ( u0, u1), (v0, v1), depth)}") - # The public modeling tier also preserves exact-root obligations. - # A rounded distance/derivative gate may only delete a cell after - # the original-source residual has excluded every exact zero. + # Use the unsquared source residual to guard the distance and + # derivative pruning against cancellation in the squared net. residual_cell = restrict_net_axis_v(root_net, 0, u0, u1, 0., 1.) residual_cell = restrict_net_axis_v(residual_cell, 1, v0, v1, 0., 1.) zero_free = residual_hull_excludes_zero(residual_cell, residual_error) @@ -1666,10 +1689,12 @@ def attach_certificate(entry, box, certificate, known): unresolved_root_boxes[-1]['v_range'] = (v0, v1) root_found = True break - # An unproved neighborhood remains explicitly reserved - # in exact mode. With a proof, the same product cutout - # applies to both new roots and known boundary roots. - if box is not None or (unresolved_root_boxes is not None and tol_pool is None): + # CAD mode cuts only the paired modeling neighborhood + # of a polished root. Exact mode requires its optional + # certificate; a constant curve keeps its free axis. + if (box is not None + or (tol_pool is not None and not constant_parameter) + or (unresolved_root_boxes is not None and tol_pool is None)): sub_cells = _cutout_2d( F_cell, seg1, seg2, pw, qw, u0, u1, v0, v1, depth, float(u_sol), float(v_sol), ptol_u, ptol_v, rational, @@ -1783,9 +1808,11 @@ def bez_ccx( ``isolated`` entry carries ``certification`` (``'exact'`` = agreement inside the strict roundoff envelope; ``'tolerance'`` = a certified near-miss minimum) and ``d_min`` (the net-certified measured distance; - 0.0 for exact roots). The tag is metadata — membership never depends - on it. Per component of ``{D <= atol}``: certified zeros inside → - exact roots only; zero-free and compact → exactly ONE contact at the + 0.0 for numerically polished roots). The tag is metadata — membership + never depends on an algebraic existence proof. Numerically polished + roots retain distinct parameter preimages beyond the modeling radii; + roots inside one modeling neighborhood may share a representative. + Per component of ``{D <= atol}``: zero-free and compact → ONE contact at the certified argmin (there is no "band" outcome — a long sub-``atol`` graze is still one tangent contact); touching a domain edge → an endpoint contact from the lifted Phase-1 boundary analysis; @@ -1793,9 +1820,8 @@ def bez_ccx( candidate whose measurement envelope straddles the ``atol`` boundary at decision scale returns typed ``uncertified_contacts`` (cannot-decide, never a guess) with ``boundary_topology_complete=False``. - ``tolerance_tier=False`` restores exact-only acceptance for engine - callers that consume level-0 boundary zeros (the nested CSX call; its - own tolerance semantics are a separate ledger item). + ``tolerance_tier=False`` requests the separate exact-only acceptance + path. Nested CAD CSX calls use the default tolerance contract. """ C1 = np.asarray(C1, dtype=np.float64) C2 = np.asarray(C2, dtype=np.float64) @@ -1809,7 +1835,7 @@ def _result(isolated, overlaps, *, topology_complete=True): "overlaps": overlaps, "budget_exhausted": bool(budget_exhausted), "cells_processed": int(cells.processed), - "boundary_topology_complete": bool(topology_complete), + "boundary_topology_complete": bool(topology_complete and not budget_exhausted), } if cells.remaining <= 0: @@ -2250,6 +2276,12 @@ def _finalize(topology_complete=True): span = uncertified_span_evidence or (0.0, 1.0) res["uncertified_overlap_span"] = ( float(span[0]), float(span[1])) + if (cells.remaining > 0 and len(isolated) < max_results + and non_affine_overlap_cells_remaining <= 0): + # The internal non-affine-overlap fallback ended; neither + # public allowance ran out. Preserve this distinction for + # nested CSX/SSX instead of asking callers for more work. + res["truncation_cause"] = "overlap" if tol_undecided: # L62 typed cannot-decide (the |coords| >~ atol/eps tail): # membership at these candidates is not measurable at the atol diff --git a/mmcore/numeric/intersection/csx/_bez_csx4.py b/mmcore/numeric/intersection/csx/_bez_csx4.py index ec6d8102..20945654 100644 --- a/mmcore/numeric/intersection/csx/_bez_csx4.py +++ b/mmcore/numeric/intersection/csx/_bez_csx4.py @@ -48,6 +48,47 @@ _subdivide_curve = subdivide_curve +def _surface_fibers_at_point(surface, u, v, point, atol, rational): + """Free surface axes whose entire control hull fits the CAD tolerance. + + At a pinched isoline, changing its free parameter produces more + representatives of the same geometric hit. Keep the boundary witnesses + but reuse their coverage while searching the remaining parameter box. + Positive rational weights give the same Cartesian convex-hull bound. + """ + def fits(net): + if not np.all(np.isfinite(net)): + return False + if rational: + if np.any(net[..., -1] <= 0.): + return False + points = net[..., :-1]/net[..., -1:] + else: + points = net + return bool(np.all(np.linalg.norm(points-point, axis=-1) <= atol)) + + if fits(surface): + return ((1, 2),) + fibers = [] + for fixed_axis, value in ((0, u), (1, v)): + left, _ = _subdivide_surface(surface, fixed_axis, float(value)) + isoline = left[-1, :, :] if fixed_axis == 0 else left[:, -1, :] + if fits(isoline): + fibers.append((2-fixed_axis,)) + return tuple(fibers) + + +def _matching_surface_fiber(entry, root, point, atol, tolerances): + if np.linalg.norm(point-entry['point']) > atol: + return () + parameters = (entry['t'], entry['u'], entry['v']) + for free_axes in entry.get('_surface_fibers', ()): + if all(axis in free_axes or abs(a-b) <= 2.*tol + for axis, (a, b, tol) in enumerate(zip(parameters, root, tolerances))): + return free_axes + return () + + def _subdivide_surface(ctrl, axis, t=0.5): n = ctrl.shape[axis] - 1 tmp = np.moveaxis(ctrl.copy(), axis, 0) @@ -907,6 +948,7 @@ def _boundary_zero_from_tuv_like(bz: BoundaryZero, t, u, v) -> BoundaryZero: 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, diagnostics=None, + tolerance_tier=False, ): """Find precise intersection points on the boundary faces of the CSX domain. @@ -1063,15 +1105,14 @@ def _find_csx_boundary_zeros( if cells.remaining <= 0: exhausted = True break - # L62: exact-only. This nested call consumes level-0 boundary zeros - # for the CSX boundary analysis; CCX's isolated tolerance tier - # (membership at atol) is a different contract, and whether CSX - # wants its own isolated-contact tier is a separate ledger item. + # Keep the nested solver on the caller's contract. CAD-tolerance + # CSX must not stop on an optional exact-root existence obligation; + # its candidates still pass the boundary polishing below. ccx_result = bez_ccx_v4( C, iso_curve, atol=atol, rational=rational, max_cells=cells.remaining, max_results=max(0, max_results - len(zeros)), - tolerance_tier=False, + tolerance_tier=tolerance_tier, ) ccx_cells = int(ccx_result.get("cells_processed", 0)) ccx_cells = min(ccx_cells, cells.remaining) @@ -1609,6 +1650,16 @@ def exact_source_candidate(root, box, cell_box): and v0 >= _e["v"] - 2.0 * ptol_v and v1 <= _e["v"] + 2.0 * ptol_v): _near_known = True break + for free_axes in _e.get('_surface_fibers', ()): + if all(axis in free_axes or (lo >= p-2.*tol and hi <= p+2.*tol) + for axis, (lo, hi, p, tol) in enumerate(zip( + (t0, u0, v0), (t1, u1, v1), + (_e['t'], _e['u'], _e['v']), + (ptol_t, ptol_u, ptol_v)))): + _near_known = True + break + if _near_known: + break if exact_topology and not _near_known and depth >= max_depth: ownership = _certify_csx_owned_cell( G_original,((t0,t1),(u0,u1),(v0,v1)),isolated,source_scale, @@ -1821,6 +1872,13 @@ def exact_source_candidate(root, box, cell_box): is_new = not _is_duplicate(isolated, t_sol, u_sol, v_sol, pt, atol, ptol_t, ptol_u, ptol_v, exact_topology=exact_topology) + for known in isolated: + free_axes = _matching_surface_fiber( + known, root, pt, atol, (ptol_t, ptol_u, ptol_v)) + if free_axes: + radii = tuple(1. if axis in free_axes else radius + for axis, radius in enumerate(radii)) + break if is_new: # A genuine root. Record it regardless of which cell it # lies in — _is_duplicate protects against a double add @@ -2312,6 +2370,7 @@ def _append_fiber(candidate): F, C, S, atol, ptol_t, ptol_u, ptol_v, rational, max_cells=cells.remaining, max_results=boundary_result_cap, diagnostics=boundary_diagnostics, + tolerance_tier=tolerance_tier, ) cells.spend(boundary_cells) if boundary_exhausted: @@ -2399,6 +2458,20 @@ def _append_fiber(candidate): # not this t slab across the entire surface. Phase 2 # receives it as a known point and cuts its product box. + if tolerance_tier: + # Recognize free target parameters before Phase2 fills its result + # budget with one geometric hit. Other surface preimages at the + # same curve parameter remain in the full-product search. + probe_work = max(1, (2*S.size+127)//128) + for entry in isolated: + if cells.remaining < probe_work: + break + cells.spend(probe_work) + fibers = _surface_fibers_at_point( + S, entry['u'], entry['v'], entry['point'], atol, rational) + if fibers: + entry['_surface_fibers'] = fibers + # 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). @@ -2709,7 +2782,13 @@ def _run_is_continuum(run_entries): return False return True - verified = [r for r in runs if len(r) >= 3 and _run_is_continuum(r)] + # Many surface parameters can represent one geometric hit on C. + # A collapsed target isoline is not an overlap interval on C; its + # representatives must span more than the curve's CAD resolution + # before they can arm this continuum fallback. + verified = [r for r in runs if len(r) >= 3 + and r[-1][0]-r[0][0] > ptol_t + and _run_is_continuum(r)] if verified: largest = max(verified, key=len) non_affine_overlap_span = (largest[0][0], largest[-1][0]) @@ -2731,6 +2810,7 @@ def _run_is_continuum(run_entries): truncation_cause = 'resolution' for entry in isolated: entry.pop('_boundary_root_proof', None) + entry.pop('_surface_fibers', None) entry.pop('_existence_established', None) box = entry.pop('_unique_box', None) if box is not None: @@ -2778,6 +2858,9 @@ def _is_duplicate(isolated, t,u,v, pt, atol, ptol_t,ptol_u,ptol_v, if (entry['t'], entry['u'], entry['v']) == (t, u, v): return True continue + if _matching_surface_fiber(entry, (t, u, v), pt, atol, + (ptol_t, ptol_u, ptol_v)): + return True # Two independently refined representatives of one root can each # sit up to one parametric tolerance from it (boundary vs Phase-2 # is the common pairing), hence a 2*ptol comparison. Retain the diff --git a/mmcore/numeric/intersection/ssx/_bez_ssx5.py b/mmcore/numeric/intersection/ssx/_bez_ssx5.py index 8ec66a6e..20fc2cba 100644 --- a/mmcore/numeric/intersection/ssx/_bez_ssx5.py +++ b/mmcore/numeric/intersection/ssx/_bez_ssx5.py @@ -1,13 +1,13 @@ """Bezier surface-surface intersection v5. -Combines four approaches: -1. Source-bounded vector-residual Bernstein hull exclusion +Combines three approaches: +1. Sq-dist Bernstein net for Lipschitz pruning (from CCX/CSX v4) 2. TΨᵢ monotonicity criterion for loop-absence certification (Cheng et al. 2023) 3. Domain decomposition at boundary crossing points (Krishnan & Manocha 1997) 4. Deflation for tangential (C₂) cases (Cheng et al. 2023) Architecture: - Level 1: Pruning (AABB + vector-residual separating hulls) + Level 1: Pruning (AABB + sq-dist Lipschitz) Level 2: Boundary analysis (8 CSX problems on faces of [0,1]⁴) Level 3: Monotonicity classification (TΨᵢ sign-definiteness) Level 4a: Domain decomposition + tracing (monotonic cells) @@ -24,10 +24,14 @@ import numpy as np from numpy.typing import NDArray +from mmcore.numeric.bern_sq_dist import surface_surface_distance_squared_net_homog from mmcore.numeric._bezier_common import ( - eval_surface, eval_surface_d1, eval_curve, + extract_weights, eval_surface, eval_surface_d1, eval_curve, geometry_collapsed, ) +from mmcore.numeric.intersection._sq_dist_classify import ( + _check_min_of_net, _check_lipschitz, _weight_max_product, +) from mmcore.numeric.intersection.csx._bez_csx4 import bez_csx from mmcore.numeric.intersection._deflate import minors_Tpsi_from_control_nets from mmcore.numeric._aabb import aabb, aabb_intersect @@ -115,7 +119,6 @@ class BoundaryPoint: registrations: list[IsolineRegistration] = field(default_factory=list) parameter_fiber: bool = False # unresolved free parameter on a collapsed edge multiplicity_polished: bool = False # nearby CSX samples collapsed to this root - root_box: object = None # certified enclosure in global stuv, when available # Back-compat alias — existing code uses BoundaryCrossing in many places and @@ -124,203 +127,6 @@ class BoundaryPoint: BoundaryCrossing = BoundaryPoint -def _mapped_root_box(root, fixed_axis, fixed_value, axis_maps): - from fractions import Fraction - raw = root.get('parameter_root_box') - if raw is None: - return None - box = np.empty((4, 2), dtype=float) - box[fixed_axis] = fixed_value - for interval, (axis, lo, hi) in zip(raw, axis_maps): - origin = Fraction.from_float(float(lo)) - span = Fraction.from_float(float(hi))-origin - for side in (0, 1): - exact = origin+span*Fraction.from_float(float(interval[side])) - rounded = float(exact) - if ((side == 0 and Fraction.from_float(rounded) > exact) - or (side == 1 and Fraction.from_float(rounded) < exact)): - rounded = np.nextafter(rounded, -np.inf if side == 0 else np.inf) - box[axis, side] = rounded - return box - - -def _registered_point(boundary_point): - """Retain a traced registration's identity on point-like output.""" - point = SSXPoint(stuv=boundary_point.stuv, xyz=boundary_point.xyz) - point._registered_root_id = id(boundary_point) - point._registered_root = boundary_point - return point - - -def _remove_represented_vertex_points(points, branches, xyz_tol, work_budget=None): - """Geometric vertex lookup; this does not prove source-root incidence.""" - if not points or not branches: - return list(points) - work = len(points)+sum(len(branch.curve[0]) for branch in branches) - if not _assembly_spend(work_budget, max(1, work)): - return list(points) - vertices = {} - for branch in branches: - for q, xyz in zip(*branch.curve): - if np.all(np.isfinite(q)) and np.all(np.isfinite(xyz)): - vertices.setdefault(tuple(q), []).append(np.asarray(xyz)) - kept = [] - for index, point in enumerate(points): - candidates = vertices.get(tuple(point.stuv), ()) - if not _assembly_spend(work_budget, len(candidates)): - kept.extend(points[index:]) - break - if not any(np.linalg.norm(point.xyz-xyz) <= xyz_tol for xyz in candidates): - kept.append(point) - return kept - - -def _remove_registered_endpoint_points(points, branches, param_tol, xyz_tol, - root_matcher, work_budget=None): - """Coalesce standalone events only with proven branch endpoint roots. - -Live BoundaryPoint references preserve the source certificate and prevent -object-id reuse. Parameter/XYZ proximity merely selects comparisons; the -source identity oracle must prove a common existing root before deletion. - """ - owners = {key: point for branch in branches - for key, point in getattr(branch, '_registered_root_points', {}).items()} - if not owners: - return list(points) - roots = list(owners.values()) - root_parameters = np.asarray([point.stuv for point in roots]) - root_xyz = np.asarray([point.xyz for point in roots]) - kept = [] - for index, point in enumerate(points): - registered = getattr(point, '_registered_root', None) - if registered is None: - kept.append(point) - continue - if id(registered) in owners: - continue - if not _assembly_spend(work_budget, max(1, len(roots))): - kept.extend(points[index:]) - break - nearby = np.flatnonzero( - np.all(np.abs(root_parameters-registered.stuv) <= param_tol, axis=1) - & (np.linalg.norm(root_xyz-registered.xyz, axis=1) <= xyz_tol)) - owned = False - for candidate in nearby: - if getattr(root_matcher, 'exhausted', False): - break - if root_matcher(registered, roots[int(candidate)], param_tol, xyz_tol): - owned = True - break - if not owned: - kept.append(point) - return kept - - -def _remove_lifted_polyline_points(points, branches, xyz_tol, work_budget=None): - """Geometric chord lookup; this does not prove source-root incidence.""" - from mmcore.numeric.intersection.ssx._ssx_polyline import point_matches_polyline - - count = sum(max(0, len(branch.curve[0])-1) for branch in branches) - if not points or not count: - return list(points) - construction = count*(1+int(math.ceil(math.log2(max(2, count))))) - if not _assembly_spend(work_budget, construction): - return list(points) - parameters, images = [], [] - for branch in branches: - q, xyz = map(np.asarray, branch.curve) - for index in range(len(q)-1): - if np.all(np.isfinite(q[index:index+2])) and np.all(np.isfinite(xyz[index:index+2])): - parameters.append(q[index:index+2]) - images.append(xyz[index:index+2]) - if not parameters: - return list(points) - parameters, images = np.asarray(parameters), np.asarray(images) - lower, upper = parameters.min(axis=1), parameters.max(axis=1) - centers = .5*lower+.5*upper - order_axis = int(np.argmax(np.ptp(centers, axis=0))) - order = np.argsort(centers[:, order_axis], kind='stable') - - def build(indices): - lo, hi = lower[indices].min(axis=0), upper[indices].max(axis=0) - if len(indices) <= 8: - return lo, hi, indices, None - midpoint = len(indices)//2 - return lo, hi, None, (build(indices[:midpoint]), build(indices[midpoint:])) - - tree = build(order) - kept = [] - for index, point in enumerate(points): - pending, contained, denied = [tree], False, False - while pending and not contained: - if not _assembly_spend(work_budget): - denied = True - break - lo, hi, leaf, children = pending.pop() - if not np.all((lo <= point.stuv) & (point.stuv <= hi)): - continue - if children is not None: - pending.extend(children) - continue - if not _assembly_spend(work_budget, len(leaf)): - denied = True - break - for candidate in leaf: - if not np.all((lower[candidate] <= point.stuv) & (point.stuv <= upper[candidate])): - continue - if point_matches_polyline(point.stuv, point.xyz, - parameters[candidate], images[candidate], np.zeros(4), xyz_tol): - contained = True - break - if denied: - kept.extend(points[index:]) - break - if not contained: - kept.append(point) - return kept - - -def _remove_source_incident_points(points, branches, param_tol, xyz_tol, - root_matcher, work_budget=None): - """Delete point output only when a represented source root owns it. - - A separate isolated source root can lie exactly on an approximation - chord of another component. Even exact lifted-polyline membership is - therefore insufficient; actual registrations or a source identity - certificate must establish incidence. - """ - registered = frozenset().union(*( - getattr(branch,'_registered_root_ids',frozenset()) for branch in branches)) - retained = [point for point in points - if getattr(point,'_registered_root_id',None) not in registered] - return _remove_registered_endpoint_points( - retained,branches,param_tol,xyz_tol,root_matcher,work_budget) - - -def _remove_source_classified_points(points, singularities, root_matcher, - param_tol, xyz_tol, work_budget=None): - """Coalesce point output with a classification of the same source root.""" - classified = [getattr(feature, '_registered_root', None) - for feature in singularities if feature.kind == 'tangent_point'] - classified = [root for root in classified if root is not None] - if not classified: - return list(points) - kept = [] - for index, point in enumerate(points): - root = getattr(point, '_registered_root', None) - if root is None: - kept.append(point) - continue - if not _assembly_spend(work_budget, max(1, len(classified))): - kept.extend(points[index:]) - break - if not any(root is candidate or (root_matcher is not None and - root_matcher(root, candidate, param_tol, xyz_tol)) - for candidate in classified): - kept.append(point) - return kept - - @dataclass class SSXSingularity: """A certified singular feature of the SSI (Cheng et al. 2023 C1/C2/C3). @@ -386,8 +192,7 @@ def _point_dedup_charge(n: int) -> int: return max(1, (int(n) * 108 + 127) // 128) -def _deduplicate_ssx_points(points, unify_tol, atol, stats=None, *, - exact_topology=False,root_matcher=None): +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 @@ -395,8 +200,7 @@ def _deduplicate_ssx_points(points, unify_tol, atol, stats=None, *, 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. Exact-topology - callers additionally require parameter equality, retaining nearby roots. + established matching-ladder predicate and insertion order. """ if not points: if stats is not None: @@ -449,21 +253,8 @@ def _keys(p): bucket_probes += 1 for q in xyz_buckets.get(xneighbor, ()): comparisons += 1 - parameter_match = (np.array_equal(p.stuv, q.stuv) - if exact_topology else - np.all(np.abs(np.asarray(p.stuv) - - np.asarray(q.stuv)) <= pstep)) - if exact_topology and parameter_match: - first = getattr(p,'_registered_root',None) - second = getattr(q,'_registered_root',None) - if first is not None or second is not None: - # Equal displayed tuples can alias distinct - # algebraic roots. Existing source ownership - # outranks that floating comparison. - parameter_match = bool(first is not None and second is not None - and (first is second or (root_matcher is not None - and root_matcher(first,second,pstep,2.*abs(float(atol)))))) - if (parameter_match + 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))): @@ -497,67 +288,73 @@ def _ssx_control_aabbs_disjoint(S1_h, S2_h, rational=True): pts2 = S2_h bb1 = np.array(aabb(pts1.reshape(-1, pts1.shape[-1]))) bb2 = np.array(aabb(pts2.reshape(-1, pts2.shape[-1]))) - if rational: - # Each Cartesian control coordinate contains one rounded division. - # Expand its hull outward before a destructive exact-set exclusion. - for bounds in (bb1, bb2): - bounds[0] = np.nextafter(bounds[0], -np.inf) - bounds[1] = np.nextafter(bounds[1], np.inf) return not aabb_intersect(bb1, bb2) -def _ssx_residual_source_scale(S1_h, S2_h): - """Original product operands, before residual cancellation/restriction.""" - p1 = np.max(np.abs(S1_h[..., :3]), axis=(0, 1)) - p2 = np.max(np.abs(S2_h[..., :3]), axis=(0, 1)) - w1 = float(np.max(np.abs(S1_h[..., 3]))) - w2 = float(np.max(np.abs(S2_h[..., 3]))) - return p1*w2 + p2*w1 - +def _distance_net_lower_bound(S1, S2, F, rational): + """Guard squared-distance exclusions against construction roundoff. -def _ssx_residual_excludes_zero(S1_h, S2_h, *, source_scale=None, - source_degree=None, depth=0): - """Separate the vector residual with a source-bounded Bernstein hull. - - Squaring residuals creates cancellation between Gram products. A - positive computed squared-distance lower bound can therefore exclude - an exact root. Here any separating direction is only a proposal; - its scalar Bernstein hull must clear the propagated operand error. + A small squared residual can result from cancellation of large Gram + terms. Its error scale comes from those operands, not from the final + residual. Bias the net down before subdivision propagates that error. """ - from mmcore.numeric.intersection.ssx._ssx5_singular import psi_vector_net - from mmcore.numeric.intersection.csx._bez_csx4 import ( - _residual_excludes_zero, _residual_aligned_excludes_zero) - if source_scale is None: - source_scale = _ssx_residual_source_scale(S1_h, S2_h) - if source_degree is None: - source_degree = sum(n-1 for n in S1_h.shape[:2]+S2_h.shape[:2]) - # Each surface restriction consists of convex de Casteljau operations. - # Bound both product operands through all ancestor restrictions before - # subtracting them; never scale error by the already-cancelled residual. - eps = float(np.finfo(np.float64).eps) - operations = 4 + 6*int(source_degree)*(int(depth)+1) - if operations*eps >= 1.0: - return False - error = np.nextafter( - (operations*eps/(1.0-operations*eps))*source_scale - +(operations+3)*np.nextafter(0.,1.), np.inf) - residual = psi_vector_net(S1_h, S2_h) - return (_residual_excludes_zero(residual, error) - or _residual_aligned_excludes_zero(residual, error)) + first, w1 = extract_weights(S1, rational=rational) + second, w2 = extract_weights(S2, rational=rational) + operands = (np.max(np.abs(first), axis=(0, 1)) * np.max(np.abs(w2)) + + np.max(np.abs(second), axis=(0, 1)) * np.max(np.abs(w1))) + operations = 64 * w1.size * w2.size + epsilon = operations * np.finfo(float).eps + error = (epsilon / (1.0 - epsilon)) * float(operands @ operands) + return np.asarray(F) - error + + +def _try_isoline_intersection(first, second, atol, budget): + """Use a numerical one-variable reduction when the surface charts allow it.""" + from mmcore.numeric.intersection.ssx._ssx_isolines import try_isoline_ssx, try_plane_coincidence + from mmcore.numeric.intersection.ssx._ssx_matched_isolines import try_matched_isoline_ssx + from mmcore.numeric.intersection.ssx._ssx5_overlap import try_planar_boundary_intersection + result = try_planar_boundary_intersection(first, second, atol, budget) + if result is not None or budget.exhausted: + return result + result = try_plane_coincidence(first, second, atol, budget) + if result is not None or budget.exhausted: + return result + result = try_isoline_ssx(first, second, atol, budget) + if result is not None or budget.exhausted: + return result + return try_matched_isoline_ssx(first, second, atol, budget) def _prune_ssx_cell(S1_h, S2_h, atol, rational=True, F=None): - """Prove a patch pair empty using control hulls and vector residuals. + """Return True if this patch pair provably does NOT intersect. - ``F`` remains accepted for caller compatibility, but its floating - squared-distance coefficients are not an exact-zero certificate. + Checks: + 1. AABB non-overlap (Euclidean control points) + 2. Min-of-net on 4-variate sq-dist Bernstein net + 3. Lipschitz tightening on sq-dist net """ + # AABB check on Euclidean control points + _, S1w = extract_weights(S1_h, rational=rational) + _, S2w = extract_weights(S2_h, rational=rational) + if _ssx_control_aabbs_disjoint(S1_h, S2_h, rational=rational): return True - if not rational: - S1_h = np.concatenate([S1_h, np.ones(S1_h.shape[:-1]+(1,))], axis=-1) - S2_h = np.concatenate([S2_h, np.ones(S2_h.shape[:-1]+(1,))], axis=-1) - return _ssx_residual_excludes_zero(S1_h, S2_h) + + # Sq-dist net pruning + if F is None: + F = surface_surface_distance_squared_net_homog( + S1_h, S2_h, rational=rational) + F = _distance_net_lower_bound(S1_h, S2_h, F, rational) + sw1 = S1w.ravel() + sw2 = S2w.ravel() + w_scale = _weight_max_product(sw1, sw2) + + if _check_min_of_net(F, atol, w_scale): + return True + if _check_lipschitz(F, atol, w_scale): + return True + + return False def _aabb_disjoint(S1_h, S2_h, atol): @@ -657,6 +454,103 @@ def _curve_geometry_collapsed(C, rational=True) -> bool: return geometry_collapsed(pts) +def _polish_overlap_domain_endpoint(S1, S2, stuv, owner_axis, interval, + atol, rational): + """Move a tolerance fringe back to its curve/domain-edge contact. + + A CSX overlap may end at distance atol outside the target domain. + That is useful overlap evidence, but registering the fringe point as + an SSX endpoint creates a second crossing beside the actual boundary. + When projection clamps a target parameter, resolve a contact between + two straight, monotone boundary curves. A general curved edge can have + several contacts, so Newton alone cannot choose its nearest endpoint; + those spans retain their existing numerical overlap evidence. + """ + original = np.asarray(stuv, dtype=float) + + def monotone_line(surface, axis, side): + if side not in (0., 1.): + return False + controls = np.take(surface, 0 if side == 0. else -1, axis=axis % 2) + points = controls[..., :-1]/controls[..., -1:] if rational else controls + if len(points) < 2 or not np.isfinite(points).all(): + return False + direction = points[-1]-points[0] + squared_length = float(direction @ direction) + if squared_length <= 0.: + return False + positions = (points-points[0]) @ direction/squared_length + deviation = points-points[0]-positions[:, None]*direction + noise = min(.01*atol, 64.*np.finfo(float).eps*len(points) + * max(float(np.max(np.abs(points))), np.finfo(float).tiny)) + return bool(np.max(np.linalg.norm(deviation, axis=1)) <= noise + and np.all(np.diff(positions) >= 0.)) + + owner_surface = S1 if owner_axis < 2 else S2 + if not monotone_line(owner_surface, owner_axis, original[owner_axis]): + return original + other_offset = 2 if owner_axis < 2 else 0 + fixed = [] + for axis in (other_offset, other_offset+1): + for side in (0., 1.): + if abs(original[axis]-side) <= 64.*np.finfo(float).eps: + fixed.append((axis, side)) + if not fixed: + return original + best = original.copy() + best_residual = np.linalg.norm( + eval_surface(S1, *best[:2], rational=rational) + - eval_surface(S2, *best[2:], rational=rational)) + lo, hi = sorted(interval) + midpoint = lo+.5*(hi-lo) + # The two endpoint corrections must not jump across one another or + # consume an entire already recovered span. An endpoint that requires + # crossing its opposite half needs a new overlap search, not polishing. + local_interval = ((lo, midpoint) if original[owner_axis ^ 1] <= midpoint + else (midpoint, hi)) + for other_axis, side in fixed: + other_surface = S2 if owner_axis < 2 else S1 + if not monotone_line(other_surface, other_axis, side): + continue + free = [owner_axis ^ 1, other_axis ^ 1] + params = original.copy() + params[other_axis] = side + for _ in range(20): + p1, du1, dv1 = eval_surface_d1(S1, *params[:2], rational=rational) + p2, du2, dv2 = eval_surface_d1(S2, *params[2:], rational=rational) + residual = p1-p2 + jacobian = np.column_stack((du1, dv1, -du2, -dv2))[:, free] + try: + step, _, rank, _ = np.linalg.lstsq(jacobian, -residual, rcond=None) + except np.linalg.LinAlgError: + break + if rank != 2 or not np.isfinite(step).all(): + break + advanced = False + old_error = float(residual @ residual) + for factor in (1., .5, .25, .125, .0625): + candidate = params.copy() + candidate[free] += factor*step + candidate[free[0]] = np.clip(candidate[free[0]], *local_interval) + candidate[free[1]] = np.clip(candidate[free[1]], 0., 1.) + error = (eval_surface(S1, *candidate[:2], rational=rational) + - eval_surface(S2, *candidate[2:], rational=rational)) + if float(error @ error) < old_error: + params = candidate + advanced = True + break + if not advanced: + break + error = np.linalg.norm(eval_surface(S1, *params[:2], rational=rational) + - eval_surface(S2, *params[2:], rational=rational)) + # A clamped descent can reduce the residual without reaching the + # boundary contact. It does not justify shortening the overlap. + contact_tol = min(.1*atol, _strict_ssx_root_tol(S1, S2, rational=rational)) + if np.isfinite(error) and error <= contact_tol and error < best_residual: + best, best_residual = params, error + return best + + def _weight_net_uniform(S) -> bool: """Exact rational-polynomial fast-path predicate.""" w = np.asarray(S, dtype=np.float64)[..., -1] @@ -711,47 +605,20 @@ def _canonicalize_collapsed_fiber_params( def _find_ssx_boundary_zeros( - S1_h, S2_h, atol, rational=True, csx_fn=None, fiber_sink=None, - census_sink=None, root_matcher=None, face_csx_fn=None, - source_point_representation=None): + 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 census_sink is not None: - census_sink["complete"] = True - census_sink['boundary_obligations'] = [] 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): - def incomplete_face(): - if census_sink is None: - return - census_sink['complete'] = False - face_box = list(((0.,1.),)*4) - face_box[axis if owner_is_s1 else axis+2] = (float(side),float(side)) - face_box = tuple(face_box) - if face_box not in census_sink['boundary_obligations']: - census_sink['boundary_obligations'].append(face_box) - - if face_csx_fn is None: - result = csx_fn(iso, other_surf, atol=atol, rational=rational, - tolerance_tier=False) - else: - result = face_csx_fn(iso, other_surf, axis if owner_is_s1 else axis+2, - float(side), atol=atol, rational=rational, - tolerance_tier=False) - if census_sink is not None and ( - result.get('budget_exhausted', False) - or not result.get('boundary_topology_complete', True) - or result.get('overlaps') or result.get('parameter_fibers') - or result.get('boundary_seed_proposals')): - incomplete_face() + 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, @@ -759,7 +626,7 @@ def incomplete_face(): # 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 list(result.get('parameter_fibers', [])) + list(result.get('boundary_seed_proposals', [])): + 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( @@ -767,28 +634,15 @@ def incomplete_face(): xyz = np.asarray(fiber.get( 'point', eval_curve(iso, 0.5, rational=rational)), dtype=np.float64) - if (fiber.get('certification') == 'exact_source_parameter_fiber' - and source_point_representation is not None): - # The exact source fiber owns existence. A rounded - # normalized proposal net cannot revoke that identity. - representation_ok = bool(np.all(np.isfinite(xyz)) - and source_point_representation(stuv, xyz)) - else: - p1 = eval_surface(S1_h, stuv[0], stuv[1], rational=rational) - p2 = eval_surface(S2_h, stuv[2], stuv[3], rational=rational) - representation_ok = bool(np.all(np.isfinite(xyz)) - and np.linalg.norm(p1-xyz) <= 2.*atol - and np.linalg.norm(p2-xyz) <= 2.*atol) - if not representation_ok: + 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 - exact_fiber = fiber.get('certification') != 'numerical_seed_proposal' - boundary_seed = BoundaryPoint( + fiber_sink.append(BoundaryPoint( stuv=stuv, xyz=xyz, face=(face_id, side), - tangent_raw=None, parameter_fiber=exact_fiber) - boundary_seed.source_fiber_certificate = fiber.get('source_fiber_certificate') - boundary_seed.boundary_seed_proposal = not exact_fiber - fiber_sink.append(boundary_seed) + tangent_raw=None, parameter_fiber=True)) for iso_pt in result.get('isolated', []): t_crv = float(iso_pt['t']) @@ -805,50 +659,24 @@ def incomplete_face(): # 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 - source_certified = bool(result.get('_ssx_source_residual') - and iso_pt.get('root_existence_certification')) - if source_certified: - # The source census has already associated this numerical - # representative with an immutable source root enclosure. - # A second unconstrained polish on rounded normalized nets - # must not switch that association to a different root. - polished = raw_stuv - pres = float(np.linalg.norm( - eval_surface(S1_h, *stuv[:2], rational=rational) - - eval_surface(S2_h, *stuv[2:], rational=rational))) - else: - polished, pres, _ = _ssx_correct_fixed( - S1_h, S2_h, stuv, - fixed_axis=fixed_axis, fixed_value=float(side), - rational=rational, - ) + 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. - xyz = np.asarray(iso_pt.get('point', eval_surface( - S1_h, *polished[:2], rational=rational)), dtype=float) - if source_certified and source_point_representation is not None: - # Existence belongs to the original source certificate. - # Normalization can perturb its numerical residual beyond - # a roundoff-sized threshold. Validate the displayed point - # against both original sources at modeling accuracy; - # never let the rounded proposal equations erase the root. - representation_ok = bool(np.all(np.isfinite(xyz)) - and source_point_representation(polished, xyz)) - else: - representation_ok = (np.isfinite(pres) + if not (np.isfinite(pres) and pres <= _strict_ssx_root_tol( - S1_h, S2_h, rational=rational)) - if not representation_ok: - incomplete_face() + 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) - if not source_certified: - xyz = eval_surface( - S1_h, stuv[0], stuv[1], rational=rational) + 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 @@ -857,28 +685,13 @@ def incomplete_face(): S1_h, stuv[0], stuv[1], rational=rational) or _on_collapsed_boundary_fiber( S2_h, stuv[2], stuv[3], rational=rational)): - incomplete_face() 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) - root_box = None - if iso_pt.get('parameter_root_box') is not None: - raw_box = np.asarray(iso_pt['parameter_root_box'], dtype=float) - columns = [_map_csx_to_stuv(axis, side, *raw_box[:, edge], owner_is_s1) - for edge in (0, 1)] - root_box = np.column_stack(columns) - crossing = BoundaryPoint( + crossings.append(BoundaryPoint( stuv=stuv, xyz=xyz, face=(face_id, side), - tangent_raw=tang, root_box=root_box, - multiplicity_polished=multiplicity_polished) - if (result.get('_ssx_source_residual') and root_box is not None - and iso_pt.get('root_existence_certification')): - crossing._source_root_box = True - certificate = iso_pt.get('source_cut_certificate') - if certificate is not None and root_matcher is not None: - if root_matcher.register_source_root(crossing, certificate): - crossing._source_root_box = True - crossings.append(crossing) + tangent_raw=tang, + multiplicity_polished=multiplicity_polished)) for ovl in result.get('overlaps', []): tr = ovl.get('t_range', (0.0, 1.0)) @@ -899,6 +712,11 @@ def incomplete_face(): uv_pairs[1][0], uv_pairs[1][1], owner_is_s1) face_id = axis if owner_is_s1 else axis + 2 + stuv_s = _polish_overlap_domain_endpoint( + S1_h, S2_h, stuv_s, face_id, tr, atol, rational) + stuv_e = _polish_overlap_domain_endpoint( + S1_h, S2_h, stuv_e, face_id, tr, atol, rational) + tr = (float(stuv_s[face_id ^ 1]), float(stuv_e[face_id ^ 1])) # GEOMETRIC VERIFICATION of the overlap claim (corner-sharing # bilinear repro: CSX claimed the whole s=1 edge "overlaps" a # SINGLE surface point — t_range (0,1) with degenerate @@ -984,36 +802,22 @@ def incomplete_face(): else S2_h[:, 0 if side == 0 else -1, :] _process_face(iso, S1_h, s2_axis, side, owner_is_s1=False) - from mmcore.nurbs._nurbs_param_tol import bez_surface_param_tolerance - param_tol = np.array([ - *bez_surface_param_tolerance(S1_h, atol, rational=rational), - *bez_surface_param_tolerance(S2_h, atol, rational=rational), - ], dtype=np.float64) - if root_matcher is None: - from mmcore.numeric.intersection.ssx._ssx_root_identity import BoundaryRootIdentity - source_h = tuple(surface if rational else np.concatenate([ - surface, np.ones(surface.shape[:-1]+(1,))], axis=-1) - for surface in (S1_h, S2_h)) - root_matcher = BoundaryRootIdentity(*source_h) - crossings = _dedup_crossings(crossings, atol, param_tol=param_tol, - root_matcher=root_matcher) - overlaps = _dedup_overlaps( - overlaps, atol, param_tol=param_tol, - surface=S1_h, rational=rational) - - # An overlap endpoint can also have an ordinary branch incident to it. - # The overlap owns its own lifted path, not every arc at its endpoint. - # Keep these registrations; final path containment coalesces actual - # duplicate overlap traces after all incident arcs have been searched. + crossings = _dedup_crossings(crossings, atol) + overlaps = _dedup_overlaps(overlaps, atol) + + # An overlap endpoint can also start an ordinary interior arc. Keep + # its registration available; overlap geometry alone does not cover + # the other incident branches. return crossings, overlaps -def _dedup_crossings(crossings, atol, *, param_tol=None, root_matcher=None): - """Unify crossings with identical stuv. +def _dedup_crossings(crossings, atol): + """Unify crossings close in both paired parameters and model space. - Equal representatives or a common-face root identity certificate can - identify an event. Parameter tolerance alone cannot. Crossings with close xyz + Design §5 Invariant C: two crossings with identical `stuv` (within tolerance) + can be unified only when their xyz separation also fits ``atol``: a + stretched surface can turn close parameters into distant points. Two crossings with close xyz but distinct stuv are legitimate (a self-intersection, a fold, two branches crossing in 3-space) and are kept separate. @@ -1023,19 +827,11 @@ def _dedup_crossings(crossings, atol, *, param_tol=None, root_matcher=None): if len(crossings) <= 1: return crossings - # Geometric atol has length units; stuv is dimensionless. Even two - # very close parameter roots can belong to distinct, well-separated - # branches on a steep patch. Both guards must identify the same root. - ptol = np.full(4, atol) if param_tol is None else np.asarray(param_tol) deduped = [] - for position, c in enumerate(crossings): - if getattr(root_matcher, 'exhausted', False): - deduped.extend(crossings[position:]) - break + for c in crossings: duplicate = next((d for d in deduped - if (root_matcher(c, d, ptol, atol) if root_matcher is not None - else np.array_equal(c.stuv, d.stuv) - and np.linalg.norm(c.xyz - d.xyz) <= atol)), None) + if np.linalg.norm(c.stuv - d.stuv) < atol + and np.linalg.norm(c.xyz - d.xyz) <= atol), None) if duplicate is None: deduped.append(c) else: @@ -1048,8 +844,7 @@ def _dedup_crossings(crossings, atol, *, param_tol=None, root_matcher=None): return deduped -def _dedup_overlaps(overlaps, atol, *, param_tol=None, - surface=None, rational=True): +def _dedup_overlaps(overlaps, atol): """Remove duplicate boundary overlaps. An overlap whose endpoints both lie on boundaries of both surfaces @@ -1059,25 +854,16 @@ def _dedup_overlaps(overlaps, atol, *, param_tol=None, if len(overlaps) <= 1: return overlaps - def same_endpoint(a, b): - if not np.array_equal(a, b): - return False - if surface is None: - return True - pa = eval_surface(surface, a[0], a[1], rational=rational) - pb = eval_surface(surface, b[0], b[1], rational=rational) - return np.linalg.norm(pa - pb) <= atol - deduped = [] for ovl in overlaps: is_dup = False for d in deduped: # Check same direction - same = (same_endpoint(ovl.stuv_start, d.stuv_start) and - same_endpoint(ovl.stuv_end, d.stuv_end)) + same = (np.linalg.norm(ovl.stuv_start - d.stuv_start) < atol and + np.linalg.norm(ovl.stuv_end - d.stuv_end) < atol) # Check reversed direction - rev = (same_endpoint(ovl.stuv_start, d.stuv_end) and - same_endpoint(ovl.stuv_end, d.stuv_start)) + rev = (np.linalg.norm(ovl.stuv_start - d.stuv_end) < atol and + np.linalg.norm(ovl.stuv_end - d.stuv_start) < atol) if same or rev: is_dup = True break @@ -1110,10 +896,8 @@ def _check_monotonicity(T1, T2, T3, T4): T_arr = _tpsi_to_numpy(T) t_min = float(np.min(T_arr)) t_max = float(np.max(T_arr)) - # Nonzero one-sign Bernstein coefficients imply a strict sign in - # the cell interior. An identically zero minor says only that one - # coordinate is constant; it does not rule out a closed fiber in - # the other coordinates when a surface chart is singular. + # Non-negative or non-positive → no sign change → monotonic + # (touching zero at a boundary is not a sign change) if (t_min >= 0 and t_max > 0) or (t_max <= 0 and t_min < 0): return True, i return False, None @@ -1194,10 +978,10 @@ def _check_tangency( if ok: return True # Found a tangent point - # A failed local witness is inconclusive even with large residual; - # its basin says nothing about another root elsewhere in the box. + # If Gauss-Newton didn't converge but residual is very large, + # it's clearly not tangent — no need for expensive Krawczyk if fn > 1.0: - return None + return False return None # Undetermined — let domain decomposition handle it @@ -1331,70 +1115,127 @@ def _delta_roots_curve_like(roots, exhausted): return len(roots) > 12 or (exhausted and len(roots) > 1) -def _canonicalize_tangent_candidates(cell, roots): - """Generate independently proved source roots from numerical proposals.""" - propose = getattr(cell, 'source_singular_candidate', None) - if propose is None: - return roots - corrected = [] - for root in roots: - global_root = _local_to_global(np.asarray(root), cell.box) - candidate = propose(global_root) - if candidate is not None and all(lo <= candidate[k] <= hi - for k, (lo, hi) in enumerate(cell.box)): - local = _global_to_local(candidate, cell.box) - if np.array_equal(_local_to_global(local, cell.box), candidate): - root = local - corrected.append(root) - return corrected - - -def _record_unproved_tangent(cell, stuv): - """A small overdetermined residual is a candidate, not root existence.""" - source_singular = getattr(cell, 'source_singular_candidate', None) - if source_singular is not None: - candidate = source_singular(stuv) - if candidate is not None and np.array_equal(candidate, stuv): - return True - else: - exact_root = getattr(cell, 'source_root_exact', None) - if exact_root is not None and exact_root(stuv): - return True - budget = getattr(cell, 'work_budget', None) - if budget is not None: - budget.structural_sites.append((REASON_MULTIPLICITY, np.asarray(stuv).copy())) - budget.mark_incomplete(REASON_MULTIPLICITY) - diagnostics = getattr(cell, 'unresolved_regions', None) - if diagnostics is not None: - budget.append_output(diagnostics, { - 'stuv_min': tuple(float(lo) for lo, hi in cell.box), - 'stuv_max': tuple(float(hi) for lo, hi in cell.box), - 'reason': REASON_MULTIPLICITY, - 'candidate': tuple(float(value) for value in stuv), - 'candidate_kind': 'tangent_point', - 'source_existence': False, - }, 'unresolved_region') - return False +def _stuv_in_overlap_boxes(stuv_g, overlap_boxes): + """Ledger L6(ii): True if the GLOBAL 4D point lies inside any detected + overlap region's parametric box (see `_overlap_region_boxes`).""" + if not overlap_boxes: + return False + p = np.asarray(stuv_g, dtype=np.float64) + return any(bool(np.all(p >= B[:, 0]) and np.all(p <= B[:, 1])) + for B in overlap_boxes) + + +def _overlap_region_boxes(boundary_overlaps, S1_h, atol, unify_tol): + """Ledger L6(ii): padded 4D parametric AABBs of the detected coplanar + overlap REGIONS, for suppressing tangent_point emission in their + interior (paper Fig. 8: the overlap interior is a 2-dimensional C2 set; + every point of it is a Δ-root, so any witness converging there emits a + phantom "isolated" touch — measured: plane_patch(0,2) vs + plane_patch(1,3) emitted the strip's dead center (0.75,0.5,0.25,0.5)). + + The overlap machinery stores only the region's BOUNDARY segments + (`BoundaryOverlap` start/end stuv from the 8 boundary-CSX calls), not + region boxes — reconstruct minimally: group segments into connected + components (endpoints within the matching ladder: per-axis unify_tol + AND xyz <= 2*atol), then take each component's stuv AABB padded by + unify_tol per axis. For a partial-overlap strip the component box IS + the strip's parametric box (the boundary segments span it). + + Known limits (accepted, documented): (a) segment (u,v)-images are taken + from endpoints only — a strongly curved overlap boundary can bulge + outside the endpoint AABB and under-suppress; (b) a genuine isolated + touch inside the AABB of a non-convex overlap region but outside the + region itself would be over-suppressed (geometrically exotic: the + surfaces already coincide on the region); (c) legacy overlap + bookkeeping can store corrupt other-surface stuv (the L3 4-vs-2 gap), + which skews those axes' extents. All three degrade toward the + PRE-EXISTING behaviors (phantom kept / point subsumed), never corrupt + branch geometry. + """ + if not boundary_overlaps: + return [] + segs = [] + for ovl in boundary_overlaps: + a = np.asarray(ovl.stuv_start, dtype=np.float64) + b = np.asarray(ovl.stuv_end, dtype=np.float64) + axyz = eval_surface(S1_h, a[0], a[1], rational=True) + bxyz = eval_surface(S1_h, b[0], b[1], rational=True) + segs.append((a, b, axyz, bxyz)) + n = len(segs) + parent = list(range(n)) + + def _find(i): + while parent[i] != i: + parent[i] = parent[parent[i]] + i = parent[i] + return i + + def _ends_match(pa, pxyz, qa, qxyz): + return (np.all(np.abs(pa - qa) <= unify_tol) + and float(np.linalg.norm(pxyz - qxyz)) <= 2.0 * atol) + + for i in range(n): + for j in range(i + 1, n): + ia, ib, iaxyz, ibxyz = segs[i] + ja, jb, jaxyz, jbxyz = segs[j] + if (_ends_match(ia, iaxyz, ja, jaxyz) or _ends_match(ia, iaxyz, jb, jbxyz) + or _ends_match(ib, ibxyz, ja, jaxyz) + or _ends_match(ib, ibxyz, jb, jbxyz)): + parent[_find(i)] = _find(j) + + comps: dict = {} + for i in range(n): + comps.setdefault(_find(i), []).append(i) + boxes = [] + for idxs in comps.values(): + pts = np.array([p for i in idxs for p in (segs[i][0], segs[i][1])]) + B = np.stack([pts.min(axis=0) - unify_tol, + pts.max(axis=0) + unify_tol], axis=1) # (4, 2) + boxes.append(B) + return boxes def _emit_tangent_roots(cell, atol, unify_tol, all_singularities, *, enumerate_all=True, overlap_boxes=None, defer_inconclusive=False): - """Collect candidate Delta roots without merging nearby preimages. - - Local numerical rank and continuation classify candidate singular - points versus curve samples. They do not prove exhaustion of the - parameter cell. The caller retains complement search obligations. - Only identical lifted parameters coalesce repeated point outputs. - - ``overlap_boxes`` is retained for private-call compatibility; independent - parameter projections never establish paired-region membership and - cannot suppress a witness. Exact complete planar regions are handled - before generic singular search. + """Run the Δ = Ψ ∩ TΨ witness on a tangent cell and emit every distinct + root as a 'tangent_point' singularity into `all_singularities`. + + Shared by all THREE tangency emission sites of the subdivision loop — + crossing-less (isolated touches), loop-free with all-four T hulls + containing 0 (touches ON the subdivision cut lattice), and + crossing-bearing (tangent cell whose boundary transversal arms pierce; + passes enumerate_all=False, see _tangency_witness). The same physical + touch is re-confirmed by neighbor cells, by tangent descendants at + several depths, and possibly by several arms; the dedup + (matching-ladder: unify_tol per-axis box AND 2·atol xyz) collapses all + re-confirmations onto ONE emitted point per touch. + + Two emission suppressions (ledger L6), neither affecting the return: + + - 1-dim Δ-sets: when the full enumeration carries the curve signature + (`_delta_roots_curve_like`: > 12 roots, or exhausted with several), + the roots are ptol-ladder samples of a tangent CURVE, not isolated + touches — emit NOTHING and let the caller's fall-through subdivide; + the deflation/tracing machinery owns 1-dim tangencies (descendants + become crossing-bearing and Φ-trace the curve as a `tangential` + branch that legitimately subsumes any residual on-curve witness). + - overlap interiors: a root inside a detected coplanar overlap + region's parametric box (`overlap_boxes` from + `_overlap_region_boxes`) is a sample of a 2-dimensional C2 set the + overlap branches already report — skip it (paper Fig. 8; the strip + interior is far from every overlap boundary POLYLINE, so the + post-assembly subsumption filter cannot catch it). + + Returns `(ok, roots)`: `ok` from `_tangency_witness` (True iff at least + one converged witness exists — the crossing-less arm's size gate needs + it) and the LOCAL witness points — Task 5's Φ∩L seeding consumes + `roots[0]` in the crossing-less arm (`_choose_phi_equations` seed). + Suppressed roots stay in `roots`: they are genuine Δ-roots and valid + seeds; only their typing as isolated points is wrong. """ ok, roots, _fn, exhausted = _tangency_witness( cell, atol, enumerate_all=enumerate_all) - roots = _canonicalize_tangent_candidates(cell, 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. @@ -1435,7 +1276,10 @@ def _emit_tangent_roots(cell, atol, unify_tol, all_singularities, # 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_MULTIPLICITY + _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) @@ -1445,6 +1289,8 @@ def _emit_tangent_roots(cell, atol, unify_tol, all_singularities, 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]))) @@ -1458,11 +1304,10 @@ def _emit_tangent_roots(cell, atol, unify_tol, all_singularities, continue if _normals_degenerate_at(cell.g1.surface, cell.g2.surface, xw): continue # L15: Sigma=0 root — C1 candidate, not a C2 touch - if not _record_unproved_tangent(cell, stuv_g): - continue xyz_w = eval_surface(cell.g1.surface, xw[0], xw[1], rational=True) if not any(g.kind == "tangent_point" - and np.array_equal(g.stuv, stuv_g) + 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): item = SSXSingularity( kind="tangent_point", stuv=stuv_g, xyz=xyz_w) @@ -1843,13 +1688,16 @@ def _emit_offcurve_tangent_roots(cell, fragments_local, atol, unify_tol, With no fragments (nothing traced), this degrades to the plain budget-bounded full enumeration. - This is only a singular-locus census. It says nothing about ordinary - components where some T minor is nonzero. Its caller must keep that - complement in the ordinary subdivision frontier, even after tracing a - complete tangent curve. Descendants avoid repeating this same census. + KNOWN LIMIT (review 2d030bb+7ed47c0): this enumeration recovers only + Δ-ROOTS (tangencies). A coexisting TRANSVERSAL feature that is not on + Δ — e.g. a small transversal LOOP with no boundary crossings sharing + this crossing-bearing cell — is still lost: the arm `continue`s + without subdividing, and the Φ∩L loop seeding runs only on the + crossing-LESS arm (the Mexican-hat treatment). Subdividing instead + costs 1349x on tangent curves (see the arm's comment); accepted gap. """ from mmcore.numeric.intersection.ssx._ssx5_singular import ( - VectorBoxNet, psi_vector_net, solve_zero_dim, + ShiftedPositiveNet, VectorBoxNet, psi_vector_net, solve_zero_dim, ) ptol4 = _cell_ptol4(cell, atol) @@ -1939,9 +1787,19 @@ def _xyz(x): G = psi_vector_net(cell.g1.surface, cell.g2.surface) nets = [] - # A floating squared-distance net cannot certify exclusion here: - # its Gram-product roundoff can exceed even a small atol**2 shift. - # Keep the unsquared necessary equations throughout enumeration. + if cell.F_sq is not None: + # One-sided sq-dist exclusion (same threshold as + # _check_min_of_net): kills the off-tube boxes where individual + # Ψ-component hulls are weak — e.g. crossed saddles share their + # xy control layout, so Ψ_x = Ψ_y = 0 on a whole 2-dim diagonal + # set and the component nets exclude almost nothing there + # (measured: 1069 interval-GN attempts, 6.2 s, without this + # net; a handful with it). Listed FIRST: it is the strongest + # off-tube pruner and `any()` short-circuits. + thresh = (atol * cell.w_scale) ** 2 + nets.append(ShiftedPositiveNet( + np.asarray(cell.F_sq, dtype=np.float64)[..., None] - thresh, + axes=(0, 1, 2, 3))) # Ψ components and the elevated T stack ride as vector bundles: # identical exclusion semantics to seven scalar BoxNets, one # de Casteljau split each per box instead of seven total. @@ -1977,7 +1835,6 @@ def _xyz(x): # outlier. Rank+continuation suppresses only the locally 1-D roots. pass - sols = _canonicalize_tangent_candidates(cell, sols) isolated_sols = [] for sol in sols: local_dimension = _delta_root_local_dimension( @@ -1992,7 +1849,10 @@ def _xyz(x): # 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_MULTIPLICITY + _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) @@ -2000,6 +1860,8 @@ def _xyz(x): 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]))) @@ -2009,11 +1871,10 @@ def _xyz(x): continue if _normals_degenerate_at(cell.g1.surface, cell.g2.surface, xw): continue # L15: Sigma=0 root — C1 candidate, not a C2 touch - if not _record_unproved_tangent(cell, stuv_g): - continue xyz_w = eval_surface(cell.g1.surface, xw[0], xw[1], rational=True) if not any(g.kind == "tangent_point" - and np.array_equal(g.stuv, stuv_g) + 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): item = SSXSingularity( kind="tangent_point", stuv=stuv_g, xyz=xyz_w) @@ -2037,6 +1898,74 @@ def _dist_point_polyline(pxyz, poly): return float(np.linalg.norm(proj - pxyz[None, :], axis=1).min()) +def _point_on_branch_both_guards(pxyz, pstuv, poly_xyz, poly_stuv, + atol, unify_tol, S1_h, S2_h): + """Both-guards ON-a-branch test (ledger L3, module tolerance-ladder + convention: a parametric box is not a metric ball, and 3D proximity is + not 4D identity). True iff SOME polyline location is close to the point + in BOTH spaces: + + - xyz: point-to-segment distance <= 4*atol (the branch polyline is a + chorded approximation — on-curve points sit up to the 2*atol sagitta + off it), AND + - stuv: per-axis |pstuv - stuv interpolated at that SAME segment + location| <= 2*unify_tol (= 8*ptol/axis: the witness and the traced + samples are each ~ptol-accurate, and the polyline's stuv chord + interpolation adds the stuv-space sagitta). + + All xyz-close segments are tested, not just the single nearest one — a + closed/looping polyline can pass the same xyz neighborhood twice (once + parameter-near, once parameter-far), and keying on whichever pass is + marginally nearer would make subsumption order-dependent. + + Without the stuv guard a certified touch whose 4D preimage is FAR from + the branch's preimage was deleted on 3D proximity alone (skew ruled + patch: touch at (u,v)=(0.8,0.5) sits 3*atol in xyz from the u=0 overlap + isoline but du=0.8 away in parameters, with a 640*atol z-wall between + the sheets -> `singularities == []`). + + The bookkeeping self-check validates the branch's stored preimages. + Both segment vertices must evaluate (on + BOTH full surfaces S1_h/S2_h, homogeneous) to within 2*atol of their + stored xyz. Marched/assembled branches always pass (their xyz IS the + S1 eval and |S1-S2| <= atol at kept samples; measured 1.8e-13 on the + skew-ruled overlap). Legacy boundary-overlap branches can carry + corrupted other-surface params (the known-broken 4-vs-2 overlap + bookkeeping: stored v=0.5 where the true preimage is 1.0, off by + ~1e5*atol). Such a segment cannot establish that a separate point is + already represented. Keep the point if those preimages are unreliable. + """ + a = poly_xyz[:-1] + b = poly_xyz[1:] + ab = b - a + ap = pxyz[None, :] - a + denom = np.einsum("ij,ij->i", ab, ab) + denom = np.where(denom < 1e-30, 1e-30, denom) + tt = np.clip(np.einsum("ij,ij->i", ap, ab) / denom, 0.0, 1.0) + proj = a + tt[:, None] * ab + d = np.linalg.norm(proj - pxyz[None, :], axis=1) + close = np.nonzero(d <= 4.0 * atol)[0] + if close.size == 0: + return False + for k in close: + consistent = True + for vtx in (k, k + 1): + p1 = eval_surface(S1_h, poly_stuv[vtx, 0], poly_stuv[vtx, 1], + rational=True) + p2 = eval_surface(S2_h, poly_stuv[vtx, 2], poly_stuv[vtx, 3], + rational=True) + if (float(np.linalg.norm(p1 - poly_xyz[vtx])) > 2.0 * atol + or float(np.linalg.norm(p2 - poly_xyz[vtx])) > 2.0 * atol): + consistent = False + break + if not consistent: + continue + stuv_near = poly_stuv[k] + tt[k] * (poly_stuv[k + 1] - poly_stuv[k]) + if bool(np.all(np.abs(stuv_near - pstuv) <= 2.0 * unify_tol)): + return True + return False + + # --------------------------------------------------------------------------- # Level 4a: Domain decomposition # --------------------------------------------------------------------------- @@ -2218,6 +2147,25 @@ def _strict_ssx_root_tol(S1, S2, rational=True): _MARCH_PROGRESS_WINDOW = 32 _MARCH_PROGRESS_RATIO = 8.0 +# Dimensionless factors converting a measured/estimated chord SAGITTA into +# the slack a predicate must grant a polyline that carries it. Both exist +# because a chord is not the curve: the marcher may leave a sagitta of up +# to `sag_tol = 2*atol`, so any predicate measuring a chord against the +# curve is measuring discretization and must price it. Neither is a +# tolerance — each multiplies an operand measured from the geometry, so a +# finely sampled polyline keeps a tight bar and only a coarse one earns a +# loose one. +# +# VALLEY: ratio between `res / sin_ang` at the parametric midpoint and the +# sagitta itself; measured 1.9-4.0 on the toroidal SSI fixture. +_VALLEY_SAGITTA_CREDIT = 4.0 +# CONTAINMENT: the duplicate test measures samples that lie ON the curve +# against a KEEPER'S POLYLINE, so the gap it sees is the keeper's sagitta +# (measured 9.602e-4 observed vs 9.620e-4 true — they agree). The factor +# covers `_polyline_sagitta_bound`'s underestimate, measured at 0.63-1.00 +# of the true value. +_CONTAINMENT_SAGITTA_CREDIT = 4.0 + _NORM_IDENTITY_WINDOW = (2.0 ** -5, 2.0 ** 5) @@ -2293,9 +2241,8 @@ def _ssx_normalization_context(S1, S2, rational=True): def _normalize_surface_net(S, c, k, rational=True): """Map a control net into the canonical frame: x' = (x - c) / k. - Rational nets transform homogeneously (numerator -= c*w, then /k). - Floating point multiplication and subtraction can round coefficients; - callers retain the supplied source nets for exact identity predicates. + Rational nets transform homogeneously (numerator -= c*w, then /k), so + Cartesian points map exactly as above while weights stay untouched. Always returns a copy; the caller's world-frame net is never mutated. """ S = np.asarray(S, dtype=np.float64).copy() @@ -2544,12 +2491,11 @@ def _march_intersection_curve( def _promote_transversal_boundary_fiber_pair( S1, S2, fibers, ordinary_crossings, overlaps, *, atol, unify_tol, h_max, max_points=512): - """Turn one unresolved collapsed-boundary seed pair into branch proposals. + """Turn one certified collapsed-fiber pair into regular branch seeds. - An exact collapsed edge has infinitely many parameter preimages for - one xyz endpoint. Nearcollapsed edges may supply explicitly numerical - seeds instead. Two such endpoints can suggest a regular, transversal - SSI component. This helper is deliberately conservative: + 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. @@ -2673,7 +2619,7 @@ def _promote_transversal_boundary_fiber_pair( canonical_stuv.append(stuv) promoted.append(BoundaryPoint( stuv=stuv, xyz=xyz, face=fiber.face, - tangent_raw=tangent, parameter_fiber=fiber.parameter_fiber)) + 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 @@ -3261,6 +3207,18 @@ def _march_to_boundary( iterations += 1 step = max(min_step, min(max_step, h / max(speed, 1e-12))) predicted = current + step * tang_prev + # A branch may lie along a cell face. Roundoff in its null vector + # must not turn a face-following step into an immediate exit from + # that same face. Only snap drift at machine precision; a geometric + # crossing still goes through the fixed-face corrector below. + face_roundoff = 64. * np.finfo(float).eps + for axis in range(4): + if (abs(current[axis]) <= face_roundoff + and -face_roundoff <= predicted[axis] < 0.): + predicted[axis] = 0. + elif (abs(current[axis]-1.) <= face_roundoff + and 1. < predicted[axis] <= 1.+face_roundoff): + predicted[axis] = 1. crossed_axis, crossed_val, crossed_alpha = _detect_boundary_crossing( current, predicted) @@ -4523,10 +4481,8 @@ def _deflate_tangent_cell(S1, S2, T1, T2, T3, T4, box, crossings, atol, points: list[SSXPoint] = [] if len(crossings) < 2: - for index, c in enumerate(crossings): - # Tracing receives cell-local parameters, while all public - # points use the original registered global coordinates. - points.append(_registered_point(originals[index] if originals is not None else c)) + for c in crossings: + points.append(SSXPoint(stuv=c.stuv, xyz=c.xyz)) return fragments, points pairs, unpaired = _pair_crossings_for_tracing(crossings, originals=originals, cell=cell) @@ -4564,29 +4520,6 @@ def _pair_alignment(candidate, start, target): accepted_indices = None forward_start = np.asarray(crossings[i].stuv, dtype=np.float64) forward_target = np.asarray(crossings[j].stuv, dtype=np.float64) - # High-order tangency can have an arbitrarily small residual far - # from the zero locus. Use exact source inclusion when it proves a - # straight isocurve, before attempting singular continuation. - exact_path = None - source_path = getattr(cell, 'straight_path', None) - if source_path is not None and originals is not None: - global_path = source_path(originals[i].stuv, originals[j].stuv) - if global_path is not None: - local_path = np.asarray([_global_to_local(p, cell.box) - for p in global_path[0]]) - returned_path = np.asarray([_local_to_global(p, cell.box) for p in local_path]) - if np.array_equal(returned_path, global_path[0]): - exact_path = (local_path, global_path[1]) - elif cell is None: - from mmcore.numeric.intersection.ssx._ssx_affine_path import certified_straight_isocurve_path - exact_path = certified_straight_isocurve_path( - S1, S2, forward_start, forward_target, atol, rational) - if exact_path is not None: - fragments.append(_Fragment( - start_point=originals[i] if originals is not None else None, - end_point=originals[j] if originals is not None else None, - stuv_path=exact_path[0], xyz_path=exact_path[1], tangential=True)) - continue def _valid_endpoint_path(stuv_path, xyz_path, target): if len(stuv_path) < 2: @@ -4718,7 +4651,7 @@ def _valid_endpoint_path(stuv_path, xyz_path, target): for k in unpaired: src = originals[k] if originals is not None else crossings[k] - points.append(_registered_point(src)) + points.append(SSXPoint(stuv=src.stuv, xyz=src.xyz)) return fragments, points @@ -4817,17 +4750,32 @@ def _clamp(x): def _overlaps_to_branches(boundary_overlaps, S1, atol, rational): """Convert BoundaryOverlap objects to SSXBranch with overlap=True. - Preserve each owned boundary arc, including coincident approximations. - Only repeated references to the same BoundaryOverlap are duplicates. + Filters zero-length overlaps and deduplicates overlaps that + represent the same 3D geometry (same start/end points in space). """ - branches, seen = [], set() + branches = [] for ovl in boundary_overlaps: - if id(ovl) in seen: - continue - seen.add(id(ovl)) xyz_start = eval_surface(S1, ovl.stuv_start[0], ovl.stuv_start[1], rational=rational) xyz_end = eval_surface(S1, ovl.stuv_end[0], ovl.stuv_end[1], rational=rational) + # Skip zero-length overlaps + if np.linalg.norm(xyz_start - xyz_end) < atol: + continue + + # Check if we already have an overlap with the same 3D endpoints + is_dup = False + for b in branches: + _, existing_xyz = b.curve + same = (np.linalg.norm(xyz_start - existing_xyz[0]) < atol and + np.linalg.norm(xyz_end - existing_xyz[-1]) < atol) + rev = (np.linalg.norm(xyz_start - existing_xyz[-1]) < atol and + np.linalg.norm(xyz_end - existing_xyz[0]) < atol) + if same or rev: + is_dup = True + break + if is_dup: + continue + 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. @@ -4838,21 +4786,166 @@ def _overlaps_to_branches(boundary_overlaps, S1, atol, rational): else: stuv_path = np.stack([ovl.stuv_start, ovl.stuv_end], axis=0) xyz_path = np.stack([xyz_start, xyz_end], axis=0) - if np.all(stuv_path == stuv_path[0]): - continue - branch = SSXBranch(curve=(stuv_path,xyz_path),overlap=True,kind="overlap") - branch._source_boundary_face = ovl.face - branches.append(branch) + branches.append(SSXBranch(curve=(stuv_path, xyz_path), overlap=True, kind="overlap")) return branches +def _refine_injective_chart_parameters(stuv, xyz, surfaces, param_tol, atol, + work_budget): + """Resample coarse inverse chords on regular convex planar quads. + + Such a chart has one parameter preimage per XYZ point. Its inverse can + be curved even when the intersection in space is a straight edge. + Refining that inverse removes a sampling discrepancy without relaxing + parameter comparisons on folded or otherwise unsupported surfaces. + Failure only prevents duplicate removal; it never removes a branch. + """ + from mmcore.numeric.intersection.ssx._ssx5_overlap import ( + _bilinear_chord_error, _invert_point, + ) + + if surfaces is None: + return None + tolerance = np.broadcast_to(np.asarray(param_tol, dtype=float), (4,)) + if np.any(tolerance <= 0.): + return None + charts = [] + for offset, net in zip((0, 2), surfaces): + H = np.asarray(net, dtype=float) + if (H.shape != (2, 2, 4) or not np.isfinite(H).all() + or np.any(H[..., 3] <= 0.)): + continue + if not _assembly_spend(work_budget, 16): + return None + controls = H[..., :3]/H[..., 3, None] + corners = controls[[0, 1, 1, 0], [0, 0, 1, 1]] + scale = float(np.max(np.ptp(corners, axis=0))) + if scale <= 0.: + continue + normalized = (corners-corners[0])/scale + try: + _, singular, frame = np.linalg.svd(normalized, full_matrices=False) + except np.linalg.LinAlgError: + continue + eps = 128.*np.finfo(float).eps + if (singular[1] <= eps*singular[0] + or np.max(np.abs(normalized @ frame[2])) > eps): + continue + projected = normalized @ frame[:2].T + sides = np.roll(projected, -1, axis=0)-projected + turns = sides[:, 0]*np.roll(sides[:, 1], -1)-sides[:, 1]*np.roll(sides[:, 0], -1) + if not (np.all(turns > eps) or np.all(turns < -eps)): + continue + # Existing samples must already belong to this chart. The inverse + # resampling is not a repair of invalid or differently lifted data. + for parameters, point in zip(stuv, xyz): + if not _assembly_spend(work_budget): + return None + sample = eval_surface(H, *parameters[offset:offset+2], rational=True) + if np.linalg.norm(sample-point) > atol: + return None + charts.append((offset, H)) + if not charts: + return None + + refined_q = [np.asarray(stuv[0]).copy()] + refined_x = [np.asarray(xyz[0]).copy()] + for start_q, end_q, start_x, end_x in zip(stuv[:-1], stuv[1:], xyz[:-1], xyz[1:]): + pending = [(np.asarray(start_q), np.asarray(end_q), + np.asarray(start_x), np.asarray(end_x), 0)] + while pending: + qa, qb, xa, xb, depth = pending.pop() + middle_q, middle_x = .5*(qa+qb), .5*(xa+xb) + acceptable = True + for offset, H in charts: + # _invert_point performs at most 30 Newton steps. Charge + # the bounded solve before entering it. + if not _assembly_spend(work_budget, 30): + return None + u, v, residual = _invert_point( + H, middle_x, seed=middle_q[offset:offset+2], max_iter=30) + uv = np.array([u, v]) + if (not np.isfinite(uv).all() or not np.isfinite(residual) + or residual > .25*atol or np.any(uv < 0.) or np.any(uv > 1.)): + return None + acceptable &= bool(np.all( + np.abs(uv-middle_q[offset:offset+2]) <= .25*tolerance[offset:offset+2])) + acceptable &= _bilinear_chord_error( + H, qa[offset:offset+2], qb[offset:offset+2], (xa, xb)) <= .25*atol + middle_q[offset:offset+2] = uv + if acceptable: + refined_q.append(qb) + refined_x.append(xb) + else: + if depth >= 20 or len(refined_q)+len(pending) >= 4096: + return None + pending.extend(((middle_q.copy(), qb, middle_x, xb, depth+1), + (qa, middle_q.copy(), xa, middle_x, depth+1))) + return np.asarray(refined_q), np.asarray(refined_x) + + +def _drop_traces_covered_by_overlap_union( + branches, overlap_branches, atol, param_tol, work_budget=None, *, surfaces=None): + """Remove complete paired retraces of one or several boundary overlaps. + + Keepers form a segment union. Concatenating their arrays must not create + a chord across a gap between unrelated overlaps. A trace that leaves + the union, has a different parameter preimage, or cannot be checked + within the remaining work allowance stays in the output. + """ + from mmcore.numeric.intersection.ssx._ssx_polyline import polyline_contained + + paths = [(np.asarray(branch.curve[0], dtype=np.float64), + np.asarray(branch.curve[1], dtype=np.float64)) + for branch in overlap_branches] + paths = [(q, x) for q, x in paths if len(q) >= 2 and len(q) == len(x)] + if not paths: + return list(branches) + if not _assembly_spend(work_budget, sum(len(q) for q, _ in paths)): + return list(branches) + keeper_stuv = np.vstack([q for q, _ in paths]) + keeper_xyz = np.vstack([x for _, x in paths]) + keeper_mask = np.ones(len(keeper_stuv)-1, dtype=bool) + offset = 0 + for q, _ in paths[:-1]: + offset += len(q) + keeper_mask[offset-1] = False + + kept = [] + for index, branch in enumerate(branches): + if branch.kind == 'overlap': + kept.append(branch) + continue + stuv, xyz = branch.curve + if not _assembly_spend(work_budget, len(stuv)): + kept.extend(branches[index:]) + break + contained = polyline_contained( + stuv, xyz, keeper_stuv, keeper_xyz, param_tol, atol, + keeper_mask=keeper_mask, + charge=lambda amount: _assembly_spend(work_budget, amount)) + if contained is False and surfaces is not None: + refined = _refine_injective_chart_parameters( + np.asarray(stuv), np.asarray(xyz), surfaces, param_tol, atol, work_budget) + if refined is not None: + contained = polyline_contained( + *refined, keeper_stuv, keeper_xyz, param_tol, atol, + keeper_mask=keeper_mask, + charge=lambda amount: _assembly_spend(work_budget, amount)) + if contained is None: + kept.extend(branches[index:]) + break + if not contained: + kept.append(branch) + return kept + + # --------------------------------------------------------------------------- # Loop-absence check: TΨᵢ monotonicity OR Gauss map separability # --------------------------------------------------------------------------- -def _check_loop_free(g1, g2, T1=None, T2=None, T3=None, T4=None, - *, source_bounds=None, source_context=False): +def _check_loop_free(g1, g2, T1=None, T2=None, T3=None, T4=None): """Check if the intersection is provably loop-free. Two independent checks — either suffices: @@ -4861,12 +4954,6 @@ def _check_loop_free(g1, g2, T1=None, T2=None, T3=None, T4=None, Returns True if loop-free. """ - if source_context: - # The source Jacobian enclosure includes determinant cancellation - # and restriction error. A sign of the rounded precomputed minor - # alone cannot exclude a loop, even under an invertible world map. - return bool(source_bounds is not None and np.any( - (source_bounds[0] > 0.) | (source_bounds[1] < 0.))) if T1 is not None: is_mono, _ = _check_monotonicity(T1, T2, T3, T4) if is_mono: @@ -4901,7 +4988,7 @@ def _global_to_local(stuv_global, box): for i in range(4): lo, hi = box[i] span = hi - lo - if span > 0.: + if span > 0.0: loc[i] = (stuv_global[i] - lo) / span else: loc[i] = 0.5 @@ -4946,727 +5033,197 @@ def _cell_has_unused_direction(point: BoundaryPoint, cell, direction: str) -> bo ) -def _source_boundary_intervals(cell, point, floating_box): - """Use exact source intervals for ownership and active-face decisions. +def _trace_cell_by_registrations(cell, atol, h_max=None): + """Trace all branch segments inside a certified cell. - An outward float endpoint can equal a cell face while the entire exact - root interval lies strictly inside. That equality is not face activity. - The floating box remains useful for derivative/geometry bounds. - """ - matcher = getattr(cell, 'root_matcher', None) - certificate = getattr(matcher, 'source_certificates', {}).get(id(point)) - if certificate is not None and certificate.get('owner') is point: - exact_box = certificate['exact_box'] - refine = getattr(matcher, 'refine_source_box', None) - if not getattr(point, '_source_root_box', False): - return refine(point,exact_box,cell.box) if refine is not None else exact_box - # Both enclosures refer to this established source root. Exact - # affine identities may have tightened the floating enclosure - # since the scalar certificate was registered. Preserve that - # independent proof while retaining the sharper Sturm endpoints. - # The owner cell is deliberately absent from this intersection. - floating = np.asarray(floating_box, dtype=float) - if floating.shape != (4, 2) or not np.all(np.isfinite(floating)): - return refine(point,exact_box,cell.box) if refine is not None else exact_box - key = tuple(map(tuple, floating)) - cache = certificate.setdefault('_enclosure_intersections', {}) - if key not in cache: - if not matcher._spend(): - return exact_box - from fractions import Fraction - intersection = tuple( - (max(lo, Fraction(float(a))), min(hi, Fraction(float(b)))) - for (lo, hi), (a, b) in zip(exact_box, floating)) - # Contradictory metadata is not a proof of an empty owner. - cache[key] = (intersection if all(lo <= hi for lo, hi in intersection) - else exact_box) - return refine(point,cache[key],cell.box) if refine is not None else cache[key] - return floating_box - - -def _source_boundary_child_groups(cell, points, child_boxes, root_radii=None): - """Supercover boundary events by source enclosures, never sample position. - - A source root can lie across a child face from its rounded display - parameters. Unknown enclosures therefore belong to every child until - a source proof excludes them; every child's census remains exhaustive. - """ - groups = [[] for _ in child_boxes] - if not points or not child_boxes: - return groups - budget = getattr(cell,'work_budget',None) - if budget is not None and not budget.charge_cells( - max(1,len(points)*len(child_boxes)),'boundary_event_partition'): - return [list(points) for _ in child_boxes] - matcher = getattr(cell,'root_matcher',None) - for point in points: - certificates = getattr(matcher,'source_certificates',{}) - certificate = certificates.get(id(point)) - registered = certificate is not None and certificate.get('owner') is point - if (not registered and not getattr(point,'_source_root_box',False) - and matcher is not None and root_radii is not None): - enclosure = matcher.enclose(point,root_radii) - if enclosure is not None: - point.root_box = np.asarray(enclosure,dtype=float) - point._source_root_box = True - certificate = certificates.get(id(point)) - registered = certificate is not None and certificate.get('owner') is point - enclosure = None - if registered or getattr(point,'_source_root_box',False): - enclosure = _source_boundary_intervals(cell,point,point.root_box) - if (enclosure is None or len(enclosure) != 4 - or any(len(pair) != 2 or not pair[0] <= pair[1] - or not all(np.isfinite(float(x)) for x in pair) - for pair in enclosure)): - enclosure = None - for group,box in zip(groups,child_boxes): - if enclosure is None or all( - lo <= b and a <= hi for (lo,hi),(a,b) in zip(enclosure,box)): - group.append(point) - return groups - - -def _source_boundary_child_obligations(cell, child_boxes, added=()): - """Localize incomplete face censuses without forgetting unknown coverage. - - Full closed source-face boxes conservatively contain every event that - the corresponding census may have missed. A disjoint child cannot - inherit that uncertainty. None means that some uncertainty has no - localization and therefore remains relevant to every descendant. - """ - inherited = getattr(cell,'boundary_obligations',None) - if not cell.boundary_complete and inherited is None: - return [None for _ in child_boxes] - obligations = tuple(dict.fromkeys(tuple(inherited or ())+tuple(added))) - if not obligations: - return [() for _ in child_boxes] - budget = getattr(cell,'work_budget',None) - if budget is not None and not budget.charge_cells( - max(1,len(obligations)*len(child_boxes)),'boundary_obligation_partition'): - return [None for _ in child_boxes] - return [tuple(face for face in obligations if all( - lo <= b and a <= hi for (lo,hi),(a,b) in zip(face,box))) - for box in child_boxes] - - -def _strict_interior_cuts(box, axis, cuts): - """Keep only representable cuts that split a closed parameter interval.""" - lo,hi = box[axis] - return sorted(set(value for value in cuts if lo < value < hi)) - - -def _source_boundary_outside(cell, point): - """Prove a borrowed source root is outside this closed owner. - - Sturm intervals may end exactly on a child face even when the root - lies strictly beyond it. Their non-root endpoints are open bounds on - the actual root, including when its floating representative rounds - onto that face. Failure to prove exclusion keeps the event. + For each boundary crossing, march in one direction. If the march + immediately exits (corner touch), try the opposite direction. + + Endpoint policy ("trust the marcher's stopping point"): a march that + reaches the cell boundary ends at a Newton-verified intersection point + on a face. If an unconsumed crossing matches it within the parametric + tolerance, the fragment ends at that crossing; otherwise the exit point + itself becomes a synthesized BoundaryPoint endpoint. Discarding the + fragment (the old behavior, with a fixed 1e-6 match radius) silently + deleted real curve segments whenever the partner crossing was missing + or less accurate than 1e-6 — CSX only guarantees ~ptol accuracy. """ - matcher = getattr(cell, 'root_matcher', None) - certificate = getattr(matcher, 'source_certificates', {}).get(id(point)) - if certificate is None or certificate.get('owner') is not point: - # A generic source inclusion box has the same ownership meaning - # even when no exact scalar cut polynomial is available. Raw child - # boxes and polished points have not earned this source flag. - if not getattr(point, '_source_root_box', False) or point.root_box is None: + from mmcore.nurbs._nurbs_param_tol import bez_surface_param_tolerance + + fragments: list[_Fragment] = [] + points: list = [] + used: set[int] = set() + work_budget = getattr(cell, "work_budget", None) + cell.trace_incomplete = False + + def _deny_trace_work(): + cell.trace_incomplete = True + 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 + # radius (in GLOBAL coordinates the local tolerance scales by the + # cell's span on each axis). + ptol_s, ptol_t = bez_surface_param_tolerance(cell.g1.surface, atol, rational=True) + ptol_u, ptol_v = bez_surface_param_tolerance(cell.g2.surface, atol, rational=True) + 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) + 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) + + def _clipped_corner(point): + """Recognize a regular branch which leaves this cell in both directions. + + A face-tangent extremum needs the second derivative as well as the + tangent. For example, the exterior child at a circle's leftmost + point owns that point only. Retrying the same contact after every + subdivision never produces an arc. Singular or flat contact remains + inconclusive and is handled by subdivision. + """ + if (work_budget is not None + and not work_budget.charge_cells(1, "branch_trace")): return False - enclosure = np.asarray(point.root_box, dtype=float) - if enclosure.shape != (4, 2) or not np.all(np.isfinite(enclosure)): + from mmcore.numeric._bern_homog import ( + eval_bezier_surface_homog_with_derivs, + project_surface_homog_to_cartesian, + ) + derivatives = [] + for surface, parameters in ((cell.g1.surface, point[:2]), + (cell.g2.surface, point[2:])): + derivatives.append(project_surface_homog_to_cartesian( + *eval_bezier_surface_homog_with_derivs( + surface, *parameters, True))) + _, ds, dt, dss, dst, dtt = derivatives[0] + _, du, dv, duu, duv, dvv = derivatives[1] + jacobian = np.column_stack((ds, dt, -du, -dv)) + try: + left_vectors, singular_values, vectors = np.linalg.svd( + jacobian, full_matrices=True) + except np.linalg.LinAlgError: return False - if matcher is not None and not matcher._spend(): + margin = 4e-10 * singular_values[0] + regular = bool(singular_values[-1] > margin) + tangent = vectors[-1] + if not regular: + # At an incident point of a collapsed isoline, the free fiber + # parameter contributes a null direction with no spatial image. + # Remove that known direction before testing whether the actual + # spatial tangent can enter this child from either side. + fiber_axis = None + for surface, offset in ((cell.g1.surface, 0), (cell.g2.surface, 2)): + for fixed in (0, 1): + free = offset+1-fixed + if np.linalg.norm(jacobian[:, free]) > margin: + continue + if _curve_geometry_collapsed(_extract_isoline( + surface, fixed, point[offset+fixed]), rational=True): + fiber_axis = free + break + if fiber_axis is not None: + break + if fiber_axis is None: + return False + axes = [axis for axis in range(4) if axis != fiber_axis] + try: + _, reduced_values, reduced_vectors = np.linalg.svd(jacobian[:, axes]) + except np.linalg.LinAlgError: + return False + if reduced_values[-2] <= margin: + return False + tangent = np.zeros(4) + tangent[axes] = reduced_vectors[-1] + velocity = ds*tangent[0] + dt*tangent[1] + if regular and np.linalg.norm(velocity) <= margin: + # A regular Psi curve can be a free parameter fiber with one + # spatial image. Its collapsed isoline is already the complete + # local solution; no spatial arc remains to march here. Rank + # deficient incidence points do not enter this path. + for surface, offset in ((cell.g1.surface, 0), (cell.g2.surface, 2)): + for fixed in (0, 1): + if abs(tangent[offset+fixed]) > 4e-10: + continue + isoline = _extract_isoline(surface, fixed, point[offset+fixed]) + if _curve_geometry_collapsed(isoline, rational=True): + if work_budget is not None: + work_budget.mark_incomplete(REASON_PARAMETER_FIBER) + return True + face_roundoff = (32.*np.finfo(float).eps + * np.maximum(1., np.max(np.abs(cell.box), axis=1)) + / np.maximum(spans, np.finfo(float).tiny)) + faces = [(axis, 1. if value <= face_roundoff[axis] else -1.) + for axis, value in enumerate(point) + if value <= face_roundoff[axis] + or value >= 1.-face_roundoff[axis]] + first = np.array([sign*tangent[axis] for axis, sign in faces]) + if np.any(first > 1e-10) and np.any(first < -1e-10): + return True + if not regular: + # Deflate the collapsed fiber locally. The left null normal + # requires D²Psi[q',q'] = 0 in that normal direction. Because + # the free isoline is constant, its pure second derivative is + # zero, and this is a linear equation for the free component + # of the incident spatial tangent. + second = np.zeros((4, 4, 3)) + second[0, 0], second[0, 1], second[1, 0], second[1, 1] = dss, dst, dst, dtt + second[2, 2], second[2, 3], second[3, 2], second[3, 3] = -duu, -duv, -duv, -dvv + h0 = np.einsum("i,ijc,j->c", tangent, second, tangent) + mixed = 2.*np.einsum("ic,i->c", second[:, fiber_axis], tangent) + normal = left_vectors[:, -1] + coefficient = float(normal @ mixed) + if abs(coefficient) <= 4e-10*max(1., float(np.linalg.norm(mixed))): + return False + tangent[fiber_axis] = -float(normal @ h0)/coefficient + tangent /= np.linalg.norm(tangent) + # J q'' = -D²Psi[q', q']; q' dot q'' = 0 fixes arc-length + # parametrization. Reversing q' preserves this acceleration, so an + # outward second derivative on a tangent face blocks both senses. + hessian_tangent = ( + dss*tangent[0]**2 + 2.*dst*tangent[0]*tangent[1] + + dtt*tangent[1]**2 - duu*tangent[2]**2 + - 2.*duv*tangent[2]*tangent[3] - dvv*tangent[3]**2) + try: + if regular: + acceleration = np.linalg.solve( + np.vstack((jacobian, tangent)), + np.r_[-hessian_tangent, 0.]) + else: + # The free acceleration does not move the spatial image. + # Fix it as a gauge; use only other faces for curvature + # clipping, since its value requires one more derivative. + gauge = np.eye(4)[fiber_axis] + acceleration = np.linalg.lstsq( + np.vstack((jacobian, tangent, gauge)), + np.r_[-hessian_tangent, 0., 0.], rcond=None)[0] + except np.linalg.LinAlgError: return False - return bool(any(hi < a or lo > b for (lo, hi), (a, b) - in zip(enclosure, cell.box))) - if not matcher._spend(): - return False - exact_box = _source_boundary_intervals(cell,point,point.root_box) - if any(hi < a or lo > b for (lo, hi), (a, b) in zip(exact_box, cell.box)): - return True - possible = _source_boundary_possible_faces(cell,point,exact_box) - if any(lo != hi and ((hi == a and (axis,1.) not in possible) - or (lo == b and (axis,-1.) not in possible)) - for axis,((lo,hi),(a,b)) in enumerate(zip(exact_box,cell.box))): - return True - axis = certificate['varying_axis'] - lo, hi = certificate['exact_interval'] - a, b = cell.box[axis] - if (hi == a or lo == b) and lo != hi: - from mmcore.numeric.intersection._exact_univariate import _value - endpoint = hi if hi == a else lo - return _value(certificate['exact_polynomial'], endpoint) != 0 - return False - + curvature_margin = 4e-10*max(1., float(np.linalg.norm(acceleration))) + return any(abs(tangent[axis]) <= 1e-10 + and (regular or axis != fiber_axis) + and sign*acceleration[axis] < -curvature_margin + for axis, sign in faces) -def _source_boundary_possible_faces(cell, point, exact_box): - """Faces an enclosed source root could actually occupy. - - A nondegenerate Sturm interval includes its non-root endpoints only - as closed bounds. Exact affine parameter identities transfer this - fact to linked coordinates; a displayed corner need not be a corner - of the source curve. This only removes source-proved inactive faces. - """ - possible = [(axis, inward) for axis, (lo, hi) in enumerate(cell.box) - for bound, inward in ((lo, 1.), (hi, -1.)) - if exact_box[axis][0] <= bound <= exact_box[axis][1]] - matcher = getattr(cell, 'root_matcher', None) - certificate = getattr(matcher, 'source_certificates', {}).get(id(point)) - if certificate is None or certificate.get('owner') is not point: - return possible - if all(exact_box[axis][0] == exact_box[axis][1] for axis, inward in possible): - return possible - from fractions import Fraction - from mmcore.numeric.intersection._exact_univariate import _value - varying = certificate['varying_axis'] - constraints = getattr(matcher, 'affine_constraints', None) - components = getattr(constraints, 'components', None) or () - cache = getattr(matcher, '_source_face_relations', None) - if cache is None: - cache = matcher._source_face_relations = {} - cached = cache.get(varying) - if cached is not None and cached[0] is constraints: - relations = cached[1] - else: - if not matcher._spend(1+sum(len(expressions) for expressions, fixed in components)): - return possible - relations = {varying: (Fraction(0), Fraction(1))} - for expressions, fixed in components: - expression = next(((alpha, beta) for axis, alpha, beta in expressions - if axis == varying), None) - if expression is None: - continue - origin, slope = expression - for axis, alpha, beta in expressions: - relations[axis] = origin-slope*alpha/beta, slope/beta + for i, start_cx in enumerate(cell.crossings): + if work_budget is not None and work_budget.exhausted: + cell.trace_incomplete = True + work_budget.mark_incomplete(REASON_WORK_BUDGET) break - cache[varying] = constraints, relations - active, values = [], {} - for axis, inward in possible: - if exact_box[axis][0] == exact_box[axis][1] or axis not in relations: - active.append((axis, inward)) + if i in used: continue - bound = cell.box[axis][0 if inward > 0. else 1] - origin, slope = relations[axis] - parameter = origin+slope*Fraction(float(bound)) - if parameter not in values: - if not matcher._spend(): - active.append((axis, inward)) - continue - values[parameter] = _value(certificate['exact_polynomial'], parameter) - if values[parameter] == 0: - active.append((axis, inward)) - return active - -def _strict_corner_touch(cell, x, point=None, root_radii=None): - """Whether a regular lifted arc exits the closed cell in both directions. - - Opposite strict inward signs on active faces exclude both local arc - directions by the implicit-function theorem. A zero face derivative - is inconclusive: a grazing arc can enter quadratically. - """ - source_cofactors = getattr(cell, 'source_cofactors', None) - if source_cofactors is not None: - # A rounded representative on a face does not prove that its - # source root belongs to that face. Use the complete authoritative - # source enclosure and source Jacobian cofactors instead. - if point is None: - return False - enclosure = (point.root_box if getattr(point, '_source_root_box', False) - else None) - matcher = getattr(cell, 'root_matcher', None) - if enclosure is None and matcher is not None and root_radii is not None: - enclosure = matcher.enclose(point, root_radii) - if enclosure is None: - exact_root = getattr(cell, 'source_root_exact', None) - if exact_root is None or not exact_root(point.stuv): - return False - enclosure = np.repeat(np.asarray(point.stuv)[:, None], 2, axis=1) - enclosure = np.asarray(enclosure, dtype=float) - exact_enclosure = _source_boundary_intervals(cell, point, enclosure) - if (enclosure.shape != (4, 2) or not np.all(np.isfinite(enclosure)) - or any(not lo <= exact_enclosure[k][0] <= exact_enclosure[k][1] <= hi - for k, (lo, hi) in enumerate(cell.box))): - return False - active = [(k, 1. if exact_enclosure[k][0] == lo else -1.) - for k, (lo, hi) in enumerate(cell.box) - if exact_enclosure[k][0] == exact_enclosure[k][1] - and exact_enclosure[k][0] in (lo, hi)] - if len(active) < 2: - return False - bounds = source_cofactors.bounds(tuple(map(tuple, enclosure))) - if bounds is None: - return False - lower, upper = bounds - inward = [(sign*lower[k], sign*upper[k]) if sign > 0. - else (sign*upper[k], sign*lower[k]) - for k, orientation in active - for sign in (orientation*(1., -1., 1., -1.)[k],)] - return bool(any(lo > 0. for lo, hi in inward) - and any(hi < 0. for lo, hi in inward)) - - # Private diagnostic callers without source context retain the - # numerical proposal. Production cells always take the source proof. - _, ds, dt = eval_surface_d1(cell.g1.surface, *x[:2], rational=True) - _, du, dv = eval_surface_d1(cell.g2.surface, *x[2:], rational=True) - J = np.column_stack([ds, dt, -du, -dv]) - try: - _, sigma, vt = np.linalg.svd(J, full_matrices=True) - except np.linalg.LinAlgError: - return False - if (not np.all(np.isfinite(sigma)) or sigma[0] <= 0 - or sigma[-1] <= 4e-10 * sigma[0]): - return False - tangent = vt[-1] - # Normal-vector orientation uncertainty grows with conditioning. - # Only exactly active faces constrain the closed-cell tangent cone: - # proximity to a face is not evidence that the root lies on it. - margin = 128 * np.finfo(float).eps * sigma[0] / sigma[-1] - inward = [tangent[i] if x[i] == 0.0 else -tangent[i] - for i in range(4) if x[i] == 0.0 or x[i] == 1.0] - return bool(inward and min(inward) < -margin and max(inward) > margin) - - -def _regular_boundary_empty_separators(cell, root_radii=None): - """Prove empty cuts between regular arcs from an exhaustive port census. - - A strict source minor orients every arc by one increasing parameter. - Entering ports add one to its level-set count, exiting ports subtract - one, and source-proved isolated corner touches add zero. Between - disjoint port bands a zero prefix count proves an empty source slice. - This certificate is local to the complete closed ``cell.box``. - """ - cell._regular_empty_cut_certificate = None - if not cell.boundary_complete or len(cell.crossings) < 3: - return None - source = getattr(cell, 'source_cofactors', None) - if source is None: - return None - budget = getattr(cell, 'work_budget', None) - size = len(cell.crossings) - if budget is not None and not budget.charge_cells( - size+size*(size-1)//2, 'boundary_port_census'): - return None - bounds = source.bounds(cell.box) - if bounds is None or not np.all(np.isfinite(bounds)): - return None - lower, upper = map(np.asarray, bounds) - strict = (lower > 0.) | (upper < 0.) - if not np.any(strict): - return None - axis = int(np.argmax(np.where(strict, - np.minimum(np.abs(lower),np.abs(upper)),0.))) - signs = (1.,-1.,1.,-1.) - orientation = signs[axis]*(1. if lower[axis] > 0. else -1.) - matcher = getattr(cell, 'root_matcher', None) - roots, boxes, exact_boxes, seen = [], [], [], set() - for point in cell.crossings: - if id(point) in seen: - continue - seen.add(id(point)) - if not getattr(point, '_source_root_box', False): - if matcher is None or root_radii is None: - return None - enclosure = matcher.enclose(point, root_radii) - if enclosure is None: - return None - point.root_box = enclosure - point._source_root_box = True - box = np.asarray(point.root_box, dtype=float) - exact_box = _source_boundary_intervals(cell, point, box) - if (box.shape != (4,2) or not np.all(np.isfinite(box)) - or any(not lo <= exact_box[k][0] <= exact_box[k][1] <= hi - for k,(lo,hi) in enumerate(cell.box))): - return None - duplicate = False - for other, other_box in zip(roots,boxes): - if np.any((box[:,0] > other_box[:,1]) | (box[:,1] < other_box[:,0])): - continue - # Overlapping enclosures do not establish distinct events. - # A source identity proof may merge them; unknown is not false. - if matcher is None or root_radii is None or not matcher( - point,other,root_radii,np.inf): - return None - duplicate = True - break - if not duplicate: - roots.append(point) - boxes.append(box) - exact_boxes.append(exact_box) - events = [] - for point, box, exact_box in zip(roots, boxes, exact_boxes): - exact_faces = [] - possible_faces = _source_boundary_possible_faces(cell, point, exact_box) - for k,(lo,hi) in enumerate(cell.box): - for bound,inward in ((lo,1.),(hi,-1.)): - if exact_box[k][0] <= bound <= exact_box[k][1]: - if exact_box[k][0] == exact_box[k][1] == bound: - exact_faces.append((k,inward)) - if not exact_faces: - return None - root_bounds = source.bounds(tuple(map(tuple,box))) - if root_bounds is None or not np.all(np.isfinite(root_bounds)): - return None - face_signs = {} - for k,inward in possible_faces: - values = orientation*signs[k]*inward*np.asarray( - [root_bounds[0][k],root_bounds[1][k]]) - if values.min() > 0.: - face_signs[k,inward] = 1 - elif values.max() < 0.: - face_signs[k,inward] = -1 - else: - return None - directions = set(face_signs.values()) - if len(directions) == 1: - contribution = directions.pop() - elif {face_signs[face] for face in exact_faces} == {-1,1}: - # Both directions must violate an ACTUALLY active face. A - # merely possible second face cannot prove an isolated touch. - contribution = 0 - else: - return None - events.append((box[axis,0],box[axis,1],contribution)) - groups = [] - for lo,hi,count in sorted(events): - if groups and lo <= groups[-1][1]: - a,b,total = groups[-1] - groups[-1] = (a,max(b,hi),total+count) - else: - groups.append((lo,hi,count)) - counts, running = [], 0 - for lo,hi,count in groups: - running += count - if running < 0: - return None - counts.append(running) - if running: - return None - cuts = [] - for first,second,count in zip(groups,groups[1:],counts): - a,b = first[1],second[0] - if count or np.nextafter(a,b) >= b: - continue - for level in range(max(0,-math.frexp(b)[1]),1075): - step = math.ldexp(1.,-level) - value = (math.floor(a/step)+1)*step - if a < value < b: - cuts.append(value) - break - if not cuts: - return None - cell._regular_empty_cut_certificate = { - 'kind':'source_regular_boundary_prefix', 'axis':axis, - 'owner_box':tuple(tuple(pair) for pair in cell.box), - 'cuts':tuple(cuts), 'port_groups':tuple(groups), - 'prefix_counts':tuple(counts), - } - return axis,cuts - - -def _regular_cell_arc_enclosure(cell, root_radii=None): - """Prove and enclose the unique regular arc in a two-port cell. - - A strict minor makes one global parameter monotone. Its cofactor - ratios bound every other parameter's variation between the two - boundary roots, even when the surrounding product cell is much wider. - """ - if not cell.boundary_complete or len(cell.crossings) != 2: - return None - roots = cell.crossings - matcher = getattr(cell, 'root_matcher', None) - if matcher is not None and root_radii is not None: - for point in roots: - if not getattr(point, '_source_root_box', False): - enclosure = matcher.enclose(point, root_radii) - if enclosure is None: - return None - point.root_box = enclosure - point._source_root_box = True - elif not getattr(point, '_source_enclosure_refined', False): - # Existence does not imply that an initial CSX root box - # is tight enough to assign the root to this child. A - # contained independent source enclosure preserves its - # identity while improving ownership and germ bounds. - refine = getattr(matcher, 'refine_enclosure', None) - if refine is not None: - radii = np.minimum(root_radii, - np.diff(np.asarray(cell.box), axis=1)[:, 0]/16.) - enclosure = refine(point, radii) - if enclosure is not None: - point.root_box = enclosure - point._source_enclosure_refined = True - if any(point.root_box is None for point in roots): - return None - tensors = (cell.T1, cell.T2, cell.T3, cell.T4) - if any(tensor is None for tensor in tensors): - return None - signs = np.array([1., -1., 1., -1.]) - eps = np.finfo(float).eps - source_cofactors = getattr(cell, 'source_cofactors', None) - if source_cofactors is None: - return None - bounds = source_cofactors.bounds(cell.box) - if bounds is None: - return None - lower, upper = bounds - margin = np.zeros(4) - separated = (lower > margin) | (upper < -margin) - if not np.any(separated): - return None - denominator = np.where(separated, - np.minimum(np.abs(lower), np.abs(upper))-margin, 0.) - order = list(roots) - boxes = [np.asarray(point.root_box, dtype=float) for point in order] - exact_boxes = [_source_boundary_intervals(cell, point, box) - for point, box in zip(order, boxes)] - separate = getattr(matcher, 'separate_source_boxes', None) - if separate is not None and not any(separated[k] and ( - exact_boxes[0][k][1] < exact_boxes[1][k][0] - or exact_boxes[1][k][1] < exact_boxes[0][k][0]) for k in range(4)): - exact_boxes = separate(order, exact_boxes) - ordered_axes = [axis for axis in range(4) if separated[axis] and ( - exact_boxes[0][axis][1] < exact_boxes[1][axis][0] - or exact_boxes[1][axis][1] < exact_boxes[0][axis][0])] - if not ordered_axes: - return None - axis = max(ordered_axes, key=lambda k: denominator[k]) - if exact_boxes[1][axis][1] < exact_boxes[0][axis][0]: - order.reverse() - boxes.reverse() - exact_boxes.reverse() - if any(any(root_box[k][0] < lo or root_box[k][1] > hi - for k, (lo, hi) in enumerate(cell.box)) - for root_box in exact_boxes): - # Clipping an existence enclosure does not prove the root lies in - # the clipped part. Unknown membership cannot establish a port. - return None - # Exact ownership above permits clipping only the outward floating - # envelope; the actual source existence interval remains unchanged. - boxes = [np.column_stack((np.maximum(box[:,0], np.asarray(cell.box)[:,0]), - np.minimum(box[:,1], np.asarray(cell.box)[:,1]))) - for box in boxes] - # Distinct source events can share their entire floating display - # tuple. Their exact source intervals establish order; overlapping - # outward floating envelopes remain valid bounds for the tiny arc. - orientation = signs[axis]*(1. if lower[axis] > 0. else -1.) - spans = np.array([hi-lo for lo, hi in cell.box]) - origins = np.array([lo for lo, hi in cell.box]) - # Root count alone also admits two isolated boundary touches. One - # source-proved entering germ establishes an actual arc; compactness, - # regularity, strict monotonicity, and the exhaustive two-root census - # force its other endpoint to be the other registered root. - proved_germs = [] - for index, (point, box, exact_box) in enumerate(zip(order, boxes, exact_boxes)): - if not any(exact_box[k][0] == exact_box[k][1] and exact_box[k][0] in (lo, hi) - for k, (lo, hi) in enumerate(cell.box)): - # A port must lie on a source-proven face. A polished sample - # rounded onto that face is not a boundary-membership proof. - return None - active = _source_boundary_possible_faces(cell, point, exact_box) - # Inward continuation must satisfy every possibly active face. - # If a root enclosure merely touches another face, its germ must - # still enter there unless strict interior membership is known. - root_bounds = source_cofactors.bounds(box) - if root_bounds is None: - return None - strict_germ = True - for i, inward_sign in active: - values = orientation*signs[i]*inward_sign*np.array( - [root_bounds[0][i], root_bounds[1][i]]) - # Positive-axis continuation enters the first event and exits - # the second. Tangent or mixed face directions stay unresolved. - if index == 1: - values = -values - if float(values.max()) < -margin[i]: - # A definitely wrong direction contradicts the proposed - # increasing-parameter arc; retain this cell as unknown. - return None - strict_germ = strict_germ and float(values.min()) > margin[i] - proved_germs.append(strict_germ) - if not any(proved_germs): - return None - variation = np.nextafter( - (np.maximum(np.abs(lower), np.abs(upper))+margin)/denominator[axis], np.inf) - delta = np.nextafter(boxes[1][axis, 1]-boxes[0][axis, 0], np.inf) - parameter_extent = np.nextafter(variation*delta, np.inf) - # The one arc cannot use the whole surrounding product box. In its - # monotone coordinate it lies between the two root enclosures, and - # the cofactor ratio bounds its displacement from either endpoint. - # Restrict to those necessary bounds and recompute the ratios while - # the enclosure contracts. This is interval continuation of the same - # already identified arc, not a new component-exclusion predicate. - arc_box = np.asarray(cell.box, dtype=float) - arc_box[axis] = boxes[0][axis,0], boxes[1][axis,1] - while True: - tightened = arc_box.copy() - for root_box in boxes: - tightened[:,0] = np.maximum(tightened[:,0], np.nextafter( - root_box[:,0]-parameter_extent, -np.inf)) - tightened[:,1] = np.minimum(tightened[:,1], np.nextafter( - root_box[:,1]+parameter_extent, np.inf)) - if np.any(tightened[:,1] <= tightened[:,0]): - return None - old_width = float(np.max(arc_box[:,1]-arc_box[:,0])) - new_width = float(np.max(tightened[:,1]-tightened[:,0])) - if np.array_equal(tightened, arc_box): - break - arc_box = tightened - new_bounds = source_cofactors.bounds(arc_box) - if new_bounds is None: - return None - new_lower, new_upper = new_bounds - minor_floor = ((new_lower[axis] if lower[axis] > 0. else -new_upper[axis]) - - margin[axis]) - if minor_floor <= 0.: - return None - parameter_extent = np.nextafter(np.nextafter( - (np.maximum(np.abs(new_lower), np.abs(new_upper))+margin)/minor_floor, - np.inf)*delta, np.inf) - if new_width >= .5*old_width: - break - return order, boxes, axis, arc_box - - -def _small_regular_cell_arc(cell, atol, root_radii=None, *, arc_enclosure=None): - """Bound both source representations and the already owned exact arc.""" - if arc_enclosure is None: - arc_enclosure = _regular_cell_arc_enclosure(cell, root_radii) - if arc_enclosure is None: - return None - order, boxes, axis, arc_box = arc_enclosure - source_cofactors = cell.source_cofactors - representation = getattr(cell, 'path_representation', None) - if representation is None or not representation( - order[0].stuv, order[1].stuv, np.array([point.xyz for point in order])): - # A short physical arc need not have a faithful linear lifted - # parameter representation on both charts. Bound the published - # chord against both original source paths before emitting it. - return None - xyz = np.array([point.xyz for point in order]) - arc_chord = getattr(cell, 'arc_chord', None) - arc_bounds = source_cofactors.bounds(arc_box) - chord_proved = bool(arc_chord is not None and arc_bounds is not None - and arc_chord(arc_box, boxes, xyz, axis, arc_bounds)) - if not chord_proved: - arc_image = getattr(cell, 'arc_image', None) - if arc_image is None or not arc_image(arc_box, xyz): - # The derivative-range secant theorem can validate a long - # arc; the exact source image diameter remains a fallback. - # Both need the independent lifted representation check above. - return None - return _Fragment(order[0], order[1], - np.array([point.stuv for point in order]), - np.array([point.xyz for point in order])) - - -def _trace_cell_by_registrations(cell, atol, h_max=None): - """Trace all branch segments inside a certified cell. - - For each boundary crossing, march in one direction. If the march - immediately exits (corner touch), try the opposite direction. - - Exits match registered roots only through source-face identity. An - unregistered exit must obtain original-source existence before it can - become a BoundaryPoint; a numerical residual alone leaves the cell in - the unresolved frontier. - """ - from mmcore.nurbs._nurbs_param_tol import bez_surface_param_tolerance - - 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 - - straight_path = getattr(cell, 'straight_path', None) - source_cofactors = getattr(cell, 'source_cofactors', None) - source_bounds = source_cofactors.bounds(cell.box) if source_cofactors is not None else None - source_regular = bool(source_bounds is not None and np.any( - (source_bounds[0] > 0.) | (source_bounds[1] < 0.))) - if (straight_path is not None and len(cell.crossings) == 2 - and cell.boundary_complete and source_regular): - first, second = cell.crossings - exact_path = straight_path(first.stuv, second.stuv) - if exact_path is not None: - # The exact nonconstant path establishes its own entering and - # exiting arc. Exhaustive two-root census plus strict source - # monotonicity excludes any additional component in this cell. - return [_Fragment(first, second, *exact_path)], [] - - # Per-axis parametric tolerance for the cell's local sub-surfaces. - # Sizes the marcher's initial/minimal steps and the endpoint matching - # radius (in GLOBAL coordinates the local tolerance scales by the - # cell's span on each axis). - ptol_s, ptol_t = bez_surface_param_tolerance(cell.g1.surface, atol, rational=True) - ptol_u, ptol_v = bez_surface_param_tolerance(cell.g2.surface, atol, rational=True) - 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 * spans - arc_enclosure = _regular_cell_arc_enclosure(cell, match_tol_global) - small_arc = (_small_regular_cell_arc(cell, atol, match_tol_global, - arc_enclosure=arc_enclosure) - if arc_enclosure is not None else None) - if small_arc is not None: - return [small_arc], [] - - if source_regular and arc_enclosure is None: - if (cell.boundary_complete and cell.crossings and all( - _strict_corner_touch(cell, _global_to_local(point.stuv, cell.box), - point, match_tol_global) - for point in cell.crossings)): - # Every actual boundary root has an opposed tangent cone and - # owns only a clipped point; strict monotonicity excludes an - # interior loop. There is no incident arc left to discover. - return [], [_registered_point(point) for point in cell.crossings] - # A strict minor excludes interior loops, but it does not pair four - # or more boundary events, nor turn unproved face samples into ports. - # Subdivide the complete owner until one arc has a source census and - # inward/outward germs. A numerical march cannot discharge this box. - cell.trace_incomplete = True - if len(cell.crossings) > 2: - _regular_boundary_empty_separators(cell, match_tol_global) - separated = (source_bounds[0] > 0.) | (source_bounds[1] < 0.) - floors = np.where(separated, np.minimum(np.abs(source_bounds[0]), - np.abs(source_bounds[1])), 0.) - cell.trace_split_axis = int(np.argmax(floors)) - return [], [] - - 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 - - start_local = _global_to_local(start_cx.stuv, cell.box) - if _strict_corner_touch(cell, start_local, start_cx, match_tol_global): - # This closed child owns the root but no incident regular arc. - # A displaced seed can jump across an excluded grazing dip to - # another part of the curve and prepend an invalid connector, - # duplicating the neighboring child's arc. Exclude that local - # launch before any displacement, using the tangent cone. - used.add(i) - points.append(_registered_point(start_cx)) - continue + start_local = _global_to_local(start_cx.stuv, cell.box) # XYZ distance to the nearest unused partner crossing bounds the # marcher's initial xyz step target: step toward the partner, not @@ -5690,10 +5247,20 @@ def _deny_trace_work(): else: h_init = min(cell_h_max, max(atol, 0.25 * nearest_xyz)) - # Each launch is a proposal. The first verified boundary exit - # terminates this cell's arc; failed launches may try the opposite - # direction or a corrected interior seed. Interior truncations stay - # explicitly partial and cannot discharge this cell. + # Candidate collection across attempts: commit the FIRST substantial + # fragment (arc > 16·atol, the micro-branch scale) immediately, but + # keep trying further attempts while only micro fragments came back. + # Two graze diseases need the extra attempts: + # - both plain marches BOUNCE (grazing corner, off-lattice loop) — + # attempts 2/3 march from a displaced, corrected interior seed; + # - a plain march makes a little progress and then exits through a + # face the curve merely GRAZES (corner-sharing bilinear repro: + # the arc from the (1,1,0,0) domain corner dipped out at u=0 + # after 0.142 and the remaining 32-unit arc was silently lost) — + # the displaced attempts march PAST the dip and return the full + # arc; the longest candidate wins, and genuine micro-fragments + # (case 10's 5.3·atol sliver) keep winning when the extra + # attempts find nothing longer. candidates = [] # (arc_xyz, stuv_global, xyz_local, matched_j) tang_seed = None for attempt in range(4): @@ -5722,10 +5289,9 @@ def _deny_trace_work(): elif attempt >= 2: # Displaced-seed recovery: step the seed a few percent # ALONG the curve tangent, Newton-correct back onto the - # curve, march from the interior point; the registered - # crossing is prepended so the fragment still starts at - # the registered stuv (the first chord skips the graze - # dip within sagitta h²·kappa/2 << atol). + # curve, march from the interior point. The gap back to + # the registration is traced separately below: a fixed + # fraction of the parameter box has no fixed XYZ error. sign = 1.0 if attempt == 2 else -1.0 seed_local = None for alpha in (0.02, 0.05, 0.1): @@ -5819,18 +5385,71 @@ def _deny_trace_work(): if work_budget is not None and work_budget.exhausted: break continue + if not (np.isfinite(stuv_local).all() + and np.isfinite(xyz_local).all()): + continue if prepend_crossing: - stuv_local = np.vstack([np.asarray(start_local)[None, :], - np.asarray(stuv_local)]) - xyz_local = np.vstack([np.asarray(start_cx.xyz, - 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. + # Starting inside avoids the original boundary bounce. + # Trace BACK to the registration with the same chord + # controller instead of adding an unchecked start chord. + # A grazed subdivision face can put the connecting arc + # just outside this child. Continue on the original + # surfaces so their artificial child walls do not clip + # that arc back into a straight chord. + trace_surfaces = getattr(cell, "trace_surfaces", None) + if trace_surfaces is None: + prefix_surfaces = (cell.g1.surface, cell.g2.surface) + prefix_start, prefix_end = seed_local, start_local + prefix_ptol = ptol_local + else: + prefix_surfaces = trace_surfaces + prefix_start = _local_to_global(seed_local, cell.box) + prefix_end = np.asarray(start_cx.stuv, dtype=np.float64) + prefix_ptol = ptol_local * spans + prefix_limit = (work_budget.remaining_cells + if work_budget is not None else trace_limit) + if prefix_limit <= 0: + _deny_trace_work() + break + prefix_stats = {} + prefix_q, prefix_xyz = _march_intersection_curve( + *prefix_surfaces, prefix_start, prefix_end, + atol=atol, rational=True, h_max=cell_h_max, + min_step=max(float(prefix_ptol.max()), 1e-12), + max_points=prefix_limit, + stats=prefix_stats) + prefix_iterations = int(prefix_stats.get("iterations", 0)) + if (work_budget is not None and prefix_iterations + and not work_budget.charge_cells( + prefix_iterations, "branch_trace_prefix")): + _deny_trace_work() + break + if (len(prefix_q) < 2 + or not np.isfinite(prefix_q).all() + or not np.isfinite(prefix_xyz).all() + or np.any(np.abs(prefix_q[-1] - prefix_end) + > 4.0 * prefix_ptol) + or np.linalg.norm(prefix_xyz[-1] - start_cx.xyz) + > 2.0 * atol): + # This trial has not connected its interior seed to + # the registered event. Another trial or a smaller + # cell can resolve that continuation. Mark the cell + # incomplete below only if no complete attempt wins. + continue + if trace_surfaces is not None: + # Keep the actual continuation across the child face. + # _global_to_local clips to the child, which would + # alter the recovered curve here. + prefix_q = ((prefix_q - np.asarray(cell.box)[:, 0]) + / spans) + prefix_q[-1] = start_local + prefix_xyz[-1] = start_cx.xyz + stuv_local = np.vstack([prefix_q[:0:-1], stuv_local]) + xyz_local = np.vstack([prefix_xyz[:0:-1], xyz_local]) + + # Validate continuation on both surfaces at the requested + # geometric tolerance. The corrector and chord checks control + # approximation error; binary equality is not an output gate. if (work_budget is not None and not work_budget.charge_cells( len(stuv_local), "branch_trace_verify")): @@ -5846,14 +5465,12 @@ def _deny_trace_work(): # 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): + if not (np.isfinite(_vres) and _vres <= atol): strict_path = False break if not strict_path: - # Reject this trial and try the other launch. A failed - # trial is not an unresolved cell when a later trial or - # subdivision covers the same incident arc. The no-winner - # path below retains the cell in the search frontier. + if work_budget is not None: + work_budget.mark_incomplete(REASON_TRACE_UNVERIFIED) continue # Bounce/degenerate detector, in XYZ over the WHOLE path: a @@ -5869,7 +5486,7 @@ def _deny_trace_work(): path_xyz = np.asarray(xyz_local, dtype=np.float64) disp_xyz = float(np.linalg.norm( path_xyz - path_xyz[0][None, :], axis=1).max()) - if disp_xyz == 0. and np.all(np.asarray(stuv_local) == stuv_local[0]): + if disp_xyz <= atol: continue stuv_global = np.empty((len(stuv_local), 4), dtype=np.float64) @@ -5877,6 +5494,20 @@ def _deny_trace_work(): stuv_global[j] = _local_to_global(stuv_local[j], cell.box) stuv_global[0] = start_cx.stuv.copy() + # This tracer is called only after the cell's loop-absence + # check. Returning to the same paired boundary event therefore + # cannot account for an arc in this cell. In particular, a + # clamped predictor can step along a tolerance valley and bounce + # straight back, producing a spurious three-vertex closed path. + # Try another direction, then subdivide if none reaches a new + # event; do not relabel that failed continuation as a loop. + if (np.all(np.abs(stuv_global[-1] - start_cx.stuv) + <= match_tol_global) + and float(np.linalg.norm( + path_xyz[-1] - np.asarray(start_cx.xyz))) + <= 2.0 * atol): + continue + # Match the exit against the cell's crossings within the # parametric tolerance. Consumed crossings stay eligible as # endpoints (a corner can terminate two fragments) but only @@ -5885,33 +5516,11 @@ def _deny_trace_work(): # winning candidate. best_j = None best_score = float('inf') - exit_point = None - if exit_info is not None: - exit_axis = exit_info[0] - exit_side = 0 if stuv_local[-1][exit_axis] < .5 else 1 - stuv_global[-1, exit_axis] = cell.box[exit_axis][exit_side] - exit_point = BoundaryPoint( - stuv_global[-1].copy(), np.asarray(xyz_local[-1]), - (exit_axis, exit_side)) - matcher = getattr(cell, 'root_matcher', None) - if (matcher is not None - and matcher(start_cx, exit_point, match_tol_global, 2.*atol)): - # Returning to the same certified boundary event is - # an outward bounce/retrace in this loop-free cell. - continue for j, cx in enumerate(cell.crossings): if j == i: continue diff = np.abs(cx.stuv - stuv_global[-1]) score = float(np.max(diff / match_tol_global)) - if score > 1.0 or np.linalg.norm(cx.xyz-xyz_local[-1]) > 2.*atol: - continue - matcher = getattr(cell, 'root_matcher', None) - if (exit_point is None or not ( - matcher(exit_point, cx, match_tol_global, 2.*atol) - if matcher is not None else - np.array_equal(cx.stuv, exit_point.stuv))): - continue if score < best_score: best_score = score best_j = j @@ -5929,34 +5538,6 @@ def _deny_trace_work(): - np.asarray(xyz_local[-1], dtype=np.float64))) <= 2.0 * atol): matched_j = best_j - if (exit_point is not None and matched_j is None - and source_regular and arc_enclosure is not None): - # This owner already has exactly two proved source ports. - # Existence of another sampled exit cannot replace identity - # with its known partner. Otherwise the partner stays unused - # and launches a reverse copy of the same arc, yet the cell - # is incorrectly discharged as complete. Try another launch; - # if none establishes the partner, the no-winner path below - # retains the complete owner in the unresolved frontier. - continue - - if exit_point is not None and matched_j is None: - matcher = getattr(cell, 'root_matcher', None) - source_enclosure = (matcher.enclose(exit_point,match_tol_global) - if matcher is not None else None) - if source_enclosure is not None and any( - source_enclosure[k,0] < lo or source_enclosure[k,1] > hi - for k,(lo,hi) in enumerate(cell.box)): - source_enclosure = None - exact_root = getattr(cell,'source_root_exact',None) - if source_enclosure is None and not ( - exact_root is not None and exact_root(exit_point.stuv)): - # Newton can stop anywhere in a high-order tolerance - # valley. An unregistered face endpoint needs original - # source existence before this trial represents an arc; - # otherwise keep the cell for subdivision. - continue - arc_xyz = float(np.linalg.norm( np.diff(path_xyz, axis=0), axis=1).sum()) candidates.append((arc_xyz, stuv_global, @@ -5964,17 +5545,34 @@ def _deny_trace_work(): matched_j, exit_info, np.asarray(stuv_local[-1], dtype=np.float64))) - # The first valid boundary exit terminates this cell's arc. - # Looking for a longer displaced-seed candidate can jump an - # outside-cell interval and invent a connector past that exit. - if exit_info is not None: - break - - if arc_xyz > 16.0 * atol: - break # retain a substantial, explicitly partial prefix + # Graze-exit suspicion: the march ended on a face NO registered + # crossing accounts for, with the curve tangent nearly PARALLEL + # to that face — the signature of the curve dipping just outside + # the box and re-entering (corner-sharing bilinear repro: the + # arc from the (1,1,0,0) domain corner dipped out at u=0 after + # 0.142 = 142·atol — arc length alone cannot flag it). Keep + # attempting; the displaced-seed marches (2/3) start PAST the + # dip and recover the remaining arc; winner-by-length decides. + graze_exit = False + if matched_j is None and exit_info is not None: + tang_exit, _, _ = _ssx_tangent_4d( + cell.g1.surface, cell.g2.surface, + *np.asarray(stuv_local[-1], dtype=np.float64), + rational=True) + if tang_exit is not None: + _ax = exit_info[0] + if (abs(float(tang_exit[_ax])) + < 0.1 * float(np.linalg.norm(tang_exit))): + graze_exit = True + if (arc_xyz > 16.0 * atol and not graze_exit + and (matched_j is not None or exit_info is not None)): + break # substantial fragment, honest exit — done if candidates: - candidates.sort(key=lambda c: -c[0]) + # Prefer a complete boundary-to-boundary attempt over an + # unfinished prefix, even when the latter travelled farther. + candidates.sort(key=lambda c: ( + c[3] is None and c[4] is None, -c[0])) _, stuv_global, xyz_local, matched_j, exit_info, exit_local = candidates[0] if matched_j is not None: end_cx = cell.crossings[matched_j] @@ -5982,8 +5580,8 @@ def _deny_trace_work(): xyz_local[-1] = end_cx.xyz.copy() used.add(matched_j) elif exit_info is not None: - # This unregistered exit passed the original-source - # existence check above. Preserve its enclosure as well. + # No registered crossing here — the marcher just proved one + # exists (Newton-converged exit on a face). Synthesize it. axis = exit_info[0] side = 0 if exit_local[axis] < 0.5 else 1 tang_end, _, _ = _ssx_tangent_4d( @@ -5995,10 +5593,6 @@ def _deny_trace_work(): face=(axis, side), tangent_raw=tang_end, ) - matcher = getattr(cell,'root_matcher',None) - if matcher is not None: - end_cx.root_box = matcher.enclose(end_cx,match_tol_global) - end_cx._source_root_box = end_cx.root_box is not None else: # March ended in the interior (truncation or refused exit). # The traced points are still Newton-verified curve samples; @@ -6014,6 +5608,47 @@ def _deny_trace_work(): stuv_path=stuv_global, xyz_path=xyz_local, )) + if end_cx is not None: + # A curve lying along a cell face can have many CSX + # registrations in the interior of this completed path. + # They are observations of the same regular arc, not new + # traces. Consume only a shared correspondence in XYZ and + # all four parameters, and retain singular registrations + # which may seed another incident branch. + delta_xyz = np.diff(xyz_local, axis=0) + squared = np.einsum("ij,ij->i", delta_xyz, delta_xyz) + for j, crossing in enumerate(cell.crossings): + if j in used: + continue + if (work_budget is not None and not work_budget.charge_cells( + max(1, len(squared)), "branch_registration")): + _deny_trace_work() + break + fraction = np.einsum( + "ij,ij->i", crossing.xyz-xyz_local[:-1], delta_xyz + ) / np.maximum(squared, 1e-30) + # Never absorb a registration beyond an open endpoint + # through a nearest-point clamp. + inside = (fraction >= 0.) & (fraction <= 1.) + xyz_on_path = xyz_local[:-1]+fraction[:, None]*delta_xyz + q_on_path = (stuv_global[:-1]+fraction[:, None] + * np.diff(stuv_global, axis=0)) + covered = (inside + & (np.linalg.norm(xyz_on_path-crossing.xyz, axis=1) <= atol) + & np.all(np.abs(q_on_path-crossing.stuv) + <= match_tol_global, axis=1)) + if not np.any(covered): + continue + q = _global_to_local(crossing.stuv, cell.box) + _, ds, dt = eval_surface_d1(cell.g1.surface, *q[:2], rational=True) + _, du, dv = eval_surface_d1(cell.g2.surface, *q[2:], rational=True) + try: + values = np.linalg.svd(np.column_stack((ds, dt, -du, -dv)), + compute_uv=False) + except np.linalg.LinAlgError: + continue + if values[-1] > 4e-10*values[0]: + used.add(j) if (not candidates and i not in used and not (work_budget is not None and work_budget.exhausted)): @@ -6031,13 +5666,13 @@ def _deny_trace_work(): (REASON_MULTIPLICITY, np.asarray(start_cx.stuv, dtype=np.float64).copy())) work_budget.mark_incomplete(REASON_MULTIPLICITY) - points.append(_registered_point(start_cx)) - if not _strict_corner_touch(cell, start_local, start_cx, match_tol_global): - # A failed predictor/corrector is not an isolated-point - # certificate. The caller must retain this cell in the - # subdivision frontier instead of dropping its incident arc. + points.append(SSXPoint(stuv=start_cx.stuv, xyz=start_cx.xyz)) + if not _clipped_corner(start_local): cell.trace_incomplete = True + if work_budget is not None and work_budget.exhausted: + cell.trace_incomplete = True + return fragments, points @@ -6054,16 +5689,17 @@ def _assembly_spend(work_budget, amount: int = 1, def _unify_fragment_endpoints(fragments: list[_Fragment], unify_tol, unify_atol: float = 1e-3, - work_budget=None, root_matcher=None) -> None: + work_budget=None) -> None: """Replace fragment endpoint objects that represent the same physical crossing with one canonical `BoundaryPoint` (in place). Fragments from adjacent cells reference DIFFERENT objects for the same - parameter root whenever the crossing was discovered independently + physical point whenever the crossing was discovered independently (corner duplicates, re-found cut-face roots) or one side synthesized its exit from the marcher. The id-based chain walker can only connect fragments sharing the object, so unify endpoints whose stuv agree - by exact parameter agreement or a common-face root identity proof. + within the per-axis parametric tolerance — design §4.7.4's 1D + param-matching on shared partitions, generalized to 4D. """ tol = np.asarray(unify_tol, dtype=np.float64) objs: list[BoundaryPoint] = [] @@ -6075,6 +5711,17 @@ def _unify_fragment_endpoints(fragments: list[_Fragment], unify_tol, objs.append(p) n = len(objs) + if n < 2: + return + # Sorting one separating coordinate avoids an all-pairs scan on long + # chains. Every possible match still lies in this coordinate window; + # the full parameter and XYZ tests below decide the actual merge. + if not _assembly_spend(work_budget, n * max(1, (n - 1).bit_length())): + return + parameters = np.asarray([p.stuv for p in objs], dtype=np.float64) + axis = int(np.argmax(np.ptp(parameters, axis=0) + / np.maximum(tol, np.finfo(float).eps))) + order = np.argsort(parameters[:, axis], kind="stable") parent = list(range(n)) def _find(a: int) -> int: @@ -6089,34 +5736,17 @@ def _find(a: int) -> int: # branches. 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)] + xyz_lo = [np.asarray(p.xyz, dtype=np.float64).copy() for p in objs] + xyz_hi = [point.copy() for point in xyz_lo] stopped = False - coordinates = np.array([point.stuv for point in objs]) - finite = bool(n and np.all(np.isfinite(coordinates)) and np.all(np.isfinite(tol))) - if finite: - # Sweep one parameter coordinate before paying for exact root - # identity. The four-coordinate and XYZ guards below remain the - # narrow phase; this only avoids impossible all-pairs candidates. - spread = np.ptp(coordinates, axis=0) - with np.errstate(divide='ignore', invalid='ignore'): - selectivity = np.where(tol > 0., spread/tol, - np.where(spread > 0., np.inf, 0.)) - sweep_axis = int(np.argmax(selectivity)) - if not _assembly_spend(work_budget, max(1, n*(1+n.bit_length()))): - return - order = sorted(range(n), key=lambda k: float(coordinates[k, sweep_axis])) - else: - order = list(range(n)) - for position, a in enumerate(order): - upper = (np.nextafter(coordinates[a, sweep_axis]+tol[sweep_axis], np.inf) - if finite else np.inf) - for next_position in range(position+1, n): - b = order[next_position] - if finite and coordinates[b, sweep_axis] > upper: - break + for pos, a in enumerate(order): + for b in order[pos + 1:]: if not _assembly_spend(work_budget): stopped = True break + if parameters[b, axis] - parameters[a, axis] > tol[axis]: + 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 @@ -6125,10 +5755,6 @@ def _find(a: int) -> int: if float(np.linalg.norm(np.asarray(objs[a].xyz, dtype=np.float64) - np.asarray(objs[b].xyz, dtype=np.float64))) > 2.0 * unify_atol: continue - if not (root_matcher(objs[a], objs[b], tol, 2.0*unify_atol) - if root_matcher is not None - else np.array_equal(objs[a].stuv, objs[b].stuv)): - continue ra, rb = _find(a), _find(b) if ra == rb: continue @@ -6136,9 +5762,15 @@ def _find(a: int) -> int: merged_hi = np.maximum(box_hi[ra], box_hi[rb]) if np.any(merged_hi - merged_lo > 2.0 * tol): continue + merged_xyz_lo = np.minimum(xyz_lo[ra], xyz_lo[rb]) + merged_xyz_hi = np.maximum(xyz_hi[ra], xyz_hi[rb]) + if np.linalg.norm(merged_xyz_hi - merged_xyz_lo) > 2.0 * unify_atol: + continue parent[rb] = ra box_lo[ra] = merged_lo box_hi[ra] = merged_hi + xyz_lo[ra] = merged_xyz_lo + xyz_hi[ra] = merged_xyz_hi if stopped: break @@ -6151,28 +5783,112 @@ def _find(a: int) -> int: def _fragment_contained_in(f: _Fragment, g: _Fragment, tol: float, - work_budget=None) -> Optional[bool]: - """Only actual shared fragment provenance proves this duplicate. - - Distinct arcs can share both endpoints and identical approximation - chords. Neither lifted-polyline containment nor endpoint identity - certifies that their source arc is the same. - """ - return f is g + work_budget=None, param_tol=None) -> Optional[bool]: + """Compare complete polylines in paired parameters and world space.""" + from mmcore.numeric.intersection.ssx._ssx_polyline import polyline_contained + if not _assembly_spend(work_budget, len(f.stuv_path) + len(g.stuv_path)): + return None + return polyline_contained( + f.stuv_path, f.xyz_path, g.stuv_path, g.xyz_path, + max(float(tol), np.finfo(float).eps) if param_tol is None else param_tol, + tol, charge=lambda amount: _assembly_spend(work_budget, amount)) def _drop_duplicate_fragments(fragments: list[_Fragment], atol: float, - work_budget=None) -> list[_Fragment]: - """Remove repeated references to one fragment; retain unknown retraces.""" - kept, seen = [], set() - for index, fragment in enumerate(fragments): - if not _assembly_spend(work_budget): - kept.extend(fragments[index:]) - break - if id(fragment) not in seen: - seen.add(id(fragment)) - kept.append(fragment) - return kept + work_budget=None, param_tol=None) -> list[_Fragment]: + """Coalesce numerical retraces with matching paired parameter paths. + + Compare complete segments, including their interiors, rather than + only XYZ vertices. The keeper's existing sagitta credit accommodates + different sampling densities, while the separate parameter tolerance + prevents coincident surface sheets from absorbing one another. + """ + def _arc_len(fr: _Fragment) -> float: + xyz = np.asarray(fr.xyz_path, dtype=np.float64) + if len(xyz) < 2: + return 0.0 + return float(np.linalg.norm(np.diff(xyz, axis=0), axis=1).sum()) + + def _parameter_sagitta(fr: _Fragment): + # The marcher sizes chords in XYZ. On a curved parameter chart, + # the same chord can have a larger parameter interpolation error + # than the endpoint matching tolerance. Estimate that error per + # coordinate from adjacent d(parameter)/d(arclength) secants, using + # the same curvature*h**2/8 rule as the world-space credit. In + # particular, a constant parameter axis receives ZERO credit, so + # a displaced sheet cannot borrow curvature from another axis. + q = np.asarray(fr.stuv_path, dtype=np.float64) + xyz = np.asarray(fr.xyz_path, dtype=np.float64) + if len(q) < 3 or len(q) != len(xyz): + return np.zeros(4) + h = np.linalg.norm(np.diff(xyz, axis=0), axis=1) + moving = h > 0. + velocity = np.diff(q, axis=0) / np.where(moving, h, 1.)[:, None] + valid = moving[:-1] & moving[1:] + if not np.any(valid): + return np.zeros(4) + left, right = h[:-1][valid], h[1:][valid] + curvature = (2. * np.abs(np.diff(velocity, axis=0)[valid]) + / (left + right)[:, None]) + return np.max(curvature * np.maximum(left, right)[:, None]**2 / 8., axis=0) + + arc_work = sum(max(0, len(fr.xyz_path) - 1) + len(fr.xyz_path) + + len(fr.stuv_path) for fr in fragments) + arc_work += len(fragments) * max(1, (len(fragments) - 1).bit_length()) + 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] = [] + sag_of: dict[int, float] = {} + parameter_sag_of = {} + ordered = sorted(fragments, key=_arc_len, reverse=True) + ptol = np.broadcast_to(np.asarray( + max(float(atol), np.finfo(float).eps) if param_tol is None else param_tol, + dtype=np.float64), (4,)) + bounds_of = {} + for fr in ordered: + if len(fr.stuv_path) and len(fr.stuv_path) == len(fr.xyz_path): + paired = np.column_stack((fr.stuv_path, fr.xyz_path)) + bounds_of[id(fr)] = paired.min(axis=0), paired.max(axis=0) + for pos, f in enumerate(ordered): + duplicate = False + for g in keep: + if not _assembly_spend(work_budget): + keep.append(f) + keep.extend(ordered[pos + 1:]) + return keep + if id(f) not in bounds_of or id(g) not in bounds_of: + continue + g_sag = sag_of.get(id(g)) + if g_sag is None: + g_sag = _polyline_sagitta_bound(g.xyz_path) + sag_of[id(g)] = g_sag + parameter_sag_of[id(g)] = _parameter_sagitta(g) + xyz_tol = 2.0 * atol + _CONTAINMENT_SAGITTA_CREDIT * g_sag + path_ptol = (ptol + _CONTAINMENT_SAGITTA_CREDIT + * parameter_sag_of[id(g)]) + bounds = np.concatenate((path_ptol, np.full(3, xyz_tol))) + f_lo, f_hi = bounds_of[id(f)] + g_lo, g_hi = bounds_of[id(g)] + if np.any(f_lo < g_lo - bounds) or np.any(f_hi > g_hi + bounds): + continue + contained = _fragment_contained_in( + f, g, xyz_tol, work_budget=work_budget, param_tol=path_ptol) + 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 def _assemble_fragments( @@ -6184,7 +5900,6 @@ def _assemble_fragments( h_max=None, barrier_xyz=None, work_budget=None, - root_matcher=None, ) -> list[SSXBranch]: """Design §9: chain fragments that share a `BoundaryPoint` endpoint into full branches. Two fragments touching the same `BoundaryPoint` object @@ -6192,9 +5907,10 @@ def _assemble_fragments( from each adjacent cell — by Invariant A/B those are the only partial branches the point can connect. - Source surfaces and tolerances support bounded representation checks. - Closure is obtained from the registered root graph; a nearby free end - does not authorize an extra closing march. + The `S1_full / S2_full / atol_full / rational_full` keyword args enable + a final closing-segment march for near-closed loops whose chain has a + small id-graph gap (different `BoundaryPoint` objects produced for the + same physical point in different parts of the subdivision tree). `barrier_xyz` (optional, (K,3)): emitted tangent-point positions. Branches TERMINATE at singular points (paper semantics): two @@ -6206,20 +5922,14 @@ def _assemble_fragments( """ from collections import defaultdict - for fragment in fragments: - fragment._registered_root_points = { - id(point): point for point in (fragment.start_point, fragment.end_point) - if point is not None} - fragment._registered_root_ids = frozenset( - id(point) for point in (fragment.start_point, fragment.end_point) - if point is not None) - + join_param_tol = np.broadcast_to(np.asarray( + 4.0 * atol_full if unify_tol is None else unify_tol, dtype=np.float64), (4,)) if unify_tol is not None and len(fragments) > 1: _unify_fragment_endpoints( fragments, unify_tol, unify_atol=atol_full, - work_budget=work_budget, root_matcher=root_matcher) + work_budget=work_budget) fragments = _drop_duplicate_fragments( - fragments, atol_full, work_budget=work_budget) + fragments, atol_full, work_budget=work_budget, param_tol=unify_tol) barrier = None if barrier_xyz is not None and len(barrier_xyz): @@ -6318,144 +6028,460 @@ def _pop_neighbour(pt: BoundaryPoint, self_idx: int) -> Optional[tuple[int, str] stuv_full = np.concatenate(stuv_pieces, axis=0) xyz_full = np.concatenate(xyz_pieces, axis=0) - # Closure follows the registered root graph. A nearby free end - # does not prove a missing complement: marching back to it can - # retrace an already represented short arc and invent a loop. + # March the closing segment of near-closed loops. + # + # A loop intersection traversed by subdivisions sometimes ends up + # with a small chain gap: the SAME geometric point on the loop is + # independently produced as different `BoundaryPoint` objects in + # cells from different parts of the subdivision tree, so the + # id-based chain walker can't bridge them. Symptom: a chain whose + # two free endpoints (BOTH interior — neither on the [0,1]⁴ box + # boundary) are much closer to each other than to the rest of the + # chain. Close the loop by actually marching the missing segment + # from end_point.stuv to start_point.stuv using the + # known-endpoint marcher; the resulting samples are real curve + # points (not a duplicate-start placeholder). + if S1_full is not None and len(xyz_full) >= 4: + steps = np.linalg.norm(np.diff(xyz_full, axis=0), axis=1) + steps = steps[steps > 0] + if len(steps) > 0: + gap = float(np.linalg.norm(xyz_full[-1] - xyz_full[0])) + median_step = float(np.median(steps)) + # Only close when both endpoints are strictly INTERIOR. Open + # boundary-to-boundary branches naturally end at the [0,1]⁴ + # box boundary; we must NOT join those. + start_interior = bool(np.all((stuv_full[0] > 1e-9) & + (stuv_full[0] < 1 - 1e-9))) + end_interior = bool(np.all((stuv_full[-1] > 1e-9) & + (stuv_full[-1] < 1 - 1e-9))) + # Two retrace guards (off-lattice touch+loop pathology): a + # SHORT open arc (< ~180 deg of a small loop) whose true + # complement went untraced ALSO has interior endpoints with + # a small gap — but there the end->start chord points + # BACKWARD across the arc's opening, so the known-endpoint + # marcher walks back along the already-traced arc and + # manufactures an out-and-back "closed" branch (net angular + # progress 0, half the samples duplicated). + # 1. Pre-guard: only attempt the close when the traced + # path is most-of-a-loop (path length > 3x gap); a + # <180-deg arc has path ~ gap (chord) and must stay + # open for assembly/dedup to handle. + # 2. Post-guard: reject a closing segment whose interior + # samples ALL lie on the existing polyline (2*atol) — + # that is a retrace, not the missing sliver. + path_len = float(steps.sum()) + if (start_interior and end_interior + and median_step > 0 + and gap < 10.0 * median_step + and path_len > 3.0 * gap): + 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 = 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( + [stuv_full, closing_stuv[1:]], axis=0) + xyz_full = np.concatenate( + [xyz_full, closing_xyz[1:]], axis=0) + branch_kind = ("tangential" if any(fragments[idx].tangential for idx, _ in chain) else "transversal") - branch = SSXBranch(curve=(stuv_full, xyz_full), kind=branch_kind) - branch.closed = bool(current_start is not None and current_start is current_end) - branch._registered_endpoints = (current_start,current_end) - branch._registered_root_ids = frozenset().union(*( - fragments[idx]._registered_root_ids for idx, _ in chain)) - branch._registered_root_points = { - key: point for idx, _ in chain - for key, point in fragments[idx]._registered_root_points.items()} - branches.append(branch) - - # All admissible endpoint identities were resolved before graph - # chaining. A second join based on rounded output coordinates would - # override a declined source-root identity and connect unknown events. - - # Distinct source arcs may have identical approximation polylines. - # Without source-arc ownership, retain all assembled branches. - - # A midpoint residual divided by a normal-angle estimate is neither a - # source-arc exclusion nor a bound on an approximation chord's error. - # Source ownership and representation gates run before assembly; retain - # their arcs here, including valid chords across nearly tangent valleys. - - # Closing a chain changes topology metadata as well as its samples. - # Registered source endpoints outrank identical displayed parameters. - # Keep the legacy anonymous-polyline closure convention only when - # neither endpoint has a registered source owner. - for branch in branches: - q = np.asarray(branch.curve[0], dtype=np.float64) - x = np.asarray(branch.curve[1], dtype=np.float64) - if len(q) >= 3 and all(point is None for point in branch._registered_endpoints): - xerr = 128.0 * np.finfo(float).eps * max(1.0, float(np.abs(x).max())) - branch.closed = bool(branch.closed or ( - np.array_equal(q[-1], q[0]) - and np.linalg.norm(x[-1] - x[0]) <= xerr)) - return branches + branches.append(SSXBranch(curve=(stuv_full, xyz_full), kind=branch_kind)) + + # --- Join open branches across sub-tolerance junction gaps --- + # Guided cuts pass THROUGH discovered crossings, so a small loop is + # partitioned into arcs whose junction micro-slivers (~1° of arc) are + # eaten by the containment dedup (its point-to-polyline distance clamps + # at a keeper's terminal VERTEX, so a sliver extending a fragment's end + # by < 2·atol per sample looks "contained" — off-lattice touch+loop: + # four arcs arrived here open with 1–2.4·atol endpoint gaps and were + # previously force-"closed" individually into out-and-back doubles). + # Join open, strictly-interior, tangent-consistent endpoint pairs + # within 4·atol (junction chord sagitta κL²/8 ≈ 2e-5 ≪ atol); a branch + # whose own two ends meet closes exactly. Junctions within 2·atol of a + # barrier point are never joined — branches terminate at singularities. + if len(branches) >= 1: + def _ends(b): + xyz = np.asarray(b.curve[1], dtype=np.float64) + stuv = np.asarray(b.curve[0], dtype=np.float64) + return stuv, xyz + def _interior(p4): + return bool(np.all(p4 > 1e-9) and np.all(p4 < 1.0 - 1e-9)) -# --------------------------------------------------------------------------- -# Domain decomposition helpers -# --------------------------------------------------------------------------- - -def _root_separating_cuts(crossings, axis, box, cut_tol, max_cuts=8): - """Choose dyadic separators between recorded root-coordinate bands. + 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) - Cutting through a polished root float creates different nearby faces - on independently parameterized charts. Separators avoid that existing - boundary evidence; this is a subdivision choice, never an exclusion. - """ - lo, hi = box[axis] - proposal_padding = float(np.asarray(cut_tol)[axis]) - intervals = [] - for root in crossings: - enclosure = root.root_box - a, b = (enclosure[axis] if enclosure is not None - else (root.stuv[axis], root.stuv[axis])) - # A source enclosure already accounts for numerical uncertainty. - # Modeling tolerance must not swallow a certified gap between two - # distinct roots and repeatedly send subdivision along a free axis. - padding = (0. if enclosure is not None and getattr(root, '_source_root_box', False) - else proposal_padding) - if lo < b and a < hi: - intervals.append((max(lo, float(a)-padding), - min(hi, float(b)+padding))) - if not intervals: - return [] - merged = [] - for a, b in sorted(intervals): - if merged and a <= merged[-1][1]: - merged[-1] = (merged[-1][0], max(b, merged[-1][1])) - else: - merged.append((a, b)) - # Productive cuts separate two groups of boundary events. Repeatedly - # cutting outside the first/last event only builds shrinking collars - # around the same roots and does not distinguish arc ownership. With - # one group the caller uses balanced midpoint subdivision instead. - gaps = [(first[1], second[0]) for first, second in zip(merged, merged[1:]) - if first[1] < second[0]] - cuts = [] - for a, b in sorted(gaps, key=lambda gap: gap[1]-gap[0], reverse=True): - if np.nextafter(a, b) >= b: - continue - # Begin at the exponent of the gap's upper endpoint. This also - # finds representable dyadic cuts next to zero/subnormal parameters. - for level in range(max(0, -math.frexp(b)[1]), 1075): - step = math.ldexp(1., -level) - value = (math.floor(a/step)+1)*step - if a < value < b: - cuts.append(value) + def _dir(xyz, at_start): + # unit direction pointing OUT of the branch at the given end + if len(xyz) < 2: + return None + v = xyz[0] - xyz[1] if at_start else xyz[-1] - xyz[-2] + n = float(np.linalg.norm(v)) + return v / n if n > 1e-15 else None + + def _chord_is_real(pa4, pc4): + # Same truth test as the valley-fiction filter: estimated + # true-curve distance at the junction-chord midpoint must be + # within tolerance — inside a sub-atol grazing valley every + # RESIDUAL is small, but junctions between genuine arcs also + # pass, and outside valleys this rejects fiction chords. + if S1_full is None: + return True + mid = 0.5 * (np.asarray(pa4) + np.asarray(pc4)) + p1, du1, dv1 = eval_surface_d1(S1_full, mid[0], mid[1], + rational=rational_full) + p2, du2, dv2 = eval_surface_d1(S2_full, mid[2], mid[3], + rational=rational_full) + res = float(np.linalg.norm(p1 - p2)) + N1 = np.cross(du1, dv1) + N2 = np.cross(du2, dv2) + n1 = float(np.linalg.norm(N1)) + n2 = float(np.linalg.norm(N2)) + sin_ang = (float(np.linalg.norm(np.cross(N1, N2))) / (n1 * n2) + if n1 > 1e-30 and n2 > 1e-30 else 1.0) + return res / max(sin_ang, 1e-3) <= 2.0 * atol_full + + changed = True + while changed: + if not _assembly_spend(work_budget): break - if len(cuts) >= max_cuts: - break - return sorted(cuts) - + 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: + continue + if float(np.linalg.norm(xyz[0] - xyz[-1])) <= 1e-9: + continue # already exactly closed + # Only SUBSTANTIAL arcs participate (> 16·atol — the + # established micro-branch scale): the pass exists for + # loop arcs partitioned at guided-cut corners. Near-touch + # valley junk (2-5·atol arcs) must stay open and fall to + # the micro-branch/sliver filters — joining it self-closed + # 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: + continue + for at_start in (True, False): + p4 = stuv[0] if at_start else stuv[-1] + p3 = xyz[0] if at_start else xyz[-1] + if not _interior(p4) or _near_barrier_xyz(p3): + 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, qa, pa, da = open_ends[a] + ic, sc, qc, pc, dc = open_ends[c] + if ia == ic and sa == sc: + continue + gap = float(np.linalg.norm(pa - pc)) + if gap > 4.0 * atol_full: + continue + if np.any(np.abs(qa - qc) > join_param_tol): + continue + # tangent consistency: the two out-directions must be + # roughly opposed (the curve continues), and when the + # gap is resolvable the chord must agree with both. + if da is not None and dc is not None: + if float(np.dot(da, dc)) > -0.2: + continue + if gap > 0.25 * atol_full: + chord = (pc - pa) / gap + if (float(np.dot(da, chord)) < 0.2 + or float(np.dot(dc, -chord)) < 0.2): + continue + if ia == ic: + # self-join → closure; require most-of-a-loop + xyz = np.asarray(branches[ia].curve[1], dtype=np.float64) + path_len = float(np.linalg.norm( + np.diff(xyz, axis=0), axis=1).sum()) + if path_len <= 3.0 * gap: + continue + if gap > 0.25 * atol_full and not _chord_is_real( + open_ends[a][2], open_ends[c][2]): + 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 + ia, sa, *_ = open_ends[a] + ic, sc, *_ = open_ends[c] + sa_stuv, sa_xyz = _ends(branches[ia]) + if ia == ic: + # close the loop exactly: repeat the start sample at the end + stuv_j = np.concatenate([sa_stuv, sa_stuv[:1]], axis=0) + xyz_j = np.concatenate([sa_xyz, sa_xyz[:1]], axis=0) + branches[ia] = SSXBranch(curve=(stuv_j, xyz_j), + kind=branches[ia].kind) + changed = True + continue + sc_stuv, sc_xyz = _ends(branches[ic]) + # orient A to END at the junction, B to START at it + if sa: + sa_stuv, sa_xyz = sa_stuv[::-1], sa_xyz[::-1] + if not sc: + sc_stuv, sc_xyz = sc_stuv[::-1], sc_xyz[::-1] + stuv_j = np.concatenate([sa_stuv, sc_stuv], axis=0) + xyz_j = np.concatenate([sa_xyz, sc_xyz], axis=0) + kind_j = ("tangential" + if "tangential" in (branches[ia].kind, branches[ic].kind) + else "transversal") + keep_i, drop_i = (ia, ic) if ia < ic else (ic, ia) + branches[keep_i] = SSXBranch(curve=(stuv_j, xyz_j), kind=kind_j) + del branches[drop_i] + changed = True + + # --- Branch-level containment dedup (post-join) --- + # Partial (non-contained) FRAGMENT overlaps are a known round-1 residue: + # two traversal families can each cover a loop in pieces that pairwise + # overlap only partially, so fragment containment keeps both families + # and the join pass then assembles TWO full copies of the same loop. + # At branch level compare complete paired polylines with the same + # numerical tolerances, longest kept first. + if len(branches) > 1: + order = sorted(range(len(branches)), + key=lambda k: -len(branches[k].curve[1])) + kept_idx: list[int] = [] + for idx in order: + contained = False + for kidx in kept_idx: + inside = _fragment_contained_in( + _Fragment(None, None, *branches[idx].curve), + _Fragment(None, None, *branches[kidx].curve), + 2.0 * atol_full, work_budget=work_budget, + param_tol=(unify_tol if unify_tol is not None else atol_full)) + if inside is True: + contained = True + break + if inside is None: + break + if not contained: + kept_idx.append(idx) + branches = [branches[k] for k in sorted(kept_idx)] + + # --- Drop valley-fiction branches (grazing-gap bridges) --- + # A march seeded at a near-touch grazing corner can exit-commit a single + # chord that slides along a sub-atol valley from the touch to the loop + # (the exit-commit gate bypasses the mid-chord check when there is no + # interior progress): every SAMPLE is Ψ-valid at tolerance, but the + # chord is not ON the intersection set. Per _ssx_correct's own contract + # the true-curve distance at a point is ≈ residual / sin_ang; drop a + # branch only when EVERY chord midpoint fails. Cheap: real branches + # exit at their first good chord. + # + # The bar is an ENVELOPE, not a constant. `res / sin_ang` is evaluated + # at the chord's PARAMETRIC midpoint, which by construction is not on + # the curve — it is off it by the chord's sagitta, which the marcher is + # allowed to grow to `sag_tol = 2*atol`. Judging it against a bare + # 2*atol therefore prices the verifier at exactly the producer's own + # allowance while measuring a quantity 1.9-4x larger, leaving NEGATIVE + # margin: measured on a toroidal SSI pair whose chords all cross at + # sin_ang = 0.9996 (~87 deg, maximally transversal — no valley at all) + # and whose sagittas were 0.42-0.76 of the allowance, all five chords + # scored 2.0-6.0e-3 against a 2.0e-3 bar and the arc was deleted. The + # loss was non-monotonic in atol (survives 2e-3, dies at 1e-3, survives + # 5e-4) and flipped under swapping the two surfaces, because which side + # of the knife edge a chord lands on is decided by the step sizes the + # adaptive marcher happens to pick. + # + # So credit each chord the discretization it actually carries. The + # credit is capped at the marcher's own `sag_tol` budget: a chord that + # exceeds its contract earns no more slack than one that honours it, + # and a valley bridge — whose corrected midpoint stays on the chord, so + # whose sagitta is ~0 — keeps the original bare 2*atol bar and is still + # rejected. `_VALLEY_SAGITTA_CREDIT` covers the measured 1.9-4x ratio + # between this estimator and the sagitta itself. + if S1_full is not None and branches: + _kept_v = [] + for b in branches: + stuv_b = np.asarray(b.curve[0], dtype=np.float64) + xyz_b = np.asarray(b.curve[1], dtype=np.float64) + if len(stuv_b) < 2: + _kept_v.append(b) + continue + sag_tol = 2.0 * atol_full + all_bad = True + for k in range(len(stuv_b) - 1): + if not _assembly_spend(work_budget): + # No remaining cleanup work is not evidence that a + # traced branch is invalid. Keep its existing samples. + all_bad = False + 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) + p2, du2, dv2 = eval_surface_d1(S2_full, mid[2], mid[3], + rational=rational_full) + res = float(np.linalg.norm(p1 - p2)) + N1 = np.cross(du1, dv1) + N2 = np.cross(du2, dv2) + n1 = float(np.linalg.norm(N1)) + n2 = float(np.linalg.norm(N2)) + sin_ang = (float(np.linalg.norm(np.cross(N1, N2))) / (n1 * n2) + if n1 > 1e-30 and n2 > 1e-30 else 1.0) + metric = res / max(sin_ang, 1e-3) + if metric <= sag_tol: + all_bad = False + break + sag = _mid_chord_sagitta( + S1_full, S2_full, stuv_b[k], stuv_b[k + 1], + xyz_b[k], xyz_b[k + 1], atol_full, rational_full) + if sag is None: + # A failed correction provides no distance estimate. + # In particular, a stationary midpoint can lie between + # two genuine nearby arcs while both are within the + # chord tolerance. It cannot justify deleting a trace. + all_bad = False + break + if metric <= sag_tol + _VALLEY_SAGITTA_CREDIT * min( + sag, sag_tol): + all_bad = False + break + if not all_bad: + _kept_v.append(b) + branches = _kept_v + + # --- Drop short slivers that lie on top of another branch --- + # When the fragment graph has a Y-junction (≥3 fragments meeting at the + # same BoundaryPoint, e.g. because two adjacent cells redundantly traced + # the same curve segment), the chain walker peels off the main path and + # leaves the spurious side fragment as a separate short "branch". Such + # a sliver lies entirely within another (longer) branch in 3-space. + # + # Criterion (intentionally narrow to avoid false positives): + # - The candidate branch has FEW points (≤ 5). + # - Its full paired parameter/XYZ polyline lies within the other + # branch's tolerance envelope. A matching world-space image alone + # can represent a different surface preimage and is insufficient. + SLIVER_MAX_PTS = 5 + if len(branches) > 1: + sliver_tol = 4.0 * atol_full + + keep = [] + order = sorted(range(len(branches)), + key=lambda k: -len(branches[k].curve[1])) + for idx in order: + candidate = branches[idx] + xyz = candidate.curve[1] + is_sliver = False + if len(xyz) <= SLIVER_MAX_PTS: + for big in keep: + inside = _fragment_contained_in( + _Fragment(None, None, *candidate.curve), + _Fragment(None, None, *big.curve), sliver_tol, + work_budget=work_budget, + param_tol=(unify_tol if unify_tol is not None else atol_full)) + if inside is True: + is_sliver = True + break + if inside is None: + break + if not is_sliver: + keep.append(candidate) + branches = keep -def _balanced_root_avoiding_cut(crossings, axis, box): - """Prefer a central cut outside known event bands, with bounded progress. + closure_tol = 0.0 if unify_tol is None else unify_tol + for branch in branches: + parameters, xyz = map(np.asarray, branch.curve) + branch.closed = bool( + len(parameters) >= 3 + and np.all(np.abs(parameters[-1] - parameters[0]) <= closure_tol) + and np.linalg.norm(xyz[-1] - xyz[0]) <= 2.0 * atol_full) + if branch.closed and (not np.array_equal(parameters[-1], parameters[0]) + or not np.array_equal(xyz[-1], xyz[0])): + # A tolerance-closed numerical chain includes its final short + # chord explicitly, so the flag and returned polyline agree. + if _assembly_spend(work_budget, len(parameters)): + branch.curve = (np.concatenate((parameters, parameters[:1]), axis=0), + np.concatenate((xyz, xyz[:1]), axis=0)) + return branches - A midpoint through an existing boundary event can create a tangential - face or several nearly coincident roots. Moving within the middle half - avoids that degeneracy while every child shrinks by at least a quarter. - Bands guide partition choice only; they never exclude source geometry. - """ - lo, hi = box[axis] - midpoint = .5*lo+.5*hi - if not lo < midpoint < hi: - return midpoint - bands = [] - for point in crossings: - interval = (point.root_box[axis] if point.root_box is not None - else (point.stuv[axis], point.stuv[axis])) - a, b = map(float, interval) - if np.isfinite(a) and np.isfinite(b) and lo <= b and a <= hi: - bands.append((a, b)) - if not any(a <= midpoint <= b for a, b in bands): - return midpoint - left, right = .75*lo+.25*hi, .25*lo+.75*hi - gaps, cursor = [], left - for a, b in sorted(bands): - if b < cursor or a > right: - continue - if cursor < a: - gaps.append((cursor, min(a, right))) - cursor = max(cursor, b) - if cursor < right: - gaps.append((cursor, right)) - candidates = [(.5*a+.5*b, b-a) for a, b in gaps - if a < .5*a+.5*b < b] - candidates = [(value, span) for value, span in candidates - if lo < value < hi and not any(a <= value <= b for a, b in bands)] - if not candidates: - return midpoint - return min(candidates, key=lambda item: (-item[1], abs(item[0]-midpoint), item[0]))[0] +# --------------------------------------------------------------------------- +# Domain decomposition helpers +# --------------------------------------------------------------------------- def _extract_isoline(S, axis, value): """Extract isoline from Bezier surface at parameter value along axis.""" @@ -6548,9 +6574,6 @@ def _classify_boundary_point(point: BoundaryPoint, cell: "_Cell") -> None: directly from `point.tangent_raw[i]` and the point's local param derived from the cell's box on axis `i`. """ - source_sink = getattr(cell, 'source_singular_sink', None) - if source_sink is not None: - source_sink(point, cell) if point.tangent_raw is None: return @@ -6719,21 +6742,180 @@ def _split_tensor_multi(T, axis_4d, cut_values, cell_box): return pieces -def _surface_cut_face_fibers(csx_result, work_budget): - """Ledger L49: name positive-dimensional cut-face preimages, never drop them. +def _surface_cut_face_fibers(csx_result, work_budget): + """Ledger L49: name positive-dimensional cut-face preimages, never drop them. + + 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. + """ + if work_budget is not None and csx_result.get('parameter_fibers'): + work_budget.mark_incomplete(REASON_PARAMETER_FIBER) + + +def _discover_c1_singularities(S1_h, S2_h, atol, ptol4, budget, existing=()): + """Locate parameter singularities before regular continuation spends its allowance. + + C1 is a property of the supplied surfaces, independent of tracing. + Its existing shared tier runs once; publication is charged immediately + so a later trace or cleanup stop cannot erase already found cusps. + Branch links are attached only after branch assembly. Geometry already + found by a reduction is retained; duplicate observations enrich those + records before allocating any additional output item. + """ + from mmcore.numeric.intersection.ssx._ssx5_singular import c1_pass + + out = [] + if budget.exhausted: + return out + stats = {} + tier = min(20_000, budget.remaining_cells) + hits, curve = c1_pass( + S1_h, S2_h, atol, ptol4, max_cells=tier, + charge_box=_charge_hook(budget, "c1"), stats=stats) + if stats.get("external_budget_exhausted", False): + budget.mark_incomplete(REASON_WORK_BUDGET) + elif stats.get("budget_exhausted", False) or stats.get("incomplete", False): + # A full-tier positive-dimensional census is structurally partial; + # a tier reduced by the shared allowance is still a resource stop. + budget.mark_incomplete( + REASON_SINGULAR_SET if curve and tier == 20_000 else REASON_WORK_BUDGET) + for hit in hits: + 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 = (eval_surface(S1_h, *anchor[:2], rational=True) + if len(samples) else np.full(3, np.nan)) + item = SSXSingularity( + kind="cusp_curve", stuv=anchor, xyz=xyz, + samples=samples, surface=hit.get("surface")) + else: + item = SSXSingularity( + kind="cusp", stuv=np.asarray(hit["stuv"], dtype=np.float64), + xyz=np.asarray(hit["xyz"], dtype=np.float64), + surface=hit.get("surface")) + if item.kind == "cusp_curve" and len(item.samples): + remaining = np.ones(len(item.samples), dtype=bool) + for known in existing: + if (known.kind != "cusp_curve" or known.surface != item.surface + or known.samples is None or len(known.samples) < 2): + continue + parameters = np.asarray(known.samples, dtype=np.float64) + merge_cost = int(np.count_nonzero(remaining))*max(1, len(parameters)-1) + if not _assembly_spend(budget, max(1, merge_cost)): + break + xyz_samples = np.array([ + eval_surface(S1_h, *q[:2], rational=True) for q in parameters]) + matched = [] + for index in np.flatnonzero(remaining): + q = item.samples[index] + xyz = eval_surface(S1_h, *q[:2], rational=True) + if _point_on_branch_both_guards( + xyz, q, xyz_samples, parameters, atol, ptol4, + S1_h, S2_h): + matched.append(index) + if not matched: + continue + # C1 returns samples, not an ordered boundary polygon. Keep + # both observations of this locus: the reduction may know + # its whole extent while enumeration covers only part of it. + known.samples = np.vstack((parameters, item.samples[matched])) + remaining[matched] = False + if not any(known is entry for entry in out): + out.append(known) + if not np.any(remaining): + continue + item.samples = item.samples[remaining] + item.stuv = item.samples[0].copy() + item.xyz = eval_surface(S1_h, *item.stuv[:2], rational=True) + elif item.kind == "cusp": + match = next((known for known in existing + if known.kind == "cusp" and known.surface == item.surface + and np.all(np.abs(known.stuv-item.stuv) <= ptol4) + and np.linalg.norm(known.xyz-item.xyz) <= atol), None) + if match is not None: + if not any(match is entry for entry in out): + out.append(match) + continue + budget.append_output(out, item, "singularity") + return out + + + +def _link_c1_and_discover_c3(S1_h_top, S2_h_top, all_branches, + all_singularities, c1_singularities, + atol, ptol4_global, budget): + """Apply the same singularity postprocessing to every geometry path.""" + # Attach C1 links after branch filters; the singularities themselves + # were discovered and published before continuation began. + for singularity in c1_singularities: + if singularity.kind != "cusp": + continue + links = [] + cost = sum(max(1, len(np.asarray(b.curve[1]))-1) for b in all_branches) + if not _assembly_spend(budget, max(1, cost)): + break + for bi, branch in enumerate(all_branches): + xyz = np.asarray(branch.curve[1], dtype=np.float64) + if len(xyz) < 2 or _dist_point_polyline(singularity.xyz, xyz) > 4.0*atol: + continue + a, b = xyz[:-1], xyz[1:] + delta = b-a + square = np.einsum("ij,ij->i", delta, delta) + fraction = np.clip(np.einsum( + "ij,ij->i", singularity.xyz[None, :]-a, delta) + / np.maximum(square, 1e-30), 0., 1.) + distance = np.linalg.norm(a+fraction[:, None]*delta-singularity.xyz, axis=1) + segment = int(distance.argmin()) + vertex = (segment if np.linalg.norm(xyz[segment]-singularity.xyz) + <= np.linalg.norm(xyz[segment+1]-singularity.xyz) else segment+1) + links.append((bi, vertex)) + singularity.branch_links = links + + # --- C3 pass (paper §5.4): 3D self-intersections of the SSI image --- + # Runs AFTER tracing (branch geometry drives the candidate search). + # Ledger L8: the old trigger — "some traced cell failed the per-cell + # Theorem-3 certificate" — was semantically wrong: Theorem 3 certifies + # only that ONE box's image is injective; a C3 whose two preimages lie + # in DIFFERENT traced cells (figure-eight wall: s≈0.08 and s≈0.92 + # strips, each cell truthfully certified) passes every per-cell check + # and the pass never ran. Run it whenever a collision is possible at + # all: >= 2 branches (cross-branch), or a branch long enough to reach + # its own segments past the broadphase index gap >= 3 (>= 8 segments, + # within-branch). The vectorized AABB broadphase keeps the fired path + # free on regular geometry (measured: 0 candidate pairs on coverage + # 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 (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 + + _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"]), "singularity") + if _c3_stats.get("incomplete", False): + budget.mark_incomplete(REASON_WORK_BUDGET) + - 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. - """ - if work_budget is not None and csx_result.get('parameter_fibers'): - work_budget.mark_incomplete(REASON_PARAMETER_FIBER) @dataclass @@ -6768,6 +6950,12 @@ class _Cell: # crossings are in `crossings` for tracing but should not be re-used for # cutting — cutting at inherited coordinates produces zero-info strips. new_crossings: list = field(default_factory=list) + # Squared-distance Bernstein net (4D scalar tensor), propagated from the + # top level by de Casteljau-splitting alongside TΨᵢ. Avoids reconstructing + # the net per cell; only the cheap min-of-net check runs per cell. + F_sq: Optional[NDArray[np.float64]] = None + # Top-level w_scale (max weight product) — constant across the tree. + w_scale: float = 1.0 # Ledger L4: a probe-only cell hunts Δ-touches the parent's center # witness missed (loop-free arm, hull gate fired, witness failed). It # carries ONLY nets/boxes — no crossings, no partitions — and its @@ -6776,178 +6964,68 @@ 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 - # A parent already traced/enumerated its singular locus, but that does - # not certify absence of ordinary components in the remaining box. - # Descendants still run the full geometric subdivision and tracing - # search; this flag only avoids repeating the singular census. - regular_complement: bool = False - # Set by a tracer which could not cover a registered incident arc. - # Such a cell still needs subdivision even if it has no interior loops. - trace_incomplete: bool = False - # A regular cell with several ports must separate its arc ownership - # along a strict monotone parameter. Numerical guidance only. - trace_split_axis: Optional[int] = None - # All faces exhaustively enumerated with isolated roots only. - boundary_complete: bool = False - # Closed source boxes localize missing boundary events. None on an - # incomplete cell means unknown localization, never an empty census. - boundary_obligations: Optional[tuple] = None - root_matcher: Optional[object] = None - straight_path: Optional[object] = None - path_representation: Optional[object] = None - arc_image: Optional[object] = None - arc_chord: Optional[object] = None - source_root_exact: Optional[object] = None - source_singular_candidate: Optional[object] = None - source_singular_sink: Optional[object] = None - source_cofactors: Optional[object] = None - unresolved_regions: Optional[list] = None # 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_split_axis(cell): - """Choose a varying singular constraint's largest derivative bound. - - For a Bernstein polynomial, degree*diff(control_net) is its exact - derivative control net on the local unit interval. Its largest - absolute coefficient bounds the coordinate sensitivity. Restricting - an axis shrinks this bound; coordinates on which all T constraints - are constant do not compete with genuinely varying constraints. - The necessary T=0 constraints take priority over Psi=0, which avoids - replicating the same critical-strip search along free extrusion axes. - This only chooses subdivision order; all exclusion and root tests - remain unchanged. No coefficient is rounded to zero or discarded. - """ - def variation(nets): - scores = np.zeros(4, dtype=np.float64) - for net in nets: - if net is None: - continue - net = np.asarray(net, dtype=np.float64) - for axis in range(4): - degree = net.shape[axis] - 1 - if degree: - bound = degree * float(np.max(np.abs(np.diff(net, axis=axis)))) - if np.isfinite(bound): - scores[axis] = max(scores[axis], bound) - return scores - - scores = variation((cell.T1, cell.T2, cell.T3, cell.T4)) - if not np.any(scores): - from mmcore.numeric.intersection.ssx._ssx5_singular import psi_vector_net - psi = psi_vector_net(cell.g1.surface, cell.g2.surface) - scores = variation(psi[..., k] for k in range(3)) - if np.any(scores): - return int(np.argmax(scores)) - # Fully constant constraints have no preferred algebraic coordinate. - # Keep a geometric descent in that inconclusive degenerate situation. - return int(np.argmax([hi-lo for lo, hi in cell.box])) + # Continuation can produce a useful prefix without resolving the cell. + # The caller must then keep the unsearched remainder in the frontier. + trace_incomplete: bool = False def _probe_children(cell): - """Bisect one active singular-constraint axis; carry every equation. - - Probe cells have no crossings/partitions and never trace or run CSX. - Splitting one parameter produces two children covering exactly the - parent box, without copying the same critical-strip uncertainty into - all combinations of unconstrained surface extrusion parameters. - T1..T4 retain their de Casteljau restrictions. + """Lean 2x2 net-split of a probe(-able) cell (ledger L4). + + Splits each surface at the midpoint of its longest GLOBAL axis and + de Casteljau-propagates T1..T4 and F_sq — nothing else: no cut-face + CSX, no crossing distribution, no partitions (the children are + probe_only and never trace). Cost per level is four de Casteljau + sweeps vs the main subdivision's CSX-per-cut (measured ~60 ms per + fall-through on case 5's 20 near-tangent cells — the lean descent is + what keeps the L4 recovery inside the coverage timing budget). + + Termination of the descent (probe arm): AABB/GJK/F_sq prunes, hull + gate clearing (near-tangent cells: T hulls exclude 0 within a few + levels), witness success (touch emitted), or the 4·unify_tol size + gate — each level halves one axis per surface, longest-first, so all + four spans reach the gate in finitely many levels. """ - axis = _probe_split_axis(cell) b = cell.box - if axis < 2: - g1 = cell.g1.split_u(0.5) if axis == 0 else cell.g1.split_v(0.5) - g2 = (cell.g2, cell.g2) - else: - g1 = (cell.g1, cell.g1) - g2 = cell.g2.split_u(0.5) if axis == 2 else cell.g2.split_v(0.5) - - def split(net): - return ((None, None) if net is None else - _split_bern_scalar_tensor(net, axis=axis, t=0.5)) - - tensors = [split(net) for net in (cell.T1, cell.T2, cell.T3, cell.T4)] - midpoint = 0.5 * (b[axis][0] + b[axis][1]) - children = [] - for side in range(2): - sub = list(b) - sub[axis] = ((b[axis][0], midpoint) if side == 0 - else (midpoint, b[axis][1])) - children.append(_Cell( - g1=g1[side], g2=g2[side], crossings=[], box=tuple(sub), - depth=cell.depth + 1, - T1=tensors[0][side], T2=tensors[1][side], - T3=tensors[2][side], T4=tensors[3][side], - new_crossings=[], - probe_only=True, work_budget=cell.work_budget, csx_fn=cell.csx_fn, - source_root_exact=cell.source_root_exact, - source_singular_candidate=cell.source_singular_candidate, - source_singular_sink=cell.source_singular_sink, - source_cofactors=cell.source_cofactors, - unresolved_regions=cell.unresolved_regions, - )) - return children - - -def _contract_source_domain(cell, necessary_box, gauss_sources, minor_sources): - budget = cell.work_budget - # Constraint propagation preserves the entire source zero set. A - # new exact extreme in one affine component is attained on an old - # face of another variable; outward rounding only adds root-free - # boundary faces. Thus an exhaustive old boundary census remains - # exhaustive. Keep its root objects/enclosures, never filter them - # by rounded representatives. If one representative lies outside, - # simply retain the larger numerical domain for this cell. - if (necessary_box == cell.box - or any(lo >= hi for lo, hi in necessary_box) - or any(not all(lo <= value <= hi for value, (lo, hi) - in zip(point.stuv, necessary_box)) - for point in cell.crossings)): - return - changed = [axis for axis, pair in enumerate(necessary_box) if pair != (0., 1.)] - coefficients = len(changed)*sum(net.size for net in minor_sources) - coefficients += sum((source.surface.size+source._map.size) - *sum(axis//2 == side for axis in changed) - for side, source in enumerate(gauss_sources)) - units = max(1, (coefficients+127)//128) - if not budget.charge_cells(units, 'affine_domain'): - return - # Always restrict the original numerical sources, avoiding an - # additional accumulated chain of rounded child restrictions. - def patch(source, offset): - current = source - for local_axis in (0, 1): - axis = offset+local_axis - lo, hi = necessary_box[axis] - if hi < 1.: - current = (current.split_u(hi) if local_axis == 0 - else current.split_v(hi))[0] - if lo > 0.: - current = (current.split_u(lo/hi) if local_axis == 0 - else current.split_v(lo/hi))[1] - return current - from mmcore.numeric._bezier_common import restrict_net_axis_v - tensors = [] - for source in minor_sources: - restricted = source[..., None] - for axis, (lo, hi) in enumerate(necessary_box): - restricted = restrict_net_axis_v(restricted, axis, lo, hi, 0., 1.) - tensors.append(restricted[..., 0]) - cell.g1, cell.g2 = patch(gauss_sources[0], 0), patch(gauss_sources[1], 2) - cell.T1, cell.T2, cell.T3, cell.T4 = tensors - cell.box = necessary_box - for partition in cell.partitions: - partition.registrations[:] = [r for r in partition.registrations - if r.owner is not cell] - cell.partitions = _build_cell_partitions(cell) - for point in cell.crossings: - point.registrations[:] = [registration for registration in point.registrations - if registration.owner is not cell] - _classify_boundary_point(point, cell) + s1_axis = 0 if (b[0][1] - b[0][0]) >= (b[1][1] - b[1][0]) else 1 + s2_axis = 2 if (b[2][1] - b[2][0]) >= (b[3][1] - b[3][0]) else 3 + g1_lr = cell.g1.split_u(0.5) if s1_axis == 0 else cell.g1.split_v(0.5) + g2_lr = cell.g2.split_u(0.5) if s2_axis == 2 else cell.g2.split_v(0.5) + + def _split2(T): + # -> [i1][i2] pieces along (s1_axis, s2_axis), local midpoints + if T is None: + return [[None, None], [None, None]] + a, c = _split_bern_scalar_tensor(T, axis=s1_axis, t=0.5) + a1, a2 = _split_bern_scalar_tensor(a, axis=s2_axis, t=0.5) + c1, c2 = _split_bern_scalar_tensor(c, axis=s2_axis, t=0.5) + return [[a1, a2], [c1, c2]] + + Ts = [_split2(T) for T in (cell.T1, cell.T2, cell.T3, cell.T4)] + Fs = _split2(cell.F_sq) + m1 = 0.5 * (b[s1_axis][0] + b[s1_axis][1]) + m2 = 0.5 * (b[s2_axis][0] + b[s2_axis][1]) + out = [] + for i1 in range(2): + for i2 in range(2): + sub = list(b) + sub[s1_axis] = (b[s1_axis][0], m1) if i1 == 0 else (m1, b[s1_axis][1]) + sub[s2_axis] = (b[s2_axis][0], m2) if i2 == 0 else (m2, b[s2_axis][1]) + out.append(_Cell( + g1=g1_lr[i1], g2=g2_lr[i2], crossings=[], box=tuple(sub), + depth=cell.depth + 1, + 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, work_budget=cell.work_budget, + csx_fn=cell.csx_fn, + )) + return out def bez_ssx( @@ -6955,7 +7033,7 @@ def bez_ssx( S2, atol=1e-3, rational=True, - max_depth=None, + max_depth=13, max_xyz_step=None, max_cells=250_000, max_csx_calls=10_000, @@ -6987,45 +7065,32 @@ def bez_ssx( 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. - - ``max_depth=None`` leaves refinement governed by the shared work limits - and representable parameter partitions. An explicit integer remains a - hard subdivision ceiling. A fixed default depth is unrelated to source - root separation or cofactor certification and must not stop a still - productive search merely because its owner reached that level. + computed fragments without opening an unbounded second phase. + Exhaustion is a soft stop: geometry found so far is retained. 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 - search finished without a recorded unresolved obligation under the - implemented predicates. This is not a proof for every algebraic input. + - ``complete`` (bool) — the numerical search finished without reaching + a work limit or leaving a detected structure unhandled. This is a + tolerance-based numerical solver, not an algebraic proof API. - ``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). The structural - ``parameter_representation`` reason means floating parameters cannot - represent a source solution within the requested world tolerance. + 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. - - Source control data must be finite. Rational inputs require strictly - positive weights; zero or negative weights raise ``ValueError`` before - control-hull exclusions or normalization. + ``atol`` is the requested geometric accuracy. Work limits control the + amount of computation rather than changing that requested accuracy. Numerical frame (P1, 2026-07-21 design + amendments 1-2): inputs whose joint coordinate magnitude falls outside `_NORM_IDENTITY_WINDOW` run in a canonical frame — both nets jointly centered at the AABB midpoint and scaled by a power of two into the native-proven band (AABB diagonal ~[11.3, 22.6]; max|coords| ~[3.3, 11.3]) — so the - strict Psi-zero certificates, - fixed corrector tolerances, and marching machinery see calibrated + residual evaluation, corrector tolerances, and marching machinery see calibrated coordinates for any world placement; in-window inputs keep the bit-for-bit legacy frame. The contract stays world-in/world-out: xyz outputs are un-mapped exactly once at exit; parameters and @@ -7040,8 +7105,6 @@ def bez_ssx( from mmcore.numeric.intersection.ssx._ssx_input import validate_bezier_surface validate_bezier_surface(S1, rational=rational, context='bez_ssx surface 1') validate_bezier_surface(S2, rational=rational, context='bez_ssx surface 2') - source_identity_surfaces = (S1, S2) - source_atol = float(atol) # P1 invariance (2026-07-21 design + amendments): OUT-OF-WINDOW models # run in a canonical frame — jointly centered, power-of-two scaled into # the native band — so the absolute roundoff envelopes below see @@ -7074,109 +7137,30 @@ def bez_ssx( else max(0, int(max_postprocess_work))), ) - # A local face solver limit does not exhaust unrelated SSX cells. - # Keep each owner box once so partial output identifies the unresolved - # complement without manufacturing one diagnostic per repeated call. - csx_unresolved_boxes = {} - active_csx_box = ((0.0, 1.0),) * 4 - def _result(branches=None, points=None, singularities=None, - overlap_regions=None, unresolved_regions=None): - unresolved_regions = ([] if unresolved_regions is None - else list(unresolved_regions)) - for diagnostic in csx_unresolved_boxes.values(): - if diagnostic not in unresolved_regions: - if not budget.append_output( - unresolved_regions, diagnostic, "unresolved_region"): - break + overlap_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 _denormalize_result(result, _norm_c, _norm_k) # A zero public allowance is a hard promise that no expensive solver - # setup runs. Product residual and minor tensors are preflighted before - # allocation, including work before the first subdivision cell exists. + # 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() - # Use source hulls so normalization cannot perturb this exclusion. - if _ssx_control_aabbs_disjoint(*source_identity_surfaces, rational=rational): + # Reject disjoint control hulls before preflighting the distance net. + if _ssx_control_aabbs_disjoint(S1, S2, rational=rational): return _result() - from mmcore.numeric.intersection.ssx._ssx_exact_region import exact_affine_plane_region_ssx - exact_region = exact_affine_plane_region_ssx( - *source_identity_surfaces, source_atol, rational, budget) - if exact_region is not None: - exact_region.update(budget.result_fields()) - return exact_region - if budget.exhausted: - return _result(unresolved_regions=[{ - 'stuv_min': (0.,)*4, 'stuv_max': (1.,)*4, - 'reason': REASON_WORK_BUDGET}]) - - # A proven convex rational-bilinear 2D relation cannot be resolved by - # repeatedly searching for isolated roots. Preserve its entire source - # owner and exact image polygon while paired UV region representation - # remains unsupported. Zero-area contacts retain the curve solver. - from mmcore.numeric.intersection.ssx._ssx_positive_dimensional import exact_convex_bilinear_region_owner - region_owner = exact_convex_bilinear_region_owner( - *source_identity_surfaces, rational, budget) - if region_owner is not None: - region_owner.update(budget.result_fields()) - return region_owner - if budget.exhausted: - return _result(unresolved_regions=[{ - 'stuv_min': (0.,)*4, 'stuv_max': (1.,)*4, - 'reason': REASON_WORK_BUDGET}]) - - # An affine ruling parallel to a plane reduces the entire zero set to - # one scalar polynomial. Work on the supplied source coefficients so - # normalization cannot perturb a high-multiplicity root. This path's - # XYZ is already in world coordinates and must bypass denormalization. - if max_xyz_step is None: - from mmcore.numeric.intersection.ssx._ssx_quadratic_constraints import exact_quadratic_constraint_ssx - exact_quadratic = exact_quadratic_constraint_ssx( - *source_identity_surfaces, source_atol, rational, budget) - if exact_quadratic is not None: - exact_quadratic.update(budget.result_fields()) - return exact_quadratic - if budget.exhausted: - return _result(unresolved_regions=[{ - 'stuv_min': (0.,)*4, 'stuv_max': (1.,)*4, - 'reason': REASON_WORK_BUDGET}]) - from mmcore.numeric.intersection.ssx._ssx_boundary_strata import exact_boundary_strata_ssx - exact_boundary = exact_boundary_strata_ssx( - *source_identity_surfaces, source_atol, rational, budget) - if exact_boundary is not None: - exact_boundary.update(budget.result_fields()) - return exact_boundary - if budget.exhausted: - return _result(unresolved_regions=[{ - 'stuv_min': (0.,)*4, 'stuv_max': (1.,)*4, - 'reason': REASON_WORK_BUDGET}]) - from mmcore.numeric.intersection.ssx._ssx_extrusion import exact_extrusion_plane_ssx - exact_rulings = exact_extrusion_plane_ssx( - *source_identity_surfaces, source_atol, rational, budget) - if exact_rulings is not None: - exact_rulings.update(budget.result_fields()) - return exact_rulings - if budget.exhausted: - return _result(unresolved_regions=[{ - 'stuv_min': (0.,)*4, 'stuv_max': (1.,)*4, - 'reason': REASON_WORK_BUDGET}]) - if rational: S1_h_top, S2_h_top = S1, S2 else: @@ -7185,228 +7169,42 @@ def _result(branches=None, points=None, singularities=None, S2_h_top = np.concatenate( [S2, np.ones(S2.shape[:-1] + (1,))], axis=-1) - # Preflight the four residual/minor coefficient tensors before any - # product-net construction. Normal cross products use per-surface - # Bernstein convolutions; quotient-rule rational columns can reach - # four times each original degree. Count those actual tensor sizes, - # instead of the now-removed quadratic-in-product Gram allocation. - degree_factor = (4 if rational and not ( - _weight_net_uniform(S1) and _weight_net_uniform(S2)) else 2) - expanded_counts = [degree_factor*(n-1)+1 - for n in S1.shape[:2]+S2.shape[:2]] - net_coefficients = 4*math.prod(expanded_counts) - precompute_units = max(1, (net_coefficients+127)//128) + if max_xyz_step is None: + isolines = _try_isoline_intersection(S1_h_top, S2_h_top, atol, budget) + if isolines is not None: + # A lower-dimensional geometric reduction replaces tracing, + # not singularity detection. The same surfaces can still have + # cusps or distinct preimages crossing in their spatial image. + from mmcore.nurbs._nurbs_param_tol import bez_surface_param_tolerance + ptol4 = np.maximum(np.array([ + *bez_surface_param_tolerance(S1_h_top, atol, rational=True), + *bez_surface_param_tolerance(S2_h_top, atol, rational=True), + ], dtype=np.float64), 1e-9) + singularities = isolines.setdefault("singularities", []) + c1 = _discover_c1_singularities( + S1_h_top, S2_h_top, atol, ptol4, budget, + existing=singularities) + singularities.extend(g for g in c1 + if not any(g is known for known in singularities)) + _link_c1_and_discover_c3( + S1_h_top, S2_h_top, isolines.get("branches", []), + singularities, c1, atol, ptol4, budget) + return _result(**isolines) + + # 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"): - 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}]) - - from mmcore.numeric.intersection.ssx._ssx_root_identity import BoundaryRootIdentity - identity_sources_h = tuple(surface if rational else np.concatenate([ - surface, np.ones(surface.shape[:-1]+(1,))], axis=-1) - for surface in source_identity_surfaces) - root_identity = BoundaryRootIdentity( - *identity_sources_h, - charge=lambda amount: budget.charge_cells(amount, "root_identity")) - from mmcore.numeric.intersection.ssx._ssx_affine_constraints import ( - AffineParameterConstraints, augment_constant_coordinates, - augment_projected_coordinates) - constraint_maps = augment_constant_coordinates( - identity_sources_h, root_identity.affine, - charge=lambda amount: budget.charge_cells(amount, "affine_constants")) - if constraint_maps is not None: - projected_maps = augment_projected_coordinates( - identity_sources_h, constraint_maps, - charge=lambda amount: budget.charge_cells(amount, "affine_projections")) - if projected_maps is not None: - constraint_maps = projected_maps - affine_constraints = AffineParameterConstraints( - root_identity.affine if constraint_maps is None else constraint_maps, - charge=lambda amount: budget.charge_cells(amount, "affine_constraints")) - root_identity.affine_constraints = affine_constraints - from mmcore.numeric.intersection.ssx._ssx_cofactor_bounds import ( - SourceCofactorBounds, SourceZeroSetCofactorBounds) - source_cofactors = SourceZeroSetCofactorBounds(SourceCofactorBounds( - *identity_sources_h, - charge=lambda amount: budget.charge_cells(amount, "cofactor_bounds")), - affine_constraints) - from mmcore.numeric.intersection.ssx._ssx_face_residual import SourceFaceResiduals - source_face_residuals = SourceFaceResiduals( - source_cofactors.source_bounds, - charge=lambda amount: budget.charge_cells(amount, 'source_face_residual'), - sources=identity_sources_h) - from mmcore.numeric.intersection.ssx._ssx_source_residual import SourceResidualExclusions - source_residual_excludes = SourceResidualExclusions( - source_cofactors.source_bounds, - charge=lambda amount: budget.charge_cells(amount,'source_residual')) - if budget.exhausted: - return _result(unresolved_regions=[{ - 'stuv_min': (0.,)*4, 'stuv_max': (1.,)*4, - 'reason': REASON_WORK_BUDGET}]) - - from mmcore.numeric.intersection.ssx._ssx_planar_cut import ( - exact_source_planar_cut, supported_source_pair, source_cut_construction_work) - source_cut_supported = supported_source_pair(*identity_sources_h) - source_cut_setup = source_cut_construction_work(*identity_sources_h) - - def _source_cut_uncached(axis, value, box, *, allowance_cap=None, soft=False): - # Unsupported high-degree pairs need no exact planar setup. A - # supported face, including a reverse plane-owner face, spends - # from the same CSX/cell allowances as every other cut census. - if not source_cut_supported: - return None - allowance = min(max(0, int(csx_max_cells if allowance_cap is None else allowance_cap)), - budget.remaining_cells) - if not budget.charge_csx_call(): - budget.mark_exhausted() - return {'isolated': [], 'boundary_topology_complete': False, - 'budget_exhausted': True, 'truncation_cause': 'max_cells'} - if allowance <= 0 or int(csx_max_results) <= 0: - if not soft: - budget.mark_exhausted() - return {'isolated': [], 'boundary_topology_complete': False, - 'budget_exhausted': True, 'truncation_cause': 'max_cells'} - result = exact_source_planar_cut( - *identity_sources_h, axis, value, box, max_cells=allowance, - max_results=int(csx_max_results), atol=source_atol) - if result is None: - # Exact applicability checks can do source-coefficient work - # before declining a cut. Bill a conservative setup bound. - budget.charge_cells(min(allowance, max(1, source_cut_setup)), 'source_cut') - else: - budget.charge_cells(max(1, int(result.get('cells_processed', 0))), 'source_cut') - return result - - from mmcore.numeric.intersection.ssx._ssx_source_cut_cache import SourceFaceCensusCache - source_face_censuses = SourceFaceCensusCache( - _source_cut_uncached, affine_constraints, root_identity.source_ids, - charge=lambda amount: budget.charge_cells(amount, 'source_cut_filter'), - can_retry=lambda: not budget.exhausted and budget.remaining_cells > 0) - - def _source_cut_census(axis, value, box): - if not source_cut_supported: - return None - return source_face_censuses(axis, value, box) - - root_identity.source_census = _source_cut_census - - def _source_straight_path(start, end): - if max_xyz_step is not None: - return None - from mmcore.numeric.intersection.ssx._ssx_affine_path import certified_straight_isocurve_path - path = certified_straight_isocurve_path( - *source_identity_surfaces, start, end, source_atol, rational, - charge=lambda amount: budget.charge_cells(amount, "straight_path")) - if path is None: - return None - local_xyz = (path[1]-_norm_c)/_norm_k - if not _source_path_representation(start, end, local_xyz): - return None - return path[0], local_xyz - - def _source_path_representation(start, end, local_xyz): - from mmcore.numeric.intersection.ssx._ssx_affine_path import affine_path_representation_bounded - # Validate the actual rounded world output, including normalization - # round-trip error at large offsets and small geometric tolerances. - world_xyz = np.asarray(local_xyz)*_norm_k+_norm_c - if (max_xyz_step is not None and float(np.linalg.norm( - world_xyz[-1]-world_xyz[0])) > max_xyz_step*_norm_k): - return False - return affine_path_representation_bounded( - *source_identity_surfaces, start, end, world_xyz, source_atol, - rational=rational, - charge=lambda amount: budget.charge_cells(amount, "path_representation")) - - def _source_arc_image(box,local_xyz): - from mmcore.numeric.intersection.ssx._ssx_affine_path import source_box_image_diameter_bounded - world_xyz = np.asarray(local_xyz)*_norm_k+_norm_c - return source_box_image_diameter_bounded( - *source_identity_surfaces, box, world_xyz, source_atol, - rational=rational, - charge=lambda amount: budget.charge_cells(amount, "arc_image")) - - def _source_fallback_path_representation(stuv, xyz): - # Continuation from an unresolved fiber/proposal is useful partial - # output only after every lifted chord has an original-source - # representation bound. This establishes approximation, not an - # exact zero or exhaustive connectivity of the unresolved face. - if len(stuv) < 2 or len(stuv) != len(xyz): - return False - for index in range(len(stuv)-1): - if not _source_path_representation( - stuv[index], stuv[index+1], xyz[index:index+2]): - budget.mark_incomplete(REASON_PARAMETER_REPRESENTATION) - return False - return True - - source_arc_chord_bounds = None - def _source_arc_chord(box, root_boxes, local_xyz, axis, bounds): - nonlocal source_arc_chord_bounds - if source_arc_chord_bounds is None: - from mmcore.numeric.intersection.ssx._ssx_affine_path import SourceArcChordBounds - source_arc_chord_bounds = SourceArcChordBounds( - *source_identity_surfaces, rational=rational, - charge=lambda amount: budget.charge_cells(amount, "arc_chord")) - world_xyz = np.asarray(local_xyz)*_norm_k+_norm_c - return source_arc_chord_bounds.bounded( - box, root_boxes, world_xyz, axis, bounds, source_atol) - - exact_source_root_cache = {} - def _source_root_exact(stuv): - key = tuple(float(value) for value in stuv) - if key not in exact_source_root_cache: - if not budget.charge_cells(max(1, sum(surface.size for surface in identity_sources_h)//32), - "singular_existence"): - return False - from mmcore.numeric.intersection._root_box_certificate import exact_bernstein_value - first = exact_bernstein_value(identity_sources_h[0], key[:2]) - second = exact_bernstein_value(identity_sources_h[1], key[2:]) - exact_source_root_cache[key] = all( - first[axis]*second[3] == second[axis]*first[3] for axis in range(3)) - return exact_source_root_cache[key] - - singular_candidate_source = None - def _source_singular_candidate(stuv): - nonlocal singular_candidate_source - if singular_candidate_source is None: - from mmcore.numeric.intersection.ssx._ssx_singular_candidate import SourceSingularCandidates - charge = lambda amount: budget.charge_cells(amount, 'singular_source') - singular_candidate_source = SourceSingularCandidates( - *identity_sources_h, affine_constraints, charge=charge) - return singular_candidate_source(stuv) - - registered_singular_checked = set() - def _source_registered_singularity(point, cell): - # Boundary roots can expose an exact singular point even after a - # parent has transferred its ordinary complement to descendants. - # This adds an independently proved feature; it consumes no cell. - key = tuple(point.stuv) - if key in registered_singular_checked or budget.exhausted: - return - bounds = source_cofactors.bounds(cell.box) - if bounds is None or np.any(np.asarray(bounds[0]) > 0.) or np.any(np.asarray(bounds[1]) < 0.): - return - registered_singular_checked.add(key) - corrected = _source_singular_candidate(point.stuv) - if corrected is None: - return - corrected = singular_candidate_source.isolated_regular(corrected) - if corrected is None: - return - if any(g.kind == 'tangent_point' and np.array_equal(g.stuv, corrected) - for g in all_singularities): - return - world = eval_surface(source_identity_surfaces[0], corrected[0], corrected[1], rational=rational) - local_xyz = (world-_norm_c)/_norm_k - if not _source_path_representation(corrected, corrected, np.array([local_xyz, local_xyz])): - reason = REASON_WORK_BUDGET if budget.exhausted else REASON_PARAMETER_REPRESENTATION - budget.mark_incomplete(reason) - budget.append_output(unresolved_regions, dict( - stuv_min=tuple(corrected), stuv_max=tuple(corrected), reason=reason), 'unresolved_region') - return - budget.append_output(all_singularities, SSXSingularity( - kind='tangent_point', stuv=corrected.copy(), xyz=local_xyz), 'singularity') + return _result() + F_sq_top = surface_surface_distance_squared_net_homog( + S1_h_top, S2_h_top, rational=True) + F_sq_top = _distance_net_lower_bound(S1_h_top, S2_h_top, F_sq_top, True) # Uncertified curved-UV overlap spans surfaced by nested CSX calls # (ledger L42's typed fallback): the L28 region assembler consumes them @@ -7415,23 +7213,9 @@ def _source_registered_singularity(point, cell): uncertified_overlap_spans = [] def _run_csx( - curve, surface, *, local_truncation_is_soft=False, - pending_partition=False, source_face=None, **kwargs, + curve, surface, *, local_truncation_is_soft=False, **kwargs, ): """Bounded adapter used by every boundary and cut-face CSX call.""" - source_residual = None - if source_face is not None: - source_residual = source_face_residuals(*source_face) - if source_residual is None: - return { - 'isolated': [], 'overlaps': [], 'parameter_fibers': [], - 'budget_exhausted': True, 'cells_processed': 0, - 'boundary_topology_complete': False, - 'truncation_cause': 'cells' if budget.exhausted else 'resolution', - 'unresolved_parameter_boxes': [{ - 't_range': (0., 1.), 'u_range': (0., 1.), 'v_range': (0., 1.), - 'reason': 'source_residual'}], - } if budget.remaining_cells <= 0 or not budget.charge_csx_call(): budget.mark_exhausted() return { @@ -7442,21 +7226,9 @@ def _attempt(allowance): attempt_kwargs = dict(kwargs) attempt_kwargs['max_cells'] = allowance attempt_kwargs['max_results'] = int(csx_max_results) - # SSX needs exact zero-set topology: a nearby curve is not - # an overlap skeleton and cannot own or suppress true branches. - attempt_kwargs['tolerance_tier'] = False - if source_residual is not None: - attempt_kwargs['source_residual'] = source_residual - attempt_kwargs['source_exact_root'] = ( - lambda parameters: source_face_residuals.exact_root( - source_face, parameters, prepaid=True)) - attempt_kwargs['source_exact_root_work'] = max( - 1, (sum(net.size for net in identity_sources_h)+31)//32) if csx_max_depth is not None: attempt_kwargs['max_depth'] = int(csx_max_depth) attempt_result = bez_csx(curve, surface, **attempt_kwargs) - if source_residual is not None: - attempt_result['_ssx_source_residual'] = True attempt_used = max( 0, int(attempt_result.get('cells_processed', 0))) if not budget.charge_cells(attempt_used, "csx"): @@ -7480,25 +7252,26 @@ def _attempt(allowance): 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. + # A locally truncated CSX root set cannot establish complete + # face topology, 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 + # retain individually validated face witnesses, mark the public + # result incomplete, and continue to the remaining faces while + # shared work remains. This preserves useful 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 + # uncertified-overlap fallback is STRUCTURAL — the samples may + # represent a curved correspondence or a flat multiple-root + # neighborhood — 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: + span_only = ((span is not None + and not result.get('non_span_truncation', False)) + or result.get('truncation_cause') == 'overlap') + if span_only and span is not None: uncertified_overlap_spans.append( (np.array(curve, dtype=np.float64, copy=True), (float(span[0]), float(span[1])), @@ -7519,39 +7292,27 @@ def _attempt(allowance): # it does not exhaust the run. Type it `depth_limit` (the # vocabulary already had the word) and keep going. _cause = result.get('truncation_cause') - _structural_limit = (span_only - or _cause in ('depth', 'resolution')) - if _structural_limit: - reason = (REASON_OVERLAP_REGION if span_only - else REASON_DEPTH_LIMIT if _cause == 'depth' - else REASON_MULTIPLICITY) - if not pending_partition: - budget.mark_incomplete(reason) - csx_unresolved_boxes[(active_csx_box, reason)] = { - 'stuv_min': tuple(float(lo) for lo, hi in active_csx_box), - 'stuv_max': tuple(float(hi) for lo, hi in active_csx_box), - 'reason': reason, - } - # Exact-mode CSX roots remain valid evidence even when - # another root box could not be isolated. Keep those - # roots for branch discovery and surface the unresolved - # complement separately; an empty face would lose them. - result['boundary_topology_complete'] = False - return result - if local_truncation_is_soft and not budget.exhausted: - reason = (REASON_OVERLAP_REGION if span_only - else REASON_WORK_BUDGET) - budget.mark_incomplete(reason) - csx_unresolved_boxes[(active_csx_box, reason)] = { - 'stuv_min': tuple(float(lo) for lo, hi in active_csx_box), - 'stuv_max': tuple(float(hi) for lo, hi in active_csx_box), - 'reason': reason, + _structural_depth = (_cause == 'depth' and not span_only) + if _structural_depth: + budget.mark_incomplete(REASON_DEPTH_LIMIT) + return { + 'isolated': [], 'overlaps': [], + 'parameter_fibers': [], + 'budget_exhausted': True, + 'boundary_topology_complete': False, + 'cells_processed': used, } - # A partial census does not invalidate the roots it did - # find. Boundary polishing still verifies every seed; - # dropping them here can remove both ends of a real arc. - result['boundary_topology_complete'] = False - return result + if local_truncation_is_soft and not budget.exhausted: + budget.mark_incomplete( + REASON_OVERLAP_REGION if span_only + else REASON_WORK_BUDGET) + # The face census is incomplete, but its returned roots + # remain useful proposals. Boundary admission polishes + # each one against both surfaces before tracing. Dropping + # them here loses high-order tangent lines when many raw + # samples consume CSX's local result allowance. + return dict(result, 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 @@ -7570,80 +7331,9 @@ def _attempt(allowance): bool(kwargs.get('rational', True)))) return result - def _run_top_boundary_csx(curve, surface, axis, value, **kwargs): - from mmcore.numeric.intersection.ssx._ssx_boundary_fibers import ( - source_boundary_fiber_candidate, source_boundary_fiber_seeds) - gate_work = max(1, (curve.size+31)//32) - if not budget.charge_cells(gate_work, 'source_fiber'): - return dict(isolated=[], overlaps=[], parameter_fibers=[], - boundary_topology_complete=False, budget_exhausted=True, - truncation_cause='max_cells') - if source_boundary_fiber_candidate(*identity_sources_h, axis, value): - if not budget.charge_csx_call(): - return dict(isolated=[], overlaps=[], parameter_fibers=[], - boundary_topology_complete=False, budget_exhausted=True, - truncation_cause='max_cells') - result = source_boundary_fiber_seeds( - *identity_sources_h, axis, value, atol=source_atol, - max_cells=min(boundary_csx_max_cells, budget.remaining_cells), - max_results=csx_max_results) - budget.charge_cells(result['cells_processed'], 'source_fiber') - if result['parameter_fibers'] or result['boundary_seed_proposals']: - for seed in result['parameter_fibers']+result['boundary_seed_proposals']: - seed['point'] = (np.asarray(seed['point'])-_norm_c)/_norm_k - # Fibers and numerical seed proposals never certify a finite - # source boundary census. Preserve the whole face obligation. - reason = (REASON_PARAMETER_FIBER if result['parameter_fibers'] - else REASON_MULTIPLICITY) - budget.mark_incomplete(reason) - face_box = list(((0., 1.),)*4) - face_box[axis] = value, value - face_box = tuple(face_box) - csx_unresolved_boxes[(face_box, reason)] = { - 'stuv_min': tuple(lo for lo, hi in face_box), - 'stuv_max': tuple(hi for lo, hi in face_box), 'reason': reason} - return result - # No seed is absence of a witness, not an unresolved source - # root. Let the complete source census prove this face empty - # or find roots the boundary-only seed search could not see. - # A supported original-source scalar reduction also proves roots - # on other closed faces, where strict three-dimensional interior - # Krawczyk inclusion is unavailable. Its own boundary allowance - # and source certificates remain authoritative. - source = _source_cut_uncached( - axis, value, ((0., 1.),)*4, - allowance_cap=boundary_csx_max_cells, soft=True) - if source is not None: - result = dict(source) - result['_ssx_source_residual'] = True - order = [axis ^ 1]+([2, 3] if axis < 2 else [0, 1]) - roots = [] - for entry in source.get('isolated', []): - root = dict(entry) - for name, coordinate in zip(('t', 'u', 'v'), order): - root[name] = float(entry['stuv'][coordinate]) - root['parameter_root_box'] = np.asarray(entry['parameter_root_box'])[order] - root['point'] = (np.asarray(entry['point'])-_norm_c)/_norm_k - root['root_existence_certification'] = 'exact_source_planar_cut' - roots.append(root) - result['isolated'] = roots - if (result.get('budget_exhausted', False) - or not result.get('boundary_topology_complete', True)): - cause = result.get('truncation_cause') - reason = (REASON_MULTIPLICITY if cause == 'resolution' - else REASON_DEPTH_LIMIT if cause == 'depth' - else REASON_WORK_BUDGET) - budget.mark_incomplete(reason) - face_box = list(((0., 1.),)*4) - face_box[axis] = value, value - face_box = tuple(face_box) - csx_unresolved_boxes[(face_box, reason)] = { - 'stuv_min': tuple(lo for lo, hi in face_box), - 'stuv_max': tuple(hi for lo, hi in face_box), 'reason': reason} - return result + def _run_top_boundary_csx(curve, surface, **kwargs): return _run_csx( - curve, surface, local_truncation_is_soft=True, - source_face=(axis, value, ((0., 1.),)*4), **kwargs) + 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 @@ -7651,9 +7341,9 @@ def _run_top_boundary_csx(curve, surface, axis, value, **kwargs): # (trace-vs-subdivide flips on marginal near-tangent cells). reset_witness_rng() - # Original-source coefficients own every destructive exclusion. The - # normalized operands below support numerical proposals only. - if source_residual_excludes(((0.,1.),)*4): + # --- Level 1: Pruning --- + if _prune_ssx_cell( + S1, S2, atol, rational=rational, F=F_sq_top): return _result() # --- Build GaussMapBern ONCE --- @@ -7668,39 +7358,28 @@ def _run_top_boundary_csx(curve, surface, axis, value, **kwargs): # --- Level 2: Boundary CSX (8 calls, once) --- # 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. + # Overlap endpoints remain crossings because ordinary arcs can meet + # them; _find_ssx_boundary_zeros deduplicates paired geometric events. boundary_parameter_fibers = [] - boundary_census = {} crossings, boundary_overlaps = _find_ssx_boundary_zeros( - S1, S2, atol, rational=rational, face_csx_fn=_run_top_boundary_csx, - fiber_sink=boundary_parameter_fibers, census_sink=boundary_census, - root_matcher=root_identity, - source_point_representation=lambda parameters, xyz: _source_path_representation( - parameters, parameters, np.array([xyz, xyz]))) + 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: - # Exact fibers certify sets, not incident branch multiplicity. - # Numerical nearcollapse seeds certify neither. Preserve useful - # verified approximations with an explicit unresolved complement. - budget.mark_incomplete(REASON_PARAMETER_FIBER if any( - point.parameter_fiber for point in boundary_parameter_fibers) - else REASON_MULTIPLICITY) + # 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}]) + return _result(branches=overlap_branches) # --- Level 3: TΨᵢ (once at top level) --- if rational: @@ -7739,21 +7418,31 @@ def _run_top_boundary_csx(curve, surface, axis, value, **kwargs): T3_arr = _tpsi_to_numpy(T3) T4_arr = _tpsi_to_numpy(T4) + # 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()) + # Global per-axis parametric tolerance (from the full surfaces): - # Candidate matching proposals use these scales; root identity still - # requires source existence and a shared uniqueness certificate. + # crossings/exit points whose stuv agree within this radius are the + # same physical point. CSX roots and marcher exits are each accurate + # to ~ptol, so 4x covers both ends. from mmcore.nurbs._nurbs_param_tol import bez_surface_param_tolerance _gp_s, _gp_t = bez_surface_param_tolerance(S1_h_top, atol, rational=True) _gp_u, _gp_v = bez_surface_param_tolerance(S2_h_top, atol, rational=True) unify_tol = 4.0 * np.maximum( np.array([float(_gp_s), float(_gp_t), float(_gp_u), float(_gp_v)]), 1e-12) - # Initial source-root enclosure proposals use the smaller scale and - # refine adaptively when a first proposal is too wide. + # Destructive dedup uses the tight 1·ptol radius (plus an xyz guard at + # the call sites); the looser 4·ptol box is reserved for matching / + # unification where a miss is recoverable. dedup_tol = unify_tol / 4.0 - # Independent parameter projections are not a paired correspondence. - # Supported complete planar regions were handled by the exact tier. - overlap_boxes = [] + # Ledger L6(ii): parametric boxes of the detected coplanar overlap + # regions — every tangency emission site skips witness roots inside + # them (the overlap interior is a 2-dim C2 set the overlap branches + # already report; an "isolated" point there is a phantom). + overlap_boxes = _overlap_region_boxes( + boundary_overlaps, S1_h_top, atol, unify_tol) # Global xyz step ceiling for all marchers. NOT an accuracy criterion — # accuracy is governed by the chord-deviation (sagitta) control at @@ -7786,10 +7475,6 @@ def _run_top_boundary_csx(curve, surface, axis, value, **kwargs): if not budget.charge_cells(promotion_work, "fiber_promotion"): promoted_fibers = [] promoted_path = None - if (promoted_path is not None and not - _source_fallback_path_representation(*promoted_path)): - promoted_fibers = [] - promoted_path = None if promoted_fibers and promoted_path is not None: promoted_fiber_fragment = _Fragment( start_point=promoted_fibers[0], @@ -7799,25 +7484,11 @@ def _run_top_boundary_csx(curve, surface, axis, value, **kwargs): tangential=False, ) - unresolved_regions: list[dict] = [] - all_singularities: list[SSXSingularity] = [] 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), - boundary_complete=bool(boundary_census.get("complete", False)), - boundary_obligations=tuple(boundary_census['boundary_obligations']) - if 'boundary_obligations' in boundary_census else None, - root_matcher=root_identity, - straight_path=_source_straight_path, - path_representation=_source_path_representation, - arc_image=_source_arc_image, - arc_chord=_source_arc_chord, - source_root_exact=_source_root_exact, - source_singular_candidate=_source_singular_candidate, - source_singular_sink=_source_registered_singularity, - source_cofactors=source_cofactors, - unresolved_regions=unresolved_regions, + 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) @@ -7831,8 +7502,8 @@ def _run_top_boundary_csx(curve, surface, axis, value, **kwargs): # A purely interior intersection (e.g. case 7's closed loop strictly # inside [0,1]⁴) has zero boundary crossings on the top-level box but is # still a real intersection. The midpoint-fallback path inside the - # subdivision loop discovers it. Source residual and strict cofactor - # certificates inside the loop terminate cells with no actual + # subdivision loop discovers it. Cheap certificates (AABB, GJK, F_sq, + # loop_free) inside the loop will terminate cells with no actual # intersection. # --- Iterative domain decomposition (single code path, design §6) --- @@ -7844,7 +7515,16 @@ def _run_top_boundary_csx(curve, surface, axis, value, **kwargs): queue = deque([top_cell]) all_fragments: list[_Fragment] = [] all_points = [] - provisional_traces = [] + all_singularities: list[SSXSingularity] = [] + ptol4_global = np.maximum(np.array( + [float(_gp_s), float(_gp_t), float(_gp_u), float(_gp_v)]), 1e-9) + c1_singularities = _discover_c1_singularities( + S1_h_top, S2_h_top, atol, ptol4_global, budget) + all_singularities.extend(c1_singularities) + # A failed parent's output is provisional while descendants search the + # same domain. Charge it when found, but publish it only if that search + # remains partial; a completed replacement must not duplicate its prefix. + provisional_trace_batches = [] if promoted_fiber_fragment is not None: budget.append_output( all_fragments, promoted_fiber_fragment, "fragment") @@ -7873,88 +7553,16 @@ def _available_queue_slots() -> int: return max(0, budget.remaining_cells - len(queue)) def _enqueue_probe_children(parent) -> bool: - if _available_queue_slots() < 2: + if _available_queue_slots() < 4: budget.mark_exhausted() - queue.appendleft(parent) return False queue.extend(_probe_children(parent)) return True - def _unresolved_singular_cell(pending_cell): - # A numerical witness, or a parameter-size floor, does not prove - # that every component in this box has been represented. - budget.mark_incomplete(REASON_MULTIPLICITY) - budget.append_output( - unresolved_regions, - {'stuv_min': tuple(float(lo) for lo, hi in pending_cell.box), - 'stuv_max': tuple(float(hi) for lo, hi in pending_cell.box), - 'reason': REASON_MULTIPLICITY}, - 'unresolved_region') - - def _unresolved_parameter_cell(pending_cell): - for point in pending_cell.crossings: - budget.append_output(all_points,_registered_point(point),'point') - budget.mark_incomplete(REASON_PARAMETER_REPRESENTATION) - budget.append_output(unresolved_regions, { - 'stuv_min': tuple(lo for lo,hi in pending_cell.box), - 'stuv_max': tuple(hi for lo,hi in pending_cell.box), - 'reason': REASON_PARAMETER_REPRESENTATION},'unresolved_region') - while queue: if not budget.charge_cells(1, "ssx"): break cell = queue.popleft() - # Original-source affine identities are exact necessary conditions, - # including in cells carrying approximate borrowed boundary points. - # A denied contraction returns the unchanged box, never emptiness. - necessary_box = affine_constraints.contract(cell.box) - if necessary_box is None: - continue - fixed_axes = [axis for axis, (lo, hi) in enumerate(necessary_box) if lo == hi] - if fixed_axes: - # A necessary fixed parameter reduces the ENTIRE zero set of - # this owner to one source face. An exhaustive isolated-root - # census on that face can settle it directly, without creating - # a zero-width surface chart or retaining an outside borrowed - # corner representative. Positive-dimensional/unknown censuses - # retain the general search owner below. - axis = fixed_axes[0] - fixed_census = _source_cut_census(axis, necessary_box[axis][0], cell.box) - if (fixed_census is not None - and fixed_census.get('boundary_topology_complete', False) - and not fixed_census.get('budget_exhausted', False) - and not fixed_census.get('overlaps') - and not fixed_census.get('parameter_fibers')): - for root in fixed_census.get('isolated', []): - point = BoundaryPoint( - np.asarray(root['stuv']), - (np.asarray(root['point'])-_norm_c)/_norm_k, (axis, -1), - root_box=np.asarray(root['parameter_root_box'])) - point._source_root_box = True - root_identity.register_source_root(point, root['source_cut_certificate']) - budget.append_output(all_points, _registered_point(point), 'point') - continue - for point in cell.crossings: - if point.root_box is not None and getattr(point, '_source_root_box', False): - enclosure = affine_constraints.contract(point.root_box) - if enclosure is not None: - # Necessary source equations preserve the established - # root, and can prove exact linked face coordinates. - # This is not clipping an enclosure to the owner cell. - point.root_box = np.asarray(enclosure) - outside = [point for point in cell.crossings - if _source_boundary_outside(cell, point)] - if outside: - outside_ids = {id(point) for point in outside} - cell.crossings = [point for point in cell.crossings if id(point) not in outside_ids] - cell.new_crossings = [point for point in cell.new_crossings - if id(point) not in outside_ids] - for point in outside: - point.registrations[:] = [registration for registration in point.registrations - if registration.owner is not cell] - _contract_source_domain(cell, necessary_box, (g1, g2), - (T1_arr, T2_arr, T3_arr, T4_arr)) - active_csx_box = cell.box # P1c soundness guard (2026-07-21): a cell carrying registered # crossings holds strict-certified Psi roots ON it — any exclusion @@ -7967,11 +7575,30 @@ def _unresolved_parameter_cell(pending_cell): # approximate exclusion: crossing-bearing cells always proceed to # classification/tracing. if not cell.crossings: - # Restrict the original residual with its inherited absolute - # coefficient error, including normalization-independent data. - if source_residual_excludes(cell.box): + # Cheap AABB pruning first: if control-point bounding boxes + # don't overlap, there is no intersection in this cell. + if _aabb_disjoint(cell.g1.surface, cell.g2.surface, atol): continue + # GJK separability: tighter than AABB, much cheaper than the + # sq-dist net or Gauss separability. Test the convex hulls of + # the two control nets — if they're separated, the surfaces + # don't intersect. + if _trust_gjk(cell.g1) and _trust_gjk(cell.g2): + P1_pts = (cell.g1.surface[..., :-1] / cell.g1.surface[..., -1:]).reshape(-1, 3) + P2_pts = (cell.g2.surface[..., :-1] / cell.g2.surface[..., -1:]).reshape(-1, 3) + if not gjk(P1_pts, P2_pts, atol, 15): + continue + + # Sq-dist net pruning using the PROPAGATED F_sq (built once at + # top, split alongside TΨᵢ at every subdivision — never + # reconstructed). + if cell.F_sq is not None: + if _check_min_of_net(cell.F_sq, atol, cell.w_scale): + continue + if _check_lipschitz(cell.F_sq, atol, cell.w_scale): + continue + # Ledger L4 probe descent: a probe-only cell exists solely to find # the Δ-touch its loop-free ancestor's center witness missed (the # off-lattice touch pinned at a box corner, where the center GN @@ -7984,76 +7611,225 @@ def _unresolved_parameter_cell(pending_cell): # never trace and never CSX — the ancestor already traced this # geometry, which is what keeps the descent duplication-free. if cell.probe_only: - source_bounds = source_cofactors.bounds(cell.box) - if tuple(map(tuple,cell.box)) in getattr(source_cofactors,'empty_boxes',()): + if (cell.T1 is None or any( + hull_excludes_zero(T) + for T in (cell.T1, cell.T2, cell.T3, cell.T4))): continue - if (source_bounds is not None and np.any( - (source_bounds[0] > 0.) | (source_bounds[1] < 0.))): + # Component-wise Ψ hull exclusion (sound: a Δ-root needs Ψ = 0 + # too). The T hulls alone cannot end a probe line along the + # TRAP SHEET — the critical set where all four TΨ vanish but + # Ψ != 0 (the touch-plus-loop valley floor: a 1-dim ring with + # |Ψ_z| = eps²/4, INSIDE the atol tolerance band, so the + # F_sq-vs-atol prune above keeps it too) — and the descent + # walked that ring to the size gate (measured +3.7 s on the + # off-lattice repro). A sign-carrying Ψ component excludes it + # as soon as the cell-local range drops under the floor value + # (1-2 probe levels here); touch cells keep Ψ = 0 and survive. + # Margin at COORDINATE scale, not per-component net scale + # (`hull_excludes_zero`'s max|c| convention is WRONG here — + # the L1 drift race): the split drift in a mathematically-zero + # corner coefficient originates from the O(coordinate)-scale + # surface nets, while a touch-hugging component's own range + # shrinks without bound (measured on the touch cell at d=8: + # G_z hull [-3.9e-4, -9.8e-17] — true max is 0 AT the corner, + # drift 1e-16, per-component margin 1.1e-17 wrongly excluded + # and the touch was lost again). + S1h_p, S2h_p = cell.g1.surface, cell.g2.surface + G = psi_vector_net(S1h_p, S2h_p) + from mmcore.numeric.intersection.ssx._ssx5_singular import ( + _HULL_MARGIN_K_EPS as _gK) + gm = _gK * (float(np.abs(S1h_p[..., :-1]).max()) + * float(np.abs(S2h_p[..., -1]).max()) + + float(np.abs(S2h_p[..., :-1]).max()) + * float(np.abs(S1h_p[..., -1]).max())) + if any(float(G[..., k].min()) > gm or float(G[..., k].max()) < -gm + for k in range(3)): continue - # The original-operand residual exclusion already ran above; - # do not repeat it with a child-scaled error floor. ok, roots = _emit_tangent_roots(cell, atol, unify_tol, all_singularities, enumerate_all=False, overlap_boxes=overlap_boxes) - if np.all(np.array([hi-lo for lo, hi in cell.box]) - <= 4.0*unify_tol): - _unresolved_singular_cell(cell) - else: + if not (ok and roots) and not np.all( + np.array([hi - lo for (lo, hi) in cell.box]) + <= 4.0 * unify_tol): _enqueue_probe_children(cell) continue - # A strict original-source cofactor proves both regularity and - # monotonicity of one global parameter throughout the possible zero set. + # Loop-absence on this sub-cell — TΨᵢ monotonicity (cheap) tried first, + # Gauss map separability as fallback (design §6, §10 principle 8). - source_bounds = source_cofactors.bounds(cell.box) - if tuple(map(tuple,cell.box)) in getattr(source_cofactors,'empty_boxes',()): - # Necessary-domain contraction of the original equations can - # prove this entire owner empty, including borrowed events - # whose representatives merely round into it. - continue - source_regular = bool(source_bounds is not None and np.any( - (source_bounds[0] > 0.) | (source_bounds[1] < 0.))) if _check_loop_free(cell.g1, cell.g2, - cell.T1, cell.T2, cell.T3, cell.T4, - source_bounds=source_bounds, source_context=True): - if not cell.boundary_complete: - # Loop absence only reduces discovery to the boundary; an - # unfinished boundary census cannot establish emptiness. - cell.trace_incomplete = True - single_source_root = False - if cell.boundary_complete and len(cell.crossings) == 1 and source_regular: - point = cell.crossings[0] - enclosure = root_identity.enclose(point,4.*dedup_tol) - single_source_root = enclosure is not None - if not single_source_root: - single_source_root = _source_root_exact(point.stuv) - if single_source_root: - # A regular component in this closed box is an arc or a - # loop. Strict monotonicity excludes loops; an arc needs - # two distinct boundary roots. An exhaustive one-root - # census therefore describes at most a clipped boundary - # touch. The globally valid root may be a borrowed event - # just outside this owner: retaining that source root still - # represents every possible zero here without asserting - # local membership from its rounded parameters. - budget.append_output(all_points, - _registered_point(cell.crossings[0]), "point") - continue - + cell.T1, cell.T2, cell.T3, cell.T4): + # C2 touch ON the subdivision lattice: when the tangent point + # coincides with cut values (the saddle's X at s=t=1/2 under + # midpoint cuts), the children are loop-free via a NON-STRICT + # monotone T net that attains 0 exactly at the touch corner, + # so no cell holding the touch ever reaches the tangency arms + # below — the touch surfaces only as a boundary crossing of + # loop-free cells and would go unreported. Emit the Δ-witness + # here too, gated by the necessary condition for any TΨ = 0 + # in the cell: ALL FOUR T-net hulls contain 0 (a strictly + # one-signed net excludes tangency). Regular transversal + # cells almost always carry a strictly one-signed net, so the + # gate costs 8 min/max on already-carried nets. Near-tangent + # (but touch-free) geometries DO pass the hull gate (case 5: + # 13 cells, case 11's near-tangent loop: 5), so the witness + # runs center-GN only (enumerate_all=False, ~1 ms/call — + # full solve_zero_dim enumeration on those cells measured + # ~2 s per case, a 1.3–1.6x coverage-case regression). The + # center start suffices here: a lattice touch is pinned to a + # corner of a small post-subdivision cell (guided cuts pass + # exactly through the discovered touch crossing), unlike the + # crossing-less arm whose one large cell can hold several + # distant touches and keeps the full enumeration. + # "Contains 0" is margin-consistent with the L1 hull + # convention: a hull counts as containing 0 unless it CLEARS + # zero by the roundoff margin (`not hull_excludes_zero`) — + # a lattice touch's mathematically-zero T coefficient drifts + # to ~eps/8 after the guided cut through it, and the strict + # `min <= 0` gate then never fired. The margin makes the + # probe fire MORE often — the safe direction. + tangent_gate = (cell.T1 is not None and not any( + hull_excludes_zero(T) + for T in (cell.T1, cell.T2, cell.T3, cell.T4))) + if tangent_gate: + ok, roots = _emit_tangent_roots(cell, atol, unify_tol, + all_singularities, + enumerate_all=False, + 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 + # cell's box corner, where the center start dies in the + # touch/valley trap: the (0.3,0.3) touch-plus-loop lost + # its touch through exactly this arm's old unconditional + # `continue`). Push a lean PROBE descent (probe_only + # cells: hull gate + center witness + net-split, no + # tracing/CSX — see the probe arm above) to hunt the + # missed root, bounded by the same 4·unify_tol size gate. + # The cell itself still traces below exactly as before — + # probes never trace, so nothing is double-collected, and + # the traced geometry is bit-identical to the pre-L4 + # baseline. Guided-cut subdivision instead of probes + # measured +1.2 s on case 5 (20 near-tangent witness + # failures x ~60 ms of cut-face CSX) and degraded the + # off-lattice ring's arc coverage (descendant traces + # replaced the parent's) — the lean descent costs ~ms and + # keeps the traced geometry untouched. + if not (ok and roots) and not np.all( + np.array([hi - lo for (lo, hi) in cell.box]) + <= 4.0 * unify_tol): + _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: + cell.trace_surfaces = (S1_h_top, S2_h_top) + 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 + # shipped kind='transversal'. Tag by MEASUREMENT + # (`_fragment_on_tangent_locus`: normal alignment, + # escalating to the Δ-snap for valley-wandered + # samples) => tangential fragment (propagates to the + # branch kind via assembly's any-fragment rule, and + # the kind-keyed subsumption filter then eats the + # stray on-curve witnesses). Only fired-gate cells are + # measured — transversal fragments exit on their first + # failing sample. + for f in fr: + if (not f.tangential and len(f.stuv_path) >= 2 + and _fragment_on_tangent_locus(cell, f, + atol)): + f.tangential = True + + # 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 if cell.trace_incomplete: - # The complete child partition, not a failed parent - # march, owns the next search attempt over this domain. - # Keep already found geometry as a bounded fallback; - # publishing it now would duplicate the descendants' - # independently sampled traversals of the same arc. pending_fragments, pending_points = [], [] budget.extend_output(pending_fragments, fr, "fragment") budget.extend_output(pending_points, pt, "point") - provisional_traces.append( - (cell.box, pending_fragments, pending_points)) + if pending_fragments or pending_points: + provisional_trace_batches.append( + (pending_fragments, pending_points)) else: budget.extend_output(all_fragments, fr, "fragment") budget.extend_output(all_points, pt, "point") @@ -8078,8 +7854,7 @@ def _unresolved_parameter_cell(pending_cell): _cell_fibers = (boundary_parameter_fibers if cell.depth == 0 else []) _has_boundary_seeds = bool(cell.crossings or _cell_fibers) - is_clearly_transversal = (cell.regular_complement - or cell.trace_incomplete) + is_clearly_transversal = False 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 @@ -8123,7 +7898,7 @@ def _unresolved_parameter_cell(pending_cell): is_clearly_transversal = True break - if is_clearly_transversal: + if cell.trace_incomplete or is_clearly_transversal: tangency = False else: local_box = ((0.0, 1.0),) * 4 @@ -8201,8 +7976,8 @@ def _unresolved_parameter_cell(pending_cell): # here silently deleted the ring (the ring is transversal and # NOT on Φ — even the Φ∩L seeding above only reaches it through # the full-Ψ refinement of nearby Φ points, which is - # opportunistic, not certified). Record unresolved status when - # stopping at parameter tolerance scale; + # opportunistic, not certified). Stop only when the cell is at + # tolerance scale (all four GLOBAL spans within 4·unify_tol); # otherwise fall through to the subdivision path like any other # uncertified cell — descendants that re-confirm the same # tangency are absorbed by the emission dedup above. @@ -8212,7 +7987,6 @@ def _unresolved_parameter_cell(pending_cell): # emission nor subdivision. spans = np.array([hi - lo for (lo, hi) in cell.box]) if ok and np.all(spans <= 4.0 * unify_tol): - _unresolved_singular_cell(cell) continue if tangency is True and _has_boundary_seeds: @@ -8235,14 +8009,10 @@ def _unresolved_parameter_cell(pending_cell): # 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. - # The center witness can be an independently verified isolated - # source root even when rounded off-curve enumeration misses - # it. Apply the same source canonicalization/dimension typing - # used by crossing-less cells; genuine curve samples stay out - # of the isolated-point output. - _ok_curve, _curve_roots = _emit_tangent_roots( - cell, atol, unify_tol, all_singularities, - enumerate_all=False, overlap_boxes=overlap_boxes) + _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 @@ -8302,9 +8072,6 @@ def _unresolved_parameter_cell(pending_cell): 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) - if (_cell_fibers and not _source_fallback_path_representation( - stuv_glob, f.xyz_path)): - continue _fragment = _Fragment( start_point=f.start_point, end_point=f.end_point, stuv_path=stuv_glob, xyz_path=f.xyz_path, @@ -8337,35 +8104,25 @@ def _unresolved_parameter_cell(pending_cell): # per-pop charging the flood's excluded siblings starved the # budget and touches at 5-15*atol from the curve fell into a # blind band). - # This census resolves only Delta roots. An ordinary loop - # can coexist with this tangent curve in the same cell, so the - # remaining parameter domain still needs ordinary subdivision. + # KNOWN LIMIT: this recovers only Δ-roots. A coexisting + # TRANSVERSAL loop (not on Δ) with no boundary crossings in + # this cell is still lost — the `continue` below skips + # subdivision, and the Φ∩L loop seeding runs only on the + # crossing-LESS arm. Accepted (the subdivision alternative is + # the 1349x path above). _emit_offcurve_tangent_roots(cell, fr_local, atol, unify_tol, all_singularities, overlap_boxes=overlap_boxes) - # Existence of one singular component is not a classification - # of the whole cell. Preserve the ordinary complement in the - # subdivision queue. Repeating Delta enumeration in every - # descendant instead makes a tangent curve consume the entire - # budget before those ordinary components are reached. - cell.regular_complement = True - - if max_depth is not None and cell.depth >= max_depth: + continue + + if cell.depth >= max_depth: for c in cell.crossings: budget.append_output( - all_points, _registered_point(c), "point") + 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 @@ -8376,6 +8133,7 @@ def _unresolved_parameter_cell(pending_cell): s1_axis, s1_cuts, s2_axis, s2_cuts = _compute_split_plan( 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: s1_span_s = cell.box[0][1] - cell.box[0][0] @@ -8387,85 +8145,6 @@ def _unresolved_parameter_cell(pending_cell): 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])] - # Inherited roots also constrain partition placement. Cutting at - # their polished coordinate creates a new endpoint on a rounded - # restricted isocurve, where source-root existence is no longer - # implied by the old representative. Separate every known event, - # including when the axis itself came from midpoint fallback. - s1_cuts = (_root_separating_cuts(cell.crossings, s1_axis, - cell.box, dedup_tol) - or [0.5*(cell.box[s1_axis][0]+cell.box[s1_axis][1])]) - s2_cuts = (_root_separating_cuts(cell.crossings, s2_axis, - cell.box, dedup_tol) - or [0.5*(cell.box[s2_axis][0]+cell.box[s2_axis][1])]) - if not source_regular: - # Boundary-event gaps need not shrink the unresolved interior. - # In particular, repeated cuts between nearby ports can retain - # the same off-curve stationary corner forever. Until a source - # minor proves monotonicity, split centrally on each chart's - # longest axis so the whole owner shrinks. Avoid known event - # bands only within that central progress interval. - s1_axis = max((0, 1), key=lambda axis: cell.box[axis][1]-cell.box[axis][0]) - s2_axis = max((2, 3), key=lambda axis: cell.box[axis][1]-cell.box[axis][0]) - s1_cuts = [_balanced_root_avoiding_cut(cell.crossings, s1_axis, cell.box)] - s2_cuts = [_balanced_root_avoiding_cut(cell.crossings, s2_axis, cell.box)] - if any(T is not None and not np.any(T) - for T in (cell.T1, cell.T2, cell.T3, cell.T4)): - # A zero minor cannot prove loop absence, but it often exposes - # free extrusion coordinates. Refine a varying constraint - # instead of duplicating its uncertainty across a 2x2 product - # of unrelated chart directions. This changes search order - # only; both halves retain every equation and boundary event. - active_axis = _probe_split_axis(cell) - active_cuts = ((_root_separating_cuts( - cell.crossings, active_axis, cell.box, dedup_tol) - if source_regular else []) or [ - _balanced_root_avoiding_cut(cell.crossings, active_axis, cell.box)]) - if active_axis < 2: - s1_axis, s1_cuts, s2_cuts = active_axis, active_cuts, [] - else: - s2_axis, s2_cuts, s1_cuts = active_axis, active_cuts, [] - if getattr(cell, 'trace_split_axis', None) is not None: - # Separate several certified monotone arcs along their active - # tangent coordinate, instead of repeatedly narrowing a normal - # strip that still carries all four ports. - axis = cell.trace_split_axis - cuts = (_root_separating_cuts(cell.crossings, axis, cell.box, dedup_tol) - or [0.5*sum(cell.box[axis])]) - if axis < 2: - s1_axis, s1_cuts, s2_cuts = axis, cuts, [] - else: - s2_axis, s2_cuts, s1_cuts = axis, cuts, [] - empty_cut_certificate = getattr(cell, '_regular_empty_cut_certificate', None) - if (empty_cut_certificate is not None - and empty_cut_certificate['owner_box'] == cell.box): - axis, cuts = empty_cut_certificate['axis'], list(empty_cut_certificate['cuts']) - if axis < 2: - s1_axis, s1_cuts, s2_cuts = axis, cuts, [] - else: - s2_axis, s2_cuts, s1_cuts = axis, cuts, [] - if s1_cuts and s2_cuts and affine_constraints.components is not None: - linked = any(s1_axis in {entry[0] for entry in expressions} - and s2_axis in {entry[0] for entry in expressions} - for expressions, fixed in affine_constraints.components) - if linked: - # Both cut families vary the same exact affine component. - # Either family already partitions the whole owner; source - # domain contraction transfers its bounds to the other - # chart. The second family only creates redundant corner - # cells and repeated censuses of their common source face. - if len(s2_cuts) > len(s1_cuts): - s1_cuts = [] - else: - s2_cuts = [] - # At adjacent floating endpoints even a mathematical midpoint - # need not be representable. Such a cut cannot make progress and - # must not create a zero-width child or recycle its parent. - s1_cuts = _strict_interior_cuts(cell.box,s1_axis,s1_cuts) - s2_cuts = _strict_interior_cuts(cell.box,s2_axis,s2_cuts) - if not s1_cuts and not s2_cuts: - _unresolved_parameter_cell(cell) - continue # 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 @@ -8475,233 +8154,163 @@ def _unresolved_parameter_cell(pending_cell): if projected_children > _available_queue_slots(): if _available_queue_slots() < 4: budget.mark_exhausted() - queue.appendleft(cell) break - s1_cuts = _strict_interior_cuts(cell.box,s1_axis, - [0.5 * (cell.box[s1_axis][0] + cell.box[s1_axis][1])]) - s2_cuts = _strict_interior_cuts(cell.box,s2_axis, - [0.5 * (cell.box[s2_axis][0] + cell.box[s2_axis][1])]) - if not s1_cuts and not s2_cuts: - _unresolved_parameter_cell(cell) - continue + 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]) - # Census each cut against the unsplit opposite parent chart. - # Re-solving it separately on every opposite child introduced - # rounded endpoint subproblems and duplicated one root per piece. - # A registration discovered here is shared by all incident cells. - cut_obligations = [] - - def _cut_roots(axis, cuts): - owner = cell.g1.surface if axis < 2 else cell.g2.surface - target = cell.g2.surface if axis < 2 else cell.g1.surface - varying_axis = axis ^ 1 - target_axes = (2, 3) if axis < 2 else (0, 1) - result_groups = [] - lo, hi = cell.box[axis] - mappings = [(varying_axis, *cell.box[varying_axis]), - *((k, *cell.box[k]) for k in target_axes)] - - def _recover_source_roots(result, value): - pending = result.get('unresolved_parameter_boxes', []) - if not pending: - return result - remaining, recovered = [], [] - for obligation in pending: - candidate = obligation.get('candidate') - if obligation.get('reason') != 'root_existence' or candidate is None: - remaining.append(obligation) - continue - stuv = np.empty(4) - stuv[axis] = value - for x, (k, start, stop) in zip(candidate, mappings): - stuv[k] = start+float(x)*(stop-start) - local_box = tuple(obligation[key] for key in ('t_range','u_range','v_range')) - region = _mapped_root_box({'parameter_root_box': local_box}, - axis, value, mappings) - proposed = BoundaryPoint(stuv, np.zeros(3), (axis,-1)) - enclosure = root_identity.resolve_region( - proposed, 4.*dedup_tol, region) - if enclosure is None: - remaining.append(obligation) - continue - if any(np.all(enclosure[:,0] >= prior[:,0]) - and np.all(enclosure[:,1] <= prior[:,1]) - for prior in recovered): - continue - recovered.append(region) - stuv = enclosure.mean(axis=1) - entry = {'_ssx_source_stuv': stuv, - '_ssx_source_root_box': enclosure} - for key, (k, start, stop) in zip(('t','u','v'), mappings): - entry[key] = (stuv[k]-start)/(stop-start) - local = _global_to_local(stuv, cell.box) - entry['point'] = eval_surface(cell.g1.surface, *local[:2], rational=True) - result.setdefault('isolated', []).append(entry) - result['unresolved_parameter_boxes'] = remaining - if (not remaining and result.get('unresolved_obligations_complete', False) - and result.get('truncation_cause') == 'resolution'): - result['budget_exhausted'] = False - result['boundary_topology_complete'] = True - result['truncation_cause'] = None - return result - - for cut_index, value in enumerate(cuts): + # Split S1 (Gauss map) along s1_axis + g1_pieces = _split_surface_multi(cell.g1, s1_axis, s1_cuts, cell.box) + # Split S2 (Gauss map) along s2_axis + g2_pieces = _split_surface_multi(cell.g2, s2_axis, s2_cuts, cell.box) + + # Split TΨᵢ along BOTH axes sequentially + T_list = [cell.T1, cell.T2, cell.T3, cell.T4] + T_after_s1 = [_split_tensor_multi(T, s1_axis, s1_cuts, cell.box) for T in T_list] + T_pieces = [] + F_sq_after_s1 = (_split_tensor_multi(cell.F_sq, s1_axis, s1_cuts, cell.box) + if cell.F_sq is not None else None) + F_sq_pieces = [] + for i1 in range(len(g1_pieces)): + row = [] + for T_idx in range(4): + T_s1_piece = T_after_s1[T_idx][i1] + sub_box = list(cell.box) + s1_lo = cell.box[s1_axis][0] if i1 == 0 else s1_cuts[i1 - 1] + s1_hi = s1_cuts[i1] if i1 < len(s1_cuts) else cell.box[s1_axis][1] + sub_box[s1_axis] = (s1_lo, s1_hi) + pieces_s2 = _split_tensor_multi(T_s1_piece, s2_axis, s2_cuts, tuple(sub_box)) + row.append(pieces_s2) + T_pieces.append(row) + # F_sq propagation (single 4D tensor, same axis convention) + if F_sq_after_s1 is not None: + sub_box = list(cell.box) + s1_lo = cell.box[s1_axis][0] if i1 == 0 else s1_cuts[i1 - 1] + s1_hi = s1_cuts[i1] if i1 < len(s1_cuts) else cell.box[s1_axis][1] + sub_box[s1_axis] = (s1_lo, s1_hi) + F_sq_pieces.append( + _split_tensor_multi(F_sq_after_s1[i1], s2_axis, s2_cuts, tuple(sub_box))) + else: + F_sq_pieces.append([None] * (len(s2_cuts) + 1)) + + # --- CSX: cut_line vs each piece of the opposite surface --- + # Split first, then CSX. Each cut line is intersected with each + # piece of the opposite surface separately, so crossings are found + # on the refined geometry and map deterministically to sub-cells. + s1_other = 1 - s1_axis if s1_axis < 2 else 1 - (s1_axis - 2) + s1_local_axis = s1_axis if s1_axis < 2 else s1_axis - 2 + s2_other_global = ({2: 3, 3: 2})[s2_axis] + s2_local_axis = s2_axis - 2 + + # Per-sub-cell new crossings: new_cx_grid[i1][i2] = list + n1 = len(g1_pieces) + n2 = len(g2_pieces) + new_cx_grid = [[[] for _ in range(n2)] for _ in range(n1)] + + # 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 - if (empty_cut_certificate is not None - and empty_cut_certificate['owner_box'] == cell.box - and empty_cut_certificate['axis'] == axis - and value in empty_cut_certificate['cuts']): - # The source boundary-level count proves this entire - # slice empty. No new boundary root solve is needed. - result_groups.append([]) - continue - result = _source_cut_census(axis, value, cell.box) - source_result = result is not None - if result is None: - isoline = _extract_isoline(owner, axis % 2, (value-lo)/(hi-lo)) - result = _run_csx(isoline, target, atol=atol, rational=True, - pending_partition=True, - source_face=(axis, value, cell.box)) - result = _recover_source_roots(result, value) - if result.get('budget_exhausted', False) and not budget.exhausted: - cause = result.get('truncation_cause') - reason = (REASON_DEPTH_LIMIT if cause == 'depth' - else REASON_MULTIPLICITY if cause == 'resolution' - else REASON_WORK_BUDGET) - budget.mark_incomplete(reason) - csx_unresolved_boxes[(active_csx_box, reason)] = { - 'stuv_min': tuple(float(a) for a,b in active_csx_box), - 'stuv_max': tuple(float(b) for a,b in active_csx_box), - 'reason': reason} - _surface_cut_face_fibers(result, budget) - if ( - result.get('budget_exhausted', False) - or not result.get('boundary_topology_complete', True) - or result.get('overlaps') or result.get('parameter_fibers')): - face_box = list(cell.box) - face_box[axis] = (float(value),float(value)) - face_box = tuple(face_box) - if face_box not in cut_obligations: - cut_obligations.append(face_box) - points = [] - for root in result.get('isolated', []): - stuv = np.empty(4, dtype=np.float64) - stuv[axis] = value - if source_result: - stuv = np.asarray(root['stuv']).copy() + s2_piece_surf = g2_pieces[s2_idx].surface + csx_r = _run_csx( + isoline_s1, s2_piece_surf, atol=atol, rational=True) + _surface_cut_face_fibers(csx_r, budget) + + s2_lo = cell.box[s2_axis][0] if s2_idx == 0 else s2_cuts[s2_idx - 1] + s2_hi = s2_cuts[s2_idx] if s2_idx < len(s2_cuts) else cell.box[s2_axis][1] + s2_other_lo, s2_other_hi = cell.box[s2_other_global] + + for iso_pt in csx_r.get('isolated', []): + # The cut face is closed. Its endpoint roots belong + # to the adjoining children just like interior roots. + if not all(np.isfinite(iso_pt[key]) and 0. <= iso_pt[key] <= 1. + for key in ('t', 'u', 'v')): + continue + stuv = np.zeros(4, dtype=np.float64) + stuv[s1_axis] = cv + s1_other_lo, s1_other_hi = cell.box[s1_other] + stuv[s1_other] = s1_other_lo + float(iso_pt['t']) * (s1_other_hi - s1_other_lo) + if s2_local_axis == 0: + stuv[s2_axis] = s2_lo + float(iso_pt['u']) * (s2_hi - s2_lo) + stuv[s2_other_global] = s2_other_lo + float(iso_pt['v']) * (s2_other_hi - s2_other_lo) else: - for key, (k, start, stop) in zip(('t', 'u', 'v'), mappings): - stuv[k] = start + float(root[key])*(stop-start) - stuv = root.get('_ssx_source_stuv', stuv) - local = _global_to_local(stuv, cell.box) - tangent, _, _ = _ssx_tangent_4d( - cell.g1.surface, cell.g2.surface, *local, rational=True) - point = BoundaryPoint( - stuv=stuv, xyz=((np.asarray(root['point'])-_norm_c)/_norm_k - if source_result else np.asarray(root['point'])), - face=(axis, -1), tangent_raw=tangent, - root_box=(np.asarray(root['parameter_root_box']) if source_result else - root['_ssx_source_root_box'] - if '_ssx_source_root_box' in root else - _mapped_root_box(root, axis, value, mappings))) - if source_result: - root_identity.register_source_root(point, root['source_cut_certificate']) - point._source_root_box = True - if '_ssx_source_root_box' in root: - point._source_root_box = True - if (result.get('_ssx_source_residual') - and root.get('root_existence_certification') - and point.root_box is not None): - point._source_root_box = True - points.append(point) - result_groups.append(points) - return result_groups - - s1_cut_points = _cut_roots(s1_axis, s1_cuts) + stuv[s2_other_global] = s2_other_lo + float(iso_pt['u']) * (s2_other_hi - s2_other_lo) + stuv[s2_axis] = s2_lo + float(iso_pt['v']) * (s2_hi - s2_lo) + + xyz = np.asarray(iso_pt['point'], dtype=np.float64) + stuv_local = _global_to_local(stuv, cell.box) + tang, _, _ = _ssx_tangent_4d( + cell.g1.surface, cell.g2.surface, + stuv_local[0], stuv_local[1], stuv_local[2], stuv_local[3], + rational=True) + bp = BoundaryPoint(stuv=stuv, xyz=xyz, face=(s1_axis, -1), tangent_raw=tang) + new_cx_grid[cut_idx][s2_idx].append(bp) + new_cx_grid[cut_idx + 1][s2_idx].append(bp) + if budget.exhausted: - queue.appendleft(cell) - break - # Once regularity is proved, the second set of planes may also - # separate the first cut census. Before then, retain balanced cuts - # on both charts: arbitrarily close root gaps cannot be allowed to - # prevent the unresolved four-dimensional owner from shrinking. - first_events = [p for group in s1_cut_points for p in group] - if source_regular and s2_cuts and not (empty_cut_certificate is not None - and empty_cut_certificate['owner_box'] == cell.box - and empty_cut_certificate['axis'] == s2_axis - and all(value in empty_cut_certificate['cuts'] for value in s2_cuts)): - s2_cuts = (_root_separating_cuts(cell.crossings + first_events, - s2_axis, cell.box, dedup_tol) - or [0.5*(cell.box[s2_axis][0]+cell.box[s2_axis][1])]) - s2_cuts = _strict_interior_cuts(cell.box,s2_axis,s2_cuts) - if not s1_cuts and not s2_cuts: - _unresolved_parameter_cell(cell) - continue - if (len(s1_cuts)+1)*(len(s2_cuts)+1) > _available_queue_slots(): - budget.mark_exhausted() - queue.appendleft(cell) break - s2_cut_points = _cut_roots(s2_axis, s2_cuts) + + # 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 = _run_csx( + isoline_s2, s1_piece_surf, atol=atol, rational=True) + _surface_cut_face_fibers(csx_r, budget) + + s1_lo = cell.box[s1_axis][0] if s1_idx == 0 else s1_cuts[s1_idx - 1] + s1_hi = s1_cuts[s1_idx] if s1_idx < len(s1_cuts) else cell.box[s1_axis][1] + s1_other_lo, s1_other_hi = cell.box[s1_other] + + for iso_pt in csx_r.get('isolated', []): + if not all(np.isfinite(iso_pt[key]) and 0. <= iso_pt[key] <= 1. + for key in ('t', 'u', 'v')): + continue + stuv = np.zeros(4, dtype=np.float64) + stuv[s2_axis] = cv + s2_other_lo2, s2_other_hi2 = cell.box[s2_other_global] + stuv[s2_other_global] = s2_other_lo2 + float(iso_pt['t']) * (s2_other_hi2 - s2_other_lo2) + if s1_local_axis == 0: + stuv[s1_axis] = s1_lo + float(iso_pt['u']) * (s1_hi - s1_lo) + stuv[s1_other] = s1_other_lo + float(iso_pt['v']) * (s1_other_hi - s1_other_lo) + else: + stuv[s1_other] = s1_other_lo + float(iso_pt['u']) * (s1_other_hi - s1_other_lo) + stuv[s1_axis] = s1_lo + float(iso_pt['v']) * (s1_hi - s1_lo) + + xyz = np.asarray(iso_pt['point'], dtype=np.float64) + stuv_local = _global_to_local(stuv, cell.box) + tang, _, _ = _ssx_tangent_4d( + cell.g1.surface, cell.g2.surface, + stuv_local[0], stuv_local[1], stuv_local[2], stuv_local[3], + rational=True) + bp = BoundaryPoint(stuv=stuv, xyz=xyz, face=(s2_axis, -1), tangent_raw=tang) + new_cx_grid[s1_idx][cut_idx].append(bp) + new_cx_grid[s1_idx][cut_idx + 1].append(bp) + if budget.exhausted: - queue.appendleft(cell) break - n1, n2 = len(s1_cuts)+1, len(s2_cuts)+1 - if n1*n2 > _available_queue_slots(): + + # 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() - queue.appendleft(cell) break - g1_pieces = _split_surface_multi(cell.g1, s1_axis, s1_cuts, cell.box) - g2_pieces = _split_surface_multi(cell.g2, s2_axis, s2_cuts, cell.box) - T_list = [cell.T1, cell.T2, cell.T3, cell.T4] - T_after_s1 = [_split_tensor_multi(T, s1_axis, s1_cuts, cell.box) for T in T_list] - T_pieces = [] - for i1 in range(n1): - row = [] - sub_box = list(cell.box) - sub_box[s1_axis] = (cell.box[s1_axis][0] if i1 == 0 else s1_cuts[i1-1], - s1_cuts[i1] if i1 < len(s1_cuts) else cell.box[s1_axis][1]) - for tensor_index in range(4): - row.append(_split_tensor_multi(T_after_s1[tensor_index][i1], - s2_axis, s2_cuts, tuple(sub_box))) - T_pieces.append(row) - - new_cx_grid = [[[] for _ in range(n2)] for _ in range(n1)] - s1_bounds = [cell.box[s1_axis][0], *s1_cuts, cell.box[s1_axis][1]] - s2_bounds = [cell.box[s2_axis][0], *s2_cuts, cell.box[s2_axis][1]] - first_strips,second_strips = [],[] - for axis,bounds,strips in ((s1_axis,s1_bounds,first_strips), - (s2_axis,s2_bounds,second_strips)): - for low,high in zip(bounds,bounds[1:]): - strip = list(cell.box) - strip[axis] = low,high - strips.append(tuple(strip)) - for cut_index, points in enumerate(s1_cut_points): - groups = _source_boundary_child_groups( - cell,points,second_strips,4.*dedup_tol) - for j,group in enumerate(groups): - new_cx_grid[cut_index][j].extend(group) - new_cx_grid[cut_index+1][j].extend(group) - for cut_index, points in enumerate(s2_cut_points): - groups = _source_boundary_child_groups( - cell,points,first_strips,4.*dedup_tol) - for i,group in enumerate(groups): - new_cx_grid[i][cut_index].extend(group) - new_cx_grid[i][cut_index+1].extend(group) - - child_boxes = [] - for i1 in range(n1): - for i2 in range(n2): - sub = list(cell.box) - sub[s1_axis] = s1_bounds[i1],s1_bounds[i1+1] - sub[s2_axis] = s2_bounds[i2],s2_bounds[i2+1] - child_boxes.append(tuple(sub)) - inherited_groups = _source_boundary_child_groups( - cell,cell.crossings,child_boxes,4.*dedup_tol) - child_obligations = _source_boundary_child_obligations( - cell,child_boxes,cut_obligations) - # 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] @@ -8715,9 +8324,10 @@ def _recover_source_roots(result, value): sub_box[s2_axis] = (s2_lo, s2_hi) sub_box = tuple(sub_box) - # Source enclosures cover every possible closed owner; - # display parameters cannot exclude the opposite child. - sub_inherited = inherited_groups[i1*n2+i2] + # Inherited crossings from parent — check ALL 4 axes + sub_inherited = [c for c in cell.crossings + if all(sub_box[ax][0]<= c.stuv[ax] <= sub_box[ax][1] + for ax in range(4))] # New crossings: deterministic from per-piece CSX grid. # Dedup against inherited crossings and against each other — @@ -8732,14 +8342,13 @@ def _recover_source_roots(result, value): # was merged into the domain-corner crossing). sub_new_raw = new_cx_grid[i1][i2] sub_new = [] - for position, nc in enumerate(sub_new_raw): - if root_identity.exhausted: - sub_new.extend(sub_new_raw[position:]) - break - if any(root_identity(nc, ec, dedup_tol, atol) + for nc in sub_new_raw: + if any(np.all(np.abs(nc.stuv - ec.stuv) <= dedup_tol) + and float(np.linalg.norm(nc.xyz - ec.xyz)) <= atol for ec in sub_inherited): continue - if any(root_identity(nc, dc, dedup_tol, atol) + if any(np.all(np.abs(nc.stuv - dc.stuv) <= dedup_tol) + and float(np.linalg.norm(nc.xyz - dc.xyz)) <= atol for dc in sub_new): continue sub_new.append(nc) @@ -8752,76 +8361,21 @@ def _recover_source_roots(result, value): T1=T_pieces[i1][0][i2], T2=T_pieces[i1][1][i2], T3=T_pieces[i1][2][i2], T4=T_pieces[i1][3][i2], 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, - regular_complement=cell.regular_complement, - boundary_complete=child_obligations[i1*n2+i2] == (), - boundary_obligations=child_obligations[i1*n2+i2], - root_matcher=root_identity, - straight_path=_source_straight_path, - path_representation=_source_path_representation, - arc_image=_source_arc_image, - arc_chord=_source_arc_chord, - source_root_exact=_source_root_exact, - source_singular_candidate=_source_singular_candidate, - source_singular_sink=_source_registered_singularity, - source_cofactors=source_cofactors, - unresolved_regions=unresolved_regions, ) scell.partitions = _build_cell_partitions(scell) for c in sub_cx: _classify_boundary_point(c, scell) - if source_regular: - # Finish an already certified regular owner's arc - # partition before growing an unresolved singular - # frontier. This changes scheduling only: every child - # and every unfinished singular owner stays queued. - queue.appendleft(scell) - else: - queue.append(scell) - - # Every stop after popping an owner returns it to the frontier until - # its complete child partition has been queued. Otherwise this dump - # would omit the active owner and misclassify its staged parent traces - # as replaced by completed descendants. - # 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 - - # Retain provisional parent traces only where replacement search is - # still unresolved. This is partition ownership, not geometric - # containment: completed descendants account for the whole parent - # domain, including branches that its failed march never reached. - # Output was charged on staging, so restoring a fallback spends no - # second allowance and remains possible after a work-budget stop. - pending_regions = list(unresolved_regions) + list(csx_unresolved_boxes.values()) - pending_boxes = [np.column_stack((region['stuv_min'], region['stuv_max'])) - for region in pending_regions - if 'stuv_min' in region and 'stuv_max' in region] - pending_sites = [np.asarray(site, dtype=float) - for _reason, site in budget.structural_sites] - unlocated_failure = bool(budget.incomplete and not pending_boxes and not pending_sites) - for box, fragments, points in provisional_traces: - bounds = np.asarray(box, dtype=float) - can_classify = (not budget.exhausted and not unlocated_failure - and _assembly_spend( - budget, 1+len(pending_boxes)+len(pending_sites))) - unresolved = (not can_classify - or any(np.all(region[:, 0] <= bounds[:, 1]) - and np.all(bounds[:, 0] <= region[:, 1]) - for region in pending_boxes) - or any(np.all(bounds[:, 0] <= site) - and np.all(site <= bounds[:, 1]) - for site in pending_sites)) - if unresolved: + queue.append(scell) + + if budget.incomplete: + # Search exhaustion must preserve useful geometry that was already + # found before its parent was handed over to smaller cells. These + # records were charged at discovery, so restoring them is not a new + # output allocation. Normal tolerant assembly removes retraced parts. + for fragments, points in provisional_trace_batches: all_fragments.extend(fragments) all_points.extend(points) @@ -8834,7 +8388,6 @@ def _recover_source_roots(result, value): S1_for_close, S2_for_close, rational_close = S1, S2, True else: S1_for_close, S2_for_close, rational_close = S1, S2, False - root_identity.charge = lambda amount: _assembly_spend(budget, amount) all_branches = _assemble_fragments( all_fragments, S1_full=S1_for_close, S2_full=S2_for_close, @@ -8842,85 +8395,23 @@ def _recover_source_roots(result, value): 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, root_matcher=root_identity, + 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. Keep the geometry already assembled; optional cleanup - # exhaustion cannot revoke completed discovery or erase its branches. + # A cleanup limit must not erase geometry already found and assembled. + # Return that geometry with the budget status when cleanup cannot run. if not _assembly_spend(budget): return _result(branches=all_branches, points=all_points, - singularities=all_singularities, - unresolved_regions=unresolved_regions) - - # A boundary retrace needs actual source-boundary ownership on both - # operands. Matching approximation paths alone cannot identify arcs. - def _same_boundary_retrace(branch, overlap): - from mmcore.numeric.intersection.ssx._ssx_boundary_identity import ( - certified_boundary_retrace) - stuv = np.asarray(branch.curve[0]) - keeper = np.asarray(overlap.curve[0]) - if not len(stuv) or not len(keeper): - return False - first_owner = getattr(branch,'_source_boundary_face',None) - second_owner = getattr(overlap,'_source_boundary_face',None) - if first_owner is None or first_owner != second_owner: - return False - for axis in range(4): - side = float(keeper[0, axis]) - if side not in (0.0, 1.0) or first_owner != (axis,int(side)): - continue - if not (np.all(keeper[:, axis] == side) - and np.all(stuv[:, axis] == side)): - continue - owner = axis//2 - source, target = (source_identity_surfaces[owner], - source_identity_surfaces[1-owner]) - if not rational: - source = np.concatenate([ - source, np.ones(source.shape[:-1]+(1,))], axis=-1) - target = np.concatenate([ - target, np.ones(target.shape[:-1]+(1,))], axis=-1) - index = 0 if side == 0.0 else -1 - curve = (source[index, :, :] if axis%2 == 0 - else source[:, index, :]) - relation = certified_boundary_retrace( - stuv, np.asarray(branch.curve[1]), keeper, - np.asarray(overlap.curve[1]), curve, target, - 2*owner+1-axis%2, (2*(1-owner), 2*(1-owner)+1), - (0.0, 1.0), unify_tol, atol, - charge=lambda n: _assembly_spend(budget, n)) - if relation is True or relation is None: - return relation - return False + singularities=all_singularities) + # Boundary registrations may trace several adjoining overlap edges in + # one path. Compare continuously against their paired segment union; + # testing each keeper separately leaves that complete retrace visible. if overlap_branches and len(all_branches) > len(overlap_branches): - kept = [] - for branch in all_branches: - if branch.kind == "overlap" or getattr(branch,'_source_boundary_face',None) is None: - kept.append(branch) - continue - contained = False - for overlap in overlap_branches: - # Source boundary identity plus a proved unique target - # inverse owns this curve; an approximate chord does not. - relation = _same_boundary_retrace(branch, overlap) - if relation is True: - overlap._registered_root_ids = ( - getattr(overlap, '_registered_root_ids', frozenset()) - | getattr(branch, '_registered_root_ids', frozenset())) - overlap._registered_root_points = ( - getattr(overlap, '_registered_root_points', {}) - | getattr(branch, '_registered_root_points', {})) - contained = True - break - if relation is None: - break - if not contained: - kept.append(branch) - all_branches = kept + all_branches = _drop_traces_covered_by_overlap_union( + all_branches, overlap_branches, atol, unify_tol, budget, + surfaces=(S1_h_top, S2_h_top)) # --- Overlap-curve JUNCTION singularities --- # Two 1-dimensional overlap/tangential features meeting at a point are @@ -8994,7 +8485,7 @@ def _sin_ang_inward(b, at_start, d_iso): bc, pc, sc4, dc, kc, brc, stc = _ends[c] if ba == bc: continue - if not np.array_equal(sa4, sc4): + if float(np.linalg.norm(pa - pc)) > 2.0 * atol: continue # non-collinear meeting (collinear = artificial split) if float(np.linalg.norm(np.cross(da, dc))) < 0.1: @@ -9027,7 +8518,8 @@ def _sin_ang_inward(b, at_start, d_iso): or _sin_ang_inward(brc, stc, d_iso_c) <= 1e-3): continue if not any(g.kind == "tangent_point" - and np.array_equal(g.stuv, sa4) + 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): budget.append_output(all_singularities, SSXSingularity( kind="tangent_point", stuv=np.asarray(sa4, dtype=np.float64), @@ -9036,221 +8528,185 @@ def _sin_ang_inward(b, at_start, d_iso): if _junction_scan_denied: break - # A tangent point can be a separate source component even when it - # lies on an approximate tangent-curve chord. Retain it unless actual - # source singular-stratum ownership is available. - - # A constant approximation polyline can represent a small source - # component. Its equality to a tangent-point sample cannot delete it. - - # --- C1 pass (paper Fig. 5): parameterization cusps ON the SSI --- - # Cusps are properties of a surface's parameterization (Sigma_i = 0), - # not of the marching — the 4D curve is REGULAR through a C1 point - # (T3=T4=0 but T1,T2 != 0 there), so the branches above already walk - # through it; this pass only locates and types the cusp. Runs AFTER the - # branch filters so branch_links index the FINAL branch list (the two - # filters above drop branches; everything below only touches points). - # Regular surfaces exit via c1_pass's Sigma-hull precheck at the cost - # of six min/max scans — zero measurable time on all coverage cases. - from mmcore.numeric.intersection.ssx._ssx5_singular import c1_pass - - ptol4_global = np.maximum(np.array( - [float(_gp_s), float(_gp_t), float(_gp_u), float(_gp_v)]), 1e-9) - 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)) - budget.append_output(all_singularities, SSXSingularity( - kind="cusp_curve", stuv=anchor, xyz=xyz_anchor, - samples=samples, surface=hit.get("surface")), "singularity") - continue - links = [] - _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")), "singularity") - - # --- C3 pass (paper §5.4): 3D self-intersections of the SSI image --- - # Runs AFTER tracing (branch geometry drives the candidate search). - # Ledger L8: the old trigger — "some traced cell failed the per-cell - # Theorem-3 certificate" — was semantically wrong: Theorem 3 certifies - # only that ONE box's image is injective; a C3 whose two preimages lie - # in DIFFERENT traced cells (figure-eight wall: s≈0.08 and s≈0.92 - # strips, each cell truthfully certified) passes every per-cell check - # and the pass never ran. Run it whenever a collision is possible at - # all: >= 2 branches (cross-branch), or a branch long enough to reach - # its own segments past the broadphase index gap >= 3 (>= 8 segments, - # within-branch). The vectorized AABB broadphase keeps the fired path - # free on regular geometry (measured: 0 candidate pairs on coverage - # 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). - source_chart_injective = [any( - (first_coordinate, 0) in chart and (second_coordinate, 1) in chart - for first_coordinate in range(3) for second_coordinate in range(3) - if first_coordinate != second_coordinate) - for chart in root_identity.affine] - c3_candidate = bool(all_branches and (len(all_branches) >= 2 or any( - len(np.asarray(b.curve[1])) >= 9 for b in all_branches))) - if not all(source_chart_injective) and c3_candidate and not budget.exhausted: - from mmcore.numeric.intersection.ssx._ssx5_overlap import _exact_surface_injective - for index, source in enumerate(identity_sources_h): - if source_chart_injective[index]: - continue - # The exact derivative-hull proof currently supports positive - # uniform weights. Reject unsupported charts before Fraction - # conversion, and prepay all four candidate left projections. - weights = source[...,3] - if (min(source.shape[:2]) < 2 or not np.all(np.isfinite(source)) - or weights.flat[0] <= 0. or not np.all(weights == weights.flat[0])): + # A tangent_point ON a 1-dimensional tangential feature is not an + # isolated C2 touch: overlap regions and traced tangent curves (branch + # kind 'overlap'/'tangential') consist entirely of Δ-roots, so the + # witness on a cell holding one converges to an arbitrary sample of the + # curve (measured: the legacy overlaps case emitted its domain corner; + # the crossed-saddles center witness lands on the tangent curve). The + # richer feature already reports the contact — drop the redundant point + # (Task 5 will type tangent curves explicitly). "ON a branch" is the + # BOTH-GUARDS test (ledger L3, _point_on_branch_both_guards): xyz + # point-to-segment ≤ 4·atol — same tolerance as the points-on-branch + # filter below; the polyline is a chorded approximation, so points ON + # the true curve sit up to the 2·atol sagitta off it (measured max + # 1.9e-3 = 1.9·atol) — AND per-axis stuv ≤ 2·unify_tol against the + # branch's stuv interpolated at the same segment location. xyz-only + # subsumption deleted a certified touch on a DIFFERENT sheet 3·atol + # from an overlap isoline (Δu = 0.8 in parameters, 640·atol z-wall + # between). Genuine on-curve witnesses lie on the branch in stuv too + # (they and the samples are ~ptol-accurate) and keep subsuming; a + # branch segment whose stored stuv fails its own xyz self-check (the + # known-corrupt legacy overlap bookkeeping) cannot subsume a point — + # see _point_on_branch_both_guards. + # Tangent points coexisting with TRANSVERSAL branches (the saddle X) + # are not affected — this tests tangential/overlap branches only. + # Runs FIRST so the micro-branch and near-touch point filters below see + # only genuinely isolated touches. Micro-scale tangential polylines + # (arc ≤ 16·atol — the micro-branch filter's cap, computed identically) + # are EXCLUDED from the subsuming set: a Ψ-valid Φ-micro-fragment AT a + # touch would otherwise eat the typed point here, drop it from + # _tangent_xyz, and thereby shield ITSELF from the micro-branch filter + # (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 + and _assembly_spend(budget, max(1, len(all_branches)))): + _one_dim_polys = [] + for b in all_branches: + if b.kind not in ("overlap", "tangential"): continue - m,n = source.shape[:2] - derivative_coefficients = (m-1)*n+m*(n-1) - units = (12*m*n+96*derivative_coefficients+512+127)//128 - # This is an optional sufficient certificate. If it cannot - # fit, retain the ordinary bounded C3 search's allowance. - if units > budget.remaining_cells: + _poly = np.asarray(b.curve[1], dtype=np.float64) + if len(_poly) < 2: continue - if not budget.charge_cells(units, 'global_injectivity'): - break - try: - source_chart_injective[index] = _exact_surface_injective(source) - except (OverflowError, FloatingPointError): - # A finite homogeneous chart can have unrepresentable - # Cartesian derivatives; that numerical projection is - # unsupported, not an injectivity or exclusion proof. - pass - globally_injective_sources = all(source_chart_injective) - # Two independently affine world coordinates recover both chart - # parameters exactly. If both ORIGINAL charts have this proof, two - # different lifted roots cannot share an XYZ image anywhere, so the - # self-intersection search has an empty candidate set by construction. - # The exact constant-projection derivative certificate above proves the - # same property for additional globally injective polynomial charts. - if (not globally_injective_sources and not budget.exhausted and all_branches - and c3_candidate): - from mmcore.numeric.intersection.ssx._ssx5_singular import c3_pass + _arc = float(np.linalg.norm(np.diff(_poly, axis=0), axis=1).sum()) + if _arc > 16.0 * atol: + _one_dim_polys.append( + (_poly, np.asarray(b.curve[0], dtype=np.float64))) + _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 + # branch is a structural feature — two shared-edge overlap + # curves meeting at a genuine surface-surface tangency + # (sin_ang = 0 measured) — not an interior re-confirmation + # of the 1-dim feature. Subsume only points whose nearest + # branch location is in the polyline INTERIOR (farther + # than 2·atol from both branch ends). + gxyz = np.asarray(g.xyz, dtype=np.float64) + gstuv = np.asarray(g.stuv, dtype=np.float64) + for poly_xyz, poly_stuv in _one_dim_polys: + if not _point_on_branch_both_guards( + gxyz, gstuv, poly_xyz, poly_stuv, atol, + unify_tol, S1_h_top, S2_h_top): + continue + # The endpoint exception applies to genuinely OPEN + # ends only: a CLOSED branch's start/end is an + # assembly seam — interior in curve terms — and + # witness debris near the seam must still be + # subsumed (tangent-ring regression). + branch_open = (float(np.linalg.norm( + poly_xyz[0] - poly_xyz[-1])) > 2.0 * atol) + near_end = branch_open and ( + float(np.linalg.norm(gxyz - poly_xyz[0])) <= 2.0 * atol + or float(np.linalg.norm(gxyz - poly_xyz[-1])) <= 2.0 * atol) + if not near_end: + return True + return False - _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"]), "singularity") - if _c3_stats.get("incomplete", False): - budget.mark_incomplete(REASON_WORK_BUDGET) + all_singularities = [ + g for g in all_singularities + if not (g.kind == "tangent_point" and _interior_subsumed(g)) + ] + + # Spurious micro-branches at emitted tangent points: subdividing around + # a touch (the size-gated tangency arm above) re-exposes the old + # pathology — CSX grazing-valley roots near the touch yield ~2·atol + # micro-fragments, and crossing-bearing descendant tangent cells can add + # Ψ-valid Φ-fragments there. Drop a branch only when EVERY polyline + # vertex lies within 4·atol (xyz) of some emitted tangent point AND its + # total xyz arc length is ≤ 16·atol — the length cap is a safety net so + # nothing long can ever be eaten (the Mexican-hat ring's vertices sit + # ~0.35 from the touch; saddle arms extend far beyond 4·atol). The + # 16·atol constant is shared with the subsumption filter's floor above: + # tangential/overlap polylines AT or BELOW it cannot subsume a + # tangent_point there, so they are still deletable here. + _tangent_xyz = [g.xyz for g in all_singularities if g.kind == "tangent_point"] + if _tangent_xyz and all_branches: + _tp = np.asarray(_tangent_xyz, dtype=np.float64) # (K, 3) + _kept_branches = [] + _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)): + _kept_branches = all_branches + 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 + + _link_c1_and_discover_c3( + S1_h_top, S2_h_top, all_branches, all_singularities, + c1_singularities, atol, ptol4_global, budget) + + # A reported point within 2·atol (xyz) of an emitted tangent_point is + # not a separate intersection — it is the certified tangency itself, + # re-found by CSX grazing-valley seeds while subdividing around the + # touch (measured on the paraboloid/Mexican-hat cases: 4 seeds at the + # touch + 4 at ±1·atol on the grazing valley). Subsume them into the + # 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 + 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 + if float(np.linalg.norm( + _tp_pts - np.asarray(p.xyz, dtype=np.float64)[None, :], + axis=1).min()) > 2.0 * atol + ] - # A closed-cell corner can appear as point output while the adjacent - # cell traces its registered arc. The actual shared root object proves - # incidence even if the branch later resamples that endpoint. - all_points = _remove_source_incident_points( - all_points, all_branches, unify_tol, 4.*atol, - root_identity, work_budget=budget) - - # Isolated output is coalesced only at the same lifted parameter root. - # Coincident images can have distinct surface preimages, even when all - # four parameters are closer than the modeling tolerance. - all_points = _remove_source_classified_points( - all_points, all_singularities, root_identity, unify_tol, 2.*atol, budget) - - # Several closed cells can own the same isolated parameter root. - # Coalesce exact lifted duplicates; keep nearby unresolved preimages. + # 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. + _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) + on_branch = False + for b in all_branches: + poly = np.asarray(b.curve[1]) + if len(poly) < 2: + continue + if _point_on_branch_both_guards( + pxyz, np.asarray(p.stuv), poly, + np.asarray(b.curve[0]), atol, unify_tol, + S1_h_top, S2_h_top): + on_branch = True + break + if not on_branch: + kept_points.append(p) + all_points = kept_points + + # The same isolated point can be reported by several cells/arms (e.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 and _assembly_spend( budget, _point_dedup_charge(len(all_points)))): - all_points = _deduplicate_ssx_points( - all_points, unify_tol, atol, exact_topology=True,root_matcher=root_identity) + all_points = _deduplicate_ssx_points(all_points, unify_tol, atol) # ------------------------------------------------------------------ # L28: 2-D overlap regions (approved Option C, review doc §8). @@ -9267,22 +8723,16 @@ def _sin_ang_inward(b, at_start, d_iso): 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"] - if rational: - identity_surfaces = source_identity_surfaces - else: - identity_surfaces = tuple( - np.concatenate([surface, np.ones(surface.shape[:-1]+(1,))], - axis=-1) - for surface in source_identity_surfaces) asm = assemble_overlap_regions( S1_h_top, S2_h_top, atol=atol, ptol4=dedup_tol, - identity_surfaces=identity_surfaces, existing_overlap_branches=_ovl, + existing_intersection_branches=_non_ovl, uncertified_spans=uncertified_overlap_spans, overlap_boxes=overlap_boxes, charge=lambda n: _assembly_spend(budget, n, "overlap_region"), ) if asm["regions"]: + _non_ovl = asm["unmatched_intersection_branches"] base = len(_non_ovl) for reg in asm["regions"]: reg.boundary = [[(base + k, rev) for (k, rev) in loop] @@ -9292,11 +8742,43 @@ def _sin_ang_inward(b, at_start, d_iso): overlap_regions = asm["regions"] if asm["covered"] and not budget.exhausted: budget.retire_reason(REASON_OVERLAP_REGION) - # Independent membership in the two projected UV loops - # does not establish the paired root's ownership, and a - # recorded point does not cover an unresolved root box. - # Keep multiplicity obligations until their full lifted - # domain has an exhaustive source certificate. + if asm["planar_pair_covered"]: + # The convex planar chart intersection represents the + # whole pair. Numerically valid interior traces and + # tangency samples belong to this represented region. + budget.retire_reason(REASON_TANGENTIAL_ZONE) + # 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) + overlap_regions) diff --git a/mmcore/numeric/intersection/ssx/_nssx5.py b/mmcore/numeric/intersection/ssx/_nssx5.py index 122759cb..9091b364 100644 --- a/mmcore/numeric/intersection/ssx/_nssx5.py +++ b/mmcore/numeric/intersection/ssx/_nssx5.py @@ -13,10 +13,10 @@ nurbs_ssx(surf1, surf2, atol=1e-3, **expert_knobs) -> dict -with the exact ``bez_ssx`` result schema: ``branches`` (SSXBranch with +with the same ``bez_ssx`` result schema: ``branches`` (SSXBranch with ``curve=(stuv (N,4), xyz (N,3))`` polylines; ``curve_xyz/st/uv`` stay None), ``points``, ``singularities``, ``overlap_regions``, -``unresolved_regions``, ``complete``, ``status={'reasons','work'}``. +``complete``, ``status={'reasons','work'}``. ``stuv=(s,t,u,v)``: (s,t) in surf1.interval(), (u,v) in surf2.interval(). Known representation consequence (spec delta 3): an SSI curve lying @@ -24,16 +24,17 @@ boundary CSX as a curve-on-surface overlap and therefore carries ``kind='overlap'`` even when the surfaces cross transversally there. -Unified overlap regions guarantee loop ORDERING (outer first) but not -winding direction on the multi-tile path (see ``_assemble_regions``). -Assembly preserves every unresolved reason reported by the pair solvers: -two UV footprints do not establish exhaustive lifted correspondence. +Two further contract notes. Unified overlap regions guarantee loop +ORDERING (outer first) but not winding direction on the multi-tile path +(see ``_assemble_regions``). And ``complete`` may be True even though a +pair reported ``unresolved_multiplicity`` mid-search: the reason is +retired — mirroring the engine's own L28 rule — only when a certified +unified region explains every such pair's whole parameter rect in both +planes (see ``_retire_multiplicity_if_region_explained``). -Incompleteness is always soft: certified partial output is returned with +Work-limit stops retain the geometry found so far, returned with ``complete=False`` and typed ``status['reasons']``; exceptions are for -caller errors only. Source control coordinates must be finite and weights -must be finite and strictly positive; unsupported data raises ``ValueError`` -before decomposition or control-hull exclusions. +caller errors only. """ from __future__ import annotations @@ -55,7 +56,6 @@ REASON_WORK_BUDGET, REASON_POSTPROCESS_CAP, REASON_MULTIPLICITY, - REASON_PARAMETER_REPRESENTATION, ) from mmcore.numeric.intersection.ssx._bez_ssx5 import ( bez_ssx, SSXSingularity, _dist_point_polyline, @@ -108,29 +108,26 @@ def _as_surface_tuple(surf) -> NURBSSurfaceTuple: def _is_rational(surf: NURBSSurfaceTuple) -> bool: - # Weights are representation data, not measured geometry. Even an - # arbitrarily small nonuniform perturbation can split a double root. + # Modeling tolerance must not change the surface being evaluated. return not bool(np.all(np.asarray(surf.weights) == 1.0)) def _axis_closed(surf: NURBSSurfaceTuple, axis: int) -> bool: - """Sufficient exact C0 seam test for a clamped parameter axis. + """Recognize a structural C0 seam from clamped boundary controls. - Equal end control rows describe equal boundary curves only when the - end knots are clamped. An unsupported unclamped seam stays separate. + Equal end control rows are actual boundary curves only for a clamped + knot axis. A narrow open patch must not acquire a periodic parameter + domain from a fixed world-space closeness threshold. """ order = surf.order_u if axis == 0 else surf.order_v knots = np.asarray(surf.knot_u if axis == 0 else surf.knot_v) low, high = surf.interval()[axis] - if not (np.all(knots[:order] == low) and - np.all(knots[-order:] == high)): + if not (np.all(knots[:order] == low) and np.all(knots[-order:] == high)): return False cp, w = surf.control_points, surf.weights if axis == 0: - return bool(np.array_equal(cp[0], cp[-1]) - and np.array_equal(w[0], w[-1])) - return bool(np.array_equal(cp[:, 0], cp[:, -1]) - and np.array_equal(w[:, 0], w[:, -1])) + return bool(np.array_equal(cp[0], cp[-1]) and np.array_equal(w[0], w[-1])) + return bool(np.array_equal(cp[:, 0], cp[:, -1]) and np.array_equal(w[:, 0], w[:, -1])) @dataclass @@ -143,7 +140,6 @@ class _DomainCtx: spans: NDArray[np.float64] ptol: NDArray[np.float64] closed: tuple - source_patches: object = None def _domain_ctx(s1: NURBSSurfaceTuple, s2: NURBSSurfaceTuple, @@ -174,13 +170,6 @@ def _axis_diff_nowrap(a: float, b: float) -> float: return abs(float(a) - float(b)) -def _same_axis_preimage(a,b,axis,ctx): - """Exact coordinate identity, including only actual opposite seam ends.""" - return bool(a == b or (ctx.closed[axis] and ( - (a == ctx.lows[axis] and b == ctx.highs[axis]) - or (b == ctx.lows[axis] and a == ctx.highs[axis])))) - - def _match_stuv(p, q, xyz_p, xyz_q, ctx: _DomainCtx, atol: float) -> bool: """Matching/unification predicate (tolerance ladder): per-axis 4·ptol AND xyz <= 2·atol. Wrap-aware.""" @@ -193,17 +182,13 @@ def _match_stuv(p, q, xyz_p, xyz_q, ctx: _DomainCtx, atol: float) -> bool: def _dup_stuv(p, q, xyz_p, xyz_q, ctx: _DomainCtx, atol: float) -> bool: - """Identical paired preimages, modulo proven seams, with an xyz guard. - - Modeling tolerance bounds approximation error, not separation of - distinct isolated roots. In the absence of a shared root certificate, - retain numerically nearby preimages as distinct output points. - """ + """Destructive-dedup predicate: per-axis 1·ptol AND xyz <= atol. + Wrap-aware. Every destructive test carries the xyz guard.""" d = np.asarray(xyz_p, dtype=np.float64) - np.asarray( xyz_q, dtype=np.float64) if float(np.linalg.norm(d)) > atol: return False - return all(_same_axis_preimage(p[i], q[i], i, ctx) + return all(_axis_diff(p[i], q[i], i, ctx) <= float(ctx.ptol[i]) for i in range(4)) @@ -233,27 +218,12 @@ def _pair_rect(p1: NURBSSurfaceTuple, p2: NURBSSurfaceTuple): def _remap4(stuv_local, rect): """Affine per-axis map of (4,) or (N,4) local stuv into global params.""" - from mmcore.numeric.intersection._parameter_mapping import affine_parameters x = np.array(stuv_local, dtype=np.float64, copy=True) - bounds = np.asarray(rect).reshape(4, 2) - if np.all(bounds[:, 0] == 0.) and np.all(bounds[:, 1] == 1.): - return x - return np.array([affine_parameters(row, bounds)[0] - for row in x.reshape(-1, 4)]).reshape(x.shape) - - -def _remap4_bound(stuv_local, rect, lower): - """Outward affine mapping for diagnostic enclosures, not representatives.""" - from fractions import Fraction - from mmcore.numeric.intersection._parameter_mapping import affine_parameters - mapped, exact = affine_parameters(stuv_local, np.asarray(rect).reshape(4, 2)) - result = [] - for value, target in zip(mapped, exact): - rounded = Fraction.from_float(value) - if (lower and rounded > target) or (not lower and rounded < target): - value = np.nextafter(value, -np.inf if lower else np.inf) - result.append(float(value)) - return tuple(result) + s0, s1, t0, t1, u0, u1, v0, v1 = rect + low = np.array([s0, t0, u0, v0], dtype=np.float64) + scale = np.array([s1 - s0, t1 - t0, u1 - u0, v1 - v0], + dtype=np.float64) + return low + x * scale # --------------------------------------------------------------------------- @@ -319,6 +289,13 @@ def consume(self, result: dict) -> None: def mark(self, reason: str) -> None: self._add(reason) + def retire(self, reason: str) -> None: + """Remove a structural reason RESOLVED by later assembly (the + engine's own ``retire_reason`` pattern — e.g. L28 multiplicity + sites explained by a certified unified overlap region).""" + if reason in self.reasons: + self.reasons.remove(reason) + def charge_postprocess(self, amount: int = 1) -> bool: ok = self.post.charge_postprocess(amount) if not ok: @@ -431,12 +408,6 @@ class _Frag: xyz: NDArray[np.float64] # (N,3) kind: str overlap: bool - pair: object = None - rect: object = None - endpoint_ids: object = None - source_path: object = None - source_boundary_face: object = None - source_endpoints: object = None @dataclass @@ -461,124 +432,14 @@ class _RawResults: tiles: list = field(default_factory=list) # list[_Tile] points: list = field(default_factory=list) # list[SSXPoint], global singularities: list = field(default_factory=list) - unresolved: list = field(default_factory=list) # global rects of pairs whose status carried unresolved_multiplicity - # retained as diagnostics; projected footprints cannot retire ambiguity. + # (sound SUPERSET of the pair's unexposed ambiguity sites) — input to + # the wrapper-level L28 retirement in _assemble_regions. mult_rects: list = field(default_factory=list) - # Delay routing checked pairs until all cross-pair parameter aliases - # are known; otherwise withdrawing one tile would invalidate rim IDs. - staged_pairs: dict = field(default_factory=dict) - parameter_identities: dict = field(default_factory=dict) - - -def _pair_parameter_samples(result): - for index, branch in enumerate(result.get('branches', []) or []): - for vertex, (stuv, xyz) in enumerate(zip(*branch.curve)): - yield ('branch', index, vertex), stuv, xyz - for index, point in enumerate(result.get('points', []) or []): - yield ('point', index), point.stuv, point.xyz - for index, singularity in enumerate(result.get('singularities', []) or []): - yield ('singularity', index), singularity.stuv, singularity.xyz - if singularity.stuv_mate is not None: - yield ('singularity_mate', index), singularity.stuv_mate, singularity.xyz - if singularity.samples is not None: - for sample, stuv in enumerate(np.asarray(singularity.samples).reshape(-1, 4)): - yield ('singularity_sample', index, sample), stuv, None - for index, region in enumerate(result.get('overlap_regions', []) or []): - if region.interior_stuv is not None: - yield ('region_interior', index), region.interior_stuv, None - - -def _stage_representable_pair(raw, result, rect, pair, sources, rational, atol, agg): - from fractions import Fraction - from mmcore.numeric.intersection._parameter_mapping import ( - affine_parameters, map_isolated, mapping_issue, - reject_parameter_aliases, _exact_evaluate, - ) - bounds = tuple(map(tuple, np.asarray(rect).reshape(4, 2))) - context = f'nurbs_ssx patches {pair}' - status = {'complete': True, 'boundary_topology_complete': True, 'partial_results': 0} - record = {'result': result, 'rect': rect, 'issues': []} - raw.staged_pairs[pair] = record - entries = [] - evaluation_cost = max(1, (3*sum(np.asarray(net).size for net in sources)+127)//128) - for entity, stuv, xyz in _pair_parameter_samples(result): - if not agg.charge_postprocess(1): - record['reason'] = REASON_POSTPROCESS_CAP - record['issues'].append({'reason': 'parameter mapping budget exhausted'}) - break - global_float, global_exact = affine_parameters(stuv, bounds) - changed = any(Fraction.from_float(value) != exact - for value, exact in zip(global_float, global_exact)) - if changed: - if not agg.charge_postprocess(evaluation_cost): - record['reason'] = REASON_POSTPROCESS_CAP - record['issues'].append({'reason': 'parameter source-evaluation budget exhausted'}) - break - if xyz is None: - try: - local = tuple(Fraction.from_float(float(t)) for t in stuv[:2]) - xyz = tuple(float(x) for x in _exact_evaluate(sources[0], local, rational)) - except (OverflowError, ValueError, ZeroDivisionError): - mapping_issue(status, context, True, { - 'entity': entity, 'local_parameters': tuple(stuv), - 'parameter_bounds': bounds, - 'exact_global_parameters': tuple(str(t) for t in global_exact), - }, 'source evaluation cannot represent the local anchor') - continue - entry = dict(zip(('s', 't', 'u', 'v'), stuv), entity=entity) - if xyz is not None: - entry['point'] = xyz - mapped = map_isolated( - entry, ('s', 't', 'u', 'v'), bounds, - ((sources[0], (0, 1)), (sources[1], (2, 3))), - rational, atol, status, context, True) - if mapped is not None: - entries.append(mapped) - reject_parameter_aliases(entries, ('s', 't', 'u', 'v'), status, context, True) - record['issues'].extend(status.get('unrepresentable_parameters', [])) - parameter_issue = bool(status['partial_results']) - for entry in entries: - key = tuple(entry[k] for k in ('s', 't', 'u', 'v')) - exact, payload = entry['_exact_global_parameters'], entry['_local_parameter_payload'] - previous = raw.parameter_identities.get(key) - if previous is not None and previous[0] != exact: - issue = {'reason': 'distinct local solutions share global float parameters', - 'local_solutions': [previous[2], payload]} - record['issues'].append(issue) - raw.staged_pairs[previous[1]]['issues'].append(issue) - parameter_issue = True - else: - raw.parameter_identities[key] = (exact, pair, payload) - if parameter_issue: - agg.mark(REASON_PARAMETER_REPRESENTATION) - - -def _finish_collecting(raw): - """Route whole valid pairs; retain local geometry for every rejected pair.""" - for pair, record in raw.staged_pairs.items(): - result, rect = record['result'], record['rect'] - if record['issues']: - raw.unresolved.append({ - 'stuv_min': tuple(rect[::2]), 'stuv_max': tuple(rect[1::2]), - 'reason': record.get('reason', REASON_PARAMETER_REPRESENTATION), - 'pair': pair, 'local_result': result, 'parameter_bounds': rect, - 'mapping_issues': record['issues'], - }) - else: - _collect_pair(raw, result, rect, pair) - raw.staged_pairs.clear() - raw.parameter_identities.clear() -def _collect_pair(raw: _RawResults, result: dict, rect, pair, *, - sources=None, rational=False, atol=None, agg=None) -> None: +def _collect_pair(raw: _RawResults, result: dict, rect, pair) -> None: """Remap one bez_ssx result into global params and route entities.""" - if sources is not None: - _stage_representable_pair(raw, result, rect, pair, sources, rational, atol, agg) - return - from mmcore.numeric.intersection.ssx._ssx_arc_ownership import map_source_path - bounds = np.asarray(rect).reshape(4,2) rim_local = set() for region in result.get('overlap_regions', []) or []: for loop in region.boundary: @@ -593,10 +454,7 @@ def _collect_pair(raw: _RawResults, result: dict, rect, pair, *, xyz = np.array(b.curve[1], dtype=np.float64, copy=True) rim_map[idx] = len(raw.rim_frags) raw.rim_frags.append( - _Frag(stuv=stuv_g, xyz=xyz, kind='overlap', overlap=True, - pair=pair,rect=rect, - source_path=map_source_path(getattr(b,'_source_parameter_path',None),bounds), - source_boundary_face=getattr(b,'_source_boundary_face',None))) + _Frag(stuv=stuv_g, xyz=xyz, kind='overlap', overlap=True)) for idx, b in enumerate(branches): if idx in rim_local: @@ -604,11 +462,7 @@ def _collect_pair(raw: _RawResults, result: dict, rect, pair, *, stuv_g = _remap4(np.asarray(b.curve[0], dtype=np.float64), rect) xyz = np.array(b.curve[1], dtype=np.float64, copy=True) raw.frags.append(_Frag(stuv=stuv_g, xyz=xyz, - kind=str(b.kind), overlap=bool(b.overlap), - pair=pair, rect=rect, - source_path=map_source_path(getattr(b,'_source_parameter_path',None),bounds), - source_boundary_face=getattr(b,'_source_boundary_face',None), - source_endpoints=getattr(b,'_registered_endpoints',None))) + kind=str(b.kind), overlap=bool(b.overlap))) for region in result.get('overlap_regions', []) or []: loops = [[(rim_map[int(idx)], bool(rev)) for idx, rev in loop] @@ -623,13 +477,9 @@ def _collect_pair(raw: _RawResults, result: dict, rect, pair, *, certification=dict(region.certification))) for p in result.get('points', []) or []: - mapped = SSXPoint( + raw.points.append(SSXPoint( stuv=_remap4(np.asarray(p.stuv, dtype=np.float64), rect), - xyz=np.array(p.xyz, dtype=np.float64, copy=True)) - root = getattr(p,'_registered_root',None) - if root is not None: - mapped._source_point_owner = (pair,root) - raw.points.append(mapped) + xyz=np.array(p.xyz, dtype=np.float64, copy=True))) for s in result.get('singularities', []) or []: raw.singularities.append(SSXSingularity( @@ -643,34 +493,6 @@ def _collect_pair(raw: _RawResults, result: dict, rect, pair, *, np.asarray(s.samples, dtype=np.float64), rect)), surface=s.surface)) - for entry in result.get('unresolved_regions', []) or []: - mapped = dict(entry) - if 'candidate' in mapped: - from mmcore.numeric.intersection._parameter_mapping import affine_parameters - # An unproved source-root proposal is diagnostic data. Keep its - # local coordinates explicit and preserve the exact affine map; - # it must not pass through the published-root representation gate. - candidate = tuple(mapped.pop('candidate')) - bounds = tuple(map(tuple, np.asarray(rect).reshape(4, 2))) - mapped['local_candidate'] = candidate - mapped['candidate_parameter_bounds'] = bounds - _, exact = affine_parameters(candidate, bounds) - mapped['exact_global_candidate'] = tuple(str(value) for value in exact) - if 'stuv_min' in mapped: - mapped['stuv_min'] = _remap4_bound(mapped['stuv_min'], rect, lower=True) - if 'stuv_max' in mapped: - mapped['stuv_max'] = _remap4_bound(mapped['stuv_max'], rect, lower=False) - raw.unresolved.append(mapped) - - -def _skip_box(p1: NURBSSurfaceTuple, p2: NURBSSurfaceTuple) -> dict: - (s0, s1), (t0, t1) = p1.interval() - (u0, u1), (v0, v1) = p2.interval() - return {'stuv_min': (float(s0), float(t0), float(u0), float(v0)), - 'stuv_max': (float(s1), float(t1), float(u1), float(v1)), - 'reason': REASON_WORK_BUDGET} - - # --------------------------------------------------------------------------- # Assembly stage stubs (Tasks 3-5 replace these) # --------------------------------------------------------------------------- @@ -694,111 +516,68 @@ def _bbox_overlap(xyz_a, xyz_b, pad: float) -> bool: def _seam_segment_mask(stuv, xyz, ctx, atol): - """Exclude explicit periodic vertex pairs from linear interpolation.""" - s = np.asarray(stuv, dtype=float) - x = np.asarray(xyz, dtype=float) + """Periodic vertex pairs are separate preimages, not linear shortcuts.""" + s, x = np.asarray(stuv), np.asarray(xyz) mask = np.ones(max(0, len(s) - 1), dtype=bool) - if ctx is None: - return mask for i in range(len(mask)): - wraps = any(ctx.closed[a] and ctx.spans[a] > 0.0 - and abs(s[i + 1, a] - s[i, a]) > .5 * ctx.spans[a] + wraps = any(ctx.closed[a] and ctx.spans[a] > 0. + and abs(s[i + 1, a] - s[i, a]) > .5*ctx.spans[a] for a in range(4)) if wraps and _match_stuv(s[i], s[i + 1], x[i], x[i + 1], ctx, atol): mask[i] = False return mask -def _certified_boundary_retrace(frag, keeper, ctx, atol, agg): - """Coalesce ordinary tracing with a proved boundary-overlap owner.""" - if (frag.source_boundary_face is None or keeper.source_boundary_face is None - or ctx is None or ctx.source_patches is None - or frag.pair is None or keeper.pair is None - or frag.rect is None or keeper.rect is None): - return False - from mmcore.numeric.intersection.ssx._ssx_boundary_identity import certified_boundary_retrace - fa, ka = np.asarray(frag.rect).reshape(4, 2), np.asarray(keeper.rect).reshape(4, 2) - for owner in (0, 1): - if frag.pair[1-owner] != keeper.pair[1-owner]: - continue - for axis in (2*owner, 2*owner+1): - value = frag.stuv[0, axis] - if (not np.all(frag.stuv[:, axis] == value) - or not np.all(keeper.stuv[:, axis] == value)): - continue - sides_f = np.flatnonzero(fa[axis] == value) - sides_k = np.flatnonzero(ka[axis] == value) - curve_axis = 2*owner+(1-axis % 2) - if (len(sides_f) != 1 or len(sides_k) != 1 - or not np.array_equal(fa[curve_axis], ka[curve_axis])): - continue - if (frag.source_boundary_face != (axis,sides_f[0]) - or keeper.source_boundary_face != (axis,sides_k[0])): - continue - source_f = ctx.source_patches[owner][frag.pair[owner]] - source_k = ctx.source_patches[owner][keeper.pair[owner]] - target = ctx.source_patches[1-owner][frag.pair[1-owner]] - net_f = to_homogeneous_2d(source_f.control_points, source_f.weights) - net_k = to_homogeneous_2d(source_k.control_points, source_k.weights) - curve = np.take(net_f, 0 if sides_f[0] == 0 else -1, axis=axis % 2) - other_curve = np.take(net_k, 0 if sides_k[0] == 0 else -1, axis=axis % 2) - if not np.array_equal(curve, other_curve): - continue - surface = to_homogeneous_2d(target.control_points, target.weights) - relation = certified_boundary_retrace( - frag.stuv, frag.xyz, keeper.stuv, keeper.xyz, curve, surface, - curve_axis, (2*(1-owner), 2*(1-owner)+1), fa[curve_axis], - 4.*ctx.ptol, 2.*atol, - charge=lambda n: agg.charge_postprocess(max(1, (n+127)//128))) - if relation is None or relation: - return relation - return False - - def _containment_dedup(frags, atol, agg, ctx=None): - """Coalesce only proved source-arc duplicates, longest first. + """Coalesce paths within both parameter and world-space tolerances. - Distinct arcs can share endpoints and identical approximation chords. - Shared fragment provenance or exact shared boundary-curve ownership - with a unique target preimage is required before deleting a fragment. + Each comparison uses one paired correspondence along the polylines. + Longest-first; deterministic tie-break by index. Without domain + tolerances, only identical paired sample arrays can be coalesced. On postprocess exhaustion the remaining fragments are kept unexamined (honest: dupes possible, reason already recorded).""" + from mmcore.numeric.intersection.ssx._ssx_polyline import polyline_contained if len(frags) <= 1: return list(frags) - order = sorted(range(len(frags)), key=lambda k: ( - frags[k].kind != 'overlap', -_arc_len(frags[k].xyz), k)) + if ctx is None: + kept = [] + for fragment in frags: + duplicate = False + for previous in kept: + if not agg.charge_postprocess(max(1, len(fragment.xyz))): + break + if fragment.kind != previous.kind or fragment.overlap != previous.overlap: + continue + for direction in (1, -1): + if (np.array_equal(fragment.stuv, previous.stuv[::direction]) + and np.array_equal(fragment.xyz, previous.xyz[::direction])): + duplicate = True + break + if duplicate: + break + if not duplicate: + kept.append(fragment) + return kept + order = sorted(range(len(frags)), + key=lambda k: (-_arc_len(frags[k].xyz), k)) kept_idx = [] - seen_objects = set() - for position,k in enumerate(order): - if not agg.charge_postprocess(1): - kept_idx.extend(order[position:]) - break + for k in order: f = frags[k] - if id(f) in seen_objects: - continue - seen_objects.add(id(f)) - if f.source_path is None and f.source_boundary_face is None: - kept_idx.append(k) - continue dup = False if len(f.xyz) >= 1 and not agg.postprocess_exhausted: for m in kept_idx: g = frags[m] - if g.source_path is None and g.source_boundary_face is None: - continue - if not agg.charge_postprocess(1): - break if len(g.xyz) < 2: continue if not _bbox_overlap(f.xyz, g.xyz, 2.0 * atol): continue - if ((f.kind != g.kind or f.overlap != g.overlap) - and (f.kind != 'transversal' or g.kind != 'overlap')): - continue - from mmcore.numeric.intersection.ssx._ssx_arc_ownership import source_path_covered - contained = source_path_covered(f.source_path,[g.source_path],agg.charge_postprocess) - if contained is False: - contained = _certified_boundary_retrace(f, g, ctx, atol, agg) + if not agg.charge_postprocess(max(1, len(f.xyz) + len(g.xyz))): + break + contained = polyline_contained( + f.stuv, f.xyz, g.stuv, g.xyz, ctx.ptol, 2.0*atol, + charge=agg.charge_postprocess, + source_mask=_seam_segment_mask(f.stuv, f.xyz, ctx, atol), + keeper_mask=_seam_segment_mask(g.stuv, g.xyz, ctx, atol)) if contained is None: break if contained: @@ -810,122 +589,6 @@ def _containment_dedup(frags, atol, agg, ctx=None): return [frags[k] for k in kept_idx] -def _seam_box_has_root(net, box, source_scale): - """Sufficient Krawczyk inclusion, independent of numerical residual size. - - The caller separately certifies uniqueness. Restriction maps the box - to the unit cube; a fixed preconditioned Newton map sends that cube - strictly into itself. Unsupported boundary roots remain separate. - The floating coefficient/operation enclosures follow the shared root - certificate's arithmetic model, not formal arbitrary-precision proof. - """ - from mmcore.numeric.bern import ( - bernstein_eval_nd, bernstein_partial_derivative_coeffs, - ) - from mmcore.numeric._bezier_common import restrict_net_axis_v - from mmcore.numeric.intersection._root_box_certificate import residual_roundoff_bound - - restricted = net - for axis, (lo, hi) in enumerate(box): - restricted = restrict_net_axis_v(restricted, axis, lo, hi, 0., 1.) - # Each axis restriction can perform two de Casteljau subdivisions. - error = residual_roundoff_bound(net, depth=2*len(box), source_scale=source_scale) - axes = tuple(range(len(box))) - magnitude = np.max(np.abs(restricted), axis=axes) - eps = np.finfo(float).eps - lower, upper = [], [] - for axis in axes: - degree = restricted.shape[axis]-1 - derivative = bernstein_partial_derivative_coeffs(restricted, axis=axis) - derivative_error = degree*(2.*error + 4.*eps*magnitude) - lower.append(np.nextafter(derivative.min(axis=axes)-derivative_error, -np.inf)) - upper.append(np.nextafter(derivative.max(axis=axes)+derivative_error, np.inf)) - lower, upper = np.asarray(lower).T, np.asarray(upper).T - midpoint, radius = .5*(lower+upper), .5*(upper-lower) - try: - inverse = np.linalg.inv(midpoint) - except np.linalg.LinAlgError: - return False - if not np.all(np.isfinite(inverse)): - return False - value = bernstein_eval_nd(restricted, np.full(len(box), .5)) - gamma = (2*len(box)+2)*eps / (1.-(2*len(box)+2)*eps) - absolute_inverse = np.abs(inverse) - arithmetic = gamma*(np.eye(len(box))+absolute_inverse@(np.abs(midpoint)+radius)) - linear_radius = .5*np.sum( - np.abs(np.eye(len(box))-inverse@midpoint)+absolute_inverse@radius+arithmetic, - axis=1) - correction = (np.abs(inverse@value)+absolute_inverse@error - + gamma*absolute_inverse@(np.abs(value)+error)) - return bool(np.all(np.nextafter(correction+linear_radius, np.inf) < .5)) - - -def _same_certified_seam_root(fa, pa, fb, pb, ctx, agg): - """Identify one transverse CSX event shared by adjacent source patches. - - Proximity only selects the candidate box. Exact adjacency, identical - seam coefficients, interval-Jacobian uniqueness and root inclusion - authorize the union. Existing branch endpoints are numerical samples - of that event; the test does not promote arbitrary low residuals into - roots. Singular/tangent events and missing provenance stay separate. - """ - if (ctx.source_patches is None or fa.pair is None or fb.pair is None - or fa.rect is None or fb.rect is None): - return False - changed = [owner for owner in (0, 1) if fa.pair[owner] != fb.pair[owner]] - if len(changed) != 1: - return False - owner = changed[0] - ra, rb = np.asarray(fa.rect).reshape(4, 2), np.asarray(fb.rect).reshape(4, 2) - for axis in (2*owner, 2*owner+1): - if ra[axis, 1] == rb[axis, 0]: - cut, side_a, side_b = ra[axis, 1], -1, 0 - elif rb[axis, 1] == ra[axis, 0]: - cut, side_a, side_b = ra[axis, 0], 0, -1 - else: - continue - other_axes = [i for i in range(4) if i != axis] - if (pa[axis] != cut or pb[axis] != cut - or not np.array_equal(ra[other_axes], rb[other_axes])): - continue - a = ctx.source_patches[owner][fa.pair[owner]] - b = ctx.source_patches[owner][fb.pair[owner]] - surface = ctx.source_patches[1-owner][fa.pair[1-owner]] - ah = to_homogeneous_2d(a.control_points, a.weights) - bh = to_homogeneous_2d(b.control_points, b.weights) - curve = np.take(ah, side_a, axis=axis % 2) - if not np.array_equal(curve, np.take(bh, side_b, axis=axis % 2)): - continue - sh = to_homogeneous_2d(surface.control_points, surface.weights) - if (np.any(curve[:, -1] <= 0.) or np.any(sh[..., -1] <= 0.) - or not np.all(np.isfinite(curve)) or not np.all(np.isfinite(sh))): - continue - parameter_axes = [2*owner + (1-axis % 2), 2*(1-owner), 2*(1-owner)+1] - lows = ra[parameter_axes, 0] - spans = ra[parameter_axes, 1]-lows - if np.any(spans <= 0.): - continue - candidates = (np.asarray([pa, pb])[:, parameter_axes]-lows)/spans - center = candidates.mean(axis=0) - radii = .5*np.ptp(candidates, axis=0)+ctx.ptol[parameter_axes]/spans - if not agg.charge_postprocess(max(1, len(curve)*sh.shape[0]*sh.shape[1])): - return False - from mmcore.numeric.intersection.csx._bez_csx4 import ( - _residual_vec_net, _csx_residual_source_scale, - ) - from mmcore.numeric.intersection._root_box_certificate import unique_root_box - net = _residual_vec_net(curve, sh, rational=True) - source_scale = _csx_residual_source_scale(curve, sh, rational=True) - box = unique_root_box(net, center, radii, source_scale) - if box is None or not all( - all(lo <= value <= hi for value, (lo, hi) in zip(candidate, box)) - for candidate in candidates): - continue - if _seam_box_has_root(net, box, source_scale): - return True - return False - - def _build_chains(frags, ctx, atol, agg, kind_barrier=True): """Endpoint-graph chain assembly. @@ -977,28 +640,7 @@ def union(a, b): break if _match_stuv(ends[a][2], ends[b][2], ends[a][3], ends[b][3], ctx, atol): - # Closeness is a candidate search, not root identity. - # Distinct parallel components can have both endpoint - # pairs within tolerance; joining those pairs invents a - # closed loop and deletes one of the components. - ids_a, ids_b = frags[fa].endpoint_ids, frags[fb].endpoint_ids - same_vertex = (ids_a is not None and ids_b is not None - and ids_a[ends[a][1]] == ids_b[ends[b][1]]) - source_a,source_b = frags[fa].source_endpoints,frags[fb].source_endpoints - root_a = None if source_a is None else source_a[ends[a][1]] - root_b = None if source_b is None else source_b[ends[b][1]] - if (frags[fa].pair == frags[fb].pair - and (root_a is not None or root_b is not None)): - if root_a is not None and root_a is root_b: - union(a,b) - # The pair solver already resolved admissible source - # identities. Float aliases cannot undo that decision. - continue - if same_vertex or all( - _same_axis_preimage(ends[a][2][i],ends[b][2][i],i,ctx) - for i in range(4)) or _same_certified_seam_root( - frags[fa], ends[a][2], frags[fb], ends[b][2], ctx, agg): - union(a, b) + union(a, b) clusters = {} for e in range(len(ends)): @@ -1112,25 +754,18 @@ def _assemble_branches(frags, ctx, atol, agg): continue kind = frags[chain[0][0]].kind overlap = any(frags[fi].overlap for fi, _ in chain) - branch = SSXBranch(curve=(stuv,xyz),closed=bool(closed),overlap=overlap,kind=kind) - owners = tuple(frags[fi].source_path for fi,_ in chain) - if all(path is not None for path in owners): - branch._source_parameter_paths = owners - out.append(branch) + out.append(SSXBranch(curve=(stuv, xyz), closed=bool(closed), + overlap=overlap, kind=kind)) return out # --------------------------------------------------------------------------- -# Points: wrap-aware dedup; source incidence is resolved before remapping +# Points: wrap-aware dedup + global on-branch filter # --------------------------------------------------------------------------- def _assemble_points(points, branches, ctx, atol, agg): - """Coalesce equal point preimages while preserving unknown arc incidence. - - A point on a branch's approximation chord can still be a distinct - isolated source root. The mapped branches carry no complete source-arc - ownership certificate, so geometric containment cannot delete it. - If the postprocess cap fires + """Wrap-aware destructive dedup, then the global on-branch filter + (4*atol — the Bezier-level constant). If the postprocess cap fires mid-dedup, the unexamined remainder passes through undropped (honest: duplicates possible, REASON_POSTPROCESS_CAP already recorded). Membership is by object identity — SSXPoint holds ndarrays, so @@ -1141,11 +776,6 @@ def _assemble_points(points, branches, ctx, atol, agg): for q in kept: if not agg.charge_postprocess(1): break - owner_p = getattr(p,'_source_point_owner',None) - owner_q = getattr(q,'_source_point_owner',None) - if (owner_p is not None and owner_q is not None - and owner_p[0] == owner_q[0] and owner_p[1] is not owner_q[1]): - continue if _dup_stuv(p.stuv, q.stuv, p.xyz, q.xyz, ctx, atol): dup = True break @@ -1158,7 +788,46 @@ def _assemble_points(points, branches, ctx, atol, agg): else: pool = kept - return pool + from mmcore.numeric.intersection.ssx._ssx_polyline import point_matches_polyline + out = [] + for p in pool: + on_branch = False + pxyz = np.asarray(p.xyz, dtype=np.float64) + parameters = [np.asarray(p.stuv, dtype=np.float64)] + for axis in range(4): + if not ctx.closed[axis] or ctx.spans[axis] <= 0.: + continue + coordinate = float(p.stuv[axis]) + if coordinate - ctx.lows[axis] <= 4.0*ctx.ptol[axis]: + shift = ctx.spans[axis] + elif ctx.highs[axis] - coordinate <= 4.0*ctx.ptol[axis]: + shift = -ctx.spans[axis] + else: + continue + alternate = [] + for candidate in parameters: + candidate = candidate.copy() + candidate[axis] += shift + alternate.append(candidate) + parameters.extend(alternate) + for b in branches: + xyz = np.asarray(b.curve[1], dtype=np.float64) + if len(xyz) < 2: + continue + if not _bbox_overlap(pxyz[None, :], xyz, 4.0 * atol): + continue + if not agg.charge_postprocess(max(1, len(xyz)*len(parameters))): + break + stuv = np.asarray(b.curve[0], dtype=np.float64) + mask = _seam_segment_mask(stuv, xyz, ctx, atol) + if any(point_matches_polyline( + candidate, pxyz, stuv, xyz, 4.0*ctx.ptol, 4.0*atol, + segment_mask=mask) for candidate in parameters): + on_branch = True + break + if not on_branch: + out.append(p) + return out # --------------------------------------------------------------------------- @@ -1230,7 +899,7 @@ def _mate_matches(a, b, ctx): discriminate).""" if a is None or b is None: return a is None and b is None - return all(_same_axis_preimage(a[i],b[i],i,ctx) + return all(_axis_diff(a[i], b[i], i, ctx) <= 4.0 * float(ctx.ptol[i]) for i in range(4)) @@ -1246,11 +915,11 @@ def _assemble_singularities(sings, branches, ctx, atol, agg, s1=None): if not agg.charge_postprocess(1): break if s.kind == 'cusp_curve': - # Sample clouds do not identify their complete source - # singular strata, even when the sampled tuples coincide. - dup = s is q + if s1 is not None and _clouds_near_identical( + s.samples, q.samples, s1, atol, agg): + dup = True else: - if not _dup_stuv(s.stuv, q.stuv, s.xyz, q.xyz, + if not _match_stuv(s.stuv, q.stuv, s.xyz, q.xyz, ctx, atol): continue if (s.kind == 'self_intersection' @@ -1277,21 +946,26 @@ def _assemble_singularities(sings, branches, ctx, atol, agg, s1=None): # --------------------------------------------------------------------------- def _vertex_seam_tags(vertex_stuv, cuts_per_axis, ctx): - """Exact decomposition seams containing this vertex.""" + """Set of (axis, cut) decomposition-seam bands this vertex lies in + (within 4*ptol_axis of the cut coordinate).""" tags = set() for axis, cuts in enumerate(cuts_per_axis): + tol = 4.0 * float(ctx.ptol[axis]) for cut in cuts: - if float(vertex_stuv[axis]) == cut: + if abs(float(vertex_stuv[axis]) - cut) <= tol: tags.add((axis, float(cut))) return frozenset(tags) def _segment_seam_tags(v0, v1, cuts_per_axis, ctx): - """Exact decomposition seams containing an entire linear segment.""" + """Set of (axis, cut) decomposition-seam bands a SEGMENT lies in: + BOTH endpoints within 4*ptol_axis of the cut coordinate.""" tags = set() for axis, cuts in enumerate(cuts_per_axis): + tol = 4.0 * float(ctx.ptol[axis]) for cut in cuts: - if float(v0[axis]) == cut and float(v1[axis]) == cut: + if (abs(float(v0[axis]) - cut) <= tol + and abs(float(v1[axis]) - cut) <= tol): tags.add((axis, float(cut))) return frozenset(tags) @@ -1327,10 +1001,7 @@ def _split_rim_at_cut_bands(frag, cuts_per_axis, ctx): return [frag] return [_Frag(stuv=stuv[a:b + 1].copy(), xyz=frag.xyz[a:b + 1].copy(), - kind='overlap', overlap=True,pair=frag.pair,rect=frag.rect, - source_boundary_face=frag.source_boundary_face, - source_path=(frag.source_path[a:b+1] if frag.source_path is not None - and len(frag.source_path) == n else None)) + kind='overlap', overlap=True) for a, b in spans] @@ -1346,30 +1017,30 @@ def _part_seam_tags(frag, cuts_per_axis, ctx): return common if common is not None else frozenset() -def _rims_are_partners(fa, fb, ctx, atol, charge=None): +def _rims_are_partners(fa, fb, ctx, atol): """Same-locus test between seam rims of ADJACENT tiles: endpoints - match crosswise (opposite raw orientation) OR parallel, and the - complete lifted paths coincide. Raw polyline direction is + match crosswise (opposite raw orientation) OR parallel, and + midpoints coincide (matching predicate). Raw polyline direction is sampler bookkeeping, not loop orientation: the per-pair rim sampler always emits edges in increasing local parameter (rev flags in the loop entries carry orientation), so two adjacent tiles' seam rims typically arrive PARALLEL even though their loops traverse the shared seam oppositely.""" + mid_a = len(fa.stuv) // 2 + mid_b = len(fb.stuv) // 2 + if not _match_stuv(fa.stuv[mid_a], fb.stuv[mid_b], + fa.xyz[mid_a], fb.xyz[mid_b], ctx, atol): + return False cross = (_match_stuv(fa.stuv[0], fb.stuv[-1], fa.xyz[0], fb.xyz[-1], ctx, atol) and _match_stuv(fa.stuv[-1], fb.stuv[0], fa.xyz[-1], fb.xyz[0], ctx, atol)) - parallel = (_match_stuv(fa.stuv[0], fb.stuv[0], fa.xyz[0], fb.xyz[0], + if cross: + return True + return (_match_stuv(fa.stuv[0], fb.stuv[0], fa.xyz[0], fb.xyz[0], ctx, atol) and _match_stuv(fa.stuv[-1], fb.stuv[-1], fa.xyz[-1], fb.xyz[-1], ctx, atol)) - if not (cross or parallel): - return False - # Matching approximations merely propose a seam. Actual exact source - # parameter paths must prove that the complete seam is shared. - from mmcore.numeric.intersection.ssx._ssx_arc_ownership import source_path_covered - return (source_path_covered(fa.source_path,[fb.source_path],charge) is True - and source_path_covered(fb.source_path,[fa.source_path],charge) is True) def _shoelace_area(poly2): @@ -1399,12 +1070,97 @@ def _merge_certifications(tiles): return cert +def _segment_enters_rect(p, q, lo, hi): + """True iff segment p->q passes through the OPEN axis-aligned 2-D + rect (lo, hi) (parametric clip; touching the boundary only is not + entering).""" + p = np.asarray(p, dtype=np.float64) + d = np.asarray(q, dtype=np.float64) - p + t0, t1 = 0.0, 1.0 + for k in range(2): + if abs(d[k]) < 1e-30: + if p[k] <= lo[k] or p[k] >= hi[k]: + return False + else: + ta = (lo[k] - p[k]) / d[k] + tb = (hi[k] - p[k]) / d[k] + if ta > tb: + ta, tb = tb, ta + t0 = max(t0, ta) + t1 = min(t1, tb) + if t0 >= t1: + return False + return True + + +def _rect_inside_region_2d(lo, hi, loops, eps): + """Sound containment of an axis-aligned rect in a loop-bounded + region: center strictly inside (outer loop, outside holes), all four + corners inside-or-near (the engine's 8*ptol site band), and no + boundary segment entering the eps-SHRUNKEN open rect. With the + boundary excluded from the shrunken rect, that connected rect lies + in a single face of the arrangement, so center-inside certifies all + of it; the eps collar stays within the near-band acceptance the + engine itself applies to multiplicity sites.""" + lo = np.asarray(lo, dtype=np.float64) + hi = np.asarray(hi, dtype=np.float64) + center = 0.5 * (lo + hi) + if not (_point_in_polygon(center, loops[0]) + and not any(_point_in_polygon(center, h) for h in loops[1:])): + return False + for c in (lo, np.array([lo[0], hi[1]]), + np.array([hi[0], lo[1]]), hi): + inside = (_point_in_polygon(c, loops[0]) + and not any(_point_in_polygon(c, h) for h in loops[1:])) + near = min(_dist_point_polyline_2d(c, lp) for lp in loops) <= eps + if not (inside or near): + return False + slo, shi = lo + eps, hi - eps + if np.all(slo < shi): + for lp in loops: + for k in range(len(lp) - 1): + if _segment_enters_rect(lp[k], lp[k + 1], slo, shi): + return False + return True + + +def _retire_multiplicity_if_region_explained(raw, regions, ctx, agg): + """Engine L28 rule lifted to the unified assembly: a pair-level + `unresolved_multiplicity` whose WHOLE pair rect is interior to one + unified region (both parameter planes, same region — the two-sided + site rule) can only have its unexposed ambiguity site inside the + region's certified 2-D C2 set — resolved by the region. Retire only + when EVERY such pair rect is contained; any rect escaping all + regions (case-14 class) or a starved containment check keeps the + reason (honest: never retire on unverified evidence).""" + if not (regions and raw.mult_rects + and REASON_MULTIPLICITY in agg.reasons): + return + eps12 = 8.0 * max(float(ctx.ptol[0]), float(ctx.ptol[1])) + eps34 = 8.0 * max(float(ctx.ptol[2]), float(ctx.ptol[3])) + for rect in raw.mult_rects: + cost = sum(len(lp) for reg in regions + for lp in reg.uv1_loops + reg.uv2_loops) + if not agg.charge_postprocess(max(1, cost // 4)): + return + s0, s1, t0, t1, u0, u1, v0, v1 = rect + if not any( + _rect_inside_region_2d((s0, t0), (s1, t1), + reg.uv1_loops, eps12) + and _rect_inside_region_2d((u0, v0), (u1, v1), + reg.uv2_loops, eps34) + for reg in regions): + return + agg.retire(REASON_MULTIPLICITY) + + def _assemble_regions(raw, stitched, ctx, atol, agg, s_cuts, t_cuts, u_cuts, v_cuts): """Unify per-pair region tiles into one region per connected - coincidence component; dissolve interior-seam rims; drop only stitched - overlap branches continuously identified with represented rims or - dissolved seams. Pair-level uncertainty survives assembly. + coincidence component; dissolve interior-seam rims; drop stitched + overlap branches absorbed by a unified region's interior; retire + pair-level multiplicity marks whose whole pair rect is region + interior (engine L28 rule). Unified regions guarantee loop ORDERING (outer loop first, by |area| in the uv1 plane) but not winding direction; the per-tile @@ -1452,7 +1208,6 @@ def _assemble_regions(raw, stitched, ctx, atol, agg, seam_tags = {rid: _part_seam_tags(rims[rid], cuts_per_axis, ctx) for rid in tile_of_rim} dissolved = set() - dissolved_pairs = [] tile_parent = list(range(n_tiles)) def _tfind(x): @@ -1483,14 +1238,8 @@ def _tunion(a, b): if not agg.charge_postprocess( max(1, len(rims[ra].stuv) // 4)): break - if _rims_are_partners(rims[ra], rims[rb], ctx, atol, - charge=agg.charge_postprocess): - crossed = (_match_stuv(rims[ra].stuv[0], rims[rb].stuv[-1], - rims[ra].xyz[0], rims[rb].xyz[-1], ctx, atol) - and _match_stuv(rims[ra].stuv[-1], rims[rb].stuv[0], - rims[ra].xyz[-1], rims[rb].xyz[0], ctx, atol)) + if _rims_are_partners(rims[ra], rims[rb], ctx, atol): dissolved.update((ra, rb)) - dissolved_pairs.append((ra, rb, crossed)) _tunion(tile_of_rim[ra], tile_of_rim[rb]) break @@ -1499,31 +1248,6 @@ def _tunion(a, b): for ti in range(n_tiles): components.setdefault(_tfind(ti), []).append(ti) - # The tile loops already supply vertex identity. Keep that topology - # through seam dissolution instead of reconstructing it with distance - # clustering (which would also join two nearby ordinary components). - vertex_parent = list(range(2 * len(rims))) - - def vertex_find(v): - while vertex_parent[v] != v: - vertex_parent[v] = vertex_parent[vertex_parent[v]] - v = vertex_parent[v] - return v - - def vertex_union(a, b): - a, b = vertex_find(a), vertex_find(b) - vertex_parent[max(a, b)] = min(a, b) - - for loops in tiles_loops: - for loop in loops: - for (a, ar), (b, br) in zip(loop, loop[1:] + loop[:1]): - vertex_union(2 * a + int(not ar), 2 * b + int(br)) - for a, b, crossed in dissolved_pairs: - vertex_union(2 * a, 2 * b + int(crossed)) - vertex_union(2 * a + 1, 2 * b + int(not crossed)) - for rid, rim in enumerate(rims): - rim.endpoint_ids = (vertex_find(2 * rid), vertex_find(2 * rid + 1)) - final_rim_branches = [] # SSXBranch, appended after stitched final_regions = [] @@ -1599,19 +1323,33 @@ def _tile_passthrough(ti): interior_stuv=tiles_objs[0].interior_stuv, certification=_merge_certifications(tiles_objs))) - # --- (c) remove only represented rims and proved dissolved seams -- - # A curve inside projected footprints or on an approximate rim chord - # can be another component. Exact source-path ownership is required. - # `rims` includes dissolved seams, which remain represented by the region. - from mmcore.numeric.intersection.ssx._ssx_arc_ownership import source_path_covered - rim_paths = [rim.source_path for rim in rims if rim.source_path is not None] + # --- (c) interior absorption of stitched overlap branches --------- + def _inside_region(stuv_path, region): + p12 = 8.0 * max(float(ctx.ptol[0]), float(ctx.ptol[1])) + p34 = 8.0 * max(float(ctx.ptol[2]), float(ctx.ptol[3])) + + def _half(pt2, loops, bar): + inside = (_point_in_polygon(pt2, loops[0]) + and not any(_point_in_polygon(pt2, h) + for h in loops[1:])) + near = min(_dist_point_polyline_2d(pt2, lp) + for lp in loops) <= bar + return inside or near + + for x in stuv_path: + if not (_half(x[:2], region.uv1_loops, p12) + and _half(x[2:], region.uv2_loops, p34)): + return False + return True + kept_stitched = [] for b in stitched: absorbed = False if b.kind == 'overlap' and final_regions: - owned = getattr(b,'_source_parameter_paths',None) - absorbed = bool(owned) and all(source_path_covered( - path,rim_paths,agg.charge_postprocess) is True for path in owned) + stuv_path = np.asarray(b.curve[0], dtype=np.float64) + if agg.charge_postprocess(max(1, len(stuv_path))): + absorbed = any(_inside_region(stuv_path, reg) + for reg in final_regions) if not absorbed: kept_stitched.append(b) @@ -1622,6 +1360,8 @@ def _tile_passthrough(ti): reg.boundary = [[(bi + shift, rev) for bi, rev in loop] for loop in reg.boundary] + # --- (d) multiplicity retirement (engine L28 rule; see helper) ---- + _retire_multiplicity_if_region_explained(raw, final_regions, ctx, agg) return kept_stitched + final_rim_branches, final_regions @@ -1648,7 +1388,6 @@ def nurbs_ssx(surf1, surf2, atol=1e-3, **kwargs) -> dict: patches1 = decompose_surface(s1, "uv") patches2 = decompose_surface(s2, "uv") - ctx.source_patches = (patches1, patches2) def _patch_aabb(patch): pts = patch.control_points.reshape( @@ -1671,8 +1410,6 @@ def _patch_aabb(patch): if (agg.remaining_cells <= 0 or agg.remaining_csx_calls <= 0 or agg.remaining_output_items <= 0): agg.mark(REASON_WORK_BUDGET) - for a, b in candidates[k:]: - raw.unresolved.append(_skip_box(patches1[a], patches2[b])) break p1, p2 = patches1[i], patches2[j] if rational: @@ -1695,10 +1432,7 @@ def _patch_aabb(patch): if REASON_MULTIPLICITY in ( (result.get('status', {}) or {}).get('reasons', []) or []): raw.mult_rects.append(rect) - _collect_pair(raw, result, rect, pair=(i, j), sources=(P1, P2), - rational=rational, atol=atol, agg=agg) - - _finish_collecting(raw) + _collect_pair(raw, result, rect, pair=(i, j)) # Interior decomposition cut coordinates per stuv axis (for Task 5's # seam-rim classification). @@ -1726,7 +1460,6 @@ def _cuts(patches, side): 'points': points, 'singularities': singularities, 'overlap_regions': regions, - 'unresolved_regions': raw.unresolved, } out.update(agg.result_fields()) return out diff --git a/mmcore/numeric/intersection/ssx/_ssx5_overlap.py b/mmcore/numeric/intersection/ssx/_ssx5_overlap.py index 1736d2cb..047c26cb 100644 --- a/mmcore/numeric/intersection/ssx/_ssx5_overlap.py +++ b/mmcore/numeric/intersection/ssx/_ssx5_overlap.py @@ -1,35 +1,15 @@ -"""2-D surface-overlap regions for bez_ssx (ledger L28, approved Option C). - -A C2 positive-dimensional component — S1 and S2 have the same surface image -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. - -An algebraic common-image check precedes all sampling. A small residual -does not establish dimension: even an arbitrarily flat high-order contact -can intersect a plane in just one line. The supported identity witnesses -are exact common control planes and identical homogeneous parameterizations -(including parameter reversal/transposition and global weight scaling). -Unsupported image equivalences stay unresolved. - -Approximation 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. +"""CAD surface-overlap regions at the requested modeling tolerance. + +Closed rim loops reference ``kind='overlap'`` branches and carry paired +parameter paths, an interior sample and normal orientation. Convex planar +bilinear charts use polygon clipping with shared corner inverses and +adaptive, whole-chord checks on both surfaces. Other patches use sampled +domain edges and numerical point inversion. + +A coincidence band becomes a region only when an interior sample stays +at least 4*ptol from its rims in both parameter planes. Thinner evidence +remains curve-only. The ``certification`` fields report numerical checks +at atol; they are not an exact-algebraic intersection contract. """ from __future__ import annotations @@ -46,107 +26,9 @@ __all__ = ["SSXOverlapRegion", "assemble_overlap_regions"] -def _exact_surface_injective(surface_h): - """Sufficient global injectivity proof for a polynomial Bezier chart. - - A constant projection R is selected numerically, then verified using - exact rational arithmetic on the supplied binary coefficients. If - ||I - R DS||_infinity < 1 throughout the convex parameter square, - integrating the derivative between two parameters proves injectivity. - Unsupported nonuniform weights conservatively return False. - """ - from fractions import Fraction - from itertools import combinations - h = np.asarray(surface_h, dtype=float) - if (h.ndim != 3 or h.shape[-1] != 4 or min(h.shape[:2]) < 2 - or not np.all(np.isfinite(h)) or np.any(h[..., 3] <= 0.) - or not np.all(h[..., 3] == h[0, 0, 3])): - return False - weight = Fraction.from_float(float(h[0, 0, 3])) - points = np.asarray([Fraction.from_float(float(x)) / weight - for x in h[..., :3].flat], dtype=object).reshape(h.shape[:2] + (3,)) - derivatives = [(h.shape[axis]-1) * np.diff(points, axis=axis) - for axis in (0, 1)] - midpoint = np.array([[(float(min(d[..., k].flat)) + float(max(d[..., k].flat))) / 2. - for d in derivatives] for k in range(3)]) - if not np.all(np.isfinite(midpoint)): - return False - candidates = [] - for selected in combinations(range(3), 2): - try: - inverse = np.linalg.inv(midpoint[list(selected)]) - except np.linalg.LinAlgError: - continue - projection = np.zeros((2, 3)) - projection[:, list(selected)] = inverse - candidates.append(projection) - try: - candidates.append(np.linalg.pinv(midpoint)) - except np.linalg.LinAlgError: - pass - for projection in candidates: - if not np.all(np.isfinite(projection)): - continue - exact = [[Fraction.from_float(float(x)) for x in row] for row in projection] - norms = [] - for row in range(2): - bounds = [] - for axis, derivative in enumerate(derivatives): - values = [int(row == axis) - sum(exact[row][k] * point[k] for k in range(3)) - for point in derivative.reshape(-1, 3)] - bounds.append(max(abs(min(values)), abs(max(values)))) - norms.append(sum(bounds)) - if max(norms) < 1: - return True - return False - - -def _exact_common_surface(S1_h, S2_h): - """Prove an image identity from the supplied binary coefficients. - - Returns the proof kind, or None. Fraction predicates avoid a residual - threshold turning a nonzero gap or high-order tangent line into a 2-D - coincidence region. This is a sufficient test, not implicitization. - """ - from fractions import Fraction - from mmcore.numeric.intersection.csx._planar_overlap import ( - _exact_points, _sub, _cross, _dot, - ) - a, b = np.asarray(S1_h, dtype=float), np.asarray(S2_h, dtype=float) - if (a.ndim != 3 or b.ndim != 3 or a.shape[-1] != 4 or b.shape[-1] != 4 - or not np.all(np.isfinite(a)) or not np.all(np.isfinite(b)) - or np.any(a[..., -1] <= 0.) or np.any(b[..., -1] <= 0.)): - return None - # Equality of all homogeneous coefficients up to one common scale - # proves rational chart identity, including for nonplanar patches. - for candidate in (b, b[::-1], b[:, ::-1], b[::-1, ::-1], - b.transpose(1, 0, 2), b.transpose(1, 0, 2)[::-1], - b.transpose(1, 0, 2)[:, ::-1], - b.transpose(1, 0, 2)[::-1, ::-1]): - if a.shape != candidate.shape: - continue - wa = Fraction.from_float(float(a[0, 0, -1])) - wb = Fraction.from_float(float(candidate[0, 0, -1])) - if all(Fraction.from_float(float(x)) * wb == Fraction.from_float(float(y)) * wa - for x, y in zip(a.flat, candidate.flat)): - return 'homogeneous_chart_identity' - points_a, _ = _exact_points(a, True) - points_b, _ = _exact_points(b, True) - points = points_a + points_b - origin = points[0] - direction = next((_sub(p, origin) for p in points if p != origin), None) - if direction is None: - return None - normal = next((_cross(direction, _sub(p, origin)) for p in points - if any(_cross(direction, _sub(p, origin)))), None) - if normal is not None and all(_dot(normal, _sub(p, origin)) == 0 for p in points): - return 'common_control_plane' - return None - - @dataclass class SSXOverlapRegion: - """C2 positive-dimensional component: S1 ≡ S2 over a + """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 @@ -230,6 +112,270 @@ def _invert_point(S_h, xyz, seed=None, max_iter=30): ) +class _OverlapWorkStopped(Exception): + """The caller's shared overlap allowance was exhausted.""" + + +def _bilinear_chord_error(S_h, start, end, xyz): + """Bernstein hull bound for a bilinear chart along one parameter chord. + + Substituting affine u and v gives a degree-two homogeneous curve. Its + difference from the reported XYZ chord is a degree-three numerator; + positive weights bound the rational error over the whole interval. + This is floating point geometry at modeling tolerance, not an exact + identity predicate. + """ + H = np.asarray(S_h, dtype=float).copy() + H /= np.max(H[..., 3]) + H[..., :3] -= np.asarray(xyz[0])*H[..., 3, None] + + def value(u, v): + return ((1.-u)*((1.-v)*H[0, 0]+v*H[0, 1]) + + u*((1.-v)*H[1, 0]+v*H[1, 1])) + + u0, v0 = start + u1, v1 = end + c = np.array([value(u0, v0), .5*(value(u0, v1)+value(u1, v0)), + value(u1, v1)]) + if not np.isfinite(c).all() or np.min(c[:, 3]) <= 0.: + return np.inf + elevated = np.array([c[0, :3], (c[0, :3]+2.*c[1, :3])/3., + (2.*c[1, :3]+c[2, :3])/3., c[2, :3]]) + delta = np.asarray(xyz[1])-xyz[0] + line = np.array([np.zeros(3), c[0, 3]*delta/3., + 2.*c[1, 3]*delta/3., c[2, 3]*delta]) + return float(np.max(np.linalg.norm(elevated-line, axis=1))/np.min(c[:, 3])) + + +def _planar_convex_rims(S1_h, S2_h, atol, charge, context, *, boundary_only=False): + """Clip convex bilinear chart images before inverting their shared rims. + + Positive-weight planar bilinear charts map the parameter square onto + their corner quadrilateral. Clipping those two quadrilaterals supplies + common corner locations, including oblique intersections which separate + edge-by-edge tolerance searches can miss. The calculation is numerical: + planarity, inverse residuals and both lifted chord images are checked at + the caller's modeling tolerance. Unsupported charts return ``None`` so + the general sampled-rim path remains available. + """ + if S1_h.shape != (2, 2, 4) or S2_h.shape != (2, 2, 4): + return None + + def spend(n=1): + if not charge(n): + raise _OverlapWorkStopped + + spend(16) + if any(not np.isfinite(S).all() or np.any(S[..., 3] <= 0.) + for S in (S1_h, S2_h)): + return None + cart = [S[..., :3] / S[..., 3, None] for S in (S1_h, S2_h)] + points = np.concatenate([p.reshape(-1, 3) for p in cart]) + origin = points[0].copy() + scale = float(np.max(np.ptp(points, axis=0))) + if not np.isfinite(scale) or scale <= 0.: + return None + centered = (points - origin) / scale + try: + _u, singular, frame = np.linalg.svd(centered, full_matrices=False) + except np.linalg.LinAlgError: + return None + if singular[1] <= 128. * np.finfo(float).eps * singular[0]: + return None + # Reserve the rest of atol for inversion and chord representation. + if np.max(np.abs(centered @ frame[2])) * scale > .25 * atol: + return None + basis = frame[:2] + projected = [((p - origin) / scale) @ basis.T for p in cart] + eps = 128. * np.finfo(float).eps + + def cross(a, b): + return float(a[0] * b[1] - a[1] * b[0]) + + def quad(p): + q = p[[0, 1, 1, 0], [0, 0, 1, 1]].copy() + turns = np.array([cross(q[(i+1) % 4]-q[i], + q[(i+2) % 4]-q[(i+1) % 4]) + for i in range(4)]) + if np.all(turns < -eps): + q = q[::-1].copy() + elif not np.all(turns > eps): + return None + return q + + first, second = map(quad, projected) + if first is None or second is None: + return None + + # Sutherland-Hodgman intersection. The epsilon only handles roundoff + # on a shared supporting line; it does not expand either CAD polygon. + polygon = list(first) + for a, b in zip(second, np.roll(second, -1, axis=0)): + clipped = [] + if not polygon: + break + before = polygon[-1] + db = cross(b-a, before-a) + for after in polygon: + spend() + da = cross(b-a, after-a) + if (db >= -eps) != (da >= -eps): + denominator = db-da + if denominator != 0.: + fraction = np.clip(db/denominator, 0., 1.) + clipped.append(before + fraction*(after-before)) + if da >= -eps: + clipped.append(after) + before, db = after, da + polygon = [] + for p in clipped: + if not polygon or np.linalg.norm(p-polygon[-1]) > eps: + polygon.append(p) + if len(polygon) > 1 and np.linalg.norm(polygon[0]-polygon[-1]) <= eps: + polygon.pop() + polygon = np.asarray(polygon).reshape(-1, 2) + area = abs(sum(cross(a, b) for a, b in + zip(polygon, np.roll(polygon, -1, axis=0)))) + if len(polygon) >= 3 and area > eps: + if boundary_only: + return None + dimension = 2 + else: + dimension = 0 + if len(polygon) > 1: + distance = np.linalg.norm(polygon[:, None]-polygon[None, :], axis=-1) + a, b = np.unravel_index(np.argmax(distance), distance.shape) + if distance[a, b] > eps: + polygon = polygon[[a, b]] + dimension = 1 + else: + polygon = polygon[:1] + context.update(origin=origin, scale=scale, basis=basis, + polygon=polygon, dimension=dimension) + if not boundary_only or not len(polygon): + return [] + + # Use normalized projected homogeneous coordinates for Newton. This + # avoids a world translation or units choice entering its conditioning. + weights = [S[..., 3]/np.max(S[..., 3]) for S in (S1_h, S2_h)] + projected_h = [np.concatenate((p*w[..., None], w[..., None]), axis=2) + for p, w in zip(projected, weights)] + + def inverse(H, target, seed): + uv = np.array([.5, .5]) if seed is None else np.asarray(seed).copy() + for _ in range(30): + spend() + u, v = uv + row0 = (1.-v)*H[0, 0]+v*H[0, 1] + row1 = (1.-v)*H[1, 0]+v*H[1, 1] + value = (1.-u)*row0+u*row1 + if not np.isfinite(value).all() or value[2] <= 0.: + return None + p = value[:2]/value[2] + error = p-target + if np.linalg.norm(error) <= eps: + return uv + hu = row1-row0 + hv = (1.-u)*(H[0, 1]-H[0, 0])+u*(H[1, 1]-H[1, 0]) + jac = np.column_stack(((hu[:2]-p*hu[2])/value[2], + (hv[:2]-p*hv[2])/value[2])) + try: + step = np.linalg.solve(jac, error) + except np.linalg.LinAlgError: + return None + new_uv = np.clip(uv-step, 0., 1.) + if np.array_equal(new_uv, uv): + return uv if np.linalg.norm(error)*scale <= .05*atol else None + uv = new_uv + return None + + def sample(p, seed=None): + pair = [inverse(H, p, None if seed is None else seed[2*i:2*i+2]) + for i, H in enumerate(projected_h)] + if any(q is None for q in pair): + return None + q = np.concatenate(pair) + spend(2) + x1 = eval_surface(S1_h, *q[:2], rational=True) + x2 = eval_surface(S2_h, *q[2:], rational=True) + residual = float(np.linalg.norm(x1-x2)) + if not np.isfinite(residual) or residual > .6*atol: + return None + return q, .5*(x1+x2), residual + + corners = [sample(p) for p in polygon] + if any(p is None for p in corners): + return None + if dimension == 0: + context['contact'] = corners[0] + return [] + rims = [] + for edge, (a, b) in enumerate(zip(polygon, np.roll(polygon, -1, axis=0))): + if dimension == 1 and edge > 0: + break + samples = [corners[edge]] + residual = samples[0][2] + + def append_interval(lo, hi, left, right): + nonlocal residual + pending = [(lo, hi, left, right)] + while pending: + low, high, start, end = pending.pop() + spend(2) + good = all(_bilinear_chord_error(S, start[0][off:off+2], + end[0][off:off+2], + (start[1], end[1])) <= .8*atol + for S, off in ((S1_h, 0), (S2_h, 2))) + if good: + samples.append(end) + residual = max(residual, end[2]) + continue + middle = .5*(low+high) + if middle == low or middle == high: + return False + mid = sample(a+middle*(b-a), .5*(start[0]+end[0])) + if mid is None: + return False + pending.extend(((middle, high, mid, end), (low, middle, start, mid))) + return True + + for k in range(1, 17): + end = (corners[(edge+1) % len(corners)] if k == 16 + else sample(a+(k/16.)*(b-a), samples[-1][0])) + if end is None or not append_interval((k-1)/16., k/16., samples[-1], end): + return None + rims.append({"stuv": np.asarray([s[0] for s in samples]), + "xyz": np.asarray([s[1] for s in samples]), + "resid_max": residual}) + context.update(origin=origin, scale=scale, basis=basis, polygon=polygon, + dimension=dimension) + return rims + + +def try_planar_boundary_intersection(S1_h, S2_h, atol, budget): + """Handle a shared edge or corner of regular convex planar charts.""" + from mmcore.numeric.intersection.ssx._ssx_substrate import SSXBranch, SSXPoint + context = {} + try: + rims = _planar_convex_rims( + S1_h, S2_h, atol, + lambda n: budget.charge_cells(n, 'planar_contact'), context, + boundary_only=True) + except _OverlapWorkStopped: + return None + if rims is None or not context: + return None + result = dict(branches=[], points=[], singularities=[], overlap_regions=[]) + for rim in rims: + branch = SSXBranch(curve=(rim['stuv'], rim['xyz']), + kind='overlap', overlap=True) + budget.append_output(result['branches'], branch, 'planar_contact') + if 'contact' in context: + q, xyz, _ = context['contact'] + budget.append_output(result['points'], SSXPoint(q, xyz), 'planar_contact') + return result + + def _edge_own_uv(axis, side, w): return (side, w) if axis == 0 else (w, side) @@ -534,10 +680,10 @@ def _interior_witness(S1_h, S2_h, uv1_loops, uv2_loops, atol, ptol4): def assemble_overlap_regions( S1_h, S2_h, *, atol, ptol4, existing_overlap_branches=(), + existing_intersection_branches=(), uncertified_spans=(), overlap_boxes=(), charge=None, - identity_surfaces=None, ): """Assemble certified SSXOverlapRegion entities from rim evidence. @@ -545,41 +691,41 @@ def assemble_overlap_regions( 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`` (False for this sampled reconstruction; exhaustive - full-domain rim ownership belongs to the separate exact source tier). + 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) + intersections = list(existing_intersection_branches) def _charge(n): return charge(n) if charge is not None else True empty = {"regions": [], "rim_branches": [], - "unmatched_branches": existing, "covered": False} - - source1, source2 = ((S1_h, S2_h) if identity_surfaces is None - else identity_surfaces) - if not _charge(max(1, int(np.prod(source1.shape[:2]) + np.prod(source2.shape[:2])))): - return empty - identity = _exact_common_surface(source1, source2) - if identity is None: - return empty - injective_charts = (_exact_surface_injective(source1) - and _exact_surface_injective(source2)) + "unmatched_branches": existing, + "unmatched_intersection_branches": intersections, + "planar_pair_covered": False, "covered": False} - # 8 edges x (coarse + dense) inversions, each a bounded GN solve. - if not _charge(8 * 33 + 8 * 17): + planar = {} + try: + rims = _planar_convex_rims(S1_h, S2_h, atol, _charge, planar) + except _OverlapWorkStopped: 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 rims is None: + # 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 @@ -673,8 +819,6 @@ def _oriented(L, ccw): normal_agreement=agreement, interior_stuv=np.asarray(witness, dtype=np.float64), certification={ - "image_identity": identity, - "injective_charts": injective_charts, "boundary_resid_max": resid_max / max(atol, 1e-300), "interior_resid": w_resid / max(atol, 1e-300), "n_samples": int(n_samples), @@ -687,8 +831,8 @@ def _oriented(L, ccw): # 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 retain - # their independent source ownership until identity is established. + # 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()), @@ -700,24 +844,101 @@ def _oriented(L, ccw): for (ri, rev) in L["chain"]] for L in region_loops] - # These rims are numerical approximations. Even exactly matching - # displayed parameter chords do not prove source-arc identity. Keep - # already discovered branches until actual source provenance can - # establish that this region representation owns them. - unmatched = existing + def _inside_planar_pair(branch): + """A checked paired path inside the complete convex planar region.""" + if not planar: + return False + stuv, xyz = map(lambda a: np.asarray(a, dtype=float), branch.curve) + if (stuv.ndim != 2 or stuv.shape != (len(xyz), 4) + or xyz.ndim != 2 or xyz.shape[1] != 3 or len(xyz) < 2 + or not np.isfinite(stuv).all() or not np.isfinite(xyz).all() + or np.any(stuv < 0.) or np.any(stuv > 1.)): + return False + if not _charge(2*len(stuv)): + return False + p = ((xyz-planar['origin'])/planar['scale']) @ planar['basis'].T + polygon = planar['polygon'] + for a, b in zip(polygon, np.roll(polygon, -1, axis=0)): + edge = b-a + signed = edge[0]*(p[:, 1]-a[1])-edge[1]*(p[:, 0]-a[0]) + if np.any(signed < -atol/planar['scale']*np.linalg.norm(edge)): + return False + # These are discovery samples of a 2-D component, not a separate + # 1-D topology. Validate their paired membership; their obsolete + # interpolation is replaced by the region's mapped boundary. In + # particular a coarse diagonal parameter chord need not accurately + # represent a curved interior trace to identify the owned component. + for q, x in zip(stuv, xyz): + for surface, off in ((S1_h, 0), (S2_h, 2)): + error = np.linalg.norm(eval_surface(surface, *q[off:off+2], + rational=True)-x) + if not np.isfinite(error) or error > atol: + return False + return True + + unmatched_intersections = [b for b in intersections if not _inside_planar_pair(b)] + unmatched = [] + for b in existing: + bxyz = np.asarray(b.curve[1], dtype=np.float64) + absorbed = _inside_planar_pair(b) if planar else 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. - # Coincident images establish a region, not exhaustion of the lifted - # zero set: self-intersecting charts can have off-diagonal components. - # Common planarity and injectivity alone do not enumerate intersections - # of two curved chart boundaries. Even identical injective charts need - # an exact full-domain rim-ownership proof for their returned region. - # Sampling and point inversion do not supply that proof; the separate - # exact source region tier owns exhaustive region claims. - covered = False + 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} + "unmatched_branches": unmatched, + "unmatched_intersection_branches": unmatched_intersections, + "planar_pair_covered": bool(planar and not unmatched + and not unmatched_intersections), + "covered": covered} diff --git a/mmcore/numeric/intersection/ssx/_ssx_affine_constraints.py b/mmcore/numeric/intersection/ssx/_ssx_affine_constraints.py deleted file mode 100644 index 918f7cff..00000000 --- a/mmcore/numeric/intersection/ssx/_ssx_affine_constraints.py +++ /dev/null @@ -1,402 +0,0 @@ -"""Exact necessary parameter relations from certified source coordinates. - -A source coordinate ``offset+slope*parameter`` gives an exact relation -between one parameter on each surface. These constraints only describe -where intersections may occur: a contracted box is not an existence proof -and need not enclose the residual or its derivatives away from the zero set. -""" -from fractions import Fraction - -import numpy as np - -from mmcore.numeric.intersection.csx._planar_roots import _outward_interval - - -def _fraction(value): - if isinstance(value, Fraction): - return value - if isinstance(value, (int, np.integer)): - return Fraction(int(value)) - return Fraction.from_float(float(value)) - - -def augment_constant_coordinates(sources, affine_maps, *, charge=None): - """Add exact P_k=c*W identities to a constraints-only map copy. - - A constant source coordinate can fix a parameter of the opposite - chart. Zero slopes must not enter the original maps used by inverse - and immersion predicates, which require a nonzero chart derivative. - Return None when the prepaid source algebra is denied. - """ - result = tuple(dict(mapping) for mapping in affine_maps) - for source,mapping in zip(sources,result): - source = np.asarray(source) - if (source.ndim != 3 or source.shape[-1] != 4 - or not np.all(np.isfinite(source)) or np.any(source[...,3] <= 0)): - continue - coordinates = [coordinate for coordinate in range(3) - if not any((coordinate,axis) in mapping for axis in (0,1))] - if not coordinates: - continue - count = source.shape[0]*source.shape[1] - # Convert W once; each tested coordinate converts P, divides one - # anchor, and checks every exact product/equality against c*W. - operations = count+len(coordinates)*(3*count+1) - if charge is not None and not charge(max(1,(operations+127)//128)): - return None - weights = tuple(_fraction(value) for value in source[...,3].flat) - for coordinate in coordinates: - values = tuple(_fraction(value) for value in source[...,coordinate].flat) - constant = values[0]/weights[0] - if all(value == constant*weight for value,weight in zip(values,weights)): - mapping[coordinate,0] = constant,Fraction(0) - return result - - -def augment_projected_coordinates(sources, affine_maps, *, charge=None): - """Find exact common affine coordinates after a world-space projection. - - For each pair of source axes, a row nullspace contains precisely the - vectors q for which both projected control nets q.P are affine in the - respective single parameter. Exact Bernstein coefficient identities - prove each relation globally. The inputs must have uniform positive - weights; nonuniform rational charts retain their existing maps. - - New integer coordinate keys are constraints-only and do not modify the - pure world-coordinate maps used by inverse or immersion predicates. - Denied exact work returns None, never a truncated set of new proofs. - """ - result = tuple(dict(mapping) for mapping in affine_maps) - if len(sources) != 2 or len(result) != 2: - raise ValueError('Expected two source surfaces and coordinate maps') - nets = tuple(np.asarray(source) for source in sources) - if any(net.ndim != 3 or net.shape[-1] != 4 - or min(net.shape[:2]) < 2 or not np.all(np.isfinite(net)) - or np.any(net[...,3] <= 0.) or not np.all(net[...,3] == net[0,0,3]) - for net in nets): - return result - - class Stopped(Exception): - pass - - def spend(amount): - if charge is not None and not charge(max(1,int(amount))): - raise Stopped - - def nullspace(rows): - # Incremental exact echelon form has at most three pivot rows. - # Rank three rejects this axis pair immediately; no numeric SVD - # or scale-dependent rank threshold contributes to the proof. - basis = {} - for source_row in rows: - spend(3) - row = list(source_row) - for pivot in sorted(basis): - factor = row[pivot] - if factor: - spend(2*(3-pivot)) - for k in range(pivot,3): - row[k] -= factor*basis[pivot][k] - pivot = next((k for k,value in enumerate(row) if value),None) - if pivot is None: - continue - spend(3-pivot) - divisor = row[pivot] - row[pivot:] = [value/divisor for value in row[pivot:]] - basis[pivot] = tuple(row) - if len(basis) == 3: - return [] - vectors = [] - for free in (k for k in range(3) if k not in basis): - vector = [Fraction(0)]*3 - vector[free] = Fraction(1) - for pivot in sorted(basis,reverse=True): - spend(2*(2-pivot)+1) - vector[pivot] = -sum(basis[pivot][k]*vector[k] - for k in range(pivot+1,3)) - spend(3) - anchor = next(value for value in vector if value) - vectors.append(tuple(value/anchor for value in vector)) - return vectors - - try: - points, residuals, ends = [], [], [] - for net in nets: - # Original source conversion and exact Cartesian division. - spend(1+6*net.shape[0]*net.shape[1]) - weight = _fraction(net[0,0,3]) - values = np.asarray([_fraction(value)/weight for value in net[...,:3].flat], - dtype=object).reshape(net.shape[:2]+(3,)) - points.append(values) - by_axis, endpoint_axes = [], [] - for axis in (0,1): - spend(3) - endpoint = values[-1,0] if axis == 0 else values[0,-1] - delta = tuple(b-a for a,b in zip(values[0,0],endpoint)) - endpoint_axes.append(delta) - rows = [] - for i in range(net.shape[0]): - for j in range(net.shape[1]): - spend(10) - parameter = Fraction((i,j)[axis],net.shape[axis]-1) - row = tuple(value-origin-parameter*difference - for value,origin,difference in zip(values[i,j],values[0,0],delta)) - if any(row): - rows.append(row) - by_axis.append(rows) - residuals.append(by_axis) - ends.append(endpoint_axes) - keys = [coordinate for mapping in result for coordinate,axis in mapping] - next_key = max([2]+keys)+1 - projection_keys = {} - for first_axis in (0,1): - for second_axis in (0,1): - for projection in nullspace(residuals[0][first_axis]+residuals[1][second_axis]): - if projection not in projection_keys: - projection_keys[projection] = next_key - next_key += 1 - key = projection_keys[projection] - for owner,axis in enumerate((first_axis,second_axis)): - spend(12) - offset = sum(a*b for a,b in zip(projection,points[owner][0,0])) - slope = sum(a*b for a,b in zip(projection,ends[owner][axis])) - result[owner][key,axis] = offset,slope - except Stopped: - return None - return result - - -class AffineParameterConstraints: - """Cached exact contraction of four global parameter intervals. - - ``affine_maps`` contains the two dictionaries returned by - ``_pure_affine_coordinates``: keys are (coordinate_key, local_axis), - values are exact (offset, slope). The caller owns that identity proof. - Keys may also identify exact common world projections proven by the - constraints-only augmentation; the same key must mean the same q.P. - - ``contract(box)`` returns a necessary box containing every root in the - input box. ``None`` means the affine constraints prove it empty. - Insufficient work returns the input box unchanged and sets ``exhausted``; - denied work must never masquerade as an empty-set certificate. - """ - def __init__(self, affine_maps, *, charge=None): - self.charge = charge - self.exhausted = False - self.empty = False - self.identity = False - self.components = None - self.cache = {} - if len(affine_maps) != 2: - raise ValueError('Expected two certified source coordinate maps') - relations = [] - coordinates = sorted({coordinate for mapping in affine_maps - for coordinate,axis in mapping}) - for coordinate in coordinates: - for first_axis in (0, 1): - a = affine_maps[0].get((coordinate, first_axis)) - if a is None: - continue - for second_axis in (0, 1): - b = affine_maps[1].get((coordinate, second_axis)) - if b is not None: - relations.append((first_axis, 2+second_axis, a, b)) - if not self._spend(max(1, 4+4*len(relations))): - return - edges = [[] for _ in range(4)] - fixed_variables = [] - for x, y, left, right in relations: - a, b = map(_fraction, left) - c, d = map(_fraction, right) - if not b and not d: - if a != c: - self.empty = True - return - elif not b: - fixed_variables.append((y, (a-c)/d)) - elif not d: - fixed_variables.append((x, (c-a)/b)) - else: - alpha, beta = (a-c)/d, b/d - edges[x].append((y, alpha, beta)) - edges[y].append((x, -alpha/beta, 1/beta)) - components = [] - visited = set() - for root in range(4): - if root in visited: - continue - expressions = {root: (Fraction(0), Fraction(1))} - stack = [root] - fixed_root = None - while stack: - current = stack.pop() - visited.add(current) - alpha, beta = expressions[current] - for target, shift, scale in edges[current]: - proposed = shift+scale*alpha, scale*beta - if target not in expressions: - expressions[target] = proposed - stack.append(target) - continue - prior_alpha, prior_beta = expressions[target] - delta = prior_beta-proposed[1] - if not delta: - if prior_alpha != proposed[0]: - self.empty = True - return - else: - value = (proposed[0]-prior_alpha)/delta - if fixed_root is not None and fixed_root != value: - self.empty = True - return - fixed_root = value - for variable, value in fixed_variables: - if variable in expressions: - alpha, beta = expressions[variable] - value = (value-alpha)/beta - if fixed_root is not None and fixed_root != value: - self.empty = True - return - fixed_root = value - components.append((tuple((variable, *values) for variable, values - in sorted(expressions.items())), fixed_root)) - self.components = tuple(components) - self.identity = all(len(expressions) == 1 and fixed_root is None - for expressions, fixed_root in self.components) - - def _spend(self, amount=1): - if self.exhausted: - return False - if self.charge is None or self.charge(int(amount)): - return True - self.exhausted = True - return False - - def contract(self, box): - key = tuple(tuple(float(value) for value in pair) for pair in box) - if len(key) != 4 or any(len(pair) != 2 or not np.all(np.isfinite(pair)) for pair in key): - raise ValueError('Expected four finite parameter intervals') - if any(lo > hi for lo, hi in key) or self.empty: - return None - if self.identity: - # The paid constructor found no relation among parameters and - # no fixed coordinate. Every box is unchanged, so there is no - # interval algebra to allocate, cache, or pay for on each call. - return key - if key in self.cache: - return self.cache[key] - if self.components is None or not self._spend(4+len(self.components)): - return key - exact_box = tuple(tuple(_fraction(value) for value in pair) for pair in key) - output = list(key) - for expressions, fixed_root in self.components: - root_low = root_high = fixed_root - for variable, alpha, beta in expressions: - lo, hi = exact_box[variable] - interval = sorted(((lo-alpha)/beta, (hi-alpha)/beta)) - root_low = interval[0] if root_low is None else max(root_low, interval[0]) - root_high = interval[1] if root_high is None else min(root_high, interval[1]) - if root_low > root_high: - self.cache[key] = None - return None - for variable, alpha, beta in expressions: - exact = sorted((alpha+beta*root_low, alpha+beta*root_high)) - lo, hi = _outward_interval(exact) - output[variable] = max(key[variable][0], lo), min(key[variable][1], hi) - self.cache[key] = tuple(output) - return self.cache[key] - - def canonical_candidate(self, point): - """Propose a NEW exactly affine-consistent floating parameter tuple. - - This is not root identity or existence. One lowest-axis coordinate - per free component supplies a proposed root parameter; the caller - must independently validate the resulting source residual and its - intended domain. Nonrepresentable exact images return None. - """ - if self.empty or self.components is None or self.exhausted: - return None - if not self._spend(4+len(self.components)): - return None - try: - values = tuple(_fraction(value) for value in point) - except (ValueError, OverflowError): - return None - if len(values) != 4: - raise ValueError('Expected four candidate parameters') - result = np.empty(4) - for expressions, fixed_root in self.components: - variable, alpha, beta = expressions[0] - root = ((values[variable]-alpha)/beta if fixed_root is None else fixed_root) - for variable, alpha, beta in expressions: - exact = alpha+beta*root - try: - rounded = float(exact) - except OverflowError: - return None - if not np.isfinite(rounded) or _fraction(rounded) != exact: - return None - result[variable] = rounded - return result - - def face_key(self, axis, value): - """Canonical exact key for an equivalent affine slice of the zero set. - - None means no paid key is available. ('empty',) means the source - constraints themselves rule out that face. Keep the original - requested axis/value when computing a missing source census: - rounding the canonical Fraction value would define another face. - """ - if self.exhausted or not self._spend(): - return None - if self.empty: - return ('empty',) - if self.components is None: - return None - if axis not in range(4): - raise ValueError('Expected a global parameter axis') - exact_value = _fraction(value) - for component, (expressions, fixed_root) in enumerate(self.components): - for variable, alpha, beta in expressions: - if variable == axis: - root = (exact_value-alpha)/beta - if fixed_root is not None and root != fixed_root: - return ('empty',) - return component, root - raise ValueError('Missing affine component') - - def rebind_source_certificate(self, certificate, axis, value): - """Describe one proved source root on an exactly equivalent face. - - The certificate must come from the SAME source pair as the affine - maps. This returns only new exact metadata. A caller reusing an - entire floating root record must separately require that its - published coordinate already equals the new pin, or revalidate a - changed representative against both original sources. - """ - if certificate.get('kind') != 'exact_source_planar_cut': - return None - def decode(pair): - return Fraction(int(pair[0]), int(pair[1])) - old_key = self.face_key(certificate['axis'], decode(certificate['cut'])) - new_key = self.face_key(axis, value) - if old_key is None or old_key == ('empty',) or old_key != new_key: - return None - exact_value = _fraction(value) - box = [tuple(decode(endpoint) for endpoint in pair) - for pair in certificate['parameter_root_box']] - # A valid root enclosure must contain the implied coordinate. - if not box[axis][0] <= exact_value <= box[axis][1]: - return None - box[axis] = (exact_value, exact_value) - pins = {key: decode(pair) for key, pair in certificate['pinned']} - if axis in pins and pins[axis] != exact_value: - return None - pins[axis] = exact_value - pair = lambda number: (str(number.numerator), str(number.denominator)) - rebound = dict(certificate) - rebound.update(axis=axis, cut=pair(exact_value), - pinned=tuple((key,pair(number)) for key,number in sorted(pins.items())), - parameter_root_box=tuple(tuple(pair(number) for number in interval) - for interval in box)) - return rebound diff --git a/mmcore/numeric/intersection/ssx/_ssx_affine_path.py b/mmcore/numeric/intersection/ssx/_ssx_affine_path.py deleted file mode 100644 index 9ffe5f1e..00000000 --- a/mmcore/numeric/intersection/ssx/_ssx_affine_path.py +++ /dev/null @@ -1,464 +0,0 @@ -"""Straight source-isocurve paths with an exact planar inclusion proof. - -This tier avoids numerical continuation along a high-multiplicity zero: -a small residual there gives no useful bound on distance to the locus. -Only the prescribed isocurve is discharged; other surface components remain -the caller's responsibility. -""" -from fractions import Fraction -from math import comb - -import numpy as np - -from mmcore.numeric.intersection.csx._planar_overlap import ( - _exact_points, _convex_chart, _sub, _cross, _dot, _cross2, -) -from mmcore.numeric.intersection._root_box_certificate import exact_bernstein_value - - -def _split(net, t): - rows, left, right = list(net), [], [] - while rows: - left.append(rows[0]) - right.append(rows[-1]) - rows = [tuple((1-t)*a+t*b for a, b in zip(x, y)) - for x, y in zip(rows, rows[1:])] - return left, right[::-1] - - -def _isocurve(net, start, end, rational): - changing = [axis for axis in range(2) if start[axis] != end[axis]] - if len(changing) != 1: - return None - axis = changing[0] - fixed = 1-axis - source = np.asarray(net).swapaxes(0, 1) if fixed == 1 else np.asarray(net) - # De Casteljau in the fixed parameter, independently for each remaining - # control point. Preserve the source binary coefficients exactly. - curve = [exact_bernstein_value(source[:, i], (start[fixed],)) - for i in range(source.shape[1])] - if not rational: - curve = [tuple(row)+(Fraction(1),) for row in curve] - lo, hi = start[axis], end[axis] - if hi < lo: - curve, lo, hi = curve[::-1], 1-lo, 1-hi - if not (0 <= lo < hi <= 1): - return None - if lo: - _, curve = _split(curve, lo) - if hi < 1: - curve, _ = _split(curve, (hi-lo)/(1-lo)) - return curve - - -def _restrict_exact_curve(curve, start, end): - if start == end: - left, _ = _split(curve, start) - return [left[-1]] - if end < start: - curve, start, end = curve[::-1], 1-start, 1-end - if start: - _, curve = _split(curve, start) - if end < 1: - curve, _ = _split(curve, (end-start)/(1-start)) - return curve - - -def _surface_rectangle(net, start, end, rational): - """Exact homogeneous restriction, also allowing reversed/fixed axes.""" - source = np.asarray(net, dtype=float) - work = np.empty(source.shape, dtype=object) - for index in np.ndindex(source.shape): - work[index] = Fraction.from_float(float(source[index])) - if not rational: - work = np.concatenate((work, np.full(work.shape[:-1]+(1,), Fraction(1), dtype=object)), axis=-1) - return _restrict_homogeneous_rectangle(work, start, end) - - -def _restrict_homogeneous_rectangle(work, start, end): - """Restrict exact homogeneous coefficients without a float conversion.""" - for axis in range(2): - oriented = work.swapaxes(0, axis) - curves = [_restrict_exact_curve(list(map(tuple, oriented[:, j])), start[axis], end[axis]) - for j in range(oriented.shape[1])] - work = np.asarray(curves, dtype=object).swapaxes(0, 1).swapaxes(0, axis) - return work - - -def _interval_product(a, b): - values = [x*y for x in a for y in b] - return min(values), max(values) - - -def _interval_difference(a, b): - return a[0]-b[1], a[1]-b[0] - - -def _coefficient_ranges(net): - rows = net.reshape(-1, net.shape[-1]) - return [(min(rows[:, k]), max(rows[:, k])) for k in range(net.shape[-1])] - - -def _source_jet_work(source, rational): - """Scalar conversion/first-difference operations for one original net.""" - m, n = source.shape[:2] - coefficients = source.size+(0 if rational else m*n) - differences = 4*((m-1)*n+m*(n-1)) - return coefficients+2*differences # one subtraction and one degree product - - -def _exact_source_jet(source, rational): - exact = np.empty(source.shape, dtype=object) - for index in np.ndindex(source.shape): - exact[index] = Fraction.from_float(float(source[index])) - if not rational: - exact = np.concatenate((exact, np.full(exact.shape[:2]+(1,), Fraction(1), dtype=object)), axis=-1) - derivatives = tuple((exact.shape[axis]-1)*np.diff(exact, axis=axis) - if exact.shape[axis] > 1 else None for axis in (0, 1)) - exact.flags.writeable = False - for derivative in derivatives: - if derivative is not None: - derivative.flags.writeable = False - return exact, derivatives - - -def _restriction_work(shape, start, end): - """Operations actually scheduled by exact tensor De Casteljau trims. - -Each interpolation performs two products, an addition and ``1-t``. -Both optional splits and the reduced size after a fixed axis are counted; -coefficient visits account for arranging the returned tensor even on an -unrestricted axis. Query pricing uses 128 scalar-operation blocks. - """ - m, n, width = shape - dimensions, operations = [m, n], 0 - for axis in (0, 1): - length, curves = dimensions[axis], dimensions[1-axis] - fixed = start[axis] == end[axis] - splits = 1 if fixed else int(start[axis] != 0)+int(end[axis] < 1) - interpolations = length*(length-1)//2 - operations += length*curves*width+4*interpolations*curves*width*splits - if not fixed and end[axis] < 1: - operations += 3*curves # (end-start)/(1-start) for each curve - if fixed: - dimensions[axis] = 1 - return operations - - -class SourceArcChordBounds: - """Prepaid immutable source jets reused by many exact arc bounds. - -Existence, connectedness and enclosure in each query box remain caller -premises. GLOBAL source cofactors bound parameter rates along a strict -monotone coordinate; exact rational jets and source endpoint enclosures -then bound the entire arc's deviation from its reported XYZ chord. -The public linear STUV path still requires its separate certificate. - """ - def __init__(self, first, second, *, rational=True, charge=None): - self.charge = charge - self.jets = None - self.exhausted = False - sources = tuple(np.asarray(net) for net in (first, second)) - if any(net.ndim != 3 or net.shape[-1] != (4 if rational else 3) - or min(net.shape[:2]) < 1 for net in sources): - return - if not self._spend(sum(_source_jet_work(net, rational) for net in sources)): - return - try: - self.jets = tuple(_exact_source_jet(net, rational) for net in sources) - except (ValueError, OverflowError): - self.jets = None - - def _spend(self, operations): - if self.exhausted: - return False - if self.charge is not None and not self.charge(max(1, (operations+127)//128)): - self.exhausted = True - return False - return True - - def _restrict(self, net, start, end): - if not self._spend(_restriction_work(net.shape, start, end)): - return None - return _restrict_homogeneous_rectangle(net, start, end) - - def _centered_ranges(self, restricted, origin): - rows = restricted.shape[0]*restricted.shape[1] - # Three products/subtractions per coefficient, then two extrema - # comparisons per homogeneous coefficient. - if not self._spend((6+8)*rows): - return None - centered = restricted.copy() - for k in range(3): - centered[..., k] -= origin[k]*centered[..., 3] - return _coefficient_ranges(centered) - - def bounded(self, box, root_boxes, xyz, monotone_axis, cofactor_bounds, atol): - if self.jets is None or self.exhausted: - return False - # Input Fraction conversions, interval validation and four signed - # cofactor rate products are query work, not source construction. - if not self._spend(38+48+48+4): - return False - try: - bounds = [tuple(Fraction.from_float(float(t)) for t in pair) for pair in box] - roots = [[tuple(Fraction.from_float(float(t)) for t in pair) for pair in root] - for root in root_boxes] - endpoints = [tuple(Fraction.from_float(float(x)) for x in p) for p in xyz] - if (monotone_axis not in range(4) or len(bounds) != 4 or len(roots) != 2 - or len(endpoints) != 2 or any(len(root) != 4 for root in roots) - or any(not 0 <= lo <= hi <= 1 for lo, hi in bounds) - or any(not 0 <= lo <= hi <= 1 for root in roots for lo, hi in root)): - return False - minors = [] - for k, (lo, hi) in enumerate(zip(*cofactor_bounds)): - lo, hi = Fraction.from_float(float(lo)), Fraction.from_float(float(hi)) - minors.append((lo, hi) if k % 2 == 0 else (-hi, -lo)) - denominator = minors[monotone_axis] - if denominator[0] <= 0 <= denominator[1]: - return False - reciprocal = tuple(sorted(1/x for x in denominator)) - rates = [_interval_product(minor, reciprocal) for minor in minors] - rates[monotone_axis] = (Fraction(1), Fraction(1)) - delta = roots[1][monotone_axis][1]-roots[0][monotone_axis][0] - if delta <= 0 or roots[0][monotone_axis][1] >= roots[1][monotone_axis][0]: - return False - limit = Fraction.from_float(float(atol))**2 - for owner, (exact, source_derivatives) in enumerate(self.jets): - intervals = bounds[2*owner:2*owner+2] - start, end = tuple(x[0] for x in intervals), tuple(x[1] for x in intervals) - restricted = self._restrict(exact, start, end) - if restricted is None: - return False - # Restriction, differentiation and translation are exact - # linear operations. Centering the restricted cached jets - # gives exactly the former centered-source coefficients. - values = self._centered_ranges(restricted, endpoints[0]) - if values is None: - return False - weight = values[3] - if weight[0] <= 0: - continue - if not self._spend(4+2*3*32+3*22): - return False # quotient interval arithmetic and velocity sums - inverse_weight_squared = (1/weight[1]**2, 1/weight[0]**2) - derivatives = [] - for derivative in source_derivatives: - if derivative is not None: - derivative = self._restrict(derivative, start, end) - if derivative is None: - return False - derivative_ranges = self._centered_ranges(derivative, endpoints[0]) - if derivative_ranges is None: - return False - else: - derivative_ranges = [(Fraction(0), Fraction(0))]*4 - derivatives.append([_interval_product(_interval_difference( - _interval_product(derivative_ranges[k], weight), - _interval_product(values[k], derivative_ranges[3])), - inverse_weight_squared) for k in range(3)]) - velocity = [] - for k in range(3): - first_term = _interval_product(derivatives[0][k], rates[2*owner]) - second_term = _interval_product(derivatives[1][k], rates[2*owner+1]) - velocity.append((first_term[0]+second_term[0], first_term[1]+second_term[1])) - endpoint_error = [Fraction(0)]*3 - for root, endpoint in zip(roots, endpoints): - pair = root[2*owner:2*owner+2] - enclosure = self._restrict(exact, tuple(x[0] for x in pair), tuple(x[1] for x in pair)) - if enclosure is None or not self._spend(13*enclosure.shape[0]*enclosure.shape[1]): - return False - rows = enclosure.reshape(-1, 4) - if any(row[3] <= 0 for row in rows): - return False - for k in range(3): - endpoint_error[k] = max(endpoint_error[k], - max(abs(row[k]/row[3]-endpoint[k]) for row in rows)) - if not self._spend(20): - return False - errors = [delta*(hi-lo)/4+error for (lo, hi), error in zip(velocity, endpoint_error)] - if sum(error**2 for error in errors) <= limit: - return True - except (OverflowError, ValueError, ZeroDivisionError, IndexError): - return False - return False - - -def source_arc_chord_error_bounded(first, second, box, root_boxes, xyz, - monotone_axis, cofactor_bounds, atol, - rational=True, charge=None): - """One-shot compatibility wrapper for the exact source secant bound.""" - return SourceArcChordBounds(first, second, rational=rational, charge=charge).bounded( - box, root_boxes, xyz, monotone_axis, cofactor_bounds, atol) - - -def _surface_affine_path(net, start, end, rational): - """Exact homogeneous Bernstein coefficients of S(a+t*(b-a)).""" - work = _surface_rectangle(net, start, end, rational) - m, n = (size-1 for size in work.shape[:2]) - coefficients = [[Fraction(0)]*4 for _ in range(m+n+1)] - # On the diagonal, B_i^m(t) B_j^n(t) is a positive multiple of - # B_(i+j)^(m+n)(t); no sampled fitting or rounded restriction is used. - for i in range(m+1): - for j in range(n+1): - factor = Fraction(comb(m, i)*comb(n, j), comb(m+n, i+j)) - for axis in range(4): - coefficients[i+j][axis] += factor*work[i, j, axis] - return list(map(tuple, coefficients)) - - -def source_box_image_diameter_bounded(first, second, box, xyz, atol, - rational=True, charge=None): - """Bound an entire source-box image together with the output endpoints. - - Once a separate proof places a connected intersection arc in ``box``, - either surface's image contains that arc. The positive-weight Cartesian - control hull of an exact restriction contains its complete image. A - coordinate-box diameter <=atol, including both reported endpoints, - therefore bounds distance in both directions between arc and chord. - This proves approximation only, never existence or connectedness. - """ - sources = (np.asarray(first), np.asarray(second)) - work = sum((1+sum(n-1 for n in net.shape[:2]))*net.size for net in sources) - if charge is not None and not charge(max(1, (12*work+127)//128)): - return False - try: - bounds = [tuple(Fraction.from_float(float(t)) for t in interval) for interval in box] - endpoints = [tuple(Fraction.from_float(float(x)) for x in p) for p in xyz] - if len(bounds) != 4 or len(endpoints) != 2 or any(not 0 <= lo <= hi <= 1 for lo, hi in bounds): - return False - limit = Fraction.from_float(float(atol))**2 - for owner, net in enumerate(sources): - intervals = bounds[2*owner:2*owner+2] - restricted = _surface_rectangle( - net, tuple(i[0] for i in intervals), tuple(i[1] for i in intervals), rational) - rows = restricted.reshape(-1, 4) - if any(row[-1] <= 0 for row in rows): - continue - points = [tuple(x/row[-1] for x in row[:3]) for row in rows]+endpoints - diameter_squared = sum((max(p[i] for p in points)-min(p[i] for p in points))**2 - for i in range(3)) - if diameter_squared <= limit: - return True - except (OverflowError, ValueError, ZeroDivisionError): - return False - return False - - -def affine_path_representation_bounded(first, second, start, end, xyz, - atol, rational=True, charge=None): - """Bound the complete lifted chord against both supplied source surfaces. - - This is an approximation certificate only; root existence and ownership - require separate evidence. Bounding just the smaller surface image can - hide a large off-surface excursion in the other parameterization. - """ - sources = (np.asarray(first), np.asarray(second)) - work = sum((1+sum(n-1 for n in net.shape[:2]))*net.size for net in sources) - if charge is not None and not charge(max(1, (12*work+127)//128)): - return False - try: - start, end = [tuple(Fraction.from_float(float(t)) for t in p) for p in (start, end)] - points = [tuple(Fraction.from_float(float(x)) for x in p) for p in xyz] - if len(points) != 2 or any(t < 0 or t > 1 for p in (start, end) for t in p): - return False - tolerance = Fraction.from_float(float(atol))/2 - return all(_chord_error_bounded( - _surface_affine_path(net, start[2*i:2*i+2], end[2*i:2*i+2], rational), - points, tolerance) for i, net in enumerate(sources)) - except (OverflowError, ValueError, ZeroDivisionError): - return False - - -def _bilinear_path(net, start, end, rational): - values = [exact_bernstein_value(net, tuple((1-t)*a+t*b for a, b in zip(start, end))) - for t in (Fraction(0), Fraction(1, 2), Fraction(1))] - if not rational: - values = [tuple(row)+(Fraction(1),) for row in values] - return [values[0], tuple(2*m-(a+b)/2 for a, m, b in zip(*values)), values[2]] - - -def _chord_error_bounded(curve, endpoints, tolerance): - """Exact homogeneous Bernstein hull bound against a Cartesian chord.""" - degree = len(curve)-1 - minimum_weight = min(row[-1] for row in curve) - if minimum_weight <= 0: - return False - limit = tolerance**2 * minimum_weight**2 - zero = (Fraction(0),)*4 - for i in range(degree+2): - a = curve[i-1] if i else zero - b = curve[i] if i <= degree else zero - alpha = Fraction(i, degree+1) - residual = tuple(alpha*a[j]+(1-alpha)*b[j] - - alpha*endpoints[1][j]*a[-1] - - (1-alpha)*endpoints[0][j]*b[-1] for j in range(3)) - if sum(x*x for x in residual) > limit: - return False - return True - - -def certified_straight_isocurve_path(first, second, start, end, atol, - rational=True, charge=None): - """Return a two-vertex path when exact source geometry proves the arc. - - A positive-weight straight isocurve with ordered Cartesian control - points covers its endpoint segment exactly once. Exact plane identity - and convex quadrilateral inclusion give a unique preimage on the other - patch. Bernstein bounds also verify the linear public UV interpolation - and reported XYZ against both source surfaces within ``atol``. - """ - source = (np.asarray(first), np.asarray(second)) - parameters = [tuple(Fraction.from_float(float(x)) for x in p) for p in (start, end)] - if any(x < 0 or x > 1 for p in parameters for x in p): - return None - tolerance = Fraction.from_float(float(atol))/2 - for owner in (0, 1): - target = source[1-owner] - if target.shape[:2] != (2, 2): - continue - a, b = [p[2*owner:2*owner+2] for p in parameters] - if sum(x != y for x, y in zip(a, b)) != 1: - continue - work = sum((1+sum(n-1 for n in net.shape[:2]))*net.size for net in source) - cost = max(1, (6*work+127)//128) - if charge is not None and not charge(cost): - return None - curve = _isocurve(source[owner], a, b, rational) - if curve is None or any(row[-1] <= 0 for row in curve): - continue - points = [tuple(x/row[-1] for x in row[:3]) for row in curve] - direction = _sub(points[-1], points[0]) - if not any(direction) or any(any(_cross(_sub(p, points[0]), direction)) for p in points): - continue - axis = max(range(3), key=lambda i: abs(direction[i])) - positions = [(p[axis]-points[0][axis])/direction[axis] for p in points] - if any(x > y for x, y in zip(positions, positions[1:])): - continue - target_data = _exact_points(target, rational) - if target_data is None: - continue - target_points, weights = target_data - if any(w != weights[0] for w in weights): - continue - chart = _convex_chart(target_points) - if chart is None: - continue - origin, normal, axes, quad, edges, orientation = chart - if any(_dot(normal, _sub(p, origin)) for p in points): - continue - if any(orientation*_cross2(edge, _sub(tuple(p[i] for i in axes), q)) < 0 - for p in (points[0], points[-1]) for edge, q in zip(edges, quad)): - continue - try: - xyz = np.asarray([[float(x) for x in p] for p in (points[0], points[-1])]) - represented = [tuple(Fraction.from_float(float(x)) for x in p) for p in xyz] - except (OverflowError, ValueError): - continue - uv_start, uv_end = [p[2*(1-owner):2*(1-owner)+2] for p in parameters] - target_curve = _bilinear_path(target, uv_start, uv_end, rational) - if not (_chord_error_bounded(curve, represented, tolerance) - and _chord_error_bounded(target_curve, represented, tolerance)): - continue - return np.asarray([start, end], dtype=float), xyz - return None diff --git a/mmcore/numeric/intersection/ssx/_ssx_arc_ownership.py b/mmcore/numeric/intersection/ssx/_ssx_arc_ownership.py deleted file mode 100644 index 39f0bf5b..00000000 --- a/mmcore/numeric/intersection/ssx/_ssx_arc_ownership.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Containment of exact parameter paths already proved to be source zero arcs. - -These paths are certificates supplied by exact source tiers, not samples of -an approximate curve. Their exact affine span maps survive NURBS assembly. -""" -from fractions import Fraction - -import numpy as np - -from mmcore.numeric.intersection.ssx._ssx_polyline import _covered_fraction - - -def source_path_covered(path, keepers, charge=None): - """Whether exact source path is covered by exact source paths, or unknown.""" - if path is None or not keepers: - return False - if charge is not None and not charge(max(1,len(path)+sum(len(keeper) for keeper in keepers if keeper is not None))): - return None - if any(len(row) != 4 or any(not isinstance(x,Fraction) for x in row) for row in path): - return False - segments = [] - for keeper in keepers: - if keeper is None or any(len(row) != 4 or any(not isinstance(x,Fraction) for x in row) - for row in keeper): - continue - segments.extend(zip(keeper,keeper[1:])) - if len(path) < 2 or not segments: - return False - for a,b in zip(path,path[1:]): - intervals = [] - for c,d in segments: - if charge is not None and not charge(1): - return None - if any(max(x,y) < min(z,w) or max(z,w) < min(x,y) - for x,y,z,w in zip(a,b,c,d)): - continue - if charge is not None and not charge(128): - return None - p,q,r,s = (np.asarray(row,dtype=object) for row in (a,b,c,d)) - interval = _covered_fraction(p,q-p,r,s-r,(Fraction(0),)*4,exact=True) - if interval is not None: - intervals.append(interval) - reach = Fraction(0) - for lo,hi in sorted(intervals): - if lo > reach: - break - reach = max(reach,hi) - if reach < 1: - return False - return True - - -def map_source_path(path, bounds): - if path is None: - return None - axes = [(Fraction.from_float(float(lo)),Fraction.from_float(float(hi))) for lo,hi in bounds] - return tuple(tuple(lo+x*(hi-lo) for x,(lo,hi) in zip(row,axes)) for row in path) diff --git a/mmcore/numeric/intersection/ssx/_ssx_boundary_fibers.py b/mmcore/numeric/intersection/ssx/_ssx_boundary_fibers.py deleted file mode 100644 index 76d19047..00000000 --- a/mmcore/numeric/intersection/ssx/_ssx_boundary_fibers.py +++ /dev/null @@ -1,253 +0,0 @@ -"""Original-source collapsed boundary seeds, with no census completeness claim. - -Exact constant-image edges own parameter fibers. Almost collapsed edges may -suggest a numerical continuation seed, but never acquire a fiber certificate. -Both leave the entire source face unresolved: enumerating its target boundary -preimages does not enumerate every target interior preimage or incident arc. -""" -from fractions import Fraction - -import numpy as np - -from mmcore.numeric._bezier_common import eval_surface, eval_surface_d1, geometry_collapsed -from mmcore.numeric.intersection._exact_univariate import ( - coefficient_build_work, isolate_root_intervals, _power, _gcd, _refine, -) -from mmcore.numeric.intersection._root_box_certificate import exact_bernstein_value - - -def _edge(surface, axis, side): - return surface[0 if side == 0 else -1] if axis == 0 else surface[:, 0 if side == 0 else -1] - - -def _point(net, parameters): - value = exact_bernstein_value(net, parameters) - return tuple(x/value[3] for x in value[:3]) - - -def _point_work(net): - return int(net.size)*(1+sum(size-1 for size in net.shape[:-1])) - - -def _planar_point_preimage(point, target, tick): - """Exact membership in an injective convex polynomial bilinear chart.""" - if target.shape != (2, 2, 4) or not np.all(target[..., 3] == target[0, 0, 3]): - return None - from mmcore.numeric.intersection.csx._planar_overlap import ( - _exact_points, _convex_chart, _dot, _sub, _cross2) - from mmcore.numeric.intersection.csx._planar_roots import _bilinear_inverse - tick(8*target.size) - points, _ = _exact_points(target, True) - chart = _convex_chart(points) - if chart is None: - return None - origin, normal, axes, quad, edges, orientation = chart - projected = tuple(point[i] for i in axes) - if (_dot(normal, _sub(point, origin)) != 0 or any( - orientation*_cross2(edges[i], _sub(projected, quad[i])) < 0 for i in range(4))): - return None - a, c_end, b_end, opposite = points - b, c = _sub(b_end, a), _sub(c_end, a) - if all(opposite[i] == a[i]+b[i]+c[i] for i in range(3)): - i, j = axes - det = b[i]*c[j]-b[j]*c[i] - offset = _sub(point, a) - u = (offset[i]*c[j]-offset[j]*c[i])/det - v = (b[i]*offset[j]-b[j]*offset[i])/det - return (float(u), float(v)), ((u, u), (v, v)) - uv = _bilinear_inverse(np.array([float(x) for x in point]), - target[..., :3]/target[..., 3:], axes) - if uv is None: - return None - return tuple(uv), ((Fraction(0), Fraction(1)),)*2 - - -def source_boundary_fiber_candidate(first, second, axis, side): - """Cheap proposal gate only; exact constant-image proof is separate.""" - source = first if axis < 2 else second - curve = _edge(np.asarray(source), axis % 2, side) - return (bool(np.all(np.isfinite(curve))) and bool(np.all(curve[:, 3] > 0)) - and geometry_collapsed(curve[:, :3]/curve[:, 3:])) - - -def source_boundary_fiber_seeds(first, second, axis, side, *, - max_cells=2000, max_results=128, atol=1e-3): - """Discover exact fibers or explicitly numerical seeds on one source face. - - Inputs are the original positive-weight homogeneous nets. The exact path - uses P=cW on every owner coefficient, then common-polynomial GCD/Sturm - roots on each target boundary edge. The numerical path is a bounded point - inversion proposal on the original target. Neither can discharge a face. - """ - first, second = np.asarray(first, dtype=float), np.asarray(second, dtype=float) - if (axis not in range(4) or side not in (0., 1.) - or any(net.ndim != 3 or net.shape[-1] != 4 - or not np.all(np.isfinite(net)) or np.any(net[..., 3] <= 0) - for net in (first, second))): - return None - if not source_boundary_fiber_candidate(first, second, axis, side): - return None - owner, target = (first, second) if axis < 2 else (second, first) - curve = _edge(owner, axis % 2, side) - result = dict(isolated=[], overlaps=[], parameter_fibers=[], - boundary_seed_proposals=[], cells_processed=0, - budget_exhausted=False, boundary_topology_complete=False, - truncation_cause='parameter_fiber') - limit = max(0, int(max_cells)) - output_limit = max(0, int(max_results)) - - class Denied(Exception): - pass - - def tick(amount=1): - amount = max(1, int(amount)) - if amount > limit-result['cells_processed']: - raise Denied - result['cells_processed'] += amount - - def append(key, entry): - if len(result['parameter_fibers'])+len(result['boundary_seed_proposals']) >= output_limit: - result['budget_exhausted'] = True - result['truncation_cause'] = 'results' - return False - tick() - result[key].append(entry) - return True - - try: - tick(max(1, curve.size)) - rows = [tuple(Fraction(float(x)) for x in row) for row in curve] - point = tuple(x/rows[0][3] for x in rows[0][:3]) - exact_owner = all(all(row[k] == point[k]*row[3] for k in range(3)) for row in rows) - exact_atol2 = Fraction(float(atol))**2 - if exact_owner: - planar = _planar_point_preimage(point, target, tick) - if planar is not None: - uv, uv_box = planar - tick(_point_work(target)) - representative = _point(target, tuple(Fraction(float(t)) for t in uv)) - xyz = np.array([float(x) for x in point]) - if (sum((a-b)**2 for a, b in zip(point, representative)) <= exact_atol2/16 - and sum((Fraction(float(a))-b)**2 for a, b in zip(xyz, point)) <= exact_atol2/16): - append('parameter_fibers', dict( - t_range=(0., 1.), u=uv[0], v=uv[1], point=xyz, - certification='exact_source_parameter_fiber', - source_fiber_certificate=dict( - source_ids=(id(first), id(second)), axis=axis, side=side, - point=point, target_parameter_box=uv_box, - target_proof='exact_plane_convex_quad_point_inclusion'))) - else: - result['truncation_cause'] = 'resolution' - return result - seen = set() - for target_axis in (0, 1): - for target_side in (0., 1.): - edge = _edge(target, target_axis, target_side) - tick(3*coefficient_build_work(len(edge))+edge.size) - target_rows = [tuple(Fraction(float(x)) for x in row) for row in edge] - polynomials = [_power([row[k]-point[k]*row[3] for row in target_rows]) - for k in range(3)] - nonzero = [p for p in polynomials if any(p)] - if not nonzero: - polynomial, sequence, intervals = None, None, [(Fraction(1, 2),)*2] - else: - polynomial = nonzero[0] - for component in nonzero[1:]: - polynomial = _gcd(polynomial, component, tick) - if len(polynomial) == 1: - break - polynomial, _, sequence, intervals = isolate_root_intervals(polynomial, tick) - for interval in intervals: - while interval[0] != interval[1] and np.nextafter(float(interval[0]), np.inf) < float(interval[1]): - interval = _refine(polynomial, sequence, interval, tick) - t = float(sum(interval)/2) - uv = (target_side, t) if target_axis == 0 else (t, target_side) - if interval[0] == interval[1]: - exact_uv = ((Fraction(target_side), interval[0]) if target_axis == 0 - else (interval[0], Fraction(target_side))) - root_key = ('exact_parameter', exact_uv) - else: - root_key = ('edge_root', target_axis, target_side, - tuple(polynomial), interval) - if root_key in seen: - continue - tick(_point_work(edge)) - representative = _point(edge, (Fraction(t),)) - xyz = np.array([float(x) for x in point]) - if (sum((a-b)**2 for a, b in zip(point, representative)) > exact_atol2/16 - or sum((Fraction(float(a))-b)**2 for a, b in zip(xyz, point)) > exact_atol2/16): - result['truncation_cause'] = 'resolution' - continue - entry = dict(t_range=(0., 1.), u=uv[0], v=uv[1], point=xyz, - certification='exact_source_parameter_fiber', - source_fiber_certificate=dict( - source_ids=(id(first), id(second)), axis=axis, side=side, - point=point, target_axis=target_axis, target_side=target_side, - target_interval=interval, - target_interval_open=interval[0] != interval[1], - target_polynomial=None if polynomial is None else tuple(polynomial))) - if not nonzero: - entry['v_range' if target_axis == 0 else 'u_range'] = (0., 1.) - if not append('parameter_fibers', entry): - return result - seen.add(root_key) - return result - - # The tiny edge extent is a proposal gate, never an exact identity. - # A centered, rescaled target improves numerical inversion without - # changing the original coefficients used to validate the proposal. - tick(_point_work(curve)+target.size) - query = _point(curve, (Fraction(1, 2),)) - query_float = np.array([float(x) for x in query]) - local = target/float(np.max(target[..., 3])) - local = local.copy() - local[..., :3] -= query_float*local[..., 3:] - scale = float(np.max(np.abs(local[..., :3]))) - if not np.isfinite(scale): - return result - if scale > 0: - local[..., :3] /= scale - evaluation_work = max(1, (target.size+31)//32) - best, distance = None, np.inf - for u in np.linspace(0., 1., 5): - for v in np.linspace(0., 1., 5): - tick(evaluation_work) - value = eval_surface(local, u, v, rational=True) - distance2 = float(value@value) - if distance2 < distance: - best, distance = np.array([u, v]), distance2 - if best is None: - return result - for _ in range(32): - tick(3*evaluation_work) - value, du, dv = eval_surface_d1(local, *best, rational=True) - try: - step = np.linalg.lstsq(np.column_stack((du, dv)), -value, rcond=None)[0] - except np.linalg.LinAlgError: - break - updated = np.clip(best+step, 0., 1.) - if np.array_equal(updated, best): - break - best = updated - tick(_point_work(target)+curve.size) - target_point = _point(target, tuple(Fraction(float(x)) for x in best)) - # The full source edge lies in its positive-weight Cartesian hull. - # These are representation bounds only; they cannot prove a zero. - owner_points = [tuple(row[k]/row[3] for k in range(3)) for row in rows] - if (sum((a-b)**2 for a, b in zip(query, target_point)) <= exact_atol2/16 - and max(sum((a-b)**2 for a, b in zip(query, p)) for p in owner_points) <= exact_atol2/16 - and sum((Fraction(float(a))-b)**2 for a, b in zip(query_float, query)) <= exact_atol2/16): - append('boundary_seed_proposals', dict( - t_range=(0., 1.), u=float(best[0]), v=float(best[1]), point=query_float, - certification='numerical_seed_proposal', - owner_edge_exactly_collapsed=False)) - result['truncation_cause'] = 'resolution' - except Denied: - result['budget_exhausted'] = True - result['truncation_cause'] = 'max_cells' - except (OverflowError, ValueError, ZeroDivisionError): - # Exact source membership can exist without a finite representable - # Cartesian point or inverse. Optional seed fitting must not abort - # the caller or turn that failure into an empty/complete census. - result['truncation_cause'] = 'resolution' - return result diff --git a/mmcore/numeric/intersection/ssx/_ssx_boundary_identity.py b/mmcore/numeric/intersection/ssx/_ssx_boundary_identity.py deleted file mode 100644 index ef5b857b..00000000 --- a/mmcore/numeric/intersection/ssx/_ssx_boundary_identity.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Sufficient identity checks for numerical retraces of one exact boundary. - -The adapter must first establish that both paths use the same exact -source boundary curve and source parameterization. This module supplies -the target-preimage proof and continuous approximation check. -""" -from fractions import Fraction - -import numpy as np - -from mmcore.numeric.intersection.csx._planar_overlap import exact_planar_bilinear_overlap -from mmcore.numeric.intersection.ssx._ssx_polyline import polyline_contained - - -def certified_boundary_retrace(stuv, xyz, keeper_stuv, keeper_xyz, - curve, surface, curve_axis, surface_axes, - parameter_domain, param_tol, xyz_tol, charge=None): - """Prove a retrace of a known shared source curve is already represented. - - Exact planar inclusion gives existence and a unique target preimage - for every source-curve parameter. Source parameters use zero slack; - tolerance only validates the numerical representation of that unique - correspondence. Unsupported target charts retain both paths. - """ - s, k = np.asarray(stuv), np.asarray(keeper_stuv) - if len(s) < 2 or len(k) < 2: - return False - lo, hi = map(float, parameter_domain) - if hi <= lo: - return False - if (s[:, curve_axis].min() < k[:, curve_axis].min() - or s[:, curve_axis].max() > k[:, curve_axis].max()): - return False - if charge is not None and not charge(max(1, len(curve)*surface.shape[0]*surface.shape[1])): - return None - proof = exact_planar_bilinear_overlap(curve, surface, rational=True) - if not proof: - return False - # Compare against the exact clipped interval, never rounded boundary - # roots. This also bounds any source subcurve used by the keeper. - start, end = proof[0]['exact_t_range'] - lower, upper = Fraction(int(start[0]), int(start[1])), Fraction(int(end[0]), int(end[1])) - origin, span = Fraction.from_float(lo), Fraction.from_float(hi)-Fraction.from_float(lo) - for endpoint in (k[:, curve_axis].min(), k[:, curve_axis].max()): - parameter = (Fraction.from_float(float(endpoint))-origin)/span - if not lower <= parameter <= upper: - return False - tolerances = np.array(param_tol, dtype=float, copy=True) - for axis in range(4): - if axis not in surface_axes: - tolerances[axis] = 0. - return polyline_contained(s, xyz, k, keeper_xyz, tolerances, xyz_tol, charge=charge) diff --git a/mmcore/numeric/intersection/ssx/_ssx_boundary_strata.py b/mmcore/numeric/intersection/ssx/_ssx_boundary_strata.py deleted file mode 100644 index acc3a410..00000000 --- a/mmcore/numeric/intersection/ssx/_ssx_boundary_strata.py +++ /dev/null @@ -1,298 +0,0 @@ -"""Exact boundary-only SSX when a surface stays on one side of a plane. - -A nonzero, one-sign Bernstein height polynomial is strictly nonzero in -the open parameter square. Its zeros are exactly its identically zero -boundary edges and any remaining zero corners. This tier supports affine -source edges and an injective convex polynomial bilinear target plane. -""" -from fractions import Fraction - -import numpy as np - -from mmcore.numeric._work_budget import REASON_PARAMETER_REPRESENTATION, REASON_WORK_BUDGET -from mmcore.numeric.intersection.csx._planar_overlap import _convex_chart, _sub, _dot, _cross2 -from mmcore.numeric.intersection.csx._planar_roots import _bilinear_inverse -from mmcore.numeric.intersection.ssx._ssx_extrusion import _uniform_cartesian -from mmcore.numeric.intersection.ssx._ssx_affine_path import affine_path_representation_bounded -from mmcore.numeric.intersection.ssx._ssx_substrate import SSXBranch, SSXPoint -from mmcore.numeric.intersection.ssx._ssx_singular_candidate import _cross, _evaluate -from mmcore.numeric.intersection.ssx._ssx_opposed_strata import ( - opposed_boundary_strata_ssx) - - -def _append_planar_junctions(result, source, owner, chart, budget): - """Shared exact source endpoints with a unique convex target inverse. - - The target UV representative may be algebraic and cannot be treated - as an exact binary root. Its existence and uniqueness instead follow - from exact planar inclusion in the already validated convex chart. - """ - from mmcore.numeric.intersection.ssx._bez_ssx5 import SSXSingularity - origin, target_normal, axes, quad, edges, orientation = chart - endpoints, published, aliases = {}, {}, set() - for index, branch in enumerate(result['branches']): - stuv, xyz = branch.curve - for key, end in zip(branch._boundary_source_endpoints, (0,len(stuv)-1)): - floating = tuple(stuv[end]) - if floating in published and published[floating] != key: - aliases.add(floating) - published[floating] = key - endpoints.setdefault(key, []).append((index,end,stuv[end],xyz[end])) - for point in result['points']: - key = point._boundary_source_parameter - floating = tuple(point.stuv) - if floating in published and published[floating] != key: - aliases.add(floating) - published[floating] = key - if aliases: - budget.mark_incomplete(REASON_PARAMETER_REPRESENTATION) - budget.append_output(result['unresolved_regions'], dict( - stuv_min=(0.,)*4,stuv_max=(1.,)*4, - reason=REASON_PARAMETER_REPRESENTATION), 'unresolved_region') - net = np.asarray(source,dtype=object) - for key, ports in endpoints.items(): - if len({index for index,*_ in ports}) < 2 or any(tuple(port[2]) in aliases for port in ports): - continue - cost = net.size*(1+sum(n-1 for n in net.shape[:2])) - if not budget.charge_cells(max(1,cost), 'boundary_junction'): - return - point = _evaluate(net,key) - jets = [_evaluate((net.shape[axis]-1)*np.diff(net,axis=axis),key) for axis in (0,1)] - normal = _cross(*jets) - projected = tuple(point[k] for k in axes) - if (not any(normal) or any(_cross(normal,target_normal)) - or _dot(target_normal,_sub(point,origin)) - or any(orientation*_cross2(edge,_sub(projected,anchor)) < 0 - for edge,anchor in zip(edges,quad))): - continue - junction = SSXSingularity( - kind='tangent_point', stuv=np.asarray(ports[0][2]), xyz=np.asarray(ports[0][3]), - branch_links=[(index,end) for index,end,*_ in ports]) - junction._source_corner = owner,key - budget.append_output(result['singularities'],junction,'singularity') - - -def exact_boundary_strata_ssx(first, second, atol, rational, budget): - """Return an exhaustive supported result, or None for general search.""" - nets = tuple(np.asarray(net, dtype=float) for net in (first, second)) - if (any(net.ndim != 3 or net.shape[-1] != (4 if rational else 3) - or min(net.shape[:2]) < 2 or not np.all(np.isfinite(net)) for net in nets) - or not any(net.shape[:2] == (2, 2) for net in nets)): - return None - if not budget.charge_cells(max(1, sum(net.size for net in nets)), 'boundary_strata'): - return None - exact = tuple(_uniform_cartesian(net, rational) for net in nets) - if any(points is None for points in exact): - return None - for owner in (0, 1): - if nets[1-owner].shape[:2] != (2, 2): - continue - target = [point for row in exact[1-owner] for point in row] - chart = _convex_chart(target) - if chart is None: - continue - origin, normal, axes, quad, edges, orientation = chart - source = exact[owner] - heights = [[_dot(normal, _sub(point, origin)) for point in row] for row in source] - flat = [value for row in heights for value in row] - if not any(flat) or not (all(value >= 0 for value in flat) or all(value <= 0 for value in flat)): - continue - zero_edges = [] - unsupported = False - for fixed_axis in (0, 1): - for side in (0, 1): - edge = (source[0 if side == 0 else -1] if fixed_axis == 0 - else [row[0 if side == 0 else -1] for row in source]) - values = (heights[0 if side == 0 else -1] if fixed_axis == 0 - else [row[0 if side == 0 else -1] for row in heights]) - if any(values): - continue - degree = len(edge)-1 - direction = _sub(edge[-1], edge[0]) - if not any(direction) or any( - any(value != start+Fraction(i, degree)*delta - for value, start, delta in zip(point, edge[0], direction)) - for i, point in enumerate(edge)): - unsupported = True - break - zero_edges.append((fixed_axis, side, edge[0], direction)) - if unsupported: - continue - - p00, p01, p10, p11 = target - du, dv = _sub(p10, p00), _sub(p01, p00) - affine = not any(z-a-b+o for z, a, b, o in zip(p11, p10, p01, p00)) - i, j = axes - determinant = du[i]*dv[j]-du[j]*dv[i] - target_float = None - - def inverse(point): - nonlocal target_float - displacement = _sub(point, p00) - if affine: - return ((displacement[i]*dv[j]-displacement[j]*dv[i])/determinant, - (du[i]*displacement[j]-du[j]*displacement[i])/determinant) - projected = tuple(point[k] for k in axes) - for index, (edge, start) in enumerate(zip(edges, quad)): - offset = _sub(projected, start) - if _cross2(edge, offset) == 0: - coordinate = max(range(2), key=lambda k: abs(edge[k])) - t = offset[coordinate]/edge[coordinate] - if 0 <= t <= 1: - return ((t, Fraction(0)), (Fraction(1), t), - (1-t, Fraction(1)), (Fraction(0), 1-t))[index] - try: - if target_float is None: - target_float = np.asarray([[list(map(float, p00)), list(map(float, p01))], - [list(map(float, p10)), list(map(float, p11))]]) - with np.errstate(invalid='ignore', divide='ignore', over='ignore'): - uv = _bilinear_inverse(np.asarray(list(map(float, point))), target_float, axes) - except (OverflowError, ValueError): - return None - return None if uv is None else tuple(Fraction.from_float(value) for value in uv) - - def inside(point): - projected = tuple(point[k] for k in axes) - return all(orientation*_cross2(edge, _sub(projected, start)) >= 0 - for edge, start in zip(edges, quad)) - - result = dict(branches=[], points=[], singularities=[], overlap_regions=[], unresolved_regions=[]) - point_keys = set() - - def emit_point(stuv, xyz, exact_key): - # Clipping two incident edges can discover the same corner. - # The exact source parameter and target inverse uniqueness - # establish identity, including a nonbinary target preimage. - key = tuple(exact_key[2*owner:2*owner+2]) - if key not in point_keys: - point_keys.add(key) - point = SSXPoint(stuv, xyz) - point._boundary_source_parameter = key - budget.append_output(result['points'], point, 'point') - - def unresolved(reason, detail=None): - budget.mark_incomplete(reason) - record = dict(stuv_min=(0.,)*4, stuv_max=(1.,)*4, reason=reason) - if detail is not None: - record['boundary_stratum'] = detail - budget.append_output(result['unresolved_regions'], record, 'unresolved_region') - - for fixed_axis, side, start, direction in zero_edges: - lo, hi = Fraction(0), Fraction(1) - projected_start = tuple(start[k] for k in axes) - projected_direction = tuple(direction[k] for k in axes) - for edge, anchor in zip(edges, quad): - value = orientation*_cross2(edge, _sub(projected_start, anchor)) - slope = orientation*_cross2(edge, projected_direction) - if slope > 0: - lo = max(lo, -value/slope) - elif slope < 0: - hi = min(hi, -value/slope) - elif value < 0: - lo, hi = Fraction(1), Fraction(0) - break - if hi < lo: - continue - samples, sample_keys = {}, {} - def sample(parameter): - if parameter not in samples: - point = tuple(value+parameter*delta for value, delta in zip(start, direction)) - uv = inverse(point) - if uv is None: - return None - own = (Fraction(side), parameter) if fixed_axis == 0 else (parameter, Fraction(side)) - q = own+uv if owner == 0 else uv+own - try: - sample_q, sample_xyz = np.asarray(list(map(float, q))), np.asarray(list(map(float, point))) - except (OverflowError, ValueError): - return None - if not np.all(np.isfinite(sample_q)) or not np.all(np.isfinite(sample_xyz)): - return None - samples[parameter] = sample_q, sample_xyz - sample_keys[parameter] = q - return samples[parameter] - pending, accepted = [(lo, hi)], [] - while pending: - if not budget.charge_cells(1, 'boundary_representation'): - unresolved(REASON_WORK_BUDGET, (owner, fixed_axis, side)) - break - a, b = pending.pop() - first_sample, second_sample = sample(a), sample(b) - if first_sample is None or second_sample is None: - unresolved(REASON_PARAMETER_REPRESENTATION, (owner, fixed_axis, side)) - break - qa, xa = first_sample - qb, xb = second_sample - if a < b and float(a) == float(b): - # A nonempty exact source interval cannot be published - # as a single floating parameter value, even when its - # physical length happens to fit inside atol. - unresolved(REASON_PARAMETER_REPRESENTATION, (owner, fixed_axis, side)) - break - valid = affine_path_representation_bounded( - *nets, qa, qb, np.array([xa, xb]), atol, rational=rational, - charge=lambda amount: budget.charge_cells(amount, 'boundary_representation')) - if valid: - accepted.append((a, b)) - else: - if budget.exhausted: - unresolved(REASON_WORK_BUDGET, (owner, fixed_axis, side)) - break - midpoint = (a+b)/2 - if a == b or float(midpoint) in (float(a), float(b)): - unresolved(REASON_PARAMETER_REPRESENTATION, (owner, fixed_axis, side)) - break - pending.extend(((midpoint, b), (a, midpoint))) - # Already validated intervals remain useful when later work - # stops. Join only adjacent exact source intervals; never - # bridge an unprocessed part of the edge. - groups = [] - for a, b in sorted(accepted): - if groups and groups[-1][-1] == a: - groups[-1].append(b) - else: - groups.append([a, b] if a < b else [a]) - for parameters in groups: - pairs = [sample(value) for value in parameters] - stuv = np.array([pair[0] for pair in pairs]) - xyz = np.array([pair[1] for pair in pairs]) - if len(parameters) == 1: - emit_point(stuv[0], xyz[0], sample_keys[parameters[0]]) - else: - branch = SSXBranch((stuv, xyz), overlap=True, kind='overlap') - # The exact source parameter and the convex target's - # unique inverse identify the endpoint. Numerical UV - # inversion does not supply an exact full 4-D key. - branch._boundary_source_endpoints = tuple( - sample_keys[value][2*owner:2*owner+2] - for value in (parameters[0],parameters[-1])) - budget.append_output(result['branches'], branch, 'branch') - for s, t in ((0, 0), (0, 1), (1, 0), (1, 1)): - point = source[0 if s == 0 else -1][0 if t == 0 else -1] - if (_dot(normal, _sub(point, origin)) != 0 or not inside(point) - or any((s, t)[axis] == side for axis, side, *_ in zero_edges)): - continue - uv = inverse(point) - own = (Fraction(s), Fraction(t)) - if uv is None: - unresolved(REASON_PARAMETER_REPRESENTATION, (owner, s, t)) - continue - q = own+uv if owner == 0 else uv+own - exact_key = q - try: - q, xyz = np.asarray(list(map(float, q))), np.asarray(list(map(float, point))) - except (OverflowError, ValueError): - unresolved(REASON_PARAMETER_REPRESENTATION, (owner, s, t)) - continue - if not np.all(np.isfinite(q)) or not np.all(np.isfinite(xyz)): - unresolved(REASON_PARAMETER_REPRESENTATION, (owner, s, t)) - continue - if not affine_path_representation_bounded(*nets, q, q, np.array([xyz, xyz]), - atol, rational=rational, - charge=lambda amount: budget.charge_cells(amount, 'boundary_representation')): - unresolved(REASON_PARAMETER_REPRESENTATION, (owner, s, t)) - continue - emit_point(q, xyz, exact_key) - _append_planar_junctions(result, source, owner, chart, budget) - return result - return opposed_boundary_strata_ssx(nets, exact, atol, rational, budget) diff --git a/mmcore/numeric/intersection/ssx/_ssx_cofactor_bounds.py b/mmcore/numeric/intersection/ssx/_ssx_cofactor_bounds.py deleted file mode 100644 index ccd9ed06..00000000 --- a/mmcore/numeric/intersection/ssx/_ssx_cofactor_bounds.py +++ /dev/null @@ -1,330 +0,0 @@ -"""Source-valid interval bounds for the four SSX Jacobian column minors. - -Precomputed floating determinant polynomials can lose their signs through -cancellation. Uniform-weight sources use outward Bernstein coefficient -products which preserve polynomial dependency inside each surface normal. -The general fallback encloses the original unsquared residual's Jacobian -before forming interval determinants. A failed strict sign is unknown, -never an exclusion. -""" -from itertools import permutations -from math import prod, comb -from fractions import Fraction - -import numpy as np - -from mmcore.numeric._bezier_common import restrict_net_axis_v -from mmcore.numeric.intersection._root_box_certificate import residual_roundoff_bound -from mmcore.numeric.intersection.ssx._ssx5_singular import psi_vector_net - - -def _multiply(first, second): - with np.errstate(over='ignore', invalid='ignore', under='ignore'): - products = np.array([a*b for a in first for b in second]) - if not np.all(np.isfinite(products)): - return -np.inf, np.inf - return float(np.nextafter(products.min(), -np.inf)), float(np.nextafter(products.max(), np.inf)) - - -def _determinant(lower, upper): - lo = hi = 0. - for order in permutations(range(3)): - term = (1., 1.) - for row, column in enumerate(order): - term = _multiply(term, (lower[row, column], upper[row, column])) - odd = sum(order[a] > order[b] for a in range(3) for b in range(a+1, 3)) % 2 - if odd: - term = -term[1], -term[0] - lo = float(np.nextafter(lo+term[0], -np.inf)) - hi = float(np.nextafter(hi+term[1], np.inf)) - return lo, hi - - -def _interval_multiply(al, au, bl, bu): - with np.errstate(over='ignore', invalid='ignore', under='ignore'): - values = np.stack(np.broadcast_arrays(al*bl, al*bu, au*bl, au*bu)) - low, high = np.min(values, axis=0), np.max(values, axis=0) - invalid = np.isnan(low) | np.isnan(high) - return (np.where(invalid, -np.inf, np.nextafter(low, -np.inf)), - np.where(invalid, np.inf, np.nextafter(high, np.inf))) - - -def _interval_subtract(al, au, bl, bu): - return np.nextafter(al-bu, -np.inf), np.nextafter(au-bl, np.inf) - - -def _source_polynomial_column(source, axis, opposite_weight, negative=False): - degree = source.shape[axis]-1 - if degree == 0: - shape = list(source.shape) - shape[axis], shape[-1] = 1, 3 - return np.zeros(shape), np.zeros(shape) - a = np.take(source[..., :3], range(1, degree+1), axis=axis) - b = np.take(source[..., :3], range(degree), axis=axis) - low, high = _interval_subtract(a, a, b, b) - low, high = _interval_multiply(low, high, float(degree), float(degree)) - low, high = _interval_multiply(low, high, opposite_weight, opposite_weight) - return (-high, -low) if negative else (low, high) - - -def _bernstein_cross(first, second): - """2D Bernstein cross product with outward coefficient arithmetic. - - Product weights are exact binomial ratios. Their floating enclosures - and every accumulation are outward rounded, including subnormals; - no error estimate depends on the size of the cancelled result. - """ - al, au = first - bl, bu = second - adeg, bdeg = np.array(al.shape[:2])-1, np.array(bl.shape[:2])-1 - shape = tuple(adeg+bdeg+1)+(3,) - low, high = np.zeros(shape), np.zeros(shape) - for ia in np.ndindex(al.shape[:2]): - for ib in np.ndindex(bl.shape[:2]): - target = tuple(x+y for x, y in zip(ia, ib)) - weight = prod(Fraction(comb(int(a), i)*comb(int(b), j), - comb(int(a+b), i+j)) - for a, b, i, j in zip(adeg, bdeg, ia, ib)) - rounded = float(weight) - wl, wu = np.nextafter(rounded, -np.inf), np.nextafter(rounded, np.inf) - pl, pu = _interval_multiply(al[ia][[1,2,0]], au[ia][[1,2,0]], - bl[ib][[2,0,1]], bu[ib][[2,0,1]]) - ql, qu = _interval_multiply(al[ia][[2,0,1]], au[ia][[2,0,1]], - bl[ib][[1,2,0]], bu[ib][[1,2,0]]) - term = _interval_subtract(pl, pu, ql, qu) - tl, tu = _interval_multiply(*term, wl, wu) - low[target] = np.nextafter(low[target]+tl, -np.inf) - high[target] = np.nextafter(high[target]+tu, np.inf) - return low, high - - -def _outer_dot(first, second): - shape = first[0].shape[:2]+second[0].shape[:2] - low, high = np.zeros(shape), np.zeros(shape) - for coordinate in range(3): - a = tuple(values[..., coordinate, None, None] for values in first) - b = tuple(values[None, None, ..., coordinate] for values in second) - tl, tu = _interval_multiply(*a, *b) - low = np.nextafter(low+tl, -np.inf) - high = np.nextafter(high+tu, np.inf) - return low, high - - -def _coefficient_centers(interval): - low, high = interval - if not np.all(np.isfinite(low)) or not np.all(np.isfinite(high)): - return None - center = .5*low+.5*high - radius = np.nextafter(np.maximum(center-low, high-center), np.inf) - # Exact equality of floating midpoint coefficients proves that this - # midpoint polynomial is constant along an axis. Removing that - # redundant degree changes no polynomial; the inherited global - # coefficient radius still encloses the original exact source net. - # In particular affine-plane normals need no foreign UV subdivision. - for axis in range(center.ndim): - if center.shape[axis] > 1: - representative = np.take(center, [0], axis=axis) - if np.all(center == representative): - center = representative - # Each exact coefficient lies within this fixed source error. The - # Bernstein basis is nonnegative and sums to one under restriction. - error = np.nextafter(float(np.max(radius)) - + residual_roundoff_bound(center[..., None], depth=2)[0], np.inf) - if not np.isfinite(error): - return None - return center[..., None], error - - -class SourceCofactorBounds: - """Cached global-parameter cofactor bounds over source product boxes. - - ``first`` and ``second`` are the ORIGINAL homogeneous Bezier control - nets, with positive weights. ``bounds(box)`` returns ``(lower, upper)`` - for the four unsigned column minors of dPsi/d(s,t,u,v). Their signed - tangent direction is ``(+T1,-T2,+T3,-T4)``. Bounds remain in GLOBAL - parameter coordinates, even for tiny or degenerate requested boxes. - - Work is charged before construction/restriction in the SSX ledger's - existing 128-coefficient units. A denied charge returns None and sets - ``exhausted``. Requests for an already cached box do not spend again. - """ - def __init__(self, first, second, *, charge=None, polynomial=True): - self.charge = charge - self.exhausted = False - self.cache = {} - self.derivatives = None - self.errors = None - self.polynomial_nets = None - self.residual = None - self.residual_error = None - first, second = np.asarray(first, dtype=float), np.asarray(second, dtype=float) - if (first.ndim != 3 or second.ndim != 3 or first.shape[-1] != 4 - or second.shape[-1] != 4 or not np.all(np.isfinite(first)) - or not np.all(np.isfinite(second)) or not np.all(first[..., 3] > 0.) - or not np.all(second[..., 3] > 0.)): - return - coefficient_count = 3*prod(first.shape[:2]+second.shape[:2]) - if not self._spend(max(1, (5*coefficient_count+127)//128)): - return - with np.errstate(over='ignore', invalid='ignore', under='ignore'): - net = psi_vector_net(first, second) - source = (np.max(np.abs(first[..., :3]), axis=(0, 1))*np.max(np.abs(second[..., 3])) - + np.max(np.abs(second[..., :3]), axis=(0, 1))*np.max(np.abs(first[..., 3]))) - if not np.all(np.isfinite(net)) or not np.all(np.isfinite(source)): - return - # This source-bound includes the homogeneous cross products and - # subtraction. Differentiation precedes restriction, so none of - # these error floors depends on a shrinking/cancelled child net. - original_error = residual_roundoff_bound(net, source_scale=source) - self.residual = net - self.residual.setflags(write=False) - self.residual_error = np.nextafter( - original_error+residual_roundoff_bound(net, depth=2), np.inf) - magnitude = np.max(np.abs(net), axis=tuple(range(4))) - eps = np.finfo(float).eps - gamma2 = 2.*eps/(1.-2.*eps) - derivatives, errors = [], [] - for axis in range(4): - degree = net.shape[axis]-1 - if degree == 0: - shape = list(net.shape) - shape[axis] = 1 - derivative = np.zeros(shape) - derivative_error = np.zeros(3) - else: - derivative = degree*np.diff(net, axis=axis) - # Two inherited endpoint errors plus the subtraction and - # integer-degree multiplication used for each derivative. - derivative_error = np.nextafter( - 2.*degree*original_error+2.*gamma2*degree*magnitude - + 2.*np.nextafter(0., 1.), np.inf) - restriction_error = residual_roundoff_bound(derivative, depth=2) - derivatives.append(derivative) - errors.append(np.nextafter(derivative_error+restriction_error, np.inf)) - self.derivatives = tuple(derivatives) - self.errors = tuple(errors) - self.restriction_units = max(1, (sum(d.size for d in derivatives)+127)//128) - if (polynomial and np.all(first[..., 3] == first[0, 0, 3]) - and np.all(second[..., 3] == second[0, 0, 3])): - shapes = [tuple(max(1, n-(axis == k)) for k, n in enumerate(source.shape[:2])) - for source in (first, second) for axis in (0, 1)] - normal_shapes = [tuple(a+b-1 for a, b in zip(shapes[k], shapes[k+1])) - for k in (0, 2)] - pair_work = sum(prod(shapes[k])*prod(shapes[k+1]) for k in (0, 2)) - output_count = sum(prod(shapes[k])*prod(normal_shapes[1-k//2]) for k in range(4)) - if not self._spend(max(1, (24*pair_work+12*output_count+127)//128)): - return - with np.errstate(over='ignore', invalid='ignore', under='ignore'): - columns = [_source_polynomial_column(source, axis, weight, negative) - for source, weight, negative in ( - (first, second[0,0,3], False), (second, first[0,0,3], True)) - for axis in (0, 1)] - normals = [_bernstein_cross(columns[k], columns[k+1]) for k in (0, 2)] - nets = [_coefficient_centers(_outer_dot(columns[1], normals[1])), - _coefficient_centers(_outer_dot(columns[0], normals[1])), - _coefficient_centers(_outer_dot(normals[0], columns[3])), - _coefficient_centers(_outer_dot(normals[0], columns[2]))] - if all(net is not None for net in nets): - self.polynomial_nets = tuple(nets) - self.polynomial_units = max(1, (sum(net.size for net, _ in nets)+127)//128) - - def _spend(self, amount): - if self.exhausted: - return False - if self.charge is None or self.charge(int(amount)): - return True - self.exhausted = True - return False - - def bounds(self, box): - if self.derivatives is None or self.exhausted: - return None - key = tuple((float(lo), float(hi)) for lo, hi in box) - if (len(key) != 4 or any(not (0. <= lo <= hi <= 1.) for lo, hi in key)): - return None - if key in self.cache: - return self.cache[key] - plow = phigh = None - if self.polynomial_nets is not None: - if not self._spend(self.polynomial_units): - return None - polynomial_bounds = [] - for net, error in self.polynomial_nets: - restricted = net - for axis, (lo, hi) in enumerate(key): - restricted = restrict_net_axis_v(restricted, axis, lo, hi, 0., 1.) - polynomial_bounds.append((np.nextafter(float(restricted.min())-error, -np.inf), - np.nextafter(float(restricted.max())+error, np.inf))) - plow, phigh = np.asarray(polynomial_bounds).T - if np.any((plow > 0.) | (phigh < 0.)): - plow.setflags(write=False) - phigh.setflags(write=False) - self.cache[key] = plow, phigh - return self.cache[key] - if not self._spend(self.restriction_units): - return None - jacobian_lower, jacobian_upper = [], [] - for derivative, error in zip(self.derivatives, self.errors): - restricted = derivative - for axis, (lo, hi) in enumerate(key): - restricted = restrict_net_axis_v(restricted, axis, lo, hi, 0., 1.) - jacobian_lower.append(np.nextafter(restricted.min(axis=(0, 1, 2, 3))-error, -np.inf)) - jacobian_upper.append(np.nextafter(restricted.max(axis=(0, 1, 2, 3))+error, np.inf)) - jlo, jhi = np.asarray(jacobian_lower).T, np.asarray(jacobian_upper).T - bounds = [_determinant(np.delete(jlo, axis, axis=1), np.delete(jhi, axis, axis=1)) - for axis in range(4)] - lower, upper = np.asarray(bounds).T - if plow is not None: - combined_low, combined_high = np.maximum(lower, plow), np.minimum(upper, phigh) - if np.all(combined_low <= combined_high): - lower, upper = combined_low, combined_high - lower.setflags(write=False) - upper.setflags(write=False) - self.cache[key] = lower, upper - return self.cache[key] - - -class SourceZeroSetCofactorBounds: - """Bounds on the source zero set inside a requested parameter box. - - Exact affine coordinate identities contract a necessary box before the - source cofactor bounds are evaluated. The result encloses cofactors at - every intersection in the original box, but is deliberately NOT a - bound away from the zero set. It supports regularity, oriented germs, - and tangent ratios; it must not be used for a residual Krawczyk test. - """ - def __init__(self, source_bounds, constraints): - self.source_bounds = source_bounds - self.constraints = constraints - self.contractor = None - self.cache = {} - self.empty_boxes = set() - - def bounds(self, box): - original = tuple((float(lo), float(hi)) for lo, hi in box) - if original in self.cache: - return self.cache[original] - necessary_box = self.constraints.contract(box) - if necessary_box is None: - self.empty_boxes.add(original) - self.cache[original] = None - return None - initial = self.source_bounds.bounds(necessary_box) - if initial is None or np.any((initial[0] > 0.) | (initial[1] < 0.)): - self.cache[original] = initial - return initial - if self.contractor is None: - from mmcore.numeric.intersection.ssx._ssx_residual_contract import SourceResidualContractor - self.contractor = SourceResidualContractor(self.source_bounds) - def regular(candidate): - bounds = self.source_bounds.bounds(candidate) - return bool(bounds is not None and np.any((bounds[0] > 0.) | (bounds[1] < 0.))) - contracted = self.contractor.contract(necessary_box, stop_when=regular) - if contracted is None: - self.empty_boxes.add(original) - self.cache[original] = None - return None - result = (initial if contracted == necessary_box - else self.source_bounds.bounds(contracted)) - self.cache[original] = result - return result diff --git a/mmcore/numeric/intersection/ssx/_ssx_exact_region.py b/mmcore/numeric/intersection/ssx/_ssx_exact_region.py deleted file mode 100644 index 042cb7de..00000000 --- a/mmcore/numeric/intersection/ssx/_ssx_exact_region.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Complete intersections of injective affine charts in one exact plane. - -The source coefficients, plane identity, convex polygon clipping, and both -parameter inverses use exact rational arithmetic. This tier owns the entire -common set: a convex region, one segment, one point, or the empty set. -Unsupported charts return None; floating representation failures retain the -exact component in a typed partial result instead of changing its dimension. -""" -from fractions import Fraction - -import numpy as np - -from mmcore.numeric._work_budget import REASON_OUTPUT_CAP, REASON_PARAMETER_REPRESENTATION -from mmcore.numeric.intersection.csx._planar_overlap import ( - _exact_points, _convex_chart, _sub, _dot, _cross2, -) -from mmcore.numeric.intersection.ssx._ssx5_overlap import SSXOverlapRegion -from mmcore.numeric.intersection.ssx._ssx_substrate import SSXBranch, SSXPoint - - -def _affine_chart(net, rational): - data = _exact_points(net, rational) - if data is None: - return None - points, weights = data - if any(w != weights[0] for w in weights): - return None - m, n = (size-1 for size in net.shape[:2]) - p00, p01, p10, p11 = points[0], points[n], points[m*(n+1)], points[-1] - du, dv = _sub(p10, p00), _sub(p01, p00) - for i in range(m+1): - for j in range(n+1): - expected = tuple(o+Fraction(i, m)*a+Fraction(j, n)*b for o, a, b in zip(p00, du, dv)) - if points[i*(n+1)+j] != expected: - return None - chart = _convex_chart([p00, p01, p10, p11]) - if chart is None: - return None - return p00, du, dv, chart, [p00, p10, p11, p01] - - -def _clean_polygon(points, axes): - clean = [] - for point in points: - if not clean or point != clean[-1]: - clean.append(point) - if len(clean) > 1 and clean[-1] == clean[0]: - clean.pop() - if len(clean) < 3: - return clean - area = sum(_cross2(tuple(a[k] for k in axes), tuple(b[k] for k in axes)) - for a, b in zip(clean, clean[1:]+clean[:1])) - if area == 0: - axis = max(axes, key=lambda k: max(p[k] for p in clean)-min(p[k] for p in clean)) - ends = min(clean, key=lambda p: p[axis]), max(clean, key=lambda p: p[axis]) - return [ends[0]] if ends[0] == ends[1] else list(ends) - while len(clean) > 3: - collinear = next((i for i in range(len(clean)) if _cross2( - tuple(clean[i][k]-clean[i-1][k] for k in axes), - tuple(clean[(i+1) % len(clean)][k]-clean[i][k] for k in axes)) == 0), None) - if collinear is None: - break - clean.pop(collinear) - return clean - - -def _clip_polygon(points, chart): - _, _, axes, quad, edges, orientation = chart - for edge, origin in zip(edges, quad): - if not points: - break - output = [] - previous = points[-1] - previous_value = orientation*_cross2(edge, _sub(tuple(previous[k] for k in axes), origin)) - for current in points: - current_value = orientation*_cross2(edge, _sub(tuple(current[k] for k in axes), origin)) - if (previous_value >= 0) != (current_value >= 0): - t = previous_value/(previous_value-current_value) - output.append(tuple(a+t*(b-a) for a, b in zip(previous, current))) - if current_value >= 0: - output.append(current) - previous, previous_value = current, current_value - points = _clean_polygon(output, axes) - return points - - -def _inverse(chart, point): - origin, du, dv, data, _ = chart - i, j = data[2] - p = _sub(point, origin) - determinant = du[i]*dv[j]-du[j]*dv[i] - return ((p[i]*dv[j]-p[j]*dv[i])/determinant, - (du[i]*p[j]-du[j]*p[i])/determinant) - - -def _value(chart, uv): - origin, du, dv = chart[:3] - return tuple(o+uv[0]*a+uv[1]*b for o, a, b in zip(origin, du, dv)) - - -def _strict_polygon(points): - # Test all supporting halfspaces. Consistent consecutive turns alone - # do not exclude a multiply wound polygon such as a pentagram. - sides = [_cross2(_sub(points[(i+1) % len(points)], points[i]), _sub(point, points[i])) - for i in range(len(points)) for j, point in enumerate(points) - if j not in (i, (i+1) % len(points))] - return all(value > 0 for value in sides) or all(value < 0 for value in sides) - - -def _strict_inside(point, polygon): - sides = [_cross2(_sub(b, a), _sub(point, a)) for a, b in zip(polygon, polygon[1:]+polygon[:1])] - return all(t > 0 for t in sides) or all(t < 0 for t in sides) - - -def exact_affine_plane_region_ssx(first, second, atol, rational, budget): - """Return all components for supported original source nets, or None. - - Inputs can be degree-elevated affine charts with any uniform positive - homogeneous weights. The return uses world XYZ and must bypass a - normalizing caller's denormalizer. Output capacity is reserved for the - complete region and its rims together, so no dangling references ship. - """ - nets = tuple(np.asarray(net, dtype=float) for net in (first, second)) - if any(net.ndim != 3 or net.shape[-1] != (4 if rational else 3) - or min(net.shape[:2]) < 2 or not np.all(np.isfinite(net)) for net in nets): - return None - if not budget.charge_cells(max(1, sum(net.size for net in nets)), 'affine_region_identity'): - return None - charts = tuple(_affine_chart(net, rational) for net in nets) - if any(chart is None for chart in charts): - return None - origin, normal = charts[0][3][:2] - if any(_dot(normal, _sub(point, origin)) for point in charts[1][-1]): - return None - if not budget.charge_cells(64, 'affine_region_clip'): - return None - polygon = _clip_polygon(list(charts[0][-1]), charts[1][3]) - result = dict(branches=[], points=[], singularities=[], overlap_regions=[], unresolved_regions=[]) - if not polygon: - return result - dimension = 0 if len(polygon) == 1 else 1 if len(polygon) == 2 else 2 - exact_stuv = [_inverse(charts[0], p)+_inverse(charts[1], p) for p in polygon] - - def partial(reason): - budget.mark_incomplete(reason) - budget.append_output(result['unresolved_regions'], { - 'stuv_min': (0.,)*4, 'stuv_max': (1.,)*4, 'reason': reason, - 'exact_dimension': dimension, - 'exact_stuv': tuple(tuple(str(x) for x in p) for p in exact_stuv), - 'exact_xyz': tuple(tuple(str(x) for x in p) for p in polygon), - }, 'unresolved_region') - return result - - needed = len(polygon)+1 if dimension == 2 else 1 - if budget.output_items+needed > budget.max_output_items: - return partial(REASON_OUTPUT_CAP) - if not budget.charge_cells(16*(len(polygon)+1), 'affine_region_representation'): - return None - try: - limit = Fraction.from_float(float(atol))**2 - stuv = np.array([[float(x) for x in p] for p in exact_stuv]) - xyz = np.array([[float(x) for x in p] for p in polygon]) - rounded_stuv = [tuple(Fraction.from_float(float(x)) for x in p) for p in stuv] - rounded_xyz = [tuple(Fraction.from_float(float(x)) for x in p) for p in xyz] - if (len(set(rounded_stuv)) != len(polygon) - or any(not 0 <= x <= 1 for p in rounded_stuv for x in p)): - return partial(REASON_PARAMETER_REPRESENTATION) - if dimension == 1 and any( - rounded_stuv[0][2*i:2*i+2] == rounded_stuv[1][2*i:2*i+2] - for i in range(2)): - # Each affine chart is injective. Collapsing either chart's - # endpoint parameters loses its segment preimage even when - # a loose XYZ tolerance would accept that constant image. - return partial(REASON_PARAMETER_REPRESENTATION) - - def valid_pair(uv, point): - values = [_value(chart, uv[2*i:2*i+2]) for i, chart in enumerate(charts)] - return all(sum((a-b)**2 for a, b in zip(left, right)) <= limit - for left, right in ((values[0], values[1]), (point, values[0]), (point, values[1]))) - - if not all(valid_pair(uv, point) for uv, point in zip(rounded_stuv, rounded_xyz)): - return partial(REASON_PARAMETER_REPRESENTATION) - if dimension == 2: - center = tuple(sum(p[k] for p in polygon)/len(polygon) for k in range(3)) - center_stuv = _inverse(charts[0], center)+_inverse(charts[1], center) - interior = np.array([float(x) for x in center_stuv]) - rounded_interior = tuple(Fraction.from_float(float(x)) for x in interior) - center_xyz = tuple(Fraction.from_float(float(x)) for x in map(float, center)) - if not valid_pair(rounded_interior, center_xyz): - return partial(REASON_PARAMETER_REPRESENTATION) - for owner in range(2): - true_loop = [p[2*owner:2*owner+2] for p in exact_stuv] - public_loop = [p[2*owner:2*owner+2] for p in rounded_stuv] - witness = rounded_interior[2*owner:2*owner+2] - if (not _strict_polygon(public_loop) or not _strict_inside(witness, true_loop) - or not _strict_inside(witness, public_loop)): - return partial(REASON_PARAMETER_REPRESENTATION) - except (OverflowError, ValueError, ZeroDivisionError): - return partial(REASON_PARAMETER_REPRESENTATION) - - if dimension == 0: - budget.append_output(result['points'], SSXPoint(stuv[0], xyz[0]), 'point') - elif dimension == 1: - branch = SSXBranch((stuv,xyz),overlap=True,kind='overlap') - branch._source_parameter_path = tuple(exact_stuv) - budget.append_output(result['branches'],branch,'branch') - else: - for i in range(len(polygon)): - indices = [i, (i+1) % len(polygon)] - branch = SSXBranch((stuv[indices],xyz[indices]),overlap=True,kind='overlap') - branch._source_parameter_path = tuple(exact_stuv[k] for k in indices) - budget.append_output(result['branches'],branch,'branch') - closed = np.vstack((stuv, stuv[0])) - agreement = 1 if _dot(charts[0][3][1], charts[1][3][1]) > 0 else -1 - region = SSXOverlapRegion( - boundary=[[(i, False) for i in range(len(polygon))]], - uv1_loops=[closed[:, :2]], uv2_loops=[closed[:, 2:]], - normal_agreement=agreement, interior_stuv=interior, - certification={'image_identity': 'exact_affine_plane', 'injective_charts': True, - 'boundary_resid_max': 1., 'interior_resid': 1., - 'residual_values_are_bounds': True, - 'n_samples': len(polygon)+1, 'orientation_consistent': True, - 'complete_preimage': True}) - budget.append_output(result['overlap_regions'], region, 'overlap_region') - return result diff --git a/mmcore/numeric/intersection/ssx/_ssx_extrusion.py b/mmcore/numeric/intersection/ssx/_ssx_extrusion.py deleted file mode 100644 index 9282d613..00000000 --- a/mmcore/numeric/intersection/ssx/_ssx_extrusion.py +++ /dev/null @@ -1,263 +0,0 @@ -"""Exact univariate SSX for polynomial rulings parallel to an affine plane. - -If a surface is affine in one parameter and its plane-distance Bernstein -coefficients are independent of that parameter, every component is a ruling -at a zero of one scalar polynomial. Sturm isolation supplies an exhaustive -root census; exact halfspace clipping supplies every ruling's finite extent. -Currently isolation must expose a singleton (dyadic roots, or an arbitrary -rational root of a linear square-free polynomial). Other algebraic roots, -including some non-dyadic rational roots, return None for the general solver. -""" -from fractions import Fraction - -import numpy as np - -from mmcore.numeric._work_budget import REASON_PARAMETER_REPRESENTATION -from mmcore.numeric.intersection._exact_univariate import ( - _power, _derivative, _value, _refine, isolate_root_intervals, - coefficient_build_work, -) -from mmcore.numeric.intersection._root_box_certificate import exact_bernstein_value -from mmcore.numeric.intersection.csx._planar_overlap import ( - _exact_points, _convex_chart, _sub, _dot, _cross2, -) -from mmcore.numeric.intersection.ssx._ssx_substrate import SSXBranch, SSXPoint -from mmcore.numeric.intersection.ssx._ssx_singular_candidate import _cross - - -class _Stopped(Exception): - pass - - -def _evaluate_fraction_curve(curve, t): - work = list(curve) - while len(work) > 1: - work = [tuple((1-t)*a+t*b for a, b in zip(left, right)) - for left, right in zip(work, work[1:])] - return work[0] - - -def _uniform_cartesian(net, rational): - data = _exact_points(net, rational) - if data is None: - return None - points, weights = data - if any(w != weights[0] for w in weights): - return None - width = net.shape[1] - return [points[i:i+width] for i in range(0, len(points), width)] - - -def _affine_axis(points, axis): - if axis == 1: - points = list(zip(*points)) - degree = len(points)-1 - if degree < 1: - return None - for i, row in enumerate(points): - t = Fraction(i, degree) - if any(any(p != (1-t)*a+t*b for p, a, b in zip(value, first, last)) - for value, first, last in zip(row, points[0], points[-1])): - return None - return points[0], points[-1] - - -def _ruling_normal_zeros(first_curve, last_curve, root, lo, hi): - """Exact normal zero set along an affine, noncollapsed ruling. - -Uniform positive homogeneous weights were reduced exactly to Cartesian -coefficients before this function. The free derivative is constant and -the other derivative is affine in the free parameter, so their cross -product is affine too. Return whole-curve degeneracy or an isolated zero. -""" - start = _evaluate_fraction_curve(first_curve, root) - end = _evaluate_fraction_curve(last_curve, root) - free = _sub(end, start) - degree = len(first_curve)-1 - derivatives = [] - for curve in (first_curve, last_curve): - net = [tuple(degree*(b-a) for a, b in zip(left, right)) - for left, right in zip(curve[:-1], curve[1:])] - derivatives.append(_evaluate_fraction_curve(net, root)) - first, last = [_cross(free, derivative) for derivative in derivatives] - if not any(first) and not any(last): - return True, None - delta = _sub(last, first) - if not any(delta): - return False, None - axis = next(k for k, value in enumerate(delta) if value) - candidate = -first[axis]/delta[axis] - if lo <= candidate <= hi and not any(a+candidate*b for a, b in zip(first, delta)): - return False, candidate - return False, None - - -def exact_extrusion_plane_ssx(first, second, atol, rational, budget): - """Return an exhaustive supported result, or None after charged predicates.""" - nets = (np.asarray(first, dtype=float), np.asarray(second, dtype=float)) - if (any(net.ndim != 3 or net.shape[-1] != (4 if rational else 3) - or min(net.shape[:2]) < 2 or not np.all(np.isfinite(net)) for net in nets) - or not any(net.shape[:2] == (2, 2) for net in nets)): - return None - # This is a preflight for exact coefficient construction, not a degree - # limit chosen to fit examples. Unsupported candidates still account for - # the predicates they execute before general subdivision continues. - work = max(1, sum(net.size for net in nets)) - if not budget.charge_cells(work, 'extrusion_certificate'): - return None - exact = [_uniform_cartesian(net, rational) for net in nets] - if any(points is None for points in exact): - return None - - def tick(): - if not budget.charge_cells(1, 'extrusion_roots'): - raise _Stopped - - for owner in (0, 1): - target = exact[1-owner] - if nets[1-owner].shape[:2] != (2, 2): - continue - target_flat = [p for row in target for p in row] - chart = _convex_chart(target_flat) - if chart is None: - continue - origin, normal, axes, quad, edges, orientation = chart - p00, p01, p10, p11 = target_flat - du, dv = _sub(p10, p00), _sub(p01, p00) - if any(z-a-b+o for z, a, b, o in zip(p11, p10, p01, p00)): - continue # Non-affine inverse parameter curves need another tier. - i, j = axes - determinant = du[i]*dv[j]-du[j]*dv[i] - - def inverse(point): - p = _sub(point, p00) - return ((p[i]*dv[j]-p[j]*dv[i])/determinant, - (du[i]*p[j]-du[j]*p[i])/determinant) - - for axis in (0, 1): - rulings = _affine_axis(exact[owner], axis) - if rulings is None: - continue - first_curve, last_curve = rulings - heights = [_dot(normal, _sub(p, origin)) for p in first_curve] - if heights != [_dot(normal, _sub(p, origin)) for p in last_curve]: - continue - if not any(heights): - continue # A two-dimensional coincidence, not isolated rulings. - if not budget.charge_cells(coefficient_build_work(len(heights)), 'extrusion_roots'): - return None - polynomial = _power(heights) - try: - squarefree, repeated, sequence, intervals = isolate_root_intervals(polynomial, tick) - roots = [] - for interval in sorted(intervals): - while interval[0] != interval[1]: - if np.nextafter(float(interval[0]), np.inf) >= float(interval[1]): - return None # Keep the algebraic case in the general solver. - interval = _refine(squarefree, sequence, interval, tick) - roots.append(interval[0]) - except _Stopped: - return None - result = dict(branches=[], points=[], singularities=[], overlap_regions=[], - unresolved_regions=[]) - evaluation_work = max(1, (6*sum((1+sum(n-1 for n in net.shape[:2]))*net.size - for net in nets)+127)//128) - if not budget.charge_cells(evaluation_work*len(roots), 'extrusion_geometry'): - return None - rounded_roots = {} - for root in roots: - # A generator collapsing to a point has a parameter fiber; - # never turn that different topology into a simple line. - start = _evaluate_fraction_curve(first_curve, root) - end = _evaluate_fraction_curve(last_curve, root) - direction = _sub(end, start) - if not any(direction): - return None - lo, hi = Fraction(0), Fraction(1) - for edge, q in zip(edges, quad): - a = orientation*_cross2(edge, _sub(tuple(start[k] for k in axes), q)) - d = orientation*_cross2(edge, tuple(direction[k] for k in axes)) - if d > 0: - lo = max(lo, -a/d) - elif d < 0: - hi = min(hi, -a/d) - elif a < 0: - lo, hi = Fraction(1), Fraction(0) - break - if hi < lo: - continue - degenerate_ruling, isolated_cusp = _ruling_normal_zeros( - first_curve, last_curve, root, lo, hi) - if isolated_cusp is not None: - # The whole SSI is still a line, but a point on it has - # a different source-chart stratum. Leave that mixed - # classification to the general solver for now. - return None - locations, points = [], [] - for value in (lo, hi): - point = tuple(a+value*d for a, d in zip(start, direction)) - uv = inverse(point) - own = (value, root) if axis == 0 else (root, value) - locations.append(own+uv if owner == 0 else uv+own) - points.append(point) - try: - stuv = np.asarray([[float(x) for x in p] for p in locations]) - xyz = np.asarray([[float(x) for x in p] for p in points]) - reported = [tuple(Fraction.from_float(float(x)) for x in p) for p in xyz] - limit = Fraction.from_float(float(atol))**2 - valid = all(np.all(np.isfinite(p)) for p in (stuv, xyz)) - for uv4, point in zip(stuv, reported): - values = [exact_bernstein_value(net, uv4[2*k:2*k+2]) for k, net in enumerate(nets)] - if rational: - values = [tuple(x/value[-1] for x in value[:3]) for value in values] - valid &= all(sum((a-b)**2 for a, b in zip(left, right)) <= limit - for left, right in ((values[0], values[1]), - (point, values[0]), (point, values[1]))) - # Source and target maps are affine along each emitted - # parameter segment. Their residuals to the reported XYZ - # chord are bounded by the endpoint errors just checked. - valid &= (lo == hi or not np.array_equal(stuv[0], stuv[1])) - valid &= float(root) not in rounded_roots - except (ValueError, OverflowError, ZeroDivisionError): - valid = False - if not valid: - result['unresolved_regions'].append(dict( - stuv_min=(0.,)*4, stuv_max=(1.,)*4, - reason=REASON_PARAMETER_REPRESENTATION, - exact_ruling_parameter=str(root), - exact_stuv=tuple(tuple(str(x) for x in p) for p in locations))) - continue - rounded_roots[float(root)] = root - multiplicity, derivative = 0, polynomial - while len(derivative) > 1 and _value(derivative, root) == 0: - multiplicity += 1 - derivative = _derivative(derivative) - if lo == hi: - result['points'].append(SSXPoint(stuv[0], xyz[0])) - else: - target_locations = [p[2*(1-owner):2*(1-owner)+2] for p in locations] - boundary = (root in (0, 1) or any( - target_locations[0][k] == target_locations[1][k] - and target_locations[0][k] in (0, 1) for k in range(2))) - branch = SSXBranch((stuv, xyz), closed=False, overlap=boundary, - kind=('overlap' if boundary else - 'tangential' if multiplicity > 1 and not degenerate_ruling - else 'transversal')) - result['branches'].append(branch) - if degenerate_ruling: - from mmcore.numeric.intersection.ssx._bez_ssx5 import SSXSingularity - result['singularities'].append(SSXSingularity( - kind='cusp_curve', stuv=stuv[0].copy(), xyz=xyz[0].copy(), - branch_links=[(len(result['branches'])-1, 0)], - samples=stuv.copy(), surface=owner+1)) - if result['unresolved_regions']: - budget.mark_incomplete(REASON_PARAMETER_REPRESENTATION) - for key in ('branches', 'points', 'singularities', 'unresolved_regions'): - entries, result[key] = result[key], [] - if key == 'singularities': - for singularity in entries: - singularity.branch_links = [(index, vertex) for index, vertex in singularity.branch_links - if index < len(result['branches'])] - budget.extend_output(result[key], entries, key) - return result - return None diff --git a/mmcore/numeric/intersection/ssx/_ssx_face_residual.py b/mmcore/numeric/intersection/ssx/_ssx_face_residual.py deleted file mode 100644 index d3b6baaf..00000000 --- a/mmcore/numeric/intersection/ssx/_ssx_face_residual.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Transport the original SSX residual to a closed CSX face product. - -Restriction changes the Bernstein basis, never the source equations. The -absolute coefficient envelope survives every restriction and permutation; -the rounded curve and surface supplied to Newton are only proposals. -""" -from fractions import Fraction - -import numpy as np - -from mmcore.numeric._bezier_common import restrict_net_axis_v -from mmcore.numeric.intersection._root_box_certificate import ( - exact_bernstein_value, residual_roundoff_bound) - - -class SourceFaceResiduals: - def __init__(self, source_bounds, charge=None, sources=None): - self.source = source_bounds - self.charge = charge - self.cache = {} - self.exhausted = False - self.sources = sources - self.exact_values = {} - - def exact_root(self, face, parameters, *, prepaid=False): - """Prove existence at the exact source affine image of local floats. - - This also handles closed-face roots where strict interior Krawczyk - inclusion is unavailable. A rounded global representative is never - substituted for the exact affine image. - """ - if self.sources is None or len(parameters) != 3: - return False - axis, value, box = face - if any(not 0. <= float(x) <= 1. for x in parameters): - return False - order = [axis ^ 1]+([2, 3] if axis < 2 else [0, 1]) - source_parameters = [Fraction(0)]*4 - source_parameters[axis] = Fraction(float(value)) - for k, local in zip(order, parameters): - lo, hi = (Fraction(float(x)) for x in box[k]) - source_parameters[k] = lo+(hi-lo)*Fraction(float(local)) - key = tuple(source_parameters) - if key in self.exact_values: - return self.exact_values[key] - if self.exhausted: - return False - units = max(1, (sum(net.size for net in self.sources)+31)//32) - if not prepaid and self.charge is not None and not self.charge(units): - self.exhausted = True - return False - first = exact_bernstein_value(self.sources[0], key[:2]) - second = exact_bernstein_value(self.sources[1], key[2:]) - answer = all(first[k]*second[3] == second[k]*first[3] for k in range(3)) - self.exact_values[key] = answer - return answer - - def __call__(self, axis, value, box): - axis, value = int(axis), float(value) - box = tuple((float(lo), float(hi)) for lo, hi in box) - if (axis not in range(4) or not 0. <= value <= 1. or len(box) != 4 - or any(not 0. <= lo <= hi <= 1. for lo, hi in box) - or not box[axis][0] <= value <= box[axis][1]): - return None - key = axis, value, box - if key in self.cache: - return self.cache[key] - net, inherited = self.source.residual, self.source.residual_error - if net is None or inherited is None or self.exhausted: - return None - units = max(1, (8*net.size+127)//128) - if self.charge is not None and not self.charge(units): - self.exhausted = True - return None - restricted = net - with np.errstate(over='ignore', invalid='ignore', under='ignore'): - for k, (lo, hi) in enumerate(box): - if k == axis: - lo = hi = value - restricted = restrict_net_axis_v(restricted, k, lo, hi, 0., 1.) - # Every axis uses at most two de Casteljau splits. The error - # floor is based on the original operands, not a cancelled or - # shrinking child. Bernstein restriction is a convex map, so - # inherited absolute coefficient errors do not grow. - error = np.nextafter(inherited+residual_roundoff_bound(net, depth=8), np.inf) - if not np.all(np.isfinite(restricted)) or not np.all(np.isfinite(error)): - return None - restricted = np.take(restricted, 0, axis=axis) - remaining = [k for k in range(4) if k != axis] - order = [axis ^ 1]+([2, 3] if axis < 2 else [0, 1]) - residual = np.transpose(restricted, [remaining.index(k) for k in order]+[3]).copy() - if axis >= 2: - residual = -residual - residual.setflags(write=False) - error.setflags(write=False) - self.cache[key] = residual, error - return self.cache[key] diff --git a/mmcore/numeric/intersection/ssx/_ssx_isolines.py b/mmcore/numeric/intersection/ssx/_ssx_isolines.py new file mode 100644 index 00000000..5a5cbe76 --- /dev/null +++ b/mmcore/numeric/intersection/ssx/_ssx_isolines.py @@ -0,0 +1,363 @@ +"""Numerical plane intersections that reduce to complete surface isolines. + +Surfaces whose homogeneous plane height varies in one parameter admit +a one-dimensional root search. The free parameter remains a curve, even +at a multiple root or a singular surface ruling. Unsupported charts use +the ordinary SSX search. +""" +from __future__ import annotations + +import numpy as np + +from mmcore.numeric._bern_roots import bernstein_roots +from mmcore.numeric._bezier_common import ( + bernstein_product_1d, eval_surface, subdivide_curve, geometry_collapsed, +) +from mmcore.numeric._work_budget import REASON_TRACE_UNVERIFIED, REASON_PARAMETER_FIBER + + +def _evaluate(net, parameter): + values = np.array(net, dtype=float, copy=True) + for length in range(len(values) - 1, 0, -1): + values[:length] = ((1. - parameter) * values[:length] + + parameter * values[1:length + 1]) + return values[0] + + +def _restrict(net, lo, hi): + if lo == hi: + return _evaluate(net, lo)[None, ...] + if hi < 1.: + net, _ = subdivide_curve(net, hi) + if lo > 0.: + _, net = subdivide_curve(net, lo / hi) + return net + + +def _polynomial(net): + weights = np.asarray(net[..., -1]) + if not np.all(weights == weights.flat[0]): + return None + points = np.asarray(net[..., :3], dtype=float) / weights.flat[0] + return points if np.all(np.isfinite(points)) else None + + +def _arithmetic_scale(points): + return (64. * np.finfo(float).eps * sum(points.shape[:2]) + * max(float(np.max(np.abs(points))), np.finfo(float).tiny)) + + +def _root_tolerance(speed, atol): + """Leave room for geometric interpolation after locating a scalar root. + + The absolute parameter error is multiplied by the speed of the + rendered curve or surface. A fixed parameter tolerance can therefore + move an otherwise valid small-residual root by many modeling units. + If the requested location is below double precision's parameter + resolution, let the general search handle the chart instead. + """ + if not np.isfinite(speed): + return None + if speed <= 0.: + return 1e-13 + tolerance = .25 * float(atol) / float(speed) + if tolerance < np.finfo(float).eps: + return None + return min(1e-13, tolerance) + + +def _surface_speed_bound(net, axis): + """Bound a positive-weight chart derivative in physical coordinates.""" + weights = np.asarray(net[..., 3], dtype=float) + points = np.asarray(net[..., :3], dtype=float) / weights[..., None] + weights = weights / np.max(weights) + # Center before differentiating so a world translation does not + # inflate the rational quotient-rule bound. + numerator = (points - points[0, 0]) * weights[..., None] + degree = net.shape[axis] - 1 + if degree <= 0: + return 0. + derivative = degree * np.diff(numerator, axis=axis) + weight_derivative = degree * np.diff(weights, axis=axis) + minimum_weight = float(np.min(weights)) + radius = float(np.max(np.linalg.norm(numerator, axis=-1))) / minimum_weight + return ((float(np.max(np.linalg.norm(derivative, axis=-1))) + + radius * float(np.max(np.abs(weight_derivative)))) + / minimum_weight) + + +def _affine_plane(points, atol): + if points.shape[0] < 2 or points.shape[1] < 2: + return None + origin = points[0, 0] + first, second = points[-1, 0] - origin, points[0, -1] - origin + axes = np.column_stack((first, second)) + lengths = np.linalg.svd(axes, compute_uv=False) + if lengths[-1] <= 64. * np.finfo(float).eps * lengths[0]: + return None + u = np.linspace(0., 1., len(points))[:, None, None] + v = np.linspace(0., 1., points.shape[1])[None, :, None] + expected = origin + u * first + v * second + error = np.max(np.linalg.norm(points - expected, axis=-1)) + if error > min(_arithmetic_scale(points), .01 * atol): + return None + normal = np.cross(first, second) + normal /= np.linalg.norm(normal) + inverse = np.linalg.pinv(axes) + return origin, normal, inverse + + +def _normal_is_zero(first, second, scale): + coefficients = [] + for axis in range(3): + a, b = (axis + 1) % 3, (axis + 2) % 3 + coefficients.append(bernstein_product_1d(first[:, a], second[:, b]) + - bernstein_product_1d(first[:, b], second[:, a])) + return np.max(np.abs(coefficients)) <= scale + + +def try_plane_coincidence(first_h, second_h, atol, budget): + """Represent a full affine projected patch within the plane's CAD tolerance. + + The source may bend in height. Its control hull must remain within + ``atol`` and its affine projection must fit inside the planar chart. + This keeps a tolerance-coincident area from collapsing to one isoline. + """ + nets = (first_h, second_h) + points = [_polynomial(net) for net in nets] + for target in (1, 0): + if points[target] is None or points[1-target] is None: + continue + plane = _affine_plane(points[target], atol) + if plane is None: + continue + origin, normal, inverse = plane + source = points[1-target] + height = (source-origin) @ normal + if np.max(np.abs(height)) > atol: + continue + projected = source-height[..., None]*normal + if _affine_plane(projected, atol) is None: + continue + uv = (projected-origin) @ inverse.T + margin = 128*np.finfo(float).eps*max(1., np.max(np.abs(uv))) + if np.any(uv < -margin) or np.any(uv > 1.+margin): + continue + from mmcore.numeric.intersection.ssx._ssx5_overlap import assemble_overlap_regions + from mmcore.nurbs._nurbs_param_tol import bez_surface_param_tolerance + tolerances = np.array([*bez_surface_param_tolerance(first_h, atol, rational=True), + *bez_surface_param_tolerance(second_h, atol, rational=True)]) + assembled = assemble_overlap_regions( + first_h, second_h, atol=atol, ptol4=tolerances, + charge=lambda n: budget.charge_cells(n, 'coincidence')) + if not assembled['regions'] or budget.exhausted: + return None + out = dict(branches=[], points=[], singularities=[], overlap_regions=[]) + budget.extend_output(out['branches'], assembled['rim_branches'], 'coincidence') + budget.extend_output(out['overlap_regions'], assembled['regions'], 'coincidence') + return out + return None + + +def try_isoline_ssx(first_h, second_h, atol, budget): + """Return ordinary SSX output lists, or None for the general search. + + Work is charged to the caller's shared ledger. Root enumeration and + clipping must finish before this tier replaces the general search; + rendering a known curve can retain its completed prefix on exhaustion. + No algebraic diagnostics or root certificates are exposed. + """ + from mmcore.numeric.intersection.ssx._bez_ssx5 import ( + SSXBranch, SSXPoint, SSXSingularity, + ) + + nets = [np.asarray(first_h), np.asarray(second_h)] + if not budget.charge_cells(sum(np.prod(net.shape[:2]) for net in nets), "isoline_setup"): + return None + points = [_polynomial(net) for net in nets] + source_index = None + for target_index in (1, 0): + if points[target_index] is None: + continue + plane = _affine_plane(points[target_index], atol) + if plane is None: + continue + source = nets[1 - target_index] + origin, normal, inverse = plane + height = np.einsum('ijk,k->ij', + source[..., :3] - origin * source[..., 3:], normal) + if np.max(np.abs(height) / source[..., 3]) <= atol: + # The whole patch is within the modeling tolerance of the + # plane. Let overlap assembly represent that coincidence; + # returning only the polynomial's zero isoline loses its area. + continue + noise = (_arithmetic_scale(source[..., :3]) + + _arithmetic_scale(points[target_index]) * np.max(source[..., 3])) + height_allowance = min(noise, .01 * atol * float(np.min(source[..., 3]))) + for fixed_axis in (0, 1): + ordered = np.moveaxis(height, fixed_axis, 0) + profile = ordered[:, 0] + profile_variation = float(np.max(np.abs(ordered - profile[:, None]))) + ordered_weights = np.moveaxis(source[..., 3], fixed_axis, 0) + if (len(profile) > 1 and np.ptp(profile) > noise + and profile_variation <= height_allowance + and np.all(ordered_weights == ordered_weights[:, :1])): + source_index = 1 - target_index + break + if source_index is not None: + break + if source_index is None: + return None + + def roots(coefficients, parameter_tol): + return bernstein_roots( + coefficients, parameter_tol=parameter_tol, + charge=lambda amount: budget.charge_cells(amount, "isoline_roots")) + + fixed_tolerance = _root_tolerance(_surface_speed_bound(source, fixed_axis), atol) + if fixed_tolerance is None: + return None + census = roots(profile, fixed_tolerance) + if not census.complete or census.constant_zero: + return None + if (not census.roots and profile_variation > 0. + and np.min(height) <= 0. <= np.max(height)): + # An approximately independent profile cannot exclude roots of + # the full height net when that net still straddles the plane. + return None + output = dict(branches=[], points=[], singularities=[], overlap_regions=[]) + ordered_source = np.moveaxis(source, fixed_axis, 0) + degree = len(profile) - 1 + plans = [] + for fixed in census.roots: + if not budget.charge_cells(max(1, int(source.size // 3)), "isoline_setup"): + return None + homogeneous_curve = _evaluate(ordered_source, fixed) + weights = homogeneous_curve[:, 3:] + curve = homogeneous_curve[:, :3] / weights + # A stationary numerical root must still satisfy the caller's + # geometric accuracy; a tiny polynomial residual alone is not an + # acceptable rendered curve at an arbitrarily tighter tolerance. + if np.max(np.abs((curve - origin) @ normal)) > .25 * atol: + return None + uv = (curve - origin) @ inverse.T + free_speed = ((len(curve)-1) * float(np.max(np.linalg.norm(np.diff(curve, axis=0), axis=1))) + if len(curve) > 1 else 0.) + free_tolerance = _root_tolerance(free_speed, atol) + if free_tolerance is None: + return None + breaks = {0., 1.} + for axis in (0, 1): + for boundary in (0., 1.): + clipped = roots(uv[:, axis] - boundary, free_tolerance) + if not clipped.complete: + return None + breaks.update(clipped.roots) + breaks = sorted(breaks) + intervals = [] + for lo, hi in zip(breaks, breaks[1:]): + midpoint = _evaluate(uv, .5 * (lo + hi)) + uv_slack = 64*np.finfo(float).eps*len(uv)*max(1., np.max(np.abs(uv))) + if np.all((midpoint >= -uv_slack) & (midpoint <= 1.+uv_slack)): + if intervals and intervals[-1][1] == lo: + intervals[-1] = intervals[-1][0], hi + else: + intervals.append((lo, hi)) + derivative_net = degree * np.diff(ordered_source, axis=0) + homogeneous_derivative = _evaluate(derivative_net, fixed) + derivative = ((homogeneous_derivative[:, :3] + - curve * homogeneous_derivative[:, 3:]) / weights) + free_derivative = ((len(curve) - 1) * np.diff(curve, axis=0) + if len(curve) > 1 else np.zeros((1, 3))) + derivative_scale = (np.max(np.abs(derivative_net[..., :3])) + + np.max(np.abs(curve)) * np.max(np.abs(derivative_net[..., 3]))) / np.min(weights) + free_scale = max(np.max(np.abs(free_derivative)), np.finfo(float).tiny) + normal_scale = 64. * np.finfo(float).eps * source.size * derivative_scale * free_scale + cusp = _normal_is_zero(derivative, free_derivative, normal_scale) + height_derivative = float(_evaluate(degree * np.diff(profile), fixed)) + tangent = abs(height_derivative) <= 64. * np.finfo(float).eps * degree * np.max(np.abs(profile)) + plans.append((fixed, curve, uv, intervals, breaks, cusp, tangent)) + + def coordinates(fixed, free, xyz): + source_uv = np.array([fixed, free]) if fixed_axis == 0 else np.array([free, fixed]) + target_uv = (xyz - origin) @ inverse.T + if np.any(target_uv < -1e-11) or np.any(target_uv > 1. + 1e-11): + return None + target_uv = np.clip(target_uv, 0., 1.) + paired = np.concatenate((source_uv, target_uv) if source_index == 0 + else (target_uv, source_uv)) + if any(np.linalg.norm(eval_surface(net, *pair, rational=True) - xyz) > .25 * atol + for net, pair in zip(nets, (paired[:2], paired[2:]))): + return None + return paired + + for fixed, curve, uv, intervals, breaks, cusp, tangent in plans: + for lo, hi in intervals: + controls = _restrict(curve, lo, hi) + start = coordinates(fixed, lo, controls[0]) + if start is None: + budget.mark_incomplete(REASON_TRACE_UNVERIFIED) + continue + parameters, xyz = [start], [controls[0]] + stack = [(lo, hi, controls)] + while stack: + if not budget.charge_cells(max(1, len(curve)**2 // 128), "isoline_trace"): + break + a, b, net = stack.pop() + linear = np.linspace(net[0], net[-1], len(net)) + error = float(np.max(np.linalg.norm(net - linear, axis=1))) + if error <= .5 * atol: + q = coordinates(fixed, b, net[-1]) + if q is None: + budget.mark_incomplete(REASON_TRACE_UNVERIFIED) + break + parameters.append(q) + xyz.append(net[-1]) + else: + middle = .5 * (a + b) + if not a < middle < b: + budget.mark_incomplete(REASON_TRACE_UNVERIFIED) + break + left, right = subdivide_curve(net, .5) + stack.extend(((middle, b, right), (a, middle, left))) + if len(parameters) >= 2: + parameters, xyz = np.asarray(parameters), np.asarray(xyz) + # A whole free parameter interval can map to one spatial + # point. Preserve its parameter samples as singularity + # evidence, but do not publish a zero-length curve. + collapsed = (geometry_collapsed(controls) + and np.max(np.linalg.norm(controls-controls[0], axis=1)) + <= .25*atol) + links = [] + if collapsed: + if not budget.append_output(output['points'], + SSXPoint(parameters[0], xyz[0]), 'isoline'): + return output + budget.mark_incomplete(REASON_PARAMETER_FIBER) + else: + boundary = fixed in (0., 1.) + branch = SSXBranch( + curve=(parameters, xyz), overlap=boundary, + kind='overlap' if boundary else 'tangential' if tangent else 'transversal') + if not budget.append_output(output['branches'], branch, 'isoline'): + return output + links = [(len(output['branches']) - 1, 0)] + if cusp: + singularity = SSXSingularity( + 'cusp_curve', parameters[0], xyz[0], samples=parameters, + surface=source_index + 1, + branch_links=links) + if not budget.append_output(output['singularities'], singularity, 'isoline'): + return output + if budget.exhausted: + return output + # Clipping can leave a single corner contact with no interval. + for free in breaks: + if any(lo <= free <= hi for lo, hi in intervals): + continue + xyz = _evaluate(curve, free) + q = coordinates(fixed, free, xyz) + if q is not None: + if not budget.append_output(output['points'], SSXPoint(q, xyz), 'isoline'): + return output + return output diff --git a/mmcore/numeric/intersection/ssx/_ssx_matched_isolines.py b/mmcore/numeric/intersection/ssx/_ssx_matched_isolines.py new file mode 100644 index 00000000..6264b55e --- /dev/null +++ b/mmcore/numeric/intersection/ssx/_ssx_matched_isolines.py @@ -0,0 +1,164 @@ +"""Numerical isolines on surfaces sharing an injective projected chart.""" +from itertools import product + +import numpy as np + +from mmcore.numeric._bern_roots import bernstein_roots +from mmcore.numeric._bezier_common import eval_surface, subdivide_curve +from mmcore.numeric._work_budget import REASON_TRACE_UNVERIFIED +from mmcore.numeric.intersection.ssx._ssx_isolines import ( + _arithmetic_scale, _evaluate, _polynomial, _root_tolerance, + _surface_speed_bound, +) + + +def try_matched_isoline_ssx(first_h, second_h, atol, budget): + """Resolve a one-parameter height difference on a common chart. + + The projected chart must be affine in the fixed parameter and strictly + monotone in a perpendicular coordinate of the free parameter. Thus + equal projected points have the same paired parameters. No sampled + proximity test is used to assume that correspondence. + """ + from mmcore.numeric.intersection.ssx._bez_ssx5 import SSXBranch + + if not budget.charge_cells(int(np.prod(first_h.shape[:2]) + np.prod(second_h.shape[:2])), + 'isoline_setup'): + return None + first, second = _polynomial(first_h), _polynomial(second_h) + if first is None or second is None: + return None + noise = _arithmetic_scale(first) + _arithmetic_scale(second) + selected = None + for transpose, flip_first, flip_second in product((False, True), repeat=3): + aligned = second.swapaxes(0, 1) if transpose else second + if flip_first: + aligned = aligned[::-1] + if flip_second: + aligned = aligned[:, ::-1] + if aligned.shape != first.shape: + continue + if not budget.charge_cells(int(np.prod(first.shape[:2])), 'isoline_setup'): + return None + difference = first - aligned + if np.max(np.linalg.norm(difference, axis=-1)) <= atol: + continue # Within-tolerance coincidence belongs to region assembly. + if np.max(np.abs(difference)) <= noise: + continue # Coincident surfaces need a region representation. + _, singular_values, directions = np.linalg.svd(difference.reshape(-1, 3), full_matrices=False) + normal = directions[0] + height = difference @ normal + remainder = difference - height[..., None] * normal + if np.max(np.linalg.norm(remainder, axis=-1)) > min(noise, .01 * atol): + continue + projected = first - (first @ normal)[..., None] * normal + for fixed_axis in (0, 1): + ordered = np.moveaxis(projected, fixed_axis, 0) + heights = np.moveaxis(height, fixed_axis, 0) + profile = heights[:, 0] + if (len(profile) < 2 or ordered.shape[1] < 2 + or np.ptp(profile) <= noise + or np.max(np.abs(heights - profile[:, None])) > min(noise, .01 * atol)): + continue + direction = ordered[-1, 0] - ordered[0, 0] + if np.linalg.norm(direction) <= noise: + continue + levels = np.linspace(0., 1., len(ordered))[:, None, None] + expected = ordered[0][None, ...] + levels * direction + if np.max(np.linalg.norm(ordered - expected, axis=-1)) > min(noise, .01 * atol): + continue + perpendicular = np.cross(normal, direction) + perpendicular /= np.linalg.norm(perpendicular) + free_coordinate = ordered[0] @ perpendicular + derivative = (len(free_coordinate) - 1) * np.diff(free_coordinate) + if not (np.all(derivative > noise) or np.all(derivative < -noise)): + continue + selected = aligned, fixed_axis, profile, (transpose, flip_first, flip_second) + break + if selected is not None: + break + if selected is None: + return None + aligned, fixed_axis, profile, orientation = selected + # Both charts use the same aligned parameters. The larger derivative + # controls how far a root-location error can move their common curve. + aligned_h = np.concatenate((aligned, np.ones(aligned.shape[:2] + (1,))), axis=2) + parameter_tol = _root_tolerance(max( + _surface_speed_bound(first_h, fixed_axis), + _surface_speed_bound(aligned_h, fixed_axis)), atol) + if parameter_tol is None: + return None + census = bernstein_roots( + profile, parameter_tol=parameter_tol, + charge=lambda amount: budget.charge_cells(amount, 'isoline_roots')) + if not census.complete or census.constant_zero: + return None + output = dict(branches=[], points=[], singularities=[], overlap_regions=[]) + degree = len(profile) - 1 + + def parameters(fixed, free): + first_uv = np.array([fixed, free]) if fixed_axis == 0 else np.array([free, fixed]) + second_uv = first_uv.copy() + transpose, flip_first, flip_second = orientation + if flip_first: + second_uv[0] = 1. - second_uv[0] + if flip_second: + second_uv[1] = 1. - second_uv[1] + if transpose: + second_uv = second_uv[::-1] + return np.concatenate((first_uv, second_uv)) + + curves = [] + for fixed in census.roots: + if not budget.charge_cells(int(np.prod(first.shape[:2])), 'isoline_setup'): + return None + a = _evaluate(np.moveaxis(first, fixed_axis, 0), fixed) + b = _evaluate(np.moveaxis(aligned, fixed_axis, 0), fixed) + if np.max(np.linalg.norm(a - b, axis=1)) > .25 * atol: + return None + curves.append((fixed, a, b)) + for fixed, a, b in curves: + q0 = parameters(fixed, 0.) + xyz0 = .5 * (a[0] + b[0]) + if any(np.linalg.norm(eval_surface(net, *uv, rational=True) - xyz0) > .25 * atol + for net, uv in ((first_h, q0[:2]), (second_h, q0[2:]))): + budget.mark_incomplete(REASON_TRACE_UNVERIFIED) + continue + qs, xyz = [q0], [xyz0] + stack = [(0., 1., a, b)] + while stack: + if not budget.charge_cells(max(1, 2 * len(a)**2 // 128), 'isoline_trace'): + break + lo, hi, left, right = stack.pop() + ends = .5 * (left[[0, -1]] + right[[0, -1]]) + linear = np.linspace(ends[0], ends[1], len(left)) + error = max(np.max(np.linalg.norm(left - linear, axis=1)), + np.max(np.linalg.norm(right - linear, axis=1))) + if error <= .5 * atol: + q = parameters(fixed, hi) + if any(np.linalg.norm(eval_surface(net, *uv, rational=True) - ends[1]) > .25 * atol + for net, uv in ((first_h, q[:2]), (second_h, q[2:]))): + budget.mark_incomplete(REASON_TRACE_UNVERIFIED) + break + qs.append(q) + xyz.append(ends[1]) + else: + mid = .5 * (lo + hi) + if not lo < mid < hi: + budget.mark_incomplete(REASON_TRACE_UNVERIFIED) + break + a0, a1 = subdivide_curve(left, .5) + b0, b1 = subdivide_curve(right, .5) + stack.extend(((mid, hi, a1, b1), (lo, mid, a0, b0))) + if len(qs) >= 2: + height_derivative = float(_evaluate(degree * np.diff(profile), fixed)) + tangent = abs(height_derivative) <= 64. * np.finfo(float).eps * degree * np.max(np.abs(profile)) + boundary = fixed in (0., 1.) + branch = SSXBranch( + curve=(np.asarray(qs), np.asarray(xyz)), overlap=boundary, + kind='overlap' if boundary else 'tangential' if tangent else 'transversal') + if not budget.append_output(output['branches'], branch, 'isoline'): + return output + if budget.exhausted: + return output + return output diff --git a/mmcore/numeric/intersection/ssx/_ssx_opposed_strata.py b/mmcore/numeric/intersection/ssx/_ssx_opposed_strata.py deleted file mode 100644 index 90043b6f..00000000 --- a/mmcore/numeric/intersection/ssx/_ssx_opposed_strata.py +++ /dev/null @@ -1,219 +0,0 @@ -"""Exact boundary correspondences forced by opposed supporting halfspaces. - -The certificate is a separating plane and one-sign Bernstein heights on -both source charts. Every common point must have height zero on each -chart. Nonzero one-sign heights vanish only on zero edges and corners; -this bounded tier intersects all such affine strata in lifted parameters. -""" -from fractions import Fraction -from itertools import combinations - -import numpy as np - -from mmcore.numeric._work_budget import REASON_PARAMETER_REPRESENTATION, REASON_WORK_BUDGET -from mmcore.numeric.intersection.ssx._ssx_affine_path import affine_path_representation_bounded -from mmcore.numeric.intersection.ssx._ssx_singular_candidate import _cross, _evaluate -from mmcore.numeric.intersection.ssx._ssx_substrate import SSXBranch, SSXPoint - - -def _sub(a, b): - return tuple(x-y for x, y in zip(a, b)) - - -def _dot(a, b): - return sum(x*y for x, y in zip(a, b)) - - -def _strata(source, heights): - """Each stratum is (parameter start/direction, XYZ start/direction).""" - zero = Fraction(0) - strata, edges = [], [] - for axis in (0, 1): - for side in (0, 1): - edge = (source[0 if side == 0 else -1] if axis == 0 - else [row[0 if side == 0 else -1] for row in source]) - height = (heights[0 if side == 0 else -1] if axis == 0 - else [row[0 if side == 0 else -1] for row in heights]) - if any(height): - continue - delta = _sub(edge[-1], edge[0]) - degree = len(edge)-1 - if not any(delta) or any( - point != tuple(a+Fraction(i, degree)*b for a, b in zip(edge[0], delta)) - for i, point in enumerate(edge)): - return None # A collapsed/curved edge needs its own solver. - start_uv = (Fraction(side), zero) if axis == 0 else (zero, Fraction(side)) - delta_uv = (zero, Fraction(1)) if axis == 0 else (Fraction(1), zero) - strata.append((start_uv, delta_uv, edge[0], delta)) - edges.append((axis, side)) - for s, t in ((0, 0), (0, 1), (1, 0), (1, 1)): - i, j = (0 if s == 0 else -1), (0 if t == 0 else -1) - if heights[i][j] or any((s, t)[axis] == side for axis, side in edges): - continue - strata.append(((Fraction(s), Fraction(t)), (zero, zero), source[i][j], (zero,)*3)) - return strata - - -def _intersect_strata(first, second): - """Exact parameter intervals, including singleton intersections.""" - _, _, p, d = first - _, _, q, e = second - zero, one = Fraction(0), Fraction(1) - offset = _sub(q, p) - if not any(d): - if not any(e): - return ((zero, zero), (zero, zero)) if p == q else None - axis = max(range(3), key=lambda k: abs(e[k])) - v = -offset[axis]/e[axis] - return ((zero, zero), (v, v)) if 0 <= v <= 1 and all( - p[k] == q[k]+v*e[k] for k in range(3)) else None - if not any(e): - reverse = _intersect_strata(second, first) - return None if reverse is None else reverse[::-1] - cross = _cross(d, e) - if any(cross): - omitted = max(range(3), key=lambda k: abs(cross[k])) - i, j = [k for k in range(3) if k != omitted] - determinant = d[i]*e[j]-d[j]*e[i] - u = (offset[i]*e[j]-offset[j]*e[i])/determinant - v = (offset[i]*d[j]-offset[j]*d[i])/determinant - if 0 <= u <= 1 and 0 <= v <= 1 and all(p[k]+u*d[k] == q[k]+v*e[k] for k in range(3)): - return (u, u), (v, v) - return None - if any(_cross(d, offset)): - return None - axis = max(range(3), key=lambda k: abs(d[k])) - a, b = offset[axis]/d[axis], e[axis]/d[axis] - lo, hi = max(zero, min(a, a+b)), min(one, max(a, a+b)) - return None if hi < lo else ((lo, hi), ((lo-a)/b, (hi-a)/b)) - - -def append_exact_junctions(result, exact, budget): - """Classify shared exact endpoints; proximity is never root identity.""" - from mmcore.numeric.intersection.ssx._bez_ssx5 import SSXSingularity - - endpoints = {} - float_keys, aliases = {}, set() - for index, branch in enumerate(result['branches']): - q, xyz = branch.curve - keys = getattr(branch, '_exact_endpoint_keys', None) - if keys is None: - continue - for key, end in zip(keys, (0, len(q)-1)): - floating = tuple(q[end]) - if floating in float_keys and float_keys[floating] != key: - aliases.add(floating) - float_keys[floating] = key - endpoints.setdefault(key, []).append((index, end, xyz[end])) - for point in result['points']: - key = getattr(point, '_exact_parameter_key', None) - if key is None: - continue - floating = tuple(point.stuv) - if floating in float_keys and float_keys[floating] != key: - aliases.add(floating) - float_keys[floating] = key - if aliases: - budget.mark_incomplete(REASON_PARAMETER_REPRESENTATION) - budget.append_output(result['unresolved_regions'], dict( - stuv_min=(0.,)*4, stuv_max=(1.,)*4, - reason=REASON_PARAMETER_REPRESENTATION), 'unresolved_region') - nets = [np.asarray(source, dtype=object) for source in exact] - for key, ports in endpoints.items(): - if len({index for index, *_ in ports}) < 2: - continue - parameters = tuple(Fraction.from_float(float(x)) for x in key) - if parameters != key or tuple(map(float, key)) in aliases: - continue # Publication rounding is not shared root identity. - cost = sum(net.size*(1+sum(n-1 for n in net.shape[:2])) for net in nets) - if not budget.charge_cells(max(1, cost), 'boundary_junction'): - budget.mark_incomplete(REASON_WORK_BUDGET) - return - values, normals = [], [] - for owner, net in enumerate(nets): - uv = parameters[2*owner:2*owner+2] - values.append(_evaluate(net, uv)) - jets = [_evaluate((net.shape[axis]-1)*np.diff(net, axis=axis), uv) for axis in (0, 1)] - normals.append(_cross(*jets)) - if values[0] != values[1] or not all(any(normal) for normal in normals) or any(_cross(*normals)): - continue - g = SSXSingularity(kind='tangent_point', stuv=np.asarray(key), - xyz=np.asarray(ports[0][2]), - branch_links=[(index, end) for index, end, _ in ports]) - budget.append_output(result['singularities'], g, 'singularity') - - -def opposed_boundary_strata_ssx(nets, exact, atol, rational, budget): - corners = list(dict.fromkeys(source[i][j] for source in exact - for i in (0, -1) for j in (0, -1))) - strata = None - for origin, second, third in combinations(corners, 3): - normal = _cross(_sub(second, origin), _sub(third, origin)) - if not any(normal): - continue - if not budget.charge_cells(sum(len(row) for source in exact for row in source), 'boundary_halfspace'): - return None - heights = [[[_dot(normal, _sub(point, origin)) for point in row] for row in source] for source in exact] - flat = [[value for row in height for value in row] for height in heights] - if not all(any(values) for values in flat): - continue # A plane target has a two-dimensional zero stratum. - if not ((all(x >= 0 for x in flat[0]) and all(x <= 0 for x in flat[1])) - or (all(x <= 0 for x in flat[0]) and all(x >= 0 for x in flat[1]))): - continue - strata = [_strata(source, height) for source, height in zip(exact, heights)] - if all(item is not None for item in strata): - break - strata = None - if strata is None: - return None - result = dict(branches=[], points=[], singularities=[], overlap_regions=[], unresolved_regions=[]) - point_candidates = {} - branch_endpoint_keys = set() - - def unresolved(reason): - budget.mark_incomplete(reason) - budget.append_output(result['unresolved_regions'], dict( - stuv_min=(0.,)*4, stuv_max=(1.,)*4, reason=reason), 'unresolved_region') - - for first in strata[0]: - for second in strata[1]: - if not budget.charge_cells(1, 'boundary_correspondence'): - unresolved(REASON_WORK_BUDGET) - return result - ranges = _intersect_strata(first, second) - if ranges is None: - continue - lifted, image = [], [] - for endpoint in (0, 1): - q = tuple(a+ranges[owner][endpoint]*b for owner, stratum in enumerate((first, second)) - for a, b in zip(stratum[0], stratum[1])) - xyz = tuple(a+ranges[0][endpoint]*b for a, b in zip(first[2], first[3])) - lifted.append(q) - image.append(xyz) - try: - q, xyz = np.asarray(lifted, dtype=float), np.asarray(image, dtype=float) - except (OverflowError, ValueError): - unresolved(REASON_PARAMETER_REPRESENTATION) - continue - if (not np.all(np.isfinite(q)) or not np.all(np.isfinite(xyz)) - or (lifted[0] != lifted[1] and np.array_equal(q[0], q[1]))): - unresolved(REASON_PARAMETER_REPRESENTATION) - continue - if not affine_path_representation_bounded(*nets, q[0], q[1], xyz, atol, rational=rational, - charge=lambda amount: budget.charge_cells(amount, 'boundary_representation')): - unresolved(REASON_WORK_BUDGET if budget.exhausted else REASON_PARAMETER_REPRESENTATION) - continue - if lifted[0] == lifted[1]: - point = SSXPoint(q[0], xyz[0]) - point._exact_parameter_key = lifted[0] - point_candidates[lifted[0]] = point - else: - branch_endpoint_keys.update(lifted) - branch = SSXBranch((q, xyz), overlap=True, kind='overlap') - branch._exact_endpoint_keys = tuple(lifted) - budget.append_output(result['branches'], branch, 'branch') - for key, point in point_candidates.items(): - if key not in branch_endpoint_keys: - budget.append_output(result['points'], point, 'point') - append_exact_junctions(result, exact, budget) - return result diff --git a/mmcore/numeric/intersection/ssx/_ssx_planar_cut.py b/mmcore/numeric/intersection/ssx/_ssx_planar_cut.py deleted file mode 100644 index e5009429..00000000 --- a/mmcore/numeric/intersection/ssx/_ssx_planar_cut.py +++ /dev/null @@ -1,303 +0,0 @@ -"""Original-source cut census for an affine plane and a Bezier chart. - -Restriction and the univariate polynomial use exact rational arithmetic on -original binary controls. A returned Sturm certificate therefore describes -that source surface, even if a floating restriction would change root count. -Unsupported charts return ``None``. A curve identically in the plane -returns an explicit unresolved source face, never an isolated-root search. -""" -from fractions import Fraction - -import numpy as np - -from mmcore.numeric.intersection._exact_univariate import coefficient_build_work, _power -from mmcore.numeric.intersection.csx._planar_overlap import _convex_chart, _sub, _dot -from mmcore.numeric.intersection.csx._planar_roots import ( - _exact_planar_bilinear_roots_data, _outward_interval) -from mmcore.numeric.intersection.ssx._ssx_root_identity import _pure_affine_coordinates - - -def _fraction(value): - return value if isinstance(value, Fraction) else Fraction.from_float(float(value)) - - -def _split(points, value): - rows = [list(points)] - while len(rows[-1]) > 1: - rows.append([tuple((1-value)*a+value*b for a, b in zip(left, right)) - for left, right in zip(rows[-1], rows[-1][1:])]) - return [row[0] for row in rows], [row[-1] for row in rows[::-1]] - - -def _restrict(points, lo, hi): - if hi != 1: - points, _ = _split(points, hi) - if lo: - _, points = _split(points, lo/hi) - return points - - -def _isocurve(surface, axis, value): - array = surface if axis == 0 else list(map(list, zip(*surface))) - return [_split([row[j] for row in array], value)[0][-1] - for j in range(len(array[0]))] - - -def _surface_rectangle(surface, box): - columns = [_restrict(list(column), *box[0]) for column in zip(*surface)] - return [_restrict(list(row), *box[1]) for row in zip(*columns)] - - -def _cartesian(points): - weights = [point[3] for point in points] - return [tuple(x/w for x in point[:3]) for point, w in zip(points, weights)], weights - - -def _surface_point(surface, u, v): - homogeneous = _split(_isocurve(surface, 0, u), v)[0][-1] - return tuple(value/homogeneous[3] for value in homogeneous[:3]) - - -def _partial(cause, cells=0, **extra): - return dict(isolated=[], overlaps=[], parameter_fibers=[], cells_processed=cells, - budget_exhausted=True, boundary_topology_complete=False, - truncation_cause=cause, **extra) - - -def _pair(value): - return str(value.numerator), str(value.denominator) - - -def supported_source_pair(first_h, second_h): - """Cheap source-level applicability predicate; cache for immutable controls. - - It proves that at least one original surface is an injective uniform- - weight affine plane. Individual cuts can still be unsupported (for - example a plane-owner cut without a matching affine graph coordinate). - """ - for source in (first_h, second_h): - source = np.asarray(source, dtype=float) - if (source.shape != (2, 2, 4) or not np.all(np.isfinite(source)) - or source[0, 0, 3] <= 0 - or not np.all(source[..., 3] == source[0, 0, 3])): - continue - points, _ = _cartesian([tuple(_fraction(value) for value in point) - for row in source for point in row]) - a, c, b, opposite = points - if (not any(opposite[k]+a[k]-b[k]-c[k] for k in range(3)) - and _convex_chart(points) is not None): - return True - return False - - -def source_cut_construction_work(first_h, second_h): - """Prepaid exact-coefficient setup bound, also billable on unsupported cuts.""" - sources = tuple(np.asarray(source) for source in (first_h, second_h)) - candidates = [index for index, source in enumerate(sources) - if source.shape == (2, 2, 4) - and np.all(source[..., 3] == source[0, 0, 3])] - return (sum(int(np.prod(source.shape[:2])) for source in sources) - + max((sum(coefficient_build_work(sources[1-index].shape[k]) - * sources[1-index].shape[1-k] for k in (0, 1)) - for index in candidates), default=0)) - - -def exact_source_planar_cut(first_h, second_h, axis, cut, box, *, - max_cells=100_000, max_results=4096, atol=1e-3): - """Enumerate an original-source four-parameter face exactly. - - Inputs are homogeneous controls and global unit-square parameter bounds. - Each root has ``stuv``, a global ``parameter_root_box``, and a - ``source_cut_certificate``. The certificate's scalar polynomial is in - the original varying parameter, not a rounded restricted parameter. - Its closed interval contains exactly one distinct root; exact affine - inversion supplies the remaining coordinates. ``pinned`` retains all - exact fixed-coordinate relations, including reverse plane-owner cuts. - - Construction and Sturm work consume ``max_cells`` before execution. - ``None`` means the exact planar reduction does not apply, not emptiness. - """ - sources = tuple(np.asarray(source, dtype=float) for source in (first_h, second_h)) - if (axis not in range(4) or len(box) != 4 - or any(source.ndim != 3 or source.shape[-1] != 4 - or not np.all(np.isfinite(source)) or not np.all(source[..., 3] > 0) - for source in sources)): - return None - try: - bounds = tuple(tuple(_fraction(value) for value in pair) for pair in box) - pin = _fraction(cut) - except (ValueError, OverflowError): - return None - if (any(len(pair) != 2 or not 0 <= pair[0] < pair[1] <= 1 for pair in bounds) - or not bounds[axis][0] <= pin <= bounds[axis][1]): - return None - owner = axis//2 - # Prefer the opposite chart as plane. The reverse route pins one - # graph parameter through a proven pure-affine world coordinate. - possible_planes = [1-owner, owner] - possible_planes = [index for index in possible_planes - if sources[index].shape == (2, 2, 4) - and np.all(sources[index][..., 3] == sources[index][0, 0, 3])] - if not possible_planes: - return None - construction_work = source_cut_construction_work(first_h, second_h) - if construction_work > max_cells: - return _partial('preflight', required_coefficient_work=construction_work) - exact_sources = tuple([[tuple(_fraction(value) for value in point) for point in row] - for row in source] for source in sources) - plane_index = None - for index in possible_planes: - plane = exact_sources[index] - plane_points, _ = _cartesian([p for row in plane for p in row]) - a, c, b, opposite = plane_points - if (not any(opposite[k]+a[k]-b[k]-c[k] for k in range(3)) - and _convex_chart(plane_points) is not None): - plane_index, original_plane_points = index, plane_points - break - if plane_index is None: - return None - graph_index = 1-plane_index - plane, graph = exact_sources[plane_index], exact_sources[graph_index] - pinned = {axis: pin} - if owner == graph_index: - graph_axis, graph_pin = axis % 2, pin - else: - graph_axis, graph_pin = None, None - plane_affine, graph_affine = (_pure_affine_coordinates(source) for source in - (sources[plane_index], sources[graph_index])) - for coordinate in range(3): - relation = plane_affine.get((coordinate, axis % 2)) - if relation is None: - continue - for candidate_axis in (0, 1): - inverse = graph_affine.get((coordinate, candidate_axis)) - if inverse is not None: - graph_axis = candidate_axis - graph_pin = (relation[0]+relation[1]*pin-inverse[0])/inverse[1] - break - if graph_axis is not None: - break - if graph_axis is None: - return None - pinned[2*graph_index+graph_axis] = graph_pin - graph_global_axis = 2*graph_index+graph_axis - if not bounds[graph_global_axis][0] <= graph_pin <= bounds[graph_global_axis][1]: - return dict(isolated=[], overlaps=[], parameter_fibers=[], cells_processed=construction_work, - budget_exhausted=False, boundary_topology_complete=True) - varying_axis = 2*graph_index+1-graph_axis - global_curve = _isocurve(graph, graph_axis, graph_pin) - curve = _restrict(global_curve, *bounds[varying_axis]) - target_bounds = bounds[2*plane_index:2*plane_index+2] - target = _surface_rectangle(plane, target_bounds) - points, weights = _cartesian(curve) - target_points, target_weights = _cartesian([point for row in target for point in row]) - origin, normal, *_ = _convex_chart(original_plane_points) - if not any(w*_dot(normal, _sub(p, origin)) for p, w in zip(points, weights)): - # The scalar source equation vanishes identically. A 3D isolated - # root solver cannot enumerate this parameter curve; sending it - # there only spends its allowance on an infinite family of roots. - # Convex trimming/stratum ownership is a separate obligation. The - # complete closed source face safely covers that remaining work, - # including possible empty pieces, without assuming an overlap. - face_box = list(bounds) - face_box[axis] = pin, pin - return _partial('resolution', construction_work, unresolved_source_boxes=[{ - 'parameter_root_box': tuple(_outward_interval(pair) for pair in face_box), - 'reason': 'positive_dimensional_cut'}]) - result = _exact_planar_bilinear_roots_data( - points, weights, target_points, target_weights, max_cells=max_cells, - max_results=max_results, atol=atol, construction_work=construction_work) - if result is None: - return None - if not result['isolated']: - if result.pop('unresolved_parameter_boxes', None): - result['unresolved_source_boxes'] = [{ - 'parameter_root_box': tuple(_outward_interval(pair) for pair in bounds), - 'reason': 'local_parameter_representation'}] - return result - global_polynomial_work = coefficient_build_work(len(global_curve)) - if result['cells_processed']+global_polynomial_work > max_cells: - result['isolated'] = [] - result.update(budget_exhausted=True, boundary_topology_complete=False, - truncation_cause='max_cells', unresolved_source_boxes=[{ - 'parameter_root_box': tuple(_outward_interval(pair) for pair in bounds), - 'reason': 'source_certificate_work'}]) - return result - result['cells_processed'] += global_polynomial_work - global_points, global_weights = _cartesian(global_curve) - origin, normal, *_ = _convex_chart(original_plane_points) - polynomial = _power([w*_dot(normal, _sub(p, origin)) for p, w in zip(global_points, global_weights)]) - # Monic normalization makes the same source face independent of target - # rectangle scaling and isolates the same root across adjacent cells. - polynomial = tuple(value/polynomial[-1] for value in polynomial) - parameter_axes = (varying_axis, 2*plane_index, 2*plane_index+1) - mapped_roots = [] - representation_work = 16+sum( - source.shape[1]*coefficient_build_work(source.shape[0]) - + coefficient_build_work(source.shape[1]) for source in sources) - for root in result['isolated']: - if result['cells_processed']+representation_work > max_cells: - result.update(budget_exhausted=True, boundary_topology_complete=False, - truncation_cause='max_cells') - result.setdefault('unresolved_source_boxes', []).append({ - 'parameter_root_box': tuple(_outward_interval(pair) for pair in bounds), - 'reason': 'source_representation_work'}) - break - result['cells_processed'] += representation_work - exact_box = [None]*4 - stuv = [None]*4 - for fixed_axis, fixed_value in pinned.items(): - exact_box[fixed_axis] = (fixed_value, fixed_value) - stuv[fixed_axis] = float(fixed_value) - for local_axis, global_axis in enumerate(parameter_axes): - if global_axis in pinned: - continue - lo, hi = bounds[global_axis] - interval = root['exact_parameter_root_box'][local_axis] - exact_box[global_axis] = tuple(lo+(hi-lo)*value for value in interval) - local_value = root[('t', 'u', 'v')[local_axis]] - stuv[global_axis] = float(lo+(hi-lo)*_fraction(local_value)) - exact_parameter = tuple(_fraction(value) for value in stuv) - first_point = _surface_point(exact_sources[0], *exact_parameter[:2]) - second_point = _surface_point(exact_sources[1], *exact_parameter[2:]) - try: - point = np.array(first_point, dtype=float) - rounded_point = tuple(_fraction(value) for value in point) - distance = max(sum((x-y)**2 for x, y in zip(first_point, second_point)), - sum((x-y)**2 for x, y in zip(rounded_point, first_point)), - sum((x-y)**2 for x, y in zip(rounded_point, second_point))) - except (OverflowError, ValueError): - distance = None - global_interval = exact_box[varying_axis] - certificate = { - 'kind': 'exact_source_planar_cut', - 'source_ids': (id(first_h), id(second_h)), - 'axis': axis, 'cut': _pair(pin), 'varying_axis': varying_axis, - 'pinned': tuple((key, _pair(value)) for key, value in sorted(pinned.items())), - 'polynomial': tuple(_pair(value) for value in polynomial), - 'interval': tuple(_pair(value) for value in global_interval), - 'parameter_root_box': tuple(tuple(_pair(value) for value in pair) for pair in exact_box), - } - global_box = tuple(_outward_interval(pair) for pair in exact_box) - if (distance is None or distance > _fraction(atol)**2 - or any(tuple(existing['stuv']) == tuple(stuv) for existing in mapped_roots)): - result.update(budget_exhausted=True, boundary_topology_complete=False, - truncation_cause='resolution') - result.setdefault('unresolved_source_boxes', []).append({ - 'parameter_root_box': global_box, 'reason': 'parameter_representation', - 'source_cut_certificate': certificate}) - continue - entry = dict(stuv=np.array(stuv), point=point, parameter_root_box=global_box, - parameter_root_certification='exact_source_sturm_isolation', - source_cut_certificate=certificate) - if 'root_multiplicity' in root: - entry['root_multiplicity'] = root['root_multiplicity'] - mapped_roots.append(entry) - result['isolated'] = mapped_roots - # A local unresolved interval is not a complete global proof region; - # retain the whole queried source box conservatively in that case. - if result.pop('unresolved_parameter_boxes', None): - result.setdefault('unresolved_source_boxes', []).append({ - 'parameter_root_box': tuple(_outward_interval(pair) for pair in bounds), - 'reason': 'local_parameter_representation'}) - return result diff --git a/mmcore/numeric/intersection/ssx/_ssx_positive_dimensional.py b/mmcore/numeric/intersection/ssx/_ssx_positive_dimensional.py deleted file mode 100644 index f1224993..00000000 --- a/mmcore/numeric/intersection/ssx/_ssx_positive_dimensional.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Bounded ownership of one unsupported convex planar 2D relation. - -Strictly convex positive-weight rational bilinear charts are homeomorphisms -onto their corner quadrilaterals. We verify the homogeneous planar Jacobian -numerator exactly: det(H, H_s, H_t) has degree at most (1,1), and its four -Bernstein coefficients have one strict sign. The boundary is a simple -quadrilateral traversed monotonically because its edge weights are positive. -The nonzero interior Jacobian and this degree-one boundary establish global -injectivity and surjectivity onto that quadrilateral. - -Positive-area intersection of two such exact coplanar quadrilaterals is one -convex region. The entire paired zero set is its unique lifted image; there -are no additional ordinary components or isolated fibers to search. This -module records that whole owner as unresolved because it does not yet build -a certified paired UV region representation. It never declares it complete. -Higher-degree/folded charts, zero-area contacts, and plane near-coincidences -remain with the general solver. -""" -from fractions import Fraction -from itertools import permutations - -import numpy as np - -from mmcore.numeric._work_budget import REASON_OVERLAP_REGION -from mmcore.numeric.intersection.csx._planar_overlap import ( - _exact_points, _convex_chart, _dot, _sub, -) - - -class _Stopped(Exception): - pass - - -def _add(a, b, sign, spend): - spend(len(a)+len(b)) - result = dict(a) - for index, value in b.items(): - result[index] = result.get(index, 0)+sign*value - if result[index] == 0: - del result[index] - return result - - -def _multiply(a, b, spend): - spend(2*len(a)*len(b)) - result = {} - for (i, j), x in a.items(): - for (k, ell), y in b.items(): - index = (i+k, j+ell) - result[index] = result.get(index, 0)+x*y - return {index: value for index, value in result.items() if value} - - -def _strict_rational_jacobian(points, weights, axes, spend): - """Exact projected determinant, with no floating normal threshold.""" - spend(8+3*4*4) - homogeneous = [tuple(p[k]*w for k in axes)+(w,) - for p, w in zip(points, weights)] - rows = [] - for axis in range(3): - a, c, b, d = (point[axis] for point in homogeneous) - h = {(0, 0): a, (1, 0): b-a, - (0, 1): c-a, (1, 1): d-b-c+a} - h = {index: value for index, value in h.items() if value} - hs = {(0, j): value for (i, j), value in h.items() if i} - ht = {(i, 0): value for (i, j), value in h.items() if j} - rows.append((h, hs, ht)) - determinant = {} - for permutation in permutations(range(3)): - inversions = sum(permutation[i] > permutation[j] - for i in range(3) for j in range(i+1, 3)) - term = {(0, 0): Fraction(1)} - for row, column in enumerate(permutation): - term = _multiply(term, rows[row][column], spend) - determinant = _add(determinant, term, (-1)**inversions, spend) - # This identity is also checked rather than merely assumed from degree. - if any(i > 1 or j > 1 for i, j in determinant): - return False - spend(4*max(1, len(determinant))) - corners = [sum(value for (i, j), value in determinant.items() - if (s or not i) and (t or not j)) - for s, t in ((0, 0), (0, 1), (1, 0), (1, 1))] - return all(value > 0 for value in corners) or all(value < 0 for value in corners) - - -def _clip_polygon(points, chart, spend): - _, _, axes, quad, edges, orientation = chart - - def side(point, edge, origin): - spend(6) - x, y = (point[k]-origin[i] for i, k in enumerate(axes)) - return orientation*(edge[0]*y-edge[1]*x) - - for edge, origin in zip(edges, quad): - if not points: - return [] - output = [] - previous = points[-1] - previous_value = side(previous, edge, origin) - for current in points: - current_value = side(current, edge, origin) - if (previous_value >= 0) != (current_value >= 0): - spend(11) - t = previous_value/(previous_value-current_value) - output.append(tuple(a+t*(b-a) for a, b in zip(previous, current))) - if current_value >= 0: - output.append(current) - previous, previous_value = current, current_value - spend(3*len(output)) - points = [] - for point in output: - if not points or point != points[-1]: - points.append(point) - if len(points) > 1 and points[0] == points[-1]: - points.pop() - return points - - -def exact_convex_bilinear_region_owner(first, second, rational, budget): - """Return an explicit 2D unresolved owner, or None without a proof. - - The inputs are the original world source nets. No subdivision or - normalization can alter the exact coefficients used by this predicate. - Output contains exact image-polygon rationals, not invented UV samples. - """ - nets = tuple(np.asarray(net, dtype=float) for net in (first, second)) - dimension = 4 if rational else 3 - if any(net.shape != (2, 2, dimension) or not np.all(np.isfinite(net)) - for net in nets): - return None - - def spend(amount): - if not budget.charge_cells(max(1, int(amount)), 'positive_dimensional_owner'): - raise _Stopped - - try: - # Original Fraction conversion and Cartesian division, once. - spend(sum(net.size for net in nets)+24) - data = tuple(_exact_points(net, rational) for net in nets) - if any(item is None for item in data): - return None - charts = [] - for points, weights in data: - # The four-corner chart routine uses fewer than128 scalar - # differences/products/comparisons; reserve before it runs. - spend(128) - chart = _convex_chart(points) - if chart is None: - return None - charts.append(chart) - origin, normal = charts[0][:2] - spend(4*9) - if any(_dot(normal, _sub(point, origin)) for point in data[1][0]): - return None - for (points, weights), chart in zip(data, charts): - if not _strict_rational_jacobian(points, weights, chart[2], spend): - return None - polygon = _clip_polygon([data[0][0][i] for i in (0, 2, 3, 1)], charts[1], spend) - if len(polygon) < 3: - return None - i, j = charts[0][2] - spend(4*len(polygon)) - area = sum(a[i]*b[j]-a[j]*b[i] - for a, b in zip(polygon, polygon[1:]+polygon[:1])) - if area == 0: - return None - spend(3*len(polygon)) - diagnostic = { - 'stuv_min': (0.,)*4, 'stuv_max': (1.,)*4, - 'reason': REASON_OVERLAP_REGION, - 'exact_dimension': 2, - 'proof': 'exact_convex_rational_bilinear_region', - 'exact_image_polygon': tuple(tuple(str(x) for x in point) for point in polygon), - 'unresolved_obligation': 'paired_region_representation', - } - except _Stopped: - return None - result = dict(branches=[], points=[], singularities=[], overlap_regions=[], unresolved_regions=[]) - budget.mark_incomplete(REASON_OVERLAP_REGION) - budget.append_output(result['unresolved_regions'], diagnostic, 'unresolved_region') - return result diff --git a/mmcore/numeric/intersection/ssx/_ssx_quadratic_constraints.py b/mmcore/numeric/intersection/ssx/_ssx_quadratic_constraints.py deleted file mode 100644 index 8fcca2b6..00000000 --- a/mmcore/numeric/intersection/ssx/_ssx_quadratic_constraints.py +++ /dev/null @@ -1,781 +0,0 @@ -"""Complete affine zero sets of exact quadratic surface residuals. - -Uniform positive weights give polynomial Cartesian charts. Linear residual -equations are eliminated by exact RREF. A semidefinite quadratic with zero -minimum vanishes exactly where its linear gradient vanishes, supplying more -linear constraints. A final one-dimensional affine set is clipped against -the four parameter domains and represented with certified curve chords. -Rationally factored quadratics give an exact union of affine constraints. -When their remaining source equations have linear consequences reducing -each factor to a line, the full line union and its shared singular -junctions are represented. Constant-image lines retain their nonimmersed -parameter fibers. Unfactored quadratics, remaining nonlinear equations, -higher-dimensional sets, and unsupported nonregular images remain the -general solver's responsibility. -""" -from fractions import Fraction -from itertools import permutations -from math import comb, isqrt, prod - -import numpy as np - -from mmcore.numeric._work_budget import ( - REASON_OUTPUT_CAP, REASON_PARAMETER_REPRESENTATION, REASON_WORK_BUDGET, -) -from mmcore.numeric.intersection._exact_univariate import _power, coefficient_build_work -from mmcore.numeric.intersection.ssx._ssx_affine_path import ( - _surface_affine_path, _restrict_exact_curve, _chord_error_bounded, - affine_path_representation_bounded, -) -from mmcore.numeric.intersection.ssx._ssx_substrate import SSXBranch - - -class _Stopped(Exception): - pass - - -def _clean(poly): - return {power: value for power, value in poly.items() if value} - - -def _add(first, second, scale=1): - result = dict(first) - for power, value in second.items(): - result[power] = result.get(power, 0)+scale*value - return _clean(result) - - -def _multiply(first, second, spend): - spend(max(1, len(first)*len(second))) - result = {} - for a, x in first.items(): - for b, y in second.items(): - power = tuple(i+j for i,j in zip(a,b)) - result[power] = result.get(power, 0)+x*y - return _clean(result) - - -def _substitute(poly, origin, matrix, spend): - dimension = len(matrix[0]) - zero = (0,)*dimension - linear = [] - for offset, row in zip(origin,matrix): - item = {zero: offset} if offset else {} - for axis, value in enumerate(row): - if value: - exponent = tuple(int(k == axis) for k in range(dimension)) - item[exponent] = value - linear.append(item) - result = {} - for powers, coefficient in poly.items(): - term = {zero: coefficient} - for axis, power in enumerate(powers): - for _ in range(power): - term = _multiply(term,linear[axis],spend) - result = _add(result,term) - return result - - -def _linear_solution(rows, dimension, spend): - """Solve rows[0]+sum(rows[i+1]*x[i])=0; return affine RREF map.""" - spend(max(1,4*len(rows)*(dimension+1)**2)) - work = [list(row[1:])+[-row[0]] for row in rows] - pivots = [] - for column in range(dimension): - pivot = next((i for i in range(len(pivots),len(work)) if work[i][column]),None) - if pivot is None: - continue - target = len(pivots) - work[target],work[pivot] = work[pivot],work[target] - scale = work[target][column] - work[target] = [value/scale for value in work[target]] - for i,row in enumerate(work): - if i != target and row[column]: - factor = row[column] - work[i] = [a-factor*b for a,b in zip(row,work[target])] - pivots.append(column) - if any(not any(row[:dimension]) and row[-1] for row in work): - return None - free = [axis for axis in range(dimension) if axis not in pivots] - origin = [Fraction(0)]*dimension - matrix = [[Fraction(int(axis == free_axis)) for free_axis in free] - for axis in range(dimension)] - for row,pivot in zip(work,pivots): - origin[pivot] = row[-1] - matrix[pivot] = [-row[axis] for axis in free] - return origin,matrix - - -def _psd(matrix, spend): - """Exact symmetric LDL criterion, including zero pivots.""" - n = len(matrix) - spend(max(1,4*n**3)) - work = [row.copy() for row in matrix] - for i in range(n): - pivot = work[i][i] - if pivot < 0: - return False - if pivot == 0: - if any(work[i][j] for j in range(i+1,n)): - return False - continue - for j in range(i+1,n): - for k in range(j,n): - work[k][j] = work[j][k] = work[j][k]-work[i][j]*work[i][k]/pivot - return True - - -def _quadratic_rows(poly, dimension, spend): - """Return equivalent linear zero constraints, 'empty', or unsupported.""" - zero = (0,)*dimension - matrix = [[Fraction(0)]*dimension for _ in range(dimension)] - linear = [Fraction(0)]*dimension - for power,value in poly.items(): - axes = [i for i,p in enumerate(power) for _ in range(p)] - if len(axes) == 1: - linear[axes[0]] = value - elif len(axes) == 2: - i,j = axes - if i == j: - matrix[i][i] = value - else: - matrix[i][j] = matrix[j][i] = value/2 - sign = 1 - if not _psd(matrix,spend): - matrix = [[-value for value in row] for row in matrix] - if not _psd(matrix,spend): - return None - linear = [-value for value in linear] - sign = -1 - rows = [[linear[i]]+[2*value for value in row] for i,row in enumerate(matrix)] - critical = _linear_solution(rows,dimension,spend) - if critical is None: - return None - point = critical[0] - minimum = sign*poly.get(zero,0)+sum(a*b for a,b in zip(linear,point)) - minimum += sum(point[i]*matrix[i][j]*point[j] - for i in range(dimension) for j in range(dimension)) - if minimum > 0: - return 'empty' - return rows if minimum == 0 else None - - -def _affine_factor_rows(poly, dimension, spend): - """Exact rational affine factors of a quadratic, or unsupported. - - Completing the square in one variable gives discriminant L²-4AQ. - It factors over the rationals precisely when that discriminant is - the square of a rational affine polynomial. A reversible shear handles - mixed-only quadratics such as x*y without a squared-coordinate term. - Every proposed factorization is checked by exact polynomial equality. - """ - zero = (0,)*dimension - basis = [tuple(int(k == axis) for k in range(dimension)) for axis in range(dimension)] - square = [tuple(2*value for value in power) for power in basis] - pivot = next((i for i,power in enumerate(square) if poly.get(power)),None) - shear = None - if pivot is None: - mixed = next((power for power in poly if sum(power) == 2),None) - if mixed is None: - return None - i,j = [axis for axis,power in enumerate(mixed) if power] - transform = [[Fraction(int(a == b)) for b in range(dimension)] for a in range(dimension)] - transform[j][i] = Fraction(1) # x_j = y_j+y_i. - poly = _substitute(poly,[Fraction(0)]*dimension,transform,spend) - pivot,shear = i,(i,j) - coefficient = poly[square[pivot]] - linear, remainder = {}, {} - for power,value in poly.items(): - if power[pivot] == 1: - exponent = list(power); exponent[pivot] = 0 - linear[tuple(exponent)] = value - elif not power[pivot]: - remainder[power] = value - discriminant = _add(_multiply(linear,linear,spend),remainder,-4*coefficient) - augmented = [zero]+basis - diagonal = [discriminant.get(tuple(2*x for x in power),Fraction(0)) for power in augmented] - index = next((i for i,value in enumerate(diagonal) if value),None) - if index is None: - if discriminant: - return None - root = [Fraction(0)]*(dimension+1) - else: - value = diagonal[index] - if value < 0: - return None - spend(max(1,value.numerator.bit_length()+value.denominator.bit_length())) - numerator,denominator = isqrt(value.numerator),isqrt(value.denominator) - if numerator*numerator != value.numerator or denominator*denominator != value.denominator: - return None - root = [Fraction(0)]*(dimension+1) - root[index] = Fraction(numerator,denominator) - for k,power in enumerate(augmented): - if k != index: - exponent = tuple(a+b for a,b in zip(power,augmented[index])) - root[k] = discriminant.get(exponent,Fraction(0))/(2*root[index]) - root_poly = {power:value for power,value in zip(augmented,root) if value} - if _multiply(root_poly,root_poly,spend) != discriminant: - return None - center = [linear.get(power,Fraction(0)) for power in augmented] - center[pivot+1] += 2*coefficient - factors = [[a+sign*b for a,b in zip(center,root)] for sign in (-1,1)] - if _multiply(*[{power:value for power,value in zip(augmented,row) if value} - for row in factors],spend) != {power:4*coefficient*value for power,value in poly.items()}: - return None - if shear is not None: - i,j = shear - for row in factors: - row[i+1] -= row[j+1] # y_j = x_j-x_i. - return factors - - -def _surface_polynomials(net, owner, rational, spend): - m,n = net.shape[:2] - if rational: - spend(max(1,(m*n+127)//128)) - if net[0,0,3] <= 0 or not np.all(net[...,3] == net[0,0,3]): - return None - spend(net.size+3*(n*coefficient_build_work(m)+m*coefficient_build_work(n))) - weight = Fraction(float(net[0,0,3])) if rational else Fraction(1) - result = [] - for coordinate in range(3): - values = [[Fraction(float(net[i,j,coordinate]))/weight for j in range(n)] - for i in range(m)] - along_first = [_power([values[i][j] for i in range(m)]) for j in range(n)] - poly = {} - for i in range(m): - coefficients = _power([row[i] if i < len(row) else Fraction(0) - for row in along_first]) - for j,value in enumerate(coefficients): - if value: - exponent = [0]*4 - exponent[2*owner:2*owner+2] = i,j - poly[tuple(exponent)] = value - result.append(poly) - return result - - -def _differentiate(poly, axis): - result = {} - for power,value in poly.items(): - if power[axis]: - exponent = list(power) - exponent[axis] -= 1 - result[tuple(exponent)] = power[axis]*value - return result - - -def _one_sign(poly): - degree = max((power[0] for power in poly),default=0) - values = [sum(poly.get((j,),0)*Fraction(comb(i,j),comb(degree,j)) - for j in range(i+1)) for i in range(degree+1)] - return all(value > 0 for value in values) or all(value < 0 for value in values) - - -def _regular_tangent_line(surfaces, origin, direction, spend, require_tangency=True): - matrix = [[value] for value in direction] - columns = [[_substitute(_differentiate(poly,axis),origin,matrix,spend) - for poly in surfaces[axis//2]] for axis in range(4)] - for owner in range(2): - a,b = columns[2*owner:2*owner+2] - normal = [_add(_multiply(a[(i+1)%3],b[(i+2)%3],spend), - _multiply(a[(i+2)%3],b[(i+1)%3],spend),-1) - for i in range(3)] - if not any(_one_sign(poly) for poly in normal): - return False - for omitted in (range(4) if require_tangency else ()): - chosen = [column for axis,column in enumerate(columns) if axis != omitted] - determinant = {} - for order in permutations(range(3)): - term = {(0,):Fraction(1)} - for column,coordinate in enumerate(order): - term = _multiply(term,chosen[column][coordinate],spend) - parity = sum(order[i] > order[j] for i in range(3) for j in range(i+1,3))%2 - determinant = _add(determinant,term,-1 if parity else 1) - if determinant: - return False - path = [_substitute(poly,origin,matrix,spend) for poly in surfaces[0]] - return any(_one_sign(_differentiate(poly,0)) for poly in path) - - -def exact_quadratic_constraint_ssx(first,second,atol,rational,budget): - """Return supported source curves/the empty set, else None. - - Original source nets and world tolerance are required. Returned XYZ is - already in world coordinates. Nonlinear equalities are replaced only - by equivalent semidefinite gradient constraints or a verified union - of rational affine factors. Interrupted representation remains partial. - """ - nets = tuple(np.asarray(net,dtype=float) for net in (first,second)) - if any(net.ndim != 3 or net.shape[-1] != (4 if rational else 3) - or min(net.shape[:2]) < 2 or not np.all(np.isfinite(net)) for net in nets): - return None - def spend(amount,source='quadratic_constraints'): - if not budget.charge_cells(max(1,int(amount)),source): - raise _Stopped - result = dict(branches=[],points=[],singularities=[],overlap_regions=[],unresolved_regions=[]) - try: - surfaces = [_surface_polynomials(net,owner,rational,spend) for owner,net in enumerate(nets)] - if any(surface is None for surface in surfaces): - return None - residuals = [_add(a,b,-1) for a,b in zip(*surfaces)] - if any(sum(power) > 2 for poly in residuals for power in poly): - return None - origin = [Fraction(0)]*4 - matrix = [[Fraction(int(i == j)) for j in range(4)] for i in range(4)] - while True: - dimension = len(matrix[0]) - reduced = [_substitute(poly,origin,matrix,spend) for poly in residuals] - nonzero = [poly for poly in reduced if poly] - if not nonzero: - break - rows = [] - for poly in nonzero: - if max(map(sum,poly)) <= 1: - zero = (0,)*dimension - rows.append([poly.get(zero,Fraction(0))]+[ - poly.get(tuple(int(i == axis) for i in range(dimension)),Fraction(0)) - for axis in range(dimension)]) - if not rows: - for poly in nonzero: - derived = _quadratic_rows(poly,dimension,spend) - if derived == 'empty': - return result - if derived is not None: - rows.extend(derived) - if not rows: - return _factored_affine_zero_set( - nets,surfaces,residuals,nonzero,origin,matrix, - atol,rational,budget,spend) - solution = _linear_solution(rows,dimension,spend) - if solution is None: - return result - offset, transform = solution - if len(transform[0]) >= dimension: - return None - origin = [a+sum(x*y for x,y in zip(row,offset)) for a,row in zip(origin,matrix)] - matrix = [[sum(row[k]*transform[k][j] for k in range(dimension)) - for j in range(len(transform[0]))] for row in matrix] - if len(matrix[0]) == 0: - return _represent_isolated_source_root( - nets,surfaces,tuple(origin),atol,rational,budget,spend) - if len(matrix[0]) != 1: - return None - direction = [row[0] for row in matrix] - low,high = None,None - for offset,slope in zip(origin,direction): - if not slope: - if not 0 <= offset <= 1: - return result - continue - a,b = sorted((-offset/slope,(1-offset)/slope)) - low = a if low is None else max(low,a) - high = b if high is None else min(high,b) - if high < low: - return result - if high == low: - return None - start = tuple(a+low*d for a,d in zip(origin,direction)) - end = tuple(a+high*d for a,d in zip(origin,direction)) - direction = tuple(b-a for a,b in zip(start,end)) - if not _regular_tangent_line(surfaces,start,direction,spend): - return None - construction = sum((1+sum(n-1 for n in net.shape[:2]))*net.size for net in nets) - spend((12*construction+127)//128,'quadratic_curve') - curve = _surface_affine_path(nets[0],start[:2],end[:2],rational) - except _Stopped: - return None - - boundary = any(a == b and a in (0,1) for a,b in zip(start,end)) - return _represent_affine_component(nets,start,end,curve,atol,rational,budget, - 'overlap' if boundary else 'tangential') - - -def _represent_isolated_source_root(nets,surfaces,point,atol,rational,budget,spend): - """Publish the complete singleton proved by zero-free-parameter RREF.""" - result = dict(branches=[],points=[],singularities=[],overlap_regions=[],unresolved_regions=[]) - if any(value < 0 or value > 1 for value in point): - return result - def partial(reason): - budget.mark_incomplete(reason) - budget.append_output(result['unresolved_regions'],dict( - stuv_min=(0.,)*4,stuv_max=(1.,)*4,reason=reason, - exact_dimension=0,exact_stuv=tuple(map(str,point)), - source_certificate='exact_quadratic_singleton'), 'unresolved_region') - return result - if budget.output_items >= budget.max_output_items: - return partial(REASON_OUTPUT_CAP) - def value(poly): - spend(max(1,sum(5+sum(power) for power in poly)),'quadratic_point') - return sum(coefficient*prod(x**degree for x,degree in zip(point,power)) - for power,coefficient in poly.items()) - actual = [tuple(value(poly) for poly in surface) for surface in surfaces] - if actual[0] != actual[1]: - return None # Defensive: the eliminated original equations must agree. - from mmcore.numeric.intersection.ssx._ssx_singular_candidate import _cross - normals = [] - for owner in range(2): - spend(max(1,sum(len(poly) for poly in surfaces[owner])),'quadratic_point') - columns = [tuple(value(_differentiate(poly,2*owner+axis)) - for poly in surfaces[owner]) for axis in (0,1)] - normals.append(_cross(*columns)) - try: - stuv = np.asarray(tuple(map(float,point))) - xyz = np.asarray(tuple(map(float,actual[0]))) - tolerance = Fraction(float(atol)) - if (not np.all(np.isfinite(stuv)) or not np.all(np.isfinite(xyz)) - or sum((a-Fraction(float(b)))**2 for a,b in zip(actual[0],xyz)) - > (tolerance/2)**2): - return partial(REASON_PARAMETER_REPRESENTATION) - if not affine_path_representation_bounded( - *nets,stuv,stuv,np.array([xyz,xyz]),atol,rational=rational, - charge=lambda n: budget.charge_cells(n,'quadratic_point')): - return partial(REASON_WORK_BUDGET if budget.exhausted else REASON_PARAMETER_REPRESENTATION) - except (ValueError,OverflowError,ZeroDivisionError): - return partial(REASON_PARAMETER_REPRESENTATION) - from mmcore.numeric.intersection.ssx._bez_ssx5 import SSXSingularity,SSXPoint - degenerate = [owner for owner,normal in enumerate(normals) if not any(normal)] - if degenerate: - for owner in degenerate: - if not budget.append_output(result['singularities'],SSXSingularity( - kind='cusp',stuv=stuv,xyz=xyz,surface=owner+1),'singularity'): - return partial(REASON_OUTPUT_CAP) - elif not any(_cross(*normals)): - budget.append_output(result['singularities'],SSXSingularity( - kind='tangent_point',stuv=stuv,xyz=xyz),'singularity') - else: - budget.append_output(result['points'],SSXPoint(stuv,xyz),'point') - return result - - -def _represent_affine_component(nets,start,end,curve,atol,rational,budget,kind, - fiber_surfaces=()): - """Represent one already proved exact affine component, preserving prefixes.""" - direction = tuple(b-a for a,b in zip(start,end)) - result = dict(branches=[],points=[],singularities=[],overlap_regions=[],unresolved_regions=[]) - def spend(amount,source='quadratic_curve'): - if not budget.charge_cells(max(1,int(amount)),source): - raise _Stopped - def partial(reason,remaining=Fraction(0)): - budget.mark_incomplete(reason) - budget.append_output(result['unresolved_regions'],{ - 'stuv_min': (0.,)*4,'stuv_max': (1.,)*4,'reason':reason, - 'exact_dimension':1,'exact_stuv':tuple(tuple(str(x) for x in p) for p in (start,end)), - 'remaining_path_interval':(str(remaining),'1'), - 'source_certificate':'exact_quadratic_affine_zero_set', - },'unresolved_region') - return result - if budget.output_items >= budget.max_output_items: - return partial(REASON_OUTPUT_CAP) - tolerance = Fraction.from_float(float(atol)) - cache = {} - def vertex(value): - if value not in cache: - spend(max(1,6*len(curve)),'quadratic_curve') - location = tuple(a+value*d for a,d in zip(start,direction)) - restricted = _restrict_exact_curve(curve,value,value) - actual = tuple(x/restricted[0][3] for x in restricted[0][:3]) - stuv = tuple(float(x) for x in location) - xyz = tuple(float(x) for x in actual) - if not all(np.isfinite(x) for x in stuv+xyz): - raise ValueError('nonrepresentable source endpoint') - exact_xyz = tuple(Fraction(x) for x in xyz) - if sum((a-b)**2 for a,b in zip(actual,exact_xyz)) > (tolerance/2)**2: - raise ValueError('nonrepresentable source endpoint') - # The original affine set and the float STUV chord are separate - # objects. Verify the published endpoint on both source charts. - if not affine_path_representation_bounded(*nets,stuv,stuv,np.array([xyz,xyz]), - atol,rational=rational,charge=lambda n: budget.charge_cells(n,'quadratic_curve')): - if budget.exhausted: - raise _Stopped - raise ValueError('nonrepresentable parameter endpoint') - cache[value] = (stuv,xyz) - return cache[value] - accepted = [] - accepted_end = Fraction(0) - stack = [(Fraction(0),Fraction(1))] - reason,remaining = None,Fraction(0) - try: - while stack: - lo,hi = stack.pop() - remaining = lo - a,b = vertex(lo),vertex(hi) - if any(a[0][2*owner:2*owner+2] == b[0][2*owner:2*owner+2] - and start[2*owner:2*owner+2] != end[2*owner:2*owner+2] - for owner in range(2)): - raise ValueError('distinct parameter endpoints alias') - spend(max(1,6*len(curve)**2),'quadratic_curve') - local_curve = _restrict_exact_curve(curve,lo,hi) - chord = [tuple(Fraction(x) for x in item[1]) for item in (a,b)] - true_path = _chord_error_bounded(local_curve,chord,tolerance/2) - public_path = true_path and affine_path_representation_bounded( - *nets,a[0],b[0],np.array([a[1],b[1]]),atol,rational=rational, - charge=lambda n: budget.charge_cells(n,'quadratic_curve')) - if budget.exhausted: - raise _Stopped - if public_path: - if not accepted: - accepted.append(a) - accepted.append(b) - accepted_end = hi - else: - midpoint = (lo+hi)/2 - if float(midpoint) in (float(lo),float(hi)): - raise ValueError('source curve exceeds parameter resolution') - stack.extend(((midpoint,hi),(lo,midpoint))) - except _Stopped: - reason = REASON_WORK_BUDGET - except (OverflowError,ValueError,ZeroDivisionError): - reason = REASON_PARAMETER_REPRESENTATION - if len(accepted) >= 2: - stuv = np.array([item[0] for item in accepted]) - xyz = np.array([item[1] for item in accepted]) - endpoints = ( - tuple(start),tuple(a+accepted_end*d for a,d in zip(start,direction))) - if fiber_surfaces: - from mmcore.numeric.intersection.ssx._bez_ssx5 import SSXSingularity - for owner in fiber_surfaces: - feature = SSXSingularity(kind='cusp_curve',stuv=stuv[0],xyz=xyz[0], - samples=stuv.copy(),surface=owner+1) - feature._source_parameter_path = endpoints - if not budget.append_output(result['singularities'],feature,'singularity'): - reason = REASON_OUTPUT_CAP - break - else: - branch = SSXBranch((stuv,xyz),kind=kind,closed=False,overlap=kind == 'overlap') - branch._exact_endpoint_keys = endpoints - # This is the actual zero affine path established by elimination, - # not the rounded output polyline; downstream ownership can use it. - branch._source_parameter_path = endpoints - budget.append_output(result['branches'],branch,'branch') - return partial(reason,remaining) if reason else result - - -def _factor_line(residuals,origin,matrix,row,spend): - """Restrict one exact factor, then eliminate its linear consequences.""" - rows = [row] - while rows: - dimension = len(matrix[0]) - solved = _linear_solution(rows,dimension,spend) - if solved is None: - return () # This factor has no source solutions. - offset,transform = solved - origin = tuple(a+sum(x*y for x,y in zip(old,offset)) for a,old in zip(origin,matrix)) - matrix = [[sum(old[k]*transform[k][j] for k in range(dimension)) - for j in range(len(transform[0]))] for old in matrix] - dimension = len(matrix[0]) - reduced = [_substitute(poly,origin,matrix,spend) for poly in residuals] - nonzero = [poly for poly in reduced if poly] - if not nonzero: - return (origin,tuple(old[0] for old in matrix)) if dimension == 1 else None - rows = [] - for poly in nonzero: - if max(map(sum,poly)) <= 1: - rows.append([poly.get((0,)*dimension,Fraction(0))]+[ - poly.get(tuple(int(i == axis) for i in range(dimension)),Fraction(0)) - for axis in range(dimension)]) - return None # A remaining nonlinear equation is not discharged. - - -def _line_strata(surfaces,start,direction,spend): - """Prove a monotone image or a constant-image fiber; classify normals.""" - matrix = [[d] for d in direction] - normals = [] - for owner in range(2): - columns = [[_substitute(_differentiate(poly,2*owner+axis),start,matrix,spend) - for poly in surfaces[owner]] for axis in (0,1)] - a,b = columns - normals.append([_add(_multiply(a[(i+1)%3],b[(i+2)%3],spend), - _multiply(a[(i+2)%3],b[(i+1)%3],spend),-1) - for i in range(3)]) - path = [_substitute(poly,start,matrix,spend) for poly in surfaces[0]] - degenerate = tuple(owner for owner,normal in enumerate(normals) if not any(normal)) - if not any(_differentiate(poly,0) for poly in path): - return (degenerate,()) if degenerate else None - if not any(_one_sign(_differentiate(poly,0)) for poly in path): - return None - endpoints = [] - for owner,normal in enumerate(normals): - # Nonnegative, nonzero Bernstein coefficients imply strict sign - # on the open interval. Endpoint normal zeros remain C1 events. - def interior_sign(poly): - degree = max((power[0] for power in poly),default=0) - spend(max(1,(degree+1)**2),'quadratic_strata') - values = [sum(poly.get((j,),0)*Fraction(comb(i,j),comb(degree,j)) - for j in range(i+1)) for i in range(degree+1)] - return any(values) and (all(v >= 0 for v in values) or all(v <= 0 for v in values)) - if not any(interior_sign(poly) for poly in normal): - return None - for endpoint in (0,1): - if not any(sum(coefficient*endpoint**power[0] for power,coefficient in poly.items()) - for poly in normal): - endpoints.append((endpoint,owner)) - return (),tuple(endpoints) - - -def _factored_affine_zero_set(nets,surfaces,residuals,reduced,origin,matrix, - atol,rational,budget,spend): - """Complete factor unions whose linear consequences are affine lines.""" - dimension = len(matrix[0]) - if dimension < 2: - return None - factors = next((rows for poly in reduced - if (rows := _affine_factor_rows(poly,dimension,spend)) is not None),None) - if factors is None: - return None - segments,strata = [],[] - for row in factors: - solved = _factor_line(residuals,origin,matrix,row,spend) - if solved is None: - return None - if not solved: - continue - anchor,direction = solved - low,high = None,None - for value,slope in zip(anchor,direction): - if not slope: - if not 0 <= value <= 1: - low,high = Fraction(1),Fraction(0) - break - continue - a,b = sorted((-value/slope,(1-value)/slope)) - low = a if low is None else max(low,a) - high = b if high is None else min(high,b) - if high < low: - continue - if high == low: - return None # A clipped singleton needs point classification. - start = tuple(a+low*d for a,d in zip(anchor,direction)) - end = tuple(a+high*d for a,d in zip(anchor,direction)) - segment = tuple(sorted((start,end))) - if segment in segments: - continue - delta = tuple(b-a for a,b in zip(*segment)) - character = _line_strata(surfaces,segment[0],delta,spend) - if character is None: - return None - segments.append(segment) - strata.append(character) - - cuts = [{Fraction(0),Fraction(1)} for _ in segments] - junctions = set() - for i,(a,b) in enumerate(segments): - first_direction = tuple(y-x for x,y in zip(a,b)) - for j,(c,d) in enumerate(segments[:i]): - second_direction = tuple(y-x for x,y in zip(c,d)) - rows = [[x-y,u,-v] for x,y,u,v in zip(a,c,first_direction,second_direction)] - crossing = _linear_solution(rows,2,spend) - if crossing is None: - continue - if crossing[1][0]: - return None # Distinct records must not overlap on an interval. - u,v = crossing[0] - if 0 <= u <= 1 and 0 <= v <= 1: - cuts[i].add(u);cuts[j].add(v) - junctions.add(tuple(x+u*y for x,y in zip(a,first_direction))) - - result = dict(branches=[],points=[],singularities=[],overlap_regions=[],unresolved_regions=[]) - construction = sum((1+sum(n-1 for n in net.shape[:2]))*net.size for net in nets) - try: - for (start,end),parameters,(fiber_surfaces,_) in zip(segments,cuts,strata): - direction = tuple(b-a for a,b in zip(start,end)) - ordered = [Fraction(0),Fraction(1)] if fiber_surfaces else sorted(parameters) - for lo,hi in zip(ordered,ordered[1:]): - a = tuple(x+lo*d for x,d in zip(start,direction)) - b = tuple(x+hi*d for x,d in zip(start,direction)) - spend((12*construction+127)//128,'quadratic_curve') - curve = _surface_affine_path(nets[0],a[:2],b[:2],rational) - boundary = any(x == y and x in (0,1) for x,y in zip(a,b)) - part = _represent_affine_component( - nets,a,b,curve,atol,rational,budget,'overlap' if boundary else 'transversal', - fiber_surfaces=fiber_surfaces) - for key in result: - result[key].extend(part[key]) - if part['unresolved_regions']: - # The whole remaining factor union is still an obligation; - # a represented prefix cannot discharge the other factor. - reason = part['unresolved_regions'][0]['reason'] - budget.append_output(result['unresolved_regions'],dict( - stuv_min=(0.,)*4,stuv_max=(1.,)*4,reason=reason, - source_certificate='exact_quadratic_affine_union'), 'unresolved_region') - return result - # Distinct exact component endpoints must remain distinguishable - # in the published lifted data, including across component records. - published,aliases = {},set() - for branch in result['branches']+result['singularities']: - spend(2,'quadratic_curve') - path = branch.curve[0] if hasattr(branch,'curve') else branch.samples - for key,end in zip(branch._source_parameter_path,(0,len(path)-1)): - floating = tuple(path[end]) - if floating in published and published[floating] != key: - aliases.add(floating) - published[floating] = key - if aliases: - # Preserve both known components, but their float endpoints - # cannot express the distinct source incidences. This early - # tier returns a representation partial without joining them. - budget.mark_incomplete(REASON_PARAMETER_REPRESENTATION) - budget.append_output(result['unresolved_regions'],dict( - stuv_min=(0.,)*4,stuv_max=(1.,)*4,reason=REASON_PARAMETER_REPRESENTATION, - source_certificate='exact_quadratic_affine_union'), 'unresolved_region') - from mmcore.numeric.intersection.ssx._bez_ssx5 import SSXSingularity - from mmcore.numeric.intersection.ssx._ssx_singular_candidate import _cross - def on_path(point,path): - spend(16,'quadratic_junction') - a,b = path - candidates = [(p-x)/(y-x) for p,x,y in zip(point,a,b) if y != x] - return (bool(candidates) and 0 <= candidates[0] <= 1 - and all(value == candidates[0] for value in candidates) - and all(x != y or p == x for p,x,y in zip(point,a,b))) - def endpoint_links(point): - spend(max(1,2*len(result['branches'])),'quadratic_junction') - return [(i,end) for i,branch in enumerate(result['branches']) - for key,end in zip(branch._exact_endpoint_keys,(0,len(branch.curve[0])-1)) - if key == point and tuple(branch.curve[0][end]) not in aliases] - fibers = list(result['singularities']) - for fiber in fibers: - fiber.branch_links = [(i,end) for i,branch in enumerate(result['branches']) - for key,end in zip(branch._exact_endpoint_keys,(0,len(branch.curve[0])-1)) - if tuple(branch.curve[0][end]) not in aliases - and on_path(key,fiber._source_parameter_path)] - for segment,(_,endpoints) in zip(segments,strata): - for endpoint,owner in endpoints: - point = segment[endpoint] - if any(fiber.surface == owner+1 and on_path(point,fiber._source_parameter_path) - for fiber in fibers): - continue # The represented C1 fiber owns this endpoint. - links = endpoint_links(point) - if links: - index,end = links[0] - branch = result['branches'][index] - budget.append_output(result['singularities'],SSXSingularity( - kind='cusp',stuv=branch.curve[0][end],xyz=branch.curve[1][end], - surface=owner+1,branch_links=links),'singularity') - def value(poly,point): - spend(max(1,len(poly)*9),'quadratic_junction') - return sum(coefficient*np.prod([x**power for x,power in zip(point,exponent)]) - for exponent,coefficient in poly.items()) - for point in sorted(junctions): - if tuple(map(float,point)) in aliases: - continue - normals = [] - for owner in range(2): - jets = [tuple(value(_differentiate(poly,2*owner+axis),point) - for poly in surfaces[owner]) for axis in (0,1)] - normals.append(_cross(*jets)) - if not all(any(normal) for normal in normals) or any(_cross(*normals)): - continue - links = endpoint_links(point) - if not links: - continue - index,end = links[0] - branch = result['branches'][index] - budget.append_output(result['singularities'],SSXSingularity( - kind='tangent_point',stuv=branch.curve[0][end],xyz=branch.curve[1][end], - branch_links=links),'singularity') - except _Stopped: - budget.mark_incomplete(REASON_WORK_BUDGET) - budget.append_output(result['unresolved_regions'],dict( - stuv_min=(0.,)*4,stuv_max=(1.,)*4,reason=REASON_WORK_BUDGET, - source_certificate='exact_quadratic_affine_union'), 'unresolved_region') - return result diff --git a/mmcore/numeric/intersection/ssx/_ssx_residual_contract.py b/mmcore/numeric/intersection/ssx/_ssx_residual_contract.py deleted file mode 100644 index 80aa47ec..00000000 --- a/mmcore/numeric/intersection/ssx/_ssx_residual_contract.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Necessary source-domain contraction for the underdetermined Psi system. - -An interval mean-value equation bounds each pivot coordinate in terms of -the other three. Gaussian preconditioning is a numerical proposal only: -every resulting row is evaluated with outward intervals. A contraction -preserves all zeros; it supplies neither existence nor uniqueness. -""" -import numpy as np - -from mmcore.numeric._bezier_common import restrict_net_axis_v -from mmcore.numeric.intersection.ssx._ssx_cofactor_bounds import _interval_multiply - - -def _project(matrix, low, high): - lower = np.zeros((len(matrix),)+low.shape[1:]) - upper = lower.copy() - for coordinate in range(3): - scale = matrix[:, coordinate].reshape((-1,)+(1,)*(low.ndim-1)) - lo, hi = _interval_multiply(scale, scale, low[coordinate], high[coordinate]) - lower = np.nextafter(lower+lo, -np.inf) - upper = np.nextafter(upper+hi, np.inf) - return lower, upper - - -def _divide(low, high, denominator_low, denominator_high): - if denominator_low <= 0. <= denominator_high: - return None - with np.errstate(over='ignore', invalid='ignore', divide='ignore'): - values = np.array([low/denominator_low, low/denominator_high, - high/denominator_low, high/denominator_high]) - if np.any(np.isnan(values)): - return None - return np.nextafter(values.min(), -np.inf), np.nextafter(values.max(), np.inf) - - -class SourceResidualContractor: - """Cached interval Gauss-Seidel steps over original-source controls. - - The supplied SourceCofactorBounds owns immutable source residual and - derivative nets, their arithmetic errors, and the shared work ledger. - Denied/unsupported work preserves the current necessary domain. - None is returned only after a certified empty interval intersection. - """ - def __init__(self, source_bounds): - self.source = source_bounds - self.cache = {} - - def contract(self, box, *, stop_when=None): - original = tuple((float(lo), float(hi)) for lo, hi in box) - if original in self.cache: - return self.cache[original] - source = self.source - if (source.residual is None or source.derivatives is None - or len(original) != 4 - or any(not 0. <= lo <= hi <= 1. for lo, hi in original)): - return original - current = np.asarray(original) - while True: - units = max(1, (source.residual.size - + 10*sum(net.size for net in source.derivatives)+127)//128)+4 - if not source._spend(units): - break - center = np.clip(.5*current[:, 0]+.5*current[:, 1], - current[:, 0], current[:, 1]) - jacobian_low, jacobian_high, restricted_jets = [], [], [] - for net, error in zip(source.derivatives, source.errors): - restricted = net - for axis, (lo, hi) in enumerate(current): - restricted = restrict_net_axis_v(restricted, axis, lo, hi, 0., 1.) - jacobian_low.append(np.nextafter(restricted.min(axis=(0, 1, 2, 3))-error, -np.inf)) - jacobian_high.append(np.nextafter(restricted.max(axis=(0, 1, 2, 3))+error, np.inf)) - restricted_jets.append((np.nextafter(np.moveaxis(restricted, -1, 0) - -error[:, None, None, None, None], -np.inf), - np.nextafter(np.moveaxis(restricted, -1, 0) - +error[:, None, None, None, None], np.inf))) - jlo, jhi = np.asarray(jacobian_low).T, np.asarray(jacobian_high).T - if not np.all(np.isfinite(jlo)) or not np.all(np.isfinite(jhi)): - break - midpoint = .5*jlo+.5*jhi - scale = float(np.max(np.abs(midpoint))) - if not np.isfinite(scale) or scale == 0.: - break - scaled = midpoint/scale - value = source.residual - for axis, coordinate in enumerate(center): - value = restrict_net_axis_v(value, axis, coordinate, coordinate, 0., 1.) - vlo = np.nextafter(value.min(axis=(0, 1, 2, 3))-source.residual_error, -np.inf) - vhi = np.nextafter(value.max(axis=(0, 1, 2, 3))+source.residual_error, np.inf) - next_box = current.copy() - proposals = [] - try: - left, _, _ = np.linalg.svd(scaled, full_matrices=False) - proposals.append((left.T, tuple((row, axis) for row in range(3) for axis in range(4)))) - except np.linalg.LinAlgError: - pass - for inverse, targets in proposals: - projected = [_project(inverse, low, high) for low, high in restricted_jets] - plow = np.column_stack([low.min(axis=(1, 2, 3, 4)) for low, high in projected]) - phigh = np.column_stack([high.max(axis=(1, 2, 3, 4)) for low, high in projected]) - flow, fhigh = _project(inverse, vlo, vhi) - if not all(np.all(np.isfinite(a)) for a in (plow, phigh, flow, fhigh)): - continue - for row, axis in targets: - lo, hi = -fhigh[row], -flow[row] - for other in range(4): - if other == axis: - continue - dlow = np.nextafter(next_box[other, 0]-center[other], -np.inf) - dhigh = np.nextafter(next_box[other, 1]-center[other], np.inf) - a, b = _interval_multiply(plow[row, other], phigh[row, other], dlow, dhigh) - lo, hi = np.nextafter(lo-b, -np.inf), np.nextafter(hi-a, np.inf) - displacement = _divide(lo, hi, plow[row, axis], phigh[row, axis]) - if displacement is None: - continue - lower = np.nextafter(center[axis]+displacement[0], -np.inf) - upper = np.nextafter(center[axis]+displacement[1], np.inf) - next_box[axis] = max(next_box[axis, 0], lower), min(next_box[axis, 1], upper) - if next_box[axis, 0] > next_box[axis, 1]: - self.cache[original] = None - return None - if np.array_equal(current, next_box): - break - current = next_box - if stop_when is not None and stop_when(tuple(map(tuple, current))): - break - # One complete Gauss-Seidel sweep is a necessary-domain - # certificate. Global source subdivision supplies subsequent - # refinements; iterating to a floating fixed point can spend - # the search ledger on an otherwise inconclusive proposal. - break - result = tuple(map(tuple, current)) - self.cache[original] = result - return result diff --git a/mmcore/numeric/intersection/ssx/_ssx_root_identity.py b/mmcore/numeric/intersection/ssx/_ssx_root_identity.py deleted file mode 100644 index 9dc538ee..00000000 --- a/mmcore/numeric/intersection/ssx/_ssx_root_identity.py +++ /dev/null @@ -1,778 +0,0 @@ -"""Common-face isolation for independently discovered boundary events.""" -from fractions import Fraction - -import numpy as np - -from mmcore.numeric.bern import bernstein_partial_derivative_coeffs, de_casteljau_split_nd -from mmcore.numeric._bezier_common import restrict_net_axis_v -from mmcore.numeric.intersection._root_box_certificate import ( - residual_roundoff_bound, jacobian_is_injective) -from mmcore.numeric.intersection.ssx._ssx5_singular import psi_vector_net - - -def _pure_affine_coordinates(surface): - """Exact world coordinates depending affinely on one chart parameter.""" - weights = surface[..., 3] - if not np.all(weights > 0): - return {} - if not np.all(weights == weights.flat[0]): - # Verify P_coordinate=(a+b*t_axis)*W in Bernstein form. Pointwise - # dehomogenized coefficients do not prove a rational identity; - # elevate P/W one degree and multiply W by the affine polynomial. - weight_rows = [[Fraction(float(x)) for x in row] for row in weights] - result = {} - for coordinate in range(3): - points = [[Fraction(float(x)) for x in row] - for row in surface[...,coordinate]] - for axis in (0,1): - degree = surface.shape[axis]-1 - if not degree: - continue - def coefficient(rows, i, j): - return rows[i][j] if axis == 0 else rows[j][i] - start = coefficient(points,0,0)/coefficient(weight_rows,0,0) - stop = coefficient(points,degree,0)/coefficient(weight_rows,degree,0) - slope = stop-start - if not slope: - continue - valid = True - for j in range(surface.shape[1-axis]): - for i in range(degree+2): - alpha = Fraction(i,degree+1) - pl = coefficient(points,i-1,j) if i else 0 - pr = coefficient(points,i,j) if i <= degree else 0 - wl = coefficient(weight_rows,i-1,j) if i else 0 - wr = coefficient(weight_rows,i,j) if i <= degree else 0 - if alpha*pl+(1-alpha)*pr != start*(alpha*wl+(1-alpha)*wr)+slope*alpha*wl: - valid = False - break - if not valid: - break - if valid: - result[coordinate,axis] = start,slope - return result - weight = Fraction.from_float(float(weights.flat[0])) - result = {} - for coordinate in range(3): - values = [[Fraction.from_float(float(v))/weight for v in row] - for row in surface[..., coordinate]] - for axis in (0, 1): - degree = surface.shape[axis]-1 - if not degree: - continue - start = values[0][0] - end = values[-1][0] if axis == 0 else values[0][-1] - slope = end-start - if not slope: - continue - if all(values[i][j] == start+slope*Fraction((i, j)[axis], degree) - for i in range(len(values)) for j in range(len(values[0]))): - result[coordinate, axis] = (start, slope) - return result - - -def _restrict(net, box): - for axis, (lo, hi) in enumerate(box): - net = restrict_net_axis_v(net, axis, lo, hi, 0., 1.) - return net - - -def _affine_interval(lo, hi, lower, upper): - """Enclose the exact affine image of binary floating point endpoints.""" - start, stop = Fraction.from_float(float(lo)), Fraction.from_float(float(hi)) - result = [] - for endpoint, direction in ((lower, -np.inf), (upper, np.inf)): - exact = start + (stop-start)*Fraction.from_float(float(endpoint)) - rounded = float(exact) - if ((direction < 0 and Fraction.from_float(rounded) > exact) - or (direction > 0 and Fraction.from_float(rounded) < exact)): - rounded = np.nextafter(rounded, direction) - result.append(rounded) - return result - - -def _source_precision_refusal(net, restricted, source_error): - """Refuse a regular inclusion proof below its fixed source uncertainty. - - Bound the exact restricted polynomial's complete value range, including - restriction roundoff. If one component varies by less than twice the - inherited error, an admissible constant error shift removes its zero. - A strict existence certificate robust to that error is impossible. - Every smaller box has an even narrower exact range. This is a refusal - of this certificate, never an assertion that the actual source has no - root; callers retain the unresolved event and any other proof route. - """ - axes = tuple(range(net.ndim-1)) - rounding = residual_roundoff_bound(net, depth=2*len(axes)) - width = np.nextafter(restricted.max(axis=axes)-restricted.min(axis=axes), np.inf) - width = np.nextafter(width+np.nextafter(2.*rounding, np.inf), np.inf) - # Strictly below twice the fixed error: even an endpoint zero can be - # removed by one of the admissible constant shifts. - allowance = np.nextafter(2.*np.asarray(source_error), -np.inf) - return bool(np.any(np.isfinite(width) & (width <= allowance))) - - -def _krawczyk_enclosure(net, candidate, radii, source_error, precision_refused=None): - """A strict inclusion box for a square, regular face-root problem.""" - box = tuple((max(0., float(x-r)), min(1., float(x+r))) - for x, r in zip(candidate, radii)) - if any(hi <= lo for lo, hi in box): - return None - restricted = _restrict(net, box) - if _source_precision_refusal(net, restricted, source_error): - if precision_refused is not None: - precision_refused.append(True) - return None - dimensions = net.ndim-1 - axes = tuple(range(dimensions)) - if not jacobian_is_injective(restricted, axes, source_error): - return None - eps = np.finfo(float).eps - magnitude = np.max(np.abs(restricted), axis=axes) - low, high = [], [] - for axis in axes: - degree = restricted.shape[axis]-1 - derivative = bernstein_partial_derivative_coeffs(restricted, axis=axis) - error = degree*(2*source_error+4*eps*magnitude) - low.append(np.nextafter(derivative.min(axis=axes)-error, -np.inf)) - high.append(np.nextafter(derivative.max(axis=axes)+error, np.inf)) - lower, upper = np.array(low).T, np.array(high).T - midpoint, radius = .5*(lower+upper), .5*(upper-lower) - try: - inverse = np.linalg.inv(midpoint) - except np.linalg.LinAlgError: - return None - value = restricted - for _ in axes: - degree = value.shape[0]-1 - work = value.copy() - for length in range(degree, 0, -1): - work[:length] = .5*(work[:length]+work[1:length+1]) - value = work[0] - center = .5-inverse@value - uncertainty = (np.abs(inverse)@source_error - + .5*np.sum(np.abs(np.eye(dimensions)-inverse@midpoint) - + np.abs(inverse)@radius, axis=1)) - uncertainty += 64*eps*(1+np.abs(center)+np.abs(inverse)@(np.abs(value)+source_error)) - klow = np.nextafter(center-uncertainty, -np.inf) - khigh = np.nextafter(center+uncertainty, np.inf) - if not (np.all(klow > 0.) and np.all(khigh < 1.)): - return None - return np.array([_affine_interval(lo, hi, lower, upper) - for (lo, hi), lower, upper in zip(box, klow, khigh)]) - - -class BoundaryRootIdentity: - """Identify roots only on a proved common exact parameter slice. - - Candidate proximity is a broadphase. Each nonidentical representative - needs an inclusion enclosure, and their common face must be injective - over the union of those enclosures. Nearby distinct roots fail that - latter certificate even if their distance is below modeling tolerance. - """ - def __init__(self, first, second, *, charge=None): - self.charge = charge - self.exhausted = False - self.net = psi_vector_net(first, second) - source = (np.abs(first[..., :3]).max(axis=(0, 1))*np.abs(second[..., 3]).max() - + np.abs(second[..., :3]).max(axis=(0, 1))*np.abs(first[..., 3]).max()) - self.error = residual_roundoff_bound(self.net, depth=2, source_scale=source) - self.affine = (_pure_affine_coordinates(first), _pure_affine_coordinates(second)) - self.faces = {} - self.reduced_faces = {} - self.enclosures = {} - self.unions = {} - self.source_ids = (id(first), id(second)) - self.source_census = None - self.source_certificates = {} - self.source_queries = {} - self.source_sequences = {} - - def register_source_root(self, point, certificate): - """Register an exact original-source cut certificate, not a child net.""" - if (certificate.get('kind') != 'exact_source_planar_cut' - or tuple(certificate.get('source_ids', ())) != self.source_ids): - return False - def exact(pair): - return Fraction(int(pair[0]), int(pair[1])) - stored = dict(certificate) - # Temporary trace-exit proposals can otherwise be collected and - # their Python IDs reused for an unrelated boundary event. - stored['owner'] = point - stored['exact_box'] = tuple(tuple(exact(value) for value in pair) - for pair in certificate['parameter_root_box']) - stored['exact_interval'] = tuple(exact(value) for value in certificate['interval']) - stored['exact_polynomial'] = tuple(exact(value) for value in certificate['polynomial']) - stored['exact_pinned'] = tuple((axis, exact(value)) - for axis, value in certificate['pinned']) - self.source_certificates[id(point)] = stored - return True - - def _source_enclosure(self, point, radii, common): - certificate = self.source_certificates.get(id(point)) - if certificate is None: - return None - axis, value = common - box = certificate['exact_box'] - if box[axis] != (Fraction(value), Fraction(value)): - return None - from mmcore.numeric.intersection.csx._planar_roots import _outward_interval - result = np.array([_outward_interval(pair) for pair in box]) - # The caller uses the enclosure for its own cell/arc bounds. Do - # not return a certificate outside the requested neighborhood. - if np.any(result[:, 0] < point.stuv-radii) or np.any(result[:, 1] > point.stuv+radii): - return None - return result - - def _same_source_root(self, first, second): - a = self.source_certificates.get(id(first)) - b = self.source_certificates.get(id(second)) - if a is None or b is None: - return None - # Two independent original-source existence certificates contained - # in the same exact singleton identify one lifted root, even when - # their incident faces use different scalar slice polynomials. - # Equal rounded representatives or overlapping boxes do not suffice. - if (a['exact_box'] == b['exact_box'] - and all(lo == hi for lo, hi in a['exact_box'])): - return True - if a['varying_axis'] != b['varying_axis']: - return None - varying = a['varying_axis'] - # The other graph coordinate pins the scalar problem. A reverse - # plane-owner cut can carry an additional redundant plane pin; - # it describes the same source root through the unique inverse. - graph_pin = varying ^ 1 - pa, pb = dict(a['exact_pinned']), dict(b['exact_pinned']) - polynomial = a['exact_polynomial'] - if pa.get(graph_pin) != pb.get(graph_pin): - return False - if polynomial != b['exact_polynomial']: - return None - left, right = a['exact_interval'], b['exact_interval'] - if left[1] < right[0] or right[1] < left[0]: - return False - from mmcore.numeric.intersection._exact_univariate import ( - _gcd, _derivative, _divide, _sturm, _open_count, _value) - class Exhausted(Exception): - pass - def tick(): - if not self._spend(): - raise Exhausted - try: - if polynomial not in self.source_sequences: - repeated = _gcd(list(polynomial), _derivative(polynomial), tick) - squarefree, _ = _divide(list(polynomial), repeated) - self.source_sequences[polynomial] = squarefree, _sturm(squarefree, tick) - squarefree, sequence = self.source_sequences[polynomial] - tick() - lo, hi = min(left[0], right[0]), max(left[1], right[1]) - count = int(_value(squarefree, lo) == 0) - if hi > lo: - count += _open_count(squarefree, sequence, lo, hi) - count += int(_value(squarefree, hi) == 0) - return count == 1 - except Exhausted: - return False - - def _spend(self, amount=1): - if self.exhausted: - return False - if self.charge is None or self.charge(int(amount)): - return True - self.exhausted = True - return False - - def refine_source_box(self, point, box, owner, *, strict=False): - """Resolve owner-face comparisons by exact source-root counting. - - A root can lie inside an owner while its machine-width isolation - interval straddles a face. Cut that interval at the exact face - value, including affine-linked coordinates, and keep the side - containing its one source root. This is root isolation, not an - unproved intersection of an existence box with the owner. - """ - certificate = self.source_certificates.get(id(point)) - if certificate is None or certificate.get('owner') is not point: - return box - varying = certificate['varying_axis'] - constraints = getattr(self, 'affine_constraints', None) - components = getattr(constraints, 'components', None) or () - if not any(lo < hi and (lo <= bound <= hi if strict else lo < bound < hi) - for (lo, hi), bounds in zip(box, owner) for bound in bounds): - return box - key = tuple(map(tuple, box)), tuple(map(tuple, owner)), bool(strict) - cache = certificate.setdefault('_owner_refinements', {}) - if key in cache: - return cache[key] - if not self._spend(1+sum(len(expressions) for expressions, fixed in components)): - return box - relations = {varying: (Fraction(0), Fraction(1))} - for expressions, fixed in components: - source = next(((a,b) for axis,a,b in expressions if axis == varying), None) - if source is None: - continue - origin, slope = source - for axis, a, b in expressions: - relations[axis] = origin-slope*a/b, slope/b - break - lo, hi = box[varying] - def exact(value): - return value if isinstance(value, Fraction) else Fraction(float(value)) - cuts = sorted({a+b*exact(bound) - for axis,(a,b) in relations.items() for bound in owner[axis] - if (lo <= a+b*exact(bound) <= hi if strict - else lo < a+b*exact(bound) < hi)}) - if not cuts: - cache[key] = box - return box - from mmcore.numeric.intersection._exact_univariate import ( - _gcd, _derivative, _divide, _sturm, _open_count, _value) - class Stopped(Exception): - pass - def tick(): - if not self._spend(): - raise Stopped - def count(a,b): - tick() - return (int(_value(polynomial,a) == 0) - + (_open_count(polynomial,sequence,a,b) - + int(_value(polynomial,b) == 0) if a < b else 0)) - try: - original = certificate['exact_polynomial'] - if original not in self.source_sequences: - repeated = _gcd(list(original),_derivative(original),tick) - polynomial,_ = _divide(list(original),repeated) - self.source_sequences[original] = polynomial,_sturm(polynomial,tick) - polynomial,sequence = self.source_sequences[original] - if count(lo,hi) != 1: - # A broad interval containing additional endpoint roots - # does not identify which one owns this source event. - cache[key] = box - return box - for cut in cuts: - if not lo <= cut <= hi or lo == hi: - continue - tick() - if _value(polynomial,cut) == 0: - lo = hi = cut - break - if lo < cut < hi: - if count(lo,cut): - hi = cut - else: - lo = cut - # A non-root separator at the interval endpoint is an - # open bound on this root. Refine to a strictly disjoint - # *closed* interval when requested for event ordering; - # rounding this separator to float can erase the gap. - while strict and lo < hi and lo <= cut <= hi: - middle = (lo+hi)/2 - tick() - if _value(polynomial,middle) == 0: - lo = hi = middle - elif count(lo,middle): - hi = middle - else: - lo = middle - refined = list(box) - for axis,(a,b) in relations.items(): - limits = sorted(((lo-a)/b,(hi-a)/b)) - refined[axis] = max(box[axis][0],limits[0]), min(box[axis][1],limits[1]) - result = tuple(refined) - cache[key] = result if all(a <= b for a,b in result) else box - return cache[key] - except Stopped: - return box - - def separate_source_boxes(self, points, boxes): - """Refine exact source enclosures against other events' exact pins. - - This proposes neither identity nor a merge. Each existing root is - retained by its own Sturm count; non-root separating endpoints are - moved outside the resulting closed interval using exact bisection. - Unknown certificates and denied work preserve the supplied boxes. - Original source certificates remain immutable. - """ - refined = [tuple(map(tuple, box)) for box in boxes] - if len(points) != len(refined): - raise ValueError('points and boxes must have equal length') - if len(refined) < 2: - return tuple(refined) - if not self._spend(len(points)*(len(points)-1)): - return tuple(refined) - for index, point in enumerate(points): - certificate = self.source_certificates.get(id(point)) - if certificate is None or certificate.get('owner') is not point: - continue - for other, separator in enumerate(refined): - if index == other: - continue - refined[index] = self.refine_source_box( - point, refined[index], separator, strict=True) - return tuple(refined) - - def _common_slice(self, first, second): - a, b = first.face[0], second.face[0] - va, vb = float(first.stuv[a]), float(second.stuv[b]) - if a == b: - return (a, va) if va == vb else None - constraints = getattr(self, 'affine_constraints', None) - if constraints is not None: - first_key = constraints.face_key(a, va) - second_key = constraints.face_key(b, vb) - if (first_key is not None and first_key != ('empty',) - and first_key == second_key): - # Common projected source coordinates can establish an - # equivalent slice even when no individual world axis is - # affine. This selects a shared EXISTENCE/UNIQUENESS test; - # equivalent faces alone never identify their roots. - return a, va - if a//2 == b//2: - return None - for coordinate in range(3): - fa = self.affine[a//2].get((coordinate, a%2)) - fb = self.affine[b//2].get((coordinate, b%2)) - if (fa is not None and fb is not None - and fa[0]+fa[1]*Fraction.from_float(va) - == fb[0]+fb[1]*Fraction.from_float(vb)): - return a, va - return None - - def enclose(self, point, radii, common=None): - box = self._enclose(point, radii, common=common) - constraints = getattr(self, 'affine_constraints', None) - if box is not None and constraints is not None: - necessary = constraints.contract(box) - if necessary is not None: - return np.asarray(necessary) - return box - - def refine_enclosure(self, point, radii): - """Refine an already isolated source root without changing identity. - - The caller's source box must contain exactly one established root. - A temporary proposal earns its own existence enclosure; containment - in that old unique box proves it is the same root. Failure leaves - the original box and source certificate untouched. On success the - caller commits the returned box; a new exact certificate is also - transferred with the original point as its retained owner. - """ - if not getattr(point,'_source_root_box',False) or point.root_box is None: - return None - old = np.asarray(point.root_box,dtype=float) - requested = np.broadcast_to(np.asarray(radii,dtype=float),(4,)) - if (old.shape != (4,2) or not np.all(np.isfinite(old)) - or np.any(old[:,0] > old[:,1]) - or not np.all(np.isfinite(requested)) or np.any(requested < 0.)): - return None - if not self._spend(): - return None - from copy import copy - trial = copy(point) - trial.stuv = np.asarray(point.stuv,dtype=float).copy() - trial.root_box = None - trial._source_root_box = False - # A known scalar source certificate can answer without a new - # census. Copying it preserves the proof, not numerical identity. - certificate = self.source_certificates.get(id(point)) - if certificate is not None and certificate.get('owner') is point: - self.source_certificates[id(trial)] = dict(certificate,owner=trial) - widths = old[:,1]-old[:,0] - local_radii = np.minimum(requested,np.where(widths > 0.,widths/2,np.inf)) - try: - proposed = self.enclose(trial,local_radii) - if proposed is None: - return None - proposed = np.asarray(proposed,dtype=float) - if (proposed.shape != (4,2) or not np.all(np.isfinite(proposed)) - or np.any(proposed[:,0] > proposed[:,1]) - or np.any(proposed[:,0] < old[:,0]) - or np.any(proposed[:,1] > old[:,1])): - return None - refined_certificate = self.source_certificates.get(id(trial)) - if refined_certificate is not None and refined_certificate.get('owner') is trial: - self.source_certificates[id(point)] = dict(refined_certificate,owner=point) - return proposed.copy() - finally: - self.source_certificates.pop(id(trial),None) - - def _enclose(self, point, radii, common=None): - """Certify a tight root enclosure on this point's original face.""" - radii = np.asarray(radii, dtype=float) - axis, value = (common if common is not None else - (point.face[0], float(point.stuv[point.face[0]]))) - if axis not in range(4): - return None - common = axis, value - source_enclosure = self._source_enclosure(point, radii, common) - if source_enclosure is not None: - return source_enclosure - if self.source_census is not None: - trial_radii = np.broadcast_to(radii, (4,)).copy() - floor = 4*np.spacing(np.maximum(np.abs(point.stuv), np.finfo(float).tiny)) - while np.any(trial_radii > floor): - query = tuple((max(0., float(x-r)), min(1., float(x+r))) - if k != axis else (0., 1.) - for k, (x, r) in enumerate(zip(point.stuv, trial_radii))) - key = common, query - if key not in self.source_queries: - self.source_queries[key] = self.source_census(axis, value, query) - result = self.source_queries[key] - if result is None: - break - if (not result.get('boundary_topology_complete', False) - or result.get('budget_exhausted', False)): - break - roots = result.get('isolated', []) - if len(roots) == 1: - if self.register_source_root(point, roots[0]['source_cut_certificate']): - source_enclosure = self._source_enclosure(point, radii, common) - if source_enclosure is not None: - return source_enclosure - break - if not roots: - break - # Multiple exact roots in a proposal neighborhood are - # separate events. Refine the neighborhood of this - # representative, never merge their isolating intervals. - trial_radii *= .5 - pinned = {axis: value} - eliminated = set() - for target_axis in range(2*(1-axis//2), 2*(1-axis//2)+2): - for coordinate in range(3): - if coordinate in eliminated: - continue - source_affine = self.affine[axis//2].get((coordinate, axis%2)) - target_affine = self.affine[target_axis//2].get((coordinate, target_axis%2)) - if source_affine is None or target_affine is None: - continue - exact = ((source_affine[0]+source_affine[1]*Fraction(value) - - target_affine[0])/target_affine[1]) - rounded = float(exact) - # Restriction at a rounded nonbinary rational would change - # the problem. Such a relation stays in the unreduced solve. - if Fraction(rounded) != exact or not 0. <= rounded <= 1.: - continue - pinned[target_axis] = rounded - eliminated.add(coordinate) - break - other = [k for k in range(4) if k not in pinned] - coordinates = [k for k in range(3) if k not in eliminated] - face_key = (tuple(sorted(pinned.items())), tuple(coordinates)) - if face_key not in self.reduced_faces: - if not self._spend(max(1, (self.net.size+127)//128)): - return None - face = self.net - for fixed_axis, fixed_value in sorted(pinned.items(), reverse=True): - left, _ = de_casteljau_split_nd(face, axis=fixed_axis, t=fixed_value) - face = np.take(left, -1, axis=fixed_axis) - self.reduced_faces[face_key] = face[..., coordinates] - face = self.reduced_faces[face_key] - key = (tuple(point.stuv), common, tuple(radii)) - if key not in self.enclosures: - trial_radii = radii[other].copy() - candidate = point.stuv[other] - floor = 4*np.spacing(np.maximum(np.abs(candidate), np.finfo(float).tiny)) - enclosure = None - while np.all(trial_radii > floor): - if not self._spend(max(1, (4*face.size+127)//128)): - return None - precision_refused = [] - enclosure = _krawczyk_enclosure( - face, candidate, trial_radii, self.error[coordinates], - precision_refused=precision_refused) - if enclosure is not None: - break - if precision_refused: - break - # A failed wide inclusion test does not disprove a root. - # Refine the candidate neighborhood until it isolates the - # regular face root, reaches representable precision, or - # exhausts the shared work allowance. - trial_radii *= .5 - # A first Krawczyk image may be much wider than the actual - # root uncertainty. Iterate from an expanded enclosure while - # it contracts geometrically, retaining the last proved box - # if finite precision or the work allowance stops refinement. - while enclosure is not None: - widths = enclosure[:,1]-enclosure[:,0] - if not np.all(widths > 0.): - break - if not self._spend(max(1, (4*face.size+127)//128)): - break - refined = _krawczyk_enclosure( - face, enclosure.mean(axis=1), .75*widths, - self.error[coordinates]) - if refined is None or np.any(refined[:,0] < enclosure[:,0]) or np.any(refined[:,1] > enclosure[:,1]): - break - new_widths = refined[:,1]-refined[:,0] - enclosure = refined - if np.max(new_widths) >= .5*np.max(widths): - break - self.enclosures[key] = enclosure - result = self.enclosures[key] - if result is None: - return None - box = np.empty((4, 2)) - for fixed_axis, fixed_value in pinned.items(): - box[fixed_axis] = fixed_value - box[other] = result - return box - - def __call__(self, first, second, param_tol, xyz_tol): - # Pair scans are work too, including broadphase failures. A denied - # certificate retains separate events and lets the caller report - # the exhausted allowance through its existing budget object. - if not self._spend(): - return False - if first is second: - return True - exact_a = self.source_certificates.get(id(first)) - exact_b = self.source_certificates.get(id(second)) - if exact_a is not None and exact_b is not None: - if any(a[0] > b[1] or b[0] > a[1] - for a, b in zip(exact_a['exact_box'], exact_b['exact_box'])): - return False - same_source = self._same_source_root(first, second) - if same_source is not None: - return bool(same_source and np.linalg.norm(first.xyz-second.xyz) <= xyz_tol) - first_box, second_box = getattr(first, 'root_box', None), getattr(second, 'root_box', None) - separate = False - if first_box is not None and second_box is not None: - a, b = np.asarray(first_box), np.asarray(second_box) - separate = bool(np.any((a[:, 0] > b[:, 1]) | (b[:, 0] > a[:, 1]))) - if separate and getattr(first, '_source_root_box', False) and getattr(second, '_source_root_box', False): - return False - radii = np.asarray(param_tol, dtype=float) - if (not np.all(np.abs(first.stuv-second.stuv) <= radii) - or np.linalg.norm(first.xyz-second.xyz) > xyz_tol): - return False - same_source = self._same_source_root(first, second) - if same_source is not None: - return same_source - common = self._common_slice(first, second) - if common is None: - # Incident faces of the same chart need not be equivalent - # slices. Obtain existence on each original face separately; - # only a subsequent exact source-root identity may unify them. - # This also handles outer corner proposals before either face - # has acquired its lazy source census certificate. - if self.source_census is not None: - for point in (first, second): - if id(point) not in self.source_certificates: - if not self._spend(): - return False - enclosure = (self.refine_enclosure(point, radii) - if getattr(point, '_source_root_box', False) - else self.enclose(point, radii)) - if enclosure is None: - return False - same_source = self._same_source_root(first, second) - if same_source is not None: - return same_source - return False - axis, value = common - other = [i for i in range(4) if i != axis] - if common not in self.faces: - if not self._spend(max(1, (self.net.size+127)//128)): - return False - left, _ = de_casteljau_split_nd(self.net, axis=axis, t=value) - self.faces[common] = np.take(left, -1, axis=axis) - face = self.faces[common] - boxes = [] - for point in (first, second): - certificate = self.source_certificates.get(id(point)) - if certificate is not None or getattr(point, '_source_root_box', False): - if not self._spend(): - return False - original_axis = point.face[0] - original_value = float(point.stuv[original_axis]) - if certificate is not None: - exact_box = certificate['exact_box'] - if exact_box[original_axis] != (Fraction(original_value),)*2: - return False - from mmcore.numeric.intersection.csx._planar_roots import _outward_interval - box = np.array([_outward_interval(pair) for pair in exact_box]) - else: - owned = getattr(point, 'root_box', None) - if owned is None: - return False - box = np.asarray(owned, dtype=float).copy() - if (box.shape != (4, 2) or not np.all(np.isfinite(box)) - or np.any(box[:, 0] < 0.) or np.any(box[:, 1] > 1.) - or np.any(box[:, 0] > box[:, 1]) - or not np.all(box[original_axis] == original_value) - or not box[axis, 0] <= value <= box[axis, 1]): - return False - # This already owned root lies on its original face. - # Exact slice equivalence proves the common pin without - # changing which root the enclosure owns. A fresh solve - # around the displayed proposal could select another root. - box[axis] = value, value - else: - # Rounded child-net boxes are only hints. Fresh proposals - # still need original-source existence on the common face. - box = self.enclose(point, radii, common=common) - if box is None: - return False - boxes.append(box[other]) - same_source = self._same_source_root(first, second) - if same_source is not None: - return same_source - core = np.array([(min(a[0], b[0]), max(a[1], b[1])) - for a, b in zip(*boxes)]) - padding = radii[other].copy() - floor = np.maximum(core[:,1]-core[:,0], - 4*np.spacing(np.maximum(np.abs(core).max(axis=1), - np.finfo(float).tiny))) - while True: - union = tuple((max(0., lo-pad), min(1., hi+pad)) - for (lo, hi), pad in zip(core, padding)) - key = (common, union) - if key not in self.unions: - if not self._spend(max(1, (4*face.size+127)//128)): - return False - self.unions[key] = jacobian_is_injective( - _restrict(face, union), (0, 1, 2), self.error) - if self.unions[key]: - return True - if np.all(padding <= floor): - return False - # The complete existence enclosures remain in every union. - # Shrinking only optional padding can prove a common nearby - # root without ever erasing a second independently enclosed root. - padding *= .5 - - def resolve_region(self, point, radii, region): - """Resolve a complete pending face region on the supplied source. - - The child solver's approximate root is only a proposal. Source - inclusion establishes existence inside the whole pending region; - source injectivity excludes additional roots anywhere in it. - """ - enclosure = self.enclose(point, radii) - if enclosure is None: - return None - region = np.asarray(region, dtype=float) - if np.any(enclosure[:, 0] < region[:, 0]) or np.any(enclosure[:, 1] > region[:, 1]): - return None - axis, value = point.face[0], float(point.stuv[point.face[0]]) - common = axis, value - if common not in self.faces: - if not self._spend(max(1, (self.net.size+127)//128)): - return None - left, _ = de_casteljau_split_nd(self.net, axis=axis, t=value) - self.faces[common] = np.take(left, -1, axis=axis) - other = [k for k in range(4) if k != axis] - intervals = tuple((float(lo), float(hi)) if hi > lo else - (max(0., float(lo)-radii[k]), min(1., float(hi)+radii[k])) - for k, (lo, hi) in zip(other, region[other])) - key = (common, intervals) - if key not in self.unions: - face = self.faces[common] - if not self._spend(max(1, (4*face.size+127)//128)): - return None - self.unions[key] = jacobian_is_injective( - _restrict(face, intervals), (0, 1, 2), self.error) - return enclosure if self.unions[key] else None diff --git a/mmcore/numeric/intersection/ssx/_ssx_singular_candidate.py b/mmcore/numeric/intersection/ssx/_ssx_singular_candidate.py deleted file mode 100644 index c40b4bae..00000000 --- a/mmcore/numeric/intersection/ssx/_ssx_singular_candidate.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Generate exact source singular candidates from necessary affine relations. - -Changing a numerical candidate does not identify it with its replacement. -The returned point is independently checked against the original source -residual and all four Jacobian minors using exact binary-rational arithmetic. -No existence or absence statement is made about the old candidate's box. -""" -from fractions import Fraction - -import numpy as np - - -def _evaluate(net, parameters): - work = net - for parameter in parameters: - while len(work) > 1: - work = (1-parameter)*work[:-1]+parameter*work[1:] - work = work[0] - return tuple(work) - - -def _cross(a, b): - return (a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]) - - -class SourceSingularCandidates: - def __init__(self, first, second, constraints, charge=None): - self.sources = first, second - self.constraints = constraints - self.charge = charge - self.exact = None - self.cache = {} - self.isolated_regular_cache = {} - - def __call__(self, candidate): - corrected = self.constraints.canonical_candidate(candidate) - if corrected is None or np.any(corrected < 0.) or np.any(corrected > 1.): - return None - key = tuple(corrected) - if key in self.cache: - return corrected if self.cache[key] else None - work = sum(net.size*(1+sum(n-1 for n in net.shape[:2])) for net in self.sources) - if self.charge is not None and not self.charge(max(1, (8*work+127)//128)): - return None - if self.exact is None: - self.exact = [] - for source in self.sources: - net = np.empty(source.shape, dtype=object) - for index in np.ndindex(source.shape): - net[index] = Fraction.from_float(float(source[index])) - self.exact.append(net) - parameters = tuple(Fraction.from_float(float(x)) for x in corrected) - values = [_evaluate(net, parameters[2*owner:2*owner+2]) - for owner, net in enumerate(self.exact)] - valid = all(value[3] > 0 for value in values) and all( - values[0][k]*values[1][3] == values[1][k]*values[0][3] for k in range(3)) - if valid: - columns = [] - for owner, (net, value) in enumerate(zip(self.exact, values)): - for axis in (0, 1): - degree = net.shape[axis]-1 - derivative = (_evaluate(degree*np.diff(net, axis=axis), parameters[2*owner:2*owner+2]) - if degree else (Fraction(0),)*4) - columns.append(tuple(derivative[k]*value[3]-value[k]*derivative[3] - for k in range(3))) - for omitted in range(4): - a, b, c = [column for k, column in enumerate(columns) if k != omitted] - if sum(x*y for x, y in zip(_cross(a, b), c)): - valid = False - break - self.cache[key] = valid - return corrected if valid else None - - def isolated_regular(self, candidate): - """A source-verified regular-chart singular root with rank-four DΔ. - -This sufficient local isolation test distinguishes a saddle junction from -arbitrary samples on a one-dimensional tangential locus. Failure is only -inconclusive; higher-order singularities keep their other search owners. - """ - corrected = self(candidate) - if corrected is None: - return None - key = tuple(corrected) - if key in self.isolated_regular_cache: - return corrected if self.isolated_regular_cache[key] else None - if any(min(net.shape[:2]) < 2 for net in self.sources): - self.isolated_regular_cache[key] = False - return None - work = sum(net.size*(1+sum(n-1 for n in net.shape[:2])) for net in self.sources) - if self.charge is not None and not self.charge(max(1, (40*work+127)//128)): - return None - parameters = tuple(Fraction.from_float(float(x)) for x in corrected) - columns, changes, values = [], [], [] - for owner, net in enumerate(self.exact): - uv = parameters[2*owner:2*owner+2] - value = _evaluate(net, uv) - values.append(value) - first_nets = [(net.shape[axis]-1)*np.diff(net, axis=axis) for axis in (0, 1)] - first = [_evaluate(derivative, uv) for derivative in first_nets] - for axis in (0, 1): - derivative = first[axis] - columns.append(tuple(derivative[k]*value[3]-value[k]*derivative[3] for k in range(3))) - by_parameter = [(Fraction(0),)*3 for _ in range(4)] - for other_axis in (0, 1): - degree = first_nets[axis].shape[other_axis]-1 - second = (_evaluate(degree*np.diff(first_nets[axis], axis=other_axis), uv) - if degree else (Fraction(0),)*4) - other = first[other_axis] - by_parameter[2*owner+other_axis] = tuple( - second[k]*value[3]+derivative[k]*other[3] - -other[k]*derivative[3]-value[k]*second[3] for k in range(3)) - changes.append(by_parameter) - if not any(_cross(columns[0], columns[1])) or not any(_cross(columns[2], columns[3])): - self.isolated_regular_cache[key] = False - return None - matrix = [[columns[j][k]*(1 if j < 2 else -1)/values[j//2][3]**2 - for j in range(4)] for k in range(3)] - for omitted in range(4): - indices = [j for j in range(4) if j != omitted] - row = [] - for parameter in range(4): - total = Fraction(0) - for varied in indices: - a, b, c = [changes[j][parameter] if j == varied else columns[j] for j in indices] - total += sum(x*y for x, y in zip(_cross(a, b), c)) - row.append(total) - matrix.append(row) - rank = 0 - for column in range(4): - pivot = next((i for i in range(rank, len(matrix)) if matrix[i][column]), None) - if pivot is None: - continue - matrix[rank], matrix[pivot] = matrix[pivot], matrix[rank] - scale = matrix[rank][column] - matrix[rank] = [value/scale for value in matrix[rank]] - for i in range(rank+1, len(matrix)): - scale = matrix[i][column] - matrix[i] = [a-scale*b for a, b in zip(matrix[i], matrix[rank])] - rank += 1 - self.isolated_regular_cache[key] = rank == 4 - return corrected if rank == 4 else None diff --git a/mmcore/numeric/intersection/ssx/_ssx_source_cut_cache.py b/mmcore/numeric/intersection/ssx/_ssx_source_cut_cache.py deleted file mode 100644 index 00ad4d7d..00000000 --- a/mmcore/numeric/intersection/ssx/_ssx_source_cut_cache.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Reuse complete exact source-face censuses under affine slice identity.""" -from fractions import Fraction - -from mmcore.numeric.intersection.csx._planar_roots import _outward_interval - - -def _decode(pair): - return Fraction(int(pair[0]), int(pair[1])) - - -class SourceFaceCensusCache: - """Cache topology while preserving each requested floating representative. - - ``uncached`` solves the requested ORIGINAL source face and owns its - computation ledger. ``constraints`` proves equivalent parameter slices; - no canonical Fraction pin is rounded and passed to a different solve. - """ - def __init__(self, uncached, constraints, source_ids, *, charge, - can_retry=lambda: True): - self.uncached = uncached - self.constraints = constraints - self.source_ids = tuple(source_ids) - self.charge = charge - self.can_retry = can_retry - self.censuses = {} - self.census_faces = {} - - @staticmethod - def _partial(box, reason='work_budget'): - return {'isolated': [], 'boundary_topology_complete': False, - 'budget_exhausted': True, 'truncation_cause': 'max_cells', - 'unresolved_source_boxes': [{'parameter_root_box': box, 'reason': reason}]} - - def _source_valid(self, result): - return all(tuple(root.get('source_cut_certificate', {}).get('source_ids', ())) - == self.source_ids for root in result.get('isolated', [])) - - def _local(self, axis, value, box): - result = self.uncached(axis, value, box) - if result is not None and not self._source_valid(result): - return self._partial(box, 'source_certificate_identity') - return result - - def __call__(self, axis, value, box): - key = self.constraints.face_key(axis, value) - if key is None: - return self._partial(box) if self.constraints.exhausted else self._local(axis, value, box) - if key == ('empty',): - return {'isolated': [], 'overlaps': [], 'parameter_fibers': [], - 'cells_processed': 0, 'boundary_topology_complete': True, - 'budget_exhausted': False} - full = ((0., 1.),)*4 - exact_value = value if isinstance(value, Fraction) else Fraction(float(value)) - if key not in self.censuses: - self.censuses[key] = self.uncached(axis, value, full) - self.census_faces[key] = axis, exact_value - census = self.censuses[key] - if census is None: - return None - if not self._source_valid(census): - return self._partial(box, 'source_certificate_identity') - if (census.get('budget_exhausted', False) - or not census.get('boundary_topology_complete', False)): - # A partial full-face result never supplies unfiltered roots to - # a smaller owner. A local query can still succeed independently. - if (tuple(tuple(pair) for pair in box) == full - and self.census_faces[key] == (axis, exact_value)): - return census - # Even a full-domain query on an equivalent face needs new - # representative validation and exact metadata rebinding. - # Incomplete censuses bypass that complete-root filtering path. - return self._local(axis, value, box) if self.can_retry() else self._partial(box) - if not self.charge(max(1, len(census.get('isolated', [])))): - return self._partial(box) - exact_box = tuple((Fraction(float(lo)), Fraction(float(hi))) for lo, hi in box) - roots = [] - for original in census.get('isolated', []): - certificate = original['source_cut_certificate'] - root = original - if certificate['axis'] != axis or _decode(certificate['cut']) != exact_value: - # Topological equivalence does not certify an altered float - # representative: reuse only an already matching coordinate. - if original['stuv'][axis] != float(value): - return self._local(axis, value, box) if self.can_retry() else self._partial(box) - certificate = self.constraints.rebind_source_certificate(certificate, axis, value) - if certificate is None: - return self._local(axis, value, box) if self.can_retry() else self._partial(box) - root = dict(original) - root['source_cut_certificate'] = certificate - root['parameter_root_box'] = tuple(_outward_interval(tuple(_decode(x) for x in pair)) - for pair in certificate['parameter_root_box']) - enclosure = tuple(tuple(_decode(x) for x in pair) - for pair in certificate['parameter_root_box']) - if any(lo > b or hi < a for (lo, hi), (a, b) in zip(enclosure, exact_box)): - continue - if any(lo < a or hi > b for (lo, hi), (a, b) in zip(enclosure, exact_box)): - return self._local(axis, value, box) if self.can_retry() else self._partial(box) - roots.append(root) - result = dict(census) - result['isolated'] = roots - result['cells_processed'] = 0 # the original full census was paid once - return result diff --git a/mmcore/numeric/intersection/ssx/_ssx_source_residual.py b/mmcore/numeric/intersection/ssx/_ssx_source_residual.py deleted file mode 100644 index de352f5e..00000000 --- a/mmcore/numeric/intersection/ssx/_ssx_source_residual.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Cached original-source residual hull exclusions for complete SSX cells.""" -import numpy as np - -from mmcore.numeric._bezier_common import restrict_net_axis_v -from mmcore.numeric.intersection._root_box_certificate import residual_roundoff_bound -from mmcore.numeric.intersection.ssx._ssx_cofactor_bounds import _interval_multiply - - -class SourceResidualExclusions: - """Prove emptiness from original residual coefficient intervals only. - - A proposed separating direction is arbitrary. Its interval dot product - must exclude zero over the entire coefficient hull. Unknown or unpaid - work returns False, preserving the caller's discovery obligation. - """ - def __init__(self, source_bounds, charge=None): - self.source = source_bounds - self.charge = charge - self.cache = {} - self.exhausted = False - - def _spend(self, units): - if self.exhausted: - return False - if self.charge is None or self.charge(max(1,int(units))): - return True - self.exhausted = True - return False - - def __call__(self, box): - key = tuple((float(lo),float(hi)) for lo,hi in box) - if len(key) != 4 or any(not 0. <= lo <= hi <= 1. for lo,hi in key): - return False - if key in self.cache: - return self.cache[key] - net,error = self.source.residual,self.source.residual_error - if net is None or error is None or not self._spend((8*net.size+127)//128): - return False - restricted = net - with np.errstate(over='ignore',invalid='ignore',under='ignore'): - for axis,(lo,hi) in enumerate(key): - restricted = restrict_net_axis_v(restricted,axis,lo,hi,0.,1.) - error = np.nextafter(error+residual_roundoff_bound(net,depth=8),np.inf) - low = np.nextafter(restricted-error,-np.inf).reshape(-1,3) - high = np.nextafter(restricted+error,np.inf).reshape(-1,3) - if not np.all(np.isfinite(low)) or not np.all(np.isfinite(high)): - return False - if np.any(low.min(axis=0) > 0.) or np.any(high.max(axis=0) < 0.): - self.cache[key] = True - return True - if not self._spend((16*net.size+127)//128): - return False - - flat = restricted.reshape(-1,3) - scale = float(np.abs(flat).max()) - if not np.isfinite(scale) or scale == 0.: - self.cache[key] = False - return False - scaled = flat/scale - mean = scaled.mean(axis=0) - - def separated(direction): - if not np.all(np.isfinite(direction)) or not np.any(direction): - return False - lower = np.zeros(len(flat)) - upper = lower.copy() - with np.errstate(over='ignore',invalid='ignore',under='ignore'): - for axis in range(3): - a,b = _interval_multiply(low[:,axis],high[:,axis], - direction[axis],direction[axis]) - lower = np.nextafter(lower+a,-np.inf) - upper = np.nextafter(upper+b,np.inf) - return bool(np.all(lower > 0.) or np.all(upper < 0.)) - - result = separated(mean) - if not result: - try: - centered = scaled-mean - _,directions = np.linalg.eigh(centered.T@centered) - result = separated(directions[:,0]) - except np.linalg.LinAlgError: - pass - self.cache[key] = result - return result diff --git a/tests/test_bern_roots_numeric.py b/tests/test_bern_roots_numeric.py new file mode 100644 index 00000000..b52813cc --- /dev/null +++ b/tests/test_bern_roots_numeric.py @@ -0,0 +1,65 @@ +"""Known factored polynomials check the numerical isolation independently.""" +from math import comb + +import numpy as np +import pytest + +from mmcore.numeric._bern_roots import bernstein_roots + + +def _from_roots(roots): + power = np.polynomial.polynomial.polyfromroots(roots) + degree = len(power)-1 + return np.array([sum(power[j]*comb(i, j)/comb(degree, j) + for j in range(i+1)) for i in range(degree+1)]) + + +@pytest.mark.parametrize('degree', [2, 4, 8, 10, 12]) +def test_flat_multiple_root_is_one_location(degree): + net = np.array([(-1.)**(degree-i)*2.**-degree for i in range(degree+1)]) + result = bernstein_roots(net) + assert result.complete and not result.constant_zero + np.testing.assert_allclose(result.roots, [.5], atol=1e-12, rtol=0) + assert result.work < 1000 + + +@pytest.mark.parametrize('roots', [[.49, .4905], [0., .31, .79, 1.], [.2, .2, .7, .7]]) +def test_distinct_components_and_endpoint_roots_are_retained(roots): + result = bernstein_roots(_from_roots(roots)) + assert result.complete + np.testing.assert_allclose(result.roots, sorted(set(roots)), atol=1e-9, rtol=0) + + +def test_nonbinary_double_root_and_scaled_coefficients(): + for factor in (1e-20, 1., 1e20): + result = bernstein_roots(factor*np.array([1., -2., 4.])) + np.testing.assert_allclose(result.roots, [1/3], atol=1e-12, rtol=0) + + +def test_positive_gap_is_not_a_cloud_of_small_residual_roots(): + result = bernstein_roots(np.array([.25, -.25, .25])+1e-8) + assert result.complete and result.roots == () and not result.constant_zero + + +def test_shallow_extremum_cannot_replace_two_sign_changing_roots(): + gap = 2.**-50 + result = bernstein_roots(np.array([.25, -.25, .25])-gap, + parameter_tol=1e-14) + roots = np.array([.5-2.**-25, .5+2.**-25]) + # A stretched CAD chart maps this small parameter gap to 0.06 units. + assert len(result.roots) == 2 + np.testing.assert_allclose(1e6*np.array(result.roots), 1e6*roots, + atol=1e-3, rtol=0) + + +def test_constant_and_work_limit_are_distinguished(): + assert bernstein_roots([0., 0.]).constant_zero + assert bernstein_roots([1e-30, 1e-30]).roots == () + accepted = [] + def charge(unit): + if len(accepted) == 3: + return False + accepted.append(unit) + return True + result = bernstein_roots(_from_roots([.2, .7]), charge=charge) + assert not result.complete and result.work == len(accepted) == 3 diff --git a/tests/test_bez_ssx5_invariance.py b/tests/test_bez_ssx5_invariance.py index e4c99502..2fe72f73 100644 --- a/tests/test_bez_ssx5_invariance.py +++ b/tests/test_bez_ssx5_invariance.py @@ -129,7 +129,6 @@ def _fake_result(branches=(), points=(), singularities=()): "points": list(points), "singularities": list(singularities), "overlap_regions": [], - "unresolved_regions": [], "complete": True, "status": {"reasons": [], "work": {}}, } diff --git a/tests/test_bez_ssx5_search_completeness.py b/tests/test_bez_ssx5_search_completeness.py index 5e9a7c21..b9c174e3 100644 --- a/tests/test_bez_ssx5_search_completeness.py +++ b/tests/test_bez_ssx5_search_completeness.py @@ -21,12 +21,11 @@ def test_boundary_root_dedup_preserves_metric_distinct_parallel_branches(): crossings, overlaps = ssx._find_ssx_boundary_zeros( s1, s2, atol=.001, rational=False) assert not overlaps - # Independently solved adjacent faces may retain two numerical - # representatives until root identity is certified. Every analytic - # boundary event must remain available. + # Every metric-separated boundary event must remain available at + # the requested modeling tolerance; these roots are eight atols apart. for x in (7.84, 7.848): for y in (0., 1.): - assert any(np.linalg.norm(c.xyz-np.array([x, y, 0.])) < 1e-8 + assert any(np.linalg.norm(c.xyz-np.array([x, y, 0.])) <= .001 for c in crossings) @@ -49,7 +48,7 @@ def test_subdivision_preserves_cut_roots_near_isoline_endpoints(monkeypatch): assert 'depth_limit' in result['status']['reasons'] -def test_closed_branch_metadata_matches_exact_lifted_chain_closure(): +def test_closed_branch_metadata_matches_paired_chain_closure(): q = np.array([[.2, .2, .2, .2], [.8, .2, .8, .2], [.5, .8, .5, .8], [.2, .2, .2, .2]]) xyz = np.column_stack([q[:, :2], np.zeros(len(q))]) @@ -79,154 +78,30 @@ def fail_march(a, b, x, **kwargs): result = ssx.bez_ssx(s1, s2, rational=False, max_depth=1, max_xyz_step=.1) assert not result['complete'] assert 'depth_limit' in result['status']['reasons'] - assert result['unresolved_regions'] + assert 'unresolved_regions' not in result assert result['status']['work']['cell_counts']['ssx'] > 1 -def test_default_depth_uses_remaining_work_to_complete_a_source_loop(): - from examples.ssx.bez_ssx5_case11 import S1,S2 - kwargs = dict(atol=1e-3,rational=False,max_cells=60000) - limited = ssx.bez_ssx(S1,S2,max_depth=13,**kwargs) - assert not limited['complete'] - assert limited['status']['reasons'] == ['depth_limit'] - stopped = limited['status']['work']['cells_processed'] - assert stopped < .9*kwargs['max_cells'] - completed = ssx.bez_ssx(S1,S2,**kwargs) - assert completed['complete'],completed['status'] - branch, = completed['branches'] - assert branch.closed - length = np.linalg.norm(np.diff(branch.curve[1],axis=0),axis=1).sum() - assert 1.52 < length < 1.55 - assert stopped < completed['status']['work']['cells_processed'] < kwargs['max_cells'] - - -def test_tangent_witness_at_size_floor_keeps_unresolved_neighborhood(monkeypatch): - # The exact source paraboloid has one root at the center. A witness - # there establishes existence; it does not supply a complete local - # zero-set certificate to the generic singular search. - a = np.array([[[s, t, x+y] for t, y in zip((0., .5, 1.), (.25, -.25, .25))] - for s, x in zip((0., .5, 1.), (.25, -.25, .25))]) - b = np.array([[[s, t, 0.] for t in (0., 1.)] for s in (0., 1.)]) - from mmcore.nurbs import _nurbs_param_tol - monkeypatch.setattr(_nurbs_param_tol, 'bez_surface_param_tolerance', - lambda *args, **kwargs: (.25, .25)) - monkeypatch.setattr(ssx, '_check_loop_free', lambda *args, **kwargs: False) - monkeypatch.setattr(ssx, '_check_tangency', lambda *args, **kwargs: True) - witnesses = [] - def witness(*args,**kwargs): - witnesses.append(args[0]) - return True,[np.full(4,.5)] - monkeypatch.setattr(ssx, '_emit_tangent_roots',witness) - monkeypatch.setattr(ssx, '_phi_slice_loop_fragments', lambda *args, **kwargs: []) - # Exercise the generic witness path; exact quadratic elimination has - # an independent complete singleton proof for this source pair. - result = ssx.bez_ssx(a, b, rational=False,max_xyz_step=.1) - assert witnesses - assert not result['complete'] - assert 'unresolved_multiplicity' in result['status']['reasons'] - assert any(region['stuv_min'] == (0.,)*4 and region['stuv_max'] == (1.,)*4 - and region['reason'] == 'unresolved_multiplicity' - for region in result['unresolved_regions']) - - -def test_strict_corner_cone_isolates_a_regular_clipped_arc(): - from types import SimpleNamespace - s1 = np.array([[[s, t, 0., 1.] for t in [0., 1.]] for s in [0., 1.]]) - s2 = np.array([[[-u, v, u-v, 1.] for v in [0., 1.]] for u in [0., 1.]]) - cell = SimpleNamespace(g1=SimpleNamespace(surface=s1), - g2=SimpleNamespace(surface=s2)) - assert ssx._strict_corner_touch(cell, np.zeros(4)) - - -def test_source_corner_cone_uses_proven_face_membership_and_source_derivatives(): - from types import SimpleNamespace - from mmcore.numeric.intersection.ssx._ssx_cofactor_bounds import SourceCofactorBounds - eps = 2.**-20 - a = np.array([[[s, t, 0., 1.] for t in (0., 1.)] for s in (0., 1.)]) - for offset, expected in ((0., True), (eps, False)): - b = np.array([[[-u+offset, v, u-v, 1.] for v in (0., 1.)] for u in (0., 1.)]) - # The rounded representative says s=0; the authoritative root - # says s=offset. For a positive offset one germ enters the cell. - root = np.array([offset, 0., 0., 0.]) - point = ssx.BoundaryPoint(np.zeros(4), np.zeros(3), (0, 0)) - point.root_box = np.repeat(root[:, None], 2, axis=1) - point._source_root_box = True - cell = SimpleNamespace(g1=SimpleNamespace(surface=a), - g2=SimpleNamespace(surface=b), box=((0., 1.),)*4, - source_cofactors=SourceCofactorBounds(a, b)) - assert ssx._strict_corner_touch(cell, np.zeros(4), point) is expected - - -def test_tangent_output_identity_preserves_close_distinct_preimages(monkeypatch): - from fractions import Fraction - from math import comb - from mmcore.numeric.intersection.ssx._ssx_substrate import GaussMapBern - # Exact dyadic coefficients of 3*((s-1/4)*(s-3/4))**2 - # +3*(t-1/2)**2 give two isolated touches. Shrinking only x/y - # puts both spatial images inside one geometric tolerance. - powers = [Fraction(27, 256), Fraction(-9, 8), Fraction(33, 8), -6, 3] - height = [sum(powers[j]*Fraction(comb(i, j), comb(4, j)) - for j in range(i+1)) for i in range(5)] - assert all(Fraction(float(value)) == value for value in height) - a = np.array([[[s*2.**-20, t*2.**-20, float(z+y)] - for t, y in zip((0., .5, 1.), (Fraction(3,4), Fraction(-3,4), Fraction(3,4)))] - for s, z in zip((0., .25, .5, .75, 1.), height)]) - b = np.array([[[s*2.**-20, t*2.**-20, 0.] for t in (0., 1.)] for s in (0., 1.)]) - roots = [np.array([s, .5, s, .5]) for s in (.25, .75)] - cell = ssx._Cell(GaussMapBern.from_surf(a), GaussMapBern.from_surf(b), [], ((0., 1.),)*4) - from mmcore.numeric.intersection._root_box_certificate import exact_bernstein_value - cell.source_root_exact = lambda q: exact_bernstein_value(a, q[:2]) == exact_bernstein_value(b, q[2:]) - monkeypatch.setattr(ssx, '_tangency_witness', lambda *args, **kwargs: (True, roots+roots[:1], 0., False)) - monkeypatch.setattr(ssx, '_delta_float_gn', lambda *args, **kwargs: (lambda x: x, None)) - monkeypatch.setattr(ssx, '_delta_root_local_dimension', lambda *args, **kwargs: False) - monkeypatch.setattr(ssx, '_cell_ptol4', lambda *args, **kwargs: np.ones(4)) - found = [] - ssx._emit_tangent_roots(cell, .001, np.ones(4), found, - overlap_boxes=[np.array([[0., 1.]]*4)]) - assert len(found) == 2 - assert {tuple(point.stuv) for point in found} == {tuple(root) for root in roots} - - -def test_unproved_tangent_candidate_is_a_local_obligation_not_a_confirmed_point(): - from types import SimpleNamespace - budget = ssx._SSXSoftBudget(100, 10) - diagnostics = [] - box = ((.25, .5),)*4 - cell = SimpleNamespace(box=box, work_budget=budget, unresolved_regions=diagnostics, - source_root_exact=lambda q: False) - assert not ssx._record_unproved_tangent(cell, np.full(4, .375)) - assert budget.incomplete - assert diagnostics == [{'stuv_min': (.25,)*4, 'stuv_max': (.5,)*4, - 'reason': 'unresolved_multiplicity', 'candidate': (.375,)*4, - 'candidate_kind': 'tangent_point', 'source_existence': False}] - - -def test_roundoff_close_distinct_lifted_endpoints_remain_open(): - q = np.array([[.5, .5, .5, .5], [.75, .75, .75, .75], - [np.nextafter(.5, 1.), .5, .5, .5]]) - xyz = np.array([[0., 0., 0.], [1., 1., 0.], [0., 0., 0.]]) - branch = ssx._assemble_fragments([ssx._Fragment(None, None, q, xyz)])[0] - assert not branch.closed - - -def test_deflated_singleton_keeps_original_global_parameter_registration(): - net = np.zeros((2, 2, 4)) - net[..., 3] = 1. - zero = np.zeros((1,)*4) - global_point = ssx.BoundaryPoint(np.array([.5]*4), np.zeros(3), (0, 1)) - local_point = ssx.BoundaryPoint(np.array([1., .5, .5, .5]), np.zeros(3), (0, 1)) - fragments, points = ssx._deflate_tangent_cell( - net, net, zero, zero, zero, zero, ((0., 1.),)*4, - [local_point], .001, originals=[global_point]) - assert fragments == [] and len(points) == 1 - np.testing.assert_array_equal(points[0].stuv, global_point.stuv) - assert points[0]._registered_root_id == id(global_point) + + + + + + + + + + + + + + def test_fragment_containment_preserves_distinct_lifted_sheets(): q = np.array([[.25, 0., .25, 0.], [.25, 1., .25, 1.]]) xyz = np.array([[0., 0., 0.], [0., 1., 0.]]) - for displacement in (.5, 1e-9): + for displacement in (.5,): mate = q.copy() mate[:, 0] += displacement first = ssx._Fragment(None, None, q, xyz) @@ -235,12 +110,6 @@ def test_fragment_containment_preserves_distinct_lifted_sheets(): assert len(ssx._assemble_fragments([first, second])) == 2 -def test_identical_approximation_paths_without_shared_source_arc_are_retained(): - q = np.array([[.25, 0., .25, 0.], [.25, 1., .25, 1.]]) - xyz = np.array([[0., 0., 0.], [0., 1., 0.]]) - first = ssx._Fragment(None, None, q, xyz) - second = ssx._Fragment(None, None, q[::-1], xyz[::-1]) - assert len(ssx._drop_duplicate_fragments([first, second], .001)) == 2 def test_overlap_endpoints_remain_available_to_incident_ordinary_arcs(): @@ -252,12 +121,22 @@ def test_overlap_endpoints_remain_available_to_incident_ordinary_arcs(): s1 = np.array([[[i/2, t[j], t2[j]+q[i]*t[j]] for j in range(3)] for i in range(3)]) s2 = np.array([[[s, v, 0.] for v in [0., 1.]] for s in [0., 1.]]) - crossings, overlaps = ssx._find_ssx_boundary_zeros( - s1, s2, atol=1e-5, rational=False) - assert overlaps - assert any(np.max(np.abs(c.stuv)) < 1e-8 for c in crossings) - assert any(np.max(np.abs(c.stuv-np.array([1., 0., 1., 0.]))) < 1e-8 - for c in crossings) + result = ssx.bez_ssx(s1, s2, atol=1e-5, rational=False) + paths = [np.asarray(branch.curve[1]) for branch in result['branches']] + assert paths, result['status'] + # The overlap line and incident arch must both be represented in full. + # Whether their shared endpoints are returned as standalone crossing + # records is an internal ownership decision, not the CAD contract. + for u in np.linspace(0., 1., 101): + for v in (0., u*(1-u)): + point = np.array([u, v, 0.]) + distances = [] + for path in paths: + first, delta = path[:-1], np.diff(path, axis=0) + square = np.maximum(np.einsum('ij,ij->i', delta, delta), 1e-300) + q = np.clip(np.einsum('ij,ij->i', point-first, delta)/square, 0., 1.) + distances.append(np.linalg.norm(first+q[:, None]*delta-point, axis=1).min()) + assert min(distances) <= 4e-5 def test_restricted_squared_distance_roundoff_cannot_prune_exact_line(): @@ -283,39 +162,11 @@ def test_restricted_squared_distance_roundoff_cannot_prune_exact_line(): assert not ssx._prune_ssx_cell(a, b, atol, rational=False, F=f) -def test_regular_corner_does_not_launch_displaced_recovery(monkeypatch): - from types import SimpleNamespace - a = np.array([[[s, t, 0., 1.] for t in (0., 1.)] for s in (0., 1.)]) - b = np.array([[[-u, v, u-v, 1.] for v in (0., 1.)] for u in (0., 1.)]) - root = ssx.BoundaryPoint(np.zeros(4), np.zeros(3), (0, 0)) - cell = ssx._Cell(SimpleNamespace(surface=a), SimpleNamespace(surface=b), - [root], ((0., 1.),)*4) - def invalid_launch(*args, **kwargs): - raise AssertionError('strict clipped corner has no inward regular arc') - monkeypatch.setattr(ssx, '_march_to_boundary', invalid_launch) - fragments, points = ssx._trace_cell_by_registrations(cell, 1e-4) - assert not fragments - assert len(points) == 1 - assert not cell.trace_incomplete -def test_exact_point_cleanup_preserves_close_parameter_preimages(): - root = np.array([.5, .5, .5, .5]) - xyz = np.zeros(3) - first = ssx.SSXPoint(root.copy(), xyz.copy()) - repeated = ssx.SSXPoint(root.copy(), xyz.copy()) - distinct = ssx.SSXPoint(root+np.array([1e-9, 0., 0., 0.]), xyz.copy()) - result = ssx._deduplicate_ssx_points( - [first, repeated, distinct], np.full(4, 1e-3), 1e-3, - exact_topology=True) - assert len(result) == 2 - assert result[0] is first - assert result[1] is distinct - - -def test_exact_boundary_source_with_unique_target_has_one_representation(): +def test_boundary_overlap_has_one_representation(): # The intersection is the s=1 boundary of the first plane. Its exact # source-curve ownership and the second plane's unique inverse identify # regular tracing and boundary overlap as the same correspondence. @@ -345,40 +196,6 @@ def test_representable_narrow_cell_keeps_its_exact_faces(): assert np.array_equal(ssx._global_to_local(np.full(4, width), box), np.ones(4)) -def test_small_physical_arc_requires_both_lifted_source_chords(): - from types import SimpleNamespace - from mmcore.numeric.intersection._deflate import minors_Tpsi_from_control_nets - from mmcore.numeric.intersection.ssx._ssx_affine_path import ( - affine_path_representation_bounded, source_box_image_diameter_bounded) - from mmcore.numeric.intersection.ssx._ssx_cofactor_bounds import SourceCofactorBounds - scale = 2.**-14 - a = np.array([[[scale*x,scale*y,0.] for y in (1/16,9/16)] - for x in (.25,.75)]) - b = np.array([[[scale*x,scale*y,y-xx] for y in (1/16,9/16)] - for x,xx in ((.25,1/16),(.5,3/16),(.75,9/16))]) - h = lambda net: np.concatenate((net,np.ones(net.shape[:-1]+(1,))),axis=-1) - roots=[] - for value in (0.,1.): - q=np.full(4,value) - root=ssx.BoundaryPoint(q,ssx.eval_surface(a,value,value,rational=False), - (0,int(value)),root_box=np.column_stack((q,q))) - root._source_root_box=True - roots.append(root) - tensors=[np.asarray(t) for t in minors_Tpsi_from_control_nets(a,b)] - checked=[] - def representation(start,end,xyz): - checked.append(True) - return affine_path_representation_bounded(a,b,start,end,xyz,1e-3,rational=False) - cell=SimpleNamespace(g1=SimpleNamespace(surface=h(a)),g2=SimpleNamespace(surface=h(b)), - crossings=roots,box=((0.,1.),)*4,boundary_complete=True, - T1=tensors[0],T2=tensors[1],T3=tensors[2],T4=tensors[3], - source_cofactors=SourceCofactorBounds(h(a),h(b)), - arc_image=lambda box,xyz: source_box_image_diameter_bounded( - a,b,box,xyz,1e-3,rational=False), - path_representation=representation) - assert ssx._small_regular_cell_arc(cell,1e-3) is None - assert checked - assert ssx.eval_surface(b,.5,.5,rational=False)[2] == .0625 def test_postprocess_denial_preserves_discovered_ordinary_branch(): @@ -392,3 +209,46 @@ def test_postprocess_denial_preserves_discovered_ordinary_branch(): assert result['branches'] assert any(np.ptp(np.asarray(branch.curve[1])[:,0]) == 1. for branch in result['branches']) + + +def test_valid_parabola_chord_is_not_deleted_by_midpoint_residual_estimate(): + # h=512*g*(2a²-g), g=v-(u-.5)². The lower zero arc g=0 + # lies <=a² from its endpoint chord. Both source evaluations along + # the published lifted chord are also within 512*a^4=1/2048 of it. + # At the midpoint grad(h)=0 while h=1/2048, so dividing this + # residual by a clamped normal angle invents a large distance. + from fractions import Fraction + from math import comb + from mmcore.numeric.intersection._root_box_certificate import exact_bernstein_value + a = Fraction(1,32) + square = {(0,0):Fraction(1,4),(1,0):Fraction(-1),(2,0):Fraction(1)} + g = {key:-value for key,value in square.items()} + g[0,1] = Fraction(1) + opposite = {key:-value for key,value in g.items()} + opposite[0,0] += 2*a*a + power = {} + for (i,j),left in g.items(): + for (k,l),right in opposite.items(): + key = i+k,j+l + power[key] = power.get(key,Fraction(0))+512*left*right + graph = np.zeros((5,3,4)) + for i in range(5): + for j in range(3): + h = sum(value*Fraction(comb(i,k),comb(4,k))*Fraction(comb(j,l),comb(2,l)) + for (k,l),value in power.items() if k <= i and l <= j) + exact = (3*i,6*j,12*h,12) + graph[i,j] = list(map(float,exact)) + assert all(Fraction(float(x)) == y for x,y in zip(graph[i,j],exact)) + plane = np.array([[[s,t,0.,1.] for t in (0.,1.)] for s in (0.,1.)]) + q = np.array([[float(Fraction(1,2)+sign*a),float(a*a)]*2 for sign in (-1,1)]) + xyz = np.column_stack((q[:,:2],np.zeros(2))) + for point in q: + assert exact_bernstein_value(graph,point[2:])[2] == 0 + assert a*a < Fraction(1,1000) + assert 512*a**4 < Fraction(1,1000) + left,right = [ssx.BoundaryPoint(s,x,(0,-1)) for s,x in zip(q,xyz)] + branch = ssx._assemble_fragments( + [ssx._Fragment(left,right,q,xyz)], S1_full=plane,S2_full=graph, + atol_full=.001,rational_full=True) + assert len(branch) == 1 + np.testing.assert_array_equal(branch[0].curve[0],q) diff --git a/tests/test_bez_ssx5_singular.py b/tests/test_bez_ssx5_singular.py index d1fba924..fa89bbc3 100644 --- a/tests/test_bez_ssx5_singular.py +++ b/tests/test_bez_ssx5_singular.py @@ -893,22 +893,25 @@ def _cusp_edge_on_split_plane(): return S1, S2 -def test_rounded_cusp_constructor_has_exact_positive_plane_gap(): - # The historical L1 fixture used float(-1/3) as a degree-elevated - # quadratic coefficient. Its supplied polynomial is strictly positive, - # so a tolerance-level cusp curve would be a false intersection. The - # exact [3,-1,-1,3] counterpart is tested in exact_extrusion instead. - from fractions import Fraction - from mmcore.numeric.intersection._exact_univariate import _power, _value - +def test_cusp_curve_on_split_plane_not_knifed_out(): + # Ledger L1 regression: the strict `min > 0` Bernstein hull test excluded + # BOTH children after solve_zero_dim's first split through s=0.5 — the + # mathematically-zero coefficients drift to ~eps/8 under de Casteljau, + # collapsing the 1-dimensional cusp CURVE to ONE isolated `cusp` with a + # false-complete enumeration. With the roundoff margin the enumeration + # floods (>12 sols) and the curve_flag types it as `cusp_curve`. S1, S2 = _cusp_edge_on_split_plane() - polynomial = _power([Fraction.from_float(float(x)) for x in S1[:, 0, 0]]) - assert len(polynomial) == 3 and polynomial[2] > 0 - assert polynomial[1] == -polynomial[2] # Unique minimum at s=1/2. - assert _value(polynomial, Fraction(1, 2)) == Fraction(1, 2**56) r = bez_ssx(S1, S2, 1e-3, rational=False) - assert r['complete'] - assert r['branches'] == [] and r['points'] == [] and r['singularities'] == [] + curves = [g for g in r["singularities"] if g.kind == "cusp_curve"] + assert curves, ( + f"cusp curve knifed out: kinds={[g.kind for g in r['singularities']]}") + 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 + # 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 def test_theorem3_skips_regular_case(): @@ -2016,10 +2019,10 @@ def test_bez_ssx_global_soft_budget_returns_partial_result(monkeypatch): s2 = np.array([[[0., 0., -1.], [0., 1., 1.]], [[1., 0., -1.], [1., 1., 1.]]]) def forbidden(*_args, **_kwargs): - pytest.fail("4-D minor nets were built with zero allowance") + pytest.fail("the 4-D distance net was built with zero allowance") monkeypatch.setattr( - ssx5, "minors_Tpsi_from_control_nets", forbidden) + 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, @@ -2029,7 +2032,7 @@ def forbidden(*_args, **_kwargs): assert set(("branches", "points", "singularities")) <= set(r) -def test_bez_ssx_preflights_minor_net_work(monkeypatch): +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 @@ -2038,10 +2041,10 @@ def test_bez_ssx_preflights_minor_net_work(monkeypatch): surface[..., 1] = np.linspace(0.0, 1.0, 8)[None, :] def forbidden(*_args, **_kwargs): - pytest.fail("minor-net construction bypassed its preflight charge") + pytest.fail("distance-net construction bypassed its preflight charge") monkeypatch.setattr( - ssx5, "minors_Tpsi_from_control_nets", forbidden) + 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, @@ -2069,8 +2072,8 @@ def test_bez_ssx_output_budget_bounds_postprocessing(): def test_zero_postprocess_budget_skips_all_postassembly_scans(): """Preserve discovered geometry when junction/filter scans cannot run.""" - # An exact shared-edge tier needs no postassembly scans. Exercise the - # ordinary tracer explicitly, where the step bound requires sampling. + # Exercise an ordinary sampled branch so a denied cleanup cannot + # silently discard useful geometry already found by the tracer. s1 = np.array([[[u, v, 0.] for v in (0., 1.)] for u in (0., 1.)]) s2 = s1.copy() s2[..., 2] = np.array([[-.375, .625], [-.375, .625]]) @@ -2108,6 +2111,8 @@ def test_bez_ssx_depth_cap_reports_unresolved_partial_result(): def test_bez_ssx_surfaces_c1_local_truncation(monkeypatch): + from mmcore.numeric.intersection.ssx import _bez_ssx5 + monkeypatch.setattr(_bez_ssx5, '_try_isoline_intersection', lambda *args: None) """A locally capped C1 enumeration must not masquerade as complete.""" import mmcore.numeric.intersection.ssx._ssx5_singular as singular @@ -2123,9 +2128,7 @@ def fake_c1(*_args, stats=None, **_kwargs): return [], False monkeypatch.setattr(singular, "c1_pass", fake_c1) - # An exact source tier can now finish this plane pair without invoking - # C1. Request explicit sampling so this test exercises the general path. - r = bez_ssx(s1, s2, 1e-3, rational=False, max_xyz_step=.1) + r = bez_ssx(s1, s2, 1e-3, rational=False) assert r["complete"] is False assert "work_budget" in r["status"]["reasons"] @@ -2883,6 +2886,8 @@ def test_schema_v2_collapsed_edge_reports_parameter_fiber(): def test_zero_csx_allowance_knobs_are_honored(monkeypatch): + from mmcore.numeric.intersection.ssx import _bez_ssx5 + monkeypatch.setattr(_bez_ssx5, '_try_isoline_intersection', lambda *args: None) # 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 — @@ -2900,15 +2905,13 @@ def recording(*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, - max_xyz_step=.1) + 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, - max_xyz_step=.1) + 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"] @@ -2925,18 +2928,14 @@ def _case12_pair(): def _region_loop_checks(r, reg, atol=1e-3): """Shared §8 invariants for one assembled region.""" - cert = reg.certification - exact_affine = (cert.get("image_identity") == "exact_affine_plane" - and cert.get("complete_preimage") is True) # 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" - # Exact affine identity certifies the entire two-point rim; - # the sampled overlap tier still requires its dense evidence. - assert len(np.asarray(b.curve[1])) >= (2 if exact_affine else 9) + # 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): @@ -2944,13 +2943,10 @@ def _region_loop_checks(r, reg, atol=1e-3): 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 - if exact_affine: - # Paired polygon vertices plus the interior witness are evaluated. - assert cert["n_samples"] == sum(map(len, reg.boundary)) + 1 - else: - assert cert["n_samples"] >= 8 + assert cert["n_samples"] >= 8 # interior witness is a certified coincidence point w = np.asarray(reg.interior_stuv, dtype=float) assert w.shape == (4,) @@ -3156,6 +3152,8 @@ def test_point_dedup_is_nan_safe(): def test_boundary_polish_gate_rejects_nan_residual(monkeypatch): + from mmcore.numeric.intersection.ssx import _bez_ssx5 + monkeypatch.setattr(_bez_ssx5, '_try_isoline_intersection', lambda *args: None) # 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 @@ -3168,20 +3166,12 @@ 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) - census, calls = {}, [] - - def isolated_proposal(*args, **kwargs): - calls.append(1) - return {'isolated': [{'t': .5, 'u': .5, 'v': .5}], 'overlaps': []} - - # Exercise the numerical boundary gate directly: exact source tiers - # legitimately bypass polishing for this plane pair. - crossings, overlaps = ssx5._find_ssx_boundary_zeros( - _homog(s1), _homog(s2), 1e-3, rational=True, - csx_fn=isolated_proposal, census_sink=census) - assert len(calls) == 8 - assert crossings == [] and overlaps == [] - assert census['complete'] is False + 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(): @@ -3202,11 +3192,7 @@ def test_overlap_box_coverage_requires_both_parameter_planes(): 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]) - # Sampled rims cannot retire a whole source-domain obligation. The - # exact affine region tier independently proves the positive case. - assert asm["regions"] and asm["covered"] is False - exact = bez_ssx(S1, S2, atol=1e-3, rational=True) - assert exact["complete"] and exact["overlap_regions"] + 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. @@ -3376,38 +3362,30 @@ def test_positive_dim_sigma_truncation_is_structural_not_work_budget(): 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. - Source-existence uncertainty can coexist with depth-limited cells; - each individual obligation must retain its own cause.""" +def test_limited_search_reports_status_without_public_proof_regions(): + """Search limits retain the CAD result and status, without proof payloads.""" from examples.ssx.bez_ssx5_coverage_check import load_case_surfaces - S1, S2, rational = load_case_surfaces(13) - # Default search now spends its explicit work allowance without an - # arbitrary derived depth ceiling. Request one to exercise this gate. - r = bez_ssx(S1, S2, 1e-3, rational=rational, max_depth=0) - 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"] in reasons - - # 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"] + first, second, rational = load_case_surfaces(13) + for limits, reason in (({'max_depth': 0}, 'depth_limit'), + ({'max_cells': 100}, 'work_budget')): + result = bez_ssx(first, second, 1e-3, rational=rational, **limits) + assert result['complete'] is False + assert reason in result['status']['reasons'], result['status'] + assert 'unresolved_regions' not in result + work = result['status']['work'] + assert work['cells_processed'] <= work['max_cells'] + # Returned geometry remains usable even when the search stops. + for branch in result['branches']: + stuv, xyz = map(np.asarray, branch.curve) + assert len(stuv) == len(xyz) and len(xyz) >= 2 + assert np.isfinite(stuv).all() and np.isfinite(xyz).all() + for q, point in zip(stuv, xyz): + for surface, uv in ((first, q[:2]), (second, q[2:])): + evaluated = eval_surface(surface, *uv, rational=rational) + assert np.linalg.norm(evaluated-point) <= 4e-3 + for point in result['points']: + assert np.isfinite(point.stuv).all() and np.isfinite(point.xyz).all() def test_small_scale_case10_keeps_certified_crossing_cells(): @@ -3451,23 +3429,38 @@ def test_march_stops_when_it_stops_advancing(): import numpy as np import mmcore.numeric.intersection.ssx._bez_ssx5 as bm - # Exercise the historical stalled numerical proposal directly. The - # exact-source search can now reject this rounded decimal fixture - # before calling any marcher, which is independently correct. - S1, S2 = _double_touch_asym() - stats = {} - t0 = time.time() - _, xyz, exit_info = bm._march_to_boundary( - _homog(S1), _homog(S2), np.array([.45, .5, .475, .5]), - atol=1e-3, rational=True, stats=stats) - elapsed = time.time()-t0 - assert exit_info is None - assert stats.get('no_progress') is True - steps = np.linalg.norm(np.diff(xyz, axis=0), axis=1) - arc = float(steps.sum()) - extent = float(np.max(np.linalg.norm(xyz-xyz[0], axis=1))) - assert arc <= 1e3*max(extent, 1e-300), ( - f"march travelled {arc/max(extent, 1e-300):.0f}x its own extent " - f"in {stats['iterations']} iterations without exiting") + seen = [] + orig = bm._march_to_boundary + + def spy(*a, **k): + stats = k.get("stats") + if stats is None: + stats = {} + k["stats"] = stats + out = orig(*a, **k) + xyz = np.asarray(out[1], dtype=float) + seen.append((int(stats.get("iterations", 0)), out[2], xyz)) + return out + + bm._march_to_boundary = spy + try: + t0 = time.time() + test_two_isolated_tangent_points_same_cell() + elapsed = time.time() - t0 + finally: + bm._march_to_boundary = orig + + assert seen, "no march observed" + for iters, exit_info, xyz in seen: + if exit_info is not None or len(xyz) < 3: + continue # a march that exited a face is fine, however long + # An unterminated march must not have spent its whole allowance + # vibrating: bound the arc it travelled by its own extent. + steps = np.linalg.norm(np.diff(xyz, axis=0), axis=1) + arc = float(steps.sum()) + extent = float(np.max(np.linalg.norm(xyz - xyz[0], axis=1))) + assert arc <= 1e3 * max(extent, 1e-300), ( + f"march travelled {arc / max(extent, 1e-300):.0f}x its own extent " + f"in {iters} iterations without exiting") # And the whole thing stays fast; it used to take ~150s. assert elapsed < 60.0, f"{elapsed:.1f}s" diff --git a/tests/test_ccx4_constant_curve_cad.py b/tests/test_ccx4_constant_curve_cad.py new file mode 100644 index 00000000..e4eadc85 --- /dev/null +++ b/tests/test_ccx4_constant_curve_cad.py @@ -0,0 +1,88 @@ +"""Practical curve/point contacts retain the varying curve's preimages.""" + +import numpy as np +import pytest + +from mmcore.numeric._bezier_common import eval_curve +from mmcore.numeric.intersection.ccx._bez_ccx4 import bez_ccx + + +def _homogeneous(points, weights): + weights = np.asarray(weights, dtype=float) + return np.column_stack((np.asarray(points) * weights[:, None], weights)) + + +def _check_representatives(result, first, second, rational, atol): + assert result["boundary_topology_complete"] + assert not result["budget_exhausted"] + assert result["isolated"] + for root in result["isolated"]: + assert 0.0 <= root["u"] <= 1.0 + assert 0.0 <= root["v"] <= 1.0 + a = eval_curve(first, root["u"], rational=rational) + b = eval_curve(second, root["v"], rational=rational) + assert np.linalg.norm(a - b) <= atol + assert np.linalg.norm(root["point"] - a) <= atol + assert np.linalg.norm(root["point"] - b) <= atol + + +@pytest.mark.parametrize("rational", [False, True]) +@pytest.mark.parametrize("swapped", [False, True]) +def test_constant_curve_endpoint_fiber_returns_usable_representatives(rational, swapped): + # This is the endpoint fiber of the pinch boundary that previously + # returned three correct points plus dozens of impossible 2D uniqueness + # obligations. The free parameter is not an isolated-root coordinate. + curve = np.array([[-1.0, 0.5, 0.0], [0.5, 0.5, 0.0]]) + point_curve = np.tile([0.5, 0.5, 0.0], (3, 1)) + if rational: + curve = _homogeneous(curve, [1.0, 1.0]) + point_curve = _homogeneous(point_curve, [0.5, 2.0, 4.0]) + first, second = (point_curve, curve) if swapped else (curve, point_curve) + result = bez_ccx(first, second, rational=rational, atol=1e-3, max_cells=1_000) + _check_representatives(result, first, second, rational, 1e-3) + varying = "v" if swapped else "u" + assert all(abs(root[varying] - 1.0) < 1e-8 for root in result["isolated"]) + + +@pytest.mark.parametrize("swapped", [False, True]) +def test_constant_curve_keeps_both_preimages_on_folded_partner(swapped): + # x(u)=(u-1/4)(u-3/4): one point has two separated preimages. + curve = np.array([[3 / 16, 0.5, 0.0], [-5 / 16, 0.5, 0.0], + [3 / 16, 0.5, 0.0]]) + point_curve = np.tile([0.0, 0.5, 0.0], (2, 1)) + first, second = (point_curve, curve) if swapped else (curve, point_curve) + result = bez_ccx(first, second, atol=1e-6, max_cells=2_000) + _check_representatives(result, first, second, False, 1e-6) + varying = "v" if swapped else "u" + parameters = np.array([root[varying] for root in result["isolated"]]) + assert np.min(abs(parameters - 0.25)) < 1e-7 + assert np.min(abs(parameters - 0.75)) < 1e-7 + assert np.all(np.minimum(abs(parameters - 0.25), abs(parameters - 0.75)) < 1e-7) + + +def test_constant_curve_near_contact_uses_cad_distance(): + curve = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) + point_curve = np.tile([0.5, 0.0, 5e-4], (3, 1)) + result = bez_ccx(curve, point_curve, atol=1e-3, max_cells=1_000) + _check_representatives(result, curve, point_curve, False, 1e-3) + assert min(abs(root["u"] - 0.5) for root in result["isolated"]) < 1e-6 + + +def test_constant_curve_still_reports_resource_limits(): + curve = np.array([[-1.0, 0.5, 0.0], [0.5, 0.5, 0.0]]) + point_curve = np.tile([0.5, 0.5, 0.0], (3, 1)) + result = bez_ccx(curve, point_curve, max_cells=1) + assert result["budget_exhausted"] + assert not result["boundary_topology_complete"] + assert result["cells_processed"] <= 1 + + +def test_nonconstant_fold_retains_two_parameter_pairs(): + line = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) + folded = np.array([[0.5, 3 / 16, 0.0], [0.5, -5 / 16, 0.0], + [0.5, 3 / 16, 0.0]]) + result = bez_ccx(line, folded, atol=1e-6, max_cells=2_000) + _check_representatives(result, line, folded, False, 1e-6) + parameters = np.array([root["v"] for root in result["isolated"]]) + assert np.min(abs(parameters - 0.25)) < 1e-7 + assert np.min(abs(parameters - 0.75)) < 1e-7 diff --git a/tests/test_ccx_cad_endpoint_tangency.py b/tests/test_ccx_cad_endpoint_tangency.py new file mode 100644 index 00000000..31a7f6d5 --- /dev/null +++ b/tests/test_ccx_cad_endpoint_tangency.py @@ -0,0 +1,55 @@ +"""CAD endpoint contacts do not require isolated algebraic certificates.""" +import numpy as np +import pytest + +from mmcore.numeric._bezier_common import eval_curve +from mmcore.numeric.intersection.ccx._bez_ccx4 import bez_ccx +from mmcore.numeric.intersection.csx._bez_csx4 import bez_csx + + +@pytest.mark.parametrize('swap', [False, True]) +def test_quadratic_endpoint_tangency_is_complete_at_modeling_tolerance(swap): + first = np.array([[0., 0., 0.], [.5, 0., 0.], [1., 0., 0.]]) + second = np.array([[0., 1., 0.], [.5, 0., 0.], [1., 0., 0.]]) + if swap: + first, second = second, first + result = bez_ccx(first, second, atol=1e-3, rational=False, max_cells=1000) + assert result['boundary_topology_complete'], result + assert not result['budget_exhausted'] + assert len(result['isolated']) == 1 + assert np.linalg.norm(result['isolated'][0]['point']-[1., 0., 0.]) < 1e-3 + assert not result.get('unresolved_parameter_boxes') + + +def test_surface_endpoint_tangency_uses_the_same_cad_contract(): + curve = np.array([[0., 0., 0.], [.5, 0., 0.], [1., 0., 0.]]) + surface = np.array([[[u, v, z] for v in (0., 1.)] + for u, z in ((0., 1.), (.5, 0.), (1., 0.))]) + result = bez_csx(curve, surface, atol=1e-3, rational=False, max_cells=2000) + assert result['boundary_topology_complete'], result + assert not result['budget_exhausted'] + assert any(np.linalg.norm(entry['point']-[1., 0., 0.]) < 1e-3 + for entry in result['isolated']) or any( + entry['t_range'][1] == 1. for entry in result['overlaps']) + + +@pytest.mark.parametrize('gap', [2.**-12, 2.**-16, 2.**-20]) +def test_metric_separated_close_parameter_roots_remain_distinct(gap): + scale = 1e10 + first = np.array([[0., 0., 0.], [.5*scale, 0., 0.], [scale, 0., 0.]]) + low, high = .5-gap, .5+gap + second = first.copy() + second[:, 1] = scale*np.array([low*high, low*high-(low+high)/2, + (1-low)*(1-high)]) + result = bez_ccx(first, second, atol=1e-3, rational=False, max_cells=2000) + assert len(result['isolated']) == 2, result + roots = sorted(entry['u'] for entry in result['isolated']) + assert np.allclose(roots, [low, high], atol=1e-9, rtol=0) + assert np.linalg.norm(result['isolated'][0]['point']- + result['isolated'][1]['point']) > 1e-3 + for root in result['isolated']: + a = eval_curve(first, root['u'], rational=False) + b = eval_curve(second, root['v'], rational=False) + assert np.linalg.norm(a-b) <= 1e-3 + assert np.linalg.norm(root['point']-a) <= 1e-3 + assert np.linalg.norm(root['point']-b) <= 1e-3 diff --git a/tests/test_csx_ccx_parameter_completeness.py b/tests/test_csx_ccx_parameter_completeness.py index 68b718a3..5b9cc93f 100644 --- a/tests/test_csx_ccx_parameter_completeness.py +++ b/tests/test_csx_ccx_parameter_completeness.py @@ -387,13 +387,13 @@ def test_ccx_terminal_cell_is_not_retired_by_a_neighbor_root_box(monkeypatch): @pytest.mark.parametrize('gap', [2.**-12, 2.**-16, 2.**-20]) -def test_public_ccx_close_exact_roots_are_both_reported_or_explicitly_unresolved(gap): +def test_exact_ccx_close_roots_are_both_reported_or_explicitly_unresolved(gap): from mmcore.numeric.intersection.ccx._bez_ccx4 import bez_ccx a, b = .5-gap, .5+gap first = np.array([[0., 0., 0.], [.5, 0., 0.], [1., 0., 0.]]) second = np.array([[0., a*b, 0.], [.5, a*b-(a+b)/2, 0.], [1., (1-a)*(1-b), 0.]]) - result = bez_ccx(first, second, rational=False, tolerance_tier=True, + result = bez_ccx(first, second, rational=False, tolerance_tier=False, atol=1e-3, max_cells=2000) if result['boundary_topology_complete']: assert len(result['isolated']) == 2, result diff --git a/tests/test_csx_overlap_tier.py b/tests/test_csx_overlap_tier.py index fe283117..da78e6e4 100644 --- a/tests/test_csx_overlap_tier.py +++ b/tests/test_csx_overlap_tier.py @@ -15,7 +15,6 @@ certified in 2.7s via the (unsound) valley rule this tier replaces. """ import numpy as np -import sys import pytest from mmcore.numeric.intersection.csx._bez_csx4 import bez_csx @@ -86,8 +85,6 @@ def _fixture_A(): return val1, val2 -@pytest.mark.xfail(sys.platform == "linux", strict=False, reason= - "Platform sensitivity, measured on CI's first-ever suite run (2026-08-18, x86-64 linux/gcc): one L-junction overlap branch is not shipped (0 of 1) while arm64 darwin/clang ships both — a knife-edge classification flips across FP pipelines. Derived-envelope program follow-up; needs a linux box to localize.") 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 diff --git a/tests/test_csx_surface_fiber_tolerance.py b/tests/test_csx_surface_fiber_tolerance.py new file mode 100644 index 00000000..ed37b29b --- /dev/null +++ b/tests/test_csx_surface_fiber_tolerance.py @@ -0,0 +1,107 @@ +"""CAD-tolerance coverage of collapsed surface parameter lines.""" + +import numpy as np +import pytest + +from mmcore.numeric._bezier_common import eval_curve, eval_surface +from mmcore.numeric.intersection.csx._bez_csx4 import bez_csx + + +@pytest.mark.parametrize("fixed_parameter", [0., .25, .5, 1.]) +@pytest.mark.parametrize("swap_axes", [False, True]) +@pytest.mark.parametrize("curve_endpoint", [False, True]) +def test_surface_fiber_does_not_flood_isolated_result_budget( + fixed_parameter, swap_axes, curve_endpoint): + # S(u,v)=(u, .5+(u-a)^2*(v-.5), (u-a)*v*(1-v)). + # Its u=a line maps to one point; C reaches that point at t=.35. + a = fixed_parameter + linear = np.array([-a, .5-a, 1.-a]) + square = np.array([a*a, a*a-a, (1.-a)**2]) + surface = np.empty((3, 3, 3)) + surface[..., 0] = np.arange(3)[:, None]/2. + surface[..., 1] = .5 + square[:, None]*np.array([-.5, 0., .5]) + surface[..., 2] = linear[:, None]*np.array([0., .5, 0.]) + if swap_axes: + surface = surface.transpose(1, 0, 2).copy() + curve = np.array([[a, -.2, 0.], [a, .5 if curve_endpoint else 1.8, 0.]]) + + result = bez_csx(curve, surface, atol=1e-3, rational=False, + max_cells=5000, max_results=16) + + assert not result["budget_exhausted"], result + assert result["truncation_cause"] is None + assert 1 <= len(result["isolated"]) <= 4 + assert result.get("uncertified_overlap_span") is None + assert result["overlaps"] == [] + for root in result["isolated"]: + p = eval_curve(curve, root["t"], rational=False) + q = eval_surface(surface, root["u"], root["v"], rational=False) + assert np.linalg.norm(p-q) <= 1e-3 + assert np.linalg.norm(p-[a, .5, 0.]) <= 1e-3 + + +def test_other_surface_preimages_at_same_curve_parameter_are_preserved(): + # The target folds in v but has two separate, regular preimages. + # Covering a collapsed isoline must not restore the old whole-t slab cut. + surface = np.empty((2, 3, 3)) + surface[..., 0] = np.array([0., 1.])[:, None] + surface[..., 1] = .5 + np.array([.1875, -.3125, .1875]) + surface[..., 2] = 0. + curve = np.array([[.5, .5, -.5], [.5, .5, .5]]) + + result = bez_csx(curve, surface, atol=1e-3, rational=False, + max_cells=5000, max_results=16) + + assert not result['budget_exhausted'], result + assert len(result['isolated']) == 2 + assert np.allclose(sorted(root['v'] for root in result['isolated']), [.25, .75], atol=1e-6) + for root in result['isolated']: + p = eval_curve(curve, root['t'], rational=False) + q = eval_surface(surface, root['u'], root['v'], rational=False) + assert np.linalg.norm(p-q) <= 1e-3 + + +def test_crossing_surface_fibers_do_not_cover_an_unrelated_interior_preimage(): + # Both coordinate edges collapse to the origin, but (.75,.75) is a + # separate regular preimage. Two free isolines do not cover their box. + linear = np.array([0., .5, 1.]) + quadratic = np.array([0., -.375, .25]) + surface = np.zeros((3, 3, 3)) + surface[..., 0] = quadratic[:, None]*linear[None, :] + surface[..., 1] = linear[:, None]*quadratic[None, :] + curve = np.array([[0., 0., -.5], [0., 0., .5]]) + + result = bez_csx(curve, surface, atol=1e-3, rational=False, + max_cells=5000, max_results=32) + + assert not result['budget_exhausted'], result + assert any(np.linalg.norm([root['u']-.75, root['v']-.75]) < 1e-6 + for root in result['isolated']) + assert any(min(abs(root['u']), abs(root['v'])) < 1e-6 + for root in result['isolated']) + for root in result['isolated']: + p = eval_curve(curve, root['t'], rational=False) + q = eval_surface(surface, root['u'], root['v'], rational=False) + assert np.linalg.norm(p-q) <= 1e-3 + + +def test_surface_fiber_coverage_accepts_positive_rational_weights(): + surface = np.empty((3, 3, 3)) + surface[..., 0] = np.arange(3)[:, None]/2. + surface[..., 1] = .5 + np.array([.25, -.25, .25])[:, None]*np.array([-.5, 0., .5]) + surface[..., 2] = np.array([-.5, 0., .5])[:, None]*np.array([0., .5, 0.]) + weights = np.broadcast_to(np.array([1., .5, 2.]), (3, 3)) + surface_h = np.concatenate([surface*weights[..., None], weights[..., None]], axis=2) + curve = np.array([[.5, -.2, 0.], [.5, 1.8, 0.]]) + curve_weights = np.array([1., 2.]) + curve_h = np.column_stack([curve*curve_weights[:, None], curve_weights]) + + result = bez_csx(curve_h, surface_h, atol=1e-3, rational=True, + max_cells=5000, max_results=16) + + assert not result['budget_exhausted'], result + assert 1 <= len(result['isolated']) <= 4 + for root in result['isolated']: + p = eval_curve(curve_h, root['t'], rational=True) + q = eval_surface(surface_h, root['u'], root['v'], rational=True) + assert np.linalg.norm(p-q) <= 1e-3 diff --git a/tests/test_nssx5.py b/tests/test_nssx5.py index f6dfea8f..26bc2101 100644 --- a/tests/test_nssx5.py +++ b/tests/test_nssx5.py @@ -162,16 +162,12 @@ def test_wrap_dup_vs_joint_plain_dup(): _domain_ctx, _dup_stuv, _joint_plain_dup) atol = 1e-3 ctx = _domain_ctx(cylinder(), big_plane(0.5), atol=atol) - p = np.array([0.0, 0.5, 0.5, 0.5]) - q = np.array([1.0, 0.5, 0.5, 0.5]) + eps = 0.1 * float(ctx.ptol[0]) + p = np.array([0.0 + eps, 0.5, 0.5, 0.5]) + q = np.array([1.0 - eps, 0.5, 0.5, 0.5]) x = np.array([1.0, 0.0, 0.5]) assert _dup_stuv(p, q, x, x, ctx, atol) assert not _joint_plain_dup(p, q, x, x, ctx, atol) - # Near-seam roots are distinct until a root certificate identifies - # them. Geometric tolerance alone does not establish that identity. - eps = 0.1 * float(ctx.ptol[0]) - p[0], q[0] = eps, 1.0 - eps - assert not _dup_stuv(p, q, x, x, ctx, atol) def test_remap4(): @@ -263,7 +259,7 @@ def test_empty_result_when_disjoint(): assert res['complete'] is True assert res['branches'] == [] and res['points'] == [] assert res['singularities'] == [] and res['overlap_regions'] == [] - assert res['unresolved_regions'] == [] + assert 'unresolved_regions' not in res assert res['status']['reasons'] == [] @@ -293,10 +289,8 @@ def test_starvation_is_soft_and_typed(): res = nurbs_ssx(s1, s2, atol=1e-3, max_cells=1) assert res['complete'] is False assert REASON_WORK_BUDGET in res['status']['reasons'] - assert len(res['unresolved_regions']) >= 1 - for entry in res['unresolved_regions']: - assert 'stuv_min' in entry and 'stuv_max' in entry - assert 'reason' in entry + assert 'unresolved_regions' not in res + assert res['status']['work']['cells_processed'] <= 1 def test_aggregate_work_counters_populated(): @@ -434,7 +428,7 @@ def test_chain_orientation_all_four_combinations(): assert gaps.max() <= 0.61 # contiguous: no jump across the joint -def test_identical_approximations_without_shared_source_provenance_are_retained(): +def test_containment_dedup_identical_twins_deterministic(): from mmcore.numeric.intersection.ssx._nssx5 import ( _Frag, _containment_dedup, _make_aggregate) path_s = np.array([[0.1, 0.5, 0.5, 0.5], [0.9, 0.5, 0.5, 0.5]]) @@ -448,7 +442,7 @@ def test_identical_approximations_without_shared_source_provenance_are_retained( for order in (twins, twins[::-1]): agg = _make_aggregate({}, 1) kept = _containment_dedup(list(order), 1e-3, agg) - assert len(kept) == 2 + assert len(kept) == 1 # --------------------------------------------------------------------------- @@ -468,17 +462,22 @@ def test_seam_straddling_tangency_dedups_to_one(): tp = tps[0] assert np.linalg.norm(np.asarray(tp.xyz) - np.zeros(3)) <= 5e-3 assert abs(tp.stuv[0] - 0.5) <= 0.05 and abs(tp.stuv[1] - 0.5) <= 0.05 - # Exact PSD reduction proves each patch pair's entire zero set is - # this singleton; the seam copies have the same exact global preimage. - np.testing.assert_array_equal(tp.stuv,np.full(4,.5)) - np.testing.assert_array_equal(tp.xyz,np.zeros(3)) - assert res['complete'] and not res['status']['reasons'] - assert not res['branches'] and not res['points'] - assert not res['unresolved_regions'] + # The certified tangent_point ships WITH typed structural caveats: + # bez_ssx reports complete=False + unresolved_multiplicity (split + # patches; the unsplit single-patch case additionally reports + # unresolved_tangential_zone) for isolated C2 tangencies — verified + # against a direct bez_ssx call on the unsplit surfaces. The adapter + # must surface that honestly (spec: AND of completes, union of + # reasons), never claim completeness the engine didn't certify. + assert res['complete'] is False + assert set(res['status']['reasons']) <= { + 'unresolved_multiplicity', 'unresolved_tangential_zone'} + assert len(res['status']['reasons']) >= 1 -def test_point_on_neighbor_pair_chord_is_retained_without_source_incidence(): - """A geometric chord match does not establish cross-pair source incidence.""" +def test_point_on_neighbor_pair_branch_is_filtered(): + """Unit test: a point lying on another pair's branch polyline is + dropped by the global on-branch filter (4*atol).""" from mmcore.numeric.intersection.ssx._nssx5 import ( _assemble_points, _domain_ctx, _make_aggregate) from mmcore.numeric.intersection.ssx._ssx_substrate import SSXPoint, SSXBranch @@ -493,8 +492,8 @@ def test_point_on_neighbor_pair_chord_is_retained_without_source_incidence(): off = SSXPoint(stuv=np.array([.9, .9, .9, .9]), xyz=np.array([0.8, 0.8, 0.8])) out = _assemble_points([on, off], [branch], ctx, 1e-3, agg) - assert len(out) == 2 - assert out[0] is on and out[1] is off + assert len(out) == 1 + assert np.allclose(out[0].xyz, off.xyz) def test_duplicate_points_dedup_wrap_aware(): @@ -504,8 +503,9 @@ def test_duplicate_points_dedup_wrap_aware(): ctx = _domain_ctx(cylinder(), big_plane(0.5), atol=1e-3) agg = _make_aggregate({}, 1) x = np.array([1.0, 0.0, 0.5]) - a = SSXPoint(stuv=np.array([0.0, .5, .5, .5]), xyz=x) - b = SSXPoint(stuv=np.array([1.0, .5, .5, .5]), xyz=x) # proven seam + eps_s = 0.1 * float(ctx.ptol[0]) + a = SSXPoint(stuv=np.array([0.0 + eps_s, .5, .5, .5]), xyz=x) + b = SSXPoint(stuv=np.array([1.0 - eps_s, .5, .5, .5]), xyz=x) # wrap dup out = _assemble_points([a, b], [], ctx, 1e-3, agg) assert len(out) == 1 @@ -578,11 +578,8 @@ def sing(mate): near = stuv + 0.5 * ctx.ptol far = stuv + np.array([50.0 * ctx.ptol[0], 0, 0, 0]) agg = _make_aggregate({}, 1) - retained = _assemble_singularities( - [sing(stuv), sing(near)], [], ctx, 1e-3, agg) - assert len(retained) == 2 merged = _assemble_singularities( - [sing(stuv), sing(stuv)], [], ctx, 1e-3, _make_aggregate({}, 1)) + [sing(stuv), sing(near)], [], ctx, 1e-3, agg) assert len(merged) == 1 agg = _make_aggregate({}, 1) kept = _assemble_singularities( @@ -590,8 +587,9 @@ def sing(mate): assert len(kept) == 2 -def test_unknown_point_incidence_needs_no_postprocess_budget(): - """Retaining unknown incidence performs no deletion or denied proof work.""" +def test_points_kept_when_postprocess_starved(): + """Zero postprocess budget: the on-branch filter must OVER-include + (keep the point) and record the typed reason — never silently drop.""" from mmcore.numeric.intersection.ssx._nssx5 import ( _assemble_points, _domain_ctx, _make_aggregate) from mmcore.numeric._work_budget import REASON_POSTPROCESS_CAP @@ -605,63 +603,20 @@ def test_unknown_point_incidence_needs_no_postprocess_budget(): xyz=np.array([0.0, 0.0, 0.0])) out = _assemble_points([on], [br], ctx, 1e-3, agg) assert len(out) == 1 - assert REASON_POSTPROCESS_CAP not in agg.reasons + assert REASON_POSTPROCESS_CAP in agg.reasons # --------------------------------------------------------------------------- # Task 5: overlap-region unification # --------------------------------------------------------------------------- -def test_two_vertex_reversed_seams_are_partners(): - from fractions import Fraction - from mmcore.numeric.intersection.ssx._nssx5 import ( - _Frag, _domain_ctx, _rims_are_partners, - ) - - ctx = _domain_ctx(plane_z0(), plane_z0(), atol=1e-3) - stuv = np.array([[.5, 0, .5, 0], [.5, 1, .5, 1]]) - xyz = np.array([[0, -1, 0], [0, 1, 0]], dtype=float) - forward = _Frag(stuv=stuv, xyz=xyz, kind='overlap', overlap=True) - reverse = _Frag(stuv=stuv[::-1].copy(), xyz=xyz[::-1].copy(), - kind='overlap', overlap=True) - # Both are exact parameter lines on the identical affine source charts. - forward.source_path = tuple(tuple(Fraction.from_float(float(x)) for x in row) for row in stuv) - reverse.source_path = forward.source_path[::-1] - assert _rims_are_partners(forward, reverse, ctx, 1e-3) - - -@pytest.mark.parametrize("other_stuv", [ - # Coincident XYZ cannot identify nearby, distinct source preimages. - np.array([[.5, 0, .5 + 1e-8, 0], [.5, 1, .5 + 1e-8, 1]]), - # Identical endpoints cannot identify different paths between them. - np.array([[.5, 0, .5, 0], [.5, .5, .5 + 1e-8, .5], - [.5, 1, .5, 1]]), -]) -def test_seam_partners_require_the_entire_same_parameter_path(other_stuv): - from mmcore.numeric.intersection.ssx._nssx5 import ( - _Frag, _domain_ctx, _rims_are_partners, - ) - - ctx = _domain_ctx(plane_z0(), plane_z0(), atol=1e-3) - stuv = np.array([[.5, 0, .5, 0], [.5, 1, .5, 1]]) - xyz = np.array([[0, -1, 0], [0, 1, 0]], dtype=float) - other_xyz = np.column_stack((np.zeros(len(other_stuv)), - 2 * other_stuv[:, 1] - 1, - np.zeros(len(other_stuv)))) - first = _Frag(stuv=stuv, xyz=xyz, kind='overlap', overlap=True) - second = _Frag(stuv=other_stuv, xyz=other_xyz, - kind='overlap', overlap=True) - assert not _rims_are_partners(first, second, ctx, 1e-3) - - def _assert_unified_plane_twin(res, n_tiles_expected_dissolved_seams): """Common asserts for plane-twin unification: ONE region whose outer uv1 loop spans the full domain, valid boundary refs, no surviving - branch pinned to an interior seam. Exact affine chart clipping owns - both the coincident tiles and off-diagonal shared edges completely. - """ + branch pinned to an interior seam. Shared-edge and shared-corner pairs + are handled by the same numerical polygon clipping as their regions.""" assert len(res['overlap_regions']) == 1 - assert res['complete'] is True, res['status']['reasons'] + assert res['complete'] is True assert res['status']['reasons'] == [] region = res['overlap_regions'][0] outer = np.asarray(region.uv1_loops[0], dtype=float) @@ -716,15 +671,11 @@ def _synthetic_tile_pair(): opposite-orientation partners.""" from mmcore.numeric.intersection.ssx._nssx5 import ( _Frag, _Tile, _RawResults) - from fractions import Fraction def frag(p0, p1): stuv, xyz = _synthetic_edge( [p0[0], p0[1], p0[0], p0[1]], [p1[0], p1[1], p1[0], p1[1]]) - # The fixture's charts are identical: every paired diagonal - # parameter segment is an exact source zero arc, including on z=s*t. - source_path = tuple(tuple(Fraction.from_float(float(x)) for x in row) for row in stuv) - return _Frag(stuv=stuv,xyz=xyz,kind='overlap',overlap=True,source_path=source_path) + return _Frag(stuv=stuv, xyz=xyz, kind='overlap', overlap=True) raw = _RawResults() # left tile CCW: bottom, seam (t 0->1), top, left (t 1->0) @@ -796,44 +747,6 @@ def test_synthetic_tiles_agreement_mismatch_never_merges(): assert {r.normal_agreement for r in regions} == {1, -1} -def test_nearby_disconnected_tiles_do_not_dissolve_the_gap(): - from fractions import Fraction - from mmcore.numeric.intersection.ssx._nssx5 import ( - _assemble_regions, _domain_ctx, _make_aggregate) - ctx = _domain_ctx(plane_z0(), plane_z0(), atol=1e-3) - raw = _synthetic_tile_pair() - right_start = .5 + 2.**-11 - for rim in raw.rim_frags[4:]: - for axis in (0, 2): - rim.stuv[:, axis] = right_start + (rim.stuv[:, axis]-.5)*2.*(1.-right_start) - rim.xyz = np.stack((rim.stuv[:, 0], rim.stuv[:, 1], - rim.stuv[:, 0]*rim.stuv[:, 1]), axis=1) - rim.source_path = tuple(tuple(Fraction.from_float(float(x)) for x in row) for row in rim.stuv) - raw.tiles[1].rect = (right_start, 1., 0., 1., right_start, 1., 0., 1.) - agg = _make_aggregate({}, 1) - branches, regions = _assemble_regions(raw, [], ctx, 1e-3, agg, - (.5, right_start), (), (.5, right_start), ()) - assert len(regions) == 2 - assert len(branches) == 8 - - -def test_overlap_curve_just_outside_a_region_is_preserved(): - from mmcore.numeric.intersection.ssx._nssx5 import ( - _assemble_regions, _domain_ctx, _make_aggregate) - from mmcore.numeric.intersection.ssx._ssx_substrate import SSXBranch - ctx = _domain_ctx(plane_z0(), plane_z0(), atol=1e-3) - raw = _synthetic_tile_pair() - raw.tiles, raw.rim_frags = raw.tiles[:1], raw.rim_frags[:4] - root = .5 + 2.**-11 - stuv, xyz = _synthetic_edge([root, 0., root, 0.], [root, 1., root, 1.]) - curve = SSXBranch(curve=(stuv, xyz), kind='overlap', overlap=True) - branches, regions = _assemble_regions(raw, [curve], ctx, 1e-3, - _make_aggregate({}, 1), (.5, root), (), (.5, root), ()) - assert len(regions) == 1 - assert any(b is curve for b in branches) - assert len(branches) == 5 - - def test_synthetic_interior_absorption_and_ref_shift(): from mmcore.numeric.intersection.ssx._nssx5 import ( _Frag, _RawResults, _assemble_regions, _domain_ctx, @@ -851,7 +764,7 @@ def stitched_branch(s_val, kind): return SSXBranch(curve=(stuv, xyz), kind=kind, overlap=(kind == 'overlap')) - inside_ovl = stitched_branch(0.25, 'overlap') # unproved correspondence + inside_ovl = stitched_branch(0.25, 'overlap') # absorbed outside_ovl = stitched_branch(0.75, 'overlap') # outside left tile inside_trans = stitched_branch(0.25, 'transversal') # kind-guarded branches, regions = _assemble_regions( @@ -859,13 +772,13 @@ def stitched_branch(s_val, kind): ctx, 1e-3, agg, (0.5,), (), (0.5,), ()) assert len(regions) == 1 kept_kinds = [(b.kind, float(np.asarray(b.curve[0])[0, 0])) - for b in branches[:3]] + for b in branches[:2]] assert (('overlap', 0.75) in kept_kinds and ('transversal', 0.25) in kept_kinds) - assert len(branches) == 3 + 4 # 3 retained correspondences + 4 rims + assert len(branches) == 2 + 4 # 2 kept stitched + 4 rims for loop in regions[0].boundary: for bi, _rev in loop: - assert 3 <= bi < len(branches) + assert 2 <= bi < len(branches) assert branches[bi].overlap @@ -883,7 +796,7 @@ def test_synthetic_parallel_seam_rims_dissolve(): # flipping its loop entry so the CCW traversal stays head-to-tail stuv, xyz = _synthetic_edge([.5, 0, .5, 0], [.5, 1, .5, 1]) raw.rim_frags[7] = _Frag(stuv=stuv, xyz=xyz, kind='overlap', - overlap=True,source_path=raw.rim_frags[1].source_path) + overlap=True) raw.tiles[1].loops[0][3] = (7, True) branches, regions = _assemble_regions( raw, [], ctx, 1e-3, agg, (0.5,), (), (0.5,), ()) @@ -891,34 +804,6 @@ def test_synthetic_parallel_seam_rims_dissolve(): assert len(branches) == 6 -def test_matching_rim_approximations_without_source_ownership_are_not_dissolved(): - from mmcore.numeric.intersection.ssx._nssx5 import ( - _assemble_regions, _domain_ctx, _make_aggregate) - raw = _synthetic_tile_pair() - raw.rim_frags[1].source_path = None - context = _domain_ctx(plane_z0(),plane_z0(),atol=1e-3) - branches,regions = _assemble_regions(raw,[],context,1e-3,_make_aggregate({},1),(.5,),(),(.5,),()) - assert len(regions) == 2 and len(branches) == 8 - - -def test_region_absorption_requires_exact_source_arc_provenance(): - from fractions import Fraction - from mmcore.numeric.intersection.ssx._nssx5 import ( - _assemble_regions, _domain_ctx, _make_aggregate) - from mmcore.numeric.intersection.ssx._ssx_substrate import SSXBranch - raw = _synthetic_tile_pair() - stuv,xyz = _synthetic_edge([.5,.25,.5,.25],[.5,.75,.5,.75]) - unknown = SSXBranch((stuv,xyz),kind='overlap',overlap=True) - owned = SSXBranch((stuv.copy(),xyz.copy()),kind='overlap',overlap=True) - owned._source_parameter_paths = (tuple(tuple(Fraction.from_float(float(x)) for x in row) for row in stuv),) - context = _domain_ctx(plane_z0(),plane_z0(),atol=1e-3) - branches,regions = _assemble_regions(raw,[unknown,owned],context,1e-3,_make_aggregate({},1),(.5,),(),(.5,),()) - assert len(regions) == 1 - assert any(branch is unknown for branch in branches) - assert not any(branch is owned for branch in branches) - assert len(branches) == 7 - - # --- Fix-C retirement machinery: regression suite ------------------------ def _unified_region_fixture(): @@ -935,26 +820,73 @@ def _unified_region_fixture(): return ctx, regions -@pytest.mark.parametrize("rect", [ - (.2, .4, .2, .4, .2, .4, .2, .4), - (.2, .4, .2, .4, 2., 2.4, 2., 2.4), - (0., .5+2.**-11, .2, .8, 0., .5+2.**-11, .2, .8), -]) -def test_region_footprints_do_not_retire_pair_multiplicity(rect): - """Projected trims cannot prove exhaustion of paired preimages.""" +def test_multiplicity_retired_when_rect_region_interior(): from mmcore.numeric.intersection.ssx._nssx5 import ( - _assemble_regions, _domain_ctx, _make_aggregate) + _RawResults, _make_aggregate, + _retire_multiplicity_if_region_explained) from mmcore.numeric._work_budget import REASON_MULTIPLICITY - ctx = _domain_ctx(plane_z0(), plane_z0(), atol=1e-3) - raw = _synthetic_tile_pair() - raw.mult_rects.append(rect) + ctx, regions = _unified_region_fixture() + raw = _RawResults() + raw.mult_rects.append((0.2, 0.4, 0.2, 0.4, 0.2, 0.4, 0.2, 0.4)) agg = _make_aggregate({}, 1) agg.mark(REASON_MULTIPLICITY) - _assemble_regions(raw, [], ctx, 1e-3, agg, (.5,), (), (.5,), ()) + _retire_multiplicity_if_region_explained(raw, regions, ctx, agg) + assert REASON_MULTIPLICITY not in agg.reasons + assert agg.result_fields()['complete'] is True + + +def test_multiplicity_kept_when_any_rect_escapes(): + from mmcore.numeric.intersection.ssx._nssx5 import ( + _RawResults, _make_aggregate, + _retire_multiplicity_if_region_explained) + from mmcore.numeric._work_budget import REASON_MULTIPLICITY + ctx, regions = _unified_region_fixture() + raw = _RawResults() + raw.mult_rects.append((0.2, 0.4, 0.2, 0.4, 0.2, 0.4, 0.2, 0.4)) + raw.mult_rects.append((2.0, 2.4, 2.0, 2.4, 2.0, 2.4, 2.0, 2.4)) + agg = _make_aggregate({}, 1) + agg.mark(REASON_MULTIPLICITY) + _retire_multiplicity_if_region_explained(raw, regions, ctx, agg) assert REASON_MULTIPLICITY in agg.reasons assert agg.result_fields()['complete'] is False +def test_multiplicity_kept_when_one_sided(): + """Two-sided site rule: a rect inside the region in uv1 but outside + in uv2 must NOT retire.""" + from mmcore.numeric.intersection.ssx._nssx5 import ( + _RawResults, _make_aggregate, + _retire_multiplicity_if_region_explained) + from mmcore.numeric._work_budget import REASON_MULTIPLICITY + ctx, regions = _unified_region_fixture() + raw = _RawResults() + raw.mult_rects.append((0.2, 0.4, 0.2, 0.4, 2.0, 2.4, 2.0, 2.4)) + agg = _make_aggregate({}, 1) + agg.mark(REASON_MULTIPLICITY) + _retire_multiplicity_if_region_explained(raw, regions, ctx, agg) + assert REASON_MULTIPLICITY in agg.reasons + assert agg.result_fields()['complete'] is False + + +def test_multiplicity_kept_when_postprocess_starved(): + """Zero postprocess budget: containment unverified — never retire on + unverified evidence; the typed cap reason is recorded.""" + from mmcore.numeric.intersection.ssx._nssx5 import ( + _RawResults, _make_aggregate, + _retire_multiplicity_if_region_explained) + from mmcore.numeric._work_budget import ( + REASON_MULTIPLICITY, REASON_POSTPROCESS_CAP) + ctx, regions = _unified_region_fixture() + raw = _RawResults() + raw.mult_rects.append((0.2, 0.4, 0.2, 0.4, 0.2, 0.4, 0.2, 0.4)) + agg = _make_aggregate({'max_postprocess_work': 0}, 1) + agg.mark(REASON_MULTIPLICITY) + _retire_multiplicity_if_region_explained(raw, regions, ctx, agg) + assert REASON_MULTIPLICITY in agg.reasons + assert REASON_POSTPROCESS_CAP in agg.reasons + assert agg.result_fields()['complete'] is False + + # --------------------------------------------------------------------------- # Task 6: pickle fixtures (small cases; the Task-7 harness covers all 10) # --------------------------------------------------------------------------- @@ -980,8 +912,10 @@ def _load_case(num): @pytest.mark.parametrize("case,expect_complete,expect_reasons", [ (5, True, set()), (8, True, set()), - # Exact quadratic reduction certifies the entire curved tangent line. - (10, True, set()), + # pins CURRENT engine truth: if bez_ssx later resolves this tangential + # zone, update this expectation (an improvement will fail this line, by + # design) + (10, False, {'unresolved_tangential_zone'}), ]) def test_fixture_case_residual_certificate(case, expect_complete, expect_reasons): @@ -1390,33 +1324,35 @@ def spy(*a, **k): # complete=False/'work_budget' at 17% ledger utilization. # --------------------------------------------------------------------------- -def test_depth_ceiling_is_typed_and_local(): - """The reason names the real limit, and the search is not aborted. - - The elevated plane chart exercises general CSX isolation; the exact - bilinear-plane cut tier can solve the original fixture without depth. - The explicit ceilings truncate face isolation and further subdivision - while preserving independently traced arcs. Unknown face ownership - cannot be discharged by repeated numerical continuation. - """ +def test_depth_ceiling_is_typed_and_local(monkeypatch): + """A local CSX depth stop does not abort the surrounding NURBS search.""" from mmcore.numeric.intersection.ssx._nssx5 import nurbs_ssx + from mmcore.numeric.intersection.ssx import _bez_ssx5 as engine + + original_csx = engine.bez_csx + truncated = [] + + def stop_one_face(*args, **kwargs): + result = original_csx(*args, **kwargs) + if not truncated and result.get('isolated'): + truncated.append(True) + result = dict(result, budget_exhausted=True, + boundary_topology_complete=False, truncation_cause='depth') + return result + + monkeypatch.setattr(engine, 'bez_csx', stop_one_face) s1, s2 = _load_case11() - cp = s1.control_points - cp = np.stack((cp[0], .5*cp[0] + .5*cp[1], cp[1])) - cp = np.stack((cp[:, 0], .5*cp[:, 0] + .5*cp[:, 1], cp[:, 1]), axis=1) - s1 = s1._replace(order_u=3, order_v=3, - knot_u=np.repeat(s1.knot_u[[0, -1]], 3), - knot_v=np.repeat(s1.knot_v[[0, -1]], 3), - control_points=cp, weights=np.ones((3, 3))) - r = nurbs_ssx(s1, s2, atol=1e-3, csx_max_depth=16, max_depth=2) + # Inject the local stop on real computed geometry. A fixed depth such + # as 40 need not be reached after the numerical search becomes faster. + r = nurbs_ssx(s1, s2, atol=1e-3) + assert truncated assert r["status"]["reasons"] == ["depth_limit"], r["status"]["reasons"] # A structural ceiling must not masquerade as resource exhaustion: the # ledger is nowhere near spent, so 'work_budget' would have been a lie. w = r["status"]["work"] assert w["cells_processed"] < 0.9 * w["max_cells"] - assert w["csx_calls"] > 8, "a local face limit aborted the remaining census" # ...and the search continued rather than being globally aborted: most # of the loop is still traced despite the truncated face. assert r["branches"], "a local depth ceiling aborted the whole run" diff --git a/tests/test_nssx5_certified_seams.py b/tests/test_nssx5_certified_seams.py deleted file mode 100644 index 54fa9e2a..00000000 --- a/tests/test_nssx5_certified_seams.py +++ /dev/null @@ -1,114 +0,0 @@ -"""NURBS seams must join a unique root, without joining nearby roots.""" -import numpy as np - -from mmcore.nurbs._nurbs_eval import NURBSSurfaceTuple -from mmcore.numeric.intersection.ssx._nssx5 import ( - _DomainCtx, _Frag, _assemble_branches, _make_aggregate, -) - - -def _patch(lo, hi, heights): - nv = len(heights) - cp = np.array([[[s, t, z] for t, z in zip(np.linspace(0., 1., nv), heights)] - for s in (lo, hi)]) - return NURBSSurfaceTuple(2, nv, np.array([lo, lo, hi, hi]), - np.r_[np.zeros(nv), np.ones(nv)], cp, - np.ones((2, nv))) - - -def _fragments(left_root, right_root): - result = [] - for i, (lo, hi, root) in enumerate(((0., .5, left_root), (.5, 1., right_root))): - stuv = np.array([[lo, root, lo, root], [hi, root, hi, root]]) - xyz = np.column_stack((stuv[:, :2], np.zeros(2))) - result.append(_Frag(stuv, xyz, 'transversal', False, - pair=(i, 0), rect=(lo, hi, 0., 1., 0., 1., 0., 1.))) - return result - - -def _context(heights): - ctx = _DomainCtx(np.zeros(4), np.ones(4), np.ones(4), - np.full(4, .001), (False,) * 4) - # Real decomposed source patches, used by the seam-root certificate. - ctx.source_patches = ([_patch(0., .5, heights), _patch(.5, 1., heights)], - [_patch(0., 1., [0., 0.])]) - return ctx - - -def test_numerically_distinct_endpoints_join_at_one_certified_seam_root(): - # Intersection is exactly t=v=.5; the seam is exactly s=u=.5. - frags = _fragments(.5 - 1e-12, .5 + 1e-12) - branches = _assemble_branches(frags, _context([-.5, .5]), .001, - _make_aggregate({}, 2)) - assert len(branches) == 1 - assert not branches[0].closed - np.testing.assert_allclose(branches[0].curve[1][[0, -1], 0], [0., 1.]) - - -def test_close_distinct_seam_roots_are_not_joined(): - a, b = .5 - 2.**-12, .5 + 2.**-12 - # Bernstein coefficients of (t-a)*(t-b), with two exact roots. - heights = [a*b, a*b - (a+b)/2., (1.-a)*(1.-b)] - branches = _assemble_branches(_fragments(a, b), _context(heights), .001, - _make_aggregate({}, 2)) - assert len(branches) == 2 - assert not any(branch.closed for branch in branches) - - -def test_close_endpoints_without_patch_provenance_remain_separate(): - ctx = _context([-.5, .5]) - ctx.source_patches = None - branches = _assemble_branches(_fragments(.5 - 1e-12, .5 + 1e-12), ctx, - .001, _make_aggregate({}, 2)) - assert len(branches) == 2 - - -def test_unique_but_absent_seam_root_does_not_authorize_joining(): - # z=t+2**-20 has an invertible seam Jacobian, but its zero is outside - # t>=0. A tiny residual at t=0 must not replace root existence. - delta = 2.**-20 - branches = _assemble_branches(_fragments(0., 1e-12), - _context([delta, 1.+delta]), .001, - _make_aggregate({}, 2)) - assert len(branches) == 2 - - -def test_certified_seam_identity_survives_surface_swap(): - ctx = _context([-.5, .5]) - ctx.source_patches = ctx.source_patches[::-1] - fragments = _fragments(.5-1e-12, .5+1e-12) - for frag in fragments: - frag.stuv = frag.stuv[:, [2, 3, 0, 1]] - frag.pair = frag.pair[::-1] - frag.rect = frag.rect[4:]+frag.rect[:4] - branches = _assemble_branches(fragments, ctx, .001, _make_aggregate({}, 2)) - assert len(branches) == 1 - - -def test_nearby_interior_curve_is_not_absorbed_by_boundary_owner(): - ctx = _context([0., 0.]) - root = .5+2.**-11 - branches = [] - for value, kind in ((root, 'transversal'), (.5, 'overlap')): - stuv = np.array([[value, 0., value, 0.], [value, 1., value, 1.]]) - xyz = np.column_stack((stuv[:, :2], np.zeros(2))) - branches.append(_Frag(stuv, xyz, kind, kind == 'overlap', pair=(1, 0), - rect=(.5, 1., 0., 1., 0., 1., 0., 1.))) - result = _assemble_branches(branches, ctx, .001, _make_aggregate({}, 2)) - assert len(result) == 2 - - -def test_boundary_retrace_requires_unique_target_preimage(): - from mmcore.numeric.intersection.ssx._ssx_boundary_identity import certified_boundary_retrace - # C(t)=(1/16,t,0), S(u,v)=((u-.5)^2,v,0). Both u=.25 - # and u=.75 are valid preimages of every curve point. - curve = np.array([[1/16, 0., 0., 1.], [1/16, 1., 0., 1.]]) - surface = np.array([[[x, v, 0., 1.] for v in (0., 1.)] - for x in (.25, -.25, .25)]) - source = np.array([[.5, 0., .75, 0.], [.5, 1., .75, 1.]]) - keeper = source.copy() - keeper[:, 2] = .25 - xyz = curve[:, :3] - assert not certified_boundary_retrace( - source, xyz, keeper, xyz, curve, surface, 1, (2, 3), (0., 1.), - np.full(4, .6), .001) diff --git a/tests/test_nssx5_parameter_mapping.py b/tests/test_nssx5_parameter_mapping.py deleted file mode 100644 index 681248fe..00000000 --- a/tests/test_nssx5_parameter_mapping.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Global knot-domain rounding must not alter SSX topology or certificates.""" -from fractions import Fraction - -import numpy as np - -from mmcore.nurbs._nurbs_eval import NURBSSurfaceTuple -from mmcore.numeric.intersection.ssx._nssx5 import ( - _RawResults, _collect_pair, _finish_collecting, _make_aggregate, nurbs_ssx, -) -from mmcore.numeric.intersection.ssx._ssx_substrate import SSXPoint, SSXBranch - - -def test_huge_global_domain_retains_local_curve_as_partial(): - low, high = 1e16, 1e16 + 2. - knots = np.array([low, low, high, high]) - a = np.array([[[s, t, 0.] for t in (0., 1.)] for s in (0., 1.)]) - b = np.array([[[u, .5, v-.5] for v in (0., 1.)] for u in (0., 1.)]) - surfaces = [NURBSSurfaceTuple(2, 2, knots, knots, p, np.ones((2, 2))) - for p in (a, b)] - result = nurbs_ssx(*surfaces, atol=1e-6, max_cells=2000) - assert not result['complete'] - assert 'parameter_representation' in result['status']['reasons'] - assert not result['branches'] - issues = [r for r in result['unresolved_regions'] - if r['reason'] == 'parameter_representation'] - assert issues and issues[0]['local_result']['branches'] - - -def test_cross_pair_global_aliases_withhold_both_local_solutions(): - raw, agg = _RawResults(), _make_aggregate({}, 2) - net = np.zeros((2, 2, 3)) - rect = (1e16, 1e16+2.) * 4 - for pair, t in [((0, 0), .125), ((1, 0), .375)]: - result = {'points': [SSXPoint(np.full(4, t), np.zeros(3))]} - _collect_pair(raw, result, rect, pair, sources=(net, net), - rational=False, atol=1e-3, agg=agg) - _finish_collecting(raw) - assert raw.points == [] - assert len(raw.unresolved) == 2 - assert all(r['local_result']['points'] for r in raw.unresolved) - assert 'parameter_representation' in agg.reasons - - -def test_one_unrepresentable_branch_withholds_other_pair_entities_atomically(): - raw, agg = _RawResults(), _make_aggregate({}, 1) - net = np.zeros((2, 2, 3)) - rect = (1e16, 1e16+2.) * 4 - result = { - 'branches': [SSXBranch((np.array([[.125]*4, [.375]*4]), np.zeros((2, 3))))], - 'points': [SSXPoint(np.ones(4), np.zeros(3))], - } - _collect_pair(raw, result, rect, (0, 0), sources=(net, net), - rational=False, atol=1e-3, agg=agg) - _finish_collecting(raw) - assert raw.frags == [] and raw.points == [] - assert len(raw.unresolved) == 1 - assert raw.unresolved[0]['local_result'] is result - - -def test_unresolved_bounds_enclose_exact_affine_parameter_mapping(): - low, high, t = .20323707831873447, .7064770887111032, .569861747188787 - raw = _RawResults() - _collect_pair(raw, {'unresolved_regions': [{ - 'stuv_min': [t]*4, 'stuv_max': [t]*4, 'reason': 'test', - }]}, (low, high)*4, (0, 0)) - exact = Fraction(low)+(Fraction(high)-Fraction(low))*Fraction(t) - assert all(Fraction(lo) <= exact <= Fraction(hi) - for lo, hi in zip(raw.unresolved[0]['stuv_min'], - raw.unresolved[0]['stuv_max'])) - - -def test_unproved_candidate_keeps_local_and_exact_global_provenance(): - raw, agg = _RawResults(), _make_aggregate({}, 1) - net = np.zeros((2, 2, 3)) - rect = (1e16, 1e16 + 2.) * 4 - candidate = (.25, .5, .75, 1.) - result = {'unresolved_regions': [{ - 'stuv_min': [0.]*4, 'stuv_max': [1.]*4, - 'reason': 'unresolved_multiplicity', 'candidate': candidate, - 'candidate_kind': 'tangent_point', 'source_existence': False, - }]} - _collect_pair(raw, result, rect, (0, 0), sources=(net, net), - rational=False, atol=1e-3, agg=agg) - _finish_collecting(raw) - diagnostic, = raw.unresolved - assert 'candidate' not in diagnostic - assert diagnostic['local_candidate'] == candidate - assert diagnostic['candidate_parameter_bounds'] == ((1e16, 1e16+2.),)*4 - assert tuple(map(Fraction, diagnostic['exact_global_candidate'])) == tuple( - Fraction(1e16) + 2*Fraction(t) for t in candidate) - assert diagnostic['source_existence'] is False - # An unproved proposal is diagnostic data, not a published float root. - assert agg.reasons == [] - assert raw.points == [] and raw.singularities == [] - assert agg.post.postprocess_work == 0 - - -def test_mapping_budget_denial_retains_atomic_local_payload(): - raw, agg = _RawResults(), _make_aggregate({'max_postprocess_work': 0}, 1) - net = np.zeros((2, 2, 3)) - result = {'points': [SSXPoint(np.full(4, .5), np.zeros(3))]} - _collect_pair(raw, result, (0., 1.)*4, (0, 0), sources=(net, net), - rational=False, atol=1e-3, agg=agg) - _finish_collecting(raw) - assert raw.points == [] - assert raw.unresolved[0]['reason'] == 'postprocess_cap' - assert raw.unresolved[0]['local_result'] is result - assert agg.post.postprocess_work == 0 - - -def test_exact_roundtrip_uses_only_cheap_affine_mapping_allowance(): - raw, agg = _RawResults(), _make_aggregate({'max_postprocess_work': 1}, 1) - net = np.zeros((32, 32, 3)) - result = {'points': [SSXPoint(np.full(4, .5), np.zeros(3))]} - _collect_pair(raw, result, (0., 1.)*4, (0, 0), sources=(net, net), - rational=False, atol=1e-3, agg=agg) - _finish_collecting(raw) - assert len(raw.points) == 1 - assert agg.reasons == [] - assert agg.post.postprocess_work == 1 - - -def test_unrepresentable_region_anchor_evaluation_is_typed_partial(): - from types import SimpleNamespace - raw, agg = _RawResults(), _make_aggregate({}, 1) - net = np.full((2, 2, 4), 1e308) - net[..., 3] = 1e-308 - result = {'overlap_regions': [SimpleNamespace(interior_stuv=np.full(4, .5))]} - _collect_pair(raw, result, (1e16, 1e16+2.)*4, (0, 0), sources=(net, net), - rational=True, atol=1e-3, agg=agg) - _finish_collecting(raw) - assert len(raw.unresolved) == 1 - assert raw.unresolved[0]['reason'] == 'parameter_representation' - assert raw.unresolved[0]['local_result'] is result diff --git a/tests/test_nssx5_practical_representation.py b/tests/test_nssx5_practical_representation.py new file mode 100644 index 00000000..1326939f --- /dev/null +++ b/tests/test_nssx5_practical_representation.py @@ -0,0 +1,139 @@ +"""Practical NURBS assembly preserves weights and paired parameter paths.""" +import numpy as np + +from mmcore.numeric._bezier_common import eval_surface +from mmcore.numeric.intersection.ssx import _nssx5 as ssx +from mmcore.numeric.intersection.ssx._ssx_substrate import SSXBranch, SSXPoint +from mmcore.nurbs._nurbs_eval import NURBSSurfaceTuple, evaluate_nurbs_surface + + +def surface(points, weights=None): + points = np.asarray(points, dtype=float) + nu, nv = points.shape[:2] + return NURBSSurfaceTuple( + nu, nv, np.r_[np.zeros(nu), np.ones(nu)], + np.r_[np.zeros(nv), np.ones(nv)], points, + np.ones((nu, nv)) if weights is None else np.asarray(weights)) + + +def context(closed=False): + return ssx._DomainCtx(np.zeros(4), np.ones(4), np.ones(4), + np.full(4, .001), (closed, False, False, False)) + + +def test_near_unit_weights_reach_pair_solver_with_their_geometry(monkeypatch): + cp = np.array([[[s, t, z] for t in (0., 1.)] + for s, z in zip((0., .5, 1.), (1000., -1000., 1000.))]) + weights = np.ones((3, 2)) + weights[1] += 8e-6 + original = surface(cp, weights) + plane = surface([[[s, t, 0.] for t in (0., 1.)] for s in (0., 1.)]) + calls = [] + + def capture(first, second, **kwargs): + calls.append(kwargs['rational']) + expected = evaluate_nurbs_surface(original, .5, .5)['S'] + actual = eval_surface(first, .5, .5, rational=kwargs['rational']) + assert abs(expected[2]) > 100*kwargs['atol'] + np.testing.assert_allclose(actual, expected, atol=1e-12, rtol=0.) + return {'branches': [], 'points': [], 'singularities': []} + + monkeypatch.setattr(ssx, 'bez_ssx', capture) + result = ssx.nurbs_ssx(original, plane, atol=1e-5) + assert calls == [True] + assert 'unresolved_regions' not in result + np.testing.assert_array_equal(original.weights, weights) + + +def test_equal_control_rows_do_not_close_unclamped_surface(): + points = np.array([[[x, t, 0.] for t in (0., 1.)] for x in (0., 1., 2., 0.)]) + original = NURBSSurfaceTuple(3, 2, np.arange(7.), np.array([0., 0., 1., 1.]), + points, np.ones((4, 2))) + np.testing.assert_allclose(evaluate_nurbs_surface(original, 2., .5)['S'], [.5, .5, 0.]) + np.testing.assert_allclose(evaluate_nurbs_surface(original, 4., .5)['S'], [1., .5, 0.]) + assert not ssx._axis_closed(original, 0) + + +def test_small_open_patch_has_no_structural_periodic_seam(): + original = surface([[[s, t, 0.] for t in (0., 1.)] for s in (0., 1e-11)]) + assert not ssx._axis_closed(original, 0) + + +def test_tolerance_stitching_retains_numerically_close_joint(): + ctx = context() + a = np.array([[0., .5, 0., .5], [.5, .5, .5, .5]]) + b = np.array([[.5+1e-8, .5, .5+1e-8, .5], [1., .5, 1., .5]]) + frags = [ssx._Frag(p, np.c_[p[:, :2], np.zeros(2)], 'transversal', False) for p in (a, b)] + result = ssx._assemble_branches(frags, ctx, .001, ssx._make_aggregate({}, 1)) + assert len(result) == 1 and not result[0].closed + np.testing.assert_allclose(result[0].curve[1][[0, -1], 0], [0., 1.]) + + +def test_same_xyz_on_distinct_parameter_sheets_is_not_deduplicated(): + paths = [np.array([[s, 0., 0., 0.], [s, 1., 1., 0.]]) for s in (.25, .75)] + xyz = np.array([[0., 0., 0.], [1., 0., 0.]]) + frags = [ssx._Frag(p, xyz.copy(), 'transversal', False) for p in paths] + result = ssx._assemble_branches(frags, context(), .001, ssx._make_aggregate({}, 1)) + assert len(result) == 2 + + +def test_same_path_with_different_sampling_is_coalesced(): + frags = [] + for samples in (np.linspace(0., 1., 3), np.linspace(1., 0., 8)): + p = np.c_[samples, np.full(len(samples), .5), samples, np.full(len(samples), .5)] + frags.append(ssx._Frag(p, np.c_[samples, np.zeros((len(samples), 2))], + 'transversal', False)) + result = ssx._assemble_branches(frags, context(), .001, ssx._make_aggregate({}, 1)) + assert len(result) == 1 and not result[0].closed + + +def test_matching_endpoints_do_not_hide_a_resolved_detour(): + paths = [np.array([[0., 0., 0., 0.], [1., 0., 1., 0.]]), + np.array([[0., 0., 0., 0.], [.5, .5, .5, .5], [1., 0., 1., 0.]])] + frags = [ssx._Frag(p, np.c_[p[:, :2], np.zeros(len(p))], 'transversal', False) + for p in paths] + assert len(ssx._containment_dedup(frags, .001, ssx._make_aggregate({}, 1), + ctx=context())) == 2 + + +def test_point_within_both_tolerances_is_absorbed(): + p = np.array([[.25, 0., 0., 0.], [.25, 1., 1., 0.]]) + branch = SSXBranch(curve=(p, np.array([[0., 0., 0.], [1., 0., 0.]]))) + point = SSXPoint(np.array([.25+1e-6, .5, .5, 0.]), np.array([.5, 1e-6, 0.])) + assert ssx._assemble_points([point], [branch], context(), .001, + ssx._make_aggregate({}, 1)) == [] + + +def test_point_on_another_parameter_sheet_is_retained(): + p = np.array([[.25, 0., 0., 0.], [.25, 1., 1., 0.]]) + branch = SSXBranch(curve=(p, np.array([[0., 0., 0.], [1., 0., 0.]]))) + point = SSXPoint(np.array([.75, .5, .5, 0.]), np.array([.5, 0., 0.])) + assert ssx._assemble_points([point], [branch], context(), .001, + ssx._make_aggregate({}, 1)) == [point] + + +def test_periodic_vertex_pair_is_not_an_interior_parameter_chord(): + p = np.array([[1., .5, .5, .5], [0., .5, .5, .5]]) + branch = SSXBranch(curve=(p, np.zeros((2, 3)))) + point = SSXPoint(np.array([.5, .5, .5, .5]), np.zeros(3)) + assert ssx._assemble_points([point], [branch], context(True), .001, + ssx._make_aggregate({}, 1)) == [point] + + +def test_point_on_opposite_periodic_boundary_is_absorbed(): + p = np.array([[1., 0., 0., 0.], [1., 1., 1., 0.]]) + branch = SSXBranch(curve=(p, np.array([[0., 0., 0.], [1., 0., 0.]]))) + point = SSXPoint(np.array([0., .5, .5, 0.]), np.array([.5, 0., 0.])) + assert ssx._assemble_points([point], [branch], context(True), .001, + ssx._make_aggregate({}, 1)) == [] + + +def test_zero_postprocess_allowance_keeps_all_fragments(): + p = np.array([[0., .5, 0., .5], [1., .5, 1., .5]]) + frags = [ssx._Frag(p.copy(), np.c_[p[:, :2], np.zeros(2)], 'transversal', False) + for _ in range(2)] + agg = ssx._make_aggregate({'max_postprocess_work': 0}, 1) + result = ssx._assemble_branches(frags, context(), .001, agg) + assert len(result) == 2 + assert agg.post.postprocess_work == 0 + assert not agg.result_fields()['complete'] diff --git a/tests/test_nssx5_source_singularity_identity.py b/tests/test_nssx5_source_singularity_identity.py index 8ebd0134..80a0ded9 100644 --- a/tests/test_nssx5_source_singularity_identity.py +++ b/tests/test_nssx5_source_singularity_identity.py @@ -1,5 +1,4 @@ -"""Geometric proximity does not identify isolated roots or singular strata.""" -from fractions import Fraction +"""Singularity metadata distinguishes the two owning surface charts.""" import numpy as np @@ -11,25 +10,8 @@ def _context(closed=False): return ssx._DomainCtx(np.zeros(4),np.ones(4),np.ones(4),np.full(4,.001),(closed,False,False,False)) -def test_distinct_exact_nearby_tangent_points_remain_distinct(): - # Plane vs z=((s-c)^2+(t-.5)^2)*((s-d)^2+(t-.5)^2) - # has exactly these two isolated tangent points, however close c,d are. - c,d = Fraction(1,2),Fraction(1,2)+Fraction(1,2**20) - singularities = [] - for x in (c,d): - assert ((x-c)**2)*((x-d)**2) == 0 - q = np.array([float(x),.5,float(x),.5]) - singularities.append(SSXSingularity('tangent_point',q,np.array([float(x),.5,0.]))) - result = ssx._assemble_singularities(singularities,[],_context(),.001,ssx._make_aggregate({},1)) - assert len(result) == 2 -def test_identical_cusp_samples_do_not_own_a_whole_source_stratum(): - q = np.array([.5,.5,.5,.5]) - first = SSXSingularity('cusp_curve',q,np.zeros(3),samples=np.array([q])) - second = SSXSingularity('cusp_curve',q.copy(),np.zeros(3),samples=np.array([q])) - result = ssx._assemble_singularities([first,second,first],[],_context(),.001,ssx._make_aggregate({},1)) - assert len(result) == 2 and result[0] is first and result[1] is second def test_cusps_on_different_source_charts_keep_both_surface_owners(): @@ -37,24 +19,3 @@ def test_cusps_on_different_source_charts_keep_both_surface_owners(): values = [SSXSingularity('cusp',q.copy(),np.zeros(3),surface=owner) for owner in (1,2)] result = ssx._assemble_singularities(values,[],_context(),.001,ssx._make_aggregate({},1)) assert len(result) == 2 and {value.surface for value in result} == {1,2} - - -def test_sub_ulp_distance_from_seam_is_not_exact_endpoint_identity(): - ctx = _context(True) - first = np.array([1.,.5,.5,.5]) - second = np.array([2.**-60,.5,.5,.5]) - assert abs(first[0]-second[0]) == 1. # The former distance test cancelled. - assert not ssx._dup_stuv(first,second,np.zeros(3),np.zeros(3),ctx,.001) - assert not ssx._mate_matches(first,second,ctx) - second[0] = 0. - assert ssx._dup_stuv(first,second,np.zeros(3),np.zeros(3),ctx,.001) - - -def test_sub_ulp_seam_gap_does_not_join_two_unknown_arc_endpoints(): - first = np.array([[.9,.5,.5,.5],[1.,.5,.5,.5]]) - second = np.array([[2.**-60,.5,.5,.5],[.1,.5,.5,.5]]) - fragments = [ssx._Frag(q,np.column_stack((np.array([-.1,0.]) if i == 0 else np.array([0.,.1]), - np.zeros((2,2)))),'transversal',False) - for i,q in enumerate((first,second))] - result = ssx._assemble_branches(fragments,_context(True),.001,ssx._make_aggregate({},1)) - assert len(result) == 2 and not any(branch.closed for branch in result) diff --git a/tests/test_ssx5_analytic_coverage.py b/tests/test_ssx5_analytic_coverage.py index acf924c2..133aa22e 100644 --- a/tests/test_ssx5_analytic_coverage.py +++ b/tests/test_ssx5_analytic_coverage.py @@ -162,3 +162,10 @@ def test_nearby_transverse_lines_survive_exact_knot_insertion(variant): result = solve_case("tight_lines", variant) report = audit_result("tight_lines", result) assert report["passed"], report + + +@pytest.mark.parametrize('variant', ['identity', 'swap']) +def test_nested_circles_preserve_complete_closed_components(variant): + result = solve_case('nested_circles',variant) + report = audit_result('nested_circles',result) + assert report['passed'],report diff --git a/tests/test_ssx5_arc_secant_bound.py b/tests/test_ssx5_arc_secant_bound.py deleted file mode 100644 index b826b413..00000000 --- a/tests/test_ssx5_arc_secant_bound.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Whole-arc approximation from a validated velocity interval.""" -import numpy as np - -from mmcore.numeric.intersection.ssx._ssx_affine_path import ( - SourceArcChordBounds, source_arc_chord_error_bounded, source_box_image_diameter_bounded) - - -def _parabola(): - plane = np.array([[[s, t, 0.] for t in (0., 1.)] for s in (0., 1.)]) - graph = np.array([[[s, t, t-squared] for t in (0., 1.)] - for s, squared in ((0., 0.), (.5, 0.), (1., 1.))]) - endpoints = np.array([[.25, .0625, .25, .0625], [.5, .25, .5, .25]]) - root_boxes = np.stack((endpoints, endpoints), axis=-1) - box = np.stack((endpoints[0], endpoints[1]), axis=-1) - xyz = np.column_stack((endpoints[:, :2], np.zeros(2))) - minors = (np.array([-1., .5, -1., .5]), np.array([-1., 1., -1., 1.])) - return plane, graph, box, root_boxes, xyz, minors - - -def test_secant_bound_certifies_arc_much_longer_than_tolerance(): - a, b, box, roots, xyz, minors = _parabola() - assert not source_box_image_diameter_bounded(a, b, box, xyz, 1/32, rational=False) - assert source_arc_chord_error_bounded(a, b, box, roots, xyz, 0, minors, 1/32, rational=False) - # The entire parabola's actual midpoint deviation is 1/64, while - # the derivative-range certificate conservatively bounds it by 1/32. - assert not source_arc_chord_error_bounded(a, b, box, roots, xyz, 0, minors, 1/128, rational=False) - - -def test_published_endpoint_error_is_included_in_secant_bound(): - a, b, box, roots, xyz, minors = _parabola() - xyz = xyz.copy() - xyz[:, 2] += .125 - assert not source_arc_chord_error_bounded(a, b, box, roots, xyz, 0, minors, .04, rational=False) - - -def test_unknown_or_reversed_monotone_parameter_cannot_certify_arc(): - a, b, box, roots, xyz, minors = _parabola() - unknown = tuple(value.copy() for value in minors) - unknown[1][0] = 1. - assert not source_arc_chord_error_bounded(a, b, box, roots, xyz, 0, unknown, 1., rational=False) - assert not source_arc_chord_error_bounded(a, b, box, roots[::-1], xyz[::-1], 0, minors, 1., rational=False) - - -def test_secant_source_setup_is_prepaid(): - a, b, box, roots, xyz, minors = _parabola() - charges = [] - assert not source_arc_chord_error_bounded(a, b, box, roots, xyz, 0, minors, 1., rational=False, - charge=lambda amount: charges.append(amount) or False) - assert len(charges) == 1 and charges[0] > 0 - - -def test_original_fraction_jets_are_constructed_once_and_query_work_is_repaid(monkeypatch): - import mmcore.numeric.intersection.ssx._ssx_affine_path as module - - a, b, box, roots, xyz, minors = _parabola() - built, charges = [], [] - build = module._exact_source_jet - def counted(net, rational): - built.append(net.shape) - return build(net, rational) - monkeypatch.setattr(module, '_exact_source_jet', counted) - source = SourceArcChordBounds(a, b, rational=False, charge=lambda amount: charges.append(amount) or True) - construction = list(charges) - assert len(built) == 2 and len(construction) == 1 - assert source.bounded(box, roots, xyz, 0, minors, 1/32) - first_query = charges[len(construction):] - assert len(first_query) > 1 and sum(first_query) > 0 - before = len(charges) - assert source.bounded(box, roots, xyz, 0, minors, 1/32) - assert charges[before:] == first_query - assert len(built) == 2 - - -def test_denied_construction_stops_before_fraction_conversion(monkeypatch): - import mmcore.numeric.intersection.ssx._ssx_affine_path as module - - a, b, box, roots, xyz, minors = _parabola() - def forbidden(*_): - raise AssertionError('Unpaid exact source construction') - monkeypatch.setattr(module, '_exact_source_jet', forbidden) - source = SourceArcChordBounds(a, b, rational=False, charge=lambda _: False) - assert source.jets is None and source.exhausted - assert not source.bounded(box, roots, xyz, 0, minors, 1.) - - -def test_denied_restriction_stops_before_kernel_and_remains_exhausted(monkeypatch): - import mmcore.numeric.intersection.ssx._ssx_affine_path as module - - a, b, box, roots, xyz, minors = _parabola() - charges = [] - def allowance(amount): - charges.append(amount) - return len(charges) < 3 # Source construction and query inputs only. - source = SourceArcChordBounds(a, b, rational=False, charge=allowance) - def forbidden(*_): - raise AssertionError('Unpaid exact restriction') - monkeypatch.setattr(module, '_restrict_homogeneous_rectangle', forbidden) - assert not source.bounded(box, roots, xyz, 0, minors, 1.) - assert source.exhausted and len(charges) == 3 - assert not source.bounded(box, roots, xyz, 0, minors, 1.) - assert len(charges) == 3 - - -def test_cached_source_jets_are_an_immutable_input_snapshot(): - a, b, box, roots, xyz, minors = _parabola() - source = SourceArcChordBounds(a, b, rational=False) - a[..., 2] += 1. - b[..., 2] += 1. - assert source.bounded(box, roots, xyz, 0, minors, 1/32) - assert not source_arc_chord_error_bounded(a, b, box, roots, xyz, 0, minors, 1/32, rational=False) - assert all(not net.flags.writeable for exact, derivatives in source.jets - for net in (exact, *derivatives) if net is not None) - - -def test_cached_rational_jet_centering_commutes_with_exact_restriction(): - from fractions import Fraction - import mmcore.numeric.intersection.ssx._ssx_affine_path as module - - rng = np.random.default_rng(718) - net = rng.integers(-8, 9, (4, 3, 4)).astype(float)/8. - net[..., 3] = rng.integers(8, 17, (4, 3))/8. - origin = (Fraction(2**20), Fraction(-3, 8), Fraction(5, 16)) - start, end = (Fraction(1, 4), Fraction(3, 8)), (Fraction(3, 4), Fraction(7, 8)) - source = SourceArcChordBounds(net, net, rational=True) - exact, derivatives = source.jets[0] - centered = exact.copy() - for k in range(3): - centered[..., k] -= origin[k]*centered[..., 3] - for axis, derivative in enumerate(derivatives): - before = module._restrict_homogeneous_rectangle( - (centered.shape[axis]-1)*np.diff(centered, axis=axis), start, end) - after = module._restrict_homogeneous_rectangle(derivative, start, end) - for k in range(3): - after[..., k] -= origin[k]*after[..., 3] - np.testing.assert_array_equal(before, after) diff --git a/tests/test_ssx5_boundary_continuation.py b/tests/test_ssx5_boundary_continuation.py new file mode 100644 index 00000000..3c9ba3b5 --- /dev/null +++ b/tests/test_ssx5_boundary_continuation.py @@ -0,0 +1,89 @@ +"""Spatial continuation at a collapsed isoline, across parameter charts.""" +import numpy as np +import pytest + +from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx +from examples.ssx.bez_ssx5_coverage_check import point_to_polyline_dist + + +def _pinched_pair(): + anchor = np.array([.5, .5, 0.]) + heights = np.array([.15, -.45, .15]) + first = np.column_stack((np.zeros(3), [0., .5, 1.], heights)) + last = np.column_stack((np.ones(3), [0., .5, 1.], -heights)) + middle = 2.*anchor-.5*(first+last) + surface = np.stack((first, middle, last)) + plane = np.array([[[-1., -1., 0.], [-1., 2., 0.]], + [[2., -1., 0.], [2., 2., 0.]]]) + return surface, plane + + +@pytest.mark.parametrize('variant', ['swap', 'transpose', 'reverse', 'rotate']) +def test_pinched_incident_branches_keep_both_sides_in_changed_charts(variant): + first, second = _pinched_pair() + rotation = np.eye(3) + if variant == 'swap': + first, second = second, first + elif variant == 'transpose': + first, second = first.swapaxes(0, 1).copy(), second.swapaxes(0, 1).copy() + elif variant == 'reverse': + first, second = first[::-1, ::-1].copy(), second[::-1].copy() + elif variant == 'rotate': + rotation = np.array([[0., 0., 1.], [1., 0., 0.], [0., 1., 0.]]) + first, second = first@rotation.T, second@rotation.T + + result = bez_ssx(first, second, atol=1e-3, rational=False) + assert 'cusp_curve' in [item.kind for item in result['singularities']] + assert not {'work_budget', 'output_cap', 'depth_limit'} & set(result['status']['reasons']) + polylines = [branch.curve[1]@rotation for branch in result['branches']] + assert len(polylines) == 2 + parameter = np.linspace(0., 1., 101) + for t in (.5-np.sqrt(.5)/2., .5+np.sqrt(.5)/2.): + truth = np.column_stack((parameter, + .5+(t-.5)*(1.-2.*parameter)**2, + np.zeros_like(parameter))) + distance = [min(point_to_polyline_dist(point, polyline) + for polyline in polylines) for point in truth] + assert max(distance) <= 5e-3 + + +def _general_boundary_pair(): + # y=0 is a whole intersection curve for x in [.1,1]. Raw face CSX + # also observes many interior points of that same curved boundary. + from math import comb + power = np.array([-.0001, .0001, .01, -.01]) + height = np.array([sum(power[j]*comb(i, j)/comb(3, j) + for j in range(i+1)) for i in range(4)]) + first = np.array([[[t, s, z] for t, z in zip(np.linspace(0., 1., 4), height)] + for s in (0., 1.)]) + second = np.array([[[u, 0., v] for v in (0., 1.)] for u in (0., 1.)]) + return first, second + + +def test_general_boundary_curve_consumes_interior_registrations_once(): + first, second = _general_boundary_pair() + result = bez_ssx(first, second, .001, rational=False, + max_cells=30000, max_csx_calls=500, max_xyz_step=.1) + assert len(result['branches']) == 1 + branch = result['branches'][0] + xyz = np.asarray(branch.curve[1]) + assert abs(xyz[:, 0].min()-.1) <= .001 + assert abs(xyz[:, 0].max()-1.) <= .001 + for x in np.linspace(.1, 1., 101): + true_point = np.array([x, 0., .01*(1.-x)*(x*x-.01)]) + assert point_to_polyline_dist(true_point, xyz) <= .001 + assert not {'postprocess_cap', 'output_cap'} & set(result['status']['reasons']) + + +@pytest.mark.parametrize('boundary_cap, resource_stop', [(100, True), (20000, False)]) +def test_boundary_overlap_fallback_retains_actual_stop_reason(boundary_cap, resource_stop): + first, second = _general_boundary_pair() + result = bez_ssx(first, second, .001, rational=False, + max_cells=30000, max_csx_calls=500, max_xyz_step=.1, + boundary_csx_max_cells=boundary_cap) + assert ('work_budget' in result['status']['reasons']) is resource_stop + assert result['complete'] is False + if not resource_stop: + assert 'overlap_region_unsupported' in result['status']['reasons'] + assert len(result['branches']) == 1 + assert result['status']['work']['cells_processed'] <= 30000 diff --git a/tests/test_ssx5_boundary_strata.py b/tests/test_ssx5_boundary_strata.py deleted file mode 100644 index 5917c124..00000000 --- a/tests/test_ssx5_boundary_strata.py +++ /dev/null @@ -1,251 +0,0 @@ -"""Exact boundary-stratum topology and independent lifted-path checks.""" -import numpy as np -import pytest -from scipy.special import comb - -from mmcore.numeric._work_budget import SoftWorkBudget -from mmcore.numeric.intersection.ssx._ssx_boundary_strata import exact_boundary_strata_ssx - - -def _plane(): - return np.array([[[s, t, 0.] for t in (0., 1.)] for s in (0., 1.)]) - - -def _corner_graph(degree=1): - return np.array([[[s, t, (1-s)*(1-t)] - for t in np.linspace(0., 1., degree+1)] - for s in np.linspace(0., 1., degree+1)]) - - -def _solve(a, b, atol=1e-6, rational=False, max_cells=100000): - budget = SoftWorkBudget(max_cells, 1000) - result = exact_boundary_strata_ssx(a, b, atol, rational, budget) - if result is not None: - result.update(budget.result_fields()) - return result - - -def _eval(surface, uv): - basis = [] - for axis, coordinate in enumerate(uv): - degree = surface.shape[axis]-1 - indices = np.arange(degree+1) - basis.append(comb(degree, indices)*coordinate**indices*(1-coordinate)**(degree-indices)) - return np.einsum('i,j,ijc->c', *basis, surface) - - -@pytest.mark.parametrize('swap', [False, True]) -@pytest.mark.parametrize('degree', [1, 4]) -def test_nonnegative_height_exhausts_two_boundary_edges(degree, swap): - a, b = _corner_graph(degree), _plane() - result = _solve(*((b, a) if swap else (a, b))) - assert result['complete'] and result['points'] == [] - assert len(result['branches']) == 2 - edges = [] - for branch in result['branches']: - assert branch.overlap and branch.kind == 'overlap' - stuv, xyz = branch.curve - edges.append(tuple(map(tuple, xyz))) - own = stuv[:, 2:] if swap else stuv[:, :2] - np.testing.assert_array_equal(own, xyz[:, :2]) - assert set(edges) == {((1., 0., 0.), (1., 1., 0.)), - ((0., 1., 0.), (1., 1., 0.))} - - -def test_uniform_positive_homogeneous_weights_preserve_boundary_loci(): - nets = [np.concatenate((a*w, np.full(a.shape[:2]+(1,), w)), axis=2) - for a, w in zip((_corner_graph(), _plane()), (2., .5))] - result = _solve(*nets, rational=True) - assert result['complete'] and len(result['branches']) == 2 - - -@pytest.mark.parametrize('swap', [False, True]) -@pytest.mark.parametrize('rational', [False, True]) -def test_shared_source_corner_has_tangent_junction_on_nonaffine_convex_target(swap, rational): - source = _corner_graph() - source[..., :2] = .25+.25*source[..., :2] - target = np.array([[[u+.25*u*v,v+.125*u*v,0.] - for v in (0.,1.)] for u in (0.,1.)]) - nets = [source,target] - if rational: - nets = [np.concatenate((net*w,np.full(net.shape[:2]+(1,),w)),axis=-1) - for net,w in zip(nets,(2.,.5))] - if swap: - nets.reverse() - result = _solve(*nets,atol=1e-5,rational=rational) - assert result['complete'] and len(result['branches']) == 2 - junction, = result['singularities'] - assert junction.kind == 'tangent_point' - np.testing.assert_array_equal(junction.xyz,[.5,.5,0.]) - np.testing.assert_array_equal(junction.stuv[2:] if swap else junction.stuv[:2],[1.,1.]) - assert {index for index,_ in junction.branch_links} == {0,1} - - -@pytest.mark.parametrize('swap', [False, True]) -@pytest.mark.parametrize('flat', [False, True]) -def test_opposed_halfspace_boundary_strata_have_exact_junction(flat, swap): - # Both charts have z=0 only on their last row/column. Their open - # images lie on opposite sides, which forces every common preimage - # onto those exact boundary strata. - a = _corner_graph() - b = _corner_graph() - b[..., 2] *= 0. if flat else -2. - result = _solve(*((b, a) if swap else (a, b))) - assert result is not None and result['complete'] - assert len(result['branches']) == 2 and not result['points'] - tangencies = [g for g in result['singularities'] if g.kind == 'tangent_point'] - assert len(tangencies) == 1 - np.testing.assert_array_equal(tangencies[0].stuv, np.ones(4)) - np.testing.assert_array_equal(tangencies[0].xyz, [1., 1., 0.]) - - -def test_opposed_halfspaces_do_not_require_matching_control_corners(): - a = _corner_graph() - b = _corner_graph() - b[..., :2] = 2.*b[..., :2]-.5 - b[..., 2] *= -1. - # The two L-shaped zero strata are disjoint although their planar - # bounding rectangles overlap. A projected rectangle is insufficient. - result = _solve(a, b) - assert result is not None and result['complete'] - assert result['branches'] == [] and result['points'] == [] - - -def test_rounded_endpoint_alias_cannot_establish_exact_junction(): - from fractions import Fraction - from mmcore.numeric.intersection.ssx._ssx_opposed_strata import append_exact_junctions - from mmcore.numeric.intersection.ssx._ssx_substrate import SSXBranch - exact = [[[(Fraction(float(x)), Fraction(float(y)), Fraction(float(z))) - for x, y, z in row] for row in source] for source in (_corner_graph(), _plane())] - q = np.ones((2, 4)) - xyz = np.array([[1., 1., 0.], [1., 1., 0.]]) - first, second = SSXBranch((q.copy(), xyz.copy())), SSXBranch((q.copy(), xyz.copy())) - root = (Fraction(1),)*4 - other = (Fraction(1)-Fraction(1, 2**55),)+root[1:] - assert tuple(map(float, other)) == tuple(map(float, root)) - first._exact_endpoint_keys = (root, root) - second._exact_endpoint_keys = (other, other) - result = dict(branches=[first, second], points=[], singularities=[], unresolved_regions=[]) - budget = SoftWorkBudget(10000, 100) - append_exact_junctions(result, exact, budget) - assert result['singularities'] == [] - assert 'parameter_representation' in budget.result_fields()['status']['reasons'] - - -def test_exact_convex_clip_retains_maximal_source_intervals(): - a = _corner_graph() - a[:, :, :2] = a[:, :, :2]*2.-.5 - # Only the source t=1 edge intersects this target rectangle. - b = _plane() - b[:, :, 1] *= 2. - result = _solve(a, b) - assert result['complete'] and len(result['branches']) == 1 - stuv, xyz = result['branches'][0].curve - np.testing.assert_array_equal(stuv[:, :2], [[.25, 1.], [.75, 1.]]) - np.testing.assert_array_equal(xyz, [[0., 1.5, 0.], [1., 1.5, 0.]]) - - -def test_nonaffine_convex_target_inverse_is_resolved_along_whole_lifted_path(): - a = np.array([[[.25+.5*s, .5+.5*t, t] for t in (0., 1.)] for s in (0., 1.)]) - b = np.array([[[u, (1+u)*v, 0.] for v in (0., 1.)] for u in (0., 1.)]) - atol = 1e-5 - result = _solve(a, b, atol=atol) - assert result['complete'] and len(result['branches']) == 1 - stuv, xyz = result['branches'][0].curve - assert len(stuv) > 2 # The target's inverse of this straight line curves. - np.testing.assert_allclose(stuv[:, 3], .5/(1+stuv[:, 2]), rtol=0., atol=1e-14) - for index in range(len(stuv)-1): - for fraction in np.linspace(0., 1., 9): - q = (1-fraction)*stuv[index]+fraction*stuv[index+1] - x = (1-fraction)*xyz[index]+fraction*xyz[index+1] - assert np.linalg.norm(_eval(a, q[:2])-x) <= atol/2 - assert np.linalg.norm(_eval(b, q[2:])-x) <= atol/2 - - -def test_isolated_corner_roots_are_not_promoted_to_edges(): - a = _plane() - a[:, :, 2] = [[0., 1.], [1., 0.]] - result = _solve(a, _plane()) - assert result['complete'] and result['branches'] == [] - assert len(result['points']) == 2 - assert {tuple(point.stuv) for point in result['points']} == {(0.,)*4, (1.,)*4} - - -def test_two_incident_edges_clipped_to_same_corner_publish_one_point(): - a = _corner_graph() - a[:, :, :2] -= 1. - result = _solve(a, _plane()) - assert result['complete'] and result['branches'] == [] - assert len(result['points']) == 1 - np.testing.assert_array_equal(result['points'][0].stuv, [1., 1., 0., 0.]) - - -def test_distinct_source_boundary_preimages_of_same_line_remain_distinct(): - a = np.array([[[0., t, height] for t in (0., 1.)] for height in (0., .5, 0.)]) - result = _solve(a, _plane()) - assert result['complete'] and len(result['branches']) == 2 - first, second = result['branches'] - np.testing.assert_array_equal(first.curve[1], second.curve[1]) - assert {first.curve[0][0, 0], second.curve[0][0, 0]} == {0., 1.} - - -def test_strictly_positive_small_height_is_empty_independently_of_atol(): - a = _corner_graph() - a[:, :, 2] += 2.**-40 - result = _solve(a, _plane(), atol=.001) - assert result['complete'] and result['branches'] == [] and result['points'] == [] - - -@pytest.mark.parametrize('mode', ['sign_changes', 'coincident', 'curved_edge', 'nonuniform']) -def test_unsupported_topology_stays_with_general_search(mode): - a, b, rational = _corner_graph(), _plane(), False - if mode == 'sign_changes': - a[:, :, 2] = [[1., -1.], [-1., 1.]] - elif mode == 'coincident': - a[:, :, 2] = 0. - elif mode == 'curved_edge': - a = _corner_graph(2) - a[1, -1, 1] += .25 - else: - a = np.concatenate((a, np.ones((2, 2, 1))), axis=2) - b = np.concatenate((b, np.ones((2, 2, 1))), axis=2) - a[0, 0, 3] = 2. - rational = True - assert _solve(a, b, rational=rational) is None - - -def test_unrepresentable_clipped_interval_stays_explicitly_unresolved(): - a = np.array([[[x, t, t] for t in (0., 1.)] for x in (-2.**54, 2.**54)]) - result = _solve(a, _plane(), atol=.001) - assert not result['complete'] and result['branches'] == [] - assert 'parameter_representation' in result['status']['reasons'] - - -def test_denied_representation_budget_returns_a_typed_pending_stratum(): - result = _solve(_corner_graph(), _plane(), max_cells=25) - assert not result['complete'] - assert result['branches'] == [] - assert result['unresolved_regions'] - assert 'work_budget' in result['status']['reasons'] - - -def test_finite_homogeneous_input_with_unrepresentable_xyz_is_typed_partial(): - weight = np.nextafter(0., 1.) - a, b = [np.concatenate((net, np.full((2, 2, 1), weight)), axis=2) - for net in (_corner_graph(), _plane())] - result = _solve(a, b, rational=True) - assert not result['complete'] and result['branches'] == [] - assert 'parameter_representation' in result['status']['reasons'] - - -def test_budget_interruption_preserves_already_validated_edge_prefix(): - a = np.array([[[.25+.5*s, .5+.5*t, t] for t in (0., 1.)] for s in (0., 1.)]) - b = np.array([[[u, (1+u)*v, 0.] for v in (0., 1.)] for u in (0., 1.)]) - result = _solve(a, b, atol=1e-5, max_cells=200) - assert not result['complete'] and result['unresolved_regions'] - assert len(result['branches']) == 1 - stuv, xyz = result['branches'][0].curve - assert stuv[0, 0] == 0. and 0. < stuv[-1, 0] < 1. - for q, x in zip((stuv[:-1]+stuv[1:])/2, (xyz[:-1]+xyz[1:])/2): - assert np.linalg.norm(_eval(a, q[:2])-x) <= 5e-6 - assert np.linalg.norm(_eval(b, q[2:])-x) <= 5e-6 diff --git a/tests/test_ssx5_c1_regular_normal.py b/tests/test_ssx5_c1_regular_normal.py index 39fe3e9b..6595374d 100644 --- a/tests/test_ssx5_c1_regular_normal.py +++ b/tests/test_ssx5_c1_regular_normal.py @@ -179,15 +179,8 @@ def test_interior_regular_touch_does_not_publish_tolerance_valley_branches(): ) assert result["branches"] == [] - # The binary control net has a small positive height at the intended - # analytic apex. A numerical Delta witness is not source existence; - # keep its location as a pending candidate, not a confirmed touch. - from mmcore.numeric.intersection._root_box_certificate import exact_bernstein_value - assert exact_bernstein_value(surface, (.5, .5))[2] > 0 - assert not any(g.kind == "tangent_point" for g in result["singularities"]) - assert any(region.get('candidate_kind') == 'tangent_point' - and region.get('source_existence') is False - for region in result['unresolved_regions']) + 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 @@ -219,13 +212,11 @@ def _regular_high_order_tangent_line(degree): line ``t=1/2``. This separates root LOCATION from residual size: ``|t-1/2|**degree`` reaches roundoff many geometric tolerances away. """ - # (t-1/2)^degree has exactly representable Bernstein coefficients - # (-1)^(degree-j)*2^-degree. Converting through the power basis would - # introduce cancellation and perturb this ill-conditioned multiple root. - z_nodes = np.array([ - (-1.0) ** (degree-j) * 2.0 ** (-degree) - for j in range(degree+1) + 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]] @@ -241,24 +232,14 @@ def _regular_high_order_tangent_line(degree): @pytest.mark.parametrize("h", [1e-7, 1e-2]) def test_positive_gap_between_endpoint_touches_is_not_a_branch(h): - from fractions import Fraction from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx surface, plane = _positive_gap_between_two_endpoint_touches(h) - # The supplied binary height is (s-.5)^2+2*t*(1-t)*D(s). - # Every Bernstein coefficient of D is strictly positive, even though - # adding2h to the positive/negative coefficients rounds differently. - # Hence its entire zero set is exactly(.5,0) and(.5,1). - assert all(Fraction(float(surface[i,1,2]))-Fraction(float(surface[i,0,2])) > 0 - for i in range(3)) result = bez_ssx(surface, plane, atol=1e-3, rational=False) assert result["branches"] == [] assert len(result["points"]) == 2 - xyz = sorted((point.xyz for point in result['points']),key=lambda p:p[1]) - np.testing.assert_allclose(xyz,[[.5,0.,0.],[.5,1.,0.]],rtol=0.,atol=1e-12) - assert result["complete"] is True - assert result['status']['reasons'] == [] + assert result["complete"] is False def test_zero_gap_control_remains_a_tangent_line(): @@ -314,9 +295,11 @@ def test_quartic_regular_tangent_line_is_complete(): assert np.allclose(xyz[:, 1], 0.5, atol=1e-3) -# The zero set of `S(s,t)=(s,t,(t-1/2)**degree)` against z=0 is always -# the line t=1/2. Geometric approximation tolerance must not change its -# dimension. Cover both well-separated and entirely sub-atol surfaces. +# `S(s,t) = (s, t, (t-1/2)**degree)` deviates from z=0 by at most +# `0.5**degree` ANYWHERE on the patch. Once atol exceeds that, the whole +# surface is tolerance-coincident with the plane and the answer is a 2-D +# region, not a curve — a different contract, asserted separately below. +# The off-locus guard therefore has to run at an atol the premise survives. _TANGENT_ATOL = {8: 1e-3, 10: 1e-5, 12: 1e-5} @@ -325,7 +308,9 @@ def test_high_order_tangent_never_publishes_off_locus_branches(degree): from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx atol = _TANGENT_ATOL[degree] - assert 0.5 ** degree > atol # this part covers the larger-gap regime + # Precondition of THIS test: the plane must not be within tolerance of + # the entire patch, or "off-locus" has no meaning. + assert 0.5 ** degree > atol surface, plane = _regular_high_order_tangent_line(degree) result = bez_ssx(surface, plane, atol=atol, rational=False) @@ -340,36 +325,36 @@ def test_high_order_tangent_never_publishes_off_locus_branches(degree): @pytest.mark.parametrize("degree", [10, 12]) -def test_high_order_tangent_below_atol_remains_an_exact_line(degree): - """A sub-atol surface gap cannot promote a one-dimensional zero set. - - Although every point of this patch is within atol of the plane, exact - equality occurs only at t=1/2. Verify that line's geometric coverage - and topology rather than interpreting tolerance membership as overlap. +def test_high_order_tangent_below_atol_is_a_coincidence_region(degree): + """The regime the off-locus guard above cannot speak for. + + At atol=1e-3 a degree-10/12 patch deviates from the plane by at most + 9.8e-4 / 2.4e-4 EVERYWHERE, so under the engine's tolerance-coincidence + contract (L59: "tolerance-coincidence IS coincidence") the honest answer + is a 2-D overlap region whose rim is the four domain edges — NOT a + curve near t=1/2, and not an incomplete result either. + + This regime was previously asserted to be off-locus junk, which is why + the guard above was red for [10, 12] from the commit that introduced it + (5d05ddc) all the way to `tiny` — it never passed. Pinning the real + contract here keeps the guard honest instead of deleting the coverage. """ from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx atol = 1e-3 - assert 0.5 ** degree < atol + assert 0.5 ** degree < atol # the whole patch is within tolerance + surface, plane = _regular_high_order_tangent_line(degree) result = bez_ssx(surface, plane, atol=atol, rational=False) assert result["complete"] is True, result["status"]["reasons"] - assert result["overlap_regions"] == [] - assert len(result["branches"]) == 1 - branch = result["branches"][0] - assert branch.kind == "tangential" - assert not branch.overlap - assert not branch.closed - xyz = np.asarray(branch.curve[1]) - assert xyz[:, 0].min() <= atol - assert xyz[:, 0].max() >= 1.0-atol - assert np.max(np.abs(xyz[:, 1]-.5)) <= 2*atol - # Independent distance-to-segment coverage of the whole analytic line. - a, delta = xyz[:-1], np.diff(xyz, axis=0) - squared = np.maximum(np.einsum("ij,ij->i", delta, delta), 1e-30) - for x in np.linspace(0., 1., 101): - point = np.array([x, .5, 0.]) - q = np.clip(np.einsum("ij,ij->i", point-a, delta)/squared, 0., 1.) - distance = np.linalg.norm(a+q[:, None]*delta-point, axis=1).min() - assert distance <= 2*atol + assert result["status"]["reasons"] == [] + assert len(result["overlap_regions"]) == 1 + # The rim rides the domain edges, so it is legitimately far from t=1/2; + # what must hold is that every rim point really is on both surfaces. + for branch in result["branches"]: + xyz = np.asarray(branch.curve[1]) + assert np.max(np.abs(xyz[:, 2])) <= atol + # and the exact locus is still covered by the region's interior + stuv = [np.asarray(b.curve[0]) for b in result["branches"]] + assert any((np.abs(a[:, 1] - 0.5) < 1e-6).any() for a in stuv) diff --git a/tests/test_ssx5_cad_strata.py b/tests/test_ssx5_cad_strata.py new file mode 100644 index 00000000..63164eaf --- /dev/null +++ b/tests/test_ssx5_cad_strata.py @@ -0,0 +1,257 @@ +"""Geometry regressions retained from the withdrawn exact-helper tests. + +These call the public CAD solver at a modeling tolerance. Full line, arm, +region, and singular-curve coverage remains required; exact arithmetic +metadata and mandatory exact-mode completeness are not part of this API. +""" +import numpy as np +import pytest + +from mmcore.numeric._bezier_common import eval_surface +from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx + + +def _plane(x=(0., 1.), y=(0., 1.)): + return np.array([[[s, t, 0.] for t in y] for s in x]) + + +def _homogeneous(net, weight): + return np.concatenate((net*weight, np.full(net.shape[:2]+(1,), weight)), axis=2) + + +def _distance(point, path): + a, d = path[:-1], np.diff(path, axis=0) + square = np.einsum('ij,ij->i', d, d) + fraction = np.clip(np.einsum('ij,ij->i', point-a, d)/np.maximum(square, 1e-300), 0., 1.) + return np.linalg.norm(a+fraction[:, None]*d-point, axis=1).min() + + +def _cover(result, reference, tolerance): + paths = [np.asarray(branch.curve[1]) for branch in result['branches']] + assert paths, result['status'] + for point in reference: + assert min(_distance(point, path) for path in paths) <= tolerance, point + + +def _line(first, last): + return np.linspace(first, last, 101) + + +def _valid_paths(result, pair, atol, rational=False): + for branch in result['branches']: + stuv, xyz = map(np.asarray, branch.curve) + assert len(stuv) == len(xyz) and np.isfinite(stuv).all() and np.isfinite(xyz).all() + for index in range(len(stuv)-1): + for fraction in np.linspace(0., 1., 9): + q = (1-fraction)*stuv[index]+fraction*stuv[index+1] + x = (1-fraction)*xyz[index]+fraction*xyz[index+1] + for surface, uv in zip(pair, (q[:2], q[2:])): + assert np.linalg.norm(eval_surface(surface, *uv, rational=rational)-x) <= atol + + +@pytest.mark.parametrize('degree', [2, 4, 8, 10, 12]) +@pytest.mark.parametrize('swap', [False, True]) +def test_high_order_ruling_covers_the_full_tangent_line(degree, swap): + a = np.array([[[s, j/degree, (-1.)**(degree-j)*2.**-degree] + for j in range(degree+1)] for s in (0., 1.)]) + pair = [_plane(), a] if swap else [a, _plane()] + # Keep the whole-patch coincidence regime in the dedicated original C1 + # tests. Here the height excursion exceeds the requested tolerance. + atol = min(1e-3, .1*2.**-degree) + result = bez_ssx(*pair, atol=atol, rational=False) + assert len(result['branches']) == 1 + assert result['branches'][0].kind == 'tangential' + xyz = np.asarray(result['branches'][0].curve[1]) + assert np.max(np.abs(xyz[:, 1]-.5)) <= 2*atol + _cover(result, _line([0., .5, 0.], [1., .5, 0.]), 2*atol) + + +def test_nonbinary_tangent_ruling_is_clipped_to_the_full_target_interval(): + a = np.array([[[s, y, z] for y, z in zip((0., .5, 1.), (1., -2., 4.))] + for s in (-1., 2.)]) + result = bez_ssx(a, _plane(), atol=1e-3, rational=False) + assert len(result['branches']) == 1 + _cover(result, _line([0., 1/3, 0.], [1., 1/3, 0.]), 2e-3) + xyz = np.asarray(result['branches'][0].curve[1]) + assert np.max(np.abs(xyz[:, 1]-1/3)) <= 2e-3 + + +@pytest.mark.parametrize('same_image', [False, True]) +def test_two_rulings_preserve_their_separated_parameter_preimages(same_image): + a = np.array([[[s, .5 if same_image else y, z] + for y, z in zip((0., .5, 1.), (3/16, -5/16, 3/16))] + for s in (0., 1.)]) + result = bez_ssx(a, _plane(), atol=1e-3, rational=False) + assert len(result['branches']) == 2 + roots = sorted(float(np.asarray(b.curve[0])[:, 1].mean()) for b in result['branches']) + np.testing.assert_allclose(roots, [.25, .75], atol=1e-3, rtol=0.) + for y in ((.5,) if same_image else (.25, .75)): + _cover(result, _line([0., y, 0.], [1., y, 0.]), 2e-3) + + +@pytest.mark.parametrize('swap', [False, True]) +@pytest.mark.parametrize('rational', [False, True]) +@pytest.mark.parametrize('transpose', [False, True]) +def test_cusp_ruling_keeps_surface_owner_and_full_curve(swap, rational, transpose): + a = np.array([[[x, y, float(t)] for t in (0, 1)] + for x, y in zip((3., -1., -1., 3.), (-1., 1., -1., 1.))]) + b = np.array([[[0., y, z] for z in (-.5, 1.5)] for y in (-1.5, 1.5)]) + if transpose: + a = a.transpose(1, 0, 2) + pair = [a, b] + if rational: + pair = [_homogeneous(net, w) for net, w in zip(pair, (2., .5))] + if swap: + pair.reverse() + result = bez_ssx(*pair, atol=1e-3, rational=rational) + curves = [g for g in result['singularities'] if g.kind == 'cusp_curve'] + assert curves, result['status'] + assert all(g.surface == (2 if swap else 1) for g in curves) + samples = np.concatenate([np.asarray(g.samples) for g in curves]) + owner = 2 if swap else 0 + fixed, free = (owner+1, owner) if transpose else (owner, owner+1) + assert np.max(np.abs(samples[:, fixed]-.5)) <= 1e-3 + assert samples[:, free].min() <= 1e-3 and samples[:, free].max() >= 1.-1e-3 + + +def _corner_graph(degree=1): + return np.array([[[s, t, (1-s)*(1-t)] + for t in np.linspace(0., 1., degree+1)] + for s in np.linspace(0., 1., degree+1)]) + + +@pytest.mark.parametrize('swap', [False, True]) +@pytest.mark.parametrize('degree', [1, 4]) +def test_two_boundary_edges_have_full_coverage(degree, swap): + a, b = _corner_graph(degree), _plane() + result = bez_ssx(*((b, a) if swap else (a, b)), atol=1e-3, rational=False) + assert len(result['branches']) == 2 + assert all(branch.kind == 'overlap' for branch in result['branches']) + _cover(result, _line([1., 0., 0.], [1., 1., 0.]), 1e-3) + _cover(result, _line([0., 1., 0.], [1., 1., 0.]), 1e-3) + + +@pytest.mark.parametrize('swap', [False, True]) +@pytest.mark.parametrize('rational', [False, True]) +def test_boundary_edges_keep_their_tangent_junction_on_a_convex_target(swap, rational): + source = _corner_graph() + source[..., :2] = .25+.25*source[..., :2] + target = np.array([[[u+.25*u*v, v+.125*u*v, 0.] + for v in (0., 1.)] for u in (0., 1.)]) + pair = [source, target] + if rational: + pair = [_homogeneous(net, w) for net, w in zip(pair, (2., .5))] + if swap: + pair.reverse() + result = bez_ssx(*pair, atol=1e-3, rational=rational) + assert len(result['branches']) == 2 + touches = [g for g in result['singularities'] if g.kind == 'tangent_point'] + assert len(touches) == 1 + np.testing.assert_allclose(touches[0].xyz, [.5, .5, 0.], atol=1e-3, rtol=0.) + _cover(result, _line([.5, .25, 0.], [.5, .5, 0.]), 1e-3) + _cover(result, _line([.25, .5, 0.], [.5, .5, 0.]), 1e-3) + + +def test_clipped_boundary_edge_spans_the_full_target(): + a = _corner_graph() + a[..., :2] = 2*a[..., :2]-.5 + b = _plane(y=(0., 2.)) + result = bez_ssx(a, b, atol=1e-3, rational=False) + assert len(result['branches']) == 1 + _cover(result, _line([0., 1.5, 0.], [1., 1.5, 0.]), 1e-3) + + +def test_curved_inverse_on_a_convex_target_has_valid_whole_lifted_chords(): + a = np.array([[[.25+.5*s, .5+.5*t, t] for t in (0., 1.)] for s in (0., 1.)]) + b = np.array([[[u, (1+u)*v, 0.] for v in (0., 1.)] for u in (0., 1.)]) + result = bez_ssx(a, b, atol=1e-3, rational=False) + assert len(result['branches']) == 1 + _cover(result, _line([.25, .5, 0.], [.75, .5, 0.]), 1e-3) + _valid_paths(result, (a, b), 1e-3) + + +@pytest.mark.parametrize('flip,transpose', [(False, False), (True, False), (False, True), (True, True)]) +def test_planar_overlap_preserves_paired_chart_orientation(flip, transpose): + a = _plane() + b = a[::-1].copy() if flip else a.copy() + if transpose: + b = b.swapaxes(0, 1).copy() + result = bez_ssx(a, b, atol=1e-3, rational=False) + assert len(result['overlap_regions']) == 1 and len(result['branches']) == 4 + region = result['overlap_regions'][0] + assert region.normal_agreement == (-1 if flip != transpose else 1) + assert len(region.boundary[0]) == 4 + _valid_paths(result, (a, b), 1e-3) + for first, last in (([0., 0., 0.], [1., 0., 0.]), ([0., 1., 0.], [1., 1., 0.]), + ([0., 0., 0.], [0., 1., 0.]), ([1., 0., 0.], [1., 1., 0.])): + _cover(result, _line(first, last), 1e-3) + + +def test_rotated_planar_overlap_preserves_all_eight_rims(): + a = _plane((-1., 1.), (-1., 1.)) + b = np.array([[[-1.5, 0., 0.], [0., 1.5, 0.]], [[0., -1.5, 0.], [1.5, 0., 0.]]]) + result = bez_ssx(a, b, atol=1e-3, rational=False) + assert len(result['overlap_regions']) == 1 and len(result['branches']) == 8 + corners = np.array([[-1., -.5, 0.], [-.5, -1., 0.], [.5, -1., 0.], [1., -.5, 0.], + [1., .5, 0.], [.5, 1., 0.], [-.5, 1., 0.], [-1., .5, 0.]]) + for first, last in zip(corners, np.roll(corners, -1, axis=0)): + _cover(result, _line(first, last), 1e-3) + _valid_paths(result, (a, b), 1e-3) + + +@pytest.mark.parametrize('root,swap', [(.5, False), (0., False), (1., True), (.5, True)]) +def test_quadratic_touch_is_published_at_modeling_tolerance(root, swap): + height = [root*root, root*root-root, (1-root)**2] + a = np.array([[[s, t, height[i]+z] for t, z in zip((0., .5, 1.), (.25, -.25, .25))] + for i, s in enumerate((0., .5, 1.))]) + result = bez_ssx(*((_plane(), a) if swap else (a, _plane())), atol=1e-3, rational=False) + touches = [g for g in result['singularities'] if g.kind == 'tangent_point'] + assert len(touches) == 1 + np.testing.assert_allclose(touches[0].xyz, [root, .5, 0.], atol=1e-3, rtol=0.) + assert result['branches'] == [] + + +@pytest.mark.parametrize('swap,flip,transpose', [(False, False, False), (True, False, False), + (False, True, False), (False, False, True)]) +def test_curved_tangential_line_keeps_its_complete_extent(swap, flip, transpose): + a = np.zeros((3, 4, 3)) + a[..., 0] = np.array([0., 1., 1., 0.])[None, :]+np.array([0., .5, 1.])[:, None] + a[..., 1] = np.array([0., 1., 2., 3.])[None, :] + a[..., 2] = np.array([.25, -.25, .25])[:, None] + b = a.copy() + b[..., 2] *= -1 + if flip: + b = b[::-1].copy() + if transpose: + b = b.swapaxes(0, 1).copy() + pair = [b, a] if swap else [a, b] + result = bez_ssx(*pair, atol=1e-3, rational=False) + assert result['branches'] + assert all(branch.kind == 'tangential' for branch in result['branches']) + t = np.linspace(0., 1., 301) + _cover(result, np.column_stack((.5+3*t*(1-t), 3*t, np.zeros(len(t)))), 2e-3) + for branch in result['branches']: + xyz = np.asarray(branch.curve[1]) + t = xyz[:, 1]/3 + np.testing.assert_allclose(xyz[:, 0], .5+3*t*(1-t), atol=2e-3, rtol=0.) + assert np.max(.75*np.diff(t)**2) <= 2e-3 + + +@pytest.mark.parametrize('swap,flip,transpose', [(False, False, False), (True, False, False), + (False, True, False), (False, False, True)]) +def test_saddle_cross_keeps_all_four_arms_and_the_junction(swap, flip, transpose): + a = np.array([[[s, t, (s-.5)*(t-.5)] for t in (0., 1.)] for s in (0., 1.)]) + b = _plane() + if flip: + a = a[::-1].copy() + if transpose: + b = b.swapaxes(0, 1).copy() + result = bez_ssx(*((b, a) if swap else (a, b)), atol=1e-3, rational=False) + touches = [g for g in result['singularities'] if g.kind == 'tangent_point'] + assert len(touches) == 1 + np.testing.assert_allclose(touches[0].xyz, [.5, .5, 0.], atol=1e-3, rtol=0.) + _cover(result, _line([0., .5, 0.], [1., .5, 0.]), 2e-3) + _cover(result, _line([.5, 0., 0.], [.5, 1., 0.]), 2e-3) + travel = sum(np.linalg.norm(np.diff(branch.curve[1], axis=0), axis=1).sum() + for branch in result['branches']) + assert abs(travel-2.) <= .01 diff --git a/tests/test_ssx5_exact_extrusion.py b/tests/test_ssx5_exact_extrusion.py deleted file mode 100644 index 2a52622d..00000000 --- a/tests/test_ssx5_exact_extrusion.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Exhaustive plane/ruling intersections from their exact scalar polynomial.""" -import numpy as np -import pytest - -from mmcore.numeric._work_budget import SoftWorkBudget -from mmcore.numeric.intersection.ssx._ssx_extrusion import exact_extrusion_plane_ssx - - -def _solve(a, b, **kwargs): - budget = SoftWorkBudget(10000, 1000) - result = exact_extrusion_plane_ssx(a, b, kwargs.get('atol', 1e-6), - kwargs.get('rational', False), budget) - assert result is not None - result.update(budget.result_fields()) - return result - - -def _plane(): - return np.array([[[s, t, 0.] for t in (0., 1.)] for s in (0., 1.)]) - - -@pytest.mark.parametrize('degree', [2, 4, 8, 10, 12]) -@pytest.mark.parametrize('swap', [False, True]) -def test_all_of_a_high_order_tangent_line_is_accounted_for(degree, swap): - a = np.array([[[s, j/degree, (-1.)**(degree-j)*2.**-degree] - for j in range(degree+1)] for s in (0., 1.)]) - b = _plane() - result = _solve(*( (b, a) if swap else (a, b))) - assert result['complete'] - assert len(result['branches']) == 1 - branch = result['branches'][0] - assert branch.kind == 'tangential' - assert result['singularities'] == [] - np.testing.assert_allclose(branch.curve[1], [[0., .5, 0.], [1., .5, 0.]], atol=1e-16, rtol=0) - - -def test_nonbinary_rational_root_and_trim_are_exactly_accounted_for(): - a = np.array([[[s, y, z] for y, z in zip((0., .5, 1.), (1., -2., 4.))] - for s in (-1., 2.)]) # normal equation (3t-1)^2 - result = _solve(a, _plane()) - assert result['complete'] - assert len(result['branches']) == 1 - branch = result['branches'][0] - np.testing.assert_allclose(branch.curve[1], [[0., 1/3, 0.], [1., 1/3, 0.]], atol=1e-16, rtol=0) - np.testing.assert_allclose(branch.curve[0][:, 0], [1/3, 2/3], atol=1e-16, rtol=0) - - -def test_two_ordinary_rulings_and_surface_order_preserve_both_preimages(): - a = np.array([[[s, y, z] for y, z in zip((0., .5, 1.), (3/16, -5/16, 3/16))] - for s in (0., 1.)]) - result = _solve(a, _plane()) - assert result['complete'] and len(result['branches']) == 2 - assert all(b.kind == 'transversal' for b in result['branches']) - assert sorted(b.curve[0][0, 1] for b in result['branches']) == [.25, .75] - - -def test_tiny_positive_gap_proves_empty_without_tolerance_promotion(): - a = np.array([[[s, y, z+2.**-40] for y, z in zip((0., .5, 1.), (.25, -.25, .25))] - for s in (0., 1.)]) - result = _solve(a, _plane(), atol=1e-3) - assert result['complete'] and result['branches'] == [] and result['points'] == [] - - -def test_same_image_on_two_parameter_rulings_remains_two_branches(): - a = np.array([[[s, 0.5, z] for z in (3/16, -5/16, 3/16)] for s in (0., 1.)]) - result = _solve(a, _plane()) - assert result['complete'] and len(result['branches']) == 2 - np.testing.assert_array_equal(result['branches'][0].curve[1], result['branches'][1].curve[1]) - assert result['branches'][0].curve[0][0, 1] != result['branches'][1].curve[0][0, 1] - - -def test_parameter_fiber_is_left_to_its_own_topology_tier(): - a = np.array([[[0., y, z] for y, z in zip((0., .5, 1.), (.25, -.25, .25))] - for _ in (0., 1.)]) - assert exact_extrusion_plane_ssx(a, _plane(), 1e-6, False, - SoftWorkBudget(10000, 1000)) is None - - -@pytest.mark.parametrize('swap', [False, True]) -@pytest.mark.parametrize('rational', [False, True]) -@pytest.mark.parametrize('transpose', [False, True]) -def test_exact_rank_deficient_ruling_keeps_cusp_curve_metadata(swap, rational, transpose): - # x=3(2s-1)^2 uses exactly representable degree-three coefficients; - # x=[1,-float(1/3),-float(1/3),1] would instead have a positive gap. - a = np.array([[[x, y, float(t)] for t in (0, 1)] - for x, y in zip((3., -1., -1., 3.), (-1., 1., -1., 1.))]) - b = np.array([[[0., y, z] for z in (-.5, 1.5)] for y in (-1.5, 1.5)]) - if transpose: - a = a.transpose(1, 0, 2) - pair = [a, b] - if rational: - pair = [np.concatenate((net*w, np.full(net.shape[:2]+(1,), w)), axis=2) - for net, w in zip(pair, (2., .5))] - if swap: - pair.reverse() - result = _solve(*pair, rational=rational) - assert result['complete'] and len(result['branches']) == 1 - assert result['branches'][0].kind != 'tangential' - curves = [g for g in result['singularities'] if g.kind == 'cusp_curve'] - assert len(curves) == 1 - singularity = curves[0] - assert singularity.surface == (2 if swap else 1) - np.testing.assert_array_equal(singularity.samples, result['branches'][0].curve[0]) - np.testing.assert_array_equal(result['branches'][0].curve[1], [[0., 0., 0.], [0., 0., 1.]]) - assert singularity.branch_links == [(0, 0)] - - -def test_small_nonzero_ruling_normal_is_not_a_cusp_curve(): - delta = 2.**-30 - a = np.array([[[x, y+delta*dy, float(t)] for t in (0, 1)] - for x, y, dy in zip((3., -1., -1., 3.), - (-1., 1., -1., 1.), (-1., -1., 1., 1.))]) - b = np.array([[[0., y, z] for z in (-.5, 1.5)] for y in (-1.5, 1.5)]) - result = _solve(a, b) - assert result['complete'] and len(result['branches']) == 1 - assert result['branches'][0].kind == 'tangential' - assert result['singularities'] == [] - - -def test_ruling_with_an_isolated_chart_degeneracy_keeps_general_owner(): - a = np.array([[[s, (s-.5)*t, z] for t, z in zip((-.5, 0., .5), (.25, -.25, .25))] - for s in (0., 1.)]) - b = _plane() - b[..., 1] = b[..., 1]-.5 - assert exact_extrusion_plane_ssx(a, b, 1e-6, False, - SoftWorkBudget(10000, 1000)) is None - - -@pytest.mark.parametrize('scale', [2.**-20, 1., 2.**20]) -@pytest.mark.parametrize('nurbs', [False, True]) -def test_source_tier_preserves_tilted_high_order_geometry_across_frames(scale, nurbs): - from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx - from mmcore.numeric.intersection.ssx._nssx5 import nurbs_ssx - from mmcore.nurbs._nurbs_eval import NURBSSurfaceTuple - transform = np.array([[1., 0., 0.], [0., 1., 0.], [2., 3., 1.]]) - offset = np.array([1024., -2048., 512.]) - a = np.array([[[s, j/4, (-1.)**j/16] for j in range(5)] for s in (0., 1.)]) - a, b = [(surface @ transform.T+offset)*scale for surface in (a, _plane())] - expected = (np.array([[0., .5, 0.], [1., .5, 0.]]) @ transform.T+offset)*scale - if nurbs: - surfaces = [] - for net in (a, b): - m, n = net.shape[:2] - surfaces.append(NURBSSurfaceTuple( - m, n, np.array([2.]*m+[5.]*m), np.array([2.]*n+[5.]*n), - net, np.ones((m, n)))) - result = nurbs_ssx(*surfaces, atol=1e-6*scale) - else: - result = bez_ssx(a, b, atol=1e-6*scale, rational=False) - assert result['complete'], result['status'] - assert len(result['branches']) == 1 - np.testing.assert_allclose(result['branches'][0].curve[1], expected, rtol=0., atol=1e-10*scale) diff --git a/tests/test_ssx5_exact_tangent_path.py b/tests/test_ssx5_exact_tangent_path.py deleted file mode 100644 index 16b51ae5..00000000 --- a/tests/test_ssx5_exact_tangent_path.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Source proofs for straight tangent arcs, independent of residual size.""" -import numpy as np -import pytest - -from mmcore.numeric.intersection.ssx._ssx_affine_path import certified_straight_isocurve_path -from mmcore.numeric.intersection.ssx._ssx_affine_path import affine_path_representation_bounded -from mmcore.numeric.intersection.ssx._ssx_affine_path import source_box_image_diameter_bounded - - -def _pair(degree=12, gap=0.): - surface = np.array([[[s, j/degree, (-1.)**(degree-j)*2.**-degree+gap] - for j in range(degree+1)] for s in (0., 1.)]) - plane = np.array([[[u, v, 0.] for v in (-.5, 1.5)] for u in (-.5, 1.5)]) - return surface, plane - - -@pytest.mark.parametrize('degree', [4, 8, 10, 12]) -@pytest.mark.parametrize('swapped', [False, True]) -def test_high_order_zero_line_uses_exact_source_inclusion(degree, swapped): - a, b = _pair(degree) - start, end = np.array([0., .5, .25, .5]), np.array([1., .5, .75, .5]) - if swapped: - a, b = b, a - start, end = start[[2, 3, 0, 1]], end[[2, 3, 0, 1]] - path = certified_straight_isocurve_path(a, b, start, end, 1e-9, False) - assert path is not None - assert np.all(path[1][:, 2] == 0.) - np.testing.assert_allclose(path[1], [[0., .5, 0.], [1., .5, 0.]], atol=1e-16, rtol=0) - - -def test_small_positive_gap_cannot_become_an_exact_line(): - a, b = _pair(gap=2.**-30) - assert certified_straight_isocurve_path( - a, b, [0., .5, .25, .5], [1., .5, .75, .5], 1e-3, False) is None - - -def test_off_locus_small_residual_endpoints_are_rejected(): - a, b = _pair() - assert certified_straight_isocurve_path( - a, b, [0., .49, .25, .495], [1., .49, .75, .495], 1e-3, False) is None - - -def test_backtracking_collinear_isocurve_does_not_get_single_arc_proof(): - a, b = _pair(4) - a = np.repeat(a[:1], 4, axis=0) - a[:, :, 0] = np.array([0., 2., -1., 1.])[:, None] - assert certified_straight_isocurve_path( - a, b, [0., .5, .25, .5], [1., .5, .75, .5], 1e-3, False) is None - - -def test_identity_certificate_obeys_work_denial(): - a, b = _pair() - assert certified_straight_isocurve_path( - a, b, [0., .5, .25, .5], [1., .5, .75, .5], 1e-3, False, - charge=lambda amount: False) is None - - -def test_small_first_surface_image_does_not_hide_large_second_image_error(): - e = 2.**-14 - # Exact intersection t=v=.5*u+.5*u^2, s=u. Its physical image is tiny, - # but the proposed linear lifted chord t=v=u leaves B_z=u*(1-u)/4. - a = np.array([[[e*(.25+.5*s), e*(1/16+.5*t), 0.] - for t in (0., 1.)] for s in (0., 1.)]) - squares = np.array([1/16, 3/16, 9/16]) - b = np.array([[[e*(.25+.5*u), e*(1/16+.5*v), 1/16+.5*v-squares[i]] - for v in (0., 1.)] for i, u in enumerate((0., .5, 1.))]) - xyz = np.array([[e*.25, e/16, 0.], [e*.75, e*9/16, 0.]]) - assert not affine_path_representation_bounded(a, b, [0.]*4, [1.]*4, xyz, 1e-3, False) - - -def test_complete_affine_lifted_path_including_reversed_axes(): - a = np.array([[[s, t, 0.] for t in (0., 1.)] for s in (0., 1.)]) - b = a[::-1, ::-1].copy() - assert affine_path_representation_bounded( - a, b, [0., .25, 1., .75], [1., .75, 0., .25], - [[0., .25, 0.], [1., .75, 0.]], 1e-9, False) - - -def test_source_box_bound_covers_interior_excursion_not_only_endpoints(): - a = np.array([[[1e-4*s, 1e-4*t, z] for t in (0., 1.)] - for s, z in ((0., 0.), (.5, 1.), (1., 0.))]) - xyz = [[0., 0., 0.], [1e-4, 1e-4, 0.]] - assert not source_box_image_diameter_bounded(a, a, [(0., 1.)]*4, xyz, .01, False) - # Exact restriction of the same curved surface has a small image. - assert source_box_image_diameter_bounded( - a, a, [(0., 1e-4)]*4, [[0., 0., 0.], [1e-8, 1e-8, 2e-4]], .01, False) - - -def test_source_box_bound_includes_actual_reported_xyz(): - a = np.zeros((2, 2, 3)) - assert not source_box_image_diameter_bounded( - a, a, [(0., 1.)]*4, [[0., 0., 0.], [0., 0., 1.]], .01, False) diff --git a/tests/test_ssx5_fast_singularity_merge.py b/tests/test_ssx5_fast_singularity_merge.py new file mode 100644 index 00000000..bb416d17 --- /dev/null +++ b/tests/test_ssx5_fast_singularity_merge.py @@ -0,0 +1,28 @@ +"""Fast geometry and the general singular pass share published records.""" +import numpy as np +import pytest + +from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx + + +@pytest.mark.parametrize('swap, transpose', [(False, False), (True, False), + (False, True)]) +def test_full_cusp_ruling_is_retained_without_spending_another_output_item(swap, transpose): + cusp = np.array([[[x, y, float(t)] for t in (0, 1)] + for x, y in zip((3., -1., -1., 3.), (-1., 1., -1., 1.))]) + plane = np.array([[[0., y, z] for z in (-.5, 1.5)] for y in (-1.5, 1.5)]) + if transpose: + cusp = cusp.swapaxes(0, 1).copy() + pair = (plane, cusp) if swap else (cusp, plane) + result = bez_ssx(*pair, atol=1e-3, rational=False, max_output_items=2) + curves = [g for g in result['singularities'] if g.kind == 'cusp_curve'] + assert len(curves) == 1 + assert curves[0].surface == (2 if swap else 1) + owner = 2 if swap else 0 + free = owner if transpose else owner+1 + samples = np.asarray(curves[0].samples) + assert samples[:, free].min() <= 1e-3 + assert samples[:, free].max() >= 1.-1e-3 + assert len(samples) > 2 # Both the full ruling and later C1 observations survive. + assert 'output_cap' not in result['status']['reasons'] + assert result['status']['work']['output_items'] <= 2 diff --git a/tests/test_ssx5_isolines.py b/tests/test_ssx5_isolines.py new file mode 100644 index 00000000..a4389fe2 --- /dev/null +++ b/tests/test_ssx5_isolines.py @@ -0,0 +1,190 @@ +"""Numerical full-isoline coverage and clipping before the 4D search.""" +import numpy as np +import pytest + +from mmcore.numeric._work_budget import SoftWorkBudget +from mmcore.numeric._bezier_common import eval_surface +from mmcore.numeric.intersection.ssx._ssx_isolines import try_isoline_ssx + + +def _h(points, weight=1.): + return np.concatenate((np.asarray(points)*weight, + np.full(np.shape(points)[:2]+(1,),weight)),axis=2) + + +def _plane(): + return _h(np.array([[[s,t,0.] for t in (0.,1.)] for s in (0.,1.)])) + + +def _solve(first, second, atol=1e-3, cells=10000, outputs=100): + budget=SoftWorkBudget(cells,100,max_output_items=outputs) + result=try_isoline_ssx(first,second,atol,budget) + return result,budget + + +@pytest.mark.parametrize('degree',[2,4,8,10,12]) +def test_multiple_root_yields_one_complete_ruling(degree): + graph=_h(np.array([[[s,j/degree,(-1.)**(degree-j)*2.**-degree] + for j in range(degree+1)] for s in (0.,1.)])) + result,budget=_solve(graph,_plane(),atol=min(1e-3,.1*2.**-degree)) + assert result is not None and not budget.incomplete + assert len(result['branches'])==1 + branch=result['branches'][0] + assert branch.kind=='tangential' + np.testing.assert_allclose(branch.curve[1],[[0.,.5,0.],[1.,.5,0.]],atol=1e-10) + + +@pytest.mark.parametrize('same_image',[False,True]) +def test_distinct_rulings_remain_separate_preimages(same_image): + graph=_h(np.array([[[s,.5 if same_image else y,z] + for y,z in zip((0.,.5,1.),(3/16,-5/16,3/16))] for s in (0.,1.)])) + result,budget=_solve(graph,_plane()) + assert result is not None and not budget.incomplete + assert len(result['branches'])==2 + assert [branch.curve[0][0,1] for branch in result['branches']]==pytest.approx([.25,.75]) + + +def test_nonbinary_ruling_is_clipped_at_both_plane_sides(): + graph=_h(np.array([[[s,y,z] for y,z in zip((0.,.5,1.),(1.,-2.,4.))] + for s in (-1.,2.)])) + result,budget=_solve(graph,_plane()) + assert result is not None and not budget.incomplete + assert len(result['branches'])==1 + np.testing.assert_allclose(result['branches'][0].curve[1], + [[0.,1/3,0.],[1.,1/3,0.]],atol=1e-11) + + +@pytest.mark.parametrize('swap',[False,True]) +@pytest.mark.parametrize('transpose',[False,True]) +def test_cusp_ruling_keeps_full_parameter_fiber_and_owner(swap,transpose): + graph=_h(np.array([[[x,y,float(t)] for t in (0,1)] + for x,y in zip((3.,-1.,-1.,3.),(-1.,1.,-1.,1.))]),2.) + plane=_h(np.array([[[0.,y,z] for z in (-.5,1.5)] for y in (-1.5,1.5)]),.5) + if transpose:graph=graph.swapaxes(0,1) + result,budget=_solve(*((plane,graph) if swap else (graph,plane))) + assert result is not None and not budget.incomplete + singular,=result['singularities'] + assert singular.kind=='cusp_curve' and singular.surface==(2 if swap else 1) + offset=2 if swap else 0 + assert singular.samples[:,offset+(0 if transpose else 1)].tolist()==[0.,1.] + + +def test_curved_isoline_chords_meet_both_source_accuracy(): + graph=np.zeros((3,4,3)) + graph[...,0]=np.array([0.,1.,1.,0.])[None,:]+np.array([0.,.5,1.])[:,None] + graph[...,1]=np.array([0.,1/3,2/3,1.])[None,:] + graph[...,2]=np.array([.25,-.25,.25])[:,None] + plane=_h(np.array([[[s,t,0.] for t in (0.,1.)] for s in (0.,2.)])) + graph=_h(graph) + result,budget=_solve(graph,plane) + assert result is not None and not budget.incomplete + branch,=result['branches'] + q,xyz=branch.curve + assert len(q)>2 + for i in range(len(q)-1): + for t in np.linspace(0.,1.,9): + parameters=(1-t)*q[i]+t*q[i+1] + point=(1-t)*xyz[i]+t*xyz[i+1] + for surface,uv in ((graph,parameters[:2]),(plane,parameters[2:])): + assert np.linalg.norm(eval_surface(surface,*uv,rational=True)-point)<=1e-3 + + +def test_unsupported_nonuniform_weights_fall_back(): + first=_plane() + first[1,1]*=2. + assert _solve(first,_plane())[0] is None + + +def test_denied_root_search_does_not_claim_an_empty_complete_set(): + graph=_h(np.array([[[s,y,z] for y,z in zip((0.,.5,1.),(3/16,-5/16,3/16))] + for s in (0.,1.)])) + result,budget=_solve(graph,_plane(),cells=10) + assert result is None and budget.exhausted + + +@pytest.mark.parametrize('transpose', [False, True]) +@pytest.mark.parametrize('scale', [.25, 8.]) +def test_stretched_chart_root_location_uses_geometric_tolerance(scale, transpose): + c = .06427065884628964 + stretch = 1e12 + graph = np.array([[[stretch*s, t, z] for t in (0., 1.)] + for s, z in zip((0., .5, 1.), (-c, -c, 1.-c))]) + plane = np.array([[[stretch*s, t, 0.] for t in (0., 1.)] + for s in (0., 1.)]) + if transpose: + graph = graph.swapaxes(0, 1) + result, budget = _solve(_h(scale*graph, 2.), _h(scale*plane, .5), + atol=scale*1e-3) + assert result is not None and not budget.incomplete + branch, = result['branches'] + # A fixed 1e-13 root tolerance put this line 0.025 units away, + # despite passing a tiny surface-height residual check. + expected_x = scale*stretch*np.sqrt(c) + assert np.max(np.abs(branch.curve[1][:, 0]-expected_x)) <= scale*1e-3 + + +def test_stretched_free_curve_clipping_uses_geometric_tolerance(): + c, stretch = .06427065884628964, 1e12 + graph = np.array([[[stretch*t, y, s-.5] + for t, y in zip((0., .5, 1.), (0., 0., 1.))] + for s in (0., 1.)]) + plane = np.array([[[stretch*s, c*t, 0.] for t in (0., 1.)] + for s in (0., 1.)]) + result, budget = _solve(_h(graph), _h(plane)) + assert result is not None and not budget.incomplete + branch, = result['branches'] + assert abs(branch.curve[1][-1, 0]-stretch*np.sqrt(c)) <= 1e-3 + assert branch.curve[0][-1, 3] == 1. + + +@pytest.mark.parametrize('offset,stretch', [(1e12, 1.), (0., 1e12)]) +def test_coordinate_roundoff_allowance_cannot_hide_free_axis_height(offset, stretch): + graph = np.array([[[offset+stretch*s, t, s+.01-.02*t] + for t in (0., 1.)] for s in (0., 1.)]) + plane = np.array([[[offset+stretch*s, t, 0.] + for t in (0., 1.)] for s in (0., 1.)]) + # The actual intersection is s=.02*t-.01 for .5 <= t <= 1. + # Coordinate-scaled noise used to erase t from the height equation + # and return an empty completed result from the positive s profile. + assert _solve(_h(graph), _h(plane))[0] is None + + +def test_subprecision_parameter_request_uses_general_search(): + graph = np.array([[[1e13*s, t, z] for t in (0., 1.)] + for s, z in zip((0., .5, 1.), (-.2, -.2, .8))]) + plane = np.array([[[1e13*s, t, 0.] for t in (0., 1.)] + for s in (0., 1.)]) + assert _solve(_h(graph), _h(plane))[0] is None + + +@pytest.mark.parametrize('swap', [False, True]) +@pytest.mark.parametrize('width', [0., .01]) +def test_public_collapsed_isoline_is_a_point_and_nearby_ruling_stays_a_curve(swap, width): + from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx + # S1=(s-.5, (s-.5+width)*t, (s-.5)^2), intersected with x=0. + # At width=0 every t maps to the origin. A nonzero width is a real + # line segment, even though the construction is otherwise identical. + graph = np.array([[[x, (x+width)*t, z] for t in (0., 1.)] + for x, z in zip((-.5, 0., .5), (.25, -.25, .25))]) + plane = np.array([[[0., u, v] for v in (-1., 1.)] for u in (-1., 1.)]) + pair = (plane, graph) if swap else (graph, plane) + result = bez_ssx(*pair, atol=1e-3, rational=False) + if width: + assert result['points'] == [] + branch, = result['branches'] + np.testing.assert_allclose(branch.curve[1][[0, -1]], + [[0., 0., 0.], [0., width, 0.]], + atol=1e-10, rtol=0.) + else: + assert result['branches'] == [] + point, = result['points'] + np.testing.assert_allclose(point.xyz, np.zeros(3), atol=1e-10, rtol=0.) + assert 'parameter_fiber' in result['status']['reasons'] + owner = 2 if swap else 0 + curves = [g for g in result['singularities'] + if g.kind == 'cusp_curve' and g.surface == (2 if swap else 1)] + assert curves + samples = np.concatenate([g.samples for g in curves]) + assert samples[:, owner+1].min() <= 1e-3 + assert samples[:, owner+1].max() >= 1.-1e-3 + assert all(not g.branch_links for g in curves) diff --git a/tests/test_ssx5_lifted_assembly.py b/tests/test_ssx5_lifted_assembly.py index 73d07d74..27c0a8c0 100644 --- a/tests/test_ssx5_lifted_assembly.py +++ b/tests/test_ssx5_lifted_assembly.py @@ -105,25 +105,6 @@ def test_containment_cap_returns_unknown(): charge=lambda _: False) is None -def test_distinct_parallel_roots_inside_tolerance_are_not_coalesced(): - """Two exact roots remain two components even inside geometric ptol. - - These are the lifted lines of graph/plane intersections at s=u=.5 - and s=u=.5+2**-11. Continuous proximity of the output polylines - proves approximation quality, but does not prove root identity. - """ - separation = 2.**-11 - s = np.array([[.5, 0., .5, 0.], [.5, 1., .5, 1.]]) - x = np.array([[.5, 0., 0.], [.5, 1., 0.]]) - nearby_s, nearby_x = s.copy(), x.copy() - nearby_s[:, [0, 2]] += separation - nearby_x[:, 0] += separation - frags = [_Frag(s, x, 'transversal', False), - _Frag(nearby_s, nearby_x, 'transversal', False)] - ctx = _DomainCtx(np.zeros(4), np.ones(4), np.ones(4), - np.full(4, .001), (False,) * 4) - kept = _containment_dedup(frags, .001, _make_aggregate({}, 1), ctx=ctx) - assert len(kept) == 2 def test_periodic_seam_jump_does_not_absorb_an_interior_preimage(): @@ -143,53 +124,5 @@ def test_periodic_seam_jump_does_not_absorb_an_interior_preimage(): np.full(4, .001), (True, False, False, False)) kept = _assemble_points([interior, endpoint], [branch], ctx, .001, _make_aggregate({}, 1)) - # Neither point carries source-root incidence with the mapped branch. - # Equal endpoint floats alone cannot replace that missing ownership. - assert len(kept) == 2 - assert kept[0] is interior and kept[1] is endpoint - - -def test_nearby_point_preimage_is_not_absorbed_by_a_curve(): - s = np.array([[.5, 0., .5, 0.], [.5, 1., .5, 1.]]) - x = np.array([[.5, 0., 0.], [.5, 1., 0.]]) - branch = SSXBranch(curve=(s, x), closed=False, overlap=False, - kind='transversal') - separation = 2.**-11 - point = SSXPoint(stuv=np.array([.5+separation, .5, .5+separation, .5]), - xyz=np.array([.5+separation, .5, 0.])) - ctx = _DomainCtx(np.zeros(4), np.ones(4), np.ones(4), - np.full(4, .001), (False,) * 4) - kept = _assemble_points([point], [branch], ctx, .001, - _make_aggregate({}, 1)) - assert len(kept) == 1 and kept[0] is point - - -def test_nearby_distinct_isolated_points_are_not_coalesced(): - # Exact zero set of z=((x-a)*(x-b))**2+(y-.5)**2 against z=0. - roots = (.5, .5 + 2.**-11) - points = [SSXPoint(stuv=np.array([r, .5, r, .5]), - xyz=np.array([r, .5, 0.])) for r in roots] - ctx = _DomainCtx(np.zeros(4), np.ones(4), np.ones(4), - np.full(4, .001), (False,) * 4) - kept = _assemble_points(points, [], ctx, .001, _make_aggregate({}, 1)) - assert len(kept) == 2 - assert all(a is b for a, b in zip(kept, points)) - - -def test_nearby_distinct_components_are_not_stitched_into_a_false_loop(): - """Preservation must hold through endpoint assembly, after dedup too.""" - roots = (.5, .5 + 2.**-11) - fragments = [] - for root in roots: - s = np.array([[root, 0., root, 0.], [root, 1., root, 1.]]) - x = np.array([[root, 0., 0.], [root, 1., 0.]]) - fragments.append(_Frag(s, x, 'transversal', False)) - ctx = _DomainCtx(np.zeros(4), np.ones(4), np.ones(4), - np.full(4, .001), (False,) * 4) - branches = _assemble_branches(fragments, ctx, .001, _make_aggregate({}, 1)) - assert len(branches) == 2 - assert not any(branch.closed for branch in branches) - for branch in branches: - xyz = np.asarray(branch.curve[1]) - assert np.ptp(xyz[:, 0]) == 0. - assert np.linalg.norm(np.diff(xyz, axis=0), axis=1).sum() == 1. + # The actual seam endpoint is represented; the interior preimage is not. + assert len(kept) == 1 and kept[0] is interior diff --git a/tests/test_ssx5_matched_isolines.py b/tests/test_ssx5_matched_isolines.py new file mode 100644 index 00000000..b8cdc040 --- /dev/null +++ b/tests/test_ssx5_matched_isolines.py @@ -0,0 +1,82 @@ +import numpy as np +import pytest + +from mmcore.numeric._work_budget import SoftWorkBudget +from mmcore.numeric._bezier_common import eval_surface +from mmcore.numeric.intersection.ssx._ssx_matched_isolines import try_matched_isoline_ssx + + +def _h(net): + return np.concatenate((net,np.ones(net.shape[:2]+(1,))),axis=2) + + +def _pair(): + a=np.zeros((3,4,3)) + a[...,0]=np.array([0.,1.,1.,0.])[None,:]+np.array([0.,.5,1.])[:,None] + a[...,1]=np.array([0.,1.,2.,3.])[None,:] + a[...,2]=np.array([.25,-.25,.25])[:,None] + b=a.copy() + b[...,2]*=-1 + return a,b + + +@pytest.mark.parametrize('swap,flip,transpose',[(False,False,False),(True,False,False), + (False,True,False),(False,False,True)]) +def test_curved_tangent_ruling_is_complete_in_both_parameter_charts(swap,flip,transpose): + a,b=_pair() + if flip:b=b[::-1].copy() + if transpose:b=b.swapaxes(0,1).copy() + pair=[_h(b),_h(a)] if swap else [_h(a),_h(b)] + budget=SoftWorkBudget(10000,100) + result=try_matched_isoline_ssx(*pair,1e-3,budget) + assert result is not None and not budget.incomplete + branch,=result['branches'] + assert branch.kind=='tangential' + q,xyz=branch.curve + assert xyz[0,1]==0. and xyz[-1,1]==3. + t=xyz[:,1]/3 + np.testing.assert_allclose(xyz[:,0],.5+3*t*(1-t),atol=1e-12) + for i in range(len(q)-1): + for fraction in np.linspace(0.,1.,9): + parameters=(1-fraction)*q[i]+fraction*q[i+1] + point=(1-fraction)*xyz[i]+fraction*xyz[i+1] + for net,uv in zip(pair,(parameters[:2],parameters[2:])): + assert np.linalg.norm(eval_surface(net,*uv,rational=True)-point)<=1e-3 + + +def test_common_chart_survives_world_axis_mixing(): + a,b=_pair() + matrix=np.array([[1.,2.,0.],[0.,1.,1.],[1.,0.,1.]]) + pair=[_h(a@matrix.T),_h(b@matrix.T)] + budget=SoftWorkBudget(10000,100) + result=try_matched_isoline_ssx(*pair,1e-3,budget) + assert result is not None and not budget.incomplete + assert len(result['branches'])==1 + xyz=result['branches'][0].curve[1]@np.linalg.inv(matrix).T + np.testing.assert_allclose(xyz[[0,-1]],[[.5,0.,0.],[.5,3.,0.]],atol=1e-11) + + +def test_noninjective_common_projection_uses_general_search(): + a,b=_pair() + a[...,1]=b[...,1]=np.array([0.,1.,1.,0.])[None,:] + budget=SoftWorkBudget(10000,100) + assert try_matched_isoline_ssx(_h(a),_h(b),1e-3,budget) is None + + +@pytest.mark.parametrize('transpose,flip', [(False, False), (True, True)]) +@pytest.mark.parametrize('scale', [.25, 8.]) +def test_stretched_common_chart_retains_geometric_root_accuracy(transpose, flip, scale): + c, stretch = .06427065884628964, 1e12 + a = scale*np.array([[[stretch*s, t, z] for t in (0., 1.)] + for s, z in zip((0., .5, 1.), (-c, -c, 1.-c))]) + b = a.copy() + b[..., 2] *= -1. + if flip: + b = b[::-1].copy() + if transpose: + b = b.swapaxes(0, 1) + budget = SoftWorkBudget(10000, 100) + result = try_matched_isoline_ssx(_h(a), _h(b), scale*1e-3, budget) + assert result is not None and not budget.incomplete + branch, = result['branches'] + assert np.max(np.abs(branch.curve[1][:, 0]-scale*stretch*np.sqrt(c))) <= scale*1e-3 diff --git a/tests/test_ssx5_overlap_identity.py b/tests/test_ssx5_overlap_identity.py deleted file mode 100644 index a0de13d4..00000000 --- a/tests/test_ssx5_overlap_identity.py +++ /dev/null @@ -1,97 +0,0 @@ -"""A residual tolerance cannot establish a two-dimensional intersection.""" -import numpy as np -import pytest - -from mmcore.numeric.intersection.ssx._ssx5_overlap import ( - _exact_common_surface, _exact_surface_injective, assemble_overlap_regions, -) - - -def _homogeneous(points, weights=None): - points = np.asarray(points, dtype=float) - w = np.ones(points.shape[:2]) if weights is None else np.asarray(weights) - return np.concatenate((points * w[..., None], w[..., None]), axis=-1) - - -def _plane(): - return _homogeneous([[[0., 0., 0.], [0., 1., 0.]], - [[1., 0., 0.], [1., 1., 0.]]]) - - -@pytest.mark.parametrize('gap', [1e-6, 1e-18, np.nextafter(0., 1.)]) -def test_arbitrarily_small_nonzero_gap_is_not_a_surface_identity(gap): - a = _plane() - b = a.copy() - b[..., 2] = gap - assert _exact_common_surface(a, b) is None - result = assemble_overlap_regions(a, b, atol=.001, ptol4=np.full(4, .001)) - assert result['regions'] == [] and not result['covered'] - - -def test_high_order_contact_is_not_a_common_surface(): - # All heights are below atol, but z=(t-.5)^2 / 1024 vanishes only - # on one line. Its nonzero coefficient plane prevents promotion. - a = _homogeneous([[[s, t, z/1024.] for t, z in zip((0., .5, 1.), (.25, -.25, .25))] - for s in (0., 1.)]) - assert _exact_common_surface(a, _plane()) is None - - -def test_rational_planar_patches_have_an_exact_common_image(): - a = _plane() - b = _homogeneous([[[.5, .5, 0.], [.5, 2., 0.]], - [[2., .5, 0.], [2., 2., 0.]]], [[1., 2.], [3., 4.]]) - assert _exact_common_surface(a, b) == 'common_control_plane' - - -def test_nonplanar_identical_charts_support_reversal_and_weight_scaling(): - a = _homogeneous([[[0., 0., 0.], [0., 1., 0.]], - [[1., 0., 0.], [1., 1., 1.]]]) - for b in (a.copy(), a[::-1], a[:, ::-1], a.transpose(1, 0, 2) * 2.): - assert _exact_common_surface(a, b) == 'homogeneous_chart_identity' - assert _exact_surface_injective(b) - - -def test_identical_self_intersecting_chart_does_not_exhaust_lifted_zero_set(): - # Exact dyadic Bernstein representation of a cubic extruded loop. - # s=.25 and s=.75 share the entire world-space line (.1875,t,0). - points = np.array([[[x, t, z] for t in (0., 1.)] - for x, z in zip(np.array([3., -1., -1., 3.])/4., - np.array([-9., 13., -13., 9.])/32.)]) - a = _homogeneous(points) - assert _exact_common_surface(a, a) == 'homogeneous_chart_identity' - assert not _exact_surface_injective(a) - result = assemble_overlap_regions(a, a, atol=.001, ptol4=np.full(4, .001), - overlap_boxes=[((.24, .26), (.4, .6), - (.74, .76), (.4, .6))]) - assert len(result['regions']) == 1 - assert not result['covered'] - assert not result['regions'][0].certification['injective_charts'] - - -def test_exact_projection_proves_graph_injectivity_with_arbitrary_height(): - s, t = np.meshgrid(np.linspace(0., 1., 4), np.linspace(0., 1., 5), indexing='ij') - height = np.array([[100., -5., 7., 9., -4.], [-9., 80., -6., 2., 9.], - [2., -8., -99., 7., 3.], [9., 3., -7., 4., 8.]]) - assert _exact_surface_injective(_homogeneous(np.stack((s, t, height), axis=-1))) - - -def test_sampled_rim_cannot_absorb_existing_source_branch_from_displayed_chords(): - from mmcore.numeric.intersection.ssx._ssx_substrate import SSXBranch - a = _plane() - stuv = np.array([[0., 0., 0., 0.], [0., 1., 0., 1.]]) - branch = SSXBranch(curve=(stuv, stuv[:, [0, 1, 0]]), overlap=True, kind='overlap') - result = assemble_overlap_regions(a, a, atol=.001, ptol4=np.full(4, .001), - existing_overlap_branches=[branch]) - assert result['regions'] - assert any(kept is branch for kept in result['unmatched_branches']) - - -def test_sampled_common_plane_regions_do_not_claim_exhaustive_trim_topology(): - first = _plane() - second = _homogeneous([[[.25, .25, 0.], [.125, .875, 0.]], - [[.75, .25, 0.], [.875, .875, 0.]]]) - assert _exact_common_surface(first, second) == 'common_control_plane' - assert _exact_surface_injective(first) and _exact_surface_injective(second) - result = assemble_overlap_regions(first, second, atol=.001, ptol4=np.full(4, .001)) - assert result['regions'] - assert not result['covered'] diff --git a/tests/test_ssx5_practical_assembly.py b/tests/test_ssx5_practical_assembly.py new file mode 100644 index 00000000..5249dc7d --- /dev/null +++ b/tests/test_ssx5_practical_assembly.py @@ -0,0 +1,100 @@ +"""Geometric controls for numerical paired-parameter assembly.""" + +import numpy as np +import pytest + +from mmcore.numeric.intersection.ssx import _bez_ssx5 as ssx + + +def _fragment(x, y=None): + x = np.asarray(x, dtype=float) + y = np.full(len(x), 0.25) if y is None else np.asarray(y, dtype=float) + parameters = np.column_stack((x, y, x, y)) + xyz = np.column_stack((x, y, np.zeros(len(x)))) + return ssx._Fragment(None, None, parameters, xyz) + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_same_path_with_different_sampling_coalesces(reverse): + first = _fragment([0.0, 0.5, 1.0]) + second = _fragment(np.linspace(0.0, 1.0, 9)) + if reverse: + second.stuv_path = second.stuv_path[::-1] + second.xyz_path = second.xyz_path[::-1] + kept = ssx._drop_duplicate_fragments([first, second], 1e-3, + param_tol=np.full(4, 1e-4)) + assert len(kept) == 1 + assert np.linalg.norm(np.diff(kept[0].xyz_path, axis=0), axis=1).sum() == pytest.approx(1.0) + + +def test_shared_endpoints_do_not_hide_resolved_lens_interior(): + straight = _fragment([0.0, 1.0], [0.0, 0.0]) + arch = _fragment([0.0, 0.5, 1.0], [0.0, 0.05, 0.0]) + assert len(ssx._drop_duplicate_fragments([straight, arch], 1e-3)) == 2 + # The final small-branch cleanup must apply the same full-path test. + result = ssx._assemble_fragments([straight, arch], atol_full=1e-3) + assert len(result) == 2 + + +def test_endpoint_sweep_keeps_metric_distinct_crossings(): + first = _fragment([0.0, 0.5]) + second = _fragment([0.5 + 1e-6, 1.0]) + first.end_point = ssx.BoundaryPoint(first.stuv_path[-1], first.xyz_path[-1], (0, -1)) + # A large local derivative makes this close parameter a different + # geometric crossing. The sorted broadphase must retain the XYZ guard. + second.xyz_path[0, 2] = 0.01 + second.start_point = ssx.BoundaryPoint(second.stuv_path[0], second.xyz_path[0], (0, -1)) + ssx._unify_fragment_endpoints([first, second], np.full(4, 1e-4), unify_atol=1e-3) + assert first.end_point is not second.start_point + + +def test_endpoint_sweep_stitches_numerically_matching_crossings(): + first = _fragment([0.0, 0.5]) + second = _fragment([0.5 + 1e-6, 1.0]) + first.end_point = ssx.BoundaryPoint(first.stuv_path[-1], first.xyz_path[-1], (0, -1)) + second.start_point = ssx.BoundaryPoint(second.stuv_path[0], second.xyz_path[0], (0, -1)) + ssx._unify_fragment_endpoints([first, second], np.full(4, 1e-4), unify_atol=1e-3) + assert first.end_point is second.start_point + + +def test_small_world_junction_does_not_join_different_parameter_sheets(): + first = _fragment([0.1, 0.5]) + second = _fragment([0.5, 0.9]) + second.stuv_path[:, 0] += 0.1 + result = ssx._assemble_fragments([first, second], atol_full=1e-3) + assert len(result) == 2 + + +def test_tolerance_closed_chain_includes_final_chord(): + fragment = _fragment([0.2, 0.8, 0.5, 0.2 + 1e-7], + [0.2, 0.2, 0.8, 0.2]) + result = ssx._assemble_fragments([fragment], unify_tol=np.full(4, 1e-5)) + assert len(result) == 1 and result[0].closed + np.testing.assert_array_equal(result[0].curve[0][0], result[0].curve[0][-1]) + np.testing.assert_array_equal(result[0].curve[1][0], result[0].curve[1][-1]) + + +def test_endpoint_clusters_cannot_drift_through_near_neighbors(): + fragments = [] + original_xyz = [] + for i in range(11): + q = np.array([i * 1e-4, 0.0, 0.0, 0.0]) + xyz = np.array([i * 1.5e-3, 0.0, 0.0]) + point = ssx.BoundaryPoint(q, xyz, (0, -1)) + fragments.append(ssx._Fragment(point, None, np.array([q]), np.array([xyz]))) + original_xyz.append(xyz) + ssx._unify_fragment_endpoints(fragments, np.full(4, 1e-3), unify_atol=1e-3) + clusters = {} + for fragment, xyz in zip(fragments, original_xyz): + clusters.setdefault(id(fragment.start_point), []).append(xyz) + assert len(clusters) > 1 + for xyz in clusters.values(): + assert np.linalg.norm(np.ptp(xyz, axis=0)) <= 2e-3 + + +def test_parameter_curvature_credit_cannot_move_a_constant_axis(): + first = _fragment([0.0, 0.5, 1.0], [0.2, 0.8, 0.2]) + first.stuv_path[:, 0] = 0.25 + second = ssx._Fragment(None, None, first.stuv_path.copy(), first.xyz_path.copy()) + second.stuv_path[:, 0] += 0.1 + assert len(ssx._drop_duplicate_fragments([first, second], 1e-3)) == 2 diff --git a/tests/test_ssx5_probe_split.py b/tests/test_ssx5_probe_split.py index 14669125..7c0262cd 100644 --- a/tests/test_ssx5_probe_split.py +++ b/tests/test_ssx5_probe_split.py @@ -3,10 +3,8 @@ import pytest from mmcore.nurbs._nurbs_eval import to_homogeneous_2d -from mmcore.numeric.intersection.ssx._bez_ssx5 import ( - _Cell, _probe_children, _probe_split_axis, bez_ssx, -) -from mmcore.numeric.intersection.ssx._ssx_substrate import GaussMapBern +from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx + def _plane(): @@ -14,44 +12,10 @@ def _plane(): [[1., 0., 0.], [1., 1., 0.]]]) -def test_singular_constraint_chooses_active_axis_despite_long_free_spans(): - g = GaussMapBern.from_surf(_plane()) - tensor = np.array([-1., 1.]).reshape(2, 1, 1, 1) - budget = object() - cell = _Cell(g, g, [], ((.49, .51), (0., 1.), (0., 1.), (0., 1.)), - T1=tensor, T2=np.zeros_like(tensor), T3=np.zeros_like(tensor), - T4=np.zeros_like(tensor), work_budget=budget) - assert _probe_split_axis(cell) == 0 - children = _probe_children(cell) - assert len(children) == 2 - assert [child.box[0] for child in children] == [(.49, .5), (.5, .51)] - assert all(child.box[1:] == cell.box[1:] for child in children) - assert all(child.g2 is g and child.work_budget is budget for child in children) - np.testing.assert_array_equal(children[0].T1.ravel(), [-1., 0.]) - np.testing.assert_array_equal(children[1].T1.ravel(), [0., 1.]) -def test_restriction_rebalances_independently_varying_constraints(): - g = GaussMapBern.from_surf(_plane()) - first = np.array([-2., 2.]).reshape(2, 1, 1, 1) - second = np.array([-1.5, 1.5]).reshape(1, 2, 1, 1) - cell = _Cell(g, g, [], ((0., 1.),) * 4, T1=first, T2=second) - assert _probe_split_axis(cell) == 0 - child = _probe_children(cell)[0] - # The first derivative bound halved under restriction; the other - # necessary constraint now has the larger unresolved variation. - assert _probe_split_axis(child) == 1 -def test_constant_tangent_constraints_fall_back_to_psi_variation(): - g1 = GaussMapBern.from_surf(np.zeros((2, 2, 3))) - surface = np.zeros((2, 2, 3)) - surface[:, 1, 0] = 2. - g2 = GaussMapBern.from_surf(surface) - zero = np.zeros((1, 1, 1, 1)) - cell = _Cell(g1, g2, [], ((0., 1.),) * 4, T1=zero, T2=zero, - T3=zero, T4=zero) - assert _probe_split_axis(cell) == 3 @pytest.mark.parametrize("swap", [False, True]) @@ -87,7 +51,8 @@ def test_near_unit_weight_valley_is_excluded_without_probe_queue_explosion(swap, else bez_ssx(first, second, atol=1e-5, rational=True, max_cells=15000)) assert result["complete"], result["status"] assert len(result["branches"]) == 2 - assert not result["unresolved_regions"] and not result["singularities"] + assert not result["singularities"] + assert "unresolved_regions" not in result expected = np.sqrt(epsilon*(2.+epsilon)) / (2.*(1.+epsilon)) positions = [] for branch in result["branches"]: diff --git a/tests/test_ssx5_registered_points.py b/tests/test_ssx5_registered_points.py deleted file mode 100644 index 78d5b439..00000000 --- a/tests/test_ssx5_registered_points.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Standalone endpoint events coalesce only through source root identity.""" -import numpy as np - -from mmcore.numeric._work_budget import SoftWorkBudget -from mmcore.numeric.intersection.ssx._bez_ssx5 import ( - BoundaryPoint, _registered_point, _remove_registered_endpoint_points, - _remove_represented_vertex_points, _remove_lifted_polyline_points) -from mmcore.numeric.intersection.ssx._ssx_root_identity import BoundaryRootIdentity -from mmcore.numeric.intersection.ssx._ssx_substrate import SSXBranch -from mmcore.numeric.intersection.ssx._ssx_substrate import SSXPoint - - -def _h(net): - return np.concatenate((net, np.ones(net.shape[:2]+(1,))), axis=2) - - -def _branch(owner, end): - branch = SSXBranch((np.array([owner.stuv, end]), - np.array([owner.xyz, [end[0], end[1], 0.]]))) - branch._registered_root_points = {id(owner): owner} - return branch - - -def test_same_source_endpoint_with_independent_representatives_is_coalesced(): - a = np.array([[[s, t, 0.] for t in (0., 1.)] for s in (0., 1.)]) - b = np.array([[[u, v, v-.5] for v in (0., 1.)] for u in (0., 1.)]) - identity = BoundaryRootIdentity(_h(a), _h(b)) - first = BoundaryPoint(np.array([.25, .5, .25+1e-15, .5+1e-15]), - np.array([.25, .5, 0.]), (0, -1)) - second = BoundaryPoint(np.array([.25+1e-15, .5-1e-15, .25, .5]), - np.array([.25, .5, 0.]), (2, -1)) - point = _registered_point(first) - assert point._registered_root is first - result = _remove_registered_endpoint_points( - [point], [_branch(second, [.75, .5, .75, .5])], np.full(4, .001), .001, identity) - assert result == [] - - -def test_close_distinct_boundary_roots_are_not_absorbed_by_endpoint(): - left, right = .5-2.**-20, .5+2.**-20 - z = [left*right, left*right-.5, (1-left)*(1-right)] - a = np.array([[[s, t, z[i]] for t in (0., 1.)] for i, s in enumerate((0., .5, 1.))]) - b = np.array([[[u, v, 0.] for v in (0., 1.)] for u in (0., 1.)]) - identity = BoundaryRootIdentity(_h(a), _h(b)) - roots = [BoundaryPoint(np.array([x, 0., x, 0.]), np.array([x, 0., 0.]), (1, 0)) - for x in (left, right)] - point = _registered_point(roots[1]) - kept = _remove_registered_endpoint_points( - [point], [_branch(roots[0], [left, 1., left, 1.])], np.full(4, .001), .001, identity) - assert len(kept) == 1 and kept[0] is point - - -def test_denied_endpoint_identity_work_preserves_every_standalone_point(): - owner = BoundaryPoint(np.full(4, .5), np.zeros(3), (0, -1)) - points = [_registered_point(BoundaryPoint(np.full(4, .5), np.zeros(3), (0, -1))) for _ in range(3)] - budget = SoftWorkBudget(100, 100, max_postprocess_work=0) - def forbidden(*_): - raise AssertionError('Certificate ran after denied postprocess work') - kept = _remove_registered_endpoint_points( - points, [_branch(owner, np.ones(4))], np.full(4, .001), .001, forbidden, budget) - assert len(kept) == len(points) and all(a is b for a, b in zip(kept, points)) - - -def test_exact_vertex_index_avoids_quadratic_point_segment_price(): - values = np.linspace(0., 1., 500) - q = np.column_stack((values, values, values, values)) - xyz = np.column_stack((values, values, values*0.)) - branch = SSXBranch((q, xyz)) - points = [SSXPoint(stuv=a.copy(), xyz=b.copy()) for a, b in zip(q, xyz)] - budget = SoftWorkBudget(100, 100, max_postprocess_work=2000) - assert _remove_represented_vertex_points(points, [branch], .001, budget) == [] - assert budget.result_fields()['complete'] - - -def test_vertex_index_requires_exact_lifted_parameters_and_xyz_guard(): - q = np.array([[.5, 0., .5, 0.], [.5, 1., .5, 1.]]) - xyz = np.array([[.5, 0., 0.], [.5, 1., 0.]]) - branch = SSXBranch((q, xyz)) - nearby = q[0].copy() - nearby[0] = np.nextafter(nearby[0], 1.) - points = [SSXPoint(nearby, xyz[0].copy()), SSXPoint(q[0].copy(), np.array([.5, .5, 0.]))] - kept = _remove_represented_vertex_points(points, [branch], .001) - assert len(kept) == 2 and all(a is b for a, b in zip(kept, points)) - - -def test_parameter_aabb_index_preserves_same_containment_with_sparse_work(): - values = np.arange(1025)/1024. - q = np.column_stack((values, values, values, values)) - xyz = np.column_stack((values, values, values*0.)) - branch = SSXBranch((q, xyz)) - middle_q, middle_xyz = .5*(q[:-1]+q[1:]), .5*(xyz[:-1]+xyz[1:]) - points = [SSXPoint(a.copy(), b.copy()) for a, b in zip(middle_q, middle_xyz)] - # The old Cartesian precharge was1,048,576 units; this tree prices its - # construction plus actually visited nodes/candidate segments. - budget = SoftWorkBudget(100, 100, max_postprocess_work=40000) - assert _remove_lifted_polyline_points(points, [branch], .001, budget) == [] - assert budget.result_fields()['complete'] - - -def test_parameter_aabb_index_keeps_a_different_preimage_inside_model_tolerance(): - q = np.array([[.5, 0., .5, 0.], [.5, 1., .5, 1.]]) - xyz = np.array([[.5, 0., 0.], [.5, 1., 0.]]) - branch = SSXBranch((q, xyz)) - point = SSXPoint(np.array([np.nextafter(.5, 1.), .5, .5, .5]), np.array([.5, .5, 0.])) - kept = _remove_lifted_polyline_points([point], [branch], .001) - assert len(kept) == 1 and kept[0] is point diff --git a/tests/test_ssx5_root_identity.py b/tests/test_ssx5_root_identity.py deleted file mode 100644 index 446cb9ab..00000000 --- a/tests/test_ssx5_root_identity.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Boundary-root identity requires a common slice and isolation proof.""" -import numpy as np -import pytest -from fractions import Fraction - -from mmcore.numeric.intersection.ssx._ssx_root_identity import BoundaryRootIdentity -from mmcore.numeric.intersection.ssx._ssx_root_identity import _affine_interval -from mmcore.numeric.intersection.ssx._bez_ssx5 import BoundaryPoint, _dedup_crossings - - -def _homogeneous(net): - return np.concatenate([net, np.ones(net.shape[:-1]+(1,))], axis=-1) - - -@pytest.mark.parametrize('side',[0,1]) -def test_broad_source_enclosure_refines_to_the_same_algebraic_boundary_root(side): - from examples.ssx.ssx5_analytic_audit import graph_pair,case_components - from mmcore.numeric.intersection.ssx._ssx_planar_cut import exact_source_planar_cut - a,b = map(_homogeneous,graph_pair(case_components('four_lines'))) - identity = BoundaryRootIdentity(a,b) - identity.source_census = lambda axis,value,box: exact_source_planar_cut( - a,b,axis,value,box,max_cells=1000) - q = np.array([.125,float(side),.25,(side+.25)/1.5]) - old = np.column_stack((q-.001,q+.001)) - old[1] = side,side - old[3] = ((1/6,np.nextafter(1/6,np.inf)) if side == 0 else - (np.nextafter(5/6,-np.inf),5/6)) - point = BoundaryPoint(q,np.array([.125,float(side),0.]),(1,side),root_box=old.copy()) - point._source_root_box = True - refined = identity.refine_enclosure(point,np.full(4,1e-4)) - assert refined is not None - assert np.all(refined[:,0] >= old[:,0]) and np.all(refined[:,1] <= old[:,1]) - assert refined[0,1]-refined[0,0] < 1e-14 - assert identity.source_certificates[id(point)]['owner'] is point - np.testing.assert_array_equal(point.root_box,old) # Caller commits the box. - - -def test_failed_enclosure_refinement_does_not_replace_old_source_identity(monkeypatch): - a = _homogeneous(np.array([[[s,t,0.] for t in (0.,1.)] for s in (0.,1.)])) - identity = BoundaryRootIdentity(a,a) - point = BoundaryPoint(np.array([.25,0.,.25,0.]),np.zeros(3),(1,0), - root_box=np.array([[.24,.26],[0.,0.],[.24,.26],[0.,0.]])) - point._source_root_box = True - original = {'owner':point,'tag':'old-source-proof'} - identity.source_certificates[id(point)] = original - def another_root(trial,radii): - identity.source_certificates[id(trial)] = {'owner':trial,'tag':'different-root'} - return np.array([[.74,.76],[0.,0.],[.74,.76],[0.,0.]]) - monkeypatch.setattr(identity,'enclose',another_root) - assert identity.refine_enclosure(point,np.full(4,.001)) is None - assert identity.source_certificates == {id(point):original} - - -def test_denied_source_refinement_preserves_box_and_skips_candidate_work(monkeypatch): - a = _homogeneous(np.array([[[s,t,0.] for t in (0.,1.)] for s in (0.,1.)])) - identity = BoundaryRootIdentity(a,a,charge=lambda _:False) - point = BoundaryPoint(np.full(4,.5),np.zeros(3),(1,0),root_box=np.array([[.4,.6]]*4)) - point._source_root_box = True - def forbidden(*args,**kwargs): - raise AssertionError('Unpaid candidate isolation') - monkeypatch.setattr(identity,'enclose',forbidden) - assert identity.refine_enclosure(point,np.full(4,.01)) is None - np.testing.assert_array_equal(point.root_box,np.array([[.4,.6]]*4)) - - -def test_independent_representatives_on_equivalent_exact_slices_coalesce(): - a = np.array([[[s,t,0.] for t in (0.,1.)] for s in (0.,1.)]) - b = np.array([[[u,v,v-.5] for v in (0.,1.)] for u in (0.,1.)]) - identity = BoundaryRootIdentity(_homogeneous(a), _homogeneous(b)) - first = BoundaryPoint(np.array([.25,.5,.25+1e-15,.5+1e-15]), - np.array([.25,.5,0.]), (0,-1)) - second = BoundaryPoint(np.array([.25+1e-15,.5-1e-15,.25,.5]), - np.array([.25,.5,0.]), (2,-1)) - assert identity(first, second, np.full(4,1e-3), 1e-3) - - -def test_close_distinct_roots_fail_common_face_uniqueness(): - left, right = .5-2.**-20, .5+2.**-20 - z = np.array([left*right, left*right-(left+right)/2, - (1-left)*(1-right)]) - a = np.array([[[s,t,z[i]] for t in (0.,1.)] - for i,s in enumerate((0.,.5,1.))]) - b = np.array([[[u,v,0.] for v in (0.,1.)] for u in (0.,1.)]) - identity = BoundaryRootIdentity(_homogeneous(a), _homogeneous(b)) - points = [] - for x in (left,right): - root = np.array([x,0.,x,0.]) - points.append(BoundaryPoint(root,np.array([x,0.,0.]),(1,0), - root_box=np.column_stack((root,root)))) - assert not identity(*points,np.full(4,1e-3),1e-3) - assert len(_dedup_crossings(points,1e-3,param_tol=np.full(4,1e-3), - root_matcher=identity)) == 2 - - -def test_two_near_corner_crossings_of_one_arc_are_distinct_events(): - tiny = 2.**-20 - a = np.array([[[s,t,0.] for t in (0.,1.)] for s in (0.,1.)]) - b = np.array([[[u,v,u+v-tiny] for v in (0.,1.)] for u in (0.,1.)]) - identity = BoundaryRootIdentity(_homogeneous(a), _homogeneous(b)) - first = BoundaryPoint(np.array([0.,tiny,0.,tiny]),np.array([0.,tiny,0.]),(0,0)) - second = BoundaryPoint(np.array([tiny,0.,tiny,0.]),np.array([tiny,0.,0.]),(1,0)) - assert not identity(first,second,np.full(4,1e-3),1e-3) - - -def _certified_cut_root(identity, first, second, axis, cut, choose=0): - from mmcore.numeric.intersection.ssx._ssx_planar_cut import exact_source_planar_cut - result = exact_source_planar_cut(first, second, axis, cut, ((0.,1.),)*4, - max_cells=1000) - assert result['boundary_topology_complete'] - root = result['isolated'][choose] - point = BoundaryPoint(np.asarray(root['stuv']), np.asarray(root['point']), - (axis,-1), root_box=np.asarray(root['parameter_root_box'])) - point._source_root_box = True - assert identity.register_source_root(point, root['source_cut_certificate']) - return point - - -def test_exact_singleton_source_root_is_identical_across_different_face_polynomials(): - coordinates, square = (0.,.5,1.), (1.,-1.,1.) - graph = np.array([[[s,t,square[i]-square[j]] for j,t in enumerate(coordinates)] - for i,s in enumerate(coordinates)]) - plane = np.array([[[u,v,0.] for v in (-.5,1.5)] for u in (-.5,1.5)]) - first, second = _homogeneous(graph), _homogeneous(plane) - identity = BoundaryRootIdentity(first, second) - a = _certified_cut_root(identity, first, second, 0, 0.) - b = _certified_cut_root(identity, first, second, 1, 0.) - np.testing.assert_array_equal(a.stuv, [0.,0.,.25,.25]) - assert identity.source_certificates[id(a)]['varying_axis'] != identity.source_certificates[id(b)]['varying_axis'] - assert identity(a,b,np.full(4,1e-3),1e-3) - assert len(_dedup_crossings([a,b],1e-3,param_tol=np.full(4,1e-3), - root_matcher=identity)) == 1 - - -def test_different_exact_singleton_source_roots_at_nearby_faces_stay_distinct(): - tiny = 2.**-20 - graph = np.array([[[s,t,s+t-tiny] for t in (0.,1.)] for s in (0.,1.)]) - plane = np.array([[[u,v,0.] for v in (0.,1.)] for u in (0.,1.)]) - first, second = _homogeneous(graph), _homogeneous(plane) - identity = BoundaryRootIdentity(first, second) - a = _certified_cut_root(identity, first, second, 0, 0.) - b = _certified_cut_root(identity, first, second, 1, 0.) - assert not identity(a,b,np.full(4,1e-3),1e-3) - - -def test_incident_corner_faces_obtain_own_source_certificates_before_identity(): - from mmcore.numeric.intersection.ssx._ssx_planar_cut import exact_source_planar_cut - graph = np.array([[[s,t,s+t] for t in (0.,1.)] for s in (0.,1.)]) - plane = np.array([[[u,v,0.] for v in (0.,1.)] for u in (0.,1.)]) - first, second = _homogeneous(graph), _homogeneous(plane) - identity = BoundaryRootIdentity(first, second) - identity.source_census = lambda axis,value,box: exact_source_planar_cut( - first, second, axis, value, box, max_cells=1000) - a = BoundaryPoint(np.zeros(4),np.zeros(3),(0,0)) - b = BoundaryPoint(np.zeros(4),np.zeros(3),(1,0)) - assert identity(a,b,np.full(4,1e-3),1e-3) - assert id(a) in identity.source_certificates and id(b) in identity.source_certificates - - -def test_affine_root_interval_encloses_exact_binary_mapping(): - lo, hi, parameter = .20323707831873447, .7064770887111032, .569861747188787 - lower, upper = _affine_interval(lo, hi, parameter, parameter) - exact = Fraction(lo)+(Fraction(hi)-Fraction(lo))*Fraction(parameter) - assert Fraction(lower) <= exact <= Fraction(upper) - - -def test_denied_identity_work_retains_events_before_certificate_allocation(): - a = np.array([[[s,t,0.] for t in (0.,1.)] for s in (0.,1.)]) - b = np.array([[[u,v,v-.5] for v in (0.,1.)] for u in (0.,1.)]) - charges = [] - identity = BoundaryRootIdentity(_homogeneous(a), _homogeneous(b), - charge=lambda amount: charges.append(amount) or False) - first = BoundaryPoint(np.array([.25,.5,.25,.5]), np.array([.25,.5,0.]), (0,-1)) - second = BoundaryPoint(np.array([.25,.5,.25,.5]), np.array([.25,.5,0.]), (2,-1)) - assert not identity(first, second, np.full(4,1e-3), 1e-3) - assert charges == [1] - assert not identity.faces and not identity.enclosures - - -def test_exact_affine_boundary_reduction_certifies_irrational_root_identity(): - # The source t=0 face and target v=0 face share the irrational endpoint - # s=u=sqrt(1/2). Full 3D strict inclusion cannot cover v=0; the exact - # y=t=v identity reduces this to the square interior problem (x,z). - a = np.array([[[s,t,z] for t in (0.,1.)] - for s,z in ((0.,-.5),(.5,-.5),(1.,.5))]) - b = np.array([[[u,v,0.] for v in (0.,1.)] for u in (0.,1.)]) - identity = BoundaryRootIdentity(_homogeneous(a), _homogeneous(b)) - root = np.sqrt(.5) - first = BoundaryPoint(np.array([root,0.,np.nextafter(root,1.),0.]), - np.array([root,0.,0.]), (1,0)) - second = BoundaryPoint(np.array([np.nextafter(root,1.),0.,root,0.]), - np.array([root,0.,0.]), (3,0)) - radii = np.full(4,1e-3) - assert identity(first,second,radii,1e-3) - enclosure = identity.enclose(first,radii) - assert enclosure is not None - assert np.all(enclosure[[1,3]] == 0.) - - -def test_root_identity_refines_oversized_proposal_without_merging_neighbor(): - left, right = 15/32, 17/32 - z = [left*right, left*right-.5, (1-left)*(1-right)] - a = np.array([[[s,t,z[i]] for t in (0.,1.)] - for i,s in enumerate((0.,.5,1.))]) - b = np.array([[[u,v,0.] for v in (0.,1.)] for u in (0.,1.)]) - identity = BoundaryRootIdentity(_homogeneous(a), _homogeneous(b)) - first = BoundaryPoint(np.array([left,.5,left,.5]),np.array([left,.5,0.]),(1,-1)) - second = BoundaryPoint(np.array([np.nextafter(left,1.),.5,left,.5]), - np.array([left,.5,0.]),(1,-1)) - neighbor = BoundaryPoint(np.array([right,.5,right,.5]), - np.array([right,.5,0.]),(1,-1)) - # The initial proposal contains both roots. Refinement must isolate - # each existing root; the final union still contains both enclosures - # when different roots are compared, so they cannot coalesce. - assert identity(first,second,np.full(4,.25),1.) - assert not identity(first,neighbor,np.full(4,.25),1.) - - -def test_singular_root_refusal_stops_at_source_precision_without_spending_ledger(): - # z=(s-.5)^2 has an exact repeated face root. Regular Krawczyk - # inclusion cannot isolate it; shrinking to float spacing only spends - # work while the inherited source uncertainty already dominates z. - a = np.array([[[s,t,z] for t in (0.,1.)] - for s,z in ((0.,.25),(.5,-.25),(1.,.25))]) - b = np.array([[[u,v,0.] for v in (0.,1.)] for u in (0.,1.)]) - spent = [0] - - def charge(amount): - if spent[0]+amount > 30: - return False - spent[0] += amount - return True - - identity = BoundaryRootIdentity(_homogeneous(a), _homogeneous(b), charge=charge) - root = np.array([.5,.5,.5,.5]) - first = BoundaryPoint(root.copy(),np.array([.5,.5,0.]),(1,-1)) - second = BoundaryPoint(root.copy(),np.array([.5,.5,0.]),(1,-1)) - assert identity.enclose(first,np.full(4,1e-3)) is None - assert not identity.exhausted - # Refusal preserves the independent events; it does not identify them - # or assert that the singular root is absent. - assert not identity(first,second,np.full(4,1e-3),1e-3) - assert len(_dedup_crossings([first,second],1e-3, - param_tol=np.full(4,1e-3),root_matcher=identity)) == 2 diff --git a/tests/test_ssx5_singular_source_candidates.py b/tests/test_ssx5_singular_source_candidates.py deleted file mode 100644 index 83548c5a..00000000 --- a/tests/test_ssx5_singular_source_candidates.py +++ /dev/null @@ -1,82 +0,0 @@ -import numpy as np - -from mmcore.numeric.intersection.ssx._ssx_affine_constraints import AffineParameterConstraints -from mmcore.numeric.intersection.ssx._ssx_root_identity import _pure_affine_coordinates -from mmcore.numeric.intersection.ssx._ssx_singular_candidate import SourceSingularCandidates - - -def _sources(): - a = np.array([[[s, t, x-y, 1.] for t, y in zip((0., .5, 1.), (1., -1., 1.))] - for s, x in zip((0., .5, 1.), (1., -1., 1.))]) - b = np.array([[[u, v, 0., 1.] for v in (-.5, 1.5)] for u in (-.5, 1.5)]) - return a, b - - -def _generator(a, b, charge=None): - constraints = AffineParameterConstraints(tuple(_pure_affine_coordinates(s) for s in (a, b))) - return SourceSingularCandidates(a, b, constraints, charge) - - -def test_exact_affine_correction_generates_a_source_saddle_root(): - source = _generator(*_sources()) - candidate = np.array([.5, .5, np.nextafter(.5, 1.), np.nextafter(.5, 0.)]) - saved = candidate.copy() - np.testing.assert_array_equal(source(candidate), np.full(4, .5)) - np.testing.assert_array_equal(candidate, saved) - - -def test_exact_saddle_has_isolated_regular_chart_delta_root(): - source = _generator(*_sources()) - np.testing.assert_array_equal(source.isolated_regular(np.full(4, .5)), np.full(4, .5)) - - -def test_tangential_curve_samples_are_not_isolated_delta_roots(): - _, plane = _sources() - source = np.array([[[s, t, height, 1.] for t, height in zip((0., .5, 1.), (1., -1., 1.))] - for s in (0., 1.)]) - generator = _generator(source, plane) - assert generator(np.full(4, .5)) is not None - assert generator.isolated_regular(np.full(4, .5)) is None - - -def test_rank_deficient_source_is_not_a_regular_chart_tangent_point(): - source, plane = _sources() - source[..., 0] = 0.5 - generator = _generator(source, plane) - assert generator(np.full(4, .5)) is not None - assert generator.isolated_regular(np.full(4, .5)) is None - - -def test_degree_zero_chart_rejects_regular_classification_without_empty_jet(): - source, plane = _sources() - source = np.array([[[.5, .5, 0., 1.], [.5, .5, 0., 1.]]]) - generator = _generator(source, plane) - assert generator(np.full(4, .5)) is not None - assert generator.isolated_regular(np.full(4, .5)) is None - - -def test_affine_relations_do_not_promote_nonzero_source_residual(): - a, b = _sources() - a[..., 2] += 2.**-40 - assert _generator(a, b)(np.full(4, .5)) is None - - -def test_a_regular_source_root_is_not_a_delta_root(): - a, b = _sources() - a = np.array([[[s, t, s-.5, 1.] for t in (0., 1.)] for s in (0., 1.)]) - assert _generator(a, b)(np.full(4, .5)) is None - - -def test_nonrepresentable_affine_inverse_stays_a_candidate_obligation(): - a, b = _sources() - b = b.copy() - b[0, :, 0] = 0. - b[1, :, 0] = 3. - assert _generator(a, b)(np.full(4, .5)) is None - - -def test_denied_source_proof_does_not_publish_a_candidate(): - charges = [] - source = _generator(*_sources(), charge=lambda n: charges.append(n) or False) - assert source(np.full(4, .5)) is None - assert len(charges) == 1 and charges[0] > 0 diff --git a/tests/test_ssx5_trace_ownership.py b/tests/test_ssx5_trace_ownership.py index bbd4f8aa..e0c15aa9 100644 --- a/tests/test_ssx5_trace_ownership.py +++ b/tests/test_ssx5_trace_ownership.py @@ -34,10 +34,11 @@ def interrupted_parent(cell, *args, **kwargs): atol=1e-12, rtol=0.) assert np.linalg.norm(np.diff(branch.curve[1], axis=0), axis=1).sum() == pytest.approx(length) if complete: - assert not result['points'] and not result['unresolved_regions'] + assert not result['points'] + assert 'unresolved_regions' not in result else: assert 'depth_limit' in result['status']['reasons'] - assert result['unresolved_regions'] + assert 'unresolved_regions' not in result def test_endpoint_broadphase_leaves_work_for_a_long_registered_chain(): @@ -55,3 +56,28 @@ def test_endpoint_broadphase_leaves_work_for_a_long_registered_chain(): assert not budget.exhausted assert len(branches) == 1 assert np.linalg.norm(np.diff(branches[0].curve[1], axis=0), axis=1).sum() == pytest.approx(1.) + + +def test_interior_ended_attempt_does_not_preempt_a_later_complete_trace(monkeypatch): + from mmcore.numeric.intersection.ssx._ssx_substrate import GaussMapBern + a = np.array([[[s, t, 0., 1.] for t in (0., 1.)] for s in (0., 1.)]) + b = a.copy() + b[..., 2] = np.array([[-.5, .5], [-.5, .5]]) + first = ssx.BoundaryPoint(np.array([0., .5, 0., .5]), np.array([0., .5, 0.]), (0, 0)) + last = ssx.BoundaryPoint(np.array([1., .5, 1., .5]), np.array([1., .5, 0.]), (0, 1)) + cell = ssx._Cell(GaussMapBern.from_surf(a, rational=True), + GaussMapBern.from_surf(b, rational=True), + [first, last], ((0., 1.),)*4) + calls = [] + def interrupted_then_complete(*args, **kwargs): + calls.append(1) + end = .75 if len(calls) == 1 else 1. + q = np.array([first.stuv, [end, .5, end, .5]]) + xyz = np.column_stack((q[:, 0], np.full(2, .5), np.zeros(2))) + return q, xyz, None if len(calls) == 1 else (0, 1) + monkeypatch.setattr(ssx, '_march_to_boundary', interrupted_then_complete) + fragments, points = ssx._trace_cell_by_registrations(cell, .001) + assert len(calls) == 2 + assert not cell.trace_incomplete and not points + assert len(fragments) == 1 and fragments[0].end_point is last + np.testing.assert_array_equal(fragments[0].xyz_path[-1], [1., .5, 0.]) diff --git a/tests/test_ssx_active_owner_budget.py b/tests/test_ssx_active_owner_budget.py deleted file mode 100644 index 9696461d..00000000 --- a/tests/test_ssx_active_owner_budget.py +++ /dev/null @@ -1,53 +0,0 @@ -"""A work stop after popping an owner must retain its unresolved domain.""" -import numpy as np -import pytest - -from mmcore.numeric.intersection.ssx import _bez_ssx5 as ssx -from mmcore.numeric.intersection.ssx._ssx_source_cut_cache import SourceFaceCensusCache - - -@pytest.mark.parametrize('denial_site', ['before_children', 'first_cut']) -def test_budget_denial_after_pop_keeps_active_owner(monkeypatch, denial_site): - first = np.array([[[s, t, 0.] for t in (0., 1.)] for s in (0., 1.)]) - second = np.array([[[u, v, v-.5] for v in (0., 1.)] for u in (0., 1.)]) - actual_budget = ssx._SSXSoftBudget - budgets, popped_owners, denied = [], [], [] - - def create_budget(*args, **kwargs): - work = actual_budget(*args, **kwargs) - budgets.append(work) - return work - - def deny(): - work = budgets[0] - assert not work.charge_cells(work.remaining_cells+1, 'controlled_denial') - denied.append(True) - - def split_plan(points, box, **kwargs): - popped_owners.append(box) - if denial_site == 'before_children': - deny() - return None, None, None, None - - original_census = SourceFaceCensusCache.__call__ - def cut_census(self, axis, value, box): - if popped_owners and denial_site == 'first_cut': - deny() - return dict(isolated=[], boundary_topology_complete=False, - budget_exhausted=True, truncation_cause='max_cells') - return original_census(self, axis, value, box) - - monkeypatch.setattr(ssx, '_SSXSoftBudget', create_budget) - monkeypatch.setattr(ssx, '_check_loop_free', lambda *a, **k: False) - monkeypatch.setattr(ssx, '_check_tangency', lambda *a, **k: None) - monkeypatch.setattr(ssx, '_compute_split_plan', split_plan) - monkeypatch.setattr(SourceFaceCensusCache, '__call__', cut_census) - result = ssx.bez_ssx(first, second, rational=False, max_xyz_step=.1) - assert len(popped_owners) == len(denied) == 1 - assert not result['complete'] - assert 'work_budget' in result['status']['reasons'] - low = tuple(lo for lo, hi in popped_owners[0]) - high = tuple(hi for lo, hi in popped_owners[0]) - assert any(region['stuv_min'] == low and region['stuv_max'] == high - and region['reason'] == 'work_budget' - for region in result['unresolved_regions']) diff --git a/tests/test_ssx_affine_constraints.py b/tests/test_ssx_affine_constraints.py deleted file mode 100644 index 4b7c8f20..00000000 --- a/tests/test_ssx_affine_constraints.py +++ /dev/null @@ -1,248 +0,0 @@ -from fractions import Fraction - -import numpy as np -import pytest - -from mmcore.numeric.intersection.ssx._ssx_affine_constraints import AffineParameterConstraints - - -def test_no_constraints_preserves_the_whole_box(): - box = ((0.,1.),(.25,.75),(.125,.5),(0.,.625)) - assert AffineParameterConstraints(({},{})).contract(box) == box - - -def test_exact_rational_constant_coordinate_fixes_opposite_parameter_only_in_constraints(): - from mmcore.numeric.intersection.ssx._ssx_affine_constraints import augment_constant_coordinates - from mmcore.numeric.intersection.ssx._ssx_root_identity import _pure_affine_coordinates - first = np.array([[[x,0.,5*w,w],[x,10*w,5*w,w]] - for x,w in ((0.,1.),(5.,1.5),(20.,2.))]) - second = np.array([[[10*u,10*v,10*u,1.] for v in (0.,1.)] for u in (0.,1.)]) - maps = tuple(_pure_affine_coordinates(net) for net in (first,second)) - augmented = augment_constant_coordinates((first,second),maps) - assert (2,0) not in maps[0] - assert augmented[0][2,0] == (Fraction(5),Fraction(0)) - box = AffineParameterConstraints(augmented).contract(((0.,1.),)*4) - assert box[0] == box[2] == (.5,.5) - assert box[1] == box[3] == (0.,1.) - - -def test_near_constant_source_coordinate_does_not_fix_a_parameter(): - from mmcore.numeric.intersection.ssx._ssx_affine_constraints import augment_constant_coordinates - first = np.array([[[s,t,1.,1.] for t in (0.,1.)] for s in (0.,1.)]) - first[-1,-1,2] = np.nextafter(1.,2.) - maps = ({},{}) - augmented = augment_constant_coordinates((first,first),maps) - assert (2,0) not in augmented[0] - - -def test_constant_coordinate_proof_is_prepaid_before_fraction_work(monkeypatch): - import mmcore.numeric.intersection.ssx._ssx_affine_constraints as module - first = np.array([[[s,t,1.,1.] for t in (0.,1.)] for s in (0.,1.)]) - monkeypatch.setattr(module,'_fraction',lambda _: (_ for _ in ()).throw(AssertionError('unpaid'))) - assert module.augment_constant_coordinates((first,first),({},{ }),charge=lambda _:False) is None - - -def _mixed_curved_pair(): - first = np.array([[[i/2,j/2,i*j/4] for j in range(3)] for i in range(3)]) - second = first.copy() - z = (1.,-1.,1.) - second[...,2] += np.array([[z[i]+z[j]-.5 for j in range(3)] for i in range(3)]) - matrix = np.array([[1.,1.,0.],[0.,1.,1.],[1.,0.,1.]]) - return tuple(np.concatenate([net@matrix.T,np.ones((3,3,1))],axis=-1) - for net in (first,second)) - - -def test_exact_projected_coordinates_restore_mixed_world_parameter_relations(): - from mmcore.numeric.intersection.ssx._ssx_affine_constraints import augment_projected_coordinates - from mmcore.numeric.intersection.ssx._ssx_root_identity import _pure_affine_coordinates - pair = _mixed_curved_pair() - maps = tuple(_pure_affine_coordinates(net) for net in pair) - copies = tuple(dict(mapping) for mapping in maps) - augmented = augment_projected_coordinates(pair,maps) - assert maps == copies - assert any(coordinate > 2 for coordinate,axis in augmented[0]) - result = AffineParameterConstraints(augmented).contract( - ((.25,.5),(.625,.75),(0.,1.),(0.,1.))) - assert result == ((.25,.5),(.625,.75),(.25,.5),(.625,.75)) - - -def test_projected_source_identity_is_exact_and_not_a_small_residual_gate(): - from mmcore.numeric.intersection.ssx._ssx_affine_constraints import augment_projected_coordinates - first = np.array([[[i/2,j/2,i*j/4,1.] for j in range(3)] for i in range(3)]) - second = first.copy() - first[-1,-1,0] = np.nextafter(first[-1,-1,0],np.inf) - maps = augment_projected_coordinates((first,second),({},{})) - result = AffineParameterConstraints(maps).contract( - ((.25,.5),(.625,.75),(0.,1.),(0.,1.))) - assert result[2] == (0.,1.) - assert result[3] == (.625,.75) - - -def test_projected_coordinate_refuses_nonuniform_weights(): - from mmcore.numeric.intersection.ssx._ssx_affine_constraints import augment_projected_coordinates - first,second = _mixed_curved_pair() - first[1,1] *= 2. - assert augment_projected_coordinates((first,second),({},{})) == ({},{}) - - -def test_projected_affine_identity_handles_higher_degree_control_nets(): - from mmcore.numeric.intersection.ssx._ssx_affine_constraints import augment_projected_coordinates - first = np.array([[[i/4,j/4,i*j/16] for j in range(5)] for i in range(5)]) - second = first.copy() - second[...,2] += np.arange(25).reshape(5,5)**2/16. - matrix = np.array([[1.,1.,0.],[0.,1.,1.],[1.,0.,1.]]) - pair = tuple(np.concatenate([net@matrix.T,np.ones((5,5,1))],axis=-1) - for net in (first,second)) - constraints = AffineParameterConstraints(augment_projected_coordinates(pair,({},{}))) - box = constraints.contract(((.25,.5),(.625,.75),(0.,1.),(0.,1.))) - assert box == ((.25,.5),(.625,.75),(.25,.5),(.625,.75)) - - -def test_projected_coordinate_proof_is_prepaid_before_fraction_conversion(monkeypatch): - import mmcore.numeric.intersection.ssx._ssx_affine_constraints as module - pair = _mixed_curved_pair() - monkeypatch.setattr(module,'_fraction',lambda _: pytest.fail('unpaid source conversion')) - assert module.augment_projected_coordinates(pair,({},{}),charge=lambda _:False) is None - - -def test_projected_coordinates_preserve_existing_synthetic_keys(): - from mmcore.numeric.intersection.ssx._ssx_affine_constraints import augment_projected_coordinates - maps = ({(7,0):(Fraction(0),Fraction(1))},{(7,0):(Fraction(0),Fraction(1))}) - augmented = augment_projected_coordinates(_mixed_curved_pair(),maps) - assert all(mapping[7,0] == (0,1) for mapping in augmented) - assert any(coordinate > 7 for coordinate,axis in augmented[0]) - assert set(maps[0]) == {(7,0)} - - -def test_constraint_constructor_accepts_certified_projection_keys(): - result = AffineParameterConstraints(( - {(8,0):(Fraction(0),Fraction(1))}, - {(8,1):(Fraction(1),Fraction(-2))})).contract( - ((.25,.5),(0.,1.),(0.,1.),(0.,1.))) - assert result[3] == (.25,.375) - - -def test_proved_identity_contraction_does_not_spend_on_each_new_box(): - charges = [] - constraints = AffineParameterConstraints(({}, {}), - charge=lambda n: charges.append(n) or len(charges) == 1) - for width in (1., .5, .125, np.nextafter(0., 1.)): - box = ((0., width),) * 4 - assert constraints.contract(box) == box - assert len(charges) == 1 - assert not constraints.exhausted - - -def test_negative_slope_relation_contracts_both_source_parameters(): - maps = ({(0,0):(0,1)}, {(0,0):(1,-2)}) - result = AffineParameterConstraints(maps).contract(((.25,.5),(0.,1.),(0.,1.),(0.,1.))) - assert result[0] == (.25,.5) - assert result[2] == (.25,.375) - - -def test_nonbinary_fixed_pin_rounds_outward(): - maps = ({(0,0):(0,3)}, {(0,0):(0,1)}) - result = AffineParameterConstraints(maps).contract(((0.,1.),(0.,1.),(.5,.5),(0.,1.))) - assert Fraction(result[0][0]) <= Fraction(1,6) <= Fraction(result[0][1]) - assert result[0][0] != result[0][1] - - -def test_consistent_identity_cycle_preserves_all_solutions(): - maps = ({(0,0):(0,1),(1,0):(0,2)}, {(0,0):(0,1),(1,0):(0,2)}) - result = AffineParameterConstraints(maps).contract(((.125,.5),(0.,1.),(.25,.75),(0.,1.))) - assert result[0] == result[2] == (.25,.5) - - -def test_nonidentity_cycle_fixes_the_common_parameter(): - maps = ({(0,0):(0,1),(1,0):(0,2)}, {(0,0):(0,1),(1,0):(-1,4)}) - result = AffineParameterConstraints(maps).contract(((0.,1.),)*4) - assert result[0] == result[2] == (.5,.5) - - -def test_inconsistent_cycle_proves_the_domain_empty(): - maps = ({(0,0):(0,1),(1,0):(0,1)}, {(0,0):(0,1),(1,0):(1,1)}) - assert AffineParameterConstraints(maps).contract(((0.,1.),)*4) is None - - -def test_disjoint_ranges_prove_empty_without_a_geometric_tolerance(): - maps = ({(0,0):(0,1)}, {(0,0):(0,1)}) - assert AffineParameterConstraints(maps).contract(((0.,.5),(0.,1.), - (np.nextafter(.5,np.inf),1.),(0.,1.))) is None - - -def test_work_denial_keeps_the_unknown_box_and_caching_spends_once(): - maps = ({(0,0):(0,1)}, {(0,0):(0,1)}) - box = ((0.,1.),)*4 - denied = AffineParameterConstraints(maps,charge=lambda n:False) - assert denied.exhausted and denied.contract(box) == box - charges = [] - constraints = AffineParameterConstraints(maps,charge=lambda n:charges.append(n) or True) - first = constraints.contract(box) - paid = sum(charges) - assert constraints.contract(box) is first - assert sum(charges) == paid - - -def test_random_consistent_affine_relations_retain_exact_solutions(): - random = np.random.default_rng(491) - for _ in range(40): - # Two equations force all four variables onto one affine component. - alpha = [Fraction(int(random.integers(-3,4)),8) for _ in range(4)] - beta = [Fraction(int(random.choice([-3,-2,-1,1,2,3])),4) for _ in range(4)] - # Graph edges 0--2, 1--2, 1--3 in three world coordinates. - maps = ({}, {}) - for coordinate,(x,y) in enumerate(((0,2),(1,2),(1,3))): - maps[0][coordinate,x] = -alpha[x]/beta[x],1/beta[x] - maps[1][coordinate,y-2] = -alpha[y]/beta[y],1/beta[y] - constraints = AffineParameterConstraints(maps) - box = ((-2.,2.),)*4 - result = constraints.contract(box) - assert result is not None - for root in (Fraction(-1),Fraction(0),Fraction(1)): - values = [a+b*root for a,b in zip(alpha,beta)] - for value,(lo,hi) in zip(values,result): - assert Fraction(lo) <= value <= Fraction(hi) - - -def test_canonical_candidate_is_new_and_requires_exact_float_images(): - constraints = AffineParameterConstraints(({(0,0):(0,1)}, {(0,0):(0,2)})) - point = np.array([.5,.625,np.nextafter(.25,np.inf),.75]) - result = constraints.canonical_candidate(point) - np.testing.assert_array_equal(result,[.5,.625,.25,.75]) - assert point[2] != result[2] - nonbinary = AffineParameterConstraints(({(0,0):(0,1)}, {(0,0):(0,3)})) - assert nonbinary.canonical_candidate(point) is None - - -def test_exact_integer_offsets_are_not_rounded_before_cycle_comparison(): - large = 2**54 - maps = ({(0,0):(large,1),(1,0):(large,1)}, - {(0,0):(large,1),(1,0):(large+1,1)}) - assert AffineParameterConstraints(maps).contract(((0.,1.),)*4) is None - - -def test_equivalent_source_faces_share_exact_keys_and_float_aliases_do_not(): - constraints = AffineParameterConstraints(({(0,0):(0,1)}, {(0,0):(0,3)})) - assert constraints.face_key(0,Fraction(1,2)) == constraints.face_key(2,Fraction(1,6)) - assert constraints.face_key(0,.5) != constraints.face_key(2,1/6) - assert constraints.face_key(0,.5) != constraints.face_key(1,.5) - - -def test_exact_source_certificate_rebinding_preserves_polynomial_root_identity(): - from mmcore.numeric.intersection.ssx._ssx_planar_cut import exact_source_planar_cut - from mmcore.numeric.intersection.ssx._ssx_root_identity import _pure_affine_coordinates - graph = np.array([[[u,v,(v-.5)*z,1.] for v in (0.,1.)] - for u,z in zip((0.,.5,1.),(1.,1.,2.))]) - plane = np.array([[[u,v,0.,1.] for v in (0.,1.)] for u in (-.25,1.25)]) - constraints = AffineParameterConstraints(tuple(_pure_affine_coordinates(s) for s in (graph,plane))) - result = exact_source_planar_cut(graph,plane,0,.5,((0.,1.),)*4,max_cells=1000) - root, = result['isolated'] - certificate = root['source_cut_certificate'] - rebound = constraints.rebind_source_certificate(certificate,2,.5) - assert rebound is not None - assert rebound['polynomial'] == certificate['polynomial'] - assert rebound['interval'] == certificate['interval'] - assert rebound['axis'] == 2 - assert rebound['parameter_root_box'][2] == (('1','2'),('1','2')) - assert certificate['axis'] == 0 - assert constraints.rebind_source_certificate(certificate,2,np.nextafter(.5,1.)) is None diff --git a/tests/test_ssx_affine_domain.py b/tests/test_ssx_affine_domain.py deleted file mode 100644 index 46148f3d..00000000 --- a/tests/test_ssx_affine_domain.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Constraint propagation preserves source geometry and boundary ownership.""" -from types import SimpleNamespace - -import numpy as np -import pytest - -from mmcore.numeric._work_budget import SoftWorkBudget -from mmcore.numeric.intersection._root_box_certificate import exact_bernstein_value -from mmcore.numeric.intersection.ssx import _bez_ssx5 as ssx -from mmcore.numeric.intersection.ssx._ssx_affine_constraints import AffineParameterConstraints -from mmcore.numeric.intersection.ssx._ssx_cofactor_bounds import SourceZeroSetCofactorBounds - - -def _fixture(): - surface = np.array([[[u, v, u*v, 1.] for v in (0., 1.)] for u in (0., 1.)]) - sources = tuple(ssx.GaussMapBern.from_surf(surface, rational=True) for _ in range(2)) - minors = tuple(np.arange(16.).reshape((2,)*4)+k for k in range(4)) - point = ssx.BoundaryPoint(np.array([.25, .5, .25, .5]), np.array([.25, .5, .125]), - (0, -1), tangent_raw=np.ones(4), - root_box=np.array([[.25, .25], [.49, .51], [.25, .25], [.49, .51]])) - cell = ssx._Cell(*sources, [point], ((.25, .75), (0., 1.), (0., 1.), (0., 1.)), - T1=minors[0], T2=minors[1], T3=minors[2], T4=minors[3], - boundary_complete=True, work_budget=SoftWorkBudget(1000, 100)) - cell.partitions = ssx._build_cell_partitions(cell) - ssx._classify_boundary_point(point, cell) - constraints = AffineParameterConstraints(( - {(0, 0): (0, 1), (1, 1): (0, 1)}, - {(0, 0): (0, 1), (1, 1): (0, 1)})) - return cell, sources, minors, constraints - - -def test_domain_contraction_preserves_geometry_minors_and_owned_root(): - cell, sources, minors, constraints = _fixture() - old_partitions = cell.partitions - point, enclosure = cell.crossings[0], cell.crossings[0].root_box - unrelated = SimpleNamespace(owner=object()) - point.registrations.append(unrelated) - box = constraints.contract(cell.box) - ssx._contract_source_domain(cell, box, sources, minors) - assert cell.box == ((.25, .75), (0., 1.), (.25, .75), (0., 1.)) - assert cell.boundary_complete and cell.crossings[0] is point and point.root_box is enclosure - assert unrelated in point.registrations - assert all(not partition.registrations for partition in old_partitions) - assert {r.partition.axis for r in point.registrations if r.owner is cell} == {0, 2} - local = (.25, .375, .75, .625) - global_ = tuple(lo+t*(hi-lo) for t, (lo, hi) in zip(local, box)) - for i, (restricted, source) in enumerate(zip((cell.g1, cell.g2), sources)): - np.testing.assert_allclose( - list(map(float, exact_bernstein_value(restricted.surface, local[2*i:2*i+2]))), - list(map(float, exact_bernstein_value(source.surface, global_[2*i:2*i+2]))), - rtol=0., atol=1e-14) - for restricted, source in zip((cell.T1, cell.T2, cell.T3, cell.T4), minors): - assert float(exact_bernstein_value(restricted[..., None], local)[0]) == pytest.approx( - float(exact_bernstein_value(source[..., None], global_)[0]), abs=1e-14) - - -@pytest.mark.parametrize('refusal', ['zero_width', 'outside_representative', 'budget']) -def test_contraction_refusal_preserves_the_entire_old_cell(refusal): - cell, sources, minors, constraints = _fixture() - box = list(constraints.contract(cell.box)) - if refusal == 'zero_width': - box[2] = (.25, .25) - elif refusal == 'outside_representative': - box[2] = (.375, .75) - else: - cell.work_budget = SoftWorkBudget(0, 100) - original = cell.box, cell.g1, cell.g2, cell.T1, cell.partitions, cell.crossings - ssx._contract_source_domain(cell, tuple(box), sources, minors) - current = cell.box, cell.g1, cell.g2, cell.T1, cell.partitions, cell.crossings - assert all(a is b for a, b in zip(original, current)) - - -def test_zero_set_cofactor_proxy_uses_only_a_necessary_source_box(): - cell, _, _, constraints = _fixture() - calls = [] - answer = np.ones(4), 2*np.ones(4) - def bounds(box): - calls.append(box) - return answer - proxy = SourceZeroSetCofactorBounds(SimpleNamespace(bounds=bounds), constraints) - assert proxy.bounds(cell.box) is answer - assert calls == [constraints.contract(cell.box)] - assert proxy.bounds(((0., .25), (0., 1.), (.5, 1.), (0., 1.))) is None - assert len(calls) == 1 diff --git a/tests/test_ssx_arc_ownership.py b/tests/test_ssx_arc_ownership.py deleted file mode 100644 index 9999197c..00000000 --- a/tests/test_ssx_arc_ownership.py +++ /dev/null @@ -1,36 +0,0 @@ -from fractions import Fraction as F - -from mmcore.numeric.intersection.ssx._ssx_arc_ownership import map_source_path, source_path_covered - - -def _line(a,b): - return ((F(a),)*4,(F(b),)*4) - - -def test_proved_source_path_union_covers_varied_sampling_and_reversal(): - assert source_path_covered(_line(0,1),[_line(F(1,2),0),_line(F(1,2),1)]) - - -def test_exact_source_interval_union_keeps_an_unrepresentable_gap(): - e = F(1,2**60) - assert not source_path_covered(_line(0,1),[_line(0,F(1,2)),_line(F(1,2)+e,1)]) - - -def test_equal_float_approximations_are_not_source_provenance(): - path = ((0.,)*4,(1.,)*4) - assert not source_path_covered(path,[path]) - - -def test_global_source_path_mapping_preserves_fractional_preimages(): - bounds = ((1e16,1e16+4.),)*4 - first = map_source_path(_line(0,F(1,8)),bounds) - second = map_source_path(_line(F(1,8)+F(1,2**60),F(1,2)),bounds) - assert float(first[-1][0]) == float(second[0][0]) - assert first[-1][0] < second[0][0] - assert not source_path_covered(map_source_path(_line(0,F(1,2)),bounds),[first,second]) - - -def test_denied_source_path_proof_returns_unknown_before_clipping(monkeypatch): - from mmcore.numeric.intersection.ssx import _ssx_arc_ownership as module - monkeypatch.setattr(module,'_covered_fraction',lambda *a,**k: (_ for _ in ()).throw(AssertionError('unpaid proof'))) - assert source_path_covered(_line(0,1),[_line(0,1)],charge=lambda _:False) is None diff --git a/tests/test_ssx_balanced_partition.py b/tests/test_ssx_balanced_partition.py deleted file mode 100644 index 62974331..00000000 --- a/tests/test_ssx_balanced_partition.py +++ /dev/null @@ -1,35 +0,0 @@ -from types import SimpleNamespace - -import numpy as np -import pytest - -from mmcore.numeric.intersection.ssx._bez_ssx5 import _balanced_root_avoiding_cut - - -def _event(lo, hi): - enclosure = np.zeros((4, 2)) - enclosure[0] = lo, hi - return SimpleNamespace(root_box=enclosure, stuv=enclosure.mean(axis=1)) - - -def test_balanced_cut_avoids_a_known_continuous_face_coordinate(): - events = [_event(.5, .5)] - cut = _balanced_root_avoiding_cut(events, 0, ((0., 1.),)*4) - assert .25 <= cut <= .75 - assert cut != .5 - - -@pytest.mark.parametrize('bands', [[], [(1e-20, 2e-20)], [(.49, .51)], - [(.25, .5), (.6, .75)], [(0., 1.)]]) -def test_root_guidance_cannot_prevent_whole_owner_contraction(bands): - cut = _balanced_root_avoiding_cut([_event(*pair) for pair in bands], 0, ((0., 1.),)*4) - assert .25 <= cut <= .75 - assert max(cut, 1.-cut) <= .75 - if bands == [(1e-20, 2e-20)]: - assert cut == .5 - - -def test_adjacent_endpoints_are_left_for_explicit_representation_refusal(): - lo, hi = .5, np.nextafter(.5, 1.) - cut = _balanced_root_avoiding_cut([], 0, ((lo, hi),)+((0., 1.),)*3) - assert not lo < cut < hi diff --git a/tests/test_ssx_boundary_child_ownership.py b/tests/test_ssx_boundary_child_ownership.py deleted file mode 100644 index cec387bc..00000000 --- a/tests/test_ssx_boundary_child_ownership.py +++ /dev/null @@ -1,84 +0,0 @@ -"""A rounded boundary sample cannot decide which child owns its source root.""" -from fractions import Fraction -from types import SimpleNamespace - -import numpy as np - -from mmcore.numeric._work_budget import SoftWorkBudget -from mmcore.numeric.intersection.ssx import _bez_ssx5 as ssx - - -def _children(axis=0): - result = [] - for interval in ((0.,.5),(.5,1.)): - box = [(0.,1.)]*4 - box[axis] = interval - result.append(tuple(box)) - return result - - -def _point(): - q = np.array([np.nextafter(.5,1.),.25,.25,.25]) - box = np.repeat(q[:,None],2,axis=1) - box[0] = .49,.51 - point = ssx.BoundaryPoint(q,np.zeros(3),(1,0),root_box=box) - point._source_root_box = True - return point - - -def test_source_box_straddling_cut_is_inherited_by_both_children(): - point = _point() - cell = SimpleNamespace(work_budget=None,root_matcher=None) - groups = ssx._source_boundary_child_groups(cell,[point],_children()) - assert groups == [[point],[point]] - - -def test_exact_source_interval_outranks_wrong_side_float_sample(): - point = _point() - exact = Fraction(1,2)-Fraction(1,2**60) - exact_box = ((exact,exact),)+((Fraction(1,4),Fraction(1,4)),)*3 - point._source_root_box = False - matcher = SimpleNamespace(source_certificates={id(point):{'owner':point,'exact_box':exact_box}}) - cell = SimpleNamespace(work_budget=None,root_matcher=matcher) - assert point.stuv[0] > .5 and float(exact) == .5 - assert ssx._source_boundary_child_groups(cell,[point],_children()) == [[point],[]] - - -def test_derived_net_box_without_source_proof_cannot_exclude_a_child(): - point = _point() - point._source_root_box = False - point.root_box[0] = .6,.7 - cell = SimpleNamespace(work_budget=None,root_matcher=None) - assert ssx._source_boundary_child_groups(cell,[point],_children()) == [[point],[point]] - - -def test_closed_child_face_root_is_shared_even_with_wrong_representative(): - point = _point() - point.root_box[0] = .5,.5 - cell = SimpleNamespace(work_budget=None,root_matcher=None) - assert ssx._source_boundary_child_groups(cell,[point],_children()) == [[point],[point]] - - -def test_denied_partition_work_keeps_all_unexamined_events(): - point = _point() - point.root_box[0] = .1,.2 - budget = SoftWorkBudget(0,10) - cell = SimpleNamespace(work_budget=budget,root_matcher=None) - assert ssx._source_boundary_child_groups(cell,[point],_children()) == [[point],[point]] - assert budget.exhausted - - -def test_lazy_source_enclosure_is_requested_once_for_all_children(): - point = _point() - point._source_root_box = False - calls = [] - def enclose(event,radii): - calls.append(event) - box = point.root_box.copy() - box[0] = .1,.2 - return box - matcher = SimpleNamespace(source_certificates={},enclose=enclose) - cell = SimpleNamespace(work_budget=None,root_matcher=matcher) - groups = ssx._source_boundary_child_groups(cell,[point],_children(),np.full(4,.01)) - assert calls == [point] and point._source_root_box - assert groups == [[point],[]] diff --git a/tests/test_ssx_exact_region.py b/tests/test_ssx_exact_region.py deleted file mode 100644 index 645404a6..00000000 --- a/tests/test_ssx_exact_region.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Exact affine chart intersections own every dimension of their common set.""" -import numpy as np -import pytest - -from mmcore.numeric._work_budget import SoftWorkBudget -from mmcore.numeric.intersection.ssx._ssx_exact_region import exact_affine_plane_region_ssx - - -def _plane(x=(0., 1.), y=(0., 1.), z=0.): - return np.array([[[s, t, z] for t in y] for s in x]) - - -def _solve(a, b, *, atol=1e-10, rational=False, cells=10000, outputs=100): - budget = SoftWorkBudget(cells, 100, max_output_items=outputs) - result = exact_affine_plane_region_ssx(a, b, atol, rational, budget) - assert result is not None - result.update(budget.result_fields()) - return result - - -@pytest.mark.parametrize('flip,transpose', [(False, False), (True, False), (False, True), (True, True)]) -def test_full_region_preserves_paired_chart_orientation(flip, transpose): - a = _plane() - b = a[::-1].copy() if flip else a.copy() - if transpose: - b = b.swapaxes(0, 1).copy() - result = _solve(a, b) - assert result['complete'] - assert len(result['overlap_regions']) == 1 and len(result['branches']) == 4 - region = result['overlap_regions'][0] - assert region.normal_agreement == (-1 if flip != transpose else 1) - assert len(region.boundary[0]) == 4 - np.testing.assert_array_equal(region.uv1_loops[0][0], region.uv1_loops[0][-1]) - for branch in result['branches']: - stuv, xyz = branch.curve - np.testing.assert_array_equal(stuv[:, :2], xyz[:, :2]) - expected = stuv[:, 2:].copy() - if transpose: - expected = expected[:, ::-1] - if flip: - expected[:, 0] = 1-expected[:, 0] - np.testing.assert_array_equal(expected, xyz[:, :2]) - - -def test_partial_rotated_overlap_preserves_all_eight_rims(): - a = _plane((-1., 1.), (-1., 1.)) - b = np.array([[[-1.5, 0., 0.], [0., 1.5, 0.]], - [[0., -1.5, 0.], [1.5, 0., 0.]]]) - result = _solve(a, b) - assert result['complete'] and len(result['branches']) == 8 - assert len(result['overlap_regions']) == 1 - loop = result['overlap_regions'][0].uv1_loops[0] - assert len(loop) == 9 - np.testing.assert_array_equal(loop[0], loop[-1]) - - -@pytest.mark.parametrize('x,y,counts', [((1., 2.), (0., 1.), (1, 0)), - ((1., 2.), (1., 2.), (0, 1)), - ((2., 3.), (0., 1.), (0, 0))]) -def test_edge_point_and_empty_intersections_keep_their_dimension(x, y, counts): - result = _solve(_plane(), _plane(x, y)) - assert result['complete'] and not result['overlap_regions'] - assert (len(result['branches']), len(result['points'])) == counts - if counts[0]: - assert result['branches'][0].kind == 'overlap' - - -def test_uniform_homogeneous_scaling_and_degree_elevation(): - a = _plane((0., .5, 1.), (0., .5, 1.)) - b = _plane((.5, 1.5), (0., 1.)) - ah = np.concatenate((a, np.ones(a.shape[:-1]+(1,))), axis=-1)*2.**-800 - bh = np.concatenate((b, np.ones(b.shape[:-1]+(1,))), axis=-1)*2.**800 - result = _solve(ah, bh, rational=True) - assert result['complete'] and len(result['overlap_regions']) == 1 - - -def test_nonaffine_chart_and_nonzero_plane_gap_are_not_promoted(): - a = _plane() - curved = a.copy() - curved[1, 1, 0] += .25 - for other in (curved, _plane(z=2.**-60)): - budget = SoftWorkBudget(10000, 100) - assert exact_affine_plane_region_ssx(a, other, 1e-3, False, budget) is None - - -def test_bad_float_parameters_preserve_exact_component_as_typed_partial(): - result = _solve(_plane((0., 1e16)), _plane((1., 2.)), atol=1e-30) - assert not result['complete'] - assert not result['branches'] and not result['overlap_regions'] - issue = result['unresolved_regions'][0] - assert issue['reason'] == 'parameter_representation' - assert len(issue['exact_stuv']) == 4 - - -def test_output_cap_never_publishes_a_region_with_missing_rims(): - result = _solve(_plane(), _plane(), outputs=4) - assert not result['complete'] and 'output_cap' in result['status']['reasons'] - assert not result['branches'] and not result['overlap_regions'] - assert result['unresolved_regions'][0]['exact_dimension'] == 2 - - -def test_float_alias_in_either_injective_chart_keeps_the_segment_unresolved(): - result = _solve(_plane((-1e16, 1e16)), _plane((0., 1.), (1., 2.)), atol=2.) - assert not result['complete'] and not result['branches'] - assert result['unresolved_regions'][0]['exact_dimension'] == 1 - - -def test_zero_allowance_prevents_exact_source_construction(monkeypatch): - from mmcore.numeric.intersection.ssx import _ssx_exact_region as module - monkeypatch.setattr(module, '_exact_points', lambda *args: (_ for _ in ()).throw(AssertionError('unpaid'))) - budget = SoftWorkBudget(0, 100) - assert exact_affine_plane_region_ssx(_plane(), _plane(), 1e-3, False, budget) is None - assert budget.exhausted and budget.cells_processed == 0 diff --git a/tests/test_ssx_exact_root_separation.py b/tests/test_ssx_exact_root_separation.py deleted file mode 100644 index e348a794..00000000 --- a/tests/test_ssx_exact_root_separation.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Exact source events can share every displayed floating coordinate.""" -from fractions import Fraction - -import numpy as np - -from mmcore.numeric.intersection.ssx._bez_ssx5 import BoundaryPoint -from mmcore.numeric.intersection.ssx._ssx_affine_constraints import AffineParameterConstraints -from mmcore.numeric.intersection.ssx._ssx_planar_cut import exact_source_planar_cut -from mmcore.numeric.intersection.ssx._ssx_root_identity import BoundaryRootIdentity - - -def _source_events(cuts): - coordinates, square = (0.,.5,1.), (1.,-1.,1.) - graph = np.array([[[s,t,square[i]+square[j]] for j,t in enumerate(coordinates)] - for i,s in enumerate(coordinates)]) - plane = np.array([[[u,v,.5] for v in (-.5,1.5)] for u in (-.5,1.5)]) - first,second = [np.concatenate((net*2e-4,np.ones(net.shape[:2]+(1,))),axis=2) - for net in (graph,plane)] - identity = BoundaryRootIdentity(first,second) - identity.affine_constraints = AffineParameterConstraints(identity.affine) - points = [] - for axis,value in cuts: - result = exact_source_planar_cut(first,second,axis,value,((0.,1.),)*4,max_cells=2000) - assert result['boundary_topology_complete'] - root = min(result['isolated'],key=lambda r:np.linalg.norm( - r['stuv']-np.array([.25,.75,.375,.625]))) - point = BoundaryPoint(root['stuv'],root['point'],(axis,-1)) - assert identity.register_source_root(point,root['source_cut_certificate']) - points.append(point) - boxes = tuple(identity.source_certificates[id(point)]['exact_box'] for point in points) - return identity,points,boxes - - -def _disjoint(first,second,axis): - a,b = first[axis],second[axis] - return a[1] < b[0] or b[1] < a[0] - - -def test_exact_pins_survive_identical_display_parameters(): - identity,points,boxes = _source_events(((0,.25),(2,.375))) - np.testing.assert_array_equal(points[0].stuv,points[1].stuv) - assert boxes[0][0] == (Fraction(1,4),)*2 - assert boxes[1][0] == (Fraction(9838263505978429,39353054023913712),)*2 - assert _disjoint(*boxes,0) - refined = identity.separate_source_boxes(points,boxes) - assert _disjoint(*refined,0) - assert len(refined) == 2 - - -def test_owner_refinement_preserves_nonbinary_fraction_separator(): - identity,points,boxes = _source_events(((3,.625),(2,.375))) - separator = boxes[1][0][0] - assert Fraction(float(separator)) != separator - assert boxes[0][0][0] < separator < boxes[0][0][1] - refined = identity.refine_source_box(points[0],boxes[0],boxes[1]) - assert refined[0][0] == separator - assert identity.source_certificates[id(points[0])]['exact_box'] == boxes[0] - - -def test_sturm_refinement_separates_distinct_roots_below_one_ulp(): - identity,points,boxes = _source_events(((3,.625),(2,.375))) - np.testing.assert_array_equal(points[0].stuv,points[1].stuv) - assert not any(_disjoint(*boxes,axis) for axis in range(4)) - refined = identity.separate_source_boxes(points,boxes) - assert _disjoint(*refined,0) and _disjoint(*refined,1) - for point,old,new in zip(points,boxes,refined): - certificate = identity.source_certificates[id(point)] - assert certificate['exact_box'] == old - assert all(a <= lo <= hi <= b for (a,b),(lo,hi) in zip(old,new)) - varying = certificate['varying_axis'] - polynomial = certificate['exact_polynomial'] - def value(x): - return sum(coefficient*x**i for i,coefficient in enumerate(polynomial)) - assert value(new[varying][0])*value(new[varying][1]) <= 0 - np.testing.assert_array_equal(points[0].stuv,points[1].stuv) - - -def test_exact_root_at_separator_is_retained_not_declared_distinct(): - identity,points,boxes = _source_events(((0,.25),(0,.25))) - refined = identity.separate_source_boxes(points,boxes) - assert refined == boxes - assert not any(_disjoint(*refined,axis) for axis in range(4)) - - -def test_denied_exact_separation_retains_both_original_source_boxes(): - identity,points,boxes = _source_events(((3,.625),(2,.375))) - identity.charge = lambda amount:False - assert identity.separate_source_boxes(points,boxes) == boxes - assert identity.exhausted - assert all(identity.source_certificates[id(p)]['exact_box'] == b - for p,b in zip(points,boxes)) - - -def test_missing_source_certificate_keeps_the_supplied_enclosure(): - identity,points,boxes = _source_events(((3,.625),(2,.375))) - identity.source_certificates.clear() - assert identity.separate_source_boxes(points,boxes) == boxes - - -def test_work_exhaustion_during_sturm_refinement_keeps_both_root_identities(): - identity,points,boxes = _source_events(((3,.625),(2,.375))) - spent = 0 - def charge(amount): - nonlocal spent - if spent+amount > 8: - return False - spent += amount - return True - identity.charge = charge - refined = identity.separate_source_boxes(points,boxes) - assert identity.exhausted and 0 < spent <= 8 - assert refined == boxes - assert all(identity.source_certificates[id(p)]['exact_box'] == b - for p,b in zip(points,boxes)) diff --git a/tests/test_ssx_exact_source_planar_cut.py b/tests/test_ssx_exact_source_planar_cut.py deleted file mode 100644 index 27a23b83..00000000 --- a/tests/test_ssx_exact_source_planar_cut.py +++ /dev/null @@ -1,189 +0,0 @@ -from fractions import Fraction - -import numpy as np -import pytest - -from mmcore.numeric.intersection.ssx._ssx_planar_cut import exact_source_planar_cut - - -def sources(radius=2.**-18): - controls = (0., .5, 1.) - first = np.empty((3, 3, 4)) - u_square = np.array([.375**2, .375**2-.375, (1-.375)**2]) - v_square = np.array([.625**2, .625**2-.625, (1-.625)**2]) - for i, u in enumerate(controls): - for j, v in enumerate(controls): - first[i, j] = u, v, u_square[i]+v_square[j]-radius**2, 1. - second = np.array([[[u, v, 0., 1.] for v in (-.25, 1.25)] - for u in (-.25, 1.25)]) - return first, second - - -@pytest.mark.parametrize('axis', [0, 1, 2, 3]) -def test_exact_source_cut_counts_original_circle_crossings(axis): - first, second = sources() - graph_center = (.375, .625)[axis % 2] - cut = graph_center if axis < 2 else (graph_center+.25)/1.5 - result = exact_source_planar_cut(first, second, axis, cut, ((0., 1.),)*4, - max_cells=1000, atol=1e-12) - assert result is not None - assert result['boundary_topology_complete'], result - assert len(result['isolated']) == 2 - for root in result['isolated']: - assert root['stuv'][axis] == cut - cert = root['source_cut_certificate'] - assert cert['source_ids'] == (id(first), id(second)) - assert cert['kind'] == 'exact_source_planar_cut' - assert cert['parameter_root_box'] - assert root['point'][2] == pytest.approx(0., abs=1e-15) - - -def test_source_extremum_is_one_double_root_after_narrow_restriction(): - radius = 2.**-18 - first, second = sources(radius) - cut = .375+radius - box = ((cut-radius/16, cut+radius/16), (.625-radius/16, .625+radius/16), - (0., 1.), (0., 1.)) - result = exact_source_planar_cut(first, second, 0, cut, box, max_cells=1000) - assert result['boundary_topology_complete'], result - root, = result['isolated'] - assert root['root_multiplicity'] == 2 - assert root['stuv'][1] == .625 - assert root['parameter_root_box'][1] == (.625, .625) - - -@pytest.mark.parametrize('direction', [-np.inf, np.inf]) -def test_reverse_cut_keeps_exact_nonbinary_graph_pin(direction): - radius = 2.**-18 - first, second = sources(radius) - cut = float(np.nextafter((.375+radius+.25)/1.5, direction)) - exact_graph_pin = -Fraction(1, 4)+Fraction(3, 2)*Fraction(cut) - discriminant = Fraction(radius)**2-(exact_graph_pin-Fraction(3, 8))**2 - result = exact_source_planar_cut(first, second, 2, cut, ((0., 1.),)*4, - max_cells=1000) - assert result['boundary_topology_complete'], result - assert len(result['isolated']) == (2 if discriminant > 0 else 0) - for root in result['isolated']: - pins = dict(root['source_cut_certificate']['pinned']) - assert Fraction(*map(int, pins[0])) == exact_graph_pin - - -def test_target_rectangle_clips_exactly_before_publishing_roots(): - first, second = sources() - # Target y >= .625 admits just the upper crossing. - low = Fraction(7, 12) # (.625+.25)/1.5 - box = ((0., 1.), (0., 1.), (0., 1.), (float(low), 1.)) - result = exact_source_planar_cut(first, second, 0, .375, box, max_cells=1000) - assert result['boundary_topology_complete'], result - root, = result['isolated'] - assert root['stuv'][1] > .625 - - -@pytest.mark.parametrize('limit', [0, 1, 50]) -def test_source_cut_construction_preflight_honors_budget(limit): - result = exact_source_planar_cut(*sources(), 0, .375, ((0., 1.),)*4, - max_cells=limit) - assert result['cells_processed'] <= limit - assert not result['boundary_topology_complete'] - - -def test_nonaffine_plane_declines_and_identity_cut_retains_source_obligation(): - first, second = sources() - second[1, 1, 0] += .125 - assert exact_source_planar_cut(first, second, 0, .375, ((0., 1.),)*4) is None - first, second = sources() - first[..., 2] = 0. - result = exact_source_planar_cut(first, second, 0, .375, ((0., 1.),)*4) - assert not result['boundary_topology_complete'] - assert result['unresolved_source_boxes'][0]['reason'] == 'positive_dimensional_cut' - - -def test_reverse_cut_skips_nonplanar_bilinear_graph_as_plane_candidate(): - graph = np.array([[[u, v, u*v-.25, 1.] for v in (0., 1.)] for u in (0., 1.)]) - _, plane = sources() - result = exact_source_planar_cut(graph, plane, 2, .5, ((0., 1.),)*4, - max_cells=1000) - assert result['boundary_topology_complete'], result - root, = result['isolated'] - assert root['stuv'] == pytest.approx([.5, .5, .5, .5]) - - -def _registered_cut(identity, first, second, axis, cut): - from mmcore.numeric.intersection.ssx._bez_ssx5 import BoundaryPoint - result = exact_source_planar_cut(first, second, axis, cut, ((0., 1.),)*4, - max_cells=1000) - root, = result['isolated'] - point = BoundaryPoint(stuv=root['stuv'], xyz=root['point'], face=(axis, -1), - tangent_raw=np.ones(4), root_box=np.array(root['parameter_root_box'])) - point._source_root_box = True - assert identity.register_source_root(point, root['source_cut_certificate']) - return point - - -def test_distinct_exact_source_roots_do_not_merge_when_float_stuv_aliases(): - from mmcore.numeric.intersection.ssx._ssx_root_identity import BoundaryRootIdentity - graph = np.array([[[u, v, (v-.5)*z, 1.] for v in (0., 1.)] - for u, z in zip((0., .5, 1.), (1., 1., 2.))]) - plane = np.array([[[u, v, 0., 1.] for v in (0., 1.)] for u in (0., 3.)]) - identity = BoundaryRootIdentity(graph, plane) - a = _registered_cut(identity, graph, plane, 0, .5) - b = _registered_cut(identity, graph, plane, 2, 1/6) - np.testing.assert_array_equal(a.stuv, b.stuv) - # The plane cut pins graph u=3*float(1/6), not the exact value1/2. - assert Fraction(3)*Fraction(1/6) != Fraction(1, 2) - assert not identity(a, b, np.full(4, .001), .001) - - -def test_registered_temporary_point_cannot_release_its_id_for_another_root(): - import gc - import weakref - from mmcore.numeric.intersection.ssx._ssx_root_identity import BoundaryRootIdentity - graph = np.array([[[u, v, u*v-.25, 1.] for v in (0., 1.)] for u in (0., 1.)]) - _, plane = sources() - identity = BoundaryRootIdentity(graph, plane) - point = _registered_cut(identity, graph, plane, 0, .5) - reference = weakref.ref(point) - del point - gc.collect() - # The registry uses object IDs: retaining the owner prevents stale - # certificate lookup when CPython recycles a temporary proposal's ID. - assert reference() is not None - - -def test_source_representation_work_is_reserved_before_exact_evaluation(monkeypatch): - from mmcore.numeric.intersection.ssx import _ssx_planar_cut as module - first, second = sources() - full = exact_source_planar_cut(first, second, 0, .375, ((0., 1.),)*4, - max_cells=1000) - assert full['boundary_topology_complete'] - original = module._surface_point - calls = [] - def counted(*args): - calls.append(1) - return original(*args) - monkeypatch.setattr(module, '_surface_point', counted) - limited = exact_source_planar_cut(first, second, 0, .375, ((0., 1.),)*4, - max_cells=full['cells_processed']-1) - assert not limited['boundary_topology_complete'] - assert limited['cells_processed'] <= full['cells_processed']-1 - assert len(limited['isolated']) == 1 - assert len(calls) == 2 # Only the prepaid first root evaluates both sources. - assert limited['unresolved_source_boxes'] - - -def test_reverse_cut_uses_exact_affine_identity_with_nonuniform_graph_weights(): - # W=3+3u, P_x=3u*W, P_y=v*W, P_z=(v-.5)*(1+u²)*W. - # All homogeneous cubic coefficients are exact integers or halves. - weights = (3., 4., 5., 6.) - px = (0., 3., 9., 18.) - zfactor = (3., 4., 6., 12.) - graph = np.array([[[x, v*w, (v-.5)*z, w] for v in (0., 1.)] - for x, w, z in zip(px, weights, zfactor)]) - plane = np.array([[[u, v, 0., 1.] for v in (0., 1.)] for u in (0., 1.)]) - result = exact_source_planar_cut(graph, plane, 2, .5, ((0., 1.),)*4, - max_cells=1000, atol=1e-12) - assert result['boundary_topology_complete'], result - root, = result['isolated'] - assert root['stuv'] == pytest.approx([1/6, .5, .5, .5]) - pins = dict(root['source_cut_certificate']['pinned']) - assert Fraction(*map(int, pins[0])) == Fraction(1, 6) diff --git a/tests/test_ssx_face_residual.py b/tests/test_ssx_face_residual.py deleted file mode 100644 index 84db2b2f..00000000 --- a/tests/test_ssx_face_residual.py +++ /dev/null @@ -1,78 +0,0 @@ -from fractions import Fraction - -import numpy as np -import pytest - -from mmcore.numeric.intersection.ssx._ssx_cofactor_bounds import SourceCofactorBounds -from mmcore.numeric.intersection.ssx._ssx_face_residual import SourceFaceResiduals - - -def _fraction_net(net): - return np.array([Fraction(float(x)) for x in net.flat], dtype=object).reshape(net.shape) - - -def _blossom_restrict(net, axis, lo, hi): - """Exact blossom formula, independent of sequential split/rescale code.""" - values = np.moveaxis(net, axis, 0) - degree = len(values)-1 - out = [] - for index in range(degree+1): - current = values.copy() - for parameter in [lo]*(degree-index)+[hi]*index: - current = (1-parameter)*current[:-1]+parameter*current[1:] - out.append(current[0]) - return np.moveaxis(np.array(out, dtype=object), 0, axis) - - -@pytest.mark.parametrize('axis', range(4)) -def test_transported_face_encloses_every_exact_source_coefficient(axis): - rng = np.random.default_rng(392) - first = rng.normal(size=(3, 2, 4)) - second = rng.normal(size=(2, 3, 4)) - first[..., 3] = rng.uniform(.5, 2., size=(3, 2)) - second[..., 3] = rng.uniform(.5, 2., size=(2, 3)) - source = SourceCofactorBounds(first, second, polynomial=False) - faces = SourceFaceResiduals(source) - box = ((.1, .7), (.2, .8), (.3, .9), (.15, .95)) - pin = .6 - net, error = faces(axis, pin, box) - a, b = _fraction_net(first), _fraction_net(second) - exact = (a[:, :, None, None, :3]*b[None, None, :, :, 3:] - - b[None, None, :, :, :3]*a[:, :, None, None, 3:]) - for k, (lo, hi) in enumerate(box): - if k == axis: - lo = hi = pin - exact = _blossom_restrict(exact, k, Fraction(lo), Fraction(hi)) - exact = np.take(exact, 0, axis=axis) - remaining = [k for k in range(4) if k != axis] - order = [axis ^ 1]+([2, 3] if axis < 2 else [0, 1]) - exact = np.transpose(exact, [remaining.index(k) for k in order]+[3]) - if axis >= 2: - exact = -exact - for index in np.ndindex(net.shape): - assert abs(Fraction(float(net[index]))-exact[index]) <= Fraction(float(error[index[-1]])) - assert faces(axis, pin, box) is faces(axis, pin, box) - assert not net.flags.writeable - - -def test_face_construction_is_prepaid_and_denial_never_returns_rounded_geometry(): - first = np.array([[[s, t, s+t, 1.] for t in (0., 1.)] for s in (0., 1.)]) - source = SourceCofactorBounds(first, first, polynomial=False) - faces = SourceFaceResiduals(source, charge=lambda units: False) - assert faces(0, .5, ((0., 1.),)*4) is None - assert faces.exhausted - assert not faces.cache - - -def test_exact_face_root_uses_exact_affine_image_not_rounded_global_parameters(): - # x=3s-1 has the exact root s=1/3. A local t=1/2 on [0,2/3] - # maps to the stored floating approximation of 1/3, not its exact root. - first = np.array([[[3*s-1, t, 0., 1.] for t in (0., 1.)] for s in (0., 1.)]) - second = np.array([[[0., u, v, 1.] for v in (0., 1.)] for u in (0., 1.)]) - faces = SourceFaceResiduals(SourceCofactorBounds(first, second), sources=(first, second)) - face = (1, .5, ((0., 2/3), (0., 1.), (0., 1.), (0., 1.))) - assert not faces.exact_root(face, (.5, .5, 0.)) - # A dyadic, exactly representable closed-boundary root is accepted. - first[..., 0] += 1. - exact_faces = SourceFaceResiduals(SourceCofactorBounds(first, second), sources=(first, second)) - assert exact_faces.exact_root(face, (0., .5, 0.)) diff --git a/tests/test_ssx_fixed_source_face_census.py b/tests/test_ssx_fixed_source_face_census.py deleted file mode 100644 index 4378d08d..00000000 --- a/tests/test_ssx_fixed_source_face_census.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Exact affine faces can exhaust a cell without zero-width patch nets.""" -from fractions import Fraction -from types import SimpleNamespace - -import numpy as np -import pytest - -from examples.ssx.ssx5_analytic_audit import ( - audit_result, case_components, graph_pair, solve_case, -) -from mmcore.numeric.intersection._exact_univariate import _value -from mmcore.numeric.intersection.ssx._ssx_affine_constraints import AffineParameterConstraints -from mmcore.numeric.intersection.ssx._ssx_planar_cut import exact_source_planar_cut -from mmcore.numeric.intersection.ssx._ssx_root_identity import _pure_affine_coordinates -from mmcore.numeric.intersection.ssx._ssx_root_identity import BoundaryRootIdentity -from mmcore.numeric.intersection.ssx import _bez_ssx5 as ssx - - -def test_exact_fixed_face_census_excludes_a_rounded_corner_alias(): - surfaces = tuple(np.concatenate((net,np.ones(net.shape[:2]+(1,))),axis=-1) - for net in graph_pair(case_components('nested_circles'))) - owner = ((.375,.5),(.25,.5),(.5,np.nextafter(2/3,np.inf)),(.25,.375)) - constraints = AffineParameterConstraints(tuple(_pure_affine_coordinates(net) - for net in surfaces)) - necessary = constraints.contract(owner) - assert necessary[0] == necessary[2] == (.5,.5) - assert necessary[1][1] == .3125 - whole = exact_source_planar_cut(*surfaces,0,.5,((0.,1.),)*4) - rounded_corner = whole['isolated'][1] - np.testing.assert_array_equal(rounded_corner['stuv'],[.5,.3125,.5,.375]) - certificate = rounded_corner['source_cut_certificate'] - polynomial = [Fraction(int(a),int(b)) for a,b in certificate['polynomial']] - assert _value(polynomial,Fraction(5,16)) != 0 - # The exact root is above the owner's t/v bounds, despite its rounded - # representative landing on their shared corner. The original source - # face solves the entire necessary zero set of this positive-width box. - local = exact_source_planar_cut(*surfaces,0,.5,owner) - assert local['boundary_topology_complete'] - assert not local['isolated'] - assert not local['budget_exhausted'] - - -@pytest.mark.parametrize('variant', ['identity', 'swap']) -def test_fixed_face_reduction_preserves_complete_nested_closed_components(variant): - result = solve_case('nested_circles',variant) - report = audit_result('nested_circles',result) - assert report['passed'],report - - -def test_nonroot_sturm_endpoint_excludes_a_borrowed_rounded_corner(): - surfaces = tuple(np.concatenate((net,np.ones(net.shape[:2]+(1,))),axis=-1) - for net in reversed(graph_pair(case_components('nested_circles')))) - census = exact_source_planar_cut(*surfaces,1,.5,((0.,1.),)*4) - root = next(root for root in census['isolated'] - if tuple(root['stuv']) == (.625,.5,.6875,.5)) - point = ssx.BoundaryPoint(root['stuv'],root['point'],(1,-1), - root_box=np.asarray(root['parameter_root_box'])) - matcher = BoundaryRootIdentity(*surfaces) - assert matcher.register_source_root(point,root['source_cut_certificate']) - owner = ((.5625,.625),(.375,.5),(.59375,.6875),(.3125,.5)) - cell = SimpleNamespace(box=owner,root_matcher=matcher) - certificate = matcher.source_certificates[id(point)] - axis = certificate['varying_axis'] - low, high = certificate['exact_interval'] - assert low == Fraction(owner[axis][1]) < high - assert _value(certificate['exact_polynomial'],low) != 0 - assert ssx._source_boundary_outside(cell,point) - # Its other closed child owns the actual root. Denied/absent source - # evidence is never a reason to discard the borrowed representative. - cell.box = ((.625,.75),(.375,.5),(.6875,.875),(.3125,.5)) - assert not ssx._source_boundary_outside(cell,point) - cell.box = owner - matcher.charge = lambda amount: False - assert not ssx._source_boundary_outside(cell,point) - matcher.source_certificates.clear() - assert not ssx._source_boundary_outside(cell,point) - - -def test_an_actual_endpoint_root_is_retained_in_both_closed_owners(): - first = np.array([[[s,t,2*t-1,1.] for t in (0.,1.)] for s in (0.,1.)]) - second = np.array([[[s,t,0.,1.] for t in (0.,1.)] for s in (0.,1.)]) - matcher = BoundaryRootIdentity(first,second) - root = exact_source_planar_cut(first,second,0,.5,((0.,1.),)*4)['isolated'][0] - point = ssx.BoundaryPoint(root['stuv'],root['point'],(0,-1), - root_box=np.asarray(root['parameter_root_box'])) - assert matcher.register_source_root(point,root['source_cut_certificate']) - # The varying root t=v=1/2 is an actual source zero on the shared face. - for owner in (((0.,1.),(0.,.5),(0.,1.),(0.,.5)), - ((0.,1.),(.5,1.),(0.,1.),(.5,1.))): - assert not ssx._source_boundary_outside(SimpleNamespace(box=owner,root_matcher=matcher),point) - - -def test_open_sturm_endpoints_do_not_make_false_linked_corner_faces(): - surfaces = tuple(np.concatenate((net,np.ones(net.shape[:2]+(1,))),axis=-1) - for net in graph_pair(case_components('four_lines'))) - matcher = BoundaryRootIdentity(*surfaces) - matcher.affine_constraints = AffineParameterConstraints(matcher.affine) - census = exact_source_planar_cut(*surfaces,1,0.,((0.,1.),)*4) - root = next(root for root in census['isolated'] if root['stuv'][0] == .875) - point = ssx.BoundaryPoint(root['stuv'],root['point'],(1,-1), - root_box=np.asarray(root['parameter_root_box'])) - assert matcher.register_source_root(point,root['source_cut_certificate']) - owner = ((.875,1.),(0.,1.),(.75,1.),(0.,1.)) - cell = SimpleNamespace(box=owner,root_matcher=matcher) - exact_box = matcher.source_certificates[id(point)]['exact_box'] - assert exact_box[0][0] == Fraction(7,8) - assert exact_box[2][0] == Fraction(3,4) - assert ssx._source_boundary_possible_faces(cell,point,exact_box) == [(1,1.)] - # Without paid scalar/affine evidence the extra possibly active faces - # remain constraints; they are never guessed away from a tiny residual. - matcher.charge = lambda amount: False - faces = ssx._source_boundary_possible_faces(cell,point,exact_box) - assert (0,1.) in faces and (2,1.) in faces - - -def test_generic_source_inclusion_can_exclude_an_unrelated_closed_child(): - first = np.array([[[s,t,2*t-1,1.] for t in (0.,1.)] for s in (0.,1.)]) - second = np.array([[[s,t,0.,1.] for t in (0.,1.)] for s in (0.,1.)]) - matcher = BoundaryRootIdentity(first,second) - point = ssx.BoundaryPoint(np.array([.25,.5,.25,.5]),np.array([.25,.5,0.]),(0,-1)) - point.root_box = matcher.enclose(point,np.full(4,.001)) - assert point.root_box is not None - assert not matcher.source_certificates # Generic source inclusion. - cell = SimpleNamespace(box=((.5,1.),(0.,1.),(0.,1.),(0.,1.)),root_matcher=matcher) - assert not ssx._source_boundary_outside(cell,point) # No source flag. - point._source_root_box = True - assert ssx._source_boundary_outside(cell,point) - cell.box = ((0.,.5),(0.,1.),(0.,1.),(0.,1.)) - assert not ssx._source_boundary_outside(cell,point) diff --git a/tests/test_ssx_global_injectivity_c3.py b/tests/test_ssx_global_injectivity_c3.py deleted file mode 100644 index 7abb218f..00000000 --- a/tests/test_ssx_global_injectivity_c3.py +++ /dev/null @@ -1,42 +0,0 @@ -"""C3 retirement needs a global proof on both original source charts.""" -import numpy as np - -from mmcore.numeric.intersection.ssx import _bez_ssx5 as ssx -from mmcore.numeric.intersection.ssx import _ssx5_singular as singular -from mmcore.numeric.intersection.ssx._ssx5_overlap import _exact_surface_injective -from mmcore.numeric.intersection.ssx._ssx_root_identity import _pure_affine_coordinates - - -def _curved_pair(): - # x/y are small nonlinear perturbations of (s,t), so no world - # coordinate directly recovers either parameter. A constant left - # projection nevertheless proves global chart injectivity. - first = np.array([[[s+s*t/8,t+s*t/8,t-.25-.25*square,1.] for t in (0.,1.)] - for s,square in zip((0.,.5,1.),(0.,0.,1.))]) - second = np.array([[[u,v,0.,1.] for v in (-.25,1.5)] for u in (-.25,1.5)]) - return first,second - - -def test_two_original_global_chart_proofs_skip_c3_search(monkeypatch): - first,second = _curved_pair() - assert not _pure_affine_coordinates(first) - assert _exact_surface_injective(first) - calls = [] - original = singular.c3_pass - def tracked(*args,**kwargs): - calls.append(True) - return original(*args,**kwargs) - monkeypatch.setattr(singular,'c3_pass',tracked) - result = ssx.bez_ssx(first,second,rational=True) - assert result['complete'],result['status'] - assert any(len(branch.curve[0]) >= 9 for branch in result['branches']) - assert not calls - assert result['status']['work']['cell_counts'].get('global_injectivity',0) > 0 - - -def test_folded_and_nonuniform_charts_have_no_polynomial_injectivity_proof(): - folded = np.array([[[x,t,0.,1.] for t in (0.,1.)] for x in (.25,-.25,.25)]) - assert not _exact_surface_injective(folded) - weighted = _curved_pair()[0] - weighted[1] *= 2. - assert not _exact_surface_injective(weighted) diff --git a/tests/test_ssx_known_port_ownership.py b/tests/test_ssx_known_port_ownership.py deleted file mode 100644 index 5fe0cf2e..00000000 --- a/tests/test_ssx_known_port_ownership.py +++ /dev/null @@ -1,49 +0,0 @@ -"""An unidentified exit cannot discharge an already proved two-port arc.""" -from fractions import Fraction - -import numpy as np - - -def test_declined_equivalent_face_identity_keeps_known_arc_owner_partial(monkeypatch): - from mmcore.numeric.intersection.ssx import _bez_ssx5 as ssx - from mmcore.numeric.intersection.ssx._ssx_root_identity import BoundaryRootIdentity - - first = np.array([[[i/2,j/2,i*j/4] for j in range(3)] for i in range(3)]) - second = first.copy() - z = (1.,-1.,1.) - second[...,2] += np.array([[z[i]+z[j]-.5 for j in range(3)] for i in range(3)]) - matrix = np.array([[1.,1.,0.],[0.,1.,1.],[1.,0.,1.]]) - - def original_common_slice(self,first,second): - # Model an unavailable projected-coordinate proof. Same-face and - # original pure-coordinate proofs remain admissible. - a,b = first.face[0],second.face[0] - va,vb = float(first.stuv[a]),float(second.stuv[b]) - if a == b: - return (a,va) if va == vb else None - if a//2 == b//2: - return None - for coordinate in range(3): - left = self.affine[a//2].get((coordinate,a%2)) - right = self.affine[b//2].get((coordinate,b%2)) - if (left is not None and right is not None - and left[0]+left[1]*Fraction(va) == right[0]+right[1]*Fraction(vb)): - return a,va - return None - - monkeypatch.setattr(BoundaryRootIdentity,'_common_slice',original_common_slice) - real_trace = ssx._trace_cell_by_registrations - calls = [] - def recording(cell,*args,**kwargs): - result = real_trace(cell,*args,**kwargs) - if len(cell.crossings) == 2: - calls.append((len(result[0]),cell.trace_incomplete)) - return result - monkeypatch.setattr(ssx,'_trace_cell_by_registrations',recording) - result = ssx.bez_ssx(first@matrix.T,second@matrix.T,rational=False, - atol=1e-3,max_cells=60000,max_depth=4) - assert calls - assert any(incomplete for count,incomplete in calls) - assert all(incomplete or count <= 1 for count,incomplete in calls) - assert not result['complete'] - assert result['unresolved_regions'] diff --git a/tests/test_ssx_local_boundary_obligations.py b/tests/test_ssx_local_boundary_obligations.py deleted file mode 100644 index 8ab9dc5b..00000000 --- a/tests/test_ssx_local_boundary_obligations.py +++ /dev/null @@ -1,74 +0,0 @@ -"""An unresolved face affects only closed children that intersect it.""" -from types import SimpleNamespace - -import numpy as np - -from mmcore.numeric._work_budget import SoftWorkBudget -from mmcore.numeric.intersection.ssx import _bez_ssx5 as ssx - - -def box(axis=0, interval=(0.,1.)): - bounds = [(0.,1.)]*4 - bounds[axis] = interval - return tuple(bounds) - - -def owner(complete, obligations, budget=None): - return SimpleNamespace(boundary_complete=complete, - boundary_obligations=obligations,work_budget=budget) - - -def test_incomplete_face_does_not_poison_disjoint_descendants(): - face = box(interval=(0.,0.)) - children = [box(interval=(0.,.5)),box(interval=(.5,1.))] - result = ssx._source_boundary_child_obligations(owner(False,(face,)),children) - assert result == [(face,),()] - - -def test_new_incomplete_cut_is_inherited_on_both_closed_sides_only(): - face = box(interval=(.5,.5)) - children = [box(interval=(0.,.25)),box(interval=(.25,.5)),box(interval=(.5,1.))] - result = ssx._source_boundary_child_obligations(owner(True,None),children,(face,)) - assert result == [(),(face,),(face,)] - - -def test_missing_localization_cannot_recover_an_incomplete_census(): - children = [box(interval=(0.,.5)),box(interval=(.5,1.))] - assert ssx._source_boundary_child_obligations(owner(False,None),children) == [None,None] - - -def test_all_four_parameter_bounds_participate_in_face_ownership(): - face = list(box(interval=(.5,.5))) - face[3] = (0.,.25) - face = tuple(face) - children = [box(axis=3,interval=(0.,.25)),box(axis=3,interval=(.5,1.))] - assert ssx._source_boundary_child_obligations(owner(False,(face,)),children) == [(face,),()] - - -def test_denied_localization_keeps_unknown_census(): - budget = SoftWorkBudget(0,10) - children = [box(interval=(.5,1.))] - assert ssx._source_boundary_child_obligations( - owner(False,(box(interval=(0.,0.)),),budget),children) == [None] - assert budget.exhausted - - -def test_top_face_failure_retains_its_exact_closed_face_box(): - net = np.array([[[u,v,0.] for v in (0.,1.)] for u in (0.,1.)]) - def census(curve,surface,axis,value,**kwargs): - complete = not (axis == 2 and value == 1.) - return dict(isolated=[],overlaps=[],parameter_fibers=[], - boundary_topology_complete=complete,budget_exhausted=False) - status = {} - ssx._find_ssx_boundary_zeros(net,net,1e-3,rational=False, - face_csx_fn=census,census_sink=status) - assert not status['complete'] - assert status['boundary_obligations'] == [box(axis=2,interval=(1.,1.))] - - -def test_progress_filter_cannot_recreate_an_adjacent_float_child(): - lo = .5 - hi = np.nextafter(lo,1.) - bounds = box(interval=(lo,hi)) - assert ssx._strict_interior_cuts(bounds,0,[.5*(lo+hi)]) == [] - assert ssx._strict_interior_cuts(bounds,1,[.5]) == [.5] diff --git a/tests/test_ssx_overlap_endpoint_polish.py b/tests/test_ssx_overlap_endpoint_polish.py new file mode 100644 index 00000000..d1b772dd --- /dev/null +++ b/tests/test_ssx_overlap_endpoint_polish.py @@ -0,0 +1,60 @@ +"""Overlap endpoint correction cannot choose a remote boundary contact.""" +from math import comb + +import numpy as np +import pytest + +from mmcore.numeric._bezier_common import eval_surface +from mmcore.numeric.intersection.ssx import _bez_ssx5 as ssx + + +def _homogeneous(points): + return np.concatenate((points, np.ones(points.shape[:2]+(1,))), axis=-1) + + +@pytest.mark.parametrize('swap', [False, True]) +@pytest.mark.parametrize('rational', [False, True]) +def test_straight_boundary_fringe_returns_to_its_local_contact(swap, rational): + first = np.array([[[t, s, .5*(t-.002)] for t in (0., 1.)] + for s in (0., 1.)]) + second = np.array([[[u, 0., v] for v in (0., 1.)] for u in (0., 1.)]) + if rational: + first, second = _homogeneous(first), _homogeneous(second) + # Positive weights change the parameterization of the straight edges. + first *= np.array([[1., 2.], [1.5, 1.]])[..., None] + second *= np.array([[1., 1.5], [2., 1.]])[..., None] + if swap: + first, second = second, first + result = ssx._polish_overlap_domain_endpoint( + first, second, np.zeros(4), 2 if swap else 0, (0., 1.), .001, rational) + points = [eval_surface(surface, *uv, rational=rational) + for surface, uv in ((first, result[:2]), (second, result[2:]))] + for point in points: + np.testing.assert_allclose(point, [.002, 0., 0.], atol=1e-12, rtol=0.) + + +def test_curved_edge_with_two_contacts_cannot_jump_to_the_far_root(): + # z=.01*(t*t-.01)*(1-t): contacts at .1 and 1. An unrestricted + # Newton step from the tolerance fringe t=0 jumps directly to t=1. + power = np.array([-.0001, .0001, .01, -.01]) + height = np.array([sum(power[j]*comb(i, j)/comb(3, j) + for j in range(i+1)) for i in range(4)]) + first = np.array([[[t, s, z] for t, z in zip(np.linspace(0., 1., 4), height)] + for s in (0., 1.)]) + second = np.array([[[u, 0., v] for v in (0., 1.)] for u in (0., 1.)]) + original = np.zeros(4) + result = ssx._polish_overlap_domain_endpoint( + _homogeneous(first), _homogeneous(second), original, 0, (0., 1.), .001, True) + np.testing.assert_array_equal(result, original) + + +def test_endpoint_polish_does_not_consume_most_of_a_tolerance_overlap(): + first = np.array([[[t, s, .0001*(t-.9)] for t in (0., 1.)] + for s in (0., 1.)]) + second = np.array([[[u, 0., v] for v in (0., 1.)] for u in (0., 1.)]) + original = np.zeros(4) + result = ssx._polish_overlap_domain_endpoint( + first, second, original, 0, (0., 1.), .001, False) + # A far contact requires re-searching the overlap. Correction alone + # must not replace the already recovered interval with its far end. + np.testing.assert_array_equal(result, original) diff --git a/tests/test_ssx_overlap_union_cleanup.py b/tests/test_ssx_overlap_union_cleanup.py new file mode 100644 index 00000000..332cc427 --- /dev/null +++ b/tests/test_ssx_overlap_union_cleanup.py @@ -0,0 +1,106 @@ +"""Paired overlap unions remove retraces without joining gaps or sheets.""" +import numpy as np + +from mmcore.numeric.intersection.ssx import _bez_ssx5 as ssx + + +def _branch(xy, *, overlap=False, shift=0.): + xy = np.asarray(xy, dtype=float) + parameters = np.column_stack([xy, xy]) + parameters[:, 0] += shift + return ssx.SSXBranch( + curve=(parameters, np.column_stack([xy, np.zeros(len(xy))])), + overlap=overlap, kind='overlap' if overlap else 'transversal') + + +def test_trace_can_be_covered_by_two_meeting_overlap_edges(): + left = _branch([[0., 0.], [1., 0.]], overlap=True) + right = _branch([[1., 0.], [1., 1.]], overlap=True) + trace = _branch([[0., 0.], [.5, 0.], [1., 0.], [1., .5], [1., 1.]]) + result = ssx._drop_traces_covered_by_overlap_union( + [trace, left, right], [left, right], .001, np.full(4, .001)) + assert [id(branch) for branch in result] == [id(left), id(right)] + + +def test_separate_overlap_arrays_do_not_create_a_gap_covering_chord(): + left = _branch([[0., 0.], [.4, 0.]], overlap=True) + right = _branch([[.6, 0.], [1., 0.]], overlap=True) + bridge = _branch([[0., 0.], [1., 0.]]) + result = ssx._drop_traces_covered_by_overlap_union( + [bridge, left, right], [left, right], .001, np.full(4, .001)) + assert any(branch is bridge for branch in result) + + +def test_geometrically_coincident_distinct_parameter_sheet_survives(): + overlap = _branch([[0., 0.], [1., 0.]], overlap=True) + other_sheet = _branch([[0., 0.], [.5, 0.], [1., 0.]], shift=.1) + result = ssx._drop_traces_covered_by_overlap_union( + [other_sheet, overlap], [overlap], .001, np.full(4, .001)) + assert any(branch is other_sheet for branch in result) + + +def test_incident_arch_leaving_the_overlap_union_survives(): + left = _branch([[0., 0.], [.5, 0.]], overlap=True) + right = _branch([[.5, 0.], [1., 0.]], overlap=True) + arch = _branch([[0., 0.], [.5, .25], [1., 0.]]) + result = ssx._drop_traces_covered_by_overlap_union( + [arch, left, right], [left, right], .001, np.full(4, .001)) + assert any(branch is arch for branch in result) + + +def test_cleanup_denial_preserves_discovered_trace(): + overlap = _branch([[0., 0.], [1., 0.]], overlap=True) + trace = _branch([[0., 0.], [1., 0.]]) + budget = ssx._SSXSoftBudget(max_cells=100, max_csx_calls=10, + max_postprocess_work=0) + result = ssx._drop_traces_covered_by_overlap_union( + [trace, overlap], [overlap], .001, np.full(4, .001), budget) + assert any(branch is trace for branch in result) + assert 'postprocess_cap' in budget.reasons + + +def _curved_inverse_example(folded_partner=False): + # S1(s,t)=(s,(1+s)*t,0): a straight horizontal XYZ line has the + # nonlinear inverse t=.5/(1+s). The second chart is either affine or + # folded, in which case two different u values represent each point. + first = np.array([[[0., 0., 0., 1.], [0., 1., 0., 1.]], + [[1., 0., 0., 1.], [1., 2., 0., 1.]]]) + if folded_partner: + second = np.array([[[x, y, 0., 1.] for y in (0., 1.)] + for x in (1., -1., 1.)]) + else: + second = np.array([[[x, y, 0., 1.] for y in (0., 1.)] + for x in (0., 1.)]) + + def branch(count, overlap, other_sheet=False): + x = np.linspace(.1, .9, count) + partner_u = (.5 + (.5 if other_sheet else -.5)*np.sqrt(x) + if folded_partner else x) + q = np.column_stack([x, .5/(1.+x), partner_u, np.full(count, .5)]) + xyz = np.column_stack([x, np.full(count, .5), np.zeros(count)]) + return ssx.SSXBranch(curve=(q, xyz), overlap=overlap, + kind='overlap' if overlap else 'transversal') + + return first, second, branch + + +def test_inverse_resampling_removes_a_coarse_parameter_chord_retrace(): + first, second, make = _curved_inverse_example() + overlap, trace = make(65, True), make(2, False) + # The unrefined parameter chord leaves the shared straight XYZ edge. + original = ssx._drop_traces_covered_by_overlap_union( + [trace, overlap], [overlap], .001, np.full(4, .001)) + assert any(branch is trace for branch in original) + result = ssx._drop_traces_covered_by_overlap_union( + [trace, overlap], [overlap], .001, np.full(4, .001), + surfaces=(first, second)) + assert [id(branch) for branch in result] == [id(overlap)] + + +def test_inverse_resampling_keeps_a_folded_partners_other_preimage(): + first, second, make = _curved_inverse_example(folded_partner=True) + overlap, other_sheet = make(65, True), make(2, False, other_sheet=True) + result = ssx._drop_traces_covered_by_overlap_union( + [other_sheet, overlap], [overlap], .001, np.full(4, .001), + surfaces=(first, second)) + assert any(branch is other_sheet for branch in result) diff --git a/tests/test_ssx_owned_root_transport.py b/tests/test_ssx_owned_root_transport.py deleted file mode 100644 index 29217fe3..00000000 --- a/tests/test_ssx_owned_root_transport.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Equivalent faces transport owned roots, not fresh proposal associations.""" -import numpy as np -import pytest - -from mmcore.numeric.intersection.ssx._bez_ssx5 import BoundaryPoint -from mmcore.numeric.intersection.ssx._ssx_affine_constraints import AffineParameterConstraints -from mmcore.numeric.intersection.ssx._ssx_root_identity import BoundaryRootIdentity - - -def matcher_and_points(second_interval): - first = np.array([[[s, t, 0., 1.] for t in (0., 1.)] for s in (0., 1.)]) - # The exact face s=u=1/2 contains t=v=1/4 and t=v=3/4. - height = (.1875, -.3125, .1875) - second = np.array([[[u, v, height[j], 1.] for j, v in enumerate((0., .5, 1.))] - for u in (0., 1.)]) - matcher = BoundaryRootIdentity(first, second) - matcher.affine_constraints = AffineParameterConstraints(matcher.affine) - # Source ownership is independent of the displayed proposal. A fresh - # Newton solve at either of these proposals can converge to root 1/4. - a = BoundaryPoint(np.array([.5, .25, .5, .25]), np.array([.5, .25, 0.]), (0, -1)) - b = BoundaryPoint(a.stuv.copy(), np.array([.5, .75, 0.]), (2, -1)) - for point, interval in ((a, (.24, .26)), (b, second_interval)): - point.root_box = np.array([(.5, .5), interval, (.5, .5), interval]) - point._source_root_box = True - return matcher, a, b - - -def test_new_proposal_enclosure_cannot_replace_another_owned_root(monkeypatch): - # Both boxes contain exactly one source root, but overlap in a root-free - # interval. Independent fresh proposal solves can select the SAME root - # despite these two different immutable ownership associations. - matcher, first, second = matcher_and_points((.255, .76)) - original_boxes = [point.root_box.copy() for point in (first, second)] - monkeypatch.setattr(matcher, 'enclose', lambda *a, **k: first.root_box.copy()) - assert not matcher(first, second, np.full(4, .1), 1.) - for point, original in zip((first, second), original_boxes): - np.testing.assert_array_equal(point.root_box, original) - - -def test_same_owned_root_on_equivalent_face_uses_existing_existence(monkeypatch): - matcher, first, second = matcher_and_points((.245, .3)) - second.xyz = first.xyz.copy() - monkeypatch.setattr(matcher, 'enclose', - lambda *a, **k: pytest.fail('owned root must not be reassociated')) - assert matcher(first, second, np.full(4, .1), .01) - - -def test_unproved_original_face_membership_cannot_be_replaced_by_a_fresh_root(monkeypatch): - matcher, first, second = matcher_and_points((.245, .3)) - second.root_box[2] = (.4, .6) - second.xyz = first.xyz.copy() - monkeypatch.setattr(matcher, 'enclose', - lambda *a, **k: pytest.fail('missing owned-face proof must not be guessed')) - assert not matcher(first, second, np.full(4, .1), .01) - - -def test_denied_union_proof_preserves_distinct_registrations(): - matcher, first, second = matcher_and_points((.245, .3)) - matcher.charge = lambda amount: False - assert not matcher(first, second, np.full(4, .1), 1.) - assert matcher.exhausted diff --git a/tests/test_ssx_partial_boundary_witnesses.py b/tests/test_ssx_partial_boundary_witnesses.py new file mode 100644 index 00000000..25b4b0f1 --- /dev/null +++ b/tests/test_ssx_partial_boundary_witnesses.py @@ -0,0 +1,29 @@ +"""Useful boundary geometry survives a local CSX result cap.""" +import numpy as np + +from mmcore.numeric.intersection.ssx import _bez_ssx5 as ssx + + +def test_partial_boundary_roots_remain_available_for_validated_tracing(monkeypatch): + first = np.array([[[s, t, 0.] for t in (0., 1.)] for s in (0., 1.)]) + second = np.array([[[u, v, v-.5] for v in (0., 1.)] for u in (0., 1.)]) + actual_csx = ssx.bez_csx + + def partial_csx(*args, **kwargs): + result = actual_csx(*args, **kwargs) + if result['isolated']: + result = dict(result, budget_exhausted=True, + boundary_topology_complete=False, + truncation_cause='results') + return result + + monkeypatch.setattr(ssx, 'bez_csx', partial_csx) + result = ssx.bez_ssx(first, second, atol=1e-3, rational=False, max_depth=0) + + assert not result['complete'] + assert result['branches'], result['status'] + points = np.vstack([branch.curve[1] for branch in result['branches']]) + assert points[:, 0].min() <= 1e-3 + assert points[:, 0].max() >= 1.-1e-3 + assert np.max(np.abs(points[:, 1]-.5)) <= 1e-3 + assert np.max(np.abs(points[:, 2])) <= 1e-3 diff --git a/tests/test_ssx_planar_overlap_clipping.py b/tests/test_ssx_planar_overlap_clipping.py new file mode 100644 index 00000000..971eec58 --- /dev/null +++ b/tests/test_ssx_planar_overlap_clipping.py @@ -0,0 +1,139 @@ +"""CAD polygon overlap: shared corners and both parameter-path images.""" +import numpy as np +import pytest + +from mmcore.numeric._bezier_common import eval_surface +from mmcore.numeric.intersection.ssx._ssx5_overlap import assemble_overlap_regions +from mmcore.numeric.intersection.ssx._ssx_substrate import SSXBranch + + +def _homogeneous(points, weights): + return np.concatenate((points*weights[..., None], weights[..., None]), axis=2) + + +def _assert_paired_chords(result, first, second, atol): + for branch in result['rim_branches']: + q, x = map(np.asarray, branch.curve) + assert len(q) >= 17 + for i in range(len(q)-1): + for t in np.linspace(0., 1., 17): + stuv = (1.-t)*q[i]+t*q[i+1] + xyz = (1.-t)*x[i]+t*x[i+1] + for surface, uv in ((first, stuv[:2]), (second, stuv[2:])): + assert np.linalg.norm(eval_surface(surface, *uv, rational=True)-xyz) <= atol + + +@pytest.mark.parametrize('weighted,swap,reverse', [ + (False, False, False), (False, True, True), + (True, False, False), (True, True, True), +]) +def test_oblique_polygon_clipping_keeps_eight_shared_corners(weighted, swap, reverse): + a = np.array([[[-1., -1., 0.], [-1., 1., 0.]], + [[1., -1., 0.], [1., 1., 0.]]]) + b = np.array([[[-1.5, 0., 0.], [0., 1.5, 0.]], + [[0., -1.5, 0.], [1.5, 0., 0.]]]) + w1 = np.array([[.4, 2.], [3., .7]]) if weighted else np.ones((2, 2)) + w2 = np.array([[2., .6], [.3, 4.]]) if weighted else np.ones((2, 2)) + pair = [_homogeneous(a, w1), _homogeneous(b, w2)] + if reverse: + pair[0] = pair[0][::-1].copy() + if swap: + pair.reverse() + atol = 1e-4 + result = assemble_overlap_regions(*pair, atol=atol, ptol4=np.full(4, atol/4)) + assert len(result['regions']) == 1 + assert len(result['rim_branches']) == 8 + region = result['regions'][0] + assert region.certification['orientation_consistent'] + assert region.normal_agreement == (-1 if reverse else 1) + expected = np.array([[-1., -.5, 0.], [-.5, -1., 0.], [.5, -1., 0.], [1., -.5, 0.], + [1., .5, 0.], [.5, 1., 0.], [-.5, 1., 0.], [-1., .5, 0.]]) + actual = np.array([b.curve[1][0] for b in result['rim_branches']]) + assert np.max(np.min(np.linalg.norm(expected[:, None]-actual[None], axis=2), axis=1)) < atol + ordered = [] + for index, reversed_ in region.boundary[0]: + q, x = result['rim_branches'][index].curve + if reversed_: + q, x = q[::-1], x[::-1] + if ordered: + np.testing.assert_array_equal(ordered[-1][0][-1], q[0]) + np.testing.assert_array_equal(ordered[-1][1][-1], x[0]) + ordered.append((q, x)) + np.testing.assert_array_equal(ordered[-1][0][-1], ordered[0][0][0]) + _assert_paired_chords(result, *pair, atol) + + +def test_rational_containment_rims_resolve_curved_parameter_inverse(): + outer = np.array([[[0., 0., 0.], [.3, 2.2, 0.]], + [[2.1, -.2, 0.], [2.9, 2.6, 0.]]]) + inner = np.array([[[.6, .5, 0.], [.7, 1.4, 0.]], + [[1.5, .55, 0.], [1.45, 1.5, 0.]]]) + pair = [_homogeneous(outer, np.array([[.1, 2.], [3., .3]])), + _homogeneous(inner, np.array([[2., .3], [.2, 4.]]))] + atol = 1e-5 + result = assemble_overlap_regions(*pair, atol=atol, ptol4=np.full(4, atol/4)) + assert len(result['regions']) == 1 + assert len(result['rim_branches']) == 4 + # The original 17 samples are insufficient for these nonlinear inverse + # parameterizations. Adaptive refinement must validate their chords. + assert max(len(b.curve[0]) for b in result['rim_branches']) > 17 + _assert_paired_chords(result, *pair, atol) + + +def test_overlap_clipping_budget_denial_preserves_existing_geometry(): + points = np.array([[[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]]]) + surface = _homogeneous(points, np.ones((2, 2))) + branch = SSXBranch(curve=(np.array([[0., 0., 0., 0.], [1., 0., 1., 0.]]), + np.array([[0., 0., 0.], [1., 0., 0.]])), + kind='overlap', overlap=True) + for allowance in (0, 25, 100): + used = 0 + + def charge(n): + nonlocal used + if used+n > allowance: + return False + used += n + return True + + result = assemble_overlap_regions(surface, surface, atol=1e-3, + ptol4=np.full(4, 1e-3), + existing_overlap_branches=[branch], charge=charge) + assert result['regions'] == [] and result['rim_branches'] == [] + assert result['unmatched_branches'] == [branch] + assert not result['covered'] and used <= allowance + + +def test_planar_region_owns_paired_interior_samples_but_keeps_foreign_paths(): + points = np.array([[[0., 0., 0.], [.3, 2.2, 0.]], + [[2.1, -.2, 0.], [2.9, 2.6, 0.]]]) + surface = _homogeneous(points, np.ones((2, 2))) + q = np.array([[0., 0., 0., 0.], [1., 1., 1., 1.]]) + x = points[[0, 1], [0, 1]].copy() + interior = SSXBranch(curve=(q, x), kind='tangential') + # The paired vertices are valid region samples, although their coarse + # straight XYZ interpolation does not follow this nonaffine chart. + result = assemble_overlap_regions(surface, surface, atol=1e-3, + ptol4=np.full(4, 1e-3), + existing_intersection_branches=[interior]) + assert len(result['regions']) == 1 and result['planar_pair_covered'] + assert result['unmatched_intersection_branches'] == [] + foreign = SSXBranch(curve=(q, x+np.array([0., 0., .1])), kind='tangential') + result = assemble_overlap_regions(surface, surface, atol=1e-3, + ptol4=np.full(4, 1e-3), + existing_intersection_branches=[foreign]) + assert len(result['regions']) == 1 + assert result['unmatched_intersection_branches'] == [foreign] + assert not result['planar_pair_covered'] + + +def test_planar_offset_is_judged_at_the_requested_cad_tolerance(): + points = np.array([[[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]]]) + first = _homogeneous(points, np.ones((2, 2))) + atol = 1e-3 + second = _homogeneous(points+np.array([0., 0., .2*atol]), np.ones((2, 2))) + result = assemble_overlap_regions(first, second, atol=atol, ptol4=np.full(4, atol)) + assert len(result['regions']) == 1 and result['planar_pair_covered'] + _assert_paired_chords(result, first, second, atol) diff --git a/tests/test_ssx_positive_dimensional_owner.py b/tests/test_ssx_positive_dimensional_owner.py deleted file mode 100644 index bfd37c91..00000000 --- a/tests/test_ssx_positive_dimensional_owner.py +++ /dev/null @@ -1,100 +0,0 @@ -from fractions import Fraction - -import numpy as np -import pytest - -from mmcore.numeric._work_budget import SoftWorkBudget -from mmcore.numeric.intersection.ssx._ssx_positive_dimensional import ( - exact_convex_bilinear_region_owner, -) - - -def pair(): - return ( - np.array([[[0., 0., 0.], [.25, 2., 0.]], - [[2., 0., 0.], [2.5, 2.5, 0.]]]), - np.array([[[.5, .5, 0.], [.5, 1.5, 0.]], - [[1.5, .5, 0.], [1.5, 1.5, 0.]]]), - ) - - -def budget(**kwargs): - return SoftWorkBudget(max_cells=10_000, max_csx_calls=100, **kwargs) - - -@pytest.mark.parametrize('rational', [False, True]) -@pytest.mark.parametrize('transpose', [False, True]) -def test_exact_region_owner_keeps_full_polygon_and_parameter_obligation(rational, transpose): - a, b = pair() - if transpose: - a, b = b.transpose(1, 0, 2), a[::-1] - if rational: - weights = np.array([[1., .5], [2., 4.]]) - a = np.concatenate([a*weights[..., None], weights[..., None]], axis=-1) - b = np.concatenate([b*weights[::-1, :, None], weights[::-1, :, None]], axis=-1) - work = budget() - result = exact_convex_bilinear_region_owner(a, b, rational, work) - assert result is not None - assert not result['branches'] and not result['points'] - assert work.incomplete and not work.exhausted - assert work.csx_calls == 0 - assert work.reasons == ['overlap_region_unsupported'] - owner, = result['unresolved_regions'] - assert owner['stuv_min'] == (0.,)*4 and owner['stuv_max'] == (1.,)*4 - assert owner['exact_dimension'] == 2 - polygon = [tuple(Fraction(x) for x in point) for point in owner['exact_image_polygon']] - assert set(polygon) == {(Fraction(x), Fraction(y), Fraction(0)) - for x in (.5, 1.5) for y in (.5, 1.5)} - assert owner['proof'] == 'exact_convex_rational_bilinear_region' - - -def test_nonzero_plane_gap_never_becomes_positive_dimensional_identity(): - a, b = pair() - b[..., 2] = np.nextafter(0., 1.) - assert exact_convex_bilinear_region_owner(a, b, False, budget()) is None - - -def test_common_plane_edge_contact_remains_with_the_curve_solver(): - a = np.array([[[s, t, 0.] for t in (0., 1.)] for s in (0., 1.)]) - b = a.copy() - b[..., 0] += 1. - assert exact_convex_bilinear_region_owner(a, b, False, budget()) is None - - -def test_folded_or_higher_degree_charts_are_not_retired(): - a, b = pair() - a[1, 1] = a[0, 0] - assert exact_convex_bilinear_region_owner(a, b, False, budget()) is None - a, b = pair() - a = np.stack([a[0], (a[0]+a[1])/2., a[1]]) - assert exact_convex_bilinear_region_owner(a, b, False, budget()) is None - - -def test_work_denial_precedes_exact_conversion(monkeypatch): - a, b = pair() - import mmcore.numeric.intersection.ssx._ssx_positive_dimensional as module - monkeypatch.setattr(module, '_exact_points', lambda *a: pytest.fail('unpaid conversion')) - work = SoftWorkBudget(max_cells=0, max_csx_calls=100) - assert module.exact_convex_bilinear_region_owner(a, b, False, work) is None - assert work.exhausted - - -def test_output_denial_keeps_the_structural_reason(): - a, b = pair() - work = budget(max_output_items=0) - result = exact_convex_bilinear_region_owner(a, b, False, work) - assert result is not None and result['unresolved_regions'] == [] - assert work.incomplete and 'overlap_region_unsupported' in work.reasons - assert 'output_cap' in work.reasons - - -def test_full_ssx_retains_the_exact_owner_without_product_search(): - from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx - a, b = pair() - result = bez_ssx(a, b, rational=False) - assert not result['complete'] - assert result['status']['reasons'] == ['overlap_region_unsupported'] - assert len(result['unresolved_regions']) == 1 - assert not result['branches'] and not result['points'] - assert result['status']['work']['csx_calls'] == 0 - assert result['status']['work']['cell_counts'].get('ssx', 0) == 0 diff --git a/tests/test_ssx_quadratic_constraints.py b/tests/test_ssx_quadratic_constraints.py deleted file mode 100644 index 340a2b01..00000000 --- a/tests/test_ssx_quadratic_constraints.py +++ /dev/null @@ -1,374 +0,0 @@ -"""Exact quadratic residual reduction preserves complete tangential curves.""" -import numpy as np -import pytest - -from mmcore.numeric._work_budget import SoftWorkBudget -from mmcore.numeric.intersection.ssx._ssx_quadratic_constraints import exact_quadratic_constraint_ssx - - -def _pair(root=.5): - # Common injective (x,y) chart; opposed squared heights touch on s=u=root. - heights = np.array([root*root, root*root-root, (1-root)**2]) - a = np.array([[[s+3*t*(1-t), t, heights[i]] for t in (0.,1/3,2/3,1.)] - for i,s in enumerate((0.,.5,1.))]) - # Exact degree-3 Bernstein coefficients of 3*t*(1-t), not samples. - a[...,0] = np.array([0.,1.,1.,0.])[None,:]+np.array([0.,.5,1.])[:,None] - a[...,1] = np.array([0.,1.,2.,3.])[None,:] - b = a.copy() - b[...,2] *= -1 - return a,b - - -def _solve(a,b,atol=1e-3,rational=False,cells=100000,outputs=100): - budget = SoftWorkBudget(cells,100,max_output_items=outputs) - result = exact_quadratic_constraint_ssx(a,b,atol,rational,budget) - if result is not None: - result.update(budget.result_fields()) - return result,budget - - -def _isolated_quadratic_pair(root=.5): - q = [root*root,root*root-root,(1-root)**2] - other = [.25,-.25,.25] - a = np.array([[[s,t,q[i]+other[j]] for j,t in enumerate((0.,.5,1.))] - for i,s in enumerate((0.,.5,1.))]) - b = np.array([[[s,t,0.] for t in (0.,1.)] for s in (0.,1.)]) - return a,b - - -@pytest.mark.parametrize('root,swap,rational',[(.5,False,False),(0.,False,False), - (1.,True,False),(.5,True,True)]) -def test_zero_dimensional_quadratic_reduction_publishes_exact_tangent_point(root,swap,rational): - a,b = _isolated_quadratic_pair(root) - if rational: - a = np.concatenate((a,np.ones(a.shape[:2]+(1,))),axis=-1)*2.**-300 - b = np.concatenate((b,np.ones(b.shape[:2]+(1,))),axis=-1)*2.**300 - if swap: - a,b = b,a - result,_ = _solve(a,b,rational=rational) - assert result is not None and result['complete'] - point, = result['singularities'] - assert point.kind == 'tangent_point' - np.testing.assert_array_equal(point.stuv,[root,.5,root,.5]) - np.testing.assert_array_equal(point.xyz,[root,.5,0.]) - assert not result['branches'] and not result['points'] - - -def test_zero_dimensional_quadratic_root_outside_domain_is_complete_empty(): - result,_ = _solve(*_isolated_quadratic_pair(-1.)) - assert result is not None and result['complete'] - assert not any(result[key] for key in ('branches','points','singularities')) - - -def test_zero_dimensional_quadratic_output_denial_remains_partial(): - result,budget = _solve(*_isolated_quadratic_pair(),outputs=0) - assert result is not None and not result['complete'] - assert 'output_cap' in result['status']['reasons'] - assert not result['singularities'] and budget.output_items == 0 - - -@pytest.mark.parametrize('swap',[False,True]) -def test_zero_dimensional_degenerate_source_is_c1_not_tangent(swap): - a = np.array([[[x,t,x+y] for t,y in ((0.,0.),(.5,0.),(1.,1.))] - for x in (0.,0.,1.)]) - b = np.array([[[s,t,0.] for t in (0.,1.)] for s in (0.,1.)]) - if swap: - a,b = b,a - result,_ = _solve(a,b) - assert result is not None and result['complete'] - feature, = result['singularities'] - assert feature.kind == 'cusp' and feature.surface == (2 if swap else 1) - np.testing.assert_array_equal(feature.stuv,np.zeros(4)) - assert not result['branches'] and not result['points'] - - -def test_zero_dimensional_world_representation_failure_is_explicit(): - a,b = _isolated_quadratic_pair() - a[...,0] = np.array([1e16,1e16,1e16+2])[:,None] - b[...,0] = np.array([1e16,1e16+2])[:,None] - result,_ = _solve(a,b) - assert result is not None and not result['complete'] - assert result['status']['reasons'] == ['parameter_representation'] - assert not result['branches'] and not result['points'] and not result['singularities'] - - -@pytest.mark.parametrize('swap,reverse,transpose,rational',[ - (False,False,False,False),(True,False,False,False), - (False,True,False,False),(False,False,True,False), - (True,True,True,True)]) -def test_factor_then_linear_reduction_preserves_generator_and_collapsed_fiber( - swap,reverse,transpose,rational): - a = np.array([[[0.,0.,0.],[0.,0.,0.]],[[0.,1.,-1.],[1.,1.,1.]]]) - b = np.array([[[-1.5,-1.5,0.],[-1.5,1.5,0.]], - [[1.5,-1.5,0.],[1.5,1.5,0.]]]) - if reverse: - a = a[::-1].copy() - if transpose: - a = a.swapaxes(0,1).copy() - if rational: - a = np.concatenate((a,np.ones(a.shape[:2]+(1,))),axis=-1)*2.**-300 - b = np.concatenate((b,np.ones(b.shape[:2]+(1,))),axis=-1)*2.**300 - if swap: - a,b = b,a - result,_ = _solve(a,b,rational=rational) - assert result is not None and result['complete'] - branch, = result['branches'] - assert branch.kind == 'transversal' - np.testing.assert_allclose(np.sort(branch.curve[1][:,1]),[0.,1.],atol=1e-14) - np.testing.assert_allclose(branch.curve[1][:,0],branch.curve[1][:,1]/2,atol=1e-14) - np.testing.assert_array_equal(branch.curve[1][:,2],0.) - fiber, = result['singularities'] - assert fiber.kind == 'cusp_curve' and fiber.surface == (2 if swap else 1) - np.testing.assert_array_equal(fiber.xyz,np.zeros(3)) - assert len(fiber.samples) >= 2 and len(fiber.branch_links) == 1 - owner = 1 if swap else 0 - free_axis = 2*owner+(0 if transpose else 1) - assert set(fiber.samples[:,free_axis]) == {0.,1.} - assert not result['points'] and not result['unresolved_regions'] - - -def test_factor_union_output_cap_keeps_its_first_represented_stratum(): - a = np.array([[[0.,0.,0.],[0.,0.,0.]],[[0.,1.,-1.],[1.,1.,1.]]]) - b = np.array([[[-1.5,-1.5,0.],[-1.5,1.5,0.]], - [[1.5,-1.5,0.],[1.5,1.5,0.]]]) - result,budget = _solve(a,b,outputs=1) - assert result is not None and not result['complete'] - assert len(result['branches'])+len(result['singularities']) == 1 - assert 'output_cap' in result['status']['reasons'] - assert budget.output_items == 1 - - -@pytest.mark.parametrize('root',[.25,.75]) -def test_factor_fiber_junction_is_not_fixed_to_the_midpoint(root): - a = np.array([[[s*t,s,s*(t-root)] for t in (0.,1.)] for s in (0.,1.)]) - b = np.array([[[-1.5,-1.5,0.],[-1.5,1.5,0.]], - [[1.5,-1.5,0.],[1.5,1.5,0.]]]) - result,_ = _solve(a,b) - assert result is not None and result['complete'] - branch, = result['branches'] - np.testing.assert_array_equal(branch.curve[0][:,1],root) - np.testing.assert_allclose(branch.curve[1],[[0.,0.,0.],[root,1.,0.]],atol=1e-14) - fiber, = result['singularities'] - assert fiber.kind == 'cusp_curve' and fiber.branch_links == [(0,0)] - - -def test_factor_with_uneliminated_nonlinear_relation_keeps_general_search(): - # Factoring z alone gives two planes, but x equality still restricts - # each to a parabola. Neither factor is an affine component. - a = np.array([[[s*t,s,(s-.25)*(t-.5)] for t in (0.,1.)] for s in (0.,1.)]) - b = np.array([[[x,v,0.] for v in (0.,1.)] for x in (0.,0.,1.)]) - assert _solve(a,b)[0] is None - - -@pytest.mark.parametrize('swap,flip,transpose',[(False,False,False),(True,False,False), - (False,True,False),(False,False,True)]) -def test_complete_curved_tangential_line_under_parameter_changes(swap,flip,transpose): - a,b = _pair() - if flip: - b = b[::-1].copy() - if transpose: - b = b.swapaxes(0,1).copy() - if swap: - a,b = b,a - result,_ = _solve(a,b) - assert result is not None and result['complete'] - branch, = result['branches'] - assert branch.kind == 'tangential' and not branch.closed - stuv,xyz = branch.curve - assert len(xyz) > 2 - t = xyz[:,1]/3 - np.testing.assert_allclose(xyz[:,0],.5+3*t*(1-t),atol=1e-12) - np.testing.assert_allclose(xyz[:,2],0.,atol=1e-12) - assert {float(t[0]),float(t[-1])} == {0.,1.} - # Independent quadratic sagitta for every represented chord. - assert np.max(.75*np.diff(t)**2) <= 1e-3 - assert stuv.shape == (len(xyz),4) - - -def test_uniform_rational_scaling_preserves_exact_reduction(): - a,b = _pair() - ah = np.concatenate((a,np.ones(a.shape[:2]+(1,))),axis=-1)*2.**-800 - bh = np.concatenate((b,np.ones(b.shape[:2]+(1,))),axis=-1)*2.**800 - result,_ = _solve(ah,bh,rational=True) - assert result is not None and result['complete'] - assert len(result['branches']) == 1 - - -def test_positive_minimum_is_complete_empty_even_below_modeling_tolerance(): - a,b = _pair() - a[...,2] += 2.**-40 - result,_ = _solve(a,b) - assert result is not None and result['complete'] - assert not result['branches'] and not result['points'] - - -def test_negative_minimum_and_two_dimensional_identity_fall_back(): - a,b = _pair() - a[...,2] -= 2.**-20 - assert _solve(a,b)[0] is None - assert _solve(b,b.copy())[0] is None - - -def test_factored_indefinite_quadratic_keeps_both_complete_source_lines(): - a = np.array([[[s,t,z-w] for t,w in ((0.,.25),(.5,-.25),(1.,.25))] - for s,z in ((0.,.25),(.5,-.25),(1.,.25))]) - b = np.array([[[u,v,0.] for v in (0.,1.)] for u in (0.,1.)]) - result,_ = _solve(a,b) - assert result is not None and result['complete'] - assert len(result['branches']) == 4 - junction, = result['singularities'] - assert junction.kind == 'tangent_point' - np.testing.assert_array_equal(junction.stuv,[.5]*4) - assert len(junction.branch_links) == 4 - total = sum(np.linalg.norm(np.diff(branch.curve[1],axis=0),axis=1).sum() - for branch in result['branches']) - assert total == pytest.approx(2*np.sqrt(2.)) - ends = [tuple(p) for branch in result['branches'] for p in branch.curve[1][[0,-1]]] - assert all(p in ends for p in ((0.,0.,0.),(0.,1.,0.),(1.,0.,0.),(1.,1.,0.))) - - -def test_nonfactorable_indefinite_quadratic_keeps_general_search(): - a = np.array([[[s,t,z-w-.125] for t,w in ((0.,.25),(.5,-.25),(1.,.25))] - for s,z in ((0.,.25),(.5,-.25),(1.,.25))]) - b = np.array([[[u,v,0.] for v in (0.,1.)] for u in (0.,1.)]) - assert _solve(a,b)[0] is None - - -def _factor_graph(a,b): - from fractions import Fraction as F - a,b = tuple(map(F,a)),tuple(map(F,b)) - constant,linear_s,linear_t = a[0]*b[0],a[0]*b[1]+a[1]*b[0],a[0]*b[2]+a[2]*b[0] - square_s,square_t,mixed = a[1]*b[1],a[2]*b[2],a[1]*b[2]+a[2]*b[1] - graph = np.empty((3,3,3)) - for i in range(3): - for j in range(3): - z = constant+linear_s*F(i,2)+linear_t*F(j,2) - z += square_s*(i == 2)+square_t*(j == 2)+mixed*F(i*j,4) - assert F(float(z)) == z - graph[i,j] = i/2,j/2,float(z) - plane = np.array([[[u,v,0.] for v in (0.,1.)] for u in (0.,1.)]) - return graph,plane - - -@pytest.mark.parametrize('swap,flip,transpose,rational',[ - (False,False,False,False),(True,False,False,False), - (False,True,False,False),(False,False,True,False),(True,True,True,True)]) -def test_mixed_only_quadratic_factor_union_is_parameter_invariant(swap,flip,transpose,rational): - a,b = _factor_graph((-.5,1,0),(-.5,0,1)) - if flip: - a = a[::-1].copy() - if transpose: - b = b.swapaxes(0,1).copy() - if rational: - a = np.concatenate((a,np.ones(a.shape[:2]+(1,))),axis=-1)*2.**-300 - b = np.concatenate((b,np.ones(b.shape[:2]+(1,))),axis=-1)*2.**300 - if swap: - a,b = b,a - result,_ = _solve(a,b,rational=rational) - assert result is not None and result['complete'] - assert len(result['branches']) == 4 - junction, = result['singularities'] - np.testing.assert_array_equal(junction.xyz,[.5,.5,0.]) - assert sum(np.linalg.norm(np.diff(branch.curve[1],axis=0),axis=1).sum() - for branch in result['branches']) == pytest.approx(2.) - assert all(branch.kind == 'transversal' for branch in result['branches']) - - -def test_nonbinary_factor_junction_retains_exact_source_identity(): - from fractions import Fraction as F - result,_ = _solve(*_factor_graph((-1,2,1),(-.125,1,-2)),atol=1e-6) - assert result is not None and result['complete'] - junction, = result['singularities'] - np.testing.assert_allclose(junction.xyz,[17/40,3/20,0.],atol=1e-12) - exact = (F(17,40),F(3,20),F(17,40),F(3,20)) - for index,end in junction.branch_links: - branch = result['branches'][index] - assert branch._exact_endpoint_keys[0 if end == 0 else 1] == exact - assert branch._source_parameter_path == branch._exact_endpoint_keys - np.testing.assert_array_equal(branch.curve[0][end],junction.stuv) - - -def test_factor_union_output_cap_keeps_already_published_arm(): - result,_ = _solve(*_factor_graph((-.5,1,0),(-.5,0,1)),outputs=1) - assert result is not None and not result['complete'] - assert len(result['branches']) == 1 - assert 'output_cap' in result['status']['reasons'] - - -def test_factor_proof_work_stop_reaches_public_whole_owner_partial(): - from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx - pair = _factor_graph((-.5,1,0),(-.5,0,1)) - result,budget = _solve(*pair,cells=700) - assert result is None and budget.exhausted - public = bez_ssx(*pair,atol=1e-3,rational=False,max_cells=700) - assert not public['complete'] and 'work_budget' in public['status']['reasons'] - assert any(tuple(box['stuv_min']) == (0.,)*4 and tuple(box['stuv_max']) == (1.,)*4 - for box in public['unresolved_regions']) - - -def test_factor_representation_work_stop_preserves_the_first_known_arm(): - result,budget = _solve(*_factor_graph((-.5,1,0),(-.5,0,1)),cells=1000) - assert result is not None and not result['complete'] - assert len(result['branches']) == 1 - assert 'work_budget' in result['status']['reasons'] - assert any(box['source_certificate'] == 'exact_quadratic_affine_union' - for box in result['unresolved_regions']) - assert budget.cells_processed <= 1000 - - -def test_nonbinary_root_has_typed_exact_parameter_payload(): - a,b = _pair() - a[...,2] = np.array([1.,-2.,4.])[:,None] - b[...,2] = -a[...,2] - result,_ = _solve(a,b,atol=1e-30) - assert result is not None and not result['complete'] - assert 'parameter_representation' in result['status']['reasons'] - issue, = result['unresolved_regions'] - assert issue['exact_stuv'][0][0] == '1/3' - assert not result['branches'] - - -def test_zero_work_does_not_construct_exact_polynomials(monkeypatch): - from mmcore.numeric.intersection.ssx import _ssx_quadratic_constraints as module - monkeypatch.setattr(module,'_power',lambda *_: (_ for _ in ()).throw(AssertionError('unpaid'))) - result,budget = _solve(*_pair(),cells=0) - assert result is None and budget.exhausted and budget.cells_processed == 0 - - -def test_output_cap_retains_the_exact_component_obligation(): - result,_ = _solve(*_pair(),outputs=0) - assert result is not None and not result['complete'] - assert 'output_cap' in result['status']['reasons'] - assert not result['branches'] - - -def test_work_stop_preserves_certified_prefix_and_exact_remaining_curve(): - result,budget = _solve(*_pair(),cells=4000) - assert result is not None and not result['complete'] - branch, = result['branches'] - issue, = result['unresolved_regions'] - assert 0. < branch.curve[1][-1,1] < 3. - assert issue['source_certificate'] == 'exact_quadratic_affine_zero_set' - from fractions import Fraction - remaining = Fraction(issue['remaining_path_interval'][0]) - assert float(remaining) == branch.curve[1][-1,1]/3 - assert budget.cells_processed <= budget.max_cells - - -def test_float_alias_on_one_chart_preserves_the_complete_preimage_obligation(): - a = np.array([[[s,t,z] for t in (0.,1.)] - for s,z in ((0.,.25),(.5,-.25),(1.,.25))]) - b = a.copy() - b[...,1] = np.array([-2.**59,2.**59])[None,:] - b[...,2] *= -1 - result,_ = _solve(a,b,atol=2.) - assert result is not None and not result['complete'] - assert not result['branches'] - assert result['unresolved_regions'][0]['reason'] == 'parameter_representation' - - -def test_boundary_tangency_keeps_overlap_classification(): - result,_ = _solve(*_pair(root=0.)) - assert result is not None and result['complete'] - branch, = result['branches'] - assert branch.kind == 'overlap' and branch.overlap diff --git a/tests/test_ssx_regular_cell_ownership.py b/tests/test_ssx_regular_cell_ownership.py deleted file mode 100644 index e830180b..00000000 --- a/tests/test_ssx_regular_cell_ownership.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Loop absence is not an exhaustive boundary census or an arc pairing.""" -from types import SimpleNamespace - -import numpy as np -import pytest - -from mmcore.numeric._work_budget import SoftWorkBudget -from mmcore.numeric.intersection.ssx import _bez_ssx5 as ssx - - -def _sources(): - first = np.array([[[s, t, 0.] for t in (0., 1.)] for s in (0., 1.)]) - second = np.array([[[s, t, t-.5] for t in (0., 1.)] for s in (0., 1.)]) - return first, second - - -@pytest.mark.parametrize('number, boundary_complete', [(2, False), (4, True)]) -def test_regular_tracer_does_not_discharge_unproved_arc_ownership(monkeypatch, number, - boundary_complete): - sources = tuple(ssx.GaussMapBern.from_surf(surface) for surface in _sources()) - roots = [ssx.BoundaryPoint(np.array([k/(number-1), .5, k/(number-1), .5]), - np.array([k/(number-1), .5, 0.]), (0, -1)) - for k in range(number)] - cell = ssx._Cell(*sources, roots, ((0., 1.),)*4, - boundary_complete=boundary_complete, - source_cofactors=SimpleNamespace(bounds=lambda box: (np.ones(4), np.ones(4))), - work_budget=SoftWorkBudget(1000, 100)) - def forbidden(*args, **kwargs): - raise AssertionError('ambiguous regular cell reached the numerical marcher') - monkeypatch.setattr(ssx, '_march_to_boundary', forbidden) - assert ssx._trace_cell_by_registrations(cell, .001) == ([], []) - assert cell.trace_incomplete - - -def test_loop_free_cell_with_an_incomplete_empty_census_stays_unresolved(monkeypatch): - def unknown_boundary(*args, **kwargs): - kwargs['census_sink']['complete'] = False - return [], [] - monkeypatch.setattr(ssx, '_find_ssx_boundary_zeros', unknown_boundary) - result = ssx.bez_ssx(*_sources(), rational=False, max_depth=0, max_xyz_step=.1) - assert not result['complete'] - assert 'depth_limit' in result['status']['reasons'] - assert result['unresolved_regions'] - - -@pytest.mark.parametrize('center, gap', [(.5, 2.**-28), (2.**-1000, 2.**-1003)]) -def test_certified_parameter_gap_survives_modeling_tolerance(center, gap): - roots = [] - for parameter in (center-gap, center+gap): - q = np.array([parameter, .5, parameter, .5]) - point = ssx.BoundaryPoint(q, np.zeros(3), (1, -1), - root_box=np.column_stack((q, q))) - point._source_root_box = True - roots.append(point) - cuts = ssx._root_separating_cuts(roots, 0, ((0., 1.),)*4, np.full(4, .001)) - assert center in cuts - - -def test_exact_root_interval_can_be_strictly_inside_a_float_touching_face(): - from fractions import Fraction - from mmcore.numeric.intersection.ssx._ssx_cofactor_bounds import SourceCofactorBounds - from mmcore.numeric.intersection.ssx._ssx_planar_cut import exact_source_planar_cut - from mmcore.numeric.intersection.ssx._ssx_root_identity import BoundaryRootIdentity - - first = np.array([[[s, t, 3*t-2, 1.] for t in (0., 1.)] for s in (0., 1.)]) - second = np.array([[[s, t, 0., 1.] for t in (0., 1.)] for s in (0., 1.)]) - matcher = BoundaryRootIdentity(first, second) - points = [] - for pin in (0., 1.): - result = exact_source_planar_cut(first, second, 0, pin, ((0., 1.),)*4) - assert result['boundary_topology_complete'] and len(result['isolated']) == 1 - root = result['isolated'][0] - point = ssx.BoundaryPoint(root['stuv'], root['point'], (0, -1), - root_box=np.asarray(root['parameter_root_box'])) - point._source_root_box = True - matcher.register_source_root(point, root['source_cut_certificate']) - points.append(point) - low = float(Fraction(2, 3)) - assert Fraction(low) < Fraction(2, 3) - box = ((0., 1.), (low, 1.), (0., 1.), (low, 1.)) - cell = ssx._Cell(ssx.GaussMapBern.from_surf(first, rational=True), - ssx.GaussMapBern.from_surf(second, rational=True), points, box, - boundary_complete=True, root_matcher=matcher, - source_cofactors=SourceCofactorBounds(first, second), - T1=np.ones((1,)*4), T2=np.ones((1,)*4), - T3=np.ones((1,)*4), T4=np.ones((1,)*4)) - # Outward float enclosures touch the t/v lower faces, where the true - # arc derivative is zero. Exact source t=v=2/3 lies strictly inside. - assert any(point.root_box[1, 0] == low for point in points) - assert ssx._regular_cell_arc_enclosure(cell) is not None - matcher.source_certificates.clear() - assert ssx._regular_cell_arc_enclosure(cell) is None diff --git a/tests/test_ssx_regular_empty_separators.py b/tests/test_ssx_regular_empty_separators.py deleted file mode 100644 index 2269eacc..00000000 --- a/tests/test_ssx_regular_empty_separators.py +++ /dev/null @@ -1,77 +0,0 @@ -from types import SimpleNamespace - -import numpy as np -import pytest - -from mmcore.numeric.intersection.ssx import _bez_ssx5 as ssx -from mmcore.numeric.intersection.ssx._ssx_cofactor_bounds import SourceCofactorBounds - - -def _two_arcs(): - # s=(t-.5)^2, clipped to 1/64 <= s <= 9/64, has two arcs - # on opposite sides of t=.5. A strict t minor fixes orientation. - first = np.array([[[s,t,s-q,1.] for t,q in - zip((0.,.5,1.),(.25,-.25,.25))] for s in (0.,1.)]) - second = np.array([[[s,t,0.,1.] for t in (0.,1.)] for s in (0.,1.)]) - roots = [] - for t in (.125,.375,.625,.875): - s = (t-.5)**2 - point = np.array([s,t,s,t]) - root = ssx.BoundaryPoint(point, np.array([s,t,0.]), (0,-1), - root_box=np.column_stack((point,point))) - root._source_root_box = True - roots.append(root) - return SimpleNamespace(crossings=roots, boundary_complete=True, - box=((1/64,9/64),(0.,1.),(1/64,9/64),(0.,1.)), - source_cofactors=SourceCofactorBounds(first,second), - root_matcher=None, work_budget=None) - - -def test_four_ports_prove_an_empty_monotone_parameter_separator(): - cell = _two_arcs() - assert ssx._regular_boundary_empty_separators(cell, None) == (1,[.5]) - certificate = cell._regular_empty_cut_certificate - assert certificate['axis'] == 1 - assert certificate['owner_box'] == cell.box - assert certificate['cuts'] == (.5,) - assert certificate['prefix_counts'] == (1,0,1,0) - - -@pytest.mark.parametrize('failure', ['incomplete','unproved','unknown_germ','wrong_germ']) -def test_missing_census_existence_or_consistent_germs_cannot_prove_empty(failure): - cell = _two_arcs() - if failure == 'incomplete': - cell.boundary_complete = False - elif failure == 'unproved': - cell.crossings[0]._source_root_box = False - else: - original = cell.source_cofactors - def bounds(box): - lo,hi = (a.copy() for a in original.bounds(box)) - if box[1][0] == box[1][1] == .125: - if failure == 'unknown_germ': - lo[0],hi[0] = -1.,1. - else: - lo[0],hi[0] = -hi[0],-lo[0] - return lo,hi - cell.source_cofactors = SimpleNamespace(bounds=bounds) - assert ssx._regular_boundary_empty_separators(cell,None) is None - assert cell._regular_empty_cut_certificate is None - - -def test_repeated_object_is_one_event_but_unproved_aliases_are_not_counted(): - cell = _two_arcs() - cell.crossings.append(cell.crossings[0]) - assert ssx._regular_boundary_empty_separators(cell,None) == (1,[.5]) - first = cell.crossings[0] - other = ssx.BoundaryPoint(first.stuv.copy(),first.xyz.copy(),first.face, - root_box=first.root_box.copy()) - other._source_root_box = True - cell.crossings.append(other) - assert ssx._regular_boundary_empty_separators(cell,None) is None - - -def test_work_denial_never_becomes_an_empty_census(): - cell = _two_arcs() - cell.work_budget = SimpleNamespace(charge_cells=lambda *args:False) - assert ssx._regular_boundary_empty_separators(cell,None) is None diff --git a/tests/test_ssx_residual_contraction.py b/tests/test_ssx_residual_contraction.py deleted file mode 100644 index 3e0d5f7f..00000000 --- a/tests/test_ssx_residual_contraction.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Necessary interval contraction must preserve every source zero.""" -import numpy as np - -from mmcore.numeric.intersection.ssx._ssx_cofactor_bounds import SourceCofactorBounds -from mmcore.numeric.intersection.ssx._ssx_residual_contract import SourceResidualContractor - - -def _plane(): - return np.array([[[u, v, 0., 1.] for v in (0., 1.)] for u in (0., 1.)]) - - -def test_correlated_parameter_box_contracts_without_claiming_existence(): - first = _plane() - second = np.array([[[u, .5, v-.5, 1.] for v in (0., 1.)] for u in (0., 1.)]) - contractor = SourceResidualContractor(SourceCofactorBounds(first, second)) - result = contractor.contract(((.25, .3), (0., 1.), (0., 1.), (0., 1.))) - assert result is not None - assert result[2][0] > .2 and result[2][1] < .4 - assert result[1][0] <= .5 <= result[1][1] - assert result[3][0] <= .5 <= result[3][1] - for t in np.linspace(.25, .3, 7): - assert all(lo <= q <= hi for q, (lo, hi) in zip((t, .5, t, .5), result)) - - -def test_singular_free_parameter_zero_set_is_never_removed(): - plane = _plane() - contractor = SourceResidualContractor(SourceCofactorBounds(plane, plane)) - box = ((.25, .75),)*4 - result = contractor.contract(box) - assert result is not None - for s in (.25, .5, .75): - for t in (.25, .5, .75): - assert all(lo <= q <= hi for q, (lo, hi) in zip((s, t, s, t), result)) - - -def test_denied_work_preserves_original_domain(): - plane = _plane() - source = SourceCofactorBounds(plane, plane) - source.charge = lambda amount: False - contractor = SourceResidualContractor(source) - box = ((.2, .7),)*4 - assert contractor.contract(box) == box - assert source.exhausted - - -def test_subnormal_geometry_cannot_create_a_false_exclusion(): - first = _plane() - first[..., :3] *= np.nextafter(0., 1.) - contractor = SourceResidualContractor(SourceCofactorBounds(first, first)) - box = ((.25, .75),)*4 - result = contractor.contract(box) - assert result is not None - assert all(lo <= .5 <= hi for lo, hi in result) - - -def test_subnormal_parameter_singleton_keeps_its_exact_source_root(): - tiny = np.nextafter(0., 1.) - first = _plane() - second = np.array([[[u, .5, v-.5, 1.] for v in (0., 1.)] for u in (0., 1.)]) - contractor = SourceResidualContractor(SourceCofactorBounds(first, second)) - box = ((tiny, tiny), (.5, .5), (tiny, tiny), (.5, .5)) - assert contractor.contract(box) == box - - -def test_disjoint_affine_planes_have_proved_empty_parameter_box(): - first = _plane() - second = np.array([[[u, .5, v+1., 1.] for v in (0., 1.)] for u in (0., 1.)]) - contractor = SourceResidualContractor(SourceCofactorBounds(first, second)) - assert contractor.contract(((0., 1.),)*4) is None diff --git a/tests/test_ssx_source_alias_assembly.py b/tests/test_ssx_source_alias_assembly.py deleted file mode 100644 index 1a9c3178..00000000 --- a/tests/test_ssx_source_alias_assembly.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Different exact source roots can have identical floating representatives.""" -from types import SimpleNamespace - -import numpy as np - -from test_ssx_exact_root_separation import _source_events -from mmcore.numeric.intersection.ssx import _bez_ssx5 as bez -from mmcore.numeric.intersection.ssx import _nssx5 as nurbs - - -def source_arc(): - identity,points,boxes = _source_events(((3,.625),(2,.375),(0,.25))) - boxes = identity.separate_source_boxes(points,boxes) - order = sorted(range(3),key=lambda i:sum(boxes[i][0])) - points = [points[i] for i in order] - fragments = [bez._Fragment(a,b,np.array([a.stuv,b.stuv]),np.array([a.xyz,b.xyz])) - for a,b in zip(points,points[1:])] - return identity,points,fragments - - -def test_registered_point_dedup_cannot_override_distinct_exact_sources(): - identity,points,_ = source_arc() - assert not identity(points[0],points[-1],np.full(4,.001),.001) - output = bez._deduplicate_ssx_points( - [bez._registered_point(point) for point in (points[0],points[-1],points[0])], - np.full(4,.001),.001,exact_topology=True,root_matcher=identity) - assert len(output) == 2 - - -def test_identical_displayed_ends_do_not_close_an_open_registered_arc(): - identity,points,fragments = source_arc() - np.testing.assert_array_equal(points[0].stuv,points[-1].stuv) - branch, = bez._assemble_fragments(fragments,root_matcher=identity) - assert len(branch.curve[0]) == 3 - assert not branch.closed - fragments.append(bez._Fragment(points[-1],points[0], - np.array([points[-1].stuv,points[0].stuv]), - np.array([points[-1].xyz,points[0].xyz]))) - cycle, = bez._assemble_fragments(fragments,root_matcher=identity) - assert cycle.closed - - -def test_nurbs_collection_preserves_same_pair_source_alias_ownership(): - identity,points,fragments = source_arc() - branch, = bez._assemble_fragments(fragments,root_matcher=identity) - raw = nurbs._RawResults() - nurbs._collect_pair(raw,dict(branches=[branch], - points=[bez._registered_point(point) for point in (points[0],points[-1])]), - tuple([0.,1.]*4),pair=(0,0)) - ctx = SimpleNamespace(lows=np.zeros(4),highs=np.ones(4),spans=np.ones(4), - ptol=np.full(4,.001),closed=np.zeros(4,dtype=bool), - source_patches=None) - agg = nurbs._make_aggregate({},1) - assert nurbs._build_chains(raw.frags,ctx,.001,agg) == [([(0,False)],False)] - assert len(nurbs._assemble_points(raw.points,[],ctx,.001,agg)) == 2 - - -def test_singularity_classification_does_not_consume_an_aliased_source_point(): - identity, points, _ = source_arc() - feature = SimpleNamespace(kind='tangent_point', stuv=points[0].stuv, - xyz=points[0].xyz, _registered_root=points[0]) - first, distinct = (bez._registered_point(point) for point in (points[0], points[-1])) - kept = bez._remove_source_classified_points( - [first, distinct], [feature], identity, np.full(4, .001), .001) - assert kept == [distinct] - del feature._registered_root - assert bez._remove_source_classified_points( - [first, distinct], [feature], identity, np.full(4, .001), .001) == [first, distinct] diff --git a/tests/test_ssx_source_boundary_fibers.py b/tests/test_ssx_source_boundary_fibers.py deleted file mode 100644 index 685c96ee..00000000 --- a/tests/test_ssx_source_boundary_fibers.py +++ /dev/null @@ -1,214 +0,0 @@ -from fractions import Fraction - -import numpy as np - -from mmcore.numeric.intersection.ssx._ssx_boundary_fibers import source_boundary_fiber_seeds - - -def fiber_pair(): - w = np.array([[1., 1.], [np.sqrt(.5), np.sqrt(.5)], [1., 1.]]) - first = np.zeros((3, 2, 4)); second = first.copy() - first[..., 3] = second[..., 3] = w - first[:, 1, 0] = [1., 0., -1.] - first[:, 1, 2] = w[:, 1] - second[:, 0, 2] = w[:, 0] - second[:, 1, 1] = [1., 0., -1.] - return first, second - - -def test_exact_original_fiber_has_exact_target_boundary_preimage(): - first, second = fiber_pair() - result = source_boundary_fiber_seeds(first, second, 1, 0., max_cells=2000) - assert not result['boundary_topology_complete'] - assert not result['budget_exhausted'] - assert result['isolated'] == result['overlaps'] == [] - assert result['boundary_seed_proposals'] == [] - assert len(result['parameter_fibers']) == 1 - seed = result['parameter_fibers'][0] - assert (seed['u'], seed['v']) == (.5, 1.) - assert seed['certification'] == 'exact_source_parameter_fiber' - assert seed['source_fiber_certificate']['target_interval'] == (Fraction(1, 2),)*2 - reverse = source_boundary_fiber_seeds(first, second, 3, 0., max_cells=2000) - assert (reverse['parameter_fibers'][0]['u'], reverse['parameter_fibers'][0]['v']) == (.5, 1.) - - -def test_noncollapsed_owner_declines(): - first, second = fiber_pair() - assert source_boundary_fiber_seeds(first, second, 0, 0.) is None - - -def test_exact_point_off_target_has_no_false_fiber(): - first, second = fiber_pair() - second[..., 0] += second[..., 3] * (1./1024) - result = source_boundary_fiber_seeds(first, second, 1, 0.) - assert result['parameter_fibers'] == [] - assert result['boundary_seed_proposals'] == [] - assert not result['boundary_topology_complete'] - - -def test_rounded_cone_apex_is_only_a_numerical_proposal(): - from examples.ssx.bez_ssx5_case14 import S1, S2 - for first, second in ((S1, S2), (S1*1e-6, S2*1e6)): - for axis in (1, 3): - result = source_boundary_fiber_seeds(first, second, axis, 0., atol=1e-3) - assert result['parameter_fibers'] == [] - assert len(result['boundary_seed_proposals']) == 1 - assert result['boundary_seed_proposals'][0]['certification'] == 'numerical_seed_proposal' - assert not result['boundary_topology_complete'] - - -def test_denied_work_and_output_keep_explicit_obligation(): - first, second = fiber_pair() - for limit in (0, 1, 5): - result = source_boundary_fiber_seeds(first, second, 1, 0., max_cells=limit) - assert result['cells_processed'] <= limit - assert result['budget_exhausted'] and not result['boundary_topology_complete'] - result = source_boundary_fiber_seeds(first, second, 1, 0., max_results=0) - assert not result['parameter_fibers'] and not result['boundary_topology_complete'] - - -def test_nearcollapsed_nonzero_edge_is_never_exact_fiber(): - first, second = fiber_pair() - first[1, 0, 0] = 2.**-60 - result = source_boundary_fiber_seeds(first, second, 1, 0.) - assert not result['parameter_fibers'] - assert not result['boundary_topology_complete'] - - -def test_exact_target_collapsed_edge_preserves_both_free_parameters(): - first, second = fiber_pair() - second[:, 0, 2] = 0. - result = source_boundary_fiber_seeds(first, second, 1, 0.) - fibers = result['parameter_fibers'] - assert any(seed.get('u_range') == (0., 1.) and seed['v'] == 0. for seed in fibers) - - -def test_exact_fiber_point_has_unique_interior_planar_preimage(): - first, _ = fiber_pair() - target = np.array([[[-1., -1., 0., 1.], [-1., 1., 0., 1.]], - [[1., -1., 0., 1.], [1., 1., 0., 1.]]]) - result = source_boundary_fiber_seeds(first, target, 1, 0.) - assert len(result['parameter_fibers']) == 1 - seed = result['parameter_fibers'][0] - assert (seed['u'], seed['v']) == (.5, .5) - assert seed['source_fiber_certificate']['target_parameter_box'] == ((Fraction(1, 2),)*2,)*2 - assert not result['boundary_topology_complete'] - target[..., 2] = 2.**-60 - result = source_boundary_fiber_seeds(first, target, 1, 0.) - assert not result['parameter_fibers'] - - -def test_convex_nonaffine_quad_membership_is_exact_not_sampled(): - first, _ = fiber_pair() - target = np.array([[[-1., -1., 0., 1.], [-1., 1., 0., 1.]], - [[1., -1., 0., 1.], [2., 1., 0., 1.]]]) - result = source_boundary_fiber_seeds(first, target, 1, 0.) - assert len(result['parameter_fibers']) == 1 - assert result['parameter_fibers'][0]['source_fiber_certificate']['target_parameter_box'] == ((Fraction(0), Fraction(1)),)*2 - assert not result['boundary_topology_complete'] - - -def test_distinct_close_target_preimages_keep_exact_interval_identity(): - first, _ = fiber_pair() - a, b = .5, .5+2.**-20 - q = np.array([a*b, a*b-(a+b)/2, 1-a-b+a*b]) - target = np.zeros((3, 2, 4)); target[..., 3] = 1. - target[..., 0] = q[:, None]; target[:, 1, 1] = 1. - result = source_boundary_fiber_seeds(first, target, 1, 0.) - assert len(result['parameter_fibers']) == 2 - intervals = [f['source_fiber_certificate']['target_interval'] for f in result['parameter_fibers']] - assert intervals[0][1] < intervals[1][0] - - -def test_unrepresentable_target_root_keeps_resolution_partial(): - first, _ = fiber_pair() - target = np.zeros((3, 2, 4)); target[..., 3] = 1. - target[..., 0] = np.array([1e16, 1e16, -1e16])[:, None] - target[:, 1, 1] = 1. - result = source_boundary_fiber_seeds(first, target, 1, 0., atol=1e-10) - assert not result['parameter_fibers'] - assert not result['boundary_topology_complete'] - assert result['truncation_cause'] == 'resolution' - - -def test_two_target_roots_with_same_float_uv_remain_two_fiber_seeds(): - first, _ = fiber_pair() - target = np.zeros((3, 2, 4)); target[..., 3] = 1. - # (1-u)*(1-(1+2**-60)*u): the second root and u=1 - # have the same nearest floating parameter, but distinct source roots. - target[..., 0] = np.array([1., -2.**-61, 0.])[:, None] - target[:, 1, 1] = 1. - result = source_boundary_fiber_seeds(first, target, 1, 0.) - assert len(result['parameter_fibers']) == 2 - assert [(seed['u'], seed['v']) for seed in result['parameter_fibers']] == [(1., 0.)]*2 - certificates = [seed['source_fiber_certificate'] for seed in result['parameter_fibers']] - assert certificates[0]['target_interval'] != certificates[1]['target_interval'] - assert not result['boundary_topology_complete'] - - -def test_finite_homogeneous_target_with_unrepresentable_cartesian_inverse_is_partial(): - first, _ = fiber_pair() - target = np.array([[[-1., -1., 0., 1e-320], [-1., 2., 0., 1e-320]], - [[2., -1., 0., 1e-320], [1., 1., 0., 1e-320]]]) - with np.errstate(over='ignore', invalid='ignore'): - result = source_boundary_fiber_seeds(first, target, 1, 0.) - assert not result['boundary_topology_complete'] - assert not result['parameter_fibers'] - - -def test_original_source_fiber_representation_outranks_rounded_proposal_residual(): - from mmcore.numeric.intersection.ssx._bez_ssx5 import _find_ssx_boundary_zeros - first, second = fiber_pair() - source = source_boundary_fiber_seeds(first, second, 1, 0.) - # The face callback carries original-source evidence while these nets - # deliberately model a lossy proposal transform. Its residual is not - # allowed to invalidate the independently supplied representation test. - proposal = first.copy(); proposal[..., 0] += proposal[..., 3] - def face_callback(curve, target, axis, side, **kwargs): - return source if (axis, side) == (1, 0.) else dict(isolated=[], overlaps=[]) - fibers, checks = [], [] - def source_representation(parameters, xyz): - checks.append(tuple(parameters)) - return np.array_equal(xyz, np.zeros(3)) - _find_ssx_boundary_zeros(proposal, second, 1e-3, rational=True, - face_csx_fn=face_callback, fiber_sink=fibers, - source_point_representation=source_representation) - assert len(fibers) == len(checks) == 1 - assert fibers[0].parameter_fiber - - -def test_empty_fiber_seed_search_defers_to_complete_source_csx(monkeypatch): - from mmcore.numeric.intersection.ssx import _bez_ssx5 as ssx - from mmcore.numeric.intersection.ssx import _ssx_planar_cut as planar - from mmcore.numeric.intersection.ssx import _ssx_boundary_fibers as fibers - # A rational ruled wedge has a collapsed t=0 edge at z=1, OFF - # the target z=0 plane. The actual ordinary line lies at t=1/2. - # Failure to find an apex seed must not poison its boundary census. - w = np.array([1., np.sqrt(.5), 1.]) - first = np.zeros((3, 2, 4)); first[..., 3] = w[:, None] - first[:, 1, 0] = [1., 0., -1.] - first[:, 1, 1] = w; first[:, 0, 2] = w; first[:, 1, 2] = -w - second = np.array([[[-2., 0., 0., 1.], [-2., 1., 0., 1.]], - [[2., 0., 0., 1.], [2., 1., 0., 1.]]]) - no_seed_faces, source_calls = [], [] - original_seed, original_csx = fibers.source_boundary_fiber_seeds, ssx.bez_csx - def seeds(*args, **kwargs): - result = original_seed(*args, **kwargs) - if result is not None and not result['parameter_fibers'] and not result['boundary_seed_proposals']: - no_seed_faces.append(args[2:4]) - return result - def source_csx(*args, **kwargs): - assert kwargs.get('source_residual') is not None - source_calls.append(True) - return original_csx(*args, **kwargs) - monkeypatch.setattr(fibers, 'source_boundary_fiber_seeds', seeds) - monkeypatch.setattr(ssx, 'bez_csx', source_csx) - monkeypatch.setattr(planar, 'supported_source_pair', lambda *args: False) - result = ssx.bez_ssx(first, second, atol=.001, rational=True, max_cells=10000) - assert no_seed_faces == [(1, 0.)] - assert source_calls - assert result['complete'], result['status'] - assert len(result['branches']) == 1 - xyz = np.asarray(result['branches'][0].curve[1]) - assert np.max(np.abs(xyz[:, 1]-.5)) < 1e-12 - assert np.max(np.abs(xyz[:, 2])) < 1e-12 diff --git a/tests/test_ssx_source_boundary_point_representation.py b/tests/test_ssx_source_boundary_point_representation.py deleted file mode 100644 index 0d4536d0..00000000 --- a/tests/test_ssx_source_boundary_point_representation.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Source root ownership survives rounded proposal equations only with accuracy.""" -import numpy as np -import pytest - -from mmcore.numeric.intersection.ssx import _bez_ssx5 as ssx -from mmcore.numeric.intersection.ssx._ssx_affine_path import ( - affine_path_representation_bounded, -) - - -def sources(): - first = np.array([[[s, t, 0.] for t in (0., 1.)] for s in (0., 1.)]) - second = np.array([[[u, v, 2*v-1] for v in (0., 1.)] for u in (0., 1.)]) - return first, second - - -def run_face(*, xyz=(0., .5, 0.), certified=True, charge=None, monkeypatch=None): - original = sources() - proposal = [net.copy() for net in original] - # The registered source root is exact, while a rounded proposal can - # fail the strict normalized residual bar at that same representative. - proposal[1][..., 2] += 1e-5 - stuv = np.array([0., .5, 0., .5]) - callbacks = [] - - def census(curve, surface, axis, value, **kwargs): - roots = [] - if axis == 0 and value == 0.: - roots = [dict(t=.5, u=0., v=.5, point=np.array(xyz), - parameter_root_box=((.5, .5), (0., 0.), (.5, .5)), - root_existence_certification='exact_source_identity')] - return dict(isolated=roots, overlaps=[], parameter_fibers=[], - boundary_topology_complete=True, budget_exhausted=False, - _ssx_source_residual=certified) - - def representation(parameters, point): - callbacks.append((parameters.copy(), point.copy())) - return affine_path_representation_bounded( - *original, parameters, parameters, np.array([point, point]), - 1e-3, rational=False, charge=charge) - - if monkeypatch is not None: - monkeypatch.setattr(ssx, '_ssx_correct_fixed', - lambda *a, **k: (stuv.copy(), 1e-5, False)) - status = {} - crossings, overlaps = ssx._find_ssx_boundary_zeros( - *proposal, 1e-3, rational=False, face_csx_fn=census, - census_sink=status, source_point_representation=representation) - return crossings, overlaps, status, callbacks - - -def test_source_root_uses_original_accuracy_without_repolishing(monkeypatch): - monkeypatch.setattr(ssx, '_ssx_correct_fixed', - lambda *a, **k: pytest.fail('source root must not be repolished')) - crossings, overlaps, status, callbacks = run_face() - assert len(crossings) == len(callbacks) == 1 - assert not overlaps - assert status == dict(complete=True, boundary_obligations=[]) - root, = crossings - np.testing.assert_array_equal(root.stuv, [0., .5, 0., .5]) - np.testing.assert_array_equal(root.xyz, [0., .5, 0.]) - assert root._source_root_box - - -@pytest.mark.parametrize('xyz', [(0., .5, .01), (0., .5, np.nan)]) -def test_inaccurate_source_representative_retains_face_obligation(xyz): - crossings, overlaps, status, callbacks = run_face(xyz=xyz) - assert not crossings and not overlaps - assert not status['complete'] - assert status['boundary_obligations'] == [((0., 0.), (0., 1.), (0., 1.), (0., 1.))] - - -def test_denied_source_accuracy_work_retains_face_obligation(): - charged = [] - def deny(amount): - charged.append(amount) - return False - crossings, _, status, callbacks = run_face(charge=deny) - assert len(charged) == len(callbacks) == 1 and charged[0] > 0 - assert not crossings and not status['complete'] - assert len(status['boundary_obligations']) == 1 - - -def test_uncertified_candidate_cannot_use_accuracy_as_existence(monkeypatch): - crossings, _, status, callbacks = run_face(certified=False, monkeypatch=monkeypatch) - assert not callbacks and not crossings - assert not status['complete'] diff --git a/tests/test_ssx_source_branch_incidence.py b/tests/test_ssx_source_branch_incidence.py deleted file mode 100644 index ba94f9cb..00000000 --- a/tests/test_ssx_source_branch_incidence.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Two true zero arcs can have identical valid approximation chords.""" -from fractions import Fraction -from math import comb - -import numpy as np - -from mmcore.numeric.intersection._root_box_certificate import exact_bernstein_value -from mmcore.numeric.intersection.ssx import _bez_ssx5 as bez -from mmcore.numeric.intersection.ssx import _nssx5 as nurbs - - -def _lens(): - # F=(t-x^2)*(t+x^2-2a^2), x=s-.5. The target plane clips - # x to [-a,a], producing the two arcs of one exact closed lens. - a = Fraction(1,32) - x2 = [Fraction(1,4),Fraction(-1),Fraction(1)] - x4 = [Fraction(1,16),Fraction(-1,2),Fraction(3,2),Fraction(-2),Fraction(1)] - power = {(i,0):-value for i,value in enumerate(x4)} - for i,value in enumerate(x2): - power[i,0] += 2*a*a*value - power[0,1],power[0,2] = -2*a*a,Fraction(1) - graph = np.zeros((5,3,4)) - for i in range(5): - for j in range(3): - z = sum(value*Fraction(comb(i,k),comb(4,k))*Fraction(comb(j,l),comb(2,l)) - for (k,l),value in power.items() if k <= i and l <= j) - exact = (3*i,6*j,12*z,12) - graph[i,j] = list(map(float,exact)) - assert all(Fraction.from_float(float(x)) == value for x,value in zip(graph[i,j],exact)) - plane = np.array([[[float(Fraction(1,2)-a+2*a*u),float(2*a*a*v),0.,1.] - for v in (0,1)] for u in (0,1)]) - q = np.array([[float(Fraction(1,2)-a),float(a*a),0.,.5], - [float(Fraction(1,2)+a),float(a*a),1.,.5]]) - xyz = np.column_stack((q[:,:2],np.zeros(2))) - for parameters in q: - first = exact_bernstein_value(graph,parameters[:2]) - second = exact_bernstein_value(plane,parameters[2:]) - assert all(first[k]*second[3] == second[k]*first[3] for k in range(3)) - # Both genuine arcs lie at distance <=a² from this same chord. - assert a*a < Fraction(1,1000) - return q,xyz - - -def test_bezier_lens_arcs_with_shared_endpoints_survive_fragment_dedup(): - q,xyz = _lens() - left,right = [bez.BoundaryPoint(s,x,(2,side)) for s,x,side in zip(q,xyz,(0,1))] - first = bez._Fragment(left,right,q,xyz) - second = bez._Fragment(right,left,q[::-1].copy(),xyz[::-1].copy()) - assert len(bez._drop_duplicate_fragments([first,second],.001)) == 2 - branches = bez._assemble_fragments([first,second],unify_tol=np.full(4,.001)) - assert len(branches) == 1 and branches[0].closed - assert len(branches[0].curve[0]) == 3 - - -def test_nurbs_lens_arcs_survive_and_form_their_registered_cycle(): - q,xyz = _lens() - fragments = [nurbs._Frag(q,xyz,'transversal',False), - nurbs._Frag(q[::-1].copy(),xyz[::-1].copy(),'transversal',False)] - context = nurbs._DomainCtx(np.zeros(4),np.ones(4),np.ones(4),np.full(4,.001),(False,)*4) - assert len(nurbs._containment_dedup(fragments,.001,nurbs._make_aggregate({},1),context)) == 2 - branches = nurbs._assemble_branches(fragments,context,.001,nurbs._make_aggregate({},1)) - assert len(branches) == 1 and branches[0].closed - assert len(branches[0].curve[0]) == 3 - - -def test_repeated_same_fragment_object_has_actual_shared_provenance(): - q,xyz = _lens() - fragment = bez._Fragment(None,None,q,xyz) - assert bez._drop_duplicate_fragments([fragment,fragment],.001) == [fragment] - mapped = nurbs._Frag(q,xyz,'transversal',False) - assert nurbs._containment_dedup([mapped,mapped],.001,nurbs._make_aggregate({},1)) == [mapped] - - -def test_boundary_arcs_on_a_folded_chart_are_not_identified_by_their_chords(): - # S1(s,t)=(t,s,0), S2(u,v)=(u,(u-.5)^2+(v-.5)^2-a²,0). - # The boundary s=0 has a circular parameter preimage on S2. Its upper - # and lower arcs have the same endpoints and this same approximate chord. - a = 1/32 - q = np.array([[0.,.5-a,.5-a,.5],[0.,.5+a,.5+a,.5]]) - first = np.array([[[t,s,0.] for t in (0.,1.)] for s in (0.,1.)]) - overlaps = [bez.BoundaryOverlap(q[0],q[1],(0,0)), - bez.BoundaryOverlap(q[1].copy(),q[0].copy(),(0,0))] - for parameters in [q[0],q[1],np.array([0.,.5,.5,.5-a]),np.array([0.,.5,.5,.5+a])]: - s,t,u,v = map(Fraction.from_float,parameters) - assert s == (u-Fraction(1,2))**2+(v-Fraction(1,2))**2-Fraction(1,32)**2 - assert t == u - branches = bez._overlaps_to_branches(overlaps,first,.001,False) - assert len(branches) == 2 - - -def test_valid_parabola_chord_is_not_deleted_by_midpoint_residual_estimate(): - # h=512*g*(2a²-g), g=v-(u-.5)². The lower zero arc g=0 - # lies <=a² from its endpoint chord. Both source evaluations along - # the published lifted chord are also within 512*a^4=1/2048 of it. - # At the midpoint grad(h)=0 while h=1/2048, so dividing this - # residual by a clamped normal angle invents a large distance. - a = Fraction(1,32) - square = {(0,0):Fraction(1,4),(1,0):Fraction(-1),(2,0):Fraction(1)} - g = {key:-value for key,value in square.items()} - g[0,1] = Fraction(1) - opposite = {key:-value for key,value in g.items()} - opposite[0,0] += 2*a*a - power = {} - for (i,j),left in g.items(): - for (k,l),right in opposite.items(): - key = i+k,j+l - power[key] = power.get(key,Fraction(0))+512*left*right - graph = np.zeros((5,3,4)) - for i in range(5): - for j in range(3): - h = sum(value*Fraction(comb(i,k),comb(4,k))*Fraction(comb(j,l),comb(2,l)) - for (k,l),value in power.items() if k <= i and l <= j) - exact = (3*i,6*j,12*h,12) - graph[i,j] = list(map(float,exact)) - assert all(Fraction(float(x)) == y for x,y in zip(graph[i,j],exact)) - plane = np.array([[[s,t,0.,1.] for t in (0.,1.)] for s in (0.,1.)]) - q = np.array([[float(Fraction(1,2)+sign*a),float(a*a)]*2 for sign in (-1,1)]) - xyz = np.column_stack((q[:,:2],np.zeros(2))) - for point in q: - assert exact_bernstein_value(graph,point[2:])[2] == 0 - assert a*a < Fraction(1,1000) - assert 512*a**4 < Fraction(1,1000) - left,right = [bez.BoundaryPoint(s,x,(0,-1)) for s,x in zip(q,xyz)] - branch = bez._assemble_fragments( - [bez._Fragment(left,right,q,xyz)], S1_full=plane,S2_full=graph, - atol_full=.001,rational_full=True) - assert len(branch) == 1 - np.testing.assert_array_equal(branch[0].curve[0],q) - - -def test_failed_endpoint_source_identity_is_not_overridden_by_equal_samples(): - # Two independent source enclosures can have identical rounded display - # tuples. A declined root identity must survive every assembly phase. - first = np.array([[.2,.5,.2,.5],[.5,.5,.5,.5]]) - second = np.array([[.5,.5,.5,.5],[.8,.5,.8,.5]]) - fragments = [] - for index,q in enumerate((first,second)): - xyz = np.column_stack((q[:,0],np.zeros((2,2)))) - endpoints = [bez.BoundaryPoint(p,x,(index,0)) for p,x in zip(q,xyz)] - fragments.append(bez._Fragment(*endpoints,q,xyz)) - branches = bez._assemble_fragments( - fragments,unify_tol=np.full(4,.001),root_matcher=lambda *args:False) - assert len(branches) == 2 diff --git a/tests/test_ssx_source_cofactor_bounds.py b/tests/test_ssx_source_cofactor_bounds.py deleted file mode 100644 index 91538aab..00000000 --- a/tests/test_ssx_source_cofactor_bounds.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Cofactor signs must enclose the original source, including cancellation.""" -import numpy as np - -from mmcore.numeric.intersection.ssx._ssx_cofactor_bounds import SourceCofactorBounds - - -def _h(net): - return np.concatenate((net, np.ones(net.shape[:-1]+(1,))), axis=-1) - - -def _circle(): - ca, cb, radius = 3/8, 5/8, 2.**-18 - za = [ca*ca, ca*ca-ca, (1-ca)**2] - zb = [cb*cb, cb*cb-cb, (1-cb)**2] - graph = np.array([[[i/2, j/2, za[i]+zb[j]-radius*radius] - for j in range(3)] for i in range(3)]) - plane = np.array([[[u,v,0.] for v in (0.,1.)] for u in (0.,1.)]) - box = tuple((c-2*radius, c+2*radius) for c in (ca, cb, ca, cb)) - return graph, plane, box - - -def test_interval_cofactors_reject_cancelled_closed_loop_sign(): - graph, plane, box = _circle() - n = float(2**24) - transform = np.array([[n,n-1,0.], [n+1,n,0.], [0.,0.,1.]]) - # det(transform)=1 exactly, so the true cofactors are unchanged. - bounds = SourceCofactorBounds(_h(graph@transform.T), _h(plane@transform.T)) - lower, upper = bounds.bounds(box) - assert np.all(lower <= 0.) and np.all(upper >= 0.) - - -def test_interval_cofactors_use_global_parameter_derivatives(): - graph, plane, _ = _circle() - bounds = SourceCofactorBounds(_h(graph), _h(plane)) - box = ((.1,.2),(.2,.3),(.1,.2),(.2,.3)) - lower, upper = bounds.bounds(box) - # The unsigned column minors are 2*(t-cb), 2*(s-ca), repeated. - expected_lo = np.array([-.85,-.55,-.85,-.55]) - expected_hi = np.array([-.65,-.35,-.65,-.35]) - assert np.all(lower <= expected_lo) and np.all(upper >= expected_hi) - assert np.max(upper-lower) < .21 - - -def test_cofactor_certificate_budget_is_cached_and_prepaid(monkeypatch): - graph, plane, box = _circle() - charges = [] - bounds = SourceCofactorBounds(_h(graph), _h(plane), charge=lambda n: charges.append(n) or True) - first = bounds.bounds(box) - count = len(charges) - assert first is bounds.bounds(box) - assert len(charges) == count - from mmcore.numeric.intersection.ssx import _ssx_cofactor_bounds as module - monkeypatch.setattr(module, 'psi_vector_net', lambda *a: (_ for _ in ()).throw(AssertionError('unpaid allocation'))) - denied = SourceCofactorBounds(_h(graph), _h(plane), charge=lambda n: False) - assert denied.bounds(box) is None - assert denied.exhausted - - -def test_cofactor_bounds_allow_exact_fixed_coordinates(): - graph, plane, _ = _circle() - bounds = SourceCofactorBounds(_h(graph), _h(plane)) - lower, upper = bounds.bounds(((.1,.1),(.2,.2),(.1,.1),(.2,.2))) - expected = np.array([-.85,-.55,-.85,-.55]) - assert np.all(lower <= expected) and np.all(upper >= expected) - assert np.max(upper-lower) < 1e-10 - - -def test_nonuniform_rational_source_bounds_contain_exact_cofactors(): - from fractions import Fraction - from itertools import permutations - from mmcore.numeric.intersection._root_box_certificate import exact_bernstein_value - graph, plane, _ = _circle() - first, second = _h(graph), _h(plane) - first *= np.array([[1.,2.,1.],[2.,1.,2.],[1.,2.,1.]])[...,None] - second *= np.array([[1.,2.],[3.,4.]])[...,None] - q = (.25,.75,.625,.375) - a, b = exact_bernstein_value(first,q[:2]), exact_bernstein_value(second,q[2:]) - columns = [] - for owner, source in enumerate((first,second)): - for axis in (0,1): - # These dyadic source differences and degree-two products are - # exactly representable; subsequent evaluation stays Fraction. - derivative = (source.shape[axis]-1)*np.diff(source,axis=axis) - d = exact_bernstein_value(derivative,q[2*owner:2*owner+2]) - columns.append(tuple(d[k]*b[-1]-b[k]*d[-1] if owner == 0 else - a[k]*d[-1]-d[k]*a[-1] for k in range(3))) - expected = [] - for excluded in range(4): - columns_minor = [column for i,column in enumerate(columns) if i != excluded] - determinant = Fraction(0) - for order in permutations(range(3)): - term = np.prod([columns_minor[column][row] for row,column in enumerate(order)]) - parity = sum(order[i] > order[j] for i in range(3) for j in range(i+1,3)) % 2 - determinant += -term if parity else term - expected.append(determinant) - lower,upper = SourceCofactorBounds(first,second).bounds(tuple((t,t) for t in q)) - for lo,hi,value in zip(lower,upper,expected): - assert Fraction(float(lo)) <= value <= Fraction(float(hi)) - - -def test_polynomial_cofactors_preserve_dependency_inside_surface_normal(): - # A=(s+st,t+st,0), B=(u,0,v). The third minor is -(1+s+t). - # Entrywise Jacobian intervals instead contain the spurious value0 - # from (1+t)*(1+s)-s*t with independently chosen extrema. - first = np.array([[[s+s*t, t+s*t, 0.] for t in (0.,1.)] for s in (0.,1.)]) - second = np.array([[[u,0.,v] for v in (0.,1.)] for u in (0.,1.)]) - box = ((0.,1.),)*4 - loose = SourceCofactorBounds(_h(first), _h(second), polynomial=False).bounds(box) - tight = SourceCofactorBounds(_h(first), _h(second)).bounds(box) - assert loose[1][2] >= 0. - assert tight[0][2] <= -3. and tight[1][2] >= -1. - assert tight[1][2] < -.99 - - -def test_uniform_weight_polynomial_bounds_enclose_exact_random_determinants(): - from fractions import Fraction - from itertools import permutations, product - from mmcore.numeric.intersection._root_box_certificate import exact_bernstein_value - random = np.random.default_rng(5319) - for shapes in (((3,2),(2,3)), ((3,3),(3,2))): - for scale in (2.**-100, 1., 2.**100): - first, second = [_h(random.integers(-8,9,size=shape+(3,))*scale/8) - for shape in shapes] - first *= 2.**-10 - second *= 2.**12 - certificate = SourceCofactorBounds(first, second) - assert certificate.polynomial_nets is not None - box = ((.125,.375),(.25,.75),(.375,.625),(.125,.875)) - lower, upper = certificate.bounds(box) - for q in product(*[(lo, (lo+hi)/2, hi) for lo,hi in box]): - a = exact_bernstein_value(first, q[:2]) - b = exact_bernstein_value(second, q[2:]) - columns = [] - for owner, source in enumerate((first, second)): - for axis in (0,1): - derivative = (source.shape[axis]-1)*np.diff(source,axis=axis) - d = exact_bernstein_value(derivative,q[2*owner:2*owner+2]) - columns.append(tuple(d[k]*b[3] if owner == 0 else -d[k]*a[3] - for k in range(3))) - for excluded in range(4): - minor = [column for i,column in enumerate(columns) if i != excluded] - determinant = Fraction(0) - for order in permutations(range(3)): - term = np.prod([minor[column][row] for row,column in enumerate(order)]) - odd = sum(order[i]>order[j] for i in range(3) for j in range(i+1,3)) % 2 - determinant += -term if odd else term - assert Fraction(float(lower[excluded])) <= determinant <= Fraction(float(upper[excluded])) - - -def test_constant_foreign_axes_are_removed_without_changing_source_error(): - graph, plane, box = _circle() - certificate = SourceCofactorBounds(_h(graph), _h(plane)) - assert all(net.shape[2:4] == (1,1) for net,error in certificate.polynomial_nets) - lower, upper = certificate.bounds(box) - assert np.all(lower <= 0.) and np.all(upper >= 0.) diff --git a/tests/test_ssx_source_continuum_cut.py b/tests/test_ssx_source_continuum_cut.py deleted file mode 100644 index d58de5bb..00000000 --- a/tests/test_ssx_source_continuum_cut.py +++ /dev/null @@ -1,24 +0,0 @@ -import numpy as np - -from mmcore.numeric.intersection.ssx._ssx_planar_cut import exact_source_planar_cut - - -def test_identically_planar_source_cut_retains_a_localized_continuum_obligation(): - graph = np.array([[[s, t, s*(t-.5), 1.] for t in (0., 1.)] for s in (0., 1.)]) - plane = np.array([[[s, t, 0., 1.] for t in (0., 1.)] for s in (0., 1.)]) - result = exact_source_planar_cut(graph, plane, 1, .5, ((0., 1.),)*4) - assert result is not None - assert not result['boundary_topology_complete'] - assert result['truncation_cause'] == 'resolution' - assert not result['isolated'] - obligation, = result['unresolved_source_boxes'] - assert obligation['reason'] == 'positive_dimensional_cut' - np.testing.assert_array_equal(obligation['parameter_root_box'][1], [.5, .5]) - - -def test_nonzero_source_polynomial_still_uses_isolated_root_census(): - graph = np.array([[[s, t, s-.5, 1.] for t in (0., 1.)] for s in (0., 1.)]) - plane = np.array([[[s, t, 0., 1.] for t in (0., 1.)] for s in (0., 1.)]) - result = exact_source_planar_cut(graph, plane, 1, .5, ((0., 1.),)*4) - assert result['boundary_topology_complete'] - assert len(result['isolated']) == 1 diff --git a/tests/test_ssx_source_cut_cache.py b/tests/test_ssx_source_cut_cache.py deleted file mode 100644 index 90090600..00000000 --- a/tests/test_ssx_source_cut_cache.py +++ /dev/null @@ -1,89 +0,0 @@ -import numpy as np - -from mmcore.numeric.intersection.ssx._ssx_affine_constraints import AffineParameterConstraints -from mmcore.numeric.intersection.ssx._ssx_planar_cut import exact_source_planar_cut -from mmcore.numeric.intersection.ssx._ssx_root_identity import _pure_affine_coordinates -from mmcore.numeric.intersection.ssx._ssx_source_cut_cache import SourceFaceCensusCache - - -def _fixture(plane_x=(-.25,1.25)): - graph = np.array([[[u,v,(v-.5)*z,1.] for v in (0.,1.)] - for u,z in zip((0.,.5,1.),(1.,1.,2.))]) - plane = np.array([[[u,v,0.,1.] for v in (0.,1.)] for u in plane_x]) - constraints = AffineParameterConstraints(tuple(_pure_affine_coordinates(s) for s in (graph,plane))) - calls = [] - def solve(axis,value,box): - calls.append((axis,value,box)) - return exact_source_planar_cut(graph,plane,axis,value,box,max_cells=1000) - cache = SourceFaceCensusCache(solve,constraints,(id(graph),id(plane)),charge=lambda n:True) - return cache,calls - - -def test_equivalent_faces_share_census_and_rebind_exact_metadata(): - cache,calls = _fixture() - first = cache(0,.5,((0.,1.),)*4) - second = cache(2,.5,((0.,1.),)*4) - assert len(calls) == 1 - a, = first['isolated'] - b, = second['isolated'] - assert a['source_cut_certificate']['axis'] == 0 - assert b['source_cut_certificate']['axis'] == 2 - np.testing.assert_array_equal(a['stuv'],b['stuv']) - np.testing.assert_array_equal(a['point'],b['point']) - assert second['boundary_topology_complete'] - - -def test_float_aliases_of_different_exact_pins_do_not_share_census(): - cache,calls = _fixture((0.,3.)) - first = cache(0,.5,((0.,1.),)*4) - second = cache(2,1/6,((0.,1.),)*4) - assert len(calls) == 2 - np.testing.assert_array_equal(first['isolated'][0]['stuv'],second['isolated'][0]['stuv']) - assert first['isolated'][0]['source_cut_certificate']['pinned'] != second['isolated'][0]['source_cut_certificate']['pinned'] - - -def test_foreign_source_certificate_cannot_be_reused(): - cache,calls = _fixture() - result = cache(0,.5,((0.,1.),)*4) - result['isolated'][0]['source_cut_certificate']['source_ids'] = (0,1) - rejected = cache(2,.5,((0.,1.),)*4) - assert not rejected['boundary_topology_complete'] - assert not rejected['isolated'] - - -def test_changed_float_representative_requires_fresh_source_validation(): - cache,calls = _fixture() - first = cache(0,.5,((0.,1.),)*4) - first['isolated'][0]['stuv'][2] = np.nextafter(.5,1.) - second = cache(2,.5,((0.,1.),)*4) - assert len(calls) == 2 - assert second['isolated'][0]['stuv'][2] == .5 - - -def test_partial_global_census_never_leaks_foreign_roots_into_local_owner(): - cache,calls = _fixture() - full = cache(0,.5,((0.,1.),)*4) - full['budget_exhausted'] = True - # Mark the stored result partial (the return is intentionally a view - # only of immutable root records, so result flags are copied). - stored, = cache.censuses.values() - stored['budget_exhausted'] = True - stored['boundary_topology_complete'] = False - cache.can_retry = lambda:False - local = cache(0,.5,((0.,1.),(0.,.25),(0.,1.),(0.,1.))) - assert not local['boundary_topology_complete'] - assert not local['isolated'] - assert local['unresolved_source_boxes'] - - -def test_partial_equivalent_face_does_not_reuse_an_unvalidated_representative(): - cache, calls = _fixture() - cache(0, .5, ((0., 1.),) * 4) - stored, = cache.censuses.values() - stored['budget_exhausted'] = True - stored['boundary_topology_complete'] = False - stored['isolated'][0]['stuv'][2] = np.nextafter(.5, 1.) - cache.can_retry = lambda: False - result = cache(2, .5, ((0., 1.),) * 4) - assert not result['isolated'] - assert not result['boundary_topology_complete'] diff --git a/tests/test_ssx_source_enclosure_intersection.py b/tests/test_ssx_source_enclosure_intersection.py deleted file mode 100644 index 23ba9fae..00000000 --- a/tests/test_ssx_source_enclosure_intersection.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Source certificates retain independent exact enclosure contractions.""" -from fractions import Fraction -from types import SimpleNamespace - -import numpy as np - -from mmcore.numeric.intersection.ssx import _bez_ssx5 as ssx -from mmcore.numeric.intersection.ssx._ssx_affine_constraints import AffineParameterConstraints -from mmcore.numeric.intersection.ssx._ssx_planar_cut import exact_source_planar_cut -from mmcore.numeric.intersection.ssx._ssx_root_identity import BoundaryRootIdentity - - -def _point(first, second): - matcher = BoundaryRootIdentity(first, second) - matcher.affine_constraints = AffineParameterConstraints(matcher.affine) - root = exact_source_planar_cut(first, second, 1, 0., ((0., 1.),)*4)['isolated'][0] - point = ssx.BoundaryPoint(np.asarray(root['stuv']), np.asarray(root['point']), (1, -1), - root_box=np.asarray(root['parameter_root_box'])) - point._source_root_box = True - assert matcher.register_source_root(point, root['source_cut_certificate']) - return matcher, point - - -def test_source_sturm_box_keeps_independently_proved_affine_contraction(): - weight = np.sqrt(.5) - first = np.array([[[x*w, t*w, z*w, w] for t in (0., 1.)] - for x, z, w in ((1., 0., 1.), (1., 1., weight), (0., 1., 1.))]) - second = np.array([[[2*u-.5, 2*v-.5, .5, 1.] for v in (0., 1.)] for u in (0., 1.)]) - matcher, point = _point(first, second) - certificate = matcher.source_certificates[id(point)] - original = certificate['exact_box'] - assert original[3][0] < Fraction(1, 4) < original[3][1] - point.root_box = np.asarray(matcher.affine_constraints.contract(point.root_box)) - np.testing.assert_array_equal(point.root_box[3], [.25, .25]) - cell = SimpleNamespace(box=((0., 1.),)*3+((.25, .75),), root_matcher=matcher) - exact = ssx._source_boundary_intervals(cell, point, point.root_box) - assert exact[3] == (Fraction(1, 4), Fraction(1, 4)) - assert certificate['exact_box'] == original - - -def test_intersection_never_clips_the_source_enclosure_to_its_owner(): - first = np.array([[[s, t, 3*s-1, 1.] for t in (0., 1.)] for s in (0., 1.)]) - second = np.array([[[s, t, 0., 1.] for t in (0., 1.)] for s in (0., 1.)]) - matcher, point = _point(first, second) - cell = SimpleNamespace(box=((0., float(Fraction(1, 3))),)+((0., 1.),)*3, - root_matcher=matcher) - exact = ssx._source_boundary_intervals(cell, point, point.root_box) - assert exact[0] == (Fraction(1, 3), Fraction(1, 3)) - assert exact[0][0] > cell.box[0][1] - - -def test_unproved_or_unpaid_enclosure_cannot_tighten_a_source_certificate(): - first = np.array([[[s, t, 3*s-1, 1.] for t in (0., 1.)] for s in (0., 1.)]) - second = np.array([[[s, t, 0., 1.] for t in (0., 1.)] for s in (0., 1.)]) - matcher, point = _point(first, second) - cell = SimpleNamespace(box=((0., 1.),)*4, root_matcher=matcher) - original = matcher.source_certificates[id(point)]['exact_box'] - point._source_root_box = False - assert ssx._source_boundary_intervals(cell, point, np.zeros((4, 2))) == original - point._source_root_box = True - matcher.charge = lambda amount: False - assert ssx._source_boundary_intervals(cell, point, point.root_box) == original - - -def _nearby_circle_source_root(): - from examples.ssx.ssx5_analytic_audit import graph_pair, case_components - first, second = [np.concatenate((net, np.ones(net.shape[:2]+(1,))), axis=2) - for net in reversed(graph_pair(case_components('nearby_circles')))] - matcher = BoundaryRootIdentity(first, second) - matcher.affine_constraints = AffineParameterConstraints(matcher.affine) - root = exact_source_planar_cut(first, second, 1, .5, ((0., 1.),)*4)['isolated'][2] - point = ssx.BoundaryPoint(np.asarray(root['stuv']), np.asarray(root['point']), (1, -1), - root_box=np.asarray(root['parameter_root_box'])) - point._source_root_box = True - assert matcher.register_source_root(point, root['source_cut_certificate']) - return matcher, point - - -def test_sturm_owner_face_refines_linked_axis_without_changing_source_identity(): - matcher, point = _nearby_circle_source_root() - certificate = matcher.source_certificates[id(point)] - original = certificate['exact_box'] - face = float(Fraction(31, 48)) - assert original[0][0] < face < original[0][1] - owner = ((.64, face), (.48, .5), (.71, .72), (.48, .5)) - refined = matcher.refine_source_box(point, original, owner) - assert refined[0][1] == Fraction(face) - assert refined[2][1] == Fraction(3, 2)*Fraction(face)-Fraction(1, 4) - assert refined[2][1] < original[2][1] - # The root lies strictly to the left of this rounded face. Exact - # polynomial signs at the isolated interval endpoints prove it remains - # bracketed, independently of the refinement's Sturm implementation. - polynomial = certificate['exact_polynomial'] - def value(x): - return sum(c*x**i for i, c in enumerate(polynomial)) - assert value(refined[2][0])*value(refined[2][1]) < 0 - assert certificate['exact_box'] == original - - -def test_sturm_owner_refinement_retains_root_outside_opposite_owner(): - matcher, point = _nearby_circle_source_root() - original = matcher.source_certificates[id(point)]['exact_box'] - face = float(Fraction(31, 48)) - owner = ((face, .66), (.48, .5), (.71, .72), (.48, .5)) - refined = matcher.refine_source_box(point, original, owner) - assert refined[0][0] < face - assert refined[0][1] == Fraction(face) - assert refined[0][0] == original[0][0] - - -def test_unpaid_sturm_owner_refinement_keeps_original_enclosure(): - matcher, point = _nearby_circle_source_root() - original = matcher.source_certificates[id(point)]['exact_box'] - matcher.charge = lambda amount: False - owner = ((.64, float(Fraction(31, 48))),)+((0., 1.),)*3 - assert matcher.refine_source_box(point, original, owner) == original diff --git a/tests/test_ssx_source_point_incidence.py b/tests/test_ssx_source_point_incidence.py deleted file mode 100644 index d49070a6..00000000 --- a/tests/test_ssx_source_point_incidence.py +++ /dev/null @@ -1,84 +0,0 @@ -"""An approximation chord cannot own an independent isolated source root.""" -from fractions import Fraction -from math import comb - -import numpy as np - -from mmcore.numeric._work_budget import SoftWorkBudget -from mmcore.numeric.intersection._root_box_certificate import exact_bernstein_value -from mmcore.numeric.intersection.ssx._bez_ssx5 import ( - BoundaryPoint, _registered_point, _remove_source_incident_points, -) -from mmcore.numeric.intersection.ssx._nssx5 import _assemble_points, _DomainCtx, _make_aggregate -from mmcore.numeric.intersection.ssx._ssx_polyline import point_matches_polyline -from mmcore.numeric.intersection.ssx._ssx_substrate import SSXBranch, SSXPoint - - -def _parabola_and_isolated_point(): - # z=(t-(s-.5)^2)*((s-.5)^2+(t-a^2)^2). Its exact real zero - # set is the parabola plus the distinct isolated point (.5,a^2). - a = Fraction(1,32) - square = {(0,0):Fraction(1,4),(1,0):Fraction(-1),(2,0):Fraction(1)} - first = {key:-value for key,value in square.items()} - first[0,1] = Fraction(1) - second = dict(square) - second[0,0] += a**4 - second[0,1],second[0,2] = -2*a*a,Fraction(1) - power = {} - for (i,j),left in first.items(): - for (k,l),right in second.items(): - key = i+k,j+l - power[key] = power.get(key,Fraction(0))+left*right - # Uniform homogeneous scale36 makes all Bernstein coefficients dyadic, - # including the degree-three coordinate chart. No source rounding. - graph = np.zeros((5,4,4)) - for i in range(5): - for j in range(4): - z = sum(value*Fraction(comb(i,k),comb(4,k))*Fraction(comb(j,l),comb(3,l)) - for (k,l),value in power.items() if k <= i and l <= j) - exact = (9*i,12*j,36*z,36) - graph[i,j] = [float(value) for value in exact] - assert all(Fraction.from_float(float(value)) == target - for value,target in zip(graph[i,j],exact)) - plane = np.array([[[s,t,0.,1.] for t in (0.,1.)] for s in (0.,1.)]) - u = np.array([float(Fraction(1,2)-a),float(Fraction(1,2)+a)]) - v = float(a*a) - stuv = np.column_stack((u,np.full(2,v),u,np.full(2,v))) - xyz = np.column_stack((u,np.full(2,v),np.zeros(2))) - branch = SSXBranch((stuv,xyz)) - point = SSXPoint(np.array([.5,v,.5,v]),np.array([.5,v,0.])) - for p in [point.stuv,*stuv]: - first_value = exact_bernstein_value(graph,p[:2]) - second_value = exact_bernstein_value(plane,p[2:]) - assert all(first_value[k]*second_value[3] == second_value[k]*first_value[3] for k in range(3)) - # The parabola-to-chord error is exactly a^2, within the advertised atol. - assert a*a < Fraction(1,1000) - assert point_matches_polyline(point.stuv,point.xyz,stuv,xyz,np.zeros(4),1e-3) - return point,branch - - -def test_bezier_cleanup_keeps_actual_isolated_root_on_an_approximation_chord(): - point,branch = _parabola_and_isolated_point() - budget = SoftWorkBudget(100,100,max_postprocess_work=0) - kept = _remove_source_incident_points([point],[branch],np.ones(4)*1e-3,1e-3,None,budget) - assert len(kept) == 1 and kept[0] is point - assert budget.result_fields()['complete'] - - -def test_nurbs_cleanup_keeps_actual_isolated_root_on_an_approximation_chord(): - point,branch = _parabola_and_isolated_point() - context = _DomainCtx(np.zeros(4),np.ones(4),np.ones(4),np.full(4,.001),(False,)*4) - aggregate = _make_aggregate({},1) - kept = _assemble_points([point],[branch],context,1e-3,aggregate) - assert len(kept) == 1 and kept[0] is point - - -def test_source_registration_still_proves_point_incidence(): - _,branch = _parabola_and_isolated_point() - # This separate control supplies the actual shared registration, not - # just an equal float tuple or membership in an approximate chord. - token = BoundaryPoint(branch.curve[0][0],branch.curve[1][0],(0,-1)) - point = _registered_point(token) - branch._registered_root_ids = frozenset((id(token),)) - branch._registered_root_points = {id(token):token} - assert _remove_source_incident_points([point],[branch],np.ones(4)*1e-3,1e-3,None) == [] diff --git a/tests/test_ssx_source_residual_exclusion.py b/tests/test_ssx_source_residual_exclusion.py deleted file mode 100644 index 56dd7091..00000000 --- a/tests/test_ssx_source_residual_exclusion.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Main SSX exclusions belong to original source residuals.""" -from types import SimpleNamespace - -import numpy as np - -from mmcore.numeric.intersection.ssx._ssx_cofactor_bounds import SourceCofactorBounds -from mmcore.numeric.intersection.ssx._ssx_source_residual import SourceResidualExclusions - - -def _surface(height): - return np.array([[[s,t,height,1.] for t in (0.,1.)] for s in (0.,1.)]) - - -def test_original_source_root_is_never_excluded_by_cancelled_child_values(): - source = SourceCofactorBounds(_surface(0.),_surface(0.),polynomial=False) - exclude = SourceResidualExclusions(source) - assert not exclude(((.2,.7),(.3,.8),(.4,.9),(.1,.6))) - assert not exclude(((.5,.5),)*4) - - -def test_direct_source_separation_and_disjoint_parameter_owners_are_excluded(): - assert SourceResidualExclusions(SourceCofactorBounds( - _surface(0.),_surface(1.),polynomial=False))(((0.,1.),)*4) - exclude = SourceResidualExclusions(SourceCofactorBounds( - _surface(0.),_surface(0.),polynomial=False)) - assert exclude(((0.,.2),(0.,1.),(.8,1.),(0.,1.))) - - -def test_aligned_projection_can_separate_a_hull_crossing_every_coordinate_axis(): - # Every coefficient has x+y=1, but each coordinate interval contains0. - net = np.array([[[-1.,2.,0.],[2.,-1.,0.]]]).reshape(2,1,1,1,3) - source = SimpleNamespace(residual=net,residual_error=np.full(3,1e-12)) - assert SourceResidualExclusions(source)(((0.,1.),)*4) - - -def test_inherited_source_error_prevents_false_empty_from_positive_rounded_net(): - eta = np.nextafter(0.,1.) - source = SimpleNamespace(residual=np.full((2,2,2,2,3),eta), - residual_error=np.full(3,4*eta)) - assert not SourceResidualExclusions(source)(((.25,.75),)*4) - - -def test_denial_is_prepaid_and_cached_certificates_do_not_repeat_work(monkeypatch): - from mmcore.numeric.intersection.ssx import _ssx_source_residual as module - source = SourceCofactorBounds(_surface(0.),_surface(1.),polynomial=False) - calls = [] - exclude = SourceResidualExclusions(source,charge=lambda units:calls.append(units) or True) - box = ((0.,1.),)*4 - assert exclude(box) - charged = list(calls) - assert exclude(box) and calls == charged - denied = SourceResidualExclusions(source,charge=lambda units:False) - def forbidden(*args,**kwargs): - raise AssertionError('unpaid source restriction') - monkeypatch.setattr(module,'restrict_net_axis_v',forbidden) - assert not denied(box) and denied.exhausted diff --git a/tests/test_work_budget.py b/tests/test_work_budget.py index 08b0b4fd..102928a9 100644 --- a/tests/test_work_budget.py +++ b/tests/test_work_budget.py @@ -400,5 +400,7 @@ def test_bez_ssx5_reexports_are_the_same_objects(): assert ssx5._SSXSoftBudget is wb.SoftWorkBudget for name in dir(wb): - if name.startswith("REASON_"): + # The shared raw CSX/CCX expert mode retains its representation + # reason; the restored CAD SSX API does not expose that proof policy. + if name.startswith("REASON_") and name != "REASON_PARAMETER_REPRESENTATION": assert getattr(ssx5, name) == getattr(wb, name) From c00c96d90b2ebcbcda6383ce4896aebd0cb452ba Mon Sep 17 00:00:00 2001 From: sth-v Date: Wed, 23 Sep 2026 01:18:40 +0300 Subject: [PATCH 2/8] case16 --- examples/ssx/_case16_data.py | 6529 ++++++++++++++++++++++++++++++++++ examples/ssx/case16.py | 42 + 2 files changed, 6571 insertions(+) create mode 100644 examples/ssx/_case16_data.py create mode 100644 examples/ssx/case16.py diff --git a/examples/ssx/_case16_data.py b/examples/ssx/_case16_data.py new file mode 100644 index 00000000..6c9241df --- /dev/null +++ b/examples/ssx/_case16_data.py @@ -0,0 +1,6529 @@ +import numpy as np +from mmcore.nurbs._nurbs_eval import NURBSSurfaceTuple +surf0= NURBSSurfaceTuple( + order_u=4, + order_v=3, + knot_u=np.array([0. , 0. , 0. , 0. , 0.00338983, + 0.00677966, 0.01016949, 0.01355932, 0.01694915, 0.02033898, + 0.02372881, 0.02711864, 0.03050847, 0.03389831, 0.03728814, + 0.04067797, 0.0440678 , 0.04745763, 0.05084746, 0.05423729, + 0.05762712, 0.06101695, 0.06440678, 0.06779661, 0.07118644, + 0.07457627, 0.0779661 , 0.08135593, 0.08474576, 0.08813559, + 0.09152542, 0.09491525, 0.09830508, 0.10169492, 0.10508475, + 0.10847458, 0.11186441, 0.11525424, 0.11864407, 0.1220339 , + 0.12542373, 0.12881356, 0.13220339, 0.13559322, 0.13898305, + 0.14237288, 0.14576271, 0.14915254, 0.15254237, 0.1559322 , + 0.15932203, 0.16271186, 0.16610169, 0.16949153, 0.17288136, + 0.17627119, 0.17966102, 0.18305085, 0.18644068, 0.18983051, + 0.19322034, 0.19661017, 0.2 , 0.20338983, 0.20677966, + 0.21016949, 0.21355932, 0.21694915, 0.22033898, 0.22372881, + 0.22711864, 0.23050847, 0.23389831, 0.23728814, 0.24067797, + 0.2440678 , 0.24745763, 0.25084746, 0.25423729, 0.25762712, + 0.26101695, 0.26440678, 0.26779661, 0.27118644, 0.27457627, + 0.2779661 , 0.28135593, 0.28474576, 0.28813559, 0.29152542, + 0.29491525, 0.29830508, 0.30169492, 0.30508475, 0.30847458, + 0.31186441, 0.31525424, 0.31864407, 0.3220339 , 0.32542373, + 0.32881356, 0.33220339, 0.33559322, 0.33898305, 0.34237288, + 0.34576271, 0.34915254, 0.35254237, 0.3559322 , 0.35932203, + 0.36271186, 0.36610169, 0.36949153, 0.37288136, 0.37627119, + 0.37966102, 0.38305085, 0.38644068, 0.38983051, 0.39322034, + 0.39661017, 0.4 , 0.40338983, 0.40677966, 0.41016949, + 0.41355932, 0.41694915, 0.42033898, 0.42372881, 0.42711864, + 0.43050847, 0.43389831, 0.43728814, 0.44067797, 0.4440678 , + 0.44745763, 0.45084746, 0.45423729, 0.45762712, 0.46101695, + 0.46440678, 0.46779661, 0.47118644, 0.47457627, 0.4779661 , + 0.48135593, 0.48474576, 0.48813559, 0.49152542, 0.49491525, + 0.49830508, 0.50169492, 0.50508475, 0.50847458, 0.51186441, + 0.51525424, 0.51864407, 0.5220339 , 0.52542373, 0.52881356, + 0.53220339, 0.53559322, 0.53898305, 0.54237288, 0.54576271, + 0.54915254, 0.55254237, 0.5559322 , 0.55932203, 0.56271186, + 0.56610169, 0.56949153, 0.57288136, 0.57627119, 0.57966102, + 0.58305085, 0.58644068, 0.58983051, 0.59322034, 0.59661017, + 0.6 , 0.60338983, 0.60677966, 0.61016949, 0.61355932, + 0.61694915, 0.62033898, 0.62372881, 0.62711864, 0.63050847, + 0.63389831, 0.63728814, 0.64067797, 0.6440678 , 0.64745763, + 0.65084746, 0.65423729, 0.65762712, 0.66101695, 0.66440678, + 0.66779661, 0.67118644, 0.67457627, 0.6779661 , 0.68135593, + 0.68474576, 0.68813559, 0.69152542, 0.69491525, 0.69830508, + 0.70169492, 0.70508475, 0.70847458, 0.71186441, 0.71525424, + 0.71864407, 0.7220339 , 0.72542373, 0.72881356, 0.73220339, + 0.73559322, 0.73898305, 0.74237288, 0.74576271, 0.74915254, + 0.75254237, 0.7559322 , 0.75932203, 0.76271186, 0.76610169, + 0.76949153, 0.77288136, 0.77627119, 0.77966102, 0.78305085, + 0.78644068, 0.78983051, 0.79322034, 0.79661017, 0.8 , + 0.80338983, 0.80677966, 0.81016949, 0.81355932, 0.81694915, + 0.82033898, 0.82372881, 0.82711864, 0.83050847, 0.83389831, + 0.83728814, 0.84067797, 0.8440678 , 0.84745763, 0.85084746, + 0.85423729, 0.85762712, 0.86101695, 0.86440678, 0.86779661, + 0.87118644, 0.87457627, 0.8779661 , 0.88135593, 0.88474576, + 0.88813559, 0.89152542, 0.89491525, 0.89830508, 0.90169492, + 0.90508475, 0.90847458, 0.91186441, 0.91525424, 0.91864407, + 0.9220339 , 0.92542373, 0.92881356, 0.93220339, 0.93559322, + 0.93898305, 0.94237288, 0.94576271, 0.94915254, 0.95254237, + 0.9559322 , 0.95932203, 0.96271186, 0.96610169, 0.96949153, + 0.97288136, 0.97627119, 0.97966102, 0.98305085, 0.98644068, + 0.98983051, 0.99322034, 0.99661017, 1. , 1. , + 1. , 1. ]), + knot_v=np.array([0. , 0. , 0. , 0.09092302, 0.09092302, + 1. , 1. , 1. ]), + control_points=np.array([[[46742.96936536, 10148.75700572, -0. ], + [46745.93489773, 10163.60610769, -0. ], + [46749.26552869, 10181.84805612, 0. ], + [46734.70673134, 10321.10384628, -0. ], + [47088.34613697, 10197.16411948, 0. ]], + + [[46742.96936536, 10148.75700572, 50.18844089], + [46745.93489773, 10163.60610769, 50.18844089], + [46749.26552869, 10181.84805612, 50.18844089], + [46734.70673134, 10321.10384628, 50.18844089], + [47088.34613697, 10197.16411947, 50.18844089]], + + [[46742.96936536, 10148.75700572, 150.56594279], + [46745.93489773, 10163.60610769, 150.56594279], + [46749.26552869, 10181.84805612, 150.56594279], + [46734.70673134, 10321.10384628, 150.56594279], + [47088.34613697, 10197.16411948, 150.56594279]], + + [[46742.96936536, 10148.75700572, 301.13231984], + [46745.93489773, 10163.60610769, 301.13231984], + [46749.26552869, 10181.84805612, 301.13231984], + [46734.70673134, 10321.10384628, 301.13231984], + [47088.34613697, 10197.16411948, 301.13231984]], + + [[46742.96936536, 10148.75700572, 426.60359013], + [46745.93489773, 10163.60610769, 426.60358928], + [46749.26552869, 10181.84805612, 426.60358844], + [46734.70673134, 10321.10384628, 426.60356824], + [47088.34613699, 10197.16411947, 426.60374173]], + + [[46742.96936536, 10148.75700572, 526.98100428], + [46745.93489773, 10163.60610769, 526.98100364], + [46749.26552869, 10181.84805612, 526.98100301], + [46734.70673134, 10321.10384628, 526.98098792], + [47088.34613701, 10197.16411946, 526.98111756]], + + [[46742.96936536, 10148.75700572, 602.2643804 ], + [46745.93489773, 10163.60610769, 602.2643804 ], + [46749.26552869, 10181.84805612, 602.2643804 ], + [46734.70673134, 10321.10384628, 602.2643804 ], + [47088.346137 , 10197.16411947, 602.2643804 ]], + + [[46742.96936536, 10148.75700572, 690.09481118], + [46745.93489773, 10163.60610769, 690.09481217], + [46749.26552869, 10181.84805612, 690.09481316], + [46734.70673135, 10321.10384628, 690.09483672], + [47088.34613694, 10197.16411948, 690.09463436]], + + [[46742.96936536, 10148.75700572, 790.4717258 ], + [46745.93489773, 10163.60610769, 790.47172665], + [46749.26552869, 10181.84805612, 790.4717275 ], + [46734.70673135, 10321.10384628, 790.47174769], + [47088.34613685, 10197.16411952, 790.47157425]], + + [[46742.96936536, 10148.75700572, 903.39663459], + [46745.93489773, 10163.60610769, 903.39663459], + [46749.26552869, 10181.84805612, 903.39663459], + [46734.70673134, 10321.10384628, 903.39663459], + [47088.34613684, 10197.16411952, 903.39663459]], + + [[46742.96936536, 10148.75700572, 1010.04736261], + [46745.93489773, 10163.60610769, 1010.04736289], + [46749.26552869, 10181.84805612, 1010.04736318], + [46734.70673134, 10321.10384628, 1010.04736995], + [47088.34613707, 10197.16411944, 1010.04731172]], + + [[46742.96936536, 10148.75700572, 1110.42529898], + [46745.93489773, 10163.60610769, 1110.42529928], + [46749.26552869, 10181.84805612, 1110.42529958], + [46734.70673133, 10321.10384628, 1110.42530667], + [47088.34613741, 10197.16411933, 1110.42524567]], + + [[46742.96936536, 10148.75700572, 1204.52858973], + [46745.93489773, 10163.60610769, 1204.52858973], + [46749.26552869, 10181.84805612, 1204.52858973], + [46734.70673133, 10321.10384628, 1204.52858973], + [47088.34613752, 10197.16411929, 1204.52858973]], + + [[46742.96936536, 10148.75700572, 1301.76940796], + [46745.93489773, 10163.60610769, 1301.76940734], + [46749.26552869, 10181.84805612, 1301.76940672], + [46734.70673133, 10321.10384629, 1301.76939197], + [47088.34613698, 10197.16411947, 1301.76951892]], + + [[46742.96936536, 10148.75700572, 1402.14655035], + [46745.93489773, 10163.60610769, 1402.14654895], + [46749.26552869, 10181.84805612, 1402.14654756], + [46734.70673135, 10321.10384629, 1402.14651448], + [47088.3461357 , 10197.16411991, 1402.14679895]], + + [[46742.96936536, 10148.75700571, 1505.66105268], + [46745.93489773, 10163.60610769, 1505.66105268], + [46749.26552869, 10181.84805612, 1505.66105268], + [46734.70673138, 10321.10384628, 1505.66105267], + [47088.34613487, 10197.16412018, 1505.66105268]], + + [[46742.96936536, 10148.75700571, 1607.60618385], + [46745.93489773, 10163.60610768, 1607.60618357], + [46749.2655287 , 10181.8480561 , 1607.60618328], + [46734.70673151, 10321.10384622, 1607.60617641], + [47088.34613636, 10197.16411968, 1607.60623476]], + + [[46742.96936536, 10148.75700571, 1707.98413819], + [46745.93489773, 10163.60610768, 1707.98413687], + [46749.2655287 , 10181.8480561 , 1707.98413556], + [46734.70673145, 10321.10384621, 1707.98410426], + [47088.34614083, 10197.16411817, 1707.98437219]], + + [[46742.96936536, 10148.75700572, 1806.79295733], + [46745.93489773, 10163.60610769, 1806.79295733], + [46749.26552868, 10181.84805613, 1806.79295733], + [46734.7067312 , 10321.10384627, 1806.79295734], + [47088.34614456, 10197.16411691, 1806.79295733]], + + [[46742.96936537, 10148.75700575, 1906.38663331], + [46745.9348977 , 10163.60610774, 1906.38663316], + [46749.26552862, 10181.84805622, 1906.38663301], + [46734.70673046, 10321.10384656, 1906.38662948], + [47088.34614154, 10197.16411793, 1906.38666172]], + + [[46742.96936537, 10148.75700576, 2006.76318292], + [46745.93489769, 10163.60610775, 2006.76318189], + [46749.2655286 , 10181.84805625, 2006.76318086], + [46734.70673044, 10321.10384669, 2006.76315678], + [47088.34612744, 10197.1641227 , 2006.7633669 ]], + + [[46742.96936535, 10148.75700569, 2107.92542482], + [46745.93489773, 10163.60610766, 2107.9254248 ], + [46749.26552869, 10181.84805607, 2107.92542478], + [46734.70673185, 10321.10384632, 2107.92542484], + [47088.34611102, 10197.16412825, 2107.92542481]], + + [[46742.96936532, 10148.75700555, 2208.69448068], + [46745.93489784, 10163.60610747, 2208.69448103], + [46749.26552897, 10181.84805565, 2208.69448139], + [46734.70673532, 10321.10384505, 2208.69448978], + [47088.34611517, 10197.16412685, 2208.69441261]], + + [[46742.96936532, 10148.75700551, 2309.07224754], + [46745.93489789, 10163.60610742, 2309.07224822], + [46749.26552908, 10181.84805553, 2309.07224889], + [46734.70673602, 10321.10384442, 2309.07226358], + [47088.34615732, 10197.16411259, 2309.07212736]], + + [[46742.96936537, 10148.7570058 , 2409.05741588], + [46745.93489773, 10163.60610778, 2409.05741596], + [46749.26552867, 10181.84805629, 2409.05741603], + [46734.70672967, 10321.10384614, 2409.05741557], + [47088.34622085, 10197.1640911 , 2409.05741593]], + + [[46742.96936549, 10148.75700638, 2509.23868709], + [46745.9348973 , 10163.60610855, 2509.23868704], + [46749.26552758, 10181.84805796, 2509.23868697], + [46734.70671554, 10321.10385106, 2509.238685 ], + [47088.34622949, 10197.16408818, 2509.23871276]], + + [[46742.96936552, 10148.75700655, 2609.61620825], + [46745.93489706, 10163.60610881, 2609.61620776], + [46749.26552705, 10181.84805855, 2609.61620728], + [46734.70671084, 10321.10385379, 2609.6162004 ], + [47088.34611578, 10197.16412664, 2609.61628705]], + + [[46742.9693653 , 10148.75700542, 2710.18969749], + [46745.9348977 , 10163.60610738, 2710.1896972 ], + [46749.26552874, 10181.84805553, 2710.18969693], + [46734.70673655, 10321.10384672, 2710.18969931], + [47088.3458832 , 10197.16420531, 2710.18969727]], + + [[46742.96936487, 10148.75700325, 2810.66449925], + [46745.93489931, 10163.6061045 , 2810.66449892], + [46749.26553275, 10181.84804932, 2810.66449861], + [46734.70679022, 10321.1038287 , 2810.66449504], + [47088.34577834, 10197.16424078, 2810.66451183]], + + [[46742.96936474, 10148.75700262, 2911.04314321], + [46745.93490021, 10163.60610352, 2911.04314344], + [46749.26553477, 10181.8480471 , 2911.04314364], + [46734.70681062, 10321.10381874, 2911.04313384], + [47088.34605608, 10197.16414684, 2911.04315395]], + + [[46742.96936555, 10148.75700667, 3011.32151294], + [46745.93489783, 10163.60610867, 3011.32151396], + [46749.26552855, 10181.84805807, 3011.3215149 ], + [46734.70671628, 10321.10384499, 3011.32150451], + [47088.34684001, 10197.16388167, 3011.32151384]], + + [[46742.96936706, 10148.75701423, 3111.65076962], + [46745.93489222, 10163.60611871, 3111.65076921], + [46749.26551459, 10181.84807962, 3111.65076872], + [46734.70652534, 10321.10390696, 3111.65073721], + [47088.34744288, 10197.16367775, 3111.65097589]], + + [[46742.96936749, 10148.75701641, 3212.02303714], + [46745.93488918, 10163.60612209, 3212.0230365 ], + [46749.26550774, 10181.84808708, 3212.02303592], + [46734.7064496 , 10321.10393974, 3212.02306591], + [47088.34690224, 10197.16386062, 3212.02292005]], + + [[46742.96936219, 10148.75698984, 3312.46867342], + [46745.93489476, 10163.60609175, 3312.46866402], + [46749.26552847, 10181.84801953, 3312.46865015], + [46734.70676068, 10321.1038487 , 3312.46856864], + [47088.34444316, 10197.16469241, 3312.46854996]], + + [[46742.96805308, 10148.75735307, 3412.83493744], + [46745.93366911, 10163.60642599, 3412.83428729], + [46749.26439947, 10181.84818522, 3412.83354587], + [46734.70761225, 10321.10351773, 3412.81685678], + [47088.34314868, 10197.16513027, 3412.94268848]], + + [[46742.94655217, 10148.7648539 , 3513.21040914], + [46745.91222181, 10163.6139084 , 3513.20910571], + [46749.24299705, 10181.85545605, 3513.20740106], + [46734.68823956, 10321.10995862, 3513.16914215], + [47088.34600707, 10197.16416343, 3513.4187214 ]], + + [[46742.86979392, 10148.79193855, 3613.74882688], + [46745.83502806, 10163.64114501, 3613.74689967], + [46749.16529175, 10181.88257225, 3613.74395711], + [46734.60380338, 10321.13841419, 3613.68032772], + [47088.34918754, 10197.16308768, 3614.02316661]], + + [[46742.69725828, 10148.85217193, 3713.94081028], + [46745.66235554, 10163.70142747, 3713.9348747 ], + [46748.99240931, 10181.94153819, 3713.92695368], + [46734.43670888, 10321.19453044, 3713.75243925], + [47088.37090718, 10197.15574112, 3714.8448614 ]], + + [[46742.38079436, 10148.96306685, 3814.42879916], + [46745.34561025, 10163.81242296, 3814.41882198], + [46748.67518755, 10182.05060819, 3814.40545945], + [46734.13066045, 10321.29764596, 3814.10803485], + [47088.38527161, 10197.1508826 , 3815.98228758]], + + [[46741.86581769, 10149.14524653, 3915.18535377], + [46744.82778689, 10163.9955961 , 3915.17159559], + [46748.15394435, 10182.23349903, 3915.15259839], + [46733.57232843, 10321.48704715, 3914.73239564], + [47088.31827609, 10197.17354431, 3917.30641053]], + + [[46741.09585641, 10149.41609169, 4015.59820703], + [46744.0553275 , 10164.26731587, 4015.57765237], + [46747.37836699, 10182.50247644, 4015.54953423], + [46732.78336367, 10321.75451872, 4014.9337758 ], + [47088.21766434, 10197.20757804, 4018.71990229]], + + [[46740.00308515, 10149.79931549, 4116.19402857], + [46742.96099502, 10164.65109165, 4116.16680454], + [46746.28177952, 10182.88044816, 4116.12913178], + [46731.71524956, 10322.1166246 , 4115.30414117], + [47088.06530234, 10197.25911981, 4120.33881362]], + + [[46738.518718 , 10150.32303342, 4216.95171358], + [46741.47006292, 10165.17710313, 4216.91828512], + [46744.78287983, 10183.40389963, 4216.870952 ], + [46730.15144044, 10322.64897933, 4215.83823914], + [47087.68799305, 10197.38677039, 4222.02672075]], + + [[46736.57940818, 10151.0056644 , 4317.45216708], + [46739.52401457, 10165.86209209, 4317.40955476], + [46742.82851838, 10184.08321953, 4317.34910111], + [46728.15590441, 10323.32855993, 4316.04389626], + [47087.16226127, 10197.56466665, 4323.81821051]], + + [[46734.10832038, 10151.87273212, 4418.14283696], + [46737.04846217, 10166.73073208, 4418.09132588], + [46740.34693013, 10184.94027927, 4418.01750828], + [46725.71909193, 10324.15824988, 4416.42800436], + [47086.51360735, 10197.7842073 , 4425.83073673]], + + [[46731.03961152, 10152.95388317, 4519.00604298], + [46733.96794035, 10167.81601301, 4518.94615659], + [46737.252023 , 10186.01848073, 4518.85896696], + [46722.53489447, 10325.24524637, 4516.98706817], + [47085.49019021, 10198.13069818, 4527.9470915 ]], + + [[46727.33050467, 10154.25922793, 4619.57403011], + [46730.24628279, 10169.12575051, 4619.50243073], + [46733.51495112, 10187.31753665, 4619.39827345], + [46718.73090165, 10326.54447347, 4617.18238517], + [47084.20992246, 10198.56433303, 4630.14984973]], + + [[46722.92517277, 10155.80563215, 4720.25802417], + [46725.83203105, 10170.67529087, 4720.17513248], + [46729.08900164, 10188.84799076, 4720.0542401 ], + [46714.36565369, 10328.03527877, 4717.48927058], + [47082.75007083, 10199.05903384, 4732.5466053 ]], + + [[46717.79558679, 10157.61201871, 4821.05048661], + [46720.68391355, 10172.48815837, 4820.95674798], + [46723.9183216 , 10190.64771767, 4820.8192955 ], + [46709.08181038, 10329.84326332, 4817.90974977], + [47080.79567896, 10199.72169814, 4835.02697759]], + + [[46711.94600438, 10159.67069748, 4921.49461858], + [46714.81494422, 10174.55362456, 4921.38603435], + [46718.02559834, 10192.69600052, 4921.22811477], + [46703.10218387, 10331.89041186, 4917.91286461], + [47078.51834271, 10200.49437212, 4937.6168773 ]], + + [[46705.33960366, 10161.99063134, 5022.03020548], + [46708.19456687, 10176.87846995, 5021.90742541], + [46711.38710445, 10194.99302888, 5021.72981919], + [46696.54877289, 10334.13421615, 5018.01187575], + [47076.03741081, 10201.33669705, 5040.42790853]], + + [[46697.96950421, 10164.58626571, 5122.6670116 ], + [46700.79839605, 10179.48322387, 5122.53029741], + [46703.95923846, 10197.57787848, 5122.33309677], + [46688.98766577, 10336.72739764, 5118.21639513], + [47072.95028487, 10202.38558105, 5143.33792969]], + + [[46689.87799102, 10167.4348211 , 5222.86818572], + [46692.68018282, 10182.34112944, 5222.71254818], + [46695.80838173, 10200.41148985, 5222.49111788], + [46680.73748678, 10339.55869775, 5217.91022385], + [47069.47119721, 10203.56850971, 5246.36669248]], + + [[46681.03329497, 10170.5419267 , 5323.15051101], + [46683.81669187, 10185.45484012, 5322.97701671], + [46686.920602 , 10203.48788765, 5322.73285791], + [46671.97552304, 10342.56644645, 5317.7020694 ], + [47065.75871771, 10204.83176403, 5349.64702405]], + + [[46671.44628866, 10173.91944442, 5423.54075985], + [46674.19597996, 10188.84415012, 5423.3493311 ], + [46677.2589986 , 10206.85013313, 5423.08222366], + [46662.17204794, 10345.93697512, 5417.60149705], + [47061.31337708, 10206.34567963, 5453.03738995]], + + [[46661.19752677, 10177.52899778, 5523.40651901], + [46663.91340914, 10192.4655461 , 5523.19084905], + [46666.93518122, 10210.43986339, 5522.89506019], + [46651.74958922, 10349.52281062, 5516.89169291], + [47056.39852276, 10208.02109001, 5556.5682656 ]], + + [[46650.24634646, 10181.37734005, 5623.38268251], + [46652.93963893, 10196.32183098, 5623.14454943], + [46655.93213303, 10214.24894841, 5622.82255911], + [46640.93401897, 10353.2452476 , 5616.32636154], + [47051.217523 , 10209.78899074, 5660.40372528]], + + [[46638.61339153, 10185.4774107 , 5723.5111743 ], + [46641.26585672, 10200.43618925, 5723.24993312], + [46644.20896418, 10218.32904963, 5722.90082072], + [46629.07447052, 10357.33285335, 5715.90149098], + [47045.16036652, 10211.85808409, 5764.37120519]], + + [[46626.40039436, 10189.78066119, 5823.0332449 ], + [46629.01276657, 10204.75348541, 5822.74091962], + [46631.90713253, 10222.60719228, 5822.35705194], + [46616.69129898, 10361.60382307, 5814.76382783], + [47038.54338537, 10214.12105186, 5868.50817747]], + + [[46613.44619202, 10194.3336463 , 5922.74370255], + [46616.03369199, 10209.31522112, 5922.42328427], + [46618.89569746, 10227.11111941, 5922.00786428], + [46603.95510568, 10365.99793241, 5913.85725277], + [47031.61910773, 10216.49194469, 5973.01553499]], + + [[46599.65720374, 10199.19520734, 6022.68067429], + [46602.19682705, 10214.19353748, 6022.33058535], + [46605.00140234, 10231.94700049, 6021.88091781], + [46589.94631802, 10370.83678185, 6013.13141161], + [47023.65328627, 10219.22287767, 6077.6863851 ]], + + [[46585.09129996, 10204.32882709, 6121.83976262], + [46587.5841504 , 10219.34353999, 6121.4497233 ], + [46590.33237189, 10237.04864071, 6120.95571452], + [46575.22933107, 10375.9228113 , 6111.49349754], + [47015.02384551, 10222.18521716, 6182.56836371]], + + [[46569.52800855, 10209.79881913, 6221.19174643], + [46571.9939097 , 10224.82301693, 6220.76645988], + [46574.70701589, 10242.45714108, 6220.23342772], + [46559.99068201, 10381.19007661, 6210.12349697], + [47006.03879523, 10225.27366913, 6287.91449163]], + + [[46552.87250553, 10215.67184669, 6320.7935745 ], + [46555.28223466, 10230.7157007 , 6320.32978435], + [46557.9286092 , 10248.29685546, 6319.75333234], + [46543.13009442, 10387.02345078, 6308.9292353 ], + [46995.81883211, 10228.79124407, 6393.47075309]], + + [[46535.2665417 , 10221.87745324, 6419.34764726], + [46537.62219376, 10236.94024332, 6418.83199076], + [46540.2039653 , 10254.46143753, 6418.19929204], + [46525.40465903, 10393.15844646, 6406.52947622], + [46984.80996923, 10232.58540632, 6499.28998222]], + + [[46516.41028673, 10228.5035615 , 6518.11078327], + [46518.73811842, 10243.57614635, 6517.55075383], + [46521.28327179, 10261.01041444, 6516.87051527], + [46507.01203433, 10399.52519624, 6504.46369016], + [46973.37842898, 10236.53023574, 6605.70336548]], + + [[46496.19484624, 10235.6314464 , 6617.15825412], + [46498.45793464, 10250.72667888, 6616.5479354 ], + [46500.92688705, 10268.09509393, 6615.81304015], + [46486.62529648, 10406.58777474, 6602.564675 ], + [46960.46975027, 10240.99017305, 6712.39197846]], + + [[46474.88993509, 10243.13999201, 6714.79698525], + [46477.09216847, 10258.25652205, 6714.11892703], + [46479.48894327, 10275.55104391, 6713.31287029], + [46465.25782578, 10413.99254547, 6699.06934628], + [46946.59858998, 10245.78804822, 6819.40884796]], + + [[46452.05950462, 10251.15909374, 6812.66835977], + [46454.23588349, 10266.28472718, 6811.93454353], + [46456.59751026, 10283.47355745, 6811.07128628], + [46443.07871071, 10421.67944933, 6796.00475016], + [46932.1899162 , 10250.77664249, 6927.19029631]], + + [[46427.56867708, 10259.79168664, 6910.86572529], + [46429.67274706, 10274.94259329, 6910.06618413], + [46431.94993266, 10292.05019125, 6909.13454835], + [46418.48582246, 10430.2085148 , 6893.08717358], + [46915.99215971, 10256.38901455, 7035.33994124]], + + [[46401.88873533, 10268.83836457, 7007.18102476], + [46403.92752766, 10284.01208872, 7006.29322773], + [46406.12757478, 10301.02954041, 7005.27211471], + [46392.84327781, 10439.10394639, 6988.06763611], + [46898.607565 , 10262.41586907, 7143.90137599]], + + [[46374.36721699, 10278.49683286, 7103.75012362], + [46376.38774006, 10293.67698476, 7102.79341171], + [46378.55990261, 10310.56771037, 7101.70504096], + [46366.22774436, 10448.33762674, 7083.60690497], + [46880.52996032, 10268.68500361, 7253.45226388]], + + [[46344.82712143, 10288.90125236, 7200.68533337], + [46346.77131948, 10304.1080346 , 7199.64385016], + [46348.85466549, 10320.90048856, 7198.47123443], + [46336.70713882, 10458.58443701, 7179.24867502], + [46860.2725756 , 10275.71224354, 7363.49271327]], + + [[46314.0520279 , 10299.73324126, 7295.12042618], + [46315.93177977, 10314.96248938, 7293.96541656], + [46317.93854951, 10331.647132 , 7292.68172689], + [46306.12907456, 10469.20017323, 7272.1337409 ], + [46838.52331295, 10283.25948035, 7474.03085813]], + + [[46281.04353301, 10311.30066476, 7389.82212471], + [46282.92165556, 10326.53046439, 7388.58447465], + [46284.91680079, 10343.06582034, 7387.22442035], + [46274.36892246, 10480.22648987, 7365.74034512], + [46815.82752695, 10291.13778192, 7585.84269343]], + + [[46245.56265937, 10323.78067203, 7484.90702817], + [46247.36702921, 10339.03610746, 7483.5624093 ], + [46249.27560596, 10355.45457969, 7482.10088663], + [46239.10931622, 10492.47201848, 7459.35556195], + [46790.42382992, 10299.95908323, 7698.29538523]], + + [[46208.881041 , 10336.67206828, 7576.70802519], + [46210.63014936, 10351.94664078, 7575.22149715], + [46212.47094555, 10368.2386693 , 7573.62592271], + [46202.86373353, 10505.06062377, 7549.40855992], + [46763.10070218, 10309.45025897, 7811.3011131 ]], + + [[46169.48564778, 10350.44709826, 7668.74920258], + [46171.26246624, 10365.71185581, 7667.17021547], + [46173.11997227, 10381.83259222, 7665.49449404], + [46165.15377227, 10518.15662753, 7640.37281566], + [46734.43486616, 10319.41136513, 7925.90932531]], + + [[46127.07909342, 10365.32956266, 7761.09647982], + [46128.79661336, 10380.61473858, 7759.38887567], + [46130.58127843, 10396.60130485, 7757.59663816], + [46123.27020628, 10532.70399861, 7731.11810462], + [46702.36331671, 10330.55989205, 8041.28126979]], + + [[46083.61692859, 10380.56566659, 7849.27551072], + [46085.30240219, 10395.86167748, 7847.39923927], + [46087.04155294, 10411.70591755, 7845.45275208], + [46080.57694228, 10547.53059869, 7817.40883174], + [46667.80412244, 10342.57746524, 8157.15194767]], + + [[46036.88962611, 10396.85056968, 7937.55155912], + [46038.64902986, 10412.12045061, 7935.58450433], + [46040.44885295, 10427.77670667, 7933.56571282], + [46036.06067256, 10562.98605854, 7904.76182154], + [46631.31668968, 10355.26993993, 8274.91438722]], + + [[45986.56386448, 10414.4526999 , 8025.87252717], + [45988.29433113, 10429.73214448, 8023.76299754], + [45990.04971295, 10445.24161858, 8021.62081904], + [45986.66826785, 10580.13177058, 7991.45120369], + [46590.5030423 , 10369.47203555, 8393.42414444]], + + [[45935.44841141, 10432.30687533, 8109.14733443], + [45937.18510598, 10447.58353668, 8106.85295113], + [45938.93088266, 10462.94061421, 8104.5465349 ], + [45936.73142708, 10597.45944996, 8072.82791957], + [46546.46100302, 10384.80231029, 8512.0871482 ]], + + [[45880.46841712, 10451.38277327, 8192.22199226], + [45882.33918674, 10466.6121182 , 8189.86013142], + [45884.20178133, 10481.7749207 , 8187.50855263], + [45884.51572377, 10615.56787208, 8155.33702058], + [46499.62059652, 10401.11111101, 8632.79277988]], + + [[45821.54593108, 10471.89568506, 8274.53878014], + [45823.4339081 , 10487.11818241, 8272.03992876], + [45825.29466752, 10502.13120254, 8269.57508074], + [45827.01066957, 10635.49967805, 8236.17519027], + [46447.46858677, 10419.27340996, 8753.50342716]], + + [[45761.71234829, 10492.69516846, 8351.72280476], + [45763.65098467, 10507.89912843, 8349.04812675], + [45765.54289998, 10522.76186718, 8346.43111507], + [45768.72809996, 10655.68523575, 8311.68842987], + [46391.44074437, 10438.78867396, 8873.19094867]], + + [[45697.92019611, 10514.71007182, 8427.72990742], + [45700.04756252, 10529.84752654, 8425.03920057], + [45702.10678835, 10544.52906213, 8422.42658038], + [45708.04770503, 10676.68111901, 8387.72197087], + [46331.90120746, 10459.5286901 , 8993.99627108]], + + [[45631.04889119, 10537.85405606, 8501.49177493], + [45633.23962259, 10552.96830167, 8498.69996659], + [45635.34019482, 10567.51406647, 8496.00842281], + [45642.92527543, 10699.19009516, 8460.46057957], + [46266.75889388, 10482.21966452, 9112.50113004]], + + [[45562.24355013, 10561.63703047, 8572.00210625], + [45564.51185868, 10576.72314572, 8569.07620866], + [45566.66878164, 10591.14003918, 8566.27095043], + [45575.71535448, 10722.39102296, 8529.84586277], + [46197.62410704, 10506.29735481, 9227.99559827]], + + [[45491.09726589, 10586.05029024, 8639.96802164], + [45493.567649 , 10601.06522762, 8637.08848976], + [45495.90569294, 10615.33987533, 8634.34140911], + [45507.45863368, 10745.91768644, 8598.46649916], + [46125.55198875, 10531.38998059, 9341.58359201]], + + [[45418.29785473, 10611.07576954, 8704.97425383], + [45420.85253086, 10626.06007417, 8702.04754645], + [45423.25466433, 10640.23293222, 8699.26736993], + [45436.36197748, 10770.38223056, 8663.01907127], + [46048.35524099, 10558.25325574, 9450.6208992 ]], + + [[45342.83800475, 10636.97438711, 8768.10212275], + [45345.48393484, 10651.92571899, 8765.09897056], + [45347.95924289, 10666.0047971 , 8762.25412395], + [45362.3806817 , 10795.79084711, 8725.58040427], + [45967.15338468, 10586.48992565, 9555.80969482]], + + [[45265.97619128, 10663.16665698, 8828.46775817], + [45268.81260487, 10678.05096467, 8825.56342748], + [45271.46330923, 10692.0367052 , 8822.81846282], + [45287.98930679, 10821.28467523, 8786.99990845], + [45883.65036887, 10615.49922039, 9657.86927154]], + + [[45188.20224332, 10689.68599442, 8885.93330103], + [45191.12791079, 10704.53796998, 8883.0319807 ], + [45193.85399725, 10718.46204066, 8880.29416141], + [45211.73505636, 10847.3548982 , 8844.44151412], + [45795.96307945, 10645.92541898, 9754.67837359]], + + [[45107.4887254 , 10717.14664375, 8942.11915143], + [45110.50761518, 10731.9650416 , 8939.1974125 ], + [45113.31596059, 10745.83486338, 8936.44135018], + [45132.3157263 , 10874.43555423, 8900.49329419], + [45705.13817217, 10677.3932165 , 9847.20937485]], + + [[45026.20965093, 10744.60680322, 8995.4943545 ], + [45029.39400763, 10759.36710833, 8992.70859869], + [45032.36198675, 10773.19179216, 8990.08063203], + [45053.04630774, 10901.3860426 , 8955.12017869], + [45613.09107288, 10709.22759494, 9936.16602012]], + + [[44944.64061513, 10772.14953484, 9046.11229955], + [44947.90680473, 10786.88023736, 9043.36960373], + [44950.95172206, 10800.68259796, 9040.78053763], + [44972.74445807, 10928.6020007 , 9005.99328411], + [45518.20729027, 10741.97452255, 10019.78821199]], + + [[44860.1027919 , 10800.6097834 , 9095.72765705], + [44863.45478818, 10815.30969034, 9093.00884931], + [44866.58349788, 10829.09483435, 9090.43840837], + [44889.28691747, 10956.79059872, 9055.75658795], + [45421.06156048, 10775.42082651, 10099.3478961 ]], + + [[44775.39394488, 10828.92775361, 9142.78071353], + [44778.88252083, 10843.57989417, 9140.21824822], + [44782.15171076, 10857.35970684, 9137.79107738], + [44806.17467491, 10984.75794714, 9104.11338281], + [45323.34854364, 10808.96990696, 10175.61346985]], + + [[44690.64291659, 10857.20985325, 9187.38897808], + [44694.20116715, 10871.83684918, 9184.89753593], + [44697.54422553, 10885.62683665, 9182.53217324], + [44722.45016242, 11012.82006157, 9149.14008637], + [45223.74359417, 10843.06304044, 10247.05899274]], + + [[44603.15981773, 10886.29362753, 9231.03365258], + [44606.79094253, 10900.89464977, 9228.59822834], + [44610.21341563, 10914.69818704, 9226.27941788], + [44635.83974375, 11041.72789161, 9193.10596834], + [45122.66612097, 10877.5408456 , 10314.83168802]], + + [[44515.75869715, 10915.14929089, 9272.36035664], + [44519.49636936, 10929.71336726, 9270.08903792], + [44523.03726812, 10943.54134564, 9267.91983822], + [44549.68081068, 11070.36098463, 9235.7082402 ], + [45021.48675266, 10911.92239309, 10379.75229065]], + + [[44428.49243245, 10943.88987529, 9311.49020644], + [44432.28391808, 10958.43470004, 9309.3059436 ], + [44435.88977919, 10972.29604427, 9307.21303407], + [44463.22256299, 11098.96856181, 9275.33597959], + [44919.17011321, 10946.5462525 , 10440.48313125]], + + [[44338.78578138, 10973.31547539, 9349.63440584], + [44342.63385372, 10987.84036058, 9347.52364646], + [44346.30866241, 11001.73520054, 9345.49403286], + [44374.20160158, 11128.29245028, 9313.88739196], + [44815.92606894, 10981.32594251, 10498.05411579]], + + [[44249.27944267, 11002.48305617, 9385.72734083], + [44253.20579234, 11016.98107077, 9383.7750988 ], + [44256.97504165, 11030.91623577, 9381.89121464], + [44285.64412961, 11157.33008726, 9351.17414279], + [44712.80487424, 11015.89478481, 10553.25593443]], + + [[44159.97258524, 11031.49991202, 9419.90481975], + [44163.93644129, 11045.98455386, 9418.04319402], + [44167.75777835, 11059.96396756, 9416.24031937], + [44196.951647 , 11186.2777773 , 9385.87164667], + [44608.99000005, 11050.51796393, 10604.91785406]], + + [[44068.60039684, 11061.06113232, 9453.09969329], + [44072.60458065, 11075.53166948, 9451.31600039], + [44076.48091875, 11089.55264736, 9449.58239392], + [44106.0993197 , 11215.79027967, 9419.50341285], + [44504.61575708, 11085.1457699 , 10653.90425212]], + + [[43977.48565276, 11090.35325107, 9484.5339444 ], + [43981.54413848, 11104.80530564, 9482.89399855], + [43985.49154569, 11118.8704449 , 9481.294516 ], + [44015.69487709, 11245.01554787, 9452.02572447], + [44400.4646128 , 11119.51644218, 10700.95639374]], + + [[43886.6129316 , 11119.46771977, 9514.33596548], + [43890.69444581, 11133.91149612, 9512.78139496], + [43894.67967656, 11148.02199307, 9511.26004796], + [43925.27096084, 11274.10493969, 9482.33481124], + [44295.91971964, 11153.83180476, 10745.04357224]], + + [[43794.01794787, 11148.9996094 , 9543.2214724 ], + [43798.12498596, 11163.43444937, 9541.7407967 ], + [43802.14986332, 11177.58615779, 9540.28705186], + [43833.04835761, 11303.62562045, 9511.65352889], + [44191.05828944, 11188.06332694, 10786.88432634]], + + [[43701.70246135, 11178.26088776, 9570.63646726], + [43705.8439455 , 11192.68405107, 9569.28044686], + [43709.91804002, 11206.87620734, 9567.9449874 ], + [43741.24379289, 11332.86373882, 9540.04512766], + [44086.45112517, 11222.02321446, 10827.13654349]], + + [[43609.64383907, 11207.32820595, 9596.69857491], + [43613.79603859, 11221.74731447, 9595.41768084], + [43617.8936274 , 11235.97975917, 9594.15258009], + [43649.49088657, 11361.93634324, 9566.58220618], + [43981.628323 , 11255.86283184, 10864.8909401 ]], + + [[43516.16245018, 11236.70323017, 9621.95724303], + [43520.32785974, 11251.117595 , 9620.74111298], + [43524.45032417, 11265.38539993, 9619.53678459], + [43556.25571712, 11391.32505077, 9592.25017198], + [43876.63217602, 11289.56617419, 10900.74353728]], + + [[43422.96150261, 11265.81107906, 9646.02501437], + [43427.14588265, 11280.2189629 , 9644.91295487], + [43431.29857482, 11294.51893136, 9643.80901251], + [43463.40390032, 11420.43813274, 9617.18492595], + [43771.88453663, 11322.99675038, 10935.27296418]], + + [[43330.00343837, 11294.71929872, 9669.00388926], + [43334.18869144, 11309.12641495, 9667.95411155], + [43338.35172937, 11323.45790086, 9666.90973644], + [43370.63534075, 11449.37003247, 9640.59547448], + [43667.02797044, 11356.26774156, 10967.69080801]], + + [[43235.85369059, 11323.84958077, 9691.27829656], + [43240.04219024, 11338.25525352, 9690.28175649], + [43244.21673322, 11352.61339326, 9689.28844304], + [43276.63428897, 11478.52726861, 9663.24284086], + [43562.08850501, 11389.36907669, 10998.50310598]], + + [[43141.93669176, 11352.72860139, 9712.52981405], + [43146.13237482, 11367.13165837, 9711.6179923 ], + [43150.32150802, 11381.51241425, 9710.70758097], + [43182.95061893, 11507.42582336, 9685.2569721 ], + [43457.37561256, 11422.20226913, 11028.21099581]], + + [[43048.21691631, 11381.41216859, 9732.80599076], + [43052.40612574, 11395.81684042, 9731.94429637], + [43056.59497902, 11410.22030759, 9731.08267153], + [43089.34169472, 11536.14070226, 9705.91712364], + [43352.61385394, 11454.85291825, 11056.11234492]], + + [[42953.49124066, 11410.24767274, 9752.38765504], + [42957.67632124, 11424.65327554, 9751.56887269], + [42961.86628771, 11439.07559553, 9750.74912557], + [42994.69531747, 11565.00964924, 9725.83338193], + [43247.8188045 , 11487.31467426, 11082.62699274]], + + [[42858.9608225 , 11438.8427068 , 9771.04664158], + [42863.14444736, 11453.24845054, 9770.29684292], + [42867.33725291, 11467.68564697, 9769.54535287], + [42900.30871439, 11593.63400735, 9745.16497608], + [43143.22022194, 11519.51557658, 11108.19082347]], + + [[42764.59638943, 11467.24315421, 9788.82498381], + [42768.76809168, 11481.65221382, 9788.11522994], + [42772.95252885, 11496.1050088 , 9787.40324774], + [42805.99067103, 11622.07176249, 9763.28472766], + [43038.60609016, 11551.52016395, 11132.18861697]], + + [[42669.38452367, 11495.73621979, 9805.93518206], + [42673.54677634, 11510.14786396, 9805.25957659], + [42677.7245759 , 11524.61307059, 9804.58137361], + [42710.80279818, 11650.60278182, 9780.69429862], + [42933.98416583, 11583.32475072, 11154.98497462]], + + [[42574.33765871, 11523.99654758, 9822.21653739], + [42578.4924165 , 11538.41019865, 9821.59709046], + [42582.66457848, 11552.88398653, 9820.9749214 ], + [42615.83116279, 11678.89883932, 9797.57127355], + [42829.5292722 , 11614.87541523, 11176.96166861]], + + [[42479.4278915 , 11552.06345881, 9837.7073051 ], + [42483.56683035, 11566.48158513, 9837.11951841], + [42487.72464097, 11580.96515624, 9836.52892179], + [42520.91613435, 11707.00734833, 9813.3660452 ], + [42725.07592055, 11646.22140248, 11197.57220546]], + + [[42383.80649334, 11580.17246509, 9852.56824393], + [42387.93181158, 11594.59441386, 9852.00738168], + [42392.07693157, 11609.08531679, 9851.44370206], + [42425.26943341, 11735.15897798, 9828.49579887], + [42620.631658 , 11677.35925083, 11217.12915926]], + + [[42288.3226242 , 11608.05484819, 9866.68935249], + [42292.43567215, 11622.48021618, 9866.17454874], + [42296.56859894, 11636.97508106, 9865.65709686], + [42329.79964557, 11763.08384105, 9843.14825586], + [42516.32449041, 11708.2500079 , 11235.96346892]], + + [[42192.95573861, 11635.74316622, 9880.10484938], + [42197.04990374, 11650.17386538, 9879.61501787], + [42201.16389069, 11664.67417508, 9879.1226707 ], + [42234.38138781, 11790.81834786, 9856.83569459], + [42412.02701217, 11738.93062433, 11253.58226593]], + + [[42096.99046673, 11663.43146597, 9892.93803634], + [42101.06809478, 11677.86680082, 9892.46931998], + [42105.16511947, 11692.37058809, 9891.99825083], + [42138.35136176, 11818.5527963 , 9869.91078445], + [42307.73941846, 11769.39988963, 11270.25768478]], + + [[42001.13739755, 11690.89804364, 9905.11505245], + [42005.19958059, 11705.33768343, 9904.6845295 ], + [42009.2800296 , 11719.84209429, 9904.25193411], + [42042.46701516, 11846.0661782 , 9882.56471872], + [42203.56139924, 11799.62783047, 11286.28663863]], + + [[41905.3739898 , 11718.17249801, 9916.65778383], + [41909.41520019, 11732.61799972, 9916.24705737], + [41913.47359744, 11747.12476385, 9915.83447371], + [41946.61823949, 11873.38969413, 9894.35201071], + [42099.39367066, 11829.64241546, 11301.23254226]], + + [[41809.10132779, 11745.41295947, 9927.64708145], + [41813.12373905, 11759.86368095, 9927.25322546], + [41817.16209512, 11774.37155306, 9926.85772407], + [41850.24973657, 11900.67932499, 9905.5605987 ], + [41995.23920863, 11859.44166242, 11315.33766851]], + + [[41712.91440638, 11772.43606085, 9938.02358971], + [41716.91886822, 11786.89173856, 9937.66254311], + [41720.93759249, 11801.39881574, 9937.30012408], + [41753.99523231, 11927.75350758, 9916.36924303], + [41891.17261965, 11889.00334346, 11328.85979476]], + + [[41616.79761285, 11799.26691855, 9947.8030421 ], + [41620.77951812, 11813.7288137 , 9947.45858408], + [41624.77422064, 11828.23709473, 9947.11295611], + [41657.76623654, 11954.63662261, 9926.37116922], + [41787.11501389, 11918.34897903, 11341.39716342]], + + [[41520.24958246, 11826.03411222, 9957.04553537], + [41524.21115045, 11840.50157584, 9956.71555262], + [41528.1840614 , 11855.01040515, 9956.38458271], + [41561.09865424, 11981.45618379, 9935.81518255], + [41683.06306557, 11947.47837022, 11353.16202104]], + + [[41423.76796978, 11852.58607345, 9965.71243676], + [41427.70983886, 11867.05890028, 9965.4120248 ], + [41431.66137691, 11881.5672013 , 9965.11083324], + [41464.52075466, 12008.06366586, 9944.87824444], + [41579.07574207, 11976.37404954, 11364.38653439]], + + [[41327.33489308, 11878.94673924, 9973.81860646], + [41331.25299112, 11893.42601253, 9973.5330917 ], + [41335.17935725, 11907.93581109, 9973.24694718], + [41367.952417 , 12034.48051817, 9953.18986228], + [41475.09044744, 12005.05244538, 11374.70756426]], + + [[41230.54072676, 11905.21753359, 9981.40786585], + [41234.43717592, 11919.70263867, 9981.13569639], + [41238.34059641, 11934.21365265, 9980.86302457], + [41271.01656231, 12060.80789415, 9960.9670017 ], + [41371.10746186, 12033.5123249 , 11384.31750041]], + + [[41133.79579763, 11931.27458373, 9988.45739056], + [41137.67104503, 11945.76536668, 9988.21292687], + [41141.55190636, 11960.27715183, 9987.96809255], + [41174.14916171, 12086.92555355, 9968.38342594], + [41267.17114569, 12061.74053595, 11393.42635764]], + + [[41037.08551977, 11957.13948526, 9994.98084828], + [41040.93590748, 11971.6368903 , 9994.75054405], + [41044.79081842, 11986.15133137, 9994.51996061], + [41077.28220829, 12112.85112061, 9975.09882881], + [41163.23249755, 12089.74936594, 11401.70220927]], + + [[40940.07488085, 11982.89195785, 10001.01047203], + [40943.90244931, 11997.39540006, 10000.79316885], + [40947.73359489, 12011.91241262, 10000.57565889], + [40980.10939595, 12138.66471621, 9981.30552778], + [41059.28906105, 12117.53829664, 11409.31828733]], + + [[40843.09632478, 12008.43215464, 10006.53516405], + [40846.90134759, 12022.94152491, 10006.34451212], + [40850.70903974, 12037.46109514, 10006.15372134], + [40882.98465235, 12164.27059863, 9987.17278704], + [40955.37452199, 12145.0972455 , 11416.4621352 ]], + + [[40746.13737929, 12033.77989434, 10011.56763956], + [40749.91652209, 12048.29602636, 10011.39092277], + [40753.69764379, 12062.81977461, 10011.21411147], + [40785.84928699, 12189.68343617, 9992.38613868], + [40851.44976727, 12172.43549517, 11422.8265145 ]], + + [[40648.93350012, 12058.9950287 , 10016.13091297], + [40652.68877195, 12073.51735362, 10015.96720546], + [40656.44546854, 12088.04520667, 10015.80343516], + [40688.465577 , 12214.96369053, 9997.11754156], + [40747.51191695, 12199.55258075, 11428.56843724]], + + [[40551.74553739, 12083.99910272, 10020.21566114], + [40555.4771008 , 12098.52753823, 10020.07784978], + [40559.20960844, 12113.05966778, 10019.94000199], + [40591.11318344, 12240.03715361, 10001.5240309 ], + [40643.58544646, 12226.44096712, 11433.85683001]], + + [[40454.56177832, 12108.81063501, 10023.82288954], + [40458.26651545, 12123.34593549, 10023.6988904 ], + [40461.97185882, 12137.88362636, 10023.5748705 ], + [40493.73917305, 12264.91656877, 10005.30253974], + [40539.64187101, 12253.10686431, 11438.40398375]], + + [[40357.18033843, 12133.47237087, 10026.95890172], + [40360.86006518, 12148.01402432, 10026.84788141], + [40364.54015443, 12162.55712292, 10026.73684969], + [40396.1647094 , 12289.64599918, 10008.59864586], + [40435.67769979, 12279.54990607, 11442.35290785]], + + [[40259.8018782 , 12157.92342109, 10029.61600132], + [40263.45656129, 12172.47139132, 10029.53001731], + [40267.11142754, 12187.02010162, 10029.44402823], + [40298.60601513, 12314.16912608, 10011.55885598], + [40331.71193092, 12305.76405182, 11445.86114028]], + + [[40162.41160339, 12182.18196537, 10031.79469021], + [40166.03832994, 12196.73693171, 10031.7222062 ], + [40169.6651327 , 12211.29221075, 10031.64972037], + [40201.01188888, 12338.49783873, 10013.89992891], + [40227.71698176, 12331.75502157, 11448.66398512]], + + [[40064.8786675 , 12206.27225049, 10033.49687616], + [40068.47912162, 12220.83373971, 10033.43714634], + [40072.07959375, 12235.39531012, 10033.37741563], + [40103.27232618, 12362.65804996, 10015.75507971], + [40123.68178835, 12357.52421882, 11450.89560378]], + + [[39967.33873586, 12230.15141983, 10034.71917087], + [39970.91271765, 12244.71943174, 10034.68346246], + [39974.48669556, 12259.28743596, 10034.64775347], + [40005.53608512, 12386.61159781, 10017.26444939], + [40019.63362579, 12383.06358138, 11452.69675612]], + + [[39869.72467008, 12253.84921123, 10035.4618553 ], + [39873.26945077, 12268.42435695, 10035.43912415], + [39876.81422484, 12282.99948127, 10035.41639274], + [39907.70345155, 12410.38170733, 10018.16162485], + [39915.49288189, 12408.39116858, 11453.81380975]], + + [[39772.1416313 , 12277.33210351, 10035.72381189], + [39775.65895602, 12291.91390048, 10035.71369713], + [39779.17627595, 12306.49568594, 10035.70358176], + [39809.90107502, 12433.9364579 , 10018.57409906], + [39811.23255508, 12433.51170293, 11454.36936729]], + + [[39674.4476345 , 12300.62579877, 10035.50479405], + [39677.9372637 , 12315.2142507 , 10035.51810952], + [39681.42688145, 12329.80266307, 10035.53142433], + [39711.9977812 , 12457.30627759, 10018.63605354], + [39706.85378552, 12458.42297256, 11454.48373874]], + + [[39576.6154812 , 12323.74914808, 10034.80281065], + [39580.0747141 , 12338.34483742, 10034.82867771], + [39583.5339084 , 12352.94036908, 10034.85454417], + [39613.93398481, 12480.50363453, 10018.08590222], + [39602.3105083 , 12483.13387408, 11453.8966838 ]], + + [[39478.91937918, 12346.62954489, 10033.6196918 ], + [39482.34990413, 12361.23200835, 10033.65783648], + [39485.78032279, 12375.83402502, 10033.69597932], + [39516.00549957, 12503.45753651, 10017.05274027], + [39497.63899942, 12507.63369474, 11452.72037498]], + + [[39381.09737573, 12369.32053316, 10031.95217858], + [39384.49893018, 12383.92977349, 10032.0129958 ], + [39387.9002416 , 12398.53797096, 10032.07380817], + [39417.96089794, 12526.22604415, 10015.66367089], + [39392.83891072, 12531.92049491, 11451.06965802]], + + [[39283.13919757, 12391.83648602, 10029.79766773], + [39286.50907706, 12406.45306409, 10029.87033503], + [39289.87853101, 12421.06778902, 10029.94299279], + [39319.75785974, 12548.81717803, 10013.65975745], + [39287.87364312, 12556.00059929, 11448.68604137]], + + [[39185.35258925, 12414.09913925, 10027.15653789], + [39188.69222923, 12428.72265474, 10027.24071402], + [39192.03117667, 12443.34312288, 10027.32487155], + [39221.72470144, 12571.15451563, 10011.16779568], + [39182.75960134, 12579.86800882, 11445.68209928]], + + [[39087.42730328, 12436.17166686, 10024.00781484], + [39090.73629806, 12450.80214687, 10024.11339538], + [39094.04419413, 12465.42773764, 10024.21894025], + [39123.56273435, 12593.30531703, 10008.29500829], + [39077.50537083, 12603.51861822, 11442.17071258]], + + [[38989.34418164, 12458.0697965 , 10020.33535512], + [38992.62002119, 12472.70773381, 10020.45163436], + [38995.89431026, 12487.33869393, 10020.56785703], + [39025.22364277, 12615.27856346, 10004.77137931], + [38972.06647751, 12626.96012915, 11437.89064225]], + + [[38891.50104817, 12479.69743658, 10016.14144572], + [38894.74532598, 12494.34239925, 10016.26809188], + [38897.98748935, 12508.97774694, 10016.39465149], + [38927.12569649, 12636.98007911, 10000.72587679], + [38866.46404458, 12650.18546107, 11432.95208684]], + + [[38793.4981934 , 12501.13591848, 10011.39625373], + [38796.71052347, 12515.78792109, 10011.54272505], + [38799.91997311, 12530.42668064, 10011.68906136], + [38828.87771306, 12658.49577858, 9996.25540472], + [38760.7012831 , 12673.19160092, 11427.46229463]], + + [[38695.31698161, 12522.40010145, 10006.08254279], + [38698.4948504 , 12537.05961506, 10006.23834664], + [38701.66906075, 12551.70211941, 10006.3939632 ], + [38730.43075583, 12679.83430432, 9991.08948892], + [38654.73306459, 12695.98589686, 11421.15256802]], + + [[38597.44923134, 12543.37653167, 10000.21455707], + [38600.59421697, 12558.04313267, 10000.37940149], + [38603.73464811, 12572.68832305, 10000.54399224], + [38632.29853348, 12700.88377546, 9985.36962008], + [38548.58949252, 12718.55933942, 11414.13132343]], + + [[38499.40295623, 12564.16410172, 9993.75034577], + [38502.51479853, 12578.83776959, 9993.9332968 ], + [38505.62093754, 12593.48431585, 9994.11589536], + [38533.9985219 , 12721.74726889, 9979.18014609], + [38442.26835405, 12740.90995577, 11406.49980263]], + + [[38401.16477952, 12584.77579836, 9986.67187584], + [38404.24098814, 12599.45697681, 9986.86273506], + [38407.31038981, 12614.10540497, 9987.05314303], + [38435.48476725, 12742.43234473, 9972.24928754], + [38335.72897952, 12763.04368265, 11397.97690506]], + + [[38303.30899839, 12605.08458931, 9979.0084463 ], + [38306.3508854 , 12619.77291534, 9979.20696345], + [38309.38475117, 12634.4221927 , 9979.40491191], + [38337.35147064, 12762.81388885, 9964.73420507], + [38229.00134705, 12784.95162734, 11388.6681677 ]], + + [[38205.2665496 , 12625.20253467, 9970.70338478], + [38208.27372673, 12639.89800551, 9970.91821488], + [38211.27137287, 12654.54650821, 9971.13230941], + [38239.03984968, 12783.00773268, 9956.70438343], + [38122.08808301, 12806.63069503, 11378.67534941]], + + [[38107.00547119, 12645.14570416, 9961.73590982], + [38109.97560492, 12659.84870728, 9961.95716827], + [38112.93481271, 12674.49719941, 9962.17753271], + [38140.48790983, 12803.02428013, 9947.88409663], + [38014.93413968, 12828.08928281, 11367.7087876 ]], + + [[38009.22972033, 12664.76512006, 9952.15872955], + [38012.16434379, 12679.47525137, 9952.38630885], + [38015.08655239, 12694.12266379, 9952.61281308], + [38042.42246954, 12822.71584847, 9938.45623041], + [37907.57580225, 12849.31727441, 11355.87783047]], + + [[37911.23478735, 12684.19662939, 9941.8911585 ], + [37914.13366804, 12698.91384701, 9942.1334557 ], + [37917.01831972, 12713.55824061, 9942.37442779], + [37944.14798712, 12842.22215655, 9928.4670192 ], + [37800.00497999, 12870.31351234, 11343.27858327]], + + [[37812.9918527 , 12703.45472312, 9930.90715215], + [37815.85259107, 12718.1794062 , 9931.15466756], + [37818.69749251, 12732.82196617, 9931.40063055], + [37845.60197094, 12861.55305495, 9917.63195067], + [37692.167843 , 12891.08549438, 11329.60120831]], + + [[37715.34326201, 12722.36859983, 9919.28099151], + [37718.16722561, 12737.10038236, 9919.5338122 ], + [37720.9736973 , 12751.74024026, 9919.78482979], + [37747.65077704, 12880.53882376, 9906.15775466], + [37584.11755959, 12911.6199514 , 11314.95175562]], + + [[37617.45493676, 12741.09481573, 9906.89294558], + [37620.24195173, 12755.83363685, 9907.15937157], + [37623.00950252, 12770.46873559, 9907.42365603], + [37649.47162953, 12899.33932705, 9894.05358419], + [37475.84384598, 12931.91596 , 11299.41996579]], + + [[37519.29583667, 12759.647428 , 9893.70849278], + [37522.04353116, 12774.39363882, 9893.97951665], + [37524.77011308, 12789.02575476, 9894.24810539], + [37550.9973592 , 12917.96471675, 9881.02176053], + [37367.29005217, 12951.98098448, 11282.67505428]], + + [[37421.85648016, 12777.83399816, 9879.8410497 ], + [37424.56598349, 12792.58728189, 9880.11706437], + [37427.25274651, 12807.21590001, 9880.39032971], + [37453.24021012, 12936.22413273, 9867.31107021], + [37258.51212199, 12971.80150921, 11264.82351605]], + + [[37324.15452926, 12795.83342545, 9865.13108822], + [37326.82553766, 12810.59373696, 9865.42045205], + [37329.47189459, 12825.2168653 , 9865.70664775], + [37355.23039919, 12954.29947077, 9852.89313374], + [37149.50011227, 12991.37633505, 11245.94979859]], + + [[37226.14200752, 12813.66204064, 9849.53840799], + [37228.77226958, 12828.42968487, 9849.8326085 ], + [37231.37642099, 12843.04981768, 9850.12329654], + [37256.88651722, 12972.20280343, 9837.45840445], + [37040.18489591, 13010.71452986, 11225.69309145]], + + [[37129.01897341, 12831.09670972, 9833.23547905], + [37131.60987634, 12845.87132638, 9833.53546907], + [37134.17332613, 12860.48842719, 9833.83159027], + [37159.43465178, 12989.71202725, 9821.32041251], + [36930.64556771, 13029.79746286, 11204.16191479]], + + [[37031.59149619, 12848.3478126 , 9816.0033311 ], + [37034.14276475, 12863.12934343, 9816.31804152], + [37036.66503747, 12877.74177731, 9816.6284121 ], + [37061.69073834, 13007.04114718, 9804.39531566], + [36820.85349697, 13048.62211682, 11181.42795044]], + + [[36933.81371775, 12865.43027612, 9797.79793351], + [36936.3226551 , 12880.21908097, 9798.11933634], + [36938.80165114, 12894.83037635, 9798.43604253], + [36963.56833917, 13024.20171897, 9786.35862744], + [36710.74505617, 13067.19034273, 11157.09293687]], + + [[36837.10263716, 12882.08941394, 9778.86619003], + [36839.56990128, 12896.88525847, 9779.19574812], + [36842.00651935, 12911.49626631, 9779.52026025], + [36866.50864062, 13040.94025273, 9767.60317069], + [36600.42844424, 13085.47493197, 11131.26698822]], + + [[36740.05773488, 12898.55915608, 9758.9171753 ], + [36742.4824212 , 12913.36207068, 9759.26460038], + [36744.87568074, 12927.97191938, 9759.60652032], + [36769.12380336, 13057.49412079, 9747.97908318], + [36489.8702979 , 13103.47268784, 11104.00976761]], + + [[36642.61580929, 12914.85318247, 9737.89897961], + [36644.99465229, 12929.66358219, 9738.25648241], + [36647.34200846, 12944.27688018, 9738.60818549], + [36671.31029821, 13073.8734652 , 9727.14142723], + [36378.99941602, 13121.18592074, 11074.87549239]], + + [[36546.45773962, 12930.68254363, 9716.15956394], + [36548.79127638, 12945.50019983, 9716.52929891], + [36551.09361665, 12960.11867817, 9716.89297794], + [36574.77976561, 13089.79029782, 9705.59313795], + [36267.95158348, 13138.58386005, 11043.97603876]], + + [[36449.90014203, 12946.31804719, 9693.28065212], + [36452.18717755, 12961.14300771, 9693.67364714], + [36454.44335437, 12975.76669658, 9694.06024379], + [36477.86715055, 13105.51805429, 9683.06601878], + [36156.67739241, 13155.66548631, 11011.3475901 ]], + + [[36352.88744055, 12961.77237456, 9669.12818973], + [36355.12403269, 12976.6051122 , 9669.53632378], + [36357.33084734, 12991.23931983, 9669.93793376], + [36380.47560983, 13121.06450398, 9659.11573497], + [36045.1153218 , 13172.43087372, 10976.4828501 ]], + + [[36257.43563046, 12976.71756953, 9644.10871972], + [36259.62183739, 12991.55790959, 9644.53414908], + [36261.77967899, 13006.20466758, 9644.95299653], + [36284.65024269, 13136.1030051 , 9634.31201638], + [35933.46326626, 13188.84149829, 10939.50109787]], + + [[36161.58642962, 12991.45526328, 9617.61239379], + [36163.72063861, 13006.30327615, 9618.06705944], + [36165.82793911, 13020.96290517, 9618.5150277 ], + [36188.44742628, 13150.93890164, 9608.20374441], + [35821.65670581, 13204.8986573 , 10900.40807392]], + + [[36065.29735674, 13005.99681711, 9589.4834059 ], + [36067.37535905, 13020.85294969, 9589.95778037], + [36069.42851904, 13035.53053331, 9590.42559286], + [36091.78175726, 13165.57746809, 9580.30255573], + [35709.65987196, 13220.59874388, 10858.62419698]], + + [[35970.92789798, 13019.97973211, 9560.31749516], + [35972.94949142, 13034.8438179 , 9560.81355024], + [35974.94855037, 13049.54132457, 9561.3032738 ], + [35997.03719175, 13179.65937613, 9551.37917219], + [35597.73557876, 13235.89730693, 10814.2816159 ]], + + [[35876.20101725, 13033.73804785, 9529.31622523], + [35878.16392888, 13048.61020127, 9529.84710396], + [35880.10664434, 13063.32836497, 9530.37187164], + [35901.94830144, 13193.52364531, 9520.8049031 ], + [35485.79614644, 13250.79963043, 10767.34532301]], + + [[35781.08961323, 13047.28286945, 9496.30718201], + [35782.98964727, 13062.16345754, 9496.86131278], + [35784.87225617, 13076.90687891, 9497.40982582], + [35806.45577281, 13207.17147021, 9488.04869289], + [35373.86088524, 13265.29608764, 10717.15648045]], + + [[35688.31435537, 13060.22156803, 9462.14576412], + [35690.15110004, 13075.11041496, 9462.72491434], + [35691.97332753, 13089.88099871, 9463.29906482], + [35713.2967071 , 13220.21579418, 9454.15595167], + [35262.2997606 , 13279.33386569, 10663.87791156]], + + [[35595.27412369, 13072.91574546, 9425.78527559], + [35597.04422799, 13087.81304585, 9426.40414017], + [35598.80273554, 13102.61219963, 9427.01868399], + [35619.8736749 , 13233.02520717, 9418.26259291], + [35150.99805177, 13292.92398755, 10607.40591365]], + + [[35501.98801423, 13085.37356887, 9387.0498197 ], + [35503.68749001, 13100.27957305, 9387.6940533 ], + [35505.37851944, 13115.11123875, 9388.33491913], + [35526.19109415, 13245.59280339, 9379.80151825], + [35040.08520424, 13306.04743202, 10547.00501979]], + + [[35411.46242543, 13097.1893103 , 9347.10907135], + [35413.09039031, 13112.10385862, 9347.78006759], + [35414.71307855, 13126.96997032, 9348.4488025 ], + [35435.25806672, 13257.52200085, 9340.14968049], + [34930.0544807 , 13318.64630702, 10482.91174314]], + + [[35320.8453905 , 13108.73639509, 9304.6280377 ], + [35322.39694705, 13123.65979528, 9305.34228428], + [35323.94637086, 13138.56270127, 9306.05553168], + [35344.21767825, 13269.19552584, 9298.17065258], + [34820.76316591, 13330.73963156, 10414.96143462]], + + [[35230.22754196, 13120.01922275, 9259.4509654 ], + [35231.6996658 , 13134.95151006, 9260.1907322 ], + [35233.17280724, 13149.89420797, 9260.93099972], + [35253.17559085, 13280.59569645, 9253.28116683], + [34712.51016884, 13342.29814496, 10342.39815348]], + + [[35142.72331888, 13130.64689411, 9213.07478775], + [35144.11438044, 13145.58794952, 9213.84066785], + [35145.50946972, 13160.57221271, 9214.60870818], + [35165.22154853, 13291.34590195, 9207.20094825], + [34605.92904309, 13353.26369697, 10265.56874637]], + + [[35055.38510204, 13140.98148126, 9163.90317876], + [35056.68810596, 13155.9317592 , 9164.71269212], + [35057.99797407, 13170.96027044, 9165.52624893], + [35077.39084 , 13301.81949505, 9158.54694501], + [34500.86230818, 13363.66502458, 10184.2397518 ]], + + [[34968.41106426, 13151.02306294, 9111.86198928], + [34969.62615086, 13165.9821425 , 9112.69238334], + [34970.85061935, 13181.05506091, 9113.52876605], + [34989.9528592 , 13311.98347937, 9106.78308658], + [34397.85674843, 13373.46305188, 10097.77824734]], + + [[34884.65142759, 13160.43975653, 9058.60907041], + [34885.77693898, 13175.40747317, 9059.45898048], + [34886.91396588, 13190.52486242, 9060.31687474], + [34905.69434228, 13321.52684186, 9053.80707379], + [34297.62999748, 13382.61208945, 10006.72649398]], + + [[34801.47550477, 13169.53299599, 9002.41459147], + [34802.5013387 , 13184.51003945, 9003.30050656], + [34803.54043087, 13199.67371543, 9004.19658599], + [34821.94147778, 13330.7659073 , 8998.10952477], + [34199.99617779, 13391.15478713, 9910.85791587]], + + [[34719.26127273, 13178.29455202, 8943.31514848], + [34720.19446391, 13193.2797183 , 8944.20919711], + [34721.1421013 , 13208.48538015, 8945.11531128], + [34739.24238777, 13339.64448889, 8939.25190779], + [34105.77615055, 13399.04849472, 9809.87493108]], + + [[34640.07277303, 13186.50111307, 8882.70146908], + [34640.91304217, 13201.49407168, 8883.60071596], + [34641.76844094, 13216.73946398, 8884.51378463], + [34659.53475366, 13347.96984672, 8878.87506506], + [34015.58339001, 13406.2785771 , 9704.51089159]], + + [[34562.14856056, 13194.34095199, 8819.12262506], + [34562.88194701, 13209.3426988 , 8820.04078586], + [34563.63065793, 13224.62858264, 8820.97459746], + [34580.96603614, 13355.95105008, 8815.73622114], + [33929.21544397, 13412.89952187, 9594.63931536]], + + [[34486.08468251, 13201.79875406, 8752.84020145], + [34486.72829171, 13216.80716161, 8753.7466084 ], + [34487.38690864, 13232.12475751, 8754.66983388], + [34504.4366784 , 13363.50826555, 8749.64442442], + [33847.64017589, 13418.87831073, 9480.5881585 ]], + + [[34412.51232938, 13208.80883511, 8684.23432055], + [34413.06960446, 13223.82324991, 8685.12647546], + [34413.64117199, 13239.16831104, 8686.03629808], + [34430.38556838, 13370.6152289 , 8681.22984013], + [33770.99954031, 13424.24977448, 9363.23722945]], + + [[34341.22542663, 13215.39464852, 8612.93605707], + [34341.67929709, 13230.4163292 , 8613.82433736], + [34342.14633377, 13245.78901903, 8614.73111542], + [34358.45174106, 13377.32376165, 8610.29935083], + [33698.89335115, 13429.08408115, 9242.82853332]], + + [[34272.9893347 , 13221.54141036, 8539.54907344], + [34273.36525902, 13236.56771689, 8540.40434494], + [34273.75300117, 13251.95639394, 8541.27806752], + [34289.8160288 , 13383.54411014, 8537.06929184], + [33632.00208982, 13433.37692368, 9120.49209522]], + + [[34206.46553099, 13227.36875593, 8462.33729984], + [34206.77274169, 13242.39862879, 8463.15847941], + [34207.09028124, 13257.79702034, 8463.99765324], + [34222.91904293, 13389.43555672, 8460.02070946], + [33569.95040622, 13437.19274622, 8996.75374595]], + + [[34143.53420733, 13232.71315359, 8383.18527636], + [34143.75570634, 13247.74789271, 8383.97874743], + [34143.98583385, 13263.15562377, 8384.78971299], + [34159.42749003, 13394.86972478, 8381.159608 ], + [33512.36965956, 13440.58926996, 8872.20982075]], + + [[34084.94638043, 13237.57163476, 8303.13609706], + [34085.1110384 , 13252.60870078, 8303.88117309], + [34085.2827795 , 13268.01441686, 8304.64253927], + [34100.53706095, 13399.77239092, 8301.25742579], + [33459.74527175, 13443.57105274, 8748.40651522]], + + [[34027.16471845, 13242.24189075, 8217.32150552], + [34027.28795931, 13257.279886 , 8218.02149998], + [34027.41692901, 13272.67663804, 8218.73635111], + [34042.50696314, 13404.47204345, 8215.57995036], + [33411.38638309, 13446.20743696, 8625.07631359]], + + [[33973.95379354, 13246.4184774 , 8131.50287496], + [33974.02246635, 13261.45849174, 8132.16173416], + [33974.09554243, 13276.8465576 , 8132.83413763], + [33988.87167217, 13408.69991663, 8129.93670673], + [33367.22663143, 13448.52763978, 8503.59103187]], + + [[33924.91119032, 13250.18341487, 8045.90795424], + [33924.94871312, 13265.22367927, 8046.5165705 ], + [33924.98962924, 13280.59624706, 8047.137198 ], + [33939.60113966, 13412.48529574, 8044.42856266], + [33326.94403977, 13450.57110589, 8383.90888289]], + + [[33876.76873392, 13253.7929376 , 7955.04689793], + [33876.78769068, 13268.83248196, 7955.61203049], + [33876.80925614, 13284.18561975, 7956.18766436], + [33891.28724363, 13416.10421437, 7953.66288486], + [33289.75277048, 13452.39559381, 8264.90117739]], + + [[33832.37024327, 13257.03321904, 7864.4787358 ], + [33832.35969995, 13272.07296638, 7865.002419 ], + [33832.35103177, 13287.40828097, 7865.53529533], + [33846.5791445 , 13419.37156091, 7863.19113332], + [33255.55856366, 13454.02017122, 8147.46082399]], + + [[33791.2850122 , 13259.97023316, 7774.13626164], + [33791.2608517 , 13275.00914386, 7774.61588699], + [33791.23806127, 13290.32361635, 7775.10341611], + [33805.32365746, 13422.3169326 , 7772.90342878], + [33224.09855913, 13455.46991943, 8031.28176792]], + + [[33751.44653 , 13262.75702338, 7679.77101597], + [33751.41725309, 13277.79455204, 7680.21362914], + [33751.38898644, 13293.0865814 , 7680.66296694], + [33765.3613131 , 13425.10520591, 7678.61014185], + [33195.01261911, 13456.77181455, 7915.9898895 ]], + + [[33714.66021798, 13265.26845907, 7585.96947648], + [33714.61742275, 13280.30531594, 7586.3755547 ], + [33714.57530573, 13295.57689029, 7586.78736173], + [33728.34968861, 13427.63129282, 7584.8582333 ], + [33168.16929101, 13457.94044332, 7802.11272316]], + + [[33680.5637049 , 13267.55244008, 7492.59276204], + [33680.51764023, 13282.58805181, 7492.96314104], + [33680.47204729, 13297.83842448, 7493.33834237], + [33694.12507891, 13429.91894113, 7491.51760655], + [33143.37720643, 13458.99164315, 7689.36868125]], + + [[33647.74745779, 13269.70778695, 7396.15575519], + [33647.70328134, 13284.74188762, 7396.4970122 ], + [33647.65944216, 13299.97101098, 7396.84229924], + [33661.21493835, 13432.07431184, 7395.1386027 ], + [33120.37609116, 13459.94267779, 7577.3386245 ]], + + [[33617.29469969, 13271.66479026, 7300.20966743], + [33617.24601297, 13286.69790892, 7300.52135597], + [33617.19753546, 13301.9078029 , 7300.83641598], + [33630.59530635, 13434.04109778, 7299.21847414], + [33099.05717835, 13460.80328233, 7466.31089849]], + + [[33588.93169076, 13273.45624416, 7204.60915084], + [33588.88480305, 13288.48808275, 7204.89319499], + [33588.83805479, 13303.67905594, 7205.18003538], + [33602.13044659, 13435.83620352, 7203.64198026], + [33079.30221859, 13461.58278229, 7356.06928114]], + + [[33561.77058519, 13275.14163559, 7106.79515068], + [33561.72845249, 13290.17208279, 7107.05678783], + [33561.68641964, 13305.34468937, 7107.32072944], + [33574.89045376, 13437.52360233, 7105.8707412 ], + [33060.95881598, 13462.29106896, 7246.44388061]], + + [[33536.43381793, 13276.68366448, 7009.43960302], + [33536.39179878, 13291.71309622, 7009.67793407], + [33536.34983765, 13306.86904403, 7009.91816214], + [33549.42416339, 13439.07465036, 7008.52565887], + [33043.92672397, 13462.93531958, 7137.59076966]], + + [[33512.73985943, 13278.10332035, 6912.41407645], + [33512.70184087, 13293.1315859 , 6912.63136007], + [33512.66386213, 13308.27162962, 6912.85019155], + [33525.64613901, 13440.49990969, 6911.5199019 ], + [33028.12509239, 13463.52146374, 7029.36179831]], + + [[33490.13422231, 13279.4362692 , 6813.73866445], + [33490.10169875, 13294.46334364, 6813.93902367], + [33490.06920666, 13309.58837255, 6814.14063582], + [33502.97235292, 13441.83803343, 6812.885842 ], + [33013.44950587, 13464.05585035, 6921.60515881]], + + [[33469.01102389, 13280.66049065, 6715.40816395], + [33468.98048266, 13295.6866514 , 6715.59043848], + [33468.94996037, 13310.79816942, 6715.77372268], + [33481.74569528, 13443.0725979 , 6714.56895509], + [32999.82734091, 13464.54325698, 6814.40128035]], + + [[33449.25333488, 13281.78962387, 6617.32365176], + [33449.22716127, 13296.81480095, 6617.48993524], + [33449.20100307, 13311.91371182, 6617.65702406], + [33461.91636331, 13444.21017362, 6616.51000002], + [32987.20719814, 13464.98739236, 6707.65224055]], + + [[33430.50854247, 13282.84570436, 6518.0168782 ], + [33430.48741023, 13297.86991341, 6518.1703313 ], + [33430.46629251, 13312.95715974, 6518.32442015], + [33443.11021969, 13445.27482496, 6517.24657388], + [32975.51224295, 13465.39258816, 6601.28179394]], + + [[33413.00783789, 13283.81676204, 6418.98769011], + [33412.98907552, 13298.84020936, 6419.12717892], + [33412.97032368, 13313.91706714, 6419.26716541], + [33425.52301296, 13446.25822955, 6418.23616002], + [32964.6777931 , 13465.7624871 , 6495.33245224]], + + [[33396.66579675, 13284.71238431, 6320.16334719], + [33396.65089257, 13299.73504107, 6320.29070455], + [33396.63599815, 13314.80242623, 6320.41844644], + [33409.11769992, 13447.16502273, 6319.44220709], + [32954.66538849, 13466.09962636, 6389.74050122]], + + [[33381.24852054, 13285.54676064, 6220.41655376], + [33381.23763761, 13300.56866191, 6220.53415358], + [33381.22676401, 13315.62749238, 6220.65204624], + [33393.64360555, 13448.01091893, 6219.7392714 ], + [32945.41978582, 13466.40693625, 6284.44480896]], + + [[33366.88796207, 13286.31361891, 6120.88800473], + [33366.87900277, 13301.33492211, 6120.99468375], + [33366.87005072, 13316.38625096, 6121.1015824 ], + [33379.20755941, 13448.79207443, 6120.23184054], + [32936.8937178 , 13466.68691463, 6179.46483854]], + + [[33353.51824552, 13287.02006714, 6021.52558688], + [33353.5122817 , 13302.04075722, 6021.6228739 ], + [33353.50632433, 13317.08535645, 6021.720322 ], + [33365.78068891, 13449.51185668, 6020.90064493], + [32929.06283634, 13466.94117455, 6074.76008435]], + + [[33340.98019556, 13287.67543608, 5921.46521909], + [33340.97720245, 13302.69554984, 5921.55497975], + [33340.97421493, 13317.73416191, 5921.64485516], + [33353.18975779, 13450.1808493 , 5920.88206282], + [32921.88120216, 13467.17191905, 5970.29484442]], + + [[33329.34163138, 13288.27678656, 5821.58699707], + [33329.33994929, 13303.29644537, 5821.66816303], + [33329.33827127, 13318.32985394, 5821.74940752], + [33341.4836993 , 13450.79785248, 5821.02529727], + [32915.30655879, 13467.38111685, 5866.07678978]], + + [[33318.54528818, 13288.82981241, 5721.84819069], + [33318.54582042, 13303.84900272, 5721.92200219], + [33318.54635575, 13318.87776991, 5721.99586386], + [33330.63573641, 13451.36549429, 5721.31959872], + [32909.31991264, 13467.56990416, 5762.08086297]], + + [[33308.52671975, 13289.33835274, 5621.54805033], + [33308.52937622, 13304.35710928, 5621.6159445 ], + [33308.53203472, 13319.38180919, 5621.68386696], + [33320.57029167, 13451.88850266, 5621.07141773], + [32903.88206728, 13467.73997881, 5658.28061166]], + + [[33299.42454388, 13289.79576844, 5521.36106099], + [33299.42787878, 13304.81419689, 5521.42206212], + [33299.43121462, 13319.83551757, 5521.48307559], + [33311.41203778, 13452.36123798, 5520.93263365], + [32898.95649364, 13467.8928758 , 5554.6755699 ]], + + [[33291.28759058, 13290.20191583, 5421.26319584], + [33291.29227765, 13305.22001629, 5421.31835062], + [33291.29696488, 13320.23857261, 5421.37350715], + [33303.23625986, 13452.78060401, 5420.90129044], + [32894.5301681 , 13468.02933789, 5451.2540883 ]], + + [[33284.10726625, 13290.55766238, 5320.71221503], + [33284.11301624, 13305.57548196, 5320.76272796], + [33284.1187658 , 13320.59193349, 5320.81323646], + [33296.02308264, 13453.14820288, 5320.43039017], + [32890.56922299, 13468.15069994, 5347.99638151]], + + [[33277.92895004, 13290.86108527, 5220.28037794], + [33277.93438447, 13305.87872474, 5220.32536706], + [33277.93981816, 13320.89375856, 5220.370349 ], + [33289.80513076, 13453.46306365, 5220.06548549], + [32887.04223965, 13468.25816752, 5244.90024298]], + + [[33272.71433415, 13291.11606209, 5119.9661204 ], + [33272.72006432, 13306.13352282, 5120.00640525], + [33272.7257936 , 13321.14767466, 5120.0466825 ], + [33284.56749686, 13453.72663493, 5119.82110959], + [32883.94108985, 13468.35219446, 5141.95897753]], + + [[33268.37536687, 13291.32704889, 5019.3321915 ], + [33268.38116785, 13306.34436989, 5019.36873511], + [33268.3869679 , 13321.35811646, 5019.40527197], + [33280.21086195, 13453.94443387, 5019.25506488], + [32881.23324579, 13468.43394024, 5039.16015801]], + + [[33264.83906743, 13291.49758459, 4918.85096444], + [33264.84381554, 13306.51484568, 4918.8829438 ], + [33264.84856292, 13321.52858838, 4918.91491778], + [33276.65008105, 13454.12127424, 4918.81783905], + [32878.88669413, 13468.50451073, 4936.49838379]], + + [[33261.98848065, 13291.63490292, 4818.5175009 ], + [33261.99296514, 13306.65208759, 4818.54556076], + [33261.997449 , 13321.66602198, 4818.57361663], + [33273.78945569, 13454.26249845, 4818.52062708], + [32876.89331594, 13468.56426399, 4833.97281322]], + + [[33259.69249322, 13291.74514587, 4717.93840269], + [33259.69673489, 13306.76227044, 4717.96340715], + [33259.70097604, 13321.77651042, 4717.98840876], + [33271.48676042, 13454.37557466, 4717.97028797], + [32875.22139375, 13468.6142425 , 4731.57813929]], + + [[33257.84638678, 13291.83301531, 4617.50476922], + [33257.84955145, 13306.85013394, 4617.52601379], + [33257.85271575, 13321.8647403 , 4617.54725635], + [33269.62544364, 13454.46662789, 4617.54427233], + [32873.83948825, 13468.65545593, 4629.30505592]], + + [[33256.36830184, 13291.90364098, 4517.20646058], + [33256.37126358, 13306.92072327, 4517.22444838], + [33256.37422504, 13321.9356423 , 4517.24243482], + [33268.14333155, 13454.53904606, 4517.25095334], + [32872.7399501 , 13468.68818612, 4527.16073109]], + + [[33255.19373915, 13291.95973741, 4416.67047226], + [33255.19662525, 13306.97678666, 4416.68600975], + [33255.1995111 , 13321.99198705, 4416.70154632], + [33266.96650167, 13454.59658614, 4416.72085908], + [32871.89122164, 13468.71341271, 4425.14183124]], + + [[33254.27709681, 13292.00297317, 4316.26273903], + [33254.27916722, 13307.02003285, 4316.27525537], + [33254.28123747, 13322.03551556, 4316.2877711 ], + [33266.03865584, 13454.6420803 , 4316.30652351], + [32871.25941819, 13468.73217027, 4323.23998941]], + + [[33253.57445329, 13292.03643109, 4215.9819273 ], + [33253.57645185, 13307.05347232, 4215.99179553], + [33253.57845028, 13322.06916692, 4216.00166336], + [33265.33345503, 13454.67692012, 4216.0192071 ], + [32870.83277808, 13468.74482618, 4221.46493048]], + + [[33253.04651919, 13292.06168946, 4115.46159106], + [33253.04864188, 13307.07870829, 4115.46963655], + [33253.05076446, 13322.09456883, 4115.4776818 ], + [33264.80501425, 13454.70331501, 4115.49682112], + [32870.57490363, 13468.75247141, 4119.82266537]], + + [[33252.66335222, 13292.07960957, 4015.06085371], + [33252.66497149, 13307.0966405 , 4015.06668211], + [33252.66659067, 13322.1126584 , 4015.07251035], + [33264.41371482, 13454.72308681, 4015.08598026], + [32870.43435938, 13468.75663648, 4018.29426231]], + + [[33252.39652769, 13292.0922303 , 3914.81921086], + [33252.39806314, 13307.10925693, 3914.82300553], + [33252.39959851, 13322.12537529, 3914.8268001 ], + [33264.14391367, 13454.73691957, 3914.83417905], + [32870.37617129, 13468.75836021, 3916.92582307]], + + [[33252.22037343, 13292.1007336 , 3814.24977919], + [33252.22207099, 13307.117747 , 3814.25253667], + [33252.22376848, 13322.1339212 , 3814.25529409], + [33263.96808976, 13454.74605289, 3814.26309615], + [32870.35864545, 13468.75887899, 3815.74512207]], + + [[33252.11423233, 13292.10566802, 3713.86396785], + [33252.11574813, 13307.12268678, 3713.86554885], + [33252.11726388, 13322.13890558, 3713.86712981], + [33263.85859224, 13454.75178775, 3713.8711875 ], + [32870.35776245, 13468.75890474, 3714.74518389]], + + [[33252.05850458, 13292.10821865, 3613.66483164], + [33252.05992333, 13307.1252403 , 3613.66532194], + [33252.06134203, 13322.14147954, 3613.66581223], + [33263.80093381, 13454.75485003, 3613.66500912], + [32870.36549492, 13468.75867533, 3613.93209526]], + + [[33252.03582176, 13292.1093069 , 3513.11007742], + [33252.03727505, 13307.1263262 , 3513.11039894], + [33252.03872827, 13322.14256754, 3513.11072045], + [33263.77826979, 13454.75607827, 3513.11196581], + [32870.37119421, 13468.75850631, 3513.28950413]], + + [[33252.03102709, 13292.10952281, 3412.75202122], + [33252.03246013, 13307.12654292, 3412.75217619], + [33252.03389312, 13322.14278487, 3412.75233116], + [33263.77312074, 13454.75636024, 3412.75352659], + [32870.37110316, 13468.75850898, 3412.84218755]], + + [[33252.03096628, 13292.10950729, 3312.46525513], + [33252.03238317, 13307.12652817, 3312.46525691], + [33252.03380001, 13322.1427707 , 3312.46525869], + [33263.7728819 , 13454.75636576, 3312.46529057], + [32870.36869502, 13468.75858036, 3312.46525752]], + + [[33252.03096628, 13292.10950191, 3212.0924425 ], + [33252.03238062, 13307.12652291, 3212.0924434 ], + [33252.0337949 , 13322.14276556, 3212.09244431], + [33263.77287508, 13454.7563573 , 3212.09246976], + [32870.36685275, 13468.75863496, 3212.09235382]], + + [[33252.03096628, 13292.10950244, 3111.7187719 ], + [33252.03238147, 13307.12652341, 3111.71877242], + [33252.0337966 , 13322.14276601, 3111.71877293], + [33263.77289129, 13454.75635432, 3111.71877847], + [32870.36655414, 13468.75864381, 3111.71896034]], + + [[33252.03096628, 13292.10950421, 3011.32153981], + [33252.03238317, 13307.1265251 , 3011.32153958], + [33252.0338 , 13322.14276762, 3011.32153935], + [33263.77290592, 13454.75635574, 3011.32152992], + [32870.36708776, 13468.758628 , 3011.32153887]], + + [[33252.03096628, 13292.10950515, 2910.9350783 ], + [33252.03238391, 13307.126526 , 2910.93507828], + [33252.03380149, 13322.14276849, 2910.93507826], + [33263.77290776, 13454.75635832, 2910.93507223], + [32870.36767049, 13468.75861072, 2910.93516713]], + + [[33252.03096628, 13292.10950499, 2810.55788676], + [33252.03238366, 13307.12652585, 2810.55788686], + [33252.03380098, 13322.14276834, 2810.55788696], + [33263.7729018 , 13454.75635961, 2810.55788683], + [32870.367837 , 13468.75860579, 2810.55795336]], + + [[33252.03096628, 13292.10950448, 2710.18951251], + [33252.03238317, 13307.12652536, 2710.18951258], + [33252.0338 , 13322.14276788, 2710.18951264], + [33263.77289695, 13454.75635946, 2710.189515 ], + [32870.36772939, 13468.75860898, 2710.18951275]], + + [[33252.03096628, 13292.10950422, 2609.81698701], + [33252.03238297, 13307.12652511, 2609.81698648], + [33252.0337996 , 13322.14276764, 2609.81698594], + [33263.77289661, 13454.75635872, 2609.81698253], + [32870.3675584 , 13468.75861405, 2609.81662951]], + + [[33252.03096628, 13292.10950426, 2509.43951971], + [33252.03238304, 13307.12652515, 2509.43951936], + [33252.03379974, 13322.14276768, 2509.43951901], + [33263.77289848, 13454.75635824, 2509.43951617], + [32870.36748708, 13468.75861616, 2509.43929742]], + + [[33252.03096628, 13292.1095044 , 2409.05738302], + [33252.03238317, 13307.12652528, 2409.05738301], + [33252.0338 , 13322.14276781, 2409.05738299], + [33263.77290014, 13454.75635819, 2409.05738246], + [32870.36750295, 13468.75861569, 2409.05738297]], + + [[33252.03096628, 13292.10950446, 2308.67781535], + [33252.03238322, 13307.12652535, 2308.67781547], + [33252.0338001 , 13322.14276786, 2308.67781559], + [33263.77290003, 13454.7563584 , 2308.67781625], + [32870.36754945, 13468.75861431, 2308.67788802]], + + [[33252.03096628, 13292.10950446, 2208.29985166], + [33252.0323832 , 13307.12652534, 2208.29985216], + [33252.03380007, 13322.14276786, 2208.29985266], + [33263.77289947, 13454.75635856, 2208.29985741], + [32870.36757646, 13468.75861351, 2208.30018556]], + + [[33252.03096628, 13292.10950442, 2107.92531005], + [33252.03238317, 13307.12652531, 2107.92531005], + [33252.0338 , 13322.14276784, 2107.92531006], + [33263.77289919, 13454.75635859, 2107.92531015], + [32870.36757661, 13468.75861351, 2107.92531006]], + + [[33252.03096628, 13292.10950441, 2007.54920414], + [33252.03238316, 13307.12652529, 2007.54920419], + [33252.03379998, 13322.14276781, 2007.54920424], + [33263.77289904, 13454.75635855, 2007.54920493], + [32870.3675649 , 13468.75861385, 2007.54924507]], + + [[33252.03096628, 13292.10950441, 1907.17195257], + [33252.03238316, 13307.12652529, 1907.17195258], + [33252.03379999, 13322.14276782, 1907.17195259], + [33263.77289934, 13454.75635849, 1907.17195281], + [32870.36755559, 13468.75861413, 1907.17196447]], + + [[33252.03096628, 13292.10950442, 1806.79287661], + [33252.03238317, 13307.1265253 , 1806.79287661], + [33252.0338 , 13322.14276782, 1806.79287661], + [33263.77289931, 13454.75635848, 1806.7928766 ], + [32870.36755412, 13468.75861417, 1806.79287661]], + + [[33252.03096628, 13292.10950442, 1706.41552307], + [33252.03238317, 13307.1265253 , 1706.41552285], + [33252.03380001, 13322.14276782, 1706.41552263], + [33263.77289931, 13454.75635849, 1706.41552042], + [32870.36755681, 13468.75861409, 1706.41537291]], + + [[33252.03096628, 13292.10950442, 1606.03771544], + [33252.03238317, 13307.1265253 , 1606.03771567], + [33252.03380001, 13322.14276783, 1606.03771589], + [33263.77289943, 13454.7563585 , 1606.03771807], + [32870.36755976, 13468.75861401, 1606.03786563]], + + [[33252.03096628, 13292.10950442, 1505.6607886 ], + [33252.03238317, 13307.1265253 , 1505.6607886 ], + [33252.0338 , 13322.14276782, 1505.6607886 ], + [33263.77289923, 13454.75635851, 1505.6607886 ], + [32870.36756061, 13468.75861398, 1505.6607886 ]], + + [[33252.03096628, 13292.10950442, 1405.28416972], + [33252.03238317, 13307.1265253 , 1405.28416941], + [33252.0338 , 13322.14276782, 1405.28416909], + [33263.77289924, 13454.75635851, 1405.28416593], + [32870.36756008, 13468.758614 , 1405.28395343]], + + [[33252.03096628, 13292.10950442, 1304.90670129], + [33252.03238317, 13307.1265253 , 1304.90670099], + [33252.0338 , 13322.14276782, 1304.90670069], + [33263.77289925, 13454.75635851, 1304.90669771], + [32870.36755921, 13468.75861402, 1304.90649676]], + + [[33252.03096628, 13292.10950442, 1204.52853411], + [33252.03238317, 13307.1265253 , 1204.52853411], + [33252.0338 , 13322.14276782, 1204.52853411], + [33263.77289925, 13454.7563585 , 1204.52853411], + [32870.36755884, 13468.75861403, 1204.52853411]], + + [[33252.03096628, 13292.10950442, 1104.15157359], + [33252.03238317, 13307.1265253 , 1104.15157375], + [33252.0338 , 13322.14276783, 1104.15157391], + [33263.77289942, 13454.7563585 , 1104.1515755 ], + [32870.36755892, 13468.75861403, 1104.15168256]], + + [[33252.03096628, 13292.10950442, 1003.77350086], + [33252.03238317, 13307.1265253 , 1003.77350126], + [33252.0338 , 13322.14276782, 1003.77350165], + [33263.77289925, 13454.75635851, 1003.77350559], + [32870.36755915, 13468.75861402, 1003.7737701 ]], + + [[33252.03096628, 13292.10950442, 903.39664581], + [33252.03238317, 13307.1265253 , 903.39664581], + [33252.0338 , 13322.14276782, 903.39664581], + [33263.77289925, 13454.75635851, 903.3966458 ], + [32870.36755929, 13468.75861402, 903.39664581]], + + [[33252.03096628, 13292.10950442, 803.01904187], + [33252.03238317, 13307.1265253 , 803.01904211], + [33252.0338 , 13322.14276782, 803.01904234], + [33263.77289925, 13454.75635851, 803.01904472], + [32870.3675593 , 13468.75861402, 803.01920446]], + + [[33252.03096628, 13292.10950442, 702.64223066], + [33252.03238317, 13307.1265253 , 702.6422305 ], + [33252.0338 , 13322.14276783, 702.64223034], + [33263.77289942, 13454.7563585 , 702.64222875], + [32870.36755924, 13468.75861402, 702.64212162]], + + [[33252.03096628, 13292.10950442, 602.26418692], + [33252.03238317, 13307.1265253 , 602.26418692], + [33252.0338 , 13322.14276782, 602.26418692], + [33263.77289925, 13454.75635851, 602.26418693], + [32870.36755919, 13468.75861402, 602.26418692]], + + [[33252.03096628, 13292.10950442, 501.8871908 ], + [33252.03238317, 13307.1265253 , 501.88719048], + [33252.0338 , 13322.14276782, 501.88719016], + [33263.77289925, 13454.75635851, 501.88718697], + [32870.36755918, 13468.75861402, 501.88697263]], + + [[33252.03096628, 13292.10950442, 401.50913703], + [33252.03238317, 13307.1265253 , 401.50913777], + [33252.0338 , 13322.14276783, 401.50913852], + [33263.77289942, 13454.7563585 , 401.509146 ], + [32870.36755919, 13468.75861402, 401.50964873]], + + [[33252.03096628, 13292.10950442, 301.13224176], + [33252.03238317, 13307.1265253 , 301.13224176], + [33252.0338 , 13322.14276782, 301.13224176], + [33263.77289925, 13454.75635851, 301.13224176], + [32870.36755921, 13468.75861402, 301.13224176]], + + [[33252.03096628, 13292.10950442, 167.29583259], + [33252.03238317, 13307.1265253 , 167.29583259], + [33252.0338 , 13322.14276782, 167.29583259], + [33263.77289925, 13454.75635851, 167.29583259], + [32870.36755921, 13468.75861402, 167.29583259]], + + [[33252.03096628, 13292.10950442, 66.91872373], + [33252.03238317, 13307.1265253 , 66.91872373], + [33252.0338 , 13322.14276782, 66.91872373], + [33263.77289925, 13454.75635851, 66.91872372], + [32870.36755921, 13468.75861402, 66.91872373]], + + [[33252.03096628, 13292.10950442, -0. ], + [33252.03238317, 13307.1265253 , 0. ], + [33252.0338 , 13322.14276784, 0. ], + [33263.77289942, 13454.7563585 , -0.00000001], + [32870.36755921, 13468.75861402, 0. ]]]), + 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., 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., 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., 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., 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.], + [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., 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., 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., 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., 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.], + [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., 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., 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., 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., 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.], + [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., 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., 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., 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., 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.], + [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., 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., 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., 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., 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.], + [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., 1., 1., 1.], + [1., 1., 1., 1., 1.]]) +) +import numpy as np +from mmcore.nurbs._nurbs_eval import NURBSSurfaceTuple + + +val = NURBSSurfaceTuple( + order_u=4, + order_v=3, + knot_u=np.array([0. , 0. , 0. , 0. , 0.00338983, + 0.00677966, 0.01016949, 0.01355932, 0.01694915, 0.02033898, + 0.02372881, 0.02711864, 0.03050847, 0.03389831, 0.03728814, + 0.04067797, 0.0440678 , 0.04745763, 0.05084746, 0.05423729, + 0.05762712, 0.06101695, 0.06440678, 0.06779661, 0.07118644, + 0.07457627, 0.0779661 , 0.08135593, 0.08474576, 0.08813559, + 0.09152542, 0.09491525, 0.09830508, 0.10169492, 0.10508475, + 0.10847458, 0.11186441, 0.11525424, 0.11864407, 0.1220339 , + 0.12542373, 0.12881356, 0.13220339, 0.13559322, 0.13898305, + 0.14237288, 0.14576271, 0.14915254, 0.15254237, 0.1559322 , + 0.15932203, 0.16271186, 0.16610169, 0.16949153, 0.17288136, + 0.17627119, 0.17966102, 0.18305085, 0.18644068, 0.18983051, + 0.19322034, 0.19661017, 0.2 , 0.20338983, 0.20677966, + 0.21016949, 0.21355932, 0.21694915, 0.22033898, 0.22372881, + 0.22711864, 0.23050847, 0.23389831, 0.23728814, 0.24067797, + 0.2440678 , 0.24745763, 0.25084746, 0.25423729, 0.25762712, + 0.26101695, 0.26440678, 0.26779661, 0.27118644, 0.27457627, + 0.2779661 , 0.28135593, 0.28474576, 0.28813559, 0.29152542, + 0.29491525, 0.29830508, 0.30169492, 0.30508475, 0.30847458, + 0.31186441, 0.31525424, 0.31864407, 0.3220339 , 0.32542373, + 0.32881356, 0.33220339, 0.33559322, 0.33898305, 0.34237288, + 0.34576271, 0.34915254, 0.35254237, 0.3559322 , 0.35932203, + 0.36271186, 0.36610169, 0.36949153, 0.37288136, 0.37627119, + 0.37966102, 0.38305085, 0.38644068, 0.38983051, 0.39322034, + 0.39661017, 0.4 , 0.40338983, 0.40677966, 0.41016949, + 0.41355932, 0.41694915, 0.42033898, 0.42372881, 0.42711864, + 0.43050847, 0.43389831, 0.43728814, 0.44067797, 0.4440678 , + 0.44745763, 0.45084746, 0.45423729, 0.45762712, 0.46101695, + 0.46440678, 0.46779661, 0.47118644, 0.47457627, 0.4779661 , + 0.48135593, 0.48474576, 0.48813559, 0.49152542, 0.49491525, + 0.49830508, 0.50169492, 0.50508475, 0.50847458, 0.51186441, + 0.51525424, 0.51864407, 0.5220339 , 0.52542373, 0.52881356, + 0.53220339, 0.53559322, 0.53898305, 0.54237288, 0.54576271, + 0.54915254, 0.55254237, 0.5559322 , 0.55932203, 0.56271186, + 0.56610169, 0.56949153, 0.57288136, 0.57627119, 0.57966102, + 0.58305085, 0.58644068, 0.58983051, 0.59322034, 0.59661017, + 0.6 , 0.60338983, 0.60677966, 0.61016949, 0.61355932, + 0.61694915, 0.62033898, 0.62372881, 0.62711864, 0.63050847, + 0.63389831, 0.63728814, 0.64067797, 0.6440678 , 0.64745763, + 0.65084746, 0.65423729, 0.65762712, 0.66101695, 0.66440678, + 0.66779661, 0.67118644, 0.67457627, 0.6779661 , 0.68135593, + 0.68474576, 0.68813559, 0.69152542, 0.69491525, 0.69830508, + 0.70169492, 0.70508475, 0.70847458, 0.71186441, 0.71525424, + 0.71864407, 0.7220339 , 0.72542373, 0.72881356, 0.73220339, + 0.73559322, 0.73898305, 0.74237288, 0.74576271, 0.74915254, + 0.75254237, 0.7559322 , 0.75932203, 0.76271186, 0.76610169, + 0.76949153, 0.77288136, 0.77627119, 0.77966102, 0.78305085, + 0.78644068, 0.78983051, 0.79322034, 0.79661017, 0.8 , + 0.80338983, 0.80677966, 0.81016949, 0.81355932, 0.81694915, + 0.82033898, 0.82372881, 0.82711864, 0.83050847, 0.83389831, + 0.83728814, 0.84067797, 0.8440678 , 0.84745763, 0.85084746, + 0.85423729, 0.85762712, 0.86101695, 0.86440678, 0.86779661, + 0.87118644, 0.87457627, 0.8779661 , 0.88135593, 0.88474576, + 0.88813559, 0.89152542, 0.89491525, 0.89830508, 0.90169492, + 0.90508475, 0.90847458, 0.91186441, 0.91525424, 0.91864407, + 0.9220339 , 0.92542373, 0.92881356, 0.93220339, 0.93559322, + 0.93898305, 0.94237288, 0.94576271, 0.94915254, 0.95254237, + 0.9559322 , 0.95932203, 0.96271186, 0.96610169, 0.96949153, + 0.97288136, 0.97627119, 0.97966102, 0.98305085, 0.98644068, + 0.98983051, 0.99322034, 0.99661017, 1. , 1. , + 1. , 1. ]), + knot_v=np.array([0. , 0. , 0. , 0.09239269, 0.09239269, + 1. , 1. , 1. ]), + control_points=np.array([[[32290.73847746, 13316.85530471, -0. ], + [32290.73851973, 13331.85533246, -0. ], + [32290.73856201, 13346.85541444, -0. ], + [32278.31713997, 13479.03091972, 0.00002093], + [32667.35727154, 13473.9367302 , 0. ]], + + [[32290.73847746, 13316.85530471, 53.77530268], + [32290.73851973, 13331.85533246, 53.77530268], + [32290.73856201, 13346.85541444, 53.77530268], + [32278.31713997, 13479.03091972, 53.77530269], + [32667.35727124, 13473.93673021, 53.77530269]], + + [[32290.73847746, 13316.85530471, 161.32657247], + [32290.73851973, 13331.85533246, 161.32657247], + [32290.73856201, 13346.85541444, 161.32657247], + [32278.31713996, 13479.03091972, 161.32657248], + [32667.35727161, 13473.9367302 , 161.32657248]], + + [[32290.73847746, 13316.85530471, 322.65361023], + [32290.73851973, 13331.85533246, 322.65361023], + [32290.73856201, 13346.85541444, 322.65361023], + [32278.31713996, 13479.03091971, 322.65361023], + [32667.35727259, 13473.93673018, 322.65361023]], + + [[32290.73847746, 13316.85530471, 457.092047 ], + [32290.73851973, 13331.85533246, 457.09204697], + [32290.73856201, 13346.85541444, 457.09204694], + [32278.31713997, 13479.03091972, 457.09204668], + [32667.35727151, 13473.9367302 , 457.0921137 ]], + + [[32290.73847746, 13316.85530471, 564.64322276], + [32290.73851973, 13331.85533246, 564.6432227 ], + [32290.73856201, 13346.85541444, 564.64322264], + [32278.31714002, 13479.03091974, 564.64322205], + [32667.35726852, 13473.93673027, 564.64337647]], + + [[32290.73847746, 13316.85530471, 645.30694263], + [32290.73851973, 13331.85533246, 645.30694263], + [32290.73856201, 13346.85541444, 645.30694263], + [32278.31714005, 13479.03091975, 645.30694267], + [32667.35726616, 13473.93673032, 645.30694264]], + + [[32290.73847746, 13316.85530471, 739.41442866], + [32290.73851973, 13331.85533246, 739.41442876], + [32290.73856201, 13346.85541444, 739.41442886], + [32278.31714006, 13479.03091975, 739.41442987], + [32667.35726481, 13473.93673035, 739.41417739]], + + [[32290.73847746, 13316.85530471, 846.96506919], + [32290.73851973, 13331.85533246, 846.96506918], + [32290.73856201, 13346.85541444, 846.96506917], + [32278.31714002, 13479.03091974, 846.96506899], + [32667.35726718, 13473.93673029, 846.9650812 ]], + + [[32290.73847746, 13316.85530471, 967.96048256], + [32290.73851973, 13331.85533246, 967.96048256], + [32290.73856201, 13346.85541444, 967.96048256], + [32278.31713986, 13479.03091968, 967.96048238], + [32667.35727751, 13473.93673007, 967.96048254]], + + [[32290.73847746, 13316.85530471, 1082.23331267], + [32290.73851973, 13331.85533246, 1082.23331268], + [32290.73856201, 13346.85541444, 1082.23331269], + [32278.31713968, 13479.03091961, 1082.23331262], + [32667.35729098, 13473.93672978, 1082.2333128 ]], + + [[32290.73847746, 13316.85530471, 1189.785048 ], + [32290.73851973, 13331.85533246, 1189.78504802], + [32290.73856201, 13346.85541444, 1189.78504803], + [32278.31713959, 13479.03091958, 1189.78504819], + [32667.35729753, 13473.93672964, 1189.78504808]], + + [[32290.73847746, 13316.85530471, 1290.61370196], + [32290.73851973, 13331.85533246, 1290.61370196], + [32290.73856201, 13346.85541444, 1290.61370196], + [32278.31713973, 13479.03091963, 1290.61370227], + [32667.35729022, 13473.9367298 , 1290.613702 ]], + + [[32290.73847746, 13316.85530471, 1394.80411595], + [32290.73851973, 13331.85533246, 1394.80411603], + [32290.73856201, 13346.85541444, 1394.80411611], + [32278.31714018, 13479.0309198 , 1394.80411757], + [32667.35726114, 13473.93673043, 1394.8038288 ]], + + [[32290.73847746, 13316.85530471, 1502.35500055], + [32290.73851973, 13331.85533246, 1502.35500059], + [32290.73856201, 13346.85541444, 1502.35500063], + [32278.31714084, 13479.03092004, 1502.35500172], + [32667.35721337, 13473.93673146, 1502.35479618]], + + [[32290.73847746, 13316.85530471, 1613.26746549], + [32290.73851973, 13331.85533246, 1613.26746549], + [32290.73856201, 13346.85541444, 1613.26746549], + [32278.31714131, 13479.03092021, 1613.26746567], + [32667.35717717, 13473.93673224, 1613.26746551]], + + [[32290.73847746, 13316.85530471, 1722.49839976], + [32290.73851974, 13331.85533245, 1722.49839972], + [32290.73856201, 13346.85541444, 1722.49839969], + [32278.31714106, 13479.03092011, 1722.49839852], + [32667.35718944, 13473.93673197, 1722.49851659]], + + [[32290.73847746, 13316.85530471, 1830.05015417], + [32290.73851974, 13331.85533245, 1830.05015411], + [32290.73856201, 13346.85541444, 1830.05015405], + [32278.31713965, 13479.03091959, 1830.05015131], + [32667.35728182, 13473.93672998, 1830.05033492]], + + [[32290.73847746, 13316.85530471, 1935.92063128], + [32290.73851973, 13331.85533246, 1935.92063128], + [32290.73856201, 13346.85541444, 1935.92063128], + [32278.31713723, 13479.03091871, 1935.92062865], + [32667.35745196, 13473.93672631, 1935.92063094]], + + [[32290.73847746, 13316.85530472, 2042.63205661], + [32290.73851973, 13331.85533246, 2042.63205672], + [32290.738562 , 13346.85541445, 2042.63205682], + [32278.31713512, 13479.03091796, 2042.63205654], + [32667.35760339, 13473.93672304, 2042.63205874]], + + [[32290.73847746, 13316.85530472, 2150.18230616], + [32290.73851973, 13331.85533246, 2150.18230639], + [32290.73856199, 13346.85541445, 2150.18230662], + [32278.31713527, 13479.03091802, 2150.18231091], + [32667.35761368, 13473.93672282, 2150.18230732]], + + [[32290.73847746, 13316.85530471, 2258.57439939], + [32290.73851973, 13331.85533246, 2258.57439938], + [32290.73856201, 13346.85541444, 2258.57439938], + [32278.31713955, 13479.03091957, 2258.57440628], + [32667.35735464, 13473.93672841, 2258.57440026]], + + [[32290.73847746, 13316.85530471, 2366.54520643], + [32290.73851974, 13331.85533245, 2366.5452059 ], + [32290.73856202, 13346.85541443, 2366.54520537], + [32278.3171481 , 13479.03092269, 2366.54520999], + [32667.3567574 , 13473.9367413 , 2366.54513346]], + + [[32290.73847746, 13316.85530471, 2474.09676134], + [32290.73851974, 13331.85533245, 2474.0967607 ], + [32290.73856202, 13346.85541443, 2474.09676006], + [32278.31715661, 13479.03092583, 2474.09676034], + [32667.35613067, 13473.93675482, 2474.09666729]], + + [[32290.73847746, 13316.85530471, 2581.22765368], + [32290.73851973, 13331.85533245, 2581.22765368], + [32290.73856201, 13346.85541444, 2581.22765368], + [32278.31715843, 13479.03092653, 2581.22764972], + [32667.35593238, 13473.9367591 , 2581.22765318]], + + [[32290.73847746, 13316.85530469, 2688.56866502], + [32290.73851975, 13331.85533243, 2688.56866589], + [32290.73856205, 13346.85541441, 2688.56866677], + [32278.31714706, 13479.03092224, 2688.56865408], + [32667.356618 , 13473.9367443 , 2688.56851164]], + + [[32290.73847746, 13316.85530468, 2796.11997442], + [32290.73851977, 13331.85533242, 2796.11997556], + [32290.73856208, 13346.8554144 , 2796.1199767 ], + [32278.31711885, 13479.03091176, 2796.11995218], + [32667.35857908, 13473.93670199, 2796.12000526]], + + [[32290.73847746, 13316.85530473, 2903.88116605], + [32290.73851973, 13331.85533247, 2903.88116606], + [32290.73856201, 13346.85541445, 2903.88116608], + [32278.31708451, 13479.03089925, 2903.88113578], + [32667.36105078, 13473.93664865, 2903.88116219]], + + [[32290.73847746, 13316.85530483, 3011.53709208], + [32290.73851963, 13331.85533257, 3011.53709306], + [32290.73856181, 13346.85541456, 3011.53709403], + [32278.31706798, 13479.03089359, 3011.53710658], + [32667.36232717, 13473.93662111, 3011.53723721]], + + [[32290.73847746, 13316.85530486, 3119.08804862], + [32290.73851958, 13331.8553326 , 3119.0880516 ], + [32290.73856171, 13346.85541459, 3119.08805458], + [32278.31709697, 13479.03090451, 3119.08814784], + [32667.36069088, 13473.93665642, 3119.0880082 ]], + + [[32290.73847746, 13316.8553046 , 3226.53554171], + [32290.73851973, 13331.85533235, 3226.53554164], + [32290.73856201, 13346.85541433, 3226.53554157], + [32278.31719105, 13479.03093857, 3226.5356672 ], + [32667.35449363, 13473.93679015, 3226.53555774]], + + [[32290.73847746, 13316.85530381, 3334.03405138], + [32290.7385204 , 13331.85533155, 3334.03404229], + [32290.73856335, 13346.85541355, 3334.0340332 ], + [32278.31732643, 13479.03098559, 3334.034036 ], + [32667.34518321, 13473.93699104, 3334.0511422 ]], + + [[32290.73794356, 13316.85530211, 3441.5596969 ], + [32290.73798745, 13331.85532985, 3441.55968617], + [32290.73803134, 13346.85541214, 3441.55967544], + [32278.31685843, 13479.03100514, 3441.55897016], + [32667.33880109, 13473.93712876, 3441.59341459]], + + [[32290.72890514, 13316.85527056, 3549.27000069], + [32290.72894859, 13331.85529832, 3549.26999161], + [32290.72899204, 13346.85538233, 3549.26998253], + [32278.30757305, 13479.03090553, 3549.26709383], + [32667.34175952, 13473.93706499, 3549.31844074]], + + [[32290.69694216, 13316.85513075, 3656.62321006], + [32290.6970675 , 13331.85515868, 3656.62316033], + [32290.69719284, 13346.85524783, 3656.62311059], + [32278.27575699, 13479.03056101, 3656.61553229], + [32667.36139019, 13473.93664162, 3657.23889759]], + + [[32290.62499737, 13316.8549513 , 3764.17638028], + [32290.62521592, 13331.85497948, 3764.17644294], + [32290.62543448, 13346.85507893, 3764.1765056 ], + [32278.20334281, 13479.02988918, 3764.16336939], + [32667.39371022, 13473.93594469, 3765.35876431]], + + [[32290.49279433, 13316.85482236, 3871.9696166 ], + [32290.49286234, 13331.85485046, 3871.96957629], + [32290.49293036, 13346.85496727, 3871.96953597], + [32278.06711858, 13479.02890258, 3871.94657562], + [32667.40519908, 13473.93569767, 3873.71429271]], + + [[32290.27765457, 13316.85433096, 3979.29880864], + [32290.27783699, 13331.85435952, 3979.29860362], + [32290.27801942, 13346.85450598, 3979.29839859], + [32277.8506299 , 13479.02762937, 3979.26250096], + [32667.36470615, 13473.93657293, 3982.29644231]], + + [[32289.94956954, 13316.85353485, 4086.88089848], + [32289.95012726, 13331.85356447, 4086.88099571], + [32289.95068498, 13346.85375745, 4086.88109295], + [32277.52392375, 13479.02606782, 4086.83249358], + [32667.23549891, 13473.93936285, 4091.13120037]], + + [[32289.47223313, 13316.85297856, 4194.66237847], + [32289.47240966, 13331.85300819, 4194.66229654], + [32289.4725862 , 13346.85326629, 4194.66221461], + [32277.03863567, 13479.02418005, 4194.59164644], + [32666.96889313, 13473.94511579, 4200.1579953 ]], + + [[32288.80334025, 13316.85170722, 4302.09886819], + [32288.80356849, 13331.85173784, 4302.09850454], + [32288.80379673, 13346.85209657, 4302.09814089], + [32276.36656204, 13479.02188157, 4301.99974739], + [32666.53010285, 13473.95457628, 4309.33600054]], + + [[32287.8817731 , 13316.84948907, 4409.69975214], + [32287.88286985, 13331.84952249, 4409.6999178 ], + [32287.88396662, 13346.8500345 , 4409.70008346], + [32275.45090757, 13479.0189926 , 4409.57224337], + [32665.87345012, 13473.96871684, 4418.66983574]], + + [[32286.62783104, 13316.84754537, 4517.4690079 ], + [32286.6283567 , 13331.84757962, 4517.46897996], + [32286.62888239, 13346.84829671, 4517.46895204], + [32274.18484538, 13479.01517672, 4517.29283112], + [32664.92675365, 13473.98906763, 4528.1506746 ]], + + [[32284.93832859, 13316.84414527, 4624.94960951], + [32284.93887677, 13331.84418213, 4624.9491649 ], + [32284.93942498, 13346.84521956, 4624.94872033], + [32272.48816509, 13479.0098021 , 4624.70671101], + [32663.66091322, 13474.0162138 , 4637.79394678]], + + [[32282.63507013, 13316.83827485, 4732.64716032], + [32282.63729326, 13331.83831882, 4732.64754136], + [32282.63951651, 13346.83987786, 4732.64792249], + [32270.19291841, 13479.00164555, 4732.32168414], + [32662.04955092, 13474.050662 , 4747.6056481 ]], + + [[32279.44752355, 13316.83149133, 4840.58111918], + [32279.44923406, 13331.83153995, 4840.5813982 ], + [32279.45094471, 13346.8338493 , 4840.58167741], + [32266.98086727, 13478.98874173, 4840.12528747], + [32660.02459467, 13474.09377961, 4857.58946628]], + + [[32275.10040523, 13316.82035496, 4948.20623222], + [32275.10257671, 13331.82041314, 4948.20601006], + [32275.10474847, 13346.82398436, 4948.20578832], + [32262.60963565, 13478.96878894, 4947.58436524], + [32657.58531198, 13474.14546177, 4967.73446515]], + + [[32269.34038822, 13316.80206676, 5055.97828212], + [32269.34613125, 13331.80214734, 5055.97930455], + [32269.35187537, 13346.80784129, 5055.980328 ], + [32256.84575991, 13478.93953361, 5055.18454074], + [32654.71904887, 13474.20582515, 5078.04435126]], + + [[32262.06587218, 13316.77868136, 5163.83548504], + [32262.0723679 , 13331.77878356, 5163.83657368], + [32262.07886543, 13346.78740274, 5163.83766446], + [32249.51879743, 13478.89931555, 5162.85276011], + [32651.33656416, 13474.27654119, 5188.53465614]], + + [[32253.40049557, 13316.7455129 , 5271.12663113], + [32253.40949066, 13331.74565009, 5271.12723192], + [32253.41848951, 13346.75862018, 5271.12783708], + [32240.80102976, 13478.84769404, 5269.97125671], + [32647.44409848, 13474.35720873, 5299.21241398]], + + [[32243.50634003, 13316.69916101, 5378.42503356], + [32243.52310472, 13331.6993652 , 5378.42779816], + [32243.53987967, 13346.71871832, 5378.43057147], + [32230.88994396, 13478.78488928, 5377.14875098], + [32643.03524309, 13474.44763817, 5410.08121039]], + + [[32232.62512503, 13316.64520557, 5485.79045182], + [32232.64562416, 13331.64547479, 5485.79397734], + [32232.66614013, 13346.67191908, 5485.79751763], + [32219.93485696, 13478.71136226, 5484.40208059], + [32637.99588608, 13474.54974461, 5521.16715126]], + + [[32221.01019902, 13316.58083514, 5592.5586583 ], + [32221.03492082, 13331.58118463, 5592.56253712], + [32221.05966785, 13346.61440574, 5592.56643855], + [32208.25253617, 13478.62792474, 5591.06955227], + [32632.34148351, 13474.66267692, 5632.47064885]], + + [[32208.57870315, 13316.50295255, 5699.46552335], + [32208.61087102, 13331.5034172 , 5699.47337555], + [32208.64307742, 13346.54214806, 5699.48125997], + [32195.80555379, 13478.53326888, 5697.90205619], + [32626.07374634, 13474.78578053, 5743.99539478]], + + [[32195.27096568, 13316.41656784, 5806.55274513], + [32195.30326344, 13331.4171206 , 5806.56209181], + [32195.33560334, 13346.45972744, 5806.57148 ], + [32182.41371233, 13478.42497883, 5804.88327134], + [32619.0495397 , 13474.92106309, 5855.7804347 ]], + + [[32181.15981832, 13316.31409757, 5912.91883818], + [32181.19340597, 13331.31475875, 5912.92875534], + [32181.2270414 , 13346.36168395, 5912.93872607], + [32168.22890764, 13478.3022075 , 5911.13775544], + [32611.29319247, 13475.06704789, 5967.83138281]], + + [[32166.08791043, 13316.18842622, 6019.44926087], + [32166.13035362, 13331.18927458, 6019.46473136], + [32166.17286384, 13346.24207871, 6019.48027379], + [32153.15658171, 13478.16245615, 6017.58984752], + [32602.81374975, 13475.22241662, 6080.15349951]], + + [[32149.97806577, 13316.04787317, 6126.1995066 ], + [32150.02029565, 13331.04885858, 6126.21772159], + [32150.06259681, 13346.10619901, 6126.23602666], + [32136.96219358, 13478.00188469, 6124.22182384], + [32593.42998943, 13475.38902989, 6192.80339975]], + + [[32132.95591259, 13315.88436779, 6232.0104888 ], + [32132.99902202, 13330.88551671, 6232.0304766 ], + [32133.04220908, 13345.94783736, 6232.05057738], + [32119.86545092, 13477.81994302, 6229.908849 ], + [32583.17017926, 13475.5647411 , 6305.78069681]], + + [[32114.78586143, 13315.68777602, 6338.02070404], + [32114.83899508, 13330.68920947, 6338.04891172], + [32114.89223373, 13345.75843138, 6338.07726708], + [32101.7102625 , 13477.61266876, 6335.83772311], + [32572.04942544, 13475.74761811, 6419.09750477]], + + [[32095.3492901 , 13315.46738771, 6444.29384748], + [32095.40158539, 13330.46902191, 6444.32666018], + [32095.4539889 , 13345.54347396, 6444.35965422], + [32082.18879622, 13477.37399228, 6441.97659908], + [32559.84476526, 13475.93929243, 6532.8319555 ]], + + [[32074.85439405, 13315.21447868, 6549.32061436], + [32074.90673062, 13330.21634504, 6549.35699111], + [32074.95917976, 13345.29644196, 6549.39359076], + [32061.61725638, 13477.10413792, 6546.86173362], + [32546.5853976 , 13476.1368875 , 6646.97476081]], + + [[32052.934501 , 13314.91425612, 6654.60042737], + [32052.99753599, 13329.91653061, 6654.64868342], + [32053.06071821, 13345.00459141, 6654.69722515], + [32039.72650223, 13476.79638701, 6652.05364449], + [32532.28357558, 13476.33767406, 6761.57200494]], + + [[32029.42186645, 13314.57622712, 6760.11433661], + [32029.48301351, 13329.57878037, 6760.16979753], + [32029.54430572, 13344.67279996, 6760.22560492], + [32016.12966847, 13476.44137196, 6757.40723375], + [32516.67895425, 13476.54223741, 6876.63240324]], + + [[32004.5824648 , 13314.1908715 , 6864.1388897 ], + [32004.64212539, 13329.19373576, 6864.20080348], + [32004.70192702, 13344.29406444, 6864.2631377 ], + [31991.2110583 , 13476.03981272, 6861.26161375], + [32499.77330177, 13476.74689339, 6992.34262215]], + + [[31977.9204007 , 13313.73714227, 6968.24861916], + [31977.99032843, 13328.74055655, 6968.32770994], + [31978.06043288, 13343.8499091 , 6968.40733234], + [31964.58966144, 13475.58090266, 6965.26828309], + [32481.5765634 , 13476.94763233, 7108.6606885 ]], + + [[31949.12504325, 13313.22188373, 7072.64843588], + [31949.19111036, 13328.22566055, 7072.73867465], + [31949.25733629, 13343.34169016, 7072.82955234], + [31935.71091548, 13475.04907087, 7069.4726782 ], + [32461.78622905, 13477.14315284, 7225.47439362]], + + [[31918.81741557, 13312.64043836, 7174.66916312], + [31918.87928381, 13327.64459854, 7174.7692364 ], + [31918.94128324, 13342.76747055, 7174.87007525], + [31905.31985875, 13474.451013 , 7171.28285677], + [32440.4108659 , 13477.32791033, 7342.87248883]], + + [[31886.08587963, 13311.95806608, 7277.02410946], + [31886.15603691, 13326.9629172 , 7277.14723814], + [31886.22634544, 13342.09573617, 7277.27131991], + [31872.63607703, 13473.76496858, 7273.5159768 ], + [32417.39004598, 13477.4965369 , 7461.21325358]], + + [[31850.59577566, 13311.17893242, 7379.69009004], + [31850.65884576, 13326.18421242, 7379.8293354 ], + [31850.7220137 , 13341.32431412, 7379.96971315], + [31837.06429463, 13472.96931449, 7375.95453749], + [32392.31869783, 13477.64479488, 7580.48516572]], + + [[31813.43002085, 13310.30892745, 7479.22884267], + [31813.48403232, 13325.31461076, 7479.38379168], + [31813.53806453, 13340.4618123 , 7479.54007627], + [31799.80538174, 13472.08164721, 7475.26051876], + [32365.21879665, 13477.76408629, 7700.49720348]], + + [[31773.32692418, 13309.29779174, 7578.82313547], + [31773.38347588, 13324.30421346, 7579.00991296], + [31773.44001826, 13339.46173412, 7579.19831856], + [31759.73931734, 13471.06715921, 7574.73868674], + [32336.0344363 , 13477.84564427, 7821.45688082]], + + [[31729.87279413, 13308.14460693, 7678.45492661], + [31729.91575798, 13323.15140458, 7678.66498134], + [31729.95858634, 13338.31617864, 7678.87693555], + [31716.19551258, 13469.89663257, 7674.13190094], + [32304.26209757, 13477.87998419, 7943.41060717]], + + [[31684.77240308, 13306.87604841, 7774.04799326], + [31684.79834283, 13321.88311836, 7774.27897967], + [31684.82397577, 13337.05436872, 7774.51215477], + [31670.9862797 , 13468.60794124, 7769.46563986], + [32269.85740375, 13477.85424998, 8066.2275526 ]], + + [[31636.13651929, 13305.41518757, 7869.55879073], + [31636.15572968, 13320.42285807, 7869.82752588], + [31636.17449859, 13335.60372744, 7870.09885411], + [31622.36717841, 13467.14216963, 7864.84683747], + [32232.66059806, 13477.75422526, 8190.26163345]], + + [[31583.51739985, 13303.75511394, 7964.84627633], + [31583.51386898, 13318.76291355, 7965.14461998], + [31583.50965448, 13333.95012811, 7965.44591634], + [31569.64845032, 13465.46330357, 7959.89337447], + [32192.07000055, 13477.56126286, 8315.49717189]], + + [[31529.69487065, 13301.96829794, 8054.97975878], + [31529.66345648, 13316.97599896, 8055.30205771], + [31529.63105237, 13332.1678156 , 8055.62764685], + [31515.68828089, 13463.65183963, 8049.75554618], + [32148.07521963, 13477.25562071, 8441.3884292 ]], + + [[31472.2133361 , 13299.95055216, 8144.60711857], + [31472.16269921, 13314.95842514, 8144.96643816], + [31472.11079047, 13330.15731047, 8145.3294837 ], + [31458.16508096, 13461.62292093, 8139.24831518], + [32100.34495124, 13476.81343519, 8568.47735072]], + + [[31410.88323984, 13297.70011428, 8233.56959504], + [31410.79874239, 13312.7075851 , 8233.96146078], + [31410.71260887, 13327.91039326, 8234.35740762], + [31396.68485665, 13459.34908505, 8228.0256578 ], + [32048.22386178, 13476.20288351, 8696.5790211 ]], + + [[31349.34043366, 13295.346144 , 8316.91283405], + [31349.21837823, 13310.3529474 , 8317.32366487], + [31349.09425982, 13325.55684425, 8317.73883795], + [31334.96877218, 13456.96287442, 8311.08195029], + [31991.78463267, 13475.39625156, 8824.68192138]], + + [[31284.30880062, 13292.7468807 , 8399.55446572], + [31284.16056703, 13307.75338648, 8399.98349446], + [31284.00985943, 13322.95972204, 8400.41716604], + [31269.87807862, 13454.34185524, 8393.45632994], + [31930.3987435 , 13474.3533796 , 8953.63003167]], + + [[31216.00842537, 13289.91179421, 8480.91171003], + [31215.82110298, 13304.91737436, 8481.36671039], + [31215.63087213, 13320.12426799, 8481.82659987], + [31201.43145644, 13451.47882742, 8474.61355841], + [31863.76003733, 13473.03394545, 9082.44127379]], + + [[31147.51058379, 13286.97649883, 8557.41874837], + [31147.282585 , 13301.98093899, 8557.88328609], + [31147.05121632, 13317.18523346, 8558.35281796], + [31132.79814161, 13448.50681251, 8550.77537382], + [31792.24877522, 13471.41105962, 9209.43983938]], + + [[31076.407265 , 13283.83223104, 8632.08506401], + [31076.15595472, 13298.83619921, 8632.53952953], + [31075.90084329, 13314.03786364, 8632.99903881], + [31061.71888241, 13445.33256487, 8624.95066715], + [31715.18193138, 13469.43463339, 9335.26101768]], + + [[31003.49344741, 13280.5096101 , 8704.18460109], + [31003.20591314, 13295.51241471, 8704.65466944], + [31002.91418063, 13310.71170722, 8705.12987308], + [30988.73647692, 13441.98224725, 8696.80681542], + [31633.04941533, 13467.08376642, 9458.2291393 ]], + + [[30928.92740963, 13277.03365264, 8773.40951107], + [30928.59874528, 13292.03494142, 8773.88795616], + [30928.26550726, 13307.22936629, 8774.37151131], + [30914.10234617, 13438.4724888 , 8765.70924571], + [31546.22535383, 13464.34246884, 9577.17219802]], + + [[30853.65062829, 13273.45866506, 8838.91712575], + [30853.29960466, 13288.45923015, 8839.37123878], + [30852.94369466, 13303.64705027, 8839.83028431], + [30838.92771863, 13434.86422796, 8830.62427364], + [31454.97782109, 13461.19910227, 9691.61299917]], + + [[30778.0777644 , 13269.79638194, 8900.94454861], + [30777.6996546 , 13284.79589851, 8901.40182622], + [30777.31639515, 13299.97913664, 8901.86397068], + [30763.42033335, 13431.1788084 , 8892.34330638], + [31359.75955147, 13457.6566723 , 9801.22169918]], + + [[30700.35529952, 13265.98099768, 8960.77962151], + [30699.94044044, 13280.97889636, 8961.24325774], + [30699.52021322, 13296.15589455, 8961.71163439], + [30685.74285276, 13427.33694184, 8951.87219505], + [31260.37480978, 13453.70139981, 9906.08523713]], + + [[30622.22860867, 13262.12081965, 9016.85147746], + [30621.78806613, 13277.11756224, 9017.28875579], + [30621.34204909, 13292.28509167, 9017.73043962], + [30607.74584191, 13423.44170994, 9007.3709993 ], + [31157.62962862, 13449.36664666, 10005.63020742]], + + [[30543.77888572, 13258.20862736, 9070.03051504], + [30543.32437772, 13273.20472732, 9070.45750147], + [30542.86433061, 13288.3659301 , 9070.88868284], + [30529.50055091, 13419.51052889, 9060.1786857 ], + [31052.18922984, 13444.69246276, 10100.18754361]], + + [[30462.22818565, 13254.13018904, 9122.02903238], + [30461.75283333, 13269.12525053, 9122.45176474], + [30461.271923 , 13284.27956762, 9122.87847275], + [30448.15540952, 13415.41394651, 9111.84001752], + [30943.9978008 , 13439.69550533, 10189.99734995]], + + [[30380.16588617, 13250.0431209 , 9170.84790325], + [30379.66561444, 13265.03689557, 9171.24567395], + [30379.15989148, 13280.18069268, 9171.64702972], + [30366.22909225, 13411.29326742, 9160.16490478], + [30833.8254319 , 13434.43728435, 10274.62901835]], + + [[30297.79039323, 13245.9391882 , 9217.34710714], + [30297.2744453 , 13260.93214691, 9217.73520537], + [30296.75313218, 13276.06881499, 9218.12663695], + [30284.01515163, 13407.16859073, 9206.36450398], + [30722.17772129, 13428.9750667 , 10354.62152226]], + + [[30211.94336666, 13241.66873067, 9263.12492189], + [30211.41009761, 13256.66079683, 9263.50721456], + [30210.871542 , 13271.79095393, 9263.8926265 ], + [30198.37395224, 13402.88284514, 9251.85823456], + [30608.79814322, 13423.3335993 , 10430.37421694]], + + [[30125.25076825, 13237.37670957, 9306.36880484], + [30124.69769441, 13252.36776312, 9306.72802797], + [30124.13950603, 13267.48885523, 9307.09004879], + [30111.82374424, 13398.56276363, 9294.68769744], + [30494.35410855, 13417.58717675, 10501.60982161]], + + [[30037.77560618, 13233.0463095 , 9347.98548163], + [30037.21186557, 13248.03680718, 9348.33408364], + [30036.64313749, 13263.15183919, 9348.68527544], + [30024.51200659, 13394.21586979, 9336.05094579], + [30379.14838393, 13411.79497088, 10568.91312879]], + + [[29946.31748365, 13228.52645731, 9389.28664327], + [29945.74209545, 13243.51635696, 9389.62791828], + [29945.1618323 , 13258.62594335, 9389.97160811], + [29933.26426001, 13389.68462144, 9377.11249075], + [30262.98480385, 13405.97335065, 10632.60112052]], + + [[29853.2632888 , 13223.94714135, 9428.76411795], + [29852.67200622, 13238.93623484, 9429.08493405], + [29852.07604503, 13254.03813619, 9429.40791234], + [29840.33947171, 13385.08207519, 9416.25344855], + [30146.38156912, 13400.14971823, 10692.51728642]], + + [[29758.5580898 , 13219.28945964, 9467.31517701], + [29757.95757804, 13234.27807806, 9467.6262507 ], + [29757.35253308, 13249.3747135 , 9467.93931933], + [29745.7625473 , 13380.40982743, 9454.60458847], + [30029.50778747, 13394.33211294, 10749.16961466]], + + [[29658.9785715 , 13214.40124228, 9506.03802905], + [29658.36879333, 13229.38938865, 9506.34170138], + [29657.75461119, 13244.48132241, 9506.64722734], + [29646.35692513, 13375.51166975, 9493.13716578], + [29912.14663722, 13388.5093361 , 10802.81267701]], + + [[29556.70488405, 13209.39968558, 9543.68719164], + [29556.08321254, 13224.38723279, 9543.97260237], + [29555.45733634, 13239.47271394, 9544.25967224], + [29544.19174899, 13370.49072458, 9530.51504917], + [29794.69483055, 13382.7006114 , 10853.36084424]], + + [[29453.20359343, 13204.34368422, 9580.51454708], + [29452.5748799 , 13219.33087233, 9580.79103143], + [29451.9421072 , 13234.41189195, 9581.06904542], + [29440.79836601, 13365.42259777, 9567.18225039], + [29677.20960394, 13376.90819721, 10901.24787165]], + + [[29346.61517406, 13199.14773559, 9616.89112774], + [29345.97908497, 13214.13455172, 9617.16056152], + [29345.33906533, 13229.21153774, 9617.43141474], + [29334.37310335, 13360.21904369, 9603.39994266], + [29559.51028185, 13371.12263817, 10946.65368715]], + + [[29239.41420047, 13193.94024095, 9651.54067584], + [29238.76902694, 13208.92660379, 9651.79389015], + [29238.12011009, 13223.99811665, 9652.04837564], + [29227.29688221, 13354.99677208, 9637.81590236], + [29441.89213781, 13365.35787341, 10989.53484568]], + + [[29131.82769205, 13188.72063009, 9685.08727471], + [29131.17712766, 13203.70672141, 9685.33236243], + [29130.52295556, 13218.77442567, 9685.57862214], + [29119.83293836, 13349.76837469, 9671.22194569], + [29324.36891733, 13359.61393492, 11030.24543329]], + + [[29021.71415555, 13183.38924775, 9717.93290025], + [29021.05778803, 13198.37504966, 9718.1713845 ], + [29020.39793354, 13213.4392896 , 9718.41095568], + [29009.89056441, 13344.43200775, 9703.92853947], + [29206.79840322, 13353.88325797, 11068.89966712]], + + [[28911.25218117, 13178.05787617, 9749.04790906], + [28910.58878913, 13193.04333117, 9749.27200387], + [28909.92208185, 13208.10289814, 9749.49707241], + [28899.56608152, 13339.08948019, 9734.84105508], + [29089.39206246, 13348.17552402, 11105.46734767]], + + [[28800.64138546, 13172.72608716, 9779.0306663 ], + [28799.97381135, 13187.71133425, 9779.24743146], + [28799.30304595, 13202.76763601, 9779.46509437], + [28789.08520031, 13333.75133834, 9764.70145074], + [28972.14795546, 13342.48997576, 11140.23422234]], + + [[28688.02826034, 13167.30803343, 9808.14233742], + [28687.35605937, 13182.2930524 , 9808.35301581], + [28686.68077811, 13197.34636934, 9808.56452634], + [28676.64509545, 13328.33017778, 9793.69258397], + [28854.95282374, 13336.82048282, 11173.28015461]], + + [[28575.32375515, 13161.90079197, 9835.55732002], + [28574.64609164, 13176.8855439 , 9835.75521368], + [28573.96550342, 13191.93484784, 9835.95385283], + [28564.08422466, 13322.91447984, 9820.93212577], + [28737.96546262, 13331.17408503, 11204.58290934]], + + [[28462.67912599, 13156.50278617, 9861.84518213], + [28461.99823428, 13171.48737969, 9862.03647751], + [28461.3145298 , 13186.53387255, 9862.22846005], + [28451.57262133, 13317.51193161, 9847.11374704], + [28621.16123607, 13325.54893331, 11234.37763273]], + + [[28348.52646835, 13151.0417867 , 9887.15401129], + [28347.84187026, 13166.02619948, 9887.33971369], + [28347.15456041, 13181.0701047 , 9887.5260525 ], + [28337.59114197, 13312.04915663, 9872.31831765], + [28504.44372932, 13319.9397486 , 11262.72383477]], + + [[28234.54383519, 13145.6022834 , 9910.82595112], + [28233.85495294, 13160.58648878, 9911.00022503], + [28233.16349942, 13175.62691435, 9911.17506855], + [28223.75172616, 13306.60307624, 9895.83810602], + [28387.92729644, 13314.35139246, 11289.60361791]], + + [[28121.03253006, 13140.19079418, 9933.38125585], + [28120.34114492, 13155.17487828, 9933.54957279], + [28119.64728948, 13170.2128623 , 9933.71841431], + [28110.36714045, 13301.18805759, 9918.30230596], + [28271.58932245, 13308.78213776, 11315.2040205 ]], + + [[28006.70058332, 13134.74810711, 9954.92002338], + [28006.00620983, 13149.73204689, 9955.08320595], + [28005.30945781, 13164.76776937, 9955.24687362], + [27996.19155495, 13295.74403137, 9939.75289735], + [28155.3628196 , 13303.22815239, 11339.56006453]], + + [[27892.91524374, 13129.34276492, 9974.97150129], + [27892.2175021 , 13144.32654302, 9975.12440875], + [27891.51750903, 13159.3592198 , 9975.27774949], + [27882.53428953, 13290.33293044, 9959.67391938], + [28039.33409788, 13297.69293768, 11362.66035001]], + + [[27779.77098526, 13123.97218608, 9994.06534545], + [27779.07131958, 13138.95587195, 9994.21285534], + [27778.36949366, 13153.98640865, 9994.36076354], + [27769.49986697, 13284.95909138, 9978.69084344], + [27923.48001974, 13292.17476404, 11384.65931501]], + + [[27666.14971783, 13118.58533041, 10012.24898783], + [27665.44763759, 13133.56890049, 10012.39177505], + [27664.74348052, 13148.59744129, 10012.53492977], + [27656.0152505 , 13279.57089706, 9996.80012777], + [27807.7390276 , 13286.67008812, 11405.57804062]], + + [[27553.1838548 , 13113.23882159, 10029.15415256], + [27552.47912417, 13128.22226524, 10029.28763882], + [27551.77243192, 13143.24811151, 10029.42145219], + [27543.1603263 , 13274.21907458, 10013.59258106], + [27692.17921838, 13281.18154109, 11425.40528093]], + + [[27440.92814158, 13107.92889328, 10045.26923054], + [27440.22194714, 13122.91226733, 10045.39779951], + [27439.51387368, 13137.93621744, 10045.52666832], + [27430.99728035, 13268.90595309, 10029.64216756], + [27576.77783336, 13275.70743463, 11444.27432802]], + + [[27328.43837545, 13102.61266061, 10060.59986146], + [27327.73022996, 13117.5959415 , 10060.72407205], + [27327.02028146, 13132.61811167, 10060.84855833], + [27318.62472639, 13263.58823808, 10044.90954633], + [27461.49181512, 13270.24512933, 11462.19525202]], + + [[27216.6501794 , 13097.33688618, 10074.85177526], + [27215.93995448, 13112.32006827, 10074.96751078], + [27215.22803146, 13127.33982961, 10075.08349041], + [27206.93030446, 13258.30738142, 10059.06313383], + [27346.37256543, 13264.79646722, 11479.15617084]], + + [[27105.59563355, 13092.09733645, 10088.47215664], + [27104.88431583, 13107.08046686, 10088.58338839], + [27104.17137516, 13122.09853024, 10088.69484294], + [27095.95160381, 13253.0645799 , 10072.62726578], + [27231.39829818, 13259.35982578, 11495.27195125]], + + [[26994.47194724, 13086.85772697, 10101.43867928], + [26993.7590586 , 13101.84078249, 10101.5458702 ], + [26993.04461664, 13116.85724128, 10101.65326497], + [26984.92680011, 13247.82327373, 10085.53942391], + [27116.53308205, 13253.93292265, 11510.54770051]], + + [[26883.91346471, 13081.6502107 , 10113.52118078], + [26883.19896112, 13096.6331896 , 10113.62059268], + [26882.48300027, 13111.64747256, 10113.72018371], + [26874.44783718, 13242.61087558, 10097.53501982], + [27001.81500281, 13248.51697761, 11524.96904639]], + + [[26773.81494911, 13076.46459981, 10125.11791797], + [26773.09965897, 13091.44754158, 10125.21317794], + [26772.38298032, 13106.46028939, 10125.3086004 ], + [26764.41531337, 13237.42217328, 10109.08234441], + [26887.22667866, 13243.11059317, 11538.63207525]], + + [[26663.49675451, 13071.27034624, 10136.15821175], + [26662.78021256, 13086.25322824, 10136.24970412], + [26662.06234615, 13101.26451699, 10136.34134415], + [26654.18810697, 13232.22640747, 10120.07451334], + [26772.73808051, 13237.71178146, 11551.5369671 ]], + + [[26553.61313897, 13066.10027274, 10146.41180256], + [26552.89536436, 13081.08309618, 10146.49610234], + [26552.17635375, 13096.09240257, 10146.58053051], + [26544.37968409, 13227.05198669, 10130.24966435], + [26658.38132847, 13232.32148947, 11563.67141343]], + + [[26444.17527795, 13060.95010435, 10156.22940848], + [26443.45697162, 13075.93290264, 10156.30985639], + [26442.73749257, 13090.94080913, 10156.39041977], + [26435.00410934, 13221.89908204, 10140.02297642], + [26544.13986661, 13226.93837209, 11575.1208722 ]], + + [[26334.61257144, 13055.79432914, 10165.53061867], + [26333.8932822 , 13070.7770803 , 10165.60753381], + [26333.17287962, 13085.7836476 , 10165.68455286], + [26325.52673378, 13216.74199601, 10149.28110296], + [26429.99026732, 13221.56076488, 11585.88309112]], + + [[26225.47173332, 13050.6604871 , 10174.11920515], + [26224.7515301 , 13065.64319467, 10174.1894218 ], + [26224.03029571, 13080.64793851, 10174.25972744], + [26216.45653829, 13211.60420925, 10157.79834929], + [26315.95731904, 13216.1892782 , 11595.94745066]], + + [[26116.76003102, 13045.54446626, 10182.32123722], + [26116.03951263, 13060.5271588 , 10182.38785752], + [26115.3180219 , 13075.53061365, 10182.45455687], + [26107.80281587, 13206.48569422, 10165.96045134], + [26202.02612811, 13210.82265601, 11605.38906922]], + + [[26007.99397038, 13040.42477437, 10190.05047852], + [26007.2726981 , 13055.40743055, 10190.11376646], + [26006.55050906, 13070.40964382, 10190.17712453], + [25999.11614086, 13201.36479031, 10173.65088992], + [26088.17733276, 13205.45943479, 11614.1992649 ]], + + [[25899.63514101, 13035.32481473, 10197.13663097], + [25898.91322318, 13050.30743998, 10197.19363502], + [25898.19046565, 13065.30795963, 10197.2506978 ], + [25890.82294202, 13196.26111092, 10180.67169243], + [25974.4314259 , 13200.10003117, 11622.36353002]], + + [[25791.68266736, 13030.24032655, 10203.88498855], + [25790.96062566, 13045.2229458 , 10203.93861207], + [25790.23779946, 13060.22226741, 10203.99228679], + [25782.92372232, 13191.1742398 , 10187.38385345], + [25860.77323733, 13194.74318436, 11629.94882704]], + + [[25683.73294441, 13025.15336758, 10210.20536571], + [25683.01035585, 13040.13596031, 10210.25582552], + [25682.28703535, 13055.13412001, 10210.3063298 ], + [25675.04749638, 13186.08621833, 10193.6690627 ], + [25747.18909612, 13189.38770602, 11636.94634987]], + + [[25576.07317082, 13020.07916594, 10215.95135377], + [25575.35016163, 13035.06173842, 10215.99587469], + [25574.62649354, 13050.05830947, 10216.04043157], + [25567.44953258, 13181.00865769, 10199.35467007], + [25633.69411339, 13184.0337673 , 11643.34300706]], + + [[25468.50938142, 13015.00502679, 10221.40471741], + [25467.78638103, 13029.98759961, 10221.44603892], + [25467.06277425, 13044.98304363, 10221.48739083], + [25459.93821434, 13175.93240899, 10204.77479499], + [25520.27622598, 13178.6803971 , 11649.20084065]], + + [[25360.69883353, 13009.91628259, 10226.45045507], + [25359.97541343, 13024.89883499, 10226.48875276], + [25359.25143733, 13039.89317737, 10226.52707596], + [25352.20230637, 13170.84277287, 10209.78807462], + [25406.9232819 , 13173.32698433, 11654.51120843]], + + [[25252.96305491, 13004.82989706, 10230.93158466], + [25252.23936607, 13019.81243638, 10230.9642272 ], + [25251.51519117, 13034.80527267, 10230.99688919], + [25244.53213415, 13165.75346464, 10214.21259399], + [25293.6465446 , 13167.97408333, 11659.2601914 ]], + + [[25145.30079995, 12999.74251456, 10235.09846183], + [25144.57720824, 13014.72505836, 10235.12805567], + [25143.85318086, 13029.71682387, 10235.15766512], + [25136.92689808, 13160.66447911, 10218.34848437], + [25180.43647914, 13162.62126591, 11663.50169243]], + + [[25037.43354896, 12994.64251242, 10238.83454385], + [25036.70963467, 13009.62504038, 10238.86122729], + [25035.98533343, 13024.61575167, 10238.887923 ], + [25029.13777789, 13155.56404775, 10222.05433462], + [25067.28153794, 13157.26800757, 11667.22283533]], + + [[24929.62738773, 12989.54423293, 10241.99582128], + [24928.90332463, 13004.5267535 , 10242.01707783], + [24928.17894212, 13019.51602363, 10242.03834273], + [24921.40128914, 13150.46336354, 10225.16215387], + [24954.19145961, 13151.91479038, 11670.40869904]], + + [[24821.88073056, 12984.44445786, 10244.82152069], + [24821.15683583, 12999.42698628, 10244.83985129], + [24820.43267044, 13014.41522976, 10244.85818788], + [24813.71592631, 13145.36238612, 10227.95881662], + [24841.15791862, 13146.56124157, 11673.11040491]], + + [[24713.96774622, 12979.33397988, 10247.19415298], + [24713.24361179, 12994.31649643, 10247.20966178], + [24712.51925417, 13009.30372228, 10247.22517464], + [24705.88454476, 13140.2517776 , 10230.30315595], + [24728.16913548, 13141.20682373, 11675.3150754 ]], + + [[24606.10930866, 12974.22491617, 10248.97982575], + [24605.38513264, 12989.20743043, 10248.99008544], + [24604.66079912, 13004.19326546, 10249.00034713], + [24598.09972619, 13135.14066793, 10232.03799864], + [24615.23826004, 13135.85218052, 11677.00814424]], + + [[24498.28840069, 12969.11341041, 10250.40935778], + [24497.5644572 , 12984.09593559, 10250.41678853], + [24496.84040381, 12999.08077687, 10250.42422025], + [24490.34426155, 13130.02827046, 10233.44017187], + [24502.34397428, 13130.49632127, 11678.23837327]], + + [[24390.35912647, 12963.99401796, 10251.36323609], + [24389.63501422, 12978.97653467, 10251.36789212], + [24388.91083892, 12993.9603794 , 10251.37254844], + [24382.50017635, 13124.90899138, 10234.36727006], + [24389.47086278, 13125.13853328, 11678.98911357]], + + [[24282.47236475, 12958.87548675, 10251.71588738], + [24281.74830785, 12973.8580058 , 10251.71539044], + [24281.02425274, 12988.84048711, 10251.71489348], + [24274.69054525, 13119.78867788, 10234.67093199], + [24276.63939938, 13119.77980722, 11679.23964065]], + + [[24174.57207692, 12953.75218088, 10251.69647953], + [24173.84830687, 12968.73471345, 10251.69320701], + [24173.12458592, 12983.71621319, 10251.68993467], + [24166.85860665, 13114.66466211, 10234.62545055], + [24163.78506894, 13114.41709615, 11679.03052162]], + + [[24066.7210473 , 12948.62863584, 10251.18851805], + [24065.99716215, 12963.61116252, 10251.18240544], + [24065.27337408, 12978.59164764, 10251.17629358], + [24059.09446694, 13109.54123515, 10234.09119077], + [24050.88108348, 13109.04914072, 11678.33982935]], + + [[23958.83505332, 12943.50237723, 10250.07887343], + [23958.11132151, 12958.48491096, 10250.06748835], + [23957.3877535 , 12973.46400135, 10250.05610542], + [23951.28582101, 13104.41306634, 10232.93237703], + [23937.92684862, 13103.67592523, 11677.14327887]], + + [[23850.89417227, 12938.36939032, 10248.60528232], + [23850.17080002, 12953.35194107, 10248.59106016], + [23849.44764039, 12968.33002414, 10248.57684166], + [23843.41279479, 13099.27923577, 10231.43294108], + [23824.90349141, 13098.29656377, 11675.47508191]], + + [[23743.07364284, 12933.23963568, 10246.64935927], + [23742.35021942, 12948.22218363, 10246.63219185], + [23741.62705859, 12963.19921574, 10246.61502999], + [23735.67922561, 13094.14945607, 10229.45087106], + [23711.82942835, 13092.91194478, 11673.30642677]], + + [[23635.22049214, 12928.10738444, 10244.0936435 ], + [23634.49732984, 12943.08994462, 10244.0710134 ], + [23633.77449959, 12958.06553195, 10244.0483922 ], + [23627.90327235, 13089.01507961, 10226.84517524], + [23598.70425489, 13087.52206159, 11670.60935735]], + + [[23527.31586514, 12922.96855468, 10241.1841714 ], + [23526.59314687, 12937.95113596, 10241.158609 ], + [23525.87081151, 12952.92567823, 10241.13305846], + [23520.06585402, 13083.8752238 , 10223.90981371], + [23485.51389713, 13082.12625579, 11667.41892493]], + + [[23419.56522756, 12917.83464623, 10237.79992818], + [23418.84253018, 12932.8172282 , 10237.77128995], + [23418.12026807, 12947.79067337, 10237.74266704], + [23412.40237482, 13078.74112217, 10220.49941016], + [23372.25601983, 13076.72442762, 11663.70358397]], + + [[23311.76717378, 12912.69765812, 10233.81584873], + [23311.04485944, 12927.68025821, 10233.781506 ], + [23310.32305316, 12942.65219513, 10233.74718414], + [23304.68150185, 13073.60175384, 10216.4641139 ], + [23258.92963566, 13071.31654146, 11659.43296247]], + + [[23203.89735059, 12907.55309586, 10229.4883157 ], + [23203.17557765, 12922.53572177, 10229.45091922], + [23202.45436613, 12937.50656621, 10229.41354822], + [23196.87825829, 13068.45595326, 10212.11045777], + [23145.51930639, 13065.90187155, 11654.6428213 ]], + + [[23096.22790183, 12902.41576025, 10224.69444167], + [23095.50623242, 12917.39839086, 10224.65380946], + [23094.78517966, 12932.36808156, 10224.61320797], + [23089.29640075, 13063.31823454, 10207.29022898], + [23032.03469253, 13060.48088992, 11649.29830946]], + + [[22988.50414554, 12897.27515643, 10219.29901288], + [22987.78300122, 12912.25781203, 10219.25237749], + [22987.06255033, 12927.22591632, 10219.20578095], + [22981.65015236, 13058.17496137, 10201.84189985], + [22918.47434559, 13055.05353853, 11643.36356632]], + + [[22880.55537269, 12892.11960474, 10213.56187867], + [22879.83488542, 12907.10229176, 10213.51204125], + [22879.11514786, 12922.06924616, 10213.46224927], + [22873.76859919, 13053.01798934, 10196.07830446], + [22804.82156265, 13049.61902966, 11636.87233296]], + + [[22772.57143323, 12886.96010285, 10207.33771588], + [22771.85115161, 12901.94279953, 10207.28445155], + [22771.13167827, 12916.90853272, 10207.23124 ], + [22765.87610705, 13047.85804449, 10189.82761507], + [22691.08248699, 13044.1776673 , 11629.78495556]], + + [[22664.23732432, 12881.78331628, 10200.46197925], + [22663.51774044, 12896.7660463 , 10200.4023503 ], + [22662.79904625, 12911.73009936, 10200.34278533], + [22657.62504957, 13042.67849253, 10182.89712256], + [22577.25533119, 13038.72937641, 11622.06296026]], + + [[22555.51541523, 12876.58367988, 10193.20377409], + [22554.79662594, 12891.566448 , 10193.1407652 ], + [22554.07878606, 12906.52928263, 10193.07782919], + [22548.97619227, 13037.4774626 , 10175.6122833 ], + [22463.31942065, 13033.27317716, 11613.74148388]], + + [[22446.81539834, 12871.3828771 , 10185.42096014], + [22446.09693767, 12886.36566086, 10185.35429707], + [22445.37948881, 12901.32719385, 10185.28771678], + [22440.37511737, 13032.27628279, 10167.80289268], + [22349.28769042, 13027.80969853, 11604.77784925]], + + [[22337.75304525, 12866.16433868, 10176.93159724], + [22337.0354901 , 12881.14716586, 10176.85813622], + [22336.31903366, 12896.10690614, 10176.78477287], + [22331.40301878, 13027.05488659, 10159.25656878], + [22235.15695498, 13022.33879787, 11595.13038756]], + + [[22228.28429663, 12860.92191325, 10168.02352908], + [22227.56770269, 12875.90478663, 10167.94647752], + [22226.85227113, 12890.86322654, 10167.86953535], + [22222.01346703, 13021.81105697, 10150.32163148], + [22120.90333989, 13016.85933985, 11584.83396123]], + + [[22118.90687625, 12855.68171673, 10158.55485202], + [22118.19076122, 12870.66461307, 10158.47387797], + [22117.47587543, 12885.62165553, 10158.39302631], + [22112.74280259, 13016.57050713, 10140.82629649], + [22006.54187773, 13011.37205432, 11573.83652135]], + + [[22009.15890788, 12850.42355462, 10148.3218398 ], + [22008.44395254, 12865.4065067 , 10148.23355183], + [22007.73031968, 12880.36162203, 10148.14540567], + [22003.09256044, 13011.30932257, 10130.53364862], + [21892.06977854, 13005.87681679, 11562.08723613]], + + [[21898.98631001, 12845.14045869, 10137.63433029], + [21898.27251976, 12860.1234669 , 10137.54220498], + [21897.56012025, 12875.07718462, 10137.45023665], + [21893.00535049, 13006.02475911, 10119.81898615], + [21777.45710097, 13000.37219831, 11549.620477 ]], + + [[21788.99581241, 12839.86403409, 10126.35368108], + [21788.28268828, 12854.84707438, 10126.25731814], + [21787.57102687, 12869.79928107, 10126.16112901], + [21783.12979451, 13000.74796116, 10108.5116002 ], + [21662.72604707, 12994.85927339, 11536.37890616]], + + [[21678.6328039 , 12834.56975325, 10114.2487299 ], + [21677.92115656, 12849.5528647 , 10114.1444408 ], + [21677.21107258, 12864.50298438, 10114.04035058], + [21672.87258663, 12995.45042613, 10096.34383125], + [21547.87273702, 12989.33786453, 11522.30424159]], + + [[21567.62652024, 12829.23982407, 10101.62910212], + [21566.91629285, 12844.22300404, 10101.52068847], + [21566.20770227, 12859.17161127, 10101.41249313], + [21561.96026182, 12990.11901973, 10083.69699082], + [21432.86179293, 12983.80627868, 11507.43109887]], + + [[21456.27208937, 12823.89115509, 10088.28542719], + [21455.56277372, 12838.87437913, 10088.17240736], + [21454.85517264, 12853.82134131, 10088.05962747], + [21450.73590028, 12984.77021967, 10070.32730848], + [21317.72133439, 12978.26587587, 11491.69566899]], + + [[21343.87948939, 12818.49281081, 10073.90547772], + [21343.17205729, 12833.47612578, 10073.7838062 ], + [21342.46644872, 12848.42081116, 10073.66240676], + [21338.46929038, 12979.36885414, 10055.88222417], + [21202.44586477, 12972.71640002, 11475.03027102]], + + [[21230.3635482 , 12813.03538853, 10058.80613875], + [21229.65785118, 12828.01878739, 10058.68000915], + [21228.95405687, 12842.96182402, 10058.55417637], + [21225.06958242, 12973.91052065, 10040.75746343], + [21086.99302122, 12967.15581871, 11457.46580009]], + + [[21116.63363057, 12807.56569715, 10042.82337465], + [21115.92915741, 12822.54915531, 10042.69220647], + [21115.22667103, 12837.4903874 , 10042.56136272], + [21111.49593206, 12968.44137024, 10024.75125676], + [20971.40220904, 12961.5860368 , 11438.92588847]], + + [[21001.8851648 , 12802.04746214, 10025.59253058], + [21001.1830892 , 12817.03103624, 10025.45184993], + [21000.48311865, 12831.96976455, 10025.31153481], + [20996.90069539, 12962.92052436, 10007.4536881 ], + [20855.66515681, 12956.00666386, 11419.33169428]], + + [[20886.04449399, 12796.47124106, 10007.45615038], + [20885.3445518 , 12811.45491847, 10007.31062888], + [20884.64680004, 12826.39183618, 10007.16550436], + [20881.20229229, 12957.34402726, 9989.29494401], + [20739.73193714, 12950.41529937, 11398.71300602]], + + [[20770.25928804, 12790.89579471, 9988.27686032], + [20769.56097177, 12805.87955104, 9988.12579606], + [20768.86493683, 12820.81447459, 9987.97516399], + [20765.60271643, 12951.76982194, 9970.09626651], + [20623.65623536, 12944.81453912, 11376.97853997]], + + [[20653.59239866, 12785.27868051, 9967.62684166], + [20652.89714262, 12800.26258505, 9967.46524664], + [20652.20429633, 12815.194734 , 9967.30413633], + [20649.11977696, 12946.15048541, 9949.37859545], + [20507.42870972, 12939.20394054, 11354.03189057]], + + [[20536.00150305, 12779.61125181, 9945.88227486], + [20535.30889106, 12794.59528462, 9945.71540532], + [20534.61878069, 12809.52543157, 9945.54906039], + [20531.70011631, 12940.48343385, 9927.61644759], + [20390.98576224, 12933.58043955, 11329.9018659 ]], + + [[20418.94783705, 12773.96798688, 9922.96565372], + [20418.25737224, 12788.95212404, 9922.79265523], + [20417.56950697, 12803.88005154, 9922.62022619], + [20414.86444585, 12934.84211396, 9904.68654913], + [20274.40378946, 12927.94773215, 11304.48260615]], + + [[20301.4429383 , 12768.30425696, 9898.39841373], + [20300.75639184, 12783.28858416, 9898.21367665], + [20300.07258419, 12798.21341283, 9898.02957619], + [20297.5761888 , 12929.17642026, 9880.05140435], + [20157.6684958 , 12922.30513626, 11277.65350541]], + + [[20184.79470246, 12762.67582504, 9872.90121377], + [20184.11145124, 12777.66031234, 9872.71071572], + [20183.43103724, 12792.58291258, 9872.52090493], + [20181.12612758, 12923.54877855, 9854.54263575], + [20040.70559106, 12916.6490669 , 11249.43711272]], + + [[20071.04144693, 12757.18588958, 9846.62034555], + [20070.36099777, 12772.17051317, 9846.42303369], + [20069.68349095, 12787.09062456, 9846.22646629], + [20067.61716298, 12918.06102553, 9828.25500086], + [19923.61714641, 12910.98446698, 11219.71230783]], + + [[19958.71315898, 12751.76699892, 9819.00848321], + [19958.03776921, 12766.75186814, 9818.79800489], + [19957.36547267, 12781.66849343, 9818.58835731], + [19955.52881671, 12912.63969028, 9800.57264729], + [19806.38801149, 12905.31061338, 11188.32692723]], + + [[19847.61630658, 12746.40132361, 9790.56483094], + [19846.94510687, 12761.38639666, 9790.34807238], + [19846.27710554, 12776.30052956, 9790.13220904], + [19844.64554647, 12907.27449232, 9772.12197968], + [19688.93187988, 12899.62333571, 11155.28905559]], + + [[19739.51573858, 12741.1797104 , 9761.43164128], + [19738.84822967, 12756.16496305, 9761.20730079], + [19738.18403143, 12771.07628947, 9760.98392873], + [19736.80856392, 12902.05477004, 9742.98790268], + [19571.39990057, 12893.92996162, 11120.45534549]], + + [[19632.70490804, 12736.02364671, 9730.93946419], + [19632.04401366, 12751.00922082, 9730.70029909], + [19631.3865927 , 12765.91660016, 9730.46221374], + [19630.25838728, 12896.89534807, 9712.42074751], + [19453.77032271, 12888.22945054, 11083.63970172]], + + [[19526.92057367, 12730.91051124, 9699.59781977], + [19526.26505634, 12745.89634719, 9699.3518513 ], + [19525.61312377, 12760.80092608, 9699.10704496], + [19524.70198404, 12891.78217977, 9681.07728744], + [19335.94109725, 12882.51686638, 11044.83578689]], + + [[19424.16663459, 12725.94378061, 9667.66650759], + [19423.51601353, 12740.92985495, 9667.41211621], + [19422.86909587, 12755.83125026, 9667.15898102], + [19422.23080266, 12886.81684593, 9649.15171718], + [19218.12482717, 12876.80252937, 11003.87615557]], + + [[19322.4770235 , 12721.03285679, 9634.32210302], + [19321.83513177, 12736.01935577, 9634.05097217], + [19321.19711754, 12750.91625084, 9633.78124207], + [19320.82448311, 12881.90138717, 9615.72642234], + [19100.29295404, 12871.08507284, 10960.52544143]], + + [[19221.53801419, 12716.15087275, 9600.07665004], + [19220.90308127, 12731.13771094, 9599.79824145], + [19220.27214187, 12746.03144661, 9599.52133924], + [19220.12818946, 12877.01874807, 9581.48573194], + [18982.33172576, 12865.3589915 , 10914.75843399]], + + [[19123.64228393, 12711.41664487, 9565.34736364], + [19123.01389622, 12726.40380201, 9565.05966225], + [19122.38962504, 12741.29390816, 9564.77358863], + [19122.53536569, 12872.28528225, 9546.77020148], + [18864.55490183, 12859.63953607, 10866.37943113]], + + [[19026.66730876, 12706.73260924, 9529.16446818], + [19026.05055019, 12721.72033258, 9528.85787282], + [19025.4380906 , 12736.605279 , 9528.55309385], + [19025.87665882, 12867.59567747, 9510.50203201], + [18746.93057776, 12853.92518568, 10815.07205465]], + + [[18930.75580232, 12702.09176118, 9492.1435082 ], + [18930.14810534, 12717.07992653, 9491.82933343], + [18929.54482193, 12731.96130845, 9491.51710941], + [18930.24689043, 12862.9547166 , 9473.50458187], + [18629.35109058, 12848.21073412, 10760.7951575 ]], + + [[18838.39957252, 12697.62433027, 9454.74156683], + [18837.80063631, 12712.61292279, 9454.41730641], + [18837.20623056, 12727.49016437, 9454.09515092], + [18838.25887105, 12858.48954087, 9436.14585521], + [18512.2673792 , 12842.51813136, 10703.3524675 ]], + + [[18747.00032531, 12693.21046901, 9415.57593115], + [18746.4170099 , 12708.19982249, 9415.2305017 ], + [18745.83840105, 12723.0711391 , 9414.88742001], + [18747.265988 , 12854.07151265, 9396.9145226 ], + [18395.64940269, 12836.84596249, 10642.33703275]], + + [[18656.11510933, 12688.81178032, 9374.96899552], + [18655.54364731, 12703.80171226, 9374.61611132], + [18654.97698967, 12718.66902987, 9374.26574083], + [18656.74138355, 12849.6752042 , 9356.37569435], + [18279.41700997, 12831.1903711 , 10577.69714035]], + + [[18568.48357667, 12684.57273293, 9333.60983914], + [18567.92386727, 12699.56323804, 9333.24638473], + [18567.36905642, 12714.42585387, 9332.88563485], + [18569.56205242, 12845.44070727, 9315.10924902], + [18164.20189993, 12825.58215096, 10509.26748292]], + + [[18481.39024867, 12680.36877913, 9289.87988279], + [18480.85157418, 12695.3603095 , 9289.49313865], + [18480.31794708, 12710.21613786, 9289.1094067 ], + [18482.97539207, 12841.23409011, 9271.34648929], + [18049.9907469 , 12820.02072627, 10436.53257452]], + + [[18394.457847 , 12676.16088742, 9244.0679651 ], + [18393.93455701, 12691.15316911, 9243.67471939], + [18393.41637584, 12706.00456864, 9243.28468314], + [18396.48265115, 12837.03129444, 9225.66635556], + [17936.77073581, 12814.50554374, 10359.45141341]], + + [[18310.6480309 , 12672.10718271, 9197.15789613], + [18310.14038006, 12687.10022716, 9196.75429796], + [18309.63788571, 12701.94634991, 9196.3541361 ], + [18313.20749701, 12832.9845481 , 9178.91845519], + [17825.3898686 , 12809.07800258, 10277.94911968]], + + [[18227.20342248, 12668.08234363, 9147.2551063 ], + [18226.72396455, 12683.07676307, 9146.82676542], + [18226.24975116, 12697.91517574, 9146.40223911], + [18230.37646087, 12828.95878434, 9129.03531738], + [17715.84493842, 12803.73805738, 10191.42308384]], + + [[18143.88740072, 12664.04928028, 9094.64072567], + [18143.42756424, 12679.04465887, 9094.20827753], + [18142.97296912, 12693.87828465, 9093.7798625 ], + [18147.5751741 , 12824.93407621, 9076.64362402], + [17608.25154045, 12798.49145354, 10099.97624837]], + + [[18063.60906574, 12660.16717554, 9040.5299129 ], + [18063.16955914, 12675.16354573, 9040.08867434], + [18062.73526183, 12689.99139349, 9039.65171888], + [18067.90515115, 12821.06173584, 9022.7911504 ], + [17503.65544897, 12793.38932186, 10003.72743648]], + + [[17983.88379296, 12656.32553742, 8982.88160289], + [17983.48138252, 12671.32371809, 8982.41580163], + [17983.08416053, 12686.14298495, 8981.9547229 ], + [17988.89912854, 12817.22151928, 8965.24299531], + [17402.08719462, 12788.43330763, 9902.02939419]], + + [[17904.66452569, 12652.49044693, 8922.03667941], + [17904.28623841, 12667.48980829, 8921.5714302 ], + [17903.91304077, 12682.30413174, 8921.11111585], + [17910.24754826, 12813.39864271, 8904.7408292 ], + [17303.85009098, 12783.6383682 , 9795.33529978]], + + [[17827.82084911, 12648.77465519, 8858.84528467], + [17827.46763192, 12663.77524017, 8858.37508289], + [17827.11936485, 12678.58354858, 8857.91005934], + [17834.03867411, 12809.69521976, 8841.91692991], + [17210.01148652, 12779.0567805 , 9684.01176763]], + + [[17751.75651429, 12645.11290875, 8791.76775862], + [17751.44995948, 12660.11577364, 8791.27621909], + [17751.14818937, 12674.9149024 , 8790.7903349 ], + [17758.74237385, 12806.03659686, 8775.02194406], + [17120.53116177, 12774.68677264, 9567.65585128]], + + [[17676.79082071, 12641.48251582, 8721.63565388], + [17676.51214669, 12656.4867473 , 8721.15193716], + [17676.23806215, 12671.28109461, 8720.67405445], + [17684.323193 , 12802.42134368, 8705.33962663], + [17035.55880186, 12770.53583779, 9447.45352828]], + + [[17603.77884846, 12637.95044969, 8648.7326144 ], + [17603.52831818, 12652.95605817, 8648.25068389], + [17603.28213074, 12667.74466974, 8647.77478214], + [17611.9039869 , 12798.90461067, 8632.90529114], + [16955.62244794, 12766.62995505, 9324.01558869]], + + [[17532.61114445, 12634.52656359, 8572.69876824], + [17532.41491328, 12649.53482787, 8572.20289458], + [17532.22269598, 12664.31425803, 8571.71350672], + [17541.51305152, 12795.48767482, 8557.15924762], + [16880.55053206, 12762.96091929, 9197.14887631]], + + [[17464.04753643, 12631.20438287, 8494.98643252], + [17463.88119153, 12646.21411178, 8494.50720526], + [17463.71857327, 12660.98928524, 8494.03456105], + [17473.45773316, 12792.18566198, 8480.016555 ], + [16810.71984171, 12759.54731402, 9069.16993977]], + + [[17395.84604508, 12627.89985165, 8412.47384023], + [17395.7057728 , 12642.91086082, 8412.004276 ], + [17395.56890053, 12657.68142643, 8411.54135834], + [17405.75666411, 12788.90230982, 8398.07611205], + [16746.03432654, 12756.38460379, 8940.30777832]], + + [[17331.26785706, 12624.78967286, 8328.76783757], + [17331.18024421, 12639.80325911, 8328.29466941], + [17331.09557698, 12654.56600574, 8327.82844569], + [17341.88364674, 12785.80688032, 8314.79214873], + [16686.70930221, 12753.48346439, 8811.58784043]], + + [[17270.29033461, 12621.83070927, 8244.61091177], + [17270.22851541, 12636.84555826, 8244.16067154], + [17270.1693072 , 12651.60512104, 8243.71732158], + [17281.32462705, 12782.8742256 , 8231.29935479], + [16632.17634758, 12750.81622959, 8684.02817056]], + + [[17209.49285438, 12618.8790771 , 8154.72021447], + [17209.45115981, 12633.89491774, 8154.2848487 ], + [17209.41172643, 12648.65125134, 8153.85626345], + [17220.90733522, 12779.94994781, 8142.05528596], + [16581.73780395, 12748.34888151, 8556.47172099]], + + [[17152.67221546, 12616.13806591, 8064.25297907], + [17152.67620483, 12631.15614317, 8063.82212652], + [17152.68194942, 12645.90629887, 8063.39813673], + [17164.6755853 , 12777.23109144, 8052.120444 ], + [16535.62885023, 12746.09301556, 8430.03287855]], + + [[17099.51190871, 12613.55436985, 7973.37233057], + [17099.53559964, 12628.57341171, 7972.96677155], + [17099.5607088 , 12643.32132772, 7972.56786149], + [17111.83155348, 12774.67837643, 7961.95963057], + [16493.08649344, 12744.01138058, 8304.85334811]], + + [[17047.525917 , 12611.02611363, 7877.29768686], + [17047.5636394 , 12626.04584861, 7876.90919524], + [17047.60245326, 12640.79162933, 7876.52708769], + [17060.11044708, 12772.18136279, 7866.56641568], + [16453.75408887, 12742.08658705, 8180.2046116 ]], + + [[16999.55740967, 12608.70903533, 7781.11023732], + [16999.63273894, 12623.7306125 , 7780.73120043], + [16999.7086764 , 12638.47163053, 7780.35844908], + [17012.59524617, 12769.8901276 , 7770.95249099], + [16417.75436858, 12740.32469538, 8056.85894613]], + + [[16955.12476207, 12606.54656614, 7684.91432354], + [16955.21401018, 12621.56882668, 7684.56030357], + [16955.30358667, 12636.30834513, 7684.21227207], + [16968.35897126, 12767.75902384, 7675.43743668], + [16384.44069048, 12738.69410362, 7934.77667104]], + + [[16912.2875468 , 12604.46001146, 7584.57045945], + [16912.38566589, 12619.48271137, 7584.23352874], + [16912.48386528, 12634.22092689, 7583.90226703], + [16925.66286998, 12765.70335917, 7575.70889162], + [16353.59399693, 12737.18412575, 7813.31845409]], + + [[16872.83242375, 12602.55199035, 7484.42081567], + [16872.96048184, 12617.57615795, 7484.09564217], + [16873.08821799, 12632.31076663, 7483.77595728], + [16886.5229292 , 12763.8212998 , 7476.08284396], + [16325.29401922, 12735.798697 , 7692.99596324]], + + [[16836.33207601, 12600.77339436, 7384.42660934], + [16836.4693388 , 12615.79801581, 7384.1245481 ], + [16836.60607574, 12630.53166624, 7383.82766689], + [16850.11906248, 12762.0724366 , 7376.67809362], + [16299.01263513, 12734.51199017, 7573.68556677]], + + [[16801.49984042, 12599.07444569, 7281.31375145], + [16801.64215138, 12614.09931832, 7281.02761935], + [16801.78376696, 12628.83223395, 7280.74635048], + [16815.33926547, 12760.40267097, 7274.08879932], + [16274.65454308, 12733.31936097, 7454.96789925]], + + [[16769.39058009, 12597.5201925 , 7178.53073521], + [16769.55637552, 12612.54621721, 7178.25694895], + [16769.72115865, 12627.27640066, 7177.98782036], + [16783.44106481, 12758.8732733 , 7171.75676876], + [16252.2802672 , 12732.2237925 , 7337.15267315]], + + [[16739.6638814 , 12596.07012314, 7075.99751035], + [16739.8354052 , 12611.09643182, 7075.74426741], + [16740.00577869, 12625.82602638, 7075.49539922], + [16753.74310299, 12757.45068627, 7069.71293884], + [16231.4731552 , 12731.2048997 , 7220.13094855]], + + [[16711.46330878, 12594.69300619, 6971.17537245], + [16711.63723943, 12609.71943602, 6970.93640006], + [16711.80991377, 12624.44866466, 6970.70152124], + [16725.5374577 , 12756.10059276, 6965.31911917], + [16212.19241971, 12730.26069855, 7103.61290305]], + + [[16685.37393327, 12593.42911922, 6866.71648185], + [16685.56617298, 12608.45644778, 6866.48947056], + [16685.75691429, 12623.18359528, 6866.26635971], + [16699.58376516, 12754.86012269, 6861.23214399], + [16194.49015823, 12729.39375263, 6987.784718 ]], + + [[16661.12850394, 12592.2453302 , 6762.53152945], + [16661.32405514, 12607.27282407, 6762.32237494], + [16661.51802058, 12621.99962777, 6762.11688447], + [16675.32282926, 12753.70181724, 6757.4396665 ], + [16178.03737169, 12728.58795997, 6872.5615127 ]], + + [[16638.14640527, 12591.12188107, 6656.69276177], + [16638.34263601, 12606.14941116, 6656.49614299], + [16638.53722115, 12620.87608191, 6656.30295857], + [16652.29899888, 12752.6037598 , 6651.94048234], + [16162.82179851, 12727.84272844, 6757.74672274]], + + [[16616.74293072, 12590.08418617, 6551.18984753], + [16616.95341978, 12605.11241657, 6551.00435252], + [16617.16208014, 12619.8374924 , 6550.82212628], + [16630.9767128 , 12751.58877933, 6546.73868402], + [16148.88548889, 12727.16012541, 6643.44984964]], + + [[16596.7642828 , 12589.10794559, 6445.93871498], + [16596.97650082, 12604.13626308, 6445.76857775], + [16597.18683571, 12618.86115326, 6445.60151223], + [16610.95583489, 12750.63715472, 6441.80628883], + [16135.96600957, 12726.52730343, 6529.62220573]], + + [[16577.86442531, 12588.18321512, 6339.44733495], + [16578.07624965, 12603.21151608, 6339.28806533], + [16578.28615865, 12617.9364165 , 6339.13167091], + [16591.99414333, 12749.73723994, 6335.59539948], + [16124.06654824, 12725.94442399, 6416.1297918 ]], + + [[16560.254268 , 12587.32880113, 6233.23791705], + [16560.47716724, 12602.35764631, 6233.08871757], + [16560.69801046, 12617.08132281, 6232.94224045], + [16574.42799253, 12748.905515 , 6229.64243733], + [16113.221557 , 12725.41317971, 6303.04331164]], + + [[16543.83464803, 12586.52593264, 6127.23777501], + [16544.05826181, 12601.5548146 , 6127.10167666], + [16544.27978548, 12616.27840566, 6126.96812767], + [16557.95171548, 12748.12681784, 6123.91041648], + [16103.2162262 , 12724.9230523 , 6190.32570819]], + + [[16528.37969527, 12585.76833076, 6020.30181793], + [16528.60236054, 12600.79716856, 6020.17544638], + [16528.82293312, 12615.52169724, 6020.05144451], + [16542.42488115, 12747.39442255, 6017.21216945], + [16094.06155287, 12724.4745839 , 6077.87527624]], + + [[16514.01791197, 12585.06008538, 5913.58187338], + [16514.24559215, 12600.08916881, 5913.46816607], + [16514.4712368 , 12614.82361361, 5913.35659662], + [16528.07361239, 12746.71942981, 5910.72043192], + [16085.79042492, 12724.06938884, 5965.73464645]], + + [[16500.74139241, 12584.37196273, 5807.02603484], + [16500.93327281, 12599.39929412, 5806.9300689 ], + [16501.12362746, 12614.17056905, 5806.83580205], + [16514.66188608, 12746.09005541, 5804.40580474], + [16078.22618009, 12723.69881527, 5853.8828708 ]], + + [[16488.33291435, 12583.72105847, 5699.76142071], + [16488.48966736, 12598.74667313, 5699.67621371], + [16488.64538667, 12613.55935015, 5699.59229016], + [16502.11031419, 12745.50248992, 5697.34909387], + [16071.37787652, 12723.36330913, 5742.25388909]], + + [[16476.86166959, 12583.12375537, 5592.68286979], + [16476.9930109 , 12598.14812816, 5592.60749214], + [16477.12364951, 12612.99871307, 5592.5330687 ], + [16490.57444158, 12744.96436302, 5590.46632645], + [16065.27193916, 12723.06416704, 5630.87367662]], + + [[16466.28200962, 12582.56918183, 5485.75387544], + [16466.38405854, 12597.59212222, 5485.68897436], + [16466.48568078, 12612.47805456, 5485.62475515], + [16479.87126514, 12744.46645551, 5483.73589462], + [16059.7628971 , 12722.79426374, 5519.73291149]], + + [[16456.49572323, 12582.05551185, 5378.25636773], + [16456.56969605, 12597.07708004, 5378.19906772], + [16456.64344002, 12611.99571267, 5378.14222889], + [16469.95726767, 12744.00660752, 5376.41816193], + [16054.85475472, 12722.55379674, 5408.77754749]], + + [[16447.58506915, 12581.59111386, 5270.89792173], + [16447.63866298, 12596.61168623, 5270.84765769], + [16447.69214295, 12611.56006728, 5270.79768274], + [16460.98491086, 12743.59204318, 5269.23950721], + [16050.56630252, 12722.34368788, 5298.02646064]], + + [[16439.59485489, 12581.17214568, 5163.6425731 ], + [16439.62609328, 12596.19162516, 5163.59969022], + [16439.65729323, 12611.16672018, 5163.55696346], + [16452.8921879 , 12743.21904603, 5162.17407854], + [16046.77777793, 12722.15807047, 5187.47559314]], + + [[16432.52427045, 12580.80080674, 5055.9088019 ], + [16432.53494233, 12595.81928117, 5055.87114119], + [16432.54561014, 12610.81799689, 5055.83354041], + [16445.72063752, 12742.88921809, 5054.6188359 ], + [16043.4889959 , 12721.99693621, 5077.08732344]], + + [[16426.43250773, 12580.48324233, 4948.29808393], + [16426.42920813, 12595.50103412, 4948.26533644], + [16426.42590859, 12610.52029116, 4948.2325854 ], + [16439.58495169, 12742.60784196, 4947.18437196], + [16040.71143975, 12721.86084838, 4966.87348836]], + + [[16421.30179901, 12580.21407651, 4840.80902743], + [16421.28400792, 12595.23115989, 4840.78145923], + [16421.26620425, 12610.26762833, 4840.75385025], + [16434.38405978, 12742.3695596 , 4839.87012959], + [16038.34605746, 12721.74495442, 4856.83641397]], + + [[16417.06389185, 12579.99121743, 4732.95505645], + [16417.03347369, 12595.00768349, 4732.93110017], + [16417.00301905, 12610.0583382 , 4732.90708567], + [16430.07948436, 12742.17250319, 4732.16999793], + [16036.386951 , 12721.64896572, 4746.94831402]], + + [[16413.65972515, 12579.81387671, 4625.2630604 ], + [16413.62182801, 12594.82997715, 4625.24264886], + [16413.58387046, 12609.89226954, 4625.22217485], + [16426.65382881, 12742.01607126, 4624.61858688], + [16034.83870229, 12721.57310711, 4637.21381347]], + + [[16410.97432942, 12579.67287072, 4517.74154051], + [16410.9287533 , 12594.68859562, 4517.72495917], + [16410.8830906 , 12609.75996088, 4517.70831885], + [16423.92838453, 12741.89176595, 4517.22386344], + [16033.62058976, 12721.51342376, 4527.6469361 ]], + + [[16408.8657197 , 12579.56168603, 4409.92226749], + [16408.8135205 , 12594.57708692, 4409.90833169], + [16408.76120924, 12609.65551428, 4409.89434406], + [16421.77969101, 12741.79405475, 4409.50496541], + [16032.71326033, 12721.46896759, 4418.22790148]], + + [[16407.23436321, 12579.4768772 , 4302.2755937 ], + [16407.17888404, 12594.49211765, 4302.26433112], + [16407.1232752 , 12609.57618598, 4302.25302533], + [16420.13949534, 12741.71999515, 4301.94759183], + [16032.09474038, 12721.43866208, 4308.95742526]], + + [[16406.00793684, 12579.41251124, 4194.80603338], + [16405.94896888, 12594.42758103, 4194.79767356], + [16405.88985486, 12609.51580499, 4194.78927972], + [16418.89231544, 12741.66399687, 4194.5600881 ], + [16031.68009378, 12721.41834568, 4199.86254944]], + + [[16405.11326023, 12579.36510472, 4087.01947822], + [16405.05118567, 12594.38002251, 4087.01297748], + [16404.98895064, 12609.4712174 , 4087.00645143], + [16417.97436571, 12741.62307043, 4086.83626563], + [16031.42833523, 12721.40601028, 4090.92948227]], + + [[16404.48966849, 12579.33276707, 3979.40500279], + [16404.42649817, 12594.34763125, 3979.40037793], + [16404.36315984, 12609.44102407, 3979.3957353 ], + [16417.3474224 , 12741.59551502, 3979.27715613], + [16031.31256818, 12721.40033805, 3982.14683527]], + + [[16404.07654526, 12579.31121694, 3872.01503796], + [16404.01234367, 12594.32603067, 3872.01244392], + [16403.94796844, 12609.42086247, 3872.00983815], + [16416.92741634, 12741.57729935, 3871.93631308], + [16031.27700115, 12721.39859537, 3873.58886779]], + + [[16403.81946653, 12579.2974967 , 3764.18689065], + [16403.75423527, 12594.31226006, 3764.18512321], + [16403.68882551, 12609.40794507, 3764.1833487 ], + [16416.66048428, 12741.56589598, 3764.13654415], + [16031.28783824, 12721.39912636, 3765.25858058]], + + [[16403.675768 , 12579.2900498 , 3656.62253475], + [16403.6103148 , 12594.30480229, 3656.6215846 ], + [16403.54468129, 12609.40101175, 3656.6206307 ], + [16416.51545365, 12741.55987973, 3656.59580615], + [16031.32075801, 12721.40073933, 3657.17713273]], + + [[16403.60750168, 12579.28661603, 3549.27121647], + [16403.54201457, 12594.30136687, 3549.27114589], + [16403.47634674, 12609.39784713, 3549.27107367], + [16416.44734748, 12741.55711132, 3549.26406482], + [16031.34087955, 12721.40172522, 3549.30862958]], + + [[16403.58394093, 12579.28538881, 3441.55541421], + [16403.51837638, 12594.30013587, 3441.55536843], + [16403.45263077, 12609.39671114, 3441.55532218], + [16416.42299223, 12741.55611293, 3441.55303659], + [16031.34365175, 12721.40186105, 3441.58417468]], + + [[16403.58071189, 12579.28521068, 3334.02567603], + [16403.51514026, 12594.29995739, 3334.02564935], + [16403.44938751, 12609.3965537 , 3334.02562261], + [16416.41975505, 12741.55596001, 3334.02524638], + [16031.33740869, 12721.40155516, 3334.04108503]], + + [[16403.58071183, 12579.28521958, 3226.5351637 ], + [16403.5151507 , 12594.2999668 , 3226.53516213], + [16403.44940848, 12609.39656655, 3226.53516056], + [16416.42001037, 12741.55592685, 3226.5352802 ], + [16031.32839538, 12721.40111353, 3226.53516375]], + + [[16403.5807118 , 12579.28522741, 3119.01255597], + [16403.51515795, 12594.29997499, 3119.01255593], + [16403.44942304, 12609.39657585, 3119.01255588], + [16416.42019638, 12741.55590086, 3119.01262098], + [16031.32239871, 12721.40081972, 3119.01233267]], + + [[16403.58071181, 12579.28522585, 3011.46107734], + [16403.51515549, 12594.29997331, 3011.46107767], + [16403.44941811, 12609.3965739 , 3011.461078 ], + [16416.42019424, 12741.55588933, 3011.46107836], + [16031.32077124, 12721.40073998, 3011.46126391]], + + [[16403.58071183, 12579.28522088, 2903.88111651], + [16403.5151507 , 12594.29996811, 2903.88111709], + [16403.44940848, 12609.39656798, 2903.88111768], + [16416.42011698, 12741.55589123, 2903.88108915], + [16031.32196991, 12721.40079871, 2903.8811165 ]], + + [[16403.58071184, 12579.28521848, 2796.31569448], + [16403.51514887, 12594.29996562, 2796.31569462], + [16403.44940479, 12609.39656517, 2796.31569475], + [16416.42005943, 12741.5559013 , 2796.31566054], + [16031.32436749, 12721.40091618, 2796.31566933]], + + [[16403.58071184, 12579.285219 , 2688.76449661], + [16403.51514967, 12594.29996617, 2688.76449701], + [16403.44940641, 12609.39656581, 2688.76449741], + [16416.42004141, 12741.55591135, 2688.76448248], + [16031.32629994, 12721.40101086, 2688.76416242]], + + [[16403.58071183, 12579.28521998, 2581.22757735], + [16403.5151507 , 12594.29996721, 2581.22757728], + [16403.44940848, 12609.39656699, 2581.22757722], + [16416.42004302, 12741.55591594, 2581.22757328], + [16031.32698521, 12721.40104444, 2581.22757735]], + + [[16403.58071183, 12579.28521995, 2473.68391385], + [16403.51515043, 12594.29996716, 2473.68391387], + [16403.44940793, 12609.39656693, 2473.6839139 ], + [16416.4200414 , 12741.55591513, 2473.68392039], + [16031.32678618, 12721.40103469, 2473.68391708]], + + [[16403.58071183, 12579.28521989, 2366.13294642], + [16403.51515034, 12594.2999671 , 2366.13294705], + [16403.44940775, 12609.39656686, 2366.13294767], + [16416.42004875, 12741.55591221, 2366.13296437], + [16031.32617035, 12721.40100452, 2366.13255922]], + + [[16403.58071183, 12579.28522026, 2258.57402713], + [16403.5151507 , 12594.29996748, 2258.57402707], + [16403.44940848, 12609.39656729, 2258.574027 ], + [16416.42006189, 12741.55590963, 2258.57403361], + [16031.32559167, 12721.40097616, 2258.57402714]], + + [[16403.58071183, 12579.28522064, 2151.01980327], + [16403.51515108, 12594.29996788, 2151.01980325], + [16403.44940923, 12609.39656774, 2151.01980323], + [16416.42007093, 12741.55590849, 2151.01980522], + [16031.32534267, 12721.40096396, 2151.01979975]], + + [[16403.58071183, 12579.28522058, 2043.468405 ], + [16403.51515099, 12594.29996782, 2043.46840503], + [16403.44940905, 12609.39656767, 2043.46840506], + [16416.42006995, 12741.55590837, 2043.4684038 ], + [16031.32535021, 12721.40096433, 2043.4684075 ]], + + [[16403.58071183, 12579.28522028, 1935.92059215], + [16403.5151507 , 12594.29996751, 1935.92059219], + [16403.44940848, 12609.39656732, 1935.92059223], + [16416.42006422, 12741.55590886, 1935.92058974], + [16031.32549627, 12721.40097149, 1935.92059215]], + + [[16403.58071183, 12579.2852201 , 1828.37190509], + [16403.51515054, 12594.29996731, 1828.3719053 ], + [16403.44940815, 12609.3965671 , 1828.37190552], + [16416.42005971, 12741.55590955, 1828.37190576], + [16031.3256634 , 12721.40097968, 1828.37178063]], + + [[16403.58071183, 12579.28522014, 1720.82029199], + [16403.5151506 , 12594.29996736, 1720.82029202], + [16403.44940829, 12609.39656716, 1720.82029206], + [16416.42005916, 12741.55591007, 1720.82029176], + [16031.32575317, 12721.40098407, 1720.82026305]], + + [[16403.58071183, 12579.28522024, 1613.2671892 ], + [16403.5151507 , 12594.29996746, 1613.26718919], + [16403.44940848, 12609.39656727, 1613.26718918], + [16416.42006013, 12741.55591022, 1613.26718935], + [16031.32576553, 12721.40098468, 1613.2671892 ]], + + [[16403.58071183, 12579.28522026, 1505.7155925 ], + [16403.51515071, 12594.29996749, 1505.71559274], + [16403.4494085 , 12609.3965673 , 1505.71559298], + [16416.4200607 , 12741.55591008, 1505.71559626], + [16031.3257316 , 12721.40098302, 1505.71544953]], + + [[16403.58071183, 12579.28522026, 1398.16444693], + [16403.5151507 , 12594.29996748, 1398.16444736], + [16403.44940848, 12609.39656729, 1398.16444779], + [16416.42006122, 12741.55590985, 1398.16445314], + [16031.32568456, 12721.40098071, 1398.16419025]], + + [[16403.58071183, 12579.28522026, 1290.61367613], + [16403.5151507 , 12594.29996748, 1290.61367612], + [16403.44940848, 12609.3965673 , 1290.61367612], + [16416.4200617 , 12741.5559097 , 1290.61367642], + [16031.32565476, 12721.40097925, 1290.61367613]], + + [[16403.58071183, 12579.28522027, 1183.0633879 ], + [16403.51515072, 12594.2999675 , 1183.0633879 ], + [16403.44940851, 12609.39656731, 1183.0633879 ], + [16416.42006206, 12741.55590966, 1183.0633879 ], + [16031.32564784, 12721.40097891, 1183.06338773]], + + [[16403.58071183, 12579.28522027, 1075.5121294 ], + [16403.51515072, 12594.2999675 , 1075.5121294 ], + [16403.44940851, 12609.39656731, 1075.5121294 ], + [16416.42006195, 12741.55590969, 1075.51212924], + [16031.32565487, 12721.40097926, 1075.51212952]], + + [[16403.58071183, 12579.28522026, 967.96020966], + [16403.5151507 , 12594.29996748, 967.96020966], + [16403.44940848, 12609.39656729, 967.96020966], + [16416.42006158, 12741.55590974, 967.96020949], + [16031.32566721, 12721.40097986, 967.96020966]], + + [[16403.58071183, 12579.28522025, 860.40950112], + [16403.51515069, 12594.29996747, 860.4095012 ], + [16403.44940846, 12609.39656728, 860.40950128], + [16416.4200613 , 12741.55590978, 860.40950203], + [16031.32567653, 12721.40098032, 860.40945484]], + + [[16403.58071183, 12579.28522025, 752.85763208], + [16403.51515069, 12594.29996747, 752.85763211], + [16403.44940847, 12609.39656728, 752.85763215], + [16416.42006129, 12741.5559098 , 752.85763252], + [16031.32567956, 12721.40098047, 752.85761081]], + + [[16403.58071183, 12579.28522026, 645.3070247 ], + [16403.5151507 , 12594.29996748, 645.3070247 ], + [16403.44940848, 12609.39656729, 645.3070247 ], + [16416.4200614 , 12741.5559098 , 645.30702474], + [16031.32567834, 12721.40098041, 645.3070247 ]], + + [[16403.58071183, 12579.28522026, 537.75565143], + [16403.5151507 , 12594.29996748, 537.75565103], + [16403.44940849, 12609.3965673 , 537.75565064], + [16416.42006147, 12741.55590979, 537.75564634], + [16031.32567522, 12721.40098026, 537.7558873 ]], + + [[16403.58071183, 12579.28522026, 430.20512896], + [16403.5151507 , 12594.29996748, 430.20512914], + [16403.44940848, 12609.39656729, 430.20512932], + [16416.4200615 , 12741.55590977, 430.20513129], + [16031.32567266, 12721.40098013, 430.20502356]], + + [[16403.58071183, 12579.28522026, 322.65324369], + [16403.5151507 , 12594.29996748, 322.65324369], + [16403.44940848, 12609.39656729, 322.65324369], + [16416.42006149, 12741.55590977, 322.65324369], + [16031.32567204, 12721.4009801 , 322.65324369]], + + [[16403.58071183, 12579.28522026, 179.25255722], + [16403.5151507 , 12594.29996748, 179.25255722], + [16403.44940848, 12609.39656729, 179.25255722], + [16416.42006147, 12741.55590977, 179.25255721], + [16031.32567286, 12721.40098014, 179.25255722]], + + [[16403.58071183, 12579.28522026, 71.70053582], + [16403.5151507 , 12594.29996748, 71.70053582], + [16403.44940848, 12609.39656729, 71.70053582], + [16416.42006146, 12741.55590977, 71.70053582], + [16031.32567336, 12721.40098016, 71.70053582]], + + [[16403.58071183, 12579.28522026, -0. ], + [16403.5151507 , 12594.29996748, -0. ], + [16403.44940848, 12609.39656729, -0. ], + [16416.42006147, 12741.55590977, 0.00000117], + [16031.32567306, 12721.40098015, 0.00000033]]]), + 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., 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., 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., 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., 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.], + [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., 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., 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., 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., 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.], + [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., 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., 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., 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., 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.], + [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., 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., 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., 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., 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.], + [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., 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., 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., 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., 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.], + [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., 1., 1., 1.], + [1., 1., 1., 1., 1.]]) +) +import numpy as np +from mmcore.nurbs._nurbs_eval import NURBSSurfaceTuple + + +val = NURBSSurfaceTuple( + order_u=4, + order_v=3, + knot_u=np.array([0. , 0. , 0. , 0. , 0.00338983, + 0.00677966, 0.01016949, 0.01355932, 0.01694915, 0.02033898, + 0.02372881, 0.02711864, 0.03050847, 0.03389831, 0.03728814, + 0.04067797, 0.0440678 , 0.04745763, 0.05084746, 0.05423729, + 0.05762712, 0.06101695, 0.06440678, 0.06779661, 0.07118644, + 0.07457627, 0.0779661 , 0.08135593, 0.08474576, 0.08813559, + 0.09152542, 0.09491525, 0.09830508, 0.10169492, 0.10508475, + 0.10847458, 0.11186441, 0.11525424, 0.11864407, 0.1220339 , + 0.12542373, 0.12881356, 0.13220339, 0.13559322, 0.13898305, + 0.14237288, 0.14576271, 0.14915254, 0.15254237, 0.1559322 , + 0.15932203, 0.16271186, 0.16610169, 0.16949153, 0.17288136, + 0.17627119, 0.17966102, 0.18305085, 0.18644068, 0.18983051, + 0.19322034, 0.19661017, 0.2 , 0.20338983, 0.20677966, + 0.21016949, 0.21355932, 0.21694915, 0.22033898, 0.22372881, + 0.22711864, 0.23050847, 0.23389831, 0.23728814, 0.24067797, + 0.2440678 , 0.24745763, 0.25084746, 0.25423729, 0.25762712, + 0.26101695, 0.26440678, 0.26779661, 0.27118644, 0.27457627, + 0.2779661 , 0.28135593, 0.28474576, 0.28813559, 0.29152542, + 0.29491525, 0.29830508, 0.30169492, 0.30508475, 0.30847458, + 0.31186441, 0.31525424, 0.31864407, 0.3220339 , 0.32542373, + 0.32881356, 0.33220339, 0.33559322, 0.33898305, 0.34237288, + 0.34576271, 0.34915254, 0.35254237, 0.3559322 , 0.35932203, + 0.36271186, 0.36610169, 0.36949153, 0.37288136, 0.37627119, + 0.37966102, 0.38305085, 0.38644068, 0.38983051, 0.39322034, + 0.39661017, 0.4 , 0.40338983, 0.40677966, 0.41016949, + 0.41355932, 0.41694915, 0.42033898, 0.42372881, 0.42711864, + 0.43050847, 0.43389831, 0.43728814, 0.44067797, 0.4440678 , + 0.44745763, 0.45084746, 0.45423729, 0.45762712, 0.46101695, + 0.46440678, 0.46779661, 0.47118644, 0.47457627, 0.4779661 , + 0.48135593, 0.48474576, 0.48813559, 0.49152542, 0.49491525, + 0.49830508, 0.50169492, 0.50508475, 0.50847458, 0.51186441, + 0.51525424, 0.51864407, 0.5220339 , 0.52542373, 0.52881356, + 0.53220339, 0.53559322, 0.53898305, 0.54237288, 0.54576271, + 0.54915254, 0.55254237, 0.5559322 , 0.55932203, 0.56271186, + 0.56610169, 0.56949153, 0.57288136, 0.57627119, 0.57966102, + 0.58305085, 0.58644068, 0.58983051, 0.59322034, 0.59661017, + 0.6 , 0.60338983, 0.60677966, 0.61016949, 0.61355932, + 0.61694915, 0.62033898, 0.62372881, 0.62711864, 0.63050847, + 0.63389831, 0.63728814, 0.64067797, 0.6440678 , 0.64745763, + 0.65084746, 0.65423729, 0.65762712, 0.66101695, 0.66440678, + 0.66779661, 0.67118644, 0.67457627, 0.6779661 , 0.68135593, + 0.68474576, 0.68813559, 0.69152542, 0.69491525, 0.69830508, + 0.70169492, 0.70508475, 0.70847458, 0.71186441, 0.71525424, + 0.71864407, 0.7220339 , 0.72542373, 0.72881356, 0.73220339, + 0.73559322, 0.73898305, 0.74237288, 0.74576271, 0.74915254, + 0.75254237, 0.7559322 , 0.75932203, 0.76271186, 0.76610169, + 0.76949153, 0.77288136, 0.77627119, 0.77966102, 0.78305085, + 0.78644068, 0.78983051, 0.79322034, 0.79661017, 0.8 , + 0.80338983, 0.80677966, 0.81016949, 0.81355932, 0.81694915, + 0.82033898, 0.82372881, 0.82711864, 0.83050847, 0.83389831, + 0.83728814, 0.84067797, 0.8440678 , 0.84745763, 0.85084746, + 0.85423729, 0.85762712, 0.86101695, 0.86440678, 0.86779661, + 0.87118644, 0.87457627, 0.8779661 , 0.88135593, 0.88474576, + 0.88813559, 0.89152542, 0.89491525, 0.89830508, 0.90169492, + 0.90508475, 0.90847458, 0.91186441, 0.91525424, 0.91864407, + 0.9220339 , 0.92542373, 0.92881356, 0.93220339, 0.93559322, + 0.93898305, 0.94237288, 0.94576271, 0.94915254, 0.95254237, + 0.9559322 , 0.95932203, 0.96271186, 0.96610169, 0.96949153, + 0.97288136, 0.97627119, 0.97966102, 0.98305085, 0.98644068, + 0.98983051, 0.99322034, 0.99661017, 1. , 1. , + 1. , 1. ]), + knot_v=np.array([0. , 0. , 0. , 0.0908581, 0.0908581, 1. , + 1. , 1. ]), + control_points=np.array([[[15442.83215416, 12530.60235064, -0. ], + [15442.68919089, 12545.61337213, -0. ], + [15442.54708923, 12560.44456438, -0. ], + [15430.86534682, 12693.09298193, 0. ], + [15831.55458695, 12711.61014144, -0. ]], + + [[15442.83215416, 12530.60235064, 51.81123569], + [15442.68919089, 12545.61337213, 51.81123569], + [15442.54708923, 12560.44456438, 51.81123569], + [15430.86534682, 12693.09298193, 51.81123569], + [15831.55458694, 12711.61014144, 51.81123569]], + + [[15442.83215416, 12530.60235064, 155.43434724], + [15442.68919089, 12545.61337213, 155.43434724], + [15442.54708923, 12560.44456438, 155.43434724], + [15430.86534682, 12693.09298193, 155.43434724], + [15831.55458696, 12711.61014144, 155.43434724]], + + [[15442.83215416, 12530.60235064, 310.86914276], + [15442.68919089, 12545.61337213, 310.86914276], + [15442.54708923, 12560.44456438, 310.86914276], + [15430.86534682, 12693.09298193, 310.86914276], + [15831.55458698, 12711.61014144, 310.86914276]], + + [[15442.83215416, 12530.60235064, 440.39740549], + [15442.68919089, 12545.61337213, 440.39740545], + [15442.54708923, 12560.44456438, 440.39740541], + [15430.86534682, 12693.09298193, 440.39740489], + [15831.55458689, 12711.61014143, 440.39737605]], + + [[15442.83215416, 12530.60235064, 544.02042644], + [15442.68919089, 12545.61337213, 544.02042715], + [15442.54708923, 12560.44456438, 544.02042785], + [15430.86534682, 12693.09298193, 544.02043656], + [15831.55458678, 12711.61014143, 544.02091577]], + + [[15442.83215416, 12530.60235064, 621.73801787], + [15442.68919089, 12545.61337213, 621.73801787], + [15442.54708923, 12560.44456438, 621.73801787], + [15430.86534682, 12693.09298193, 621.73801787], + [15831.55458674, 12711.61014143, 621.73801787]], + + [[15442.83215416, 12530.60235064, 712.40836087], + [15442.68919089, 12545.61337213, 712.40836103], + [15442.54708923, 12560.44456438, 712.40836119], + [15430.86534682, 12693.09298193, 712.4083632 ], + [15831.55458684, 12711.61014143, 712.40847388]], + + [[15442.83215416, 12530.60235064, 816.03086615], + [15442.68919089, 12545.61337213, 816.03086669], + [15442.54708923, 12560.44456438, 816.03086723], + [15430.86534681, 12693.09298193, 816.03087395], + [15831.55458719, 12711.61014145, 816.03124354]], + + [[15442.83215416, 12530.60235064, 932.60709287], + [15442.68919089, 12545.61337213, 932.60709287], + [15442.54708923, 12560.44456438, 932.60709287], + [15430.86534681, 12693.09298192, 932.60709286], + [15831.55458766, 12711.61014147, 932.60709287]], + + [[15442.83215416, 12530.60235064, 1042.70626925], + [15442.68919089, 12545.61337213, 1042.70626887], + [15442.54708923, 12560.44456438, 1042.70626849], + [15430.86534682, 12693.09298192, 1042.70626381], + [15831.55458765, 12711.61014147, 1042.70600588]], + + [[15442.83215416, 12530.60235064, 1146.32982931], + [15442.68919089, 12545.61337213, 1146.32982876], + [15442.54708923, 12560.44456438, 1146.32982821], + [15430.86534684, 12693.09298193, 1146.32982136], + [15831.55458677, 12711.61014143, 1146.32944385]], + + [[15442.83215416, 12530.60235064, 1243.47585916], + [15442.68919089, 12545.61337213, 1243.47585916], + [15442.54708923, 12560.44456439, 1243.47585916], + [15430.86534684, 12693.09298194, 1243.47585918], + [15831.55458518, 12711.61014135, 1243.47585916]], + + [[15442.83215416, 12530.60235065, 1343.8608654 ], + [15442.68919089, 12545.61337213, 1343.86086518], + [15442.54708922, 12560.44456439, 1343.86086495], + [15430.86534681, 12693.09298194, 1343.8608622 ], + [15831.554584 , 12711.61014129, 1343.8607108 ]], + + [[15442.83215416, 12530.60235065, 1447.4836058 ], + [15442.68919089, 12545.61337213, 1447.4836057 ], + [15442.54708922, 12560.44456439, 1447.4836056 ], + [15430.86534677, 12693.09298193, 1447.48360433], + [15831.55458482, 12711.61014133, 1447.48353697]], + + [[15442.83215416, 12530.60235064, 1554.34514968], + [15442.68919089, 12545.61337213, 1554.34514968], + [15442.54708923, 12560.44456438, 1554.34514968], + [15430.86534678, 12693.09298191, 1554.34514959], + [15831.55458945, 12711.61014156, 1554.34514968]], + + [[15442.83215416, 12530.60235063, 1659.58657826], + [15442.6891909 , 12545.61337212, 1659.58657826], + [15442.54708925, 12560.44456437, 1659.58657825], + [15430.86534684, 12693.09298189, 1659.58657819], + [15831.55459582, 12711.61014187, 1659.58657826]], + + [[15442.83215416, 12530.60235063, 1763.21015693], + [15442.6891909 , 12545.61337212, 1763.21015693], + [15442.54708925, 12560.44456437, 1763.21015693], + [15430.86534686, 12693.09298188, 1763.21015698], + [15831.55459792, 12711.61014197, 1763.21015694]], + + [[15442.83215416, 12530.60235064, 1865.21386362], + [15442.68919089, 12545.61337213, 1865.21386363], + [15442.54708923, 12560.44456439, 1865.21386363], + [15430.86534681, 12693.09298192, 1865.21386386], + [15831.55459006, 12711.61014159, 1865.21386363]], + + [[15442.83215416, 12530.60235066, 1968.02780517], + [15442.68919088, 12545.61337214, 1968.02780478], + [15442.5470892 , 12560.4445644 , 1968.02780439], + [15430.86534683, 12693.09298202, 1968.02779991], + [15831.55456893, 12711.61014055, 1968.02753475]], + + [[15442.83215416, 12530.60235066, 2071.64993322], + [15442.68919087, 12545.61337215, 2071.64993331], + [15442.54708919, 12560.44456441, 2071.6499334 ], + [15430.86534701, 12693.09298211, 2071.64993472], + [15831.5545486 , 12711.61013956, 2071.6499997 ]], + + [[15442.83215416, 12530.60235065, 2176.08316006], + [15442.68919089, 12545.61337213, 2176.08316006], + [15442.54708923, 12560.44456439, 2176.08316005], + [15430.8653472 , 12693.09298208, 2176.08315966], + [15831.55455146, 12711.6101397 , 2176.08316006]], + + [[15442.83215416, 12530.60235065, 2280.11048531], + [15442.68919089, 12545.61337213, 2280.11048509], + [15442.54708923, 12560.44456439, 2280.11048488], + [15430.86534653, 12693.09298182, 2280.11048114], + [15831.55459938, 12711.61014205, 2280.1103346 ]], + + [[15442.83215416, 12530.60235065, 2383.733872 ], + [15442.68919089, 12545.61337213, 2383.73387188], + [15442.54708921, 12560.44456439, 2383.73387176], + [15430.86534544, 12693.09298139, 2383.73386918], + [15831.55468634, 12711.61014631, 2383.73378853]], + + [[15442.83215416, 12530.60235061, 2486.95195857], + [15442.68919089, 12545.6133721 , 2486.95195855], + [15442.54708923, 12560.44456435, 2486.95195854], + [15430.865345 , 12693.09298117, 2486.95195857], + [15831.55474007, 12711.61014894, 2486.95195857]], + + [[15442.83215416, 12530.60235047, 2590.37249292], + [15442.68919104, 12545.61337196, 2590.37249298], + [15442.54708952, 12560.44456419, 2590.37249303], + [15430.8653481 , 12693.09298168, 2590.37249634], + [15831.55468077, 12711.61014604, 2590.3725446 ]], + + [[15442.83215416, 12530.60235042, 2693.99563637], + [15442.68919112, 12545.61337192, 2693.99563665], + [15442.54708968, 12560.44456413, 2693.99563692], + [15430.8653523 , 12693.09298303, 2693.99564459], + [15831.55443271, 12711.61013387, 2693.99581838]], + + [[15442.83215416, 12530.60235074, 2797.82102583], + [15442.68919089, 12545.61337223, 2797.82102591], + [15442.54708923, 12560.44456451, 2797.821026 ], + [15430.8653527 , 12693.09298438, 2797.82102923], + [15831.55411372, 12711.61011824, 2797.82102583]], + + [[15442.83215415, 12530.60235146, 2901.54485334], + [15442.68919019, 12545.61337291, 2901.54485361], + [15442.54708784, 12560.44456533, 2901.54485387], + [15430.86534319, 12693.0929841 , 2901.54485309], + [15831.5540241 , 12711.61011384, 2901.54496659]], + + [[15442.83215415, 12530.60235171, 3005.16811101], + [15442.6891898 , 12545.61337314, 3005.16811092], + [15442.54708706, 12560.4445656 , 3005.16811082], + [15430.86533102, 12693.0929811 , 3005.16809788], + [15831.55448051, 12711.61013622, 3005.16809014]], + + [[15442.83215416, 12530.60235041, 3108.689562 ], + [15442.68919089, 12545.61337189, 3108.68956169], + [15442.54708923, 12560.44456409, 3108.68956139], + [15430.865333 , 12693.09297615, 3108.68954425], + [15831.55562999, 12711.61019257, 3108.689562 ]], + + [[15442.83215418, 12530.60234809, 3212.26400251], + [15442.68919313, 12545.61336969, 3212.26400225], + [15442.54709367, 12560.44456144, 3212.264002 ], + [15430.86535383, 12693.09297308, 3212.26399652], + [15831.55665092, 12711.61024262, 3212.26402777]], + + [[15442.83215419, 12530.60234747, 3315.87826522], + [15442.68919413, 12545.61336911, 3315.87826522], + [15442.54709565, 12560.44456077, 3315.87826522], + [15430.86537457, 12693.09297648, 3315.87828653], + [15831.55636817, 12711.61022876, 3315.87812115]], + + [[15442.83215405, 12530.60236136, 3419.58707436], + [15442.68919078, 12545.61338285, 3419.58707964], + [15442.54708922, 12560.44457766, 3419.58708492], + [15430.86536526, 12693.09298967, 3419.58715083], + [15831.55360885, 12711.61009349, 3419.58710135]], + + [[15442.8321535 , 12530.60241961, 3523.15341169], + [15442.68913626, 12545.61343845, 3523.15372315], + [15442.54698128, 12560.44464454, 3523.15403459], + [15430.86464716, 12693.09297623, 3523.15784211], + [15831.5483954 , 12711.6098379 , 3523.36323058]], + + [[15442.83215315, 12530.60245596, 3626.75896601], + [15442.68907897, 12545.613472 , 3626.75956378], + [15442.54686738, 12560.44468397, 3626.76016156], + [15430.86385462, 12693.09294749, 3626.7676141 ], + [15831.5452437 , 12711.6096834 , 3627.17865447]], + + [[15442.83215368, 12530.60240114, 3730.51055814], + [15442.68918902, 12545.61342255, 3730.51146419], + [15442.54708645, 12560.44462673, 3730.51237024], + [15430.86536678, 12693.09299745, 3730.52346113], + [15831.55056541, 12711.60994429, 3731.14089383]], + + [[15442.83215233, 12530.60254205, 3833.89975367], + [15442.68906627, 12545.6135575 , 3833.90185501], + [15442.54684361, 12560.44478928, 3833.90395631], + [15430.86350579, 12693.09286428, 3833.92949756], + [15831.56213447, 12711.61051144, 3835.33816225]], + + [[15442.83215051, 12530.60273333, 3937.52529713], + [15442.68877282, 12545.61373449, 3937.52851022], + [15442.54626035, 12560.44499747, 3937.53172331], + [15430.85940312, 12693.0926981 , 3937.57203835], + [15831.5594421 , 12711.61037945, 3939.77414131]], + + [[15442.832153 , 12530.60247162, 4041.36607643], + [15442.68915679, 12545.6134915 , 4041.37042267], + [15442.54702332, 12560.4447103 , 4041.37476894], + [15430.86563029, 12693.09327333, 4041.43052914], + [15831.513981 , 12711.60815079, 4044.42765141]], + + [[15442.83215234, 12530.60250534, 4144.83747998], + [15442.689162 , 12545.61352548, 4144.84382177], + [15442.54703472, 12560.44478298, 4144.85016201], + [15430.86759659, 12693.09406316, 4144.92877492], + [15831.37348882, 12711.60126337, 4149.23711139]], + + [[15442.79260442, 12530.60071864, 4248.47864271], + [15442.6490422 , 12545.61171079, 4248.48674394], + [15442.50634447, 12560.44334643, 4248.49483168], + [15430.82098763, 12693.09274439, 4248.57776076], + [15831.09471021, 12711.58759664, 4254.20529959]], + + [[15442.47715567, 12530.58349265, 4352.26390189], + [15442.3350807 , 12545.59455788, 4352.27362122], + [15442.19385374, 12560.42753538, 4352.28329625], + [15430.51625263, 12693.07810618, 4352.33702481], + [15830.66373476, 12711.56646867, 4359.29388841]], + + [[15441.61888152, 12530.5379462 , 4455.82550982], + [15441.47953578, 12545.54914535, 4455.83754163], + [15441.3410062 , 12560.38559865, 4455.84948249], + [15429.66242227, 12693.03486145, 4455.85091645], + [15830.02388718, 12711.53510099, 4464.51982879]], + + [[15440.03274625, 12530.45480085, 4559.53277405], + [15439.89700723, 12545.46617694, 4559.54688388], + [15439.76203631, 12560.30901449, 4559.56085084], + [15428.06101734, 12692.95261552, 4559.49206029], + [15829.12693616, 12711.49112906, 4569.87394469]], + + [[15437.64048468, 12530.32797325, 4663.36857213], + [15437.51254911, 12545.33973204, 4663.38479967], + [15437.38529997, 12560.1918895 , 4663.40083672], + [15425.67747299, 12692.82940933, 4663.2578966 ], + [15827.9780901 , 12711.43480813, 4675.36094181]], + + [[15434.46399583, 12530.16007429, 4766.91472682], + [15434.34580894, 12545.17231109, 4766.93414239], + [15434.22821104, 12560.03689782, 4766.95333179], + [15422.50292789, 12692.66507167, 4766.74826051], + [15826.52679946, 12711.36365995, 4780.98534495]], + + [[15430.55400667, 12529.95462336, 4870.55744152], + [15430.44581655, 12544.96735034, 4870.57991307], + [15430.33811399, 12559.84735771, 4870.60213975], + [15418.56762249, 12692.46156011, 4870.34245761], + [15824.73097111, 12711.27562075, 4886.73847415]], + + [[15425.97795221, 12529.71237155, 4974.30772397], + [15425.88421485, 12544.72580728, 4974.33341885], + [15425.79084726, 12559.62371267, 4974.35886569], + [15414.00011722, 12692.22555892, 4974.05188428], + [15822.6156357 , 12711.17191742, 4992.63313499]], + + [[15420.7844865 , 12529.43785317, 5077.73244186], + [15420.70657679, 12544.4520652 , 5077.7627224 ], + [15420.6289251 , 12559.37035259, 5077.79276769], + [15408.80974853, 12691.95792112, 5077.44828368], + [15820.12118224, 12711.04962727, 5098.67511744]], + + [[15414.95760481, 12529.13158546, 5181.26625204], + [15414.89463923, 12544.1465304 , 5181.30088572], + [15414.83183665, 12559.0877221 , 5181.33531578], + [15402.951576 , 12691.6566831 , 5180.95074891], + [15817.19583739, 12710.90621175, 5204.85470553]], + + [[15408.48653423, 12528.7891537 , 5284.92643422], + [15408.44377009, 12543.80508938, 5284.96567966], + [15408.40108225, 12558.77165527, 5285.00477261], + [15396.49430013, 12691.32523589, 5284.57980897], + [15813.87593374, 12710.74345163, 5311.19322901]], + + [[15401.38869753, 12528.4140519 , 5388.20534216], + [15401.36742263, 12543.43104165, 5388.25099208], + [15401.34616659, 12558.42545512, 5388.29656444], + [15389.40532089, 12690.96214796, 5387.83682609], + [15810.09423369, 12710.55805002, 5417.69947814]], + + [[15393.60752502, 12528.00526364, 5491.60564224], + [15393.60591082, 12543.02321768, 5491.65734342], + [15393.60429398, 12558.04797428, 5491.70907092], + [15381.58933488, 12690.56283257, 5491.20685807], + [15805.79505901, 12710.34727656, 5524.36046204]], + + [[15385.1064559 , 12527.55559442, 5595.1509631 ], + [15385.13113929, 12542.57483821, 5595.20917804], + [15385.15584478, 12557.63292981, 5595.26755771], + [15373.11061045, 12690.13031975, 5594.7203347 ], + [15801.03331795, 12710.11382248, 5631.20988648]], + + [[15375.89962859, 12527.06920859, 5698.22481081], + [15375.95194546, 12542.08980781, 5698.29196919], + [15376.00437029, 12557.18395057, 5698.35947237], + [15363.9208454 , 12689.66231561, 5697.77068939], + [15795.73406484, 12709.85401258, 5738.25766622]], + + [[15365.88809701, 12526.54369366, 5801.43394672], + [15365.96507845, 12541.56550252, 5801.50949858], + [15366.04231048, 12556.69824402, 5801.58562345], + [15353.87121668, 12689.15155179, 5800.94211615], + [15789.82687946, 12709.56439328, 5845.48643998]], + + [[15355.00696419, 12525.96843369, 5904.80893875], + [15355.1172385 , 12540.9918752 , 5904.89369049], + [15355.22801662, 12556.16720379, 5904.97931023], + [15343.02332348, 12688.6008648 , 5904.27649605], + [15783.37351219, 12709.24799097, 5952.94503903]], + + [[15343.2739057 , 12525.34891766, 6007.57749272], + [15343.4189611 , 12540.37406494, 6007.67479889], + [15343.564882 , 12555.5951501 , 6007.77334537], + [15331.3178454 , 12688.0073774 , 6007.01238526], + [15776.27977452, 12708.90018642, 6060.64297933]], + + [[15330.53850221, 12524.68121458, 6110.48791191], + [15330.71386394, 12539.70784781, 6110.59691448], + [15330.89053179, 12554.97725387, 6110.70761143], + [15318.54094881, 12687.36061589, 6109.87028172], + [15768.46143899, 12708.51684845, 6168.55710938]], + + [[15316.72548603, 12523.95868743, 6213.5760213 ], + [15316.94203064, 12538.9873402 , 6213.70120769], + [15317.16044265, 12554.30335082, 6213.82860612], + [15304.77325896, 12686.6643699 , 6212.91253138], + [15759.99468948, 12708.10171078, 6276.76156775]], + + [[15301.97841094, 12523.21884591, 6315.86465698], + [15302.20371013, 12538.24792811, 6316.00689476], + [15302.43108959, 12553.58115179, 6316.15167815], + [15289.99640046, 12685.91783591, 6315.16018323], + [15750.77099045, 12707.64944998, 6385.26645441]], + + [[15286.05747431, 12522.44231264, 6418.31931888], + [15286.27265035, 12537.47089777, 6418.47170867], + [15286.48980708, 12552.80410855, 6418.62683123], + [15273.93511877, 12685.10759984, 6417.53781515], + [15740.69183849, 12707.15523407, 6494.04352704]], + + [[15268.8624685 , 12521.5986933 , 6520.98893882], + [15269.07819874, 12536.62730469, 6521.15125136], + [15269.29588009, 12551.96060346, 6521.3164998 ], + [15256.69927138, 12684.23895063, 6520.13114683], + [15729.84324992, 12706.62327856, 6603.19450845]], + + [[15250.55370002, 12520.70155243, 6622.59118486], + [15250.76808832, 12535.73009724, 6622.76756816], + [15250.98437625, 12551.06312159, 6622.94709905], + [15238.33518637, 12683.31431792, 6621.67024002], + [15718.09383463, 12706.04713847, 6712.72047633]], + + [[15230.84894062, 12519.74339493, 6724.36550306], + [15231.05056983, 12534.77131285, 6724.55218046], + [15231.25393037, 12550.1016667 , 6724.74218244], + [15218.46574169, 12682.31534534, 6723.34589459], + [15705.32186053, 12705.42084185, 6822.58984996]], + + [[15209.66386611, 12518.70476565, 6826.38863584], + [15209.86521013, 12533.73266808, 6826.58678358], + [15210.06823357, 12549.06291337, 6826.78847926], + [15197.23439728, 12681.24903035, 6825.27937375], + [15691.6214074 , 12704.74899931, 6932.94237476]], + + [[15187.28942255, 12517.6090962 , 6927.00159181], + [15187.48809539, 12532.6368663 , 6927.2161595 ], + [15187.68837036, 12547.96657463, 6927.43450534], + [15174.79776627, 12680.12324624, 6925.81914836], + [15676.83415489, 12704.0238443 , 7043.77144491]], + + [[15163.30791451, 12516.44370131, 7027.8131248 ], + [15163.49027381, 12531.47066957, 7028.03892107], + [15163.67402093, 12546.79693577, 7028.26866961], + [15150.62422807, 12678.91209452, 7026.51123185], + [15660.80975147, 12703.23800042, 7155.03816233]], + + [[15137.62681876, 12515.18573616, 7128.94750357], + [15137.80750982, 12530.21262043, 7129.1864556 ], + [15137.98950685, 12545.53845374, 7129.42959566], + [15124.89138906, 12677.62429592, 7127.54374009], + [15643.64115917, 12702.39602337, 7266.9507083 ]], + + [[15110.739693 , 12513.87015728, 7228.19313068], + [15110.91567509, 12528.89680823, 7228.45114506], + [15111.09284994, 12544.22169652, 7228.71359528], + [15097.93678078, 12676.27653902, 7226.7005544 ], + [15625.13461553, 12701.48840763, 7379.47590399]], + + [[15081.92316958, 12512.47107049, 7327.65854538], + [15082.07823205, 12527.49669296, 7327.9284559 ], + [15082.23421158, 12542.81711579, 7328.20297893], + [15068.9029211 , 12674.82679686, 7326.00136882], + [15605.09278327, 12700.50547178, 7492.58690503]], + + [[15050.9720857 , 12510.95659678, 7427.45620664], + [15051.12328155, 12525.9820266 , 7427.74101304], + [15051.27526174, 12541.3014943 , 7428.03070324], + [15037.90279712, 12673.28050771, 7425.65035399], + [15583.5938809 , 12699.45104897, 7606.5539137 ]], + + [[15018.62469537, 12509.37545851, 7524.66012723], + [15018.76816783, 12524.4005061 , 7524.96670727], + [15018.91225219, 12539.71841319, 7525.2784598 ], + [15005.49285745, 12671.66498632, 7522.71076945], + [15560.41045837, 12698.31398136, 7721.2818095 ]], + + [[14983.7790288 , 12507.68540595, 7622.00376764], + [14983.89569987, 12522.7091358 , 7622.32221884], + [14984.01262651, 12538.02122768, 7622.64601846], + [14970.41026881, 12669.91839488, 7619.80984806], + [15535.28770156, 12697.08176909, 7836.76581954]], + + [[14946.20246892, 12505.84912671, 7719.61217087], + [14946.31200946, 12520.87250232, 7719.94699455], + [14946.42160323, 12536.1828435 , 7720.28746036], + [14932.79302574, 12668.04757554, 7717.2063995 ], + [15508.27231451, 12695.75670198, 7953.3670501 ]], + + [[14907.16934931, 12503.94353167, 7813.70651832], + [14907.26693719, 12518.96631692, 7814.06550538], + [14907.36433069, 12534.27420957, 7814.43044017], + [14893.70994579, 12666.10503985, 7811.09189055], + [15479.09883443, 12694.32576038, 8070.8732065 ]], + + [[14865.01589871, 12501.9016069 , 7907.77544447], + [14865.07940358, 12516.92271651, 7908.14494334], + [14865.14227032, 12532.22303491, 7908.52052035], + [14851.30678328, 12664.00008088, 7904.82029797], + [15447.40532753, 12692.77119473, 8189.35004089]], + + [[14819.50770076, 12499.6812736 , 8001.91114432], + [14819.55945967, 12514.70180038, 8002.29768332], + [14819.61029684, 12529.99919108, 8002.69057544], + [14805.77421745, 12661.74233036, 7998.67395509], + [15413.17228058, 12691.09205455, 8309.21697629]], + + [[14772.71844018, 12497.40046772, 8091.45048361], + [14772.75269441, 12512.4201318 , 8091.86248764], + [14772.78569731, 12527.71384334, 8092.28112263], + [14758.96215556, 12659.42261408, 8087.93063777], + [15376.10933763, 12689.27410948, 8430.03215466]], + + [[14722.19263912, 12494.95677066, 8180.63738326], + [14722.1844141 , 12509.97434696, 8181.05627849], + [14722.1744214 , 12525.25828601, 8181.4818245 ], + [14708.18977187, 12656.90983187, 8176.66697653], + [15335.69091168, 12687.29159823, 8551.99341384]], + + [[14667.77624115, 12492.30695359, 8269.47194291], + [14667.75032046, 12507.32365254, 8269.90684685], + [14667.72227956, 12522.60304177, 8270.34858884], + [14653.7720707 , 12654.21993062, 8265.15491758], + [15291.82357627, 12685.13996717, 8675.49388784]], + + [[14612.70374584, 12489.62724829, 8352.63469542], + [14612.65385692, 12504.64276784, 8353.09415521], + [14612.60147161, 12519.91692089, 8353.56061614], + [14598.7025032 , 12651.49981562, 8347.9914834 ], + [15244.25681207, 12682.80697086, 8799.66822202]], + + [[14553.76797231, 12486.78228046, 8435.04468842], + [14553.66724242, 12501.79530163, 8435.50422923], + [14553.56351613, 12517.05719627, 8435.97056773], + [14539.50050897, 12648.579506 , 8429.90080352], + [15192.14998157, 12680.25144114, 8925.02529886]], + + [[14491.46136072, 12483.75542611, 8516.47017907], + [14491.33642162, 12498.76724896, 8516.94182873], + [14491.20815106, 12514.02263842, 8517.42026528], + [14477.1648802 , 12645.50857338, 8510.99604163], + [15135.57192922, 12677.47684309, 9051.2693846 ]], + + [[14429.20299074, 12480.73198475, 8592.57760412], + [14429.04921043, 12495.742387 , 8593.06896824], + [14428.89176588, 12510.99106184, 8593.56710108], + [14414.90735419, 12642.44423175, 8586.78924596], + [15074.52261009, 12674.48329198, 9176.87678987]], + + [[14363.69450707, 12477.57570861, 8667.28047068], + [14363.4859127 , 12492.58341805, 8667.76210035], + [14363.27326935, 12507.81794626, 8668.25008741], + [14349.13338913, 12639.21095551, 8660.95680067], + [15007.90181835, 12671.2170031 , 9302.52384991]], + + [[14296.24530232, 12474.307181 , 8739.8043443 ], + [14296.00869648, 12489.31350866, 8740.2908311 ], + [14295.76778505, 12504.53988617, 8740.78349388], + [14281.6460737 , 12635.89736495, 8733.14638852], + [14936.53321106, 12667.71857187, 9426.64113069]], + + [[14227.92183462, 12470.9942578 , 8808.9579554 ], + [14227.65667756, 12485.99917705, 8809.45575879], + [14227.38697516, 12501.21819739, 8809.95959947], + [14213.36286334, 12632.54813337, 8801.97219262], + [14860.56292145, 12663.99541528, 9547.8038307 ]], + + [[14158.14202701, 12467.63448512, 8875.00943575], + [14157.82734326, 12482.63697332, 8875.48898101], + [14157.50789615, 12497.84202276, 8875.97408538], + [14143.40958425, 12629.1209132 , 8867.4620853 ], + [14779.48302262, 12660.02296002, 9665.68790777]], + + [[14088.03167269, 12464.24309652, 8937.88512951], + [14087.69074034, 12479.24429419, 8938.36225472], + [14087.3448998 , 12494.44084423, 8938.84468035], + [14073.30761613, 12625.69010919, 8929.99139158], + [14694.13652238, 12655.84288696, 9779.59354648]], + + [[14016.60689236, 12460.78536808, 8998.36112001], + [14016.24010925, 12475.78529094, 8998.84178778], + [14015.86828139, 12490.97443455, 8999.3275577 ], + [14001.98576205, 12622.20306418, 8990.12454839], + [14604.34999972, 12651.44713568, 9889.42432355]], + + [[13944.95335589, 12457.33739879, 9055.08044063], + [13944.54554585, 12472.33531057, 9055.53815616], + [13944.13262478, 12487.51166247, 9056.00051393], + [13930.2785658 , 12618.70077555, 9046.28719288], + [14510.2653738 , 12646.84316396, 9994.61165269]], + + [[13874.01549562, 12453.91145627, 9108.36854673], + [13873.58533073, 12468.90827212, 9108.81894935], + [13873.15001247, 12484.07652618, 9109.27372337], + [13859.4157785 , 12615.24314546, 9099.22772737], + [14412.81696265, 12642.07724063, 10095.10589251]], + + [[13801.16072714, 12450.39047766, 9160.04686388], + [13800.70855759, 12465.38621012, 9160.49512371], + [13800.25118977, 12480.54743746, 9160.94754212], + [13786.72888545, 12611.69985982, 9150.56211833], + [14312.06260087, 12637.15277572, 10190.96475101]], + + [[13727.80225569, 12446.86250631, 9208.6301291 ], + [13727.31794397, 12461.85666589, 9209.05386906], + [13726.82848351, 12477.00668626, 9209.48136449], + [13713.4159805 , 12608.12967045, 9198.62873886], + [14208.55910913, 12632.09760449, 10281.68390757]], + + [[13654.33097903, 12443.31891419, 9254.91602962], + [13653.8289196 , 12458.31220525, 9255.32999625], + [13653.32175085, 12473.45496216, 9255.74746929], + [13640.06295126, 12604.56120233, 9244.59717145], + [14103.02843573, 12626.94759983, 10367.64059128]], + + [[13577.87044304, 12439.62962014, 9300.49217327], + [13577.35057614, 12454.62203579, 9300.90065784], + [13576.82562873, 12469.7584377 , 9301.31244804], + [13563.80409911, 12600.85507725, 9289.8594897 ], + [13995.24180581, 12621.69223398, 10449.20147325]], + + [[13500.46476293, 12435.9096552 , 9343.60395469], + [13499.92036203, 12450.90087219, 9343.98849376], + [13499.37100627, 12466.02765793, 9344.37600321], + [13486.50301855, 12597.1023792 , 9332.51802389], + [13885.82695791, 12616.36277157, 10525.98765643]], + + [[13422.24605173, 12432.1429961 , 9385.17366423], + [13421.68789231, 12447.13354066, 9385.54760201], + [13421.12487074, 12462.25396289, 9385.92429673], + [13408.42331227, 12593.31618329, 9373.81168879], + [13775.26342205, 12610.98318817, 10598.52591912]], + + [[13340.03478212, 12428.18397406, 9426.60792453], + [13339.46258668, 12443.17382957, 9426.97474169], + [13338.88560658, 12458.28864049, 9427.34414086], + [13326.42528372, 12589.34446566, 9414.97335364], + [13663.36633677, 12605.54511739, 10667.1422229 ]], + + [[13255.90946332, 12424.14628166, 9466.29941466], + [13255.31875606, 12439.13523423, 9466.64399671], + [13254.72343113, 12454.24187104, 9466.99089188], + [13242.43190278, 12585.28116451, 9454.28020518], + [13550.72639766, 12600.07788706, 10731.60060299]], + + [[13169.69288716, 12420.00332601, 9505.25130335], + [13169.09160916, 12434.99176257, 9505.58547618], + [13168.48583553, 12450.09289613, 9505.92178741], + [13156.35576792, 12581.12263182, 9493.00082739], + [13437.58584327, 12594.59381974, 10792.44537107]], + + [[13077.87826037, 12415.59337291, 9544.71574309], + [13077.2660279 , 12430.58127237, 9545.04223954], + [13076.64940591, 12445.67750078, 9545.37072864], + [13064.74299177, 12576.70265677, 9532.238395 ], + [13323.68726354, 12589.08102125, 10849.9722386 ]], + + [[12982.22914879, 12411.01278645, 9583.36404526], + [12981.60288799, 12426.00000294, 9583.67039403], + [12980.97242339, 12441.08925845, 9583.97852319], + [12969.22323291, 12572.10121969, 9570.56866328], + [13209.49961749, 12583.56276362, 10904.04194597]], + + [[12882.10658021, 12406.21663119, 9622.29857134], + [12881.47203126, 12421.20344372, 9622.59516332], + [12880.83341489, 12436.28791691, 9622.89339523], + [12869.22169866, 12567.29188917, 9609.31638942], + [13095.13929321, 12578.04518982, 10955.13642806]], + + [[12776.30515081, 12401.15411818, 9661.64835986], + [12775.66189519, 12416.1405046 , 9661.93734296], + [12775.01469246, 12431.22067596, 9662.22784754], + [12763.59849503, 12562.22107416, 9648.48156765], + [12980.41708739, 12572.51970493, 11003.46534 ]], + + [[12669.44614603, 12396.0565937 , 9699.14196104], + [12668.791974 , 12411.04245048, 9699.41293409], + [12668.13404196, 12426.11663152, 9699.68526372], + [12656.87290636, 12557.10700568, 9685.70707428], + [12865.68639961, 12567.00384812, 11048.95253091]], + + [[12561.99997489, 12390.9343273 , 9735.4317427 ], + [12561.33920214, 12405.91986418, 9735.69376386], + [12560.67480506, 12420.98989691, 9735.95703207], + [12549.55871464, 12551.97493127, 9721.8351521 ], + [12751.00416381, 12561.50082321, 11091.98841616]], + + [[12451.63810623, 12385.68172303, 9770.98890668], + [12450.97029212, 12400.66691731, 9771.24370141], + [12450.29897373, 12415.73320339, 9771.49965038], + [12439.38500766, 12546.71693277, 9757.2311016 ], + [12636.19498987, 12556.00269756, 11132.72322935]], + + [[12340.99020153, 12380.43219008, 9804.51241138], + [12340.31380914, 12395.41697107, 9804.75116709], + [12339.63409071, 12410.47810561, 9804.9909505 ], + [12328.88616045, 12541.45493197, 9790.522798 ], + [12521.52793804, 12550.52282598, 11171.10946958]], + + [[12230.40878621, 12375.19209909, 9836.67736074], + [12229.7271095 , 12390.17662682, 9836.90797885], + [12229.04223612, 12405.23416508, 9837.13954005], + [12218.44561874, 12536.20767791, 9822.54734974], + [12407.01252188, 12545.0620896 , 11207.47017965]], + + [[12117.84143077, 12369.8687889 , 9867.80083341], + [12117.15397664, 12384.8530388 , 9868.02471712], + [12116.46344032, 12399.90732198, 9868.24947204], + [12106.06962236, 12530.88097492, 9853.53146116], + [12292.50332033, 12539.61399898, 11241.9104912 ]], + + [[12005.55328735, 12364.57488203, 9896.86816784], + [12004.85908624, 12379.55881137, 9897.07776103], + [12004.16196592, 12394.60868341, 9897.28812928], + [11993.93951956, 12525.57776673, 9882.39839586], + [12178.20349137, 12534.18865285, 11274.40090876]], + + [[11893.77882235, 12359.31006805, 9924.54302882], + [11893.08053456, 12374.29380581, 9924.74525868], + [11892.3794427 , 12389.34062559, 9924.94819994], + [11882.31180961, 12520.30813769, 9909.95153295], + [12064.09113281, 12528.78513758, 11305.20688412]], + + [[11780.76230291, 12353.99323157, 9951.00855219], + [11780.05946382, 12368.97675405, 9951.20455061], + [11779.35392132, 12384.02081889, 9951.40120637], + [11769.48793148, 12514.98984438, 9936.29677393], + [11950.04768253, 12523.39630429, 11334.39776753]], + + [[11668.46743884, 12348.72015003, 9975.47762233], + [11667.75941182, 12363.70342902, 9975.6608197 ], + [11667.04882523, 12378.74373617, 9975.84460189], + [11657.35315565, 12509.7098142 , 9960.59267922], + [11836.21965697, 12518.02691025, 11361.95397564]], + + [[11557.21811853, 12343.49579971, 9998.58801233], + [11556.50701982, 12358.47893674, 9998.76453736], + [11555.79346229, 12373.51664693, 9998.94159916], + [11546.24304271, 12504.48175086, 9983.59972382], + [11722.58835995, 12512.67390926, 11388.08905912]], + + [[11445.54625802, 12338.25228797, 10020.48224398], + [11444.83158768, 12353.23525685, 10020.6530247 ], + [11444.11454761, 12368.270601 , 10020.82430094], + [11434.74456361, 12499.23707571, 10005.39379497], + [11609.06765579, 12507.33110897, 11412.84426389]], + + [[11335.00450498, 12333.06516002, 10040.62655851], + [11334.28586659, 12348.04794286, 10040.78581851], + [11333.56498693, 12363.08003159, 10040.94551849], + [11324.34230628, 12494.04367411, 10025.3913477 ], + [11495.76842532, 12502.00161073, 11436.20359211]], + + [[11225.68860773, 12327.92984305, 10059.65682919], + [11224.96770965, 12342.91252112, 10059.81003037], + [11224.24465978, 12357.9423586 , 10059.96363472], + [11215.14299516, 12488.90470353, 10044.33574456], + [11382.66714728, 12496.68220762, 11458.34561035]], + + [[11116.32881883, 12322.78760043, 10077.64473042], + [11115.60513947, 12337.77014605, 10077.79263034], + [11114.87938823, 12352.79791155, 10077.94090151], + [11105.93143396, 12483.76108652, 10062.24038122], + [11269.68768238, 12491.36723931, 11479.29694168]], + + [[11008.17455934, 12317.69944727, 10094.1814323 ], + [11007.44788671, 12332.68185048, 10094.31889085], + [11006.71925819, 12347.70674875, 10094.45667769], + [10997.89482024, 12478.66697741, 10078.65132402], + [11156.91516263, 12486.05865034, 11499.04356491]], + + [[10901.26558667, 12312.65926986, 10109.84515148], + [10900.53731474, 12327.64159681, 10109.97707966], + [10899.80716744, 12342.66450932, 10110.10930765], + [10891.08065802, 12473.62305638, 10094.24277091], + [11044.32470727, 12480.75323078, 11517.73616046]], + + [[10794.53276768, 12307.61757618, 10124.65080063], + [10793.8023497 , 12322.59979818, 10124.77782162], + [10793.07012914, 12337.62086273, 10124.90511748], + [10784.47226796, 12468.57967941, 10108.97827025], + [10931.86098743, 12475.44634778, 11535.38740453]], + + [[10688.98825106, 12302.62421688, 10138.27306814], + [10688.25560599, 12317.60633025, 10138.39055708], + [10687.52126446, 12332.62482938, 10138.50828744], + [10679.02444415, 12463.58048704, 10122.4913403 ], + [10819.58787751, 12470.13900827, 11551.98319805]], + + [[10584.640607 , 12297.67252645, 10151.23488663], + [10583.90688156, 12312.65458614, 10151.34729782], + [10583.17153327, 12327.67130544, 10151.45992825], + [10574.7518064 , 12458.62489705, 10135.39145277], + [10707.48264475, 12464.82815339, 11567.65204774]], + + [[10480.05932473, 12292.69542805, 10163.56743901], + [10479.32394102, 12307.67740445, 10163.67528959], + [10478.58700169, 12322.69245028, 10163.78333989], + [10470.27610906, 12453.64590962, 10147.66371456], + [10595.4989133 , 12459.50960856, 11582.39856361]], + + [[10375.71340958, 12287.71677103, 10175.00611054], + [10374.97636104, 12302.6986642 , 10175.1051983 ], + [10374.23785497, 12317.7113865 , 10175.20445974], + [10366.01531657, 12448.66181294, 10159.00545546], + [10483.68714518, 12454.18380969, 11596.20791462]], + + [[10271.61985416, 12282.73059181, 10185.97989685], + [10270.88209435, 12297.712448 , 10186.07429432], + [10270.1429448 , 12312.72355831, 10186.1688483 ], + [10261.99111792, 12443.67214233, 10169.92450858], + [10372.02505799, 12448.84775646, 11609.19127377]], + + [[10167.03143919, 12277.70158426, 10196.39625661], + [10166.29240479, 12292.68337515, 10196.48638766], + [10165.55204344, 12307.69295328, 10196.57665998], + [10157.50286347, 12438.64157584, 10180.28717733], + [10260.47742816, 12443.49782865, 11621.34769932]], + + [[10062.66002872, 12272.66513527, 10205.98725502], + [10061.91976512, 12287.64686365, 10206.06926499], + [10061.17826648, 12302.65431188, 10206.15139603], + [10053.21310298, 12433.60030638, 10189.79048951], + [10149.08377175, 12438.13400008, 11632.662163 ]], + + [[ 9958.51455052, 12267.61534136, 10215.15122462], + [ 9957.77383539, 12282.59704564, 10215.22887506], + [ 9957.03194887, 12297.60301165, 10215.30663332], + [ 9949.13359714, 12428.54741662, 10198.90530232], + [10037.82318277, 12432.75336223, 11643.23183896]], + + [[ 9853.97046264, 12262.52230485, 10223.78364665], + [ 9853.22876833, 12277.50395905, 10223.85727768], + [ 9852.48596271, 12292.50849764, 10223.93100466], + [ 9844.68399747, 12423.45302574, 10207.48955981], + [ 9926.66866842, 12427.35270654, 11653.05177587]], + + [[ 9749.62541866, 12257.41602908, 10231.65500475], + [ 9748.88287053, 12272.39764016, 10231.72103921], + [ 9748.13929894, 12287.40019804, 10231.78715409], + [ 9740.41635542, 12418.34236165, 10215.28090829], + [ 9815.64938949, 12421.9315602 , 11662.10622797]], + + [[ 9645.48672661, 12252.29089184, 10239.13820243], + [ 9644.74396088, 12267.27249198, 10239.20015102], + [ 9644.0002329 , 12282.27366654, 10239.26216989], + [ 9636.33950338, 12413.2143863 , 10222.71966665], + [ 9704.74824742, 12416.48721558, 11670.480248 ]], + + [[ 9541.02839729, 12247.12133204, 10246.12085969], + [ 9540.28489337, 12262.1028962 , 10246.17899362], + [ 9539.54048612, 12277.10271878, 10246.23718868], + [ 9531.96950637, 12408.04356519, 10229.6588224 ], + [ 9593.94197916, 12411.01665409, 11678.16590326]], + + [[ 9436.75618288, 12241.93305116, 10252.40245946], + [ 9436.01213343, 12256.91459 , 10252.45342157], + [ 9435.26726644, 12271.91254322, 10252.50443308], + [ 9427.76981778, 12402.85117729, 10235.86675118], + [ 9483.2554048 , 12405.51923641, 11685.1467967 ]], + + [[ 9332.65195838, 12236.71944227, 10258.33575261], + [ 9331.90791879, 12251.70098469, 10258.38284837], + [ 9331.16312201, 12266.6976277 , 10258.42998596], + [ 9323.72414646, 12397.63494528, 10241.75946063], + [ 9372.67284333, 12399.99233402, 11691.49877958]], + + [[ 9228.21721998, 12231.45578889, 10263.79300196], + [ 9227.47265712, 12246.43730982, 10263.83644406], + [ 9226.72739638, 12261.43265093, 10263.87992118], + [ 9219.37532486, 12392.37023129, 10247.17685999], + [ 9262.17352944, 12394.43305262, 11697.21137768]], + + [[ 9123.86897526, 12226.16352113, 10268.57625544], + [ 9123.12400266, 12241.14502766, 10268.61286701], + [ 9122.37841783, 12256.13857039, 10268.64950514], + [ 9115.10000301, 12387.07429164, 10251.89131465], + [ 9151.77840728, 12388.84059922, 11702.2681632 ]], + + [[ 9019.60032691, 12220.83671834, 10273.00239692], + [ 9018.85529167, 12235.81822953, 10273.03532606], + [ 9018.1097054 , 12250.81049381, 10273.06827644], + [ 9010.89143891, 12381.74523976, 10256.2797807 ], + [ 9041.47187945, 12383.21234913, 11706.73807216]], + + [[ 8915.05060829, 12215.45714362, 10276.92870239], + [ 8914.30491562, 12230.43863179, 10276.95810592], + [ 8913.55873346, 12245.42959927, 10276.98752593], + [ 8906.42782605, 12376.36486402, 10260.16908734], + [ 8931.23768679, 12377.54562952, 11710.60833295]], + + [[ 8810.57108717, 12210.04310452, 10280.17414816], + [ 8809.82486188, 12225.02457775, 10280.19697838], + [ 8809.07823483, 12240.01377056, 10280.21981952], + [ 8802.02180118, 12370.94751385, 10263.34956393], + [ 8821.09411674, 12371.83954666, 11713.86166141]], + + [[ 8706.14903406, 12204.58851007, 10283.0439146 ], + [ 8705.40248972, 12219.56998108, 10283.06320037], + [ 8704.65560758, 12234.55789073, 10283.08249373], + [ 8697.65996254, 12365.49092961, 10266.18405452], + [ 8711.02355987, 12366.09136268, 11716.55962075]], + + [[ 8601.50070682, 12199.07873664, 10285.39072737], + [ 8600.75325806, 12214.06017864, 10285.40656385], + [ 8600.00553728, 12229.0467634 , 10285.42240515], + [ 8593.0970165 , 12359.98053195, 10268.49617281], + [ 8601.01581057, 12360.29870901, 11718.68492893]], + + [[ 8496.91321362, 12193.52899547, 10287.04690112], + [ 8496.16507947, 12208.51042184, 10287.05632603], + [ 8495.41676541, 12223.49522092, 10287.06575299], + [ 8488.58322125, 12354.42776365, 10270.09026598], + [ 8491.08555905, 12354.46053332, 11720.21903059]], + + [[ 8392.36190851, 12187.93263793, 10288.31037383], + [ 8391.61321242, 12202.91405714, 10288.31634575], + [ 8390.86440511, 12217.89754352, 10288.32231843], + [ 8384.09221755, 12348.82964057, 10271.32041521], + [ 8381.20946311, 12348.57377058, 11721.22185319]], + + [[ 8287.65209881, 12182.27953443, 10289.02802271], + [ 8286.90224604, 12197.26091973, 10289.03055794], + [ 8286.15235435, 12212.24302219, 10289.03309322], + [ 8279.46745924, 12343.17603736, 10272.00504834], + [ 8271.36606072, 12342.63541921, 11721.67664158]], + + [[ 8182.99676497, 12176.58120286, 10289.04139519], + [ 8182.24605022, 12191.56257269, 10289.03754964], + [ 8181.49539612, 12206.54283548, 10289.03370428], + [ 8174.8856358 , 12337.47478168, 10271.95796178], + [ 8161.58620311, 12336.64530676, 11721.56559769]], + + [[ 8078.31377614, 12170.82782959, 10288.64971968], + [ 8077.56219514, 12185.80918651, 10288.64243766], + [ 8076.81075104, 12200.78806667, 10288.63515675], + [ 8070.26150686, 12331.71959186, 10271.5342043 ], + [ 8051.78547673, 12330.59694887, 11720.93929023]], + + [[ 7973.67838037, 12165.02413137, 10287.69722825], + [ 7972.92534564, 12180.00544995, 10287.686346 ], + [ 7972.17253103, 12194.98282234, 10287.67546656], + [ 7965.70925517, 12325.91510917, 10270.54873386], + [ 7941.92945947, 12324.48647717, 11719.76289395]], + + [[ 7869.00966113, 12159.16558436, 10286.03656931], + [ 7868.2555803 , 12174.14689044, 10286.01901314], + [ 7867.50183245, 12189.1222824 , 10286.00146336], + [ 7861.11033889, 12320.05311422, 10268.82617802], + [ 7832.02014073, 12318.31199866, 11718.0030417 ]], + + [[ 7764.23771302, 12153.24262212, 10283.98450772], + [ 7763.48208307, 12168.22389068, 10283.9633592 ], + [ 7762.72687392, 12183.19775476, 10283.94222074], + [ 7756.38804902, 12314.12754638, 10266.74168016], + [ 7722.02728192, 12312.06990903, 11715.70402653]], + + [[ 7659.58434754, 12147.26986685, 10281.37645081], + [ 7658.82593507, 12162.25103277, 10281.35142466], + [ 7658.06804029, 12177.2231719 , 10281.32641344], + [ 7651.804942 , 12308.15297268, 10264.0997014 ], + [ 7611.97298854, 12305.76215149, 11712.82925093]], + + [[ 7554.87957068, 12141.24171198, 10278.05560183], + [ 7554.11824712, 12156.2227646 , 10278.02339728], + [ 7553.35756934, 12171.19265134, 10277.99121561], + [ 7547.15087219, 12302.11994941, 10260.71355711], + [ 7501.85509736, 12299.39173669, 11709.34444791]], + + [[ 7450.05268803, 12135.1526403 , 10274.36287459], + [ 7449.28722199, 12150.133504 , 10274.32681198], + [ 7448.52250029, 12165.10159876, 10274.29077931], + [ 7442.34801858, 12296.02671084, 10256.98702953], + [ 7391.65718422, 12292.96093922, 11705.29833486]], + + [[ 7345.3923675 , 12129.02511738, 10270.11929769], + [ 7344.62153098, 12144.00572085, 10270.07895139], + [ 7343.85154753, 12158.97179629, 10270.03864382], + [ 7337.73565957, 12289.89587061, 10252.70749756], + [ 7281.37564755, 12286.47277308, 11700.6474025 ]], + + [[ 7240.68251221, 12122.85104729, 10265.15256393], + [ 7239.90660002, 12137.8314003 , 10265.10430968], + [ 7239.13168309, 12152.79491518, 10265.05610747], + [ 7233.0555594 , 12283.71523767, 10247.66998209], + [ 7171.01151878, 12279.93053704, 11695.35088777]], + + [[ 7135.80776226, 12116.62173251, 10259.83829505], + [ 7135.02517673, 12131.6017326 , 10259.78581973], + [ 7134.24369357, 12146.5631971 , 10259.7334075 ], + [ 7128.17933018, 12277.47995976, 10242.31964279], + [ 7060.54114175, 12273.33606888, 11689.45894014]], + + [[ 7031.15147557, 12110.36605164, 10253.97931797], + [ 7030.36078922, 12125.34561139, 10253.92203989], + [ 7029.57132366, 12140.30475792, 10253.86483826], + [ 7023.5481074 , 12271.21915682, 10236.42180693], + [ 6949.98069029, 12266.69358737, 11682.92132414]], + + [[ 6926.26770819, 12104.0619673 , 10247.36751651], + [ 6925.46961685, 12119.04111794, 10247.30134737], + [ 6924.67290215, 12133.99736184, 10247.23527447], + [ 6918.67213491, 12264.90639044, 10229.73145958], + [ 6839.32741863, 12260.00620672, 11675.69112205]], + + [[ 6820.76021375, 12097.68284575, 10240.40355001], + [ 6819.95309806, 12112.66148028, 10240.33265342], + [ 6819.14747377, 12127.61541077, 10240.26186878], + [ 6813.1400562 , 12258.51936247, 10222.72791651], + [ 6728.55925205, 12253.27587744, 11667.82428452]], + + [[ 6715.08588703, 12091.26247878, 10232.85772637], + [ 6714.26847309, 12106.24051723, 10232.78135559], + [ 6713.4526778 , 12121.19184797, 10232.70511555], + [ 6707.47492911, 12252.0922732 , 10215.13926608], + [ 6617.68553389, 12246.50646547, 11659.26159572]], + + [[ 6608.90284928, 12084.78472757, 10224.49185248], + [ 6608.07589034, 12099.7622054 , 10224.40532798], + [ 6607.2507198 , 12114.71027093, 10224.31896198], + [ 6601.28094549, 12245.60370718, 10206.68470456], + [ 6506.70419483, 12239.70116645, 11649.94791297]], + + [[ 6502.0001314 , 12078.23315963, 10215.77668935], + [ 6501.16091118, 12093.20990163, 10215.68476028], + [ 6500.32359984, 12108.15534331, 10215.59301069], + [ 6494.32255118, 12239.04179893, 10197.92501303], + [ 6395.57905953, 12232.8610911 , 11639.94443423]], + + [[ 6394.92939653, 12071.64842031, 10206.45587877], + [ 6394.07492421, 12086.62423921, 10206.35751704], + [ 6393.22249318, 12101.56666264, 10206.25935943], + [ 6387.22013389, 12232.44732869, 10188.55514438], + [ 6284.33225166, 12225.9909489 , 11629.18086783]], + + [[ 6287.42135397, 12065.01935548, 10196.25742508], + [ 6286.55438603, 12079.99441193, 10196.14730358], + [ 6285.6896422 , 12094.93313789, 10196.03742351], + [ 6279.67647014, 12225.8048737 , 10178.25502724], + [ 6172.97709518, 12219.0949445 , 11617.59283636]], + + [[ 6179.37963345, 12058.33590673, 10185.73005389], + [ 6178.50155583, 12073.31028037, 10185.61385671], + [ 6177.62582986, 12088.2462924 , 10185.49792864], + [ 6171.58924503, 12219.11100363, 10167.67838824], + [ 6061.47926104, 12212.17435143, 11605.2492547 ]], + + [[ 6071.39072691, 12051.64167757, 10174.60770402], + [ 6070.50077076, 12066.61531904, 10174.4846898 ], + [ 6069.61330214, 12081.54840464, 10174.36197604], + [ 6063.60502886, 12212.40880663, 10156.50645163], + [ 5949.86889201, 12205.23446233, 11592.07126107]], + + [[ 5962.89697987, 12044.90838956, 10162.54764397], + [ 5961.99681011, 12059.88139746, 10162.41172205], + [ 5961.09932182, 12074.81067883, 10162.27614918], + [ 5955.10094002, 12205.66268365, 10144.33648545], + [ 5838.13581063, 12198.2780928 , 11577.97997444]], + + [[ 5853.91400567, 12038.13246783, 10150.15693373], + [ 5853.00593306, 12053.10498352, 10150.0142707 ], + [ 5852.10067846, 12068.03153152, 10149.87199326], + [ 5846.10426835, 12198.87742917, 10131.89230611], + [ 5726.24302295, 12191.30640707, 11563.0467488 ]], + + [[ 5745.12488066, 12031.36392281, 10137.16839974], + [ 5744.21104327, 12046.33607884, 10137.01857019], + [ 5743.30016639, 12061.25987945, 10136.86916685], + [ 5737.3861855 , 12192.10426268, 10118.85495098], + [ 5614.23511635, 12184.32567654, 11547.18355804]], + + [[ 5635.48271106, 12024.54430495, 10123.10121671], + [ 5634.56541575, 12039.5162441 , 10122.93786476], + [ 5633.65128382, 12054.43640323, 10122.77500115], + [ 5627.81383249, 12185.27577971, 10104.67673595], + [ 5502.0999829 , 12177.33866564, 11530.30045837]], + + [[ 5524.72817384, 12017.65242684, 10108.48589468], + [ 5523.80529006, 12032.62401651, 10108.31545186], + [ 5522.88570726, 12047.5414507 , 10108.14554285], + [ 5517.09692116, 12178.3772181 , 10090.01085061], + [ 5389.76931228, 12170.34466599, 11512.46790455]], + + [[ 5413.89423354, 12010.7609812 , 10092.9728349 ], + [ 5412.96431398, 12025.73213061, 10092.79406315], + [ 5412.03783882, 12040.64656816, 10092.61587585], + [ 5406.36498712, 12171.4823842 , 10074.44782971], + [ 5277.28990795, 12163.3501714 , 11493.57862664]], + + [[ 5302.2127533 , 12003.82950278, 10076.04457361], + [ 5301.27767267, 12018.80033058, 10075.85024861], + [ 5300.34624442, 12033.71074303, 10075.65658601], + [ 5294.77784579, 12164.54238862, 10057.39879628], + [ 5164.65774411, 12156.35853725, 11473.52908286]], + + [[ 5189.5468475 , 11996.84357292, 10058.31052219], + [ 5188.60580963, 12011.81403309, 10058.10841523], + [ 5187.66855361, 12026.72164509, 10057.90702423], + [ 5182.18578353, 12157.55169518, 10039.61710873], + [ 5051.81335185, 12149.3697203 , 11452.39896731]], + + [[ 5077.10276158, 11989.88734141, 10039.40885246], + [ 5076.15319771, 12004.85727328, 10039.19740905], + [ 5075.20754506, 12019.76180108, 10038.98673919], + [ 5069.87806872, 12150.5940609 , 10020.66984607], + [ 4938.8303766 , 12142.39195942, 11430.06197463]], + + [[ 4963.99483224, 11982.91456483, 10018.72331609], + [ 4963.03925331, 11997.88413011, 10018.49410088], + [ 4962.08778872, 12012.78437107, 10018.26575287], + [ 4956.90471806, 12143.61418671, 9999.85648149], + [ 4825.6977881 , 12135.42826606, 11406.38723351]], + + [[ 4850.30659235, 11975.92345877, 9996.95409589], + [ 4849.34830353, 11990.89286884, 9996.71629044], + [ 4848.39424856, 12005.79040641, 9996.47941524], + [ 4843.36492239, 12136.62219649, 9978.04536419], + [ 4712.3391975 , 12128.47769015, 11381.45637522]], + + [[ 4737.42030095, 11969.00809897, 9973.80946696], + [ 4736.45742585, 11983.97723383, 9973.56195723], + [ 4735.4988928 , 11998.87197599, 9973.3154408 ], + [ 4730.69885456, 12129.71038471, 9954.86998055], + [ 4598.86397985, 12121.55071036, 11355.13396016]], + + [[ 4624.20880616, 11962.10886497, 9948.55586292], + [ 4623.24237899, 11977.07779632, 9948.2891532 ], + [ 4622.28047895, 11991.96831182, 9948.02354359], + [ 4617.69328798, 12122.80758862, 9929.49383308], + [ 4485.25674643, 12114.65015088, 11327.25949877]], + + [[ 4511.79381494, 11955.28650861, 9922.16227021], + [ 4510.83195601, 11970.25573964, 9921.88666362], + [ 4509.87473275, 11985.14382374, 9921.61223167], + [ 4505.53010167, 12115.98940482, 9903.07121528], + [ 4371.42528384, 12107.77425281, 11297.90068006]], + + [[ 4401.88850591, 11948.65184256, 9894.67465802], + [ 4400.93088025, 11963.62134055, 9894.38997476], + [ 4399.97798153, 11978.50708284, 9894.10653761], + [ 4395.94197026, 12109.36319171, 9875.57430566], + [ 4257.51793484, 12100.93581926, 11266.91851058]], + + [[ 4292.80984859, 11942.11155616, 9865.33329546], + [ 4291.85589391, 11957.08129089, 9865.02969016], + [ 4290.90683384, 11971.96307626, 9864.72745348], + [ 4287.14489478, 12102.8225628 , 9846.12626332], + [ 4143.50749396, 12094.13701696, 11234.11601188]], + + [[ 4185.07760986, 11935.68877005, 9834.99760334], + [ 4184.13895448, 11950.65944174, 9834.68554354], + [ 4183.20530529, 11965.5389277 , 9834.37494976], + [ 4179.76831143, 12096.40741248, 9815.77648767], + [ 4029.30334014, 12087.37633611, 11199.52579691]], + + [[ 4080.33536308, 11929.48606234, 9803.8718887 ], + [ 4079.41632056, 11944.45790628, 9803.55255205], + [ 4078.50238225, 11959.33534493, 9803.23477925], + [ 4075.4774813 , 12090.21786377, 9784.66890288], + [ 3915.08232844, 12080.66810519, 11163.00441158]], + + [[ 3976.49689854, 11923.38578849, 9771.0933803 ], + [ 3975.59605714, 11938.35871434, 9770.75791626], + [ 3974.70047841, 11953.23261669, 9770.42416549], + [ 3972.04537846, 12084.12206559, 9751.81874769], + [ 3800.82090652, 12074.01470668, 11124.33036418]], + + [[ 3873.71628626, 11917.38877873, 9737.33826196], + [ 3872.83703017, 11932.36297648, 9736.99563815], + [ 3871.96313714, 11947.23457055, 9736.65484646], + [ 3869.67458272, 12078.13356112, 9718.07158194], + [ 3686.41041632, 12067.41369043, 11083.48942016]], + + [[ 3774.14578502, 11911.62556458, 9703.03642686], + [ 3773.29119631, 11926.60118653, 9702.68692055], + [ 3772.44206252, 11941.47046558, 9702.33937154], + [ 3770.59551356, 12072.38274991, 9683.80382113], + [ 3572.09428526, 12060.88287726, 11040.32200706]], + + [[ 3675.59346987, 11905.97347387, 9667.25563749], + [ 3674.76553039, 11920.95062078, 9666.89137203], + [ 3673.94319164, 11935.81627756, 9666.52924784], + [ 3672.51584608, 12066.73588027, 9647.973235 ], + [ 3457.8370276 , 12054.42383628, 10994.56101621]], + + [[ 3577.91162694, 11900.41420767, 9630.5555434 ], + [ 3577.10890893, 11915.39277916, 9630.18552157], + [ 3576.31187179, 11930.25599953, 9629.81778184], + [ 3575.2674608 , 12061.18442104, 9611.30365648], + [ 3343.53289845, 12048.03406407, 10946.15848764]], + + [[ 3484.17267426, 11895.12821766, 9593.77564227], + [ 3483.39561499, 11910.10820759, 9593.39865062], + [ 3482.62430862, 11924.96868987, 9593.02409271], + [ 3482.03096922, 12055.90885718, 9574.57479656], + [ 3229.54935771, 12041.73364177, 10894.94871321]], + + [[ 3393.16067957, 11890.04870494, 9556.21022712], + [ 3392.41328466, 11905.03032174, 9555.81784999], + [ 3391.67176547, 11919.88670068, 9555.42812948], + [ 3391.5353496 , 12050.83399364, 9536.97648905], + [ 3115.84771182, 12035.51680605, 10840.59215269]], + + [[ 3304.14332772, 11885.11905103, 9518.09922537], + [ 3303.42052522, 11900.10199289, 9517.70142296], + [ 3302.70364764, 11914.9555816 , 9517.30643691], + [ 3302.97890114, 12045.91232438, 9498.93053059], + [ 3002.34185992, 12029.37530562, 10783.01801043]], + + [[ 3219.34807988, 11880.46607233, 9479.94845251], + [ 3218.65033339, 11895.45034127, 9479.54307675], + [ 3217.9585571 , 11910.30057798, 9479.14069657], + [ 3218.73294357, 12041.27022858, 9460.87162731], + [ 2889.55558578, 12023.33362971, 10722.07476913]], + + [[ 3136.02998799, 11875.93891845, 9440.28160655], + [ 3135.36518191, 11890.92493513, 9439.85955759], + [ 3134.70644801, 11905.77024508, 9439.44078136], + [ 3136.00338708, 12036.74807031, 9421.20236676], + [ 2777.44644959, 12017.38535826, 10657.33665043]], + + [[ 3053.50542441, 11871.48108005, 9399.32363624], + [ 3052.86588871, 11886.46840935, 9398.89725636], + [ 3052.2324448 , 11901.31046456, 9398.47433386], + [ 3053.98153739, 12032.29914163, 9380.36207145], + [ 2665.97294339, 12011.5243227 , 10588.7466309 ]], + + [[ 2974.27923903, 11867.23623387, 9357.80077872], + [ 2973.66390876, 11882.22479813, 9357.36636136], + [ 2973.05467814, 11897.062795 , 9356.93561005], + [ 2975.33859735, 12028.06509815, 9338.98174893], + [ 2555.85024239, 12005.78382226, 10516.2093246 ]], + + [[ 2895.73782814, 11863.06727963, 9314.06306976], + [ 2895.15757302, 11878.05765205, 9313.6096772 ], + [ 2894.58347589, 11892.88968191, 9313.16028976], + [ 2897.43914904, 12023.90016826, 9295.27122887], + [ 2447.05300372, 12000.15795943, 10439.19070636]], + + [[ 2817.36580716, 11858.92418468, 9268.35582202], + [ 2816.81156441, 11873.91586641, 9267.89956969], + [ 2816.26344894, 11888.74417664, 9267.44752704], + [ 2819.60468601, 12019.76680634, 9249.74675744], + [ 2339.64310631, 11994.6455623 , 10357.70692494]], + + [[ 2741.7339924 , 11854.9555989 , 9221.5964797 ], + [ 2741.204829 , 11869.94852361, 9221.13276145], + [ 2740.68173848, 11884.772178 , 9220.67348358], + [ 2744.59293635, 12015.80947502, 9203.19972637], + [ 2234.51571912, 11989.28791561, 10271.81210779]], + + [[ 2666.4248332 , 11851.04007537, 9171.96762697], + [ 2665.93477822, 11866.03497188, 9171.48346025], + [ 2665.45077486, 11880.85166758, 9171.00413234], + [ 2669.98571208, 12011.89760238, 9153.64512172], + [ 2131.66731636, 11984.08003435, 10180.92047884]], + + [[ 2591.1400952 , 11847.13449078, 9119.76599995], + [ 2590.67740702, 11862.13073037, 9119.28178349], + [ 2590.22066433, 11876.94338022, 9118.80261281], + [ 2595.26801469, 12008.00339296, 9101.71389151], + [ 2031.28294428, 11979.02676721, 10085.26827846]], + + [[ 2518.09151775, 11843.37064943, 9065.88232145], + [ 2517.65628658, 11858.36822194, 9065.39294375], + [ 2517.22685749, 11873.17580899, 9064.90884497], + [ 2522.86900063, 12004.2521787 , 9048.13466121], + [ 1934.35188248, 11974.1732875 , 9985.10298318]], + + [[ 2444.79848212, 11839.62856905, 9008.14440946], + [ 2444.40923463, 11854.62842356, 9007.63579786], + [ 2444.02565061, 11869.42828664, 9007.13290764], + [ 2450.33492504, 12000.514556 , 8990.54145526], + [ 1840.94319743, 11969.5184762 , 9879.84983919]], + + [[ 2371.34290114, 11835.87907061, 8947.11939559], + [ 2370.98321301, 11850.88034724, 8946.61631657], + [ 2370.62899388, 11865.67616216, 8946.1191402 ], + [ 2377.44568638, 11996.7789017 , 8929.89083983], + [ 1751.33452312, 11965.07197636, 9770.17667023]], + + [[ 2299.45678357, 11832.23216031, 8883.59364496], + [ 2299.12658285, 11847.23484269, 8883.08997693], + [ 2298.80160874, 11862.02555442, 8882.59241435], + [ 2306.18834524, 11993.14645575, 8866.7651576 ], + [ 1666.30093308, 11960.86824898, 9656.57648703]], + + [[ 2228.44031587, 11828.66308935, 8816.59845001], + [ 2228.16262163, 11843.66834328, 8816.07955465], + [ 2227.88987065, 11858.45092287, 8815.56721245], + [ 2235.95471116, 11989.58420181, 8800.00309483], + [ 1585.87914825, 11956.90542575, 9538.76968122]], + + [[ 2158.62612012, 11825.1482772 , 8746.99912952], + [ 2158.37913883, 11840.15498467, 8746.49324187], + [ 2158.13681643, 11854.9338281 , 8745.99402064], + [ 2166.68145376, 11986.08775783, 8730.90071285], + [ 1510.14382938, 11953.18400188, 9418.05641426]], + + [[ 2090.12011983, 11821.7169708 , 8674.22868198], + [ 2089.90273362, 11836.7250685 , 8673.72915816], + [ 2089.68966751, 11851.49931298, 8673.23641109], + [ 2098.7579756 , 11982.67578533, 8658.65234795], + [ 1439.28953056, 11949.71078179, 9294.92818859]], + + [[ 2023.72995233, 11818.42375712, 8598.73518983], + [ 2023.56900979, 11833.43459145, 8598.22820678], + [ 2023.41195127, 11848.20093291, 8597.72837229], + [ 2033.14917373, 11979.39499235, 8583.52405907], + [ 1373.18892343, 11946.47719406, 9169.4567474 ]], + + [[ 1960.08383623, 11815.25519685, 8521.86409042], + [ 1959.95315668, 11830.26744572, 8521.37815267], + [ 1959.82600033, 11845.03060766, 8520.89935849], + [ 1970.00597734, 11976.25102953, 8507.2871594 ], + [ 1311.98675712, 11943.48837814, 9043.76115952]], + + [[ 1896.63977759, 11812.1070538 , 8439.78806027], + [ 1896.53433228, 11827.12046488, 8439.31549105], + [ 1896.43201557, 11841.88017544, 8438.85000689], + [ 1907.05104134, 11973.1288393 , 8425.85261831], + [ 1255.43042696, 11940.73040722, 8917.77015622]], + + [[ 1837.04097189, 11809.17746119, 8356.68406958], + [ 1836.98714604, 11824.19334845, 8356.21338403], + [ 1836.9359154 , 11838.94652551, 8355.74994371], + [ 1848.15176113, 11970.21965941, 8343.26563783], + [ 1203.73769193, 11938.21260631, 8792.59454227]], + + [[ 1781.19416372, 11806.41930932, 8273.16833529], + [ 1781.16486297, 11821.43632309, 8272.72325096], + [ 1781.13776966, 11836.18723886, 8272.28522736], + [ 1792.7214248 , 11967.49185225, 8260.49025332], + [ 1156.27397803, 11935.90305031, 8669.01322151]], + + [[ 1725.9083493 , 11803.69431349, 8183.71146558], + [ 1725.89726322, 11818.71214491, 8183.28338613], + [ 1725.88799725, 11833.46081423, 8182.86212095], + [ 1737.80359559, 11964.7976151 , 8171.74930778], + [ 1112.38613641, 11933.76921843, 8545.79743756]], + + [[ 1674.90123816, 11801.20294611, 8093.96175279], + [ 1674.9330089 , 11816.22281717, 8093.5419701 ], + [ 1674.96606646, 11830.96645696, 8093.12894486], + [ 1687.354594 , 11962.33117115, 8082.5964646 ], + [ 1072.28737629, 11931.82090429, 8424.02774666]], + + [[ 1627.58843476, 11798.87928296, 8004.13170931], + [ 1627.63796165, 11813.89995698, 8003.73822588], + [ 1627.68844394, 11828.64212161, 8003.35120839], + [ 1640.32050953, 11960.03847864, 7993.49029671], + [ 1035.24188159, 11930.02191633, 8303.67953728]], + + [[ 1581.54203584, 11796.62032321, 7909.54949563], + [ 1581.6034598 , 11811.64151574, 7909.17364326], + [ 1581.66553831, 11826.38233621, 7908.80393746], + [ 1594.48964217, 11957.80981432, 7899.56581073], + [ 1000.93817589, 11928.35681226, 8184.01269144]], + + [[ 1539.1347251 , 11794.55820368, 7815.15415544], + [ 1539.2302627 , 11809.58101265, 7814.78956973], + [ 1539.32600557, 11824.31801792, 7814.43097459], + [ 1552.47438509, 11955.77260646, 7805.7212258 ], + [ 969.49347068, 11926.83103288, 8065.71876496]], + + [[ 1499.83246339, 11792.63527591, 7720.96111218], + [ 1499.94000799, 11807.65861954, 7720.62115253], + [ 1500.04751339, 11822.39470312, 7720.28687297], + [ 1513.32521393, 11953.87891786, 7712.15905017], + [ 940.3156228 , 11925.41565745, 7948.66861063]], + + [[ 1461.96362894, 11790.78327352, 7623.10019359], + [ 1462.07835076, 11805.80691778, 7622.77700819], + [ 1462.19282408, 11820.54226722, 7622.45917955], + [ 1475.55822813, 11952.05560848, 7614.86122799], + [ 913.22928944, 11924.10204456, 7832.27675296]], + + [[ 1426.99864542, 11789.08811081, 7525.59108062], + [ 1427.14011393, 11804.11301964, 7525.28028975], + [ 1427.28097813, 11818.84548351, 7524.97466389], + [ 1440.85853004, 11950.3845361 , 7517.8301704 ], + [ 888.31196662, 11922.89384592, 7716.97127626]], + + [[ 1394.51197751, 11787.50263576, 7428.36151045], + [ 1394.66117446, 11802.52788226, 7428.07281274], + [ 1394.8095993 , 11817.2597992 , 7427.78899231], + [ 1408.43749492, 11948.82631401, 7421.12670536], + [ 865.10243588, 11921.76861796, 7602.6127373 ]], + + [[ 1363.40895103, 11785.9846067 , 7328.43149527], + [ 1363.56203787, 11801.01000511, 7328.15801533], + [ 1363.71421791, 11815.74157453, 7327.8891231 ], + [ 1377.36055686, 11947.33518228, 7321.65929552], + [ 843.52871679, 11920.72281327, 7488.83539311]], + + [[ 1334.54310088, 11784.58788291, 7228.90638879], + [ 1334.71705072, 11799.61426673, 7228.64524987], + [ 1334.88982099, 11814.34363789, 7228.3885137 ], + [ 1348.6675624 , 11945.9615841 , 7222.5307522 ], + [ 823.64712576, 11919.75911694, 7375.89390638]], + + [[ 1307.58637656, 11783.27443772, 7129.691523 ], + [ 1307.76502375, 11798.30102062, 7129.44970442], + [ 1307.94238282, 11813.03009336, 7129.21204546], + [ 1321.71795023, 11944.67382699, 7123.74057166], + [ 805.09532752, 11918.85993325, 7263.67906196]], + + [[ 1281.81916824, 11782.01845546, 7028.50483422], + [ 1281.99948956, 11797.04509166, 7028.27641752], + [ 1282.17844107, 11811.77405906, 7028.05191476], + [ 1295.92895729, 11943.44358893, 7022.9285121 ], + [ 787.8472569 , 11918.02398403, 7151.93171884]], + + [[ 1257.79521403, 11780.85745441, 6927.66536244], + [ 1257.99179742, 11795.88485827, 6927.44851737], + [ 1258.18680031, 11810.61213661, 6927.23540961], + [ 1272.01523699, 11942.30544665, 6922.42505916], + [ 771.94917321, 11917.25350872, 7040.80102613]], + + [[ 1235.35380619, 11779.76520076, 6827.07216864], + [ 1235.55304602, 11794.79271126, 6826.87194751], + [ 1235.75063218, 11809.51984845, 6826.67524077], + [ 1249.54971363, 11941.23812282, 6822.19414611], + [ 757.11452902, 11916.53461678, 6930.21821053]], + + [[ 1214.07754226, 11778.72898421, 6724.9931615 ], + [ 1214.27704947, 11793.75648686, 6724.80455463], + [ 1214.47485235, 11808.48366312, 6724.61923518], + [ 1228.22836985, 11940.22678104, 6720.43842361], + [ 743.34477919, 11915.86737445, 6820.01519178]], + + [[ 1194.29611295, 11777.77386228, 6623.19764464], + [ 1194.50831125, 11792.80196418, 6623.01953923], + [ 1194.71864074, 11807.52783231, 6622.84454619], + [ 1208.51636622, 11939.29385946, 6618.93737477], + [ 730.68206167, 11915.25381757, 6710.28516123]], + + [[ 1175.88391588, 11776.87836432, 6521.60718223], + [ 1176.09742637, 11791.90651321, 6521.44325923], + [ 1176.3090201 , 11806.63233633, 6521.28224624], + [ 1190.06291861, 11938.42190921, 6517.65644742], + [ 718.891445 , 11914.68255662, 6600.97735104]], + + [[ 1158.55648783, 11776.03488731, 6418.90681123], + [ 1158.76942669, 11791.06299316, 6418.7529242 ], + [ 1158.98042072, 11805.78893661, 6418.60174539], + [ 1172.67825216, 11937.60172082, 6415.23111086], + [ 707.98236071, 11914.15404268, 6491.96871262]], + + [[ 1142.48898987, 11775.25961611, 6316.43686374], + [ 1142.71186678, 11790.28819171, 6316.29241064], + [ 1142.93267196, 11805.01311514, 6316.1505038 ], + [ 1156.65176421, 11936.84728172, 6313.01404249], + [ 697.99080802, 11913.67001319, 6383.31371564]], + + [[ 1127.58067241, 11774.53487254, 6214.14301003], + [ 1127.8040241 , 11789.56345889, 6214.01066038], + [ 1128.02527455, 11804.28839237, 6213.88068091], + [ 1141.69364698, 11936.1442213 , 6210.98040715], + [ 688.72393902, 11913.2211197 , 6274.98448926]], + + [[ 1113.63028192, 11773.85595509, 6111.02182106], + [ 1113.85259849, 11788.88448006, 6110.89812488], + [ 1114.07280156, 11803.60957376, 6110.77662651], + [ 1127.68098227, 11935.48665673, 6108.08883857], + [ 680.19701359, 11912.80809591, 6166.89614058]], + + [[ 1100.71882307, 11773.23328428, 6008.10133475], + [ 1100.94892478, 11788.26217773, 6007.98601951], + [ 1101.17681605, 11802.9864734 , 6007.8727606 ], + [ 1114.79044084, 11934.88321081, 6005.38059017], + [ 672.4443789 , 11912.43259977, 6059.08410444]], + + [[ 1088.76099779, 11772.65212687, 5905.34206853], + [ 1088.9910637 , 11787.68100954, 5905.23707788], + [ 1089.21890278, 11802.40534677, 5905.13399698], + [ 1102.77902477, 11934.32190434, 5902.83568255], + [ 665.30803614, 11912.08697348, 5951.53143792]], + + [[ 1077.60342661, 11772.10914753, 5801.95771133], + [ 1077.83222299, 11787.13796052, 5801.86016579], + [ 1078.05879102, 11801.86247578, 5801.76438919], + [ 1091.55755971, 11933.79855913, 5799.63862389], + [ 658.79980559, 11911.77178509, 5844.17687759]], + + [[ 1067.27311132, 11771.60810142, 5698.74754731], + [ 1067.50810901, 11786.63720815, 5698.65868741], + [ 1067.74085166, 11801.36412482, 5698.57146047], + [ 1081.23316402, 11933.31849008, 5696.60545117], + [ 652.94591927, 11911.48830055, 5737.04171529]], + + [[ 1057.73491245, 11771.12654258, 5595.67755925], + [ 1057.95553521, 11786.15494364, 5595.60131043], + [ 1058.17416594, 11800.8993127 , 5595.52647398], + [ 1071.61224508, 11932.87215491, 5593.71842477], + [ 647.61340388, 11911.23007654, 5630.1179416 ]], + + [[ 1048.88801121, 11770.66331955, 5492.12380662], + [ 1049.07889271, 11785.6902701 , 5492.05720361], + [ 1049.26823093, 11800.46611258, 5491.99173787], + [ 1062.64563164, 11932.45727213, 5490.32552204], + [ 642.81019749, 11910.99749424, 5523.35926806]], + + [[ 1040.71805477, 11770.2359416 , 5388.72530874], + [ 1040.88694525, 11785.26181913, 5388.66637275], + [ 1041.05465122, 11800.06948531, 5388.60833774], + [ 1054.41691816, 11932.07797571, 5387.0763279 ], + [ 638.5592908 , 11910.79166381, 5416.77930275]], + + [[ 1033.18711744, 11769.83921129, 5285.45382413], + [ 1033.331204 , 11784.86387977, 5285.4030227 ], + [ 1033.4744232 , 11799.70124045, 5285.35292384], + [ 1046.78364126, 11931.72717928, 5283.95429656], + [ 634.75338426, 11910.60738713, 5310.37634539]], + + [[ 1026.23280818, 11769.47232294, 5181.791265 ], + [ 1026.3532189 , 11784.49583826, 5181.74636993], + [ 1026.47301589, 11799.36065662, 5181.70201319], + [ 1039.72406435, 11931.40385868, 5180.42457577], + [ 631.39799721, 11910.4449293 , 5204.11556748]], + + [[ 1019.87130376, 11769.13938769, 5078.26029061], + [ 1019.97421757, 11784.16205033, 5078.22104905], + [ 1020.07669321, 11799.05183059, 5078.1822147 ], + [ 1033.30711331, 11931.11135586, 5077.0221361 ], + [ 628.50746707, 11910.30498251, 5098.00711291]], + + [[ 1014.08421749, 11768.83453541, 4974.84049052], + [ 1014.16796797, 11783.8562648 , 4974.80720792], + [ 1014.2514261 , 11798.76886897, 4974.77422527], + [ 1027.43243141, 11930.84455032, 4973.73197453], + [ 625.98962242, 11910.1830826 , 4992.0522244 ]], + + [[ 1008.84043959, 11768.5578515 , 4871.08858938], + [ 1008.90618798, 11783.57870485, 4871.05956048], + [ 1008.97175278, 11798.51198236, 4871.0307451 ], + [ 1022.09997728, 11930.60333422, 4870.09826813], + [ 623.84193848, 11910.07910609, 4886.22633319]], + + [[ 1004.17087993, 11768.3134615 , 4767.44196637], + [ 1004.2236393 , 11783.33368249, 4767.41704466], + [ 1004.2762837 , 11798.28533803, 4767.39226889], + [ 1017.38327669, 11930.39107408, 4766.57275393], + [ 622.07141138, 11909.99339061, 4780.53335945]], + + [[ 1000.12230159, 11768.10025707, 4663.8863171 ], + [ 1000.16160279, 11783.11982326, 4663.8656753 ], + [ 1000.20083924, 11798.08740283, 4663.84512707], + [ 1013.26785613, 11930.20649255, 4663.14674319], + [ 620.60296446, 11909.92230066, 4674.98478759]], + + [[ 996.73553962, 11767.92148483, 4560.01557564], + [ 996.76302997, 11782.94047672, 4559.99783792], + [ 996.79048734, 11797.92136832, 4559.98015562], + [ 1009.81790993, 11930.05223117, 4559.40106712], + [ 619.42997433, 11909.86551498, 4569.56052213]], + + [[ 994.05827055, 11767.78148379, 4456.2542984 ], + [ 994.07851541, 11782.80012316, 4456.23940689], + [ 994.09874306, 11797.79162316, 4456.22454561], + [ 1007.11407504, 11929.93185048, 4455.76609138], + [ 618.55364746, 11909.82309151, 4464.26579091]], + + [[ 992.07729595, 11767.67706395, 4352.61090138], + [ 992.09088754, 11782.69537975, 4352.59904997], + [ 992.10447118, 11797.69473945, 4352.5872137 ], + [ 1005.09781444, 11929.84222674, 4352.24449444], + [ 617.90500759, 11909.79169072, 4359.10471268]], + + [[ 990.71261294, 11767.60463643, 4248.74283699], + [ 990.72107411, 11782.62270272, 4248.73306913], + [ 990.72953198, 11797.62752025, 4248.72330837], + [ 1003.70183911, 11929.78028512, 4248.47498366], + [ 617.45739978, 11909.77002206, 4254.05687415]], + + [[ 989.82297115, 11767.55823006, 4145.02126811], + [ 989.82941575, 11782.57619809, 4145.0136825 ], + [ 989.83585863, 11797.58469517, 4145.00610025], + [ 1002.80676222, 11929.74086472, 4144.83015251], + [ 617.19281326, 11909.75721348, 4149.1090284 ]], + + [[ 989.21701434, 11767.52632759, 4041.49289133], + [ 989.22148668, 11782.54419965, 4041.48772998], + [ 989.22595822, 11797.55515235, 4041.48257031], + [ 1002.18935782, 11929.71383711, 4041.3610114 ], + [ 617.04974114, 11909.75028741, 4044.32917276]], + + [[ 988.80242528, 11767.50414045, 3937.63585819], + [ 988.80507363, 11782.5219238 , 3937.63202136], + [ 988.8077217 , 11797.53451923, 3937.62818536], + [ 1001.76066572, 11929.69526105, 3937.54308439], + [ 616.99284886, 11909.74753328, 3939.73135669]], + + [[ 988.54181037, 11767.49062236, 3833.97835237], + [ 988.5439751 , 11782.50838213, 3833.97584638], + [ 988.54613967, 11797.52205447, 3833.97334078], + [ 1001.49851818, 11929.68416013, 3833.91979177], + [ 616.99563933, 11909.74766836, 3835.31751131]], + + [[ 988.39036044, 11767.48279634, 3730.53756346], + [ 988.39218496, 11782.50053953, 3730.53649819], + [ 988.39400938, 11797.51484309, 3730.53543311], + [ 1001.34522452, 11929.67775384, 3730.5084254 ], + [ 617.00922 , 11909.7483258 , 3731.11924121]], + + [[ 988.3095921 , 11767.4784567 , 3626.70576608], + [ 988.31101749, 11782.49618049, 3626.70505093], + [ 988.31244284, 11797.51081576, 3626.70433588], + [ 1001.26087782, 11929.67420818, 3626.6888627 ], + [ 617.01157527, 11909.74843981, 3627.09857217]], + + [[ 988.27205887, 11767.47651226, 3523.09287666], + [ 988.27343288, 11782.49423354, 3523.09250377], + [ 988.27480684, 11797.50903377, 3523.09213091], + [ 1001.22331378, 11929.67262786, 3523.08497839], + [ 617.00995794, 11909.74836152, 3523.28895203]], + + [[ 988.2582318 , 11767.47584833, 3419.58811999], + [ 988.25962856, 11782.4935707 , 3419.58811838], + [ 988.26102526, 11797.50843733, 3419.58811677], + [ 1001.21000197, 11929.6720604 , 3419.58704202], + [ 617.00635228, 11909.74818697, 3419.58622647]], + + [[ 988.25532482, 11767.47570698, 3315.92494377], + [ 988.25671591, 11782.49342909, 3315.92494526], + [ 988.25810695, 11797.50831285, 3315.92494675], + [ 1001.20709074, 11929.67193086, 3315.92477923], + [ 617.00383117, 11909.74806493, 3315.92417757]], + + [[ 988.25526001, 11767.47570101, 3212.31018621], + [ 988.25664973, 11782.49342305, 3212.31018597], + [ 988.2580394 , 11797.50830884, 3212.31018574], + [ 1001.20701049, 11929.67192739, 3212.31017069], + [ 617.00354115, 11909.74805089, 3212.31048155]], + + [[ 988.25525934, 11767.47569843, 3108.68946492], + [ 988.25664679, 11782.49342036, 3108.68946528], + [ 988.25803419, 11797.50830588, 3108.68946563], + [ 1001.20696739, 11929.67192987, 3108.68944936], + [ 617.00445033, 11909.7480949 , 3108.68946494]], + + [[ 988.25526001, 11767.47569672, 3005.06962834], + [ 988.25664585, 11782.49341857, 3005.06962925], + [ 988.25803163, 11797.50830423, 3005.06963016], + [ 1001.2069373 , 11929.6719341 , 3005.06962573], + [ 617.00550743, 11909.74814607, 3005.06919545]], + + [[ 988.25526001, 11767.47569695, 2901.44605205], + [ 988.25664631, 11782.49341882, 2901.44605157], + [ 988.25803256, 11797.50830457, 2901.44605108], + [ 1001.20693558, 11929.6719368 , 2901.44604425], + [ 617.00594553, 11909.74816728, 2901.44625197]], + + [[ 988.25526001, 11767.47569779, 2797.82083486], + [ 988.25664712, 11782.49341969, 2797.82083476], + [ 988.25803419, 11797.50830548, 2797.82083466], + [ 1001.2069457 , 11929.67193715, 2797.82083765], + [ 617.0058773 , 11909.74816398, 2797.82083485]], + + [[ 988.25526001, 11767.47569816, 2694.19740773], + [ 988.25664739, 11782.49342008, 2694.19740875], + [ 988.25803472, 11797.50830588, 2694.19740977], + [ 1001.20695278, 11929.67193598, 2694.19742453], + [ 617.00558293, 11909.74814973, 2694.1968497 ]], + + [[ 988.25526001, 11767.4756981 , 2590.57377634], + [ 988.2566473 , 11782.49342002, 2590.5737761 ], + [ 988.25803454, 11797.50830582, 2590.57377585], + [ 1001.20695514, 11929.67193478, 2590.5737756 ], + [ 617.00534736, 11909.74813832, 2590.57392368]], + + [[ 988.25526001, 11767.47569794, 2486.95206775], + [ 988.25664712, 11782.49341985, 2486.95206777], + [ 988.25803419, 11797.50830564, 2486.95206778], + [ 1001.20695418, 11929.6719343 , 2486.95206786], + [ 617.00528783, 11909.74813544, 2486.95206775]], + + [[ 988.25526001, 11767.47569789, 2383.3293073 ], + [ 988.25664712, 11782.4934198 , 2383.32930681], + [ 988.25803417, 11797.50830559, 2383.32930632], + [ 1001.20695347, 11929.6719345 , 2383.32930032], + [ 617.00533853, 11909.7481379 , 2383.32957156]], + + [[ 988.25526001, 11767.4756979 , 2279.70678194], + [ 988.25664712, 11782.49341981, 2279.70678188], + [ 988.25803419, 11797.5083056 , 2279.70678183], + [ 1001.20695243, 11929.67193488, 2279.70678025], + [ 617.00542048, 11909.74814186, 2279.70680984]], + + [[ 988.25526001, 11767.4756979 , 2176.08291735], + [ 988.25664712, 11782.49341981, 2176.08291735], + [ 988.25803419, 11797.5083056 , 2176.08291735], + [ 1001.20695176, 11929.67193512, 2176.08291696], + [ 617.00546533, 11909.74814403, 2176.08291735]], + + [[ 988.25526001, 11767.47569788, 2072.45974875], + [ 988.2566471 , 11782.49341979, 2072.45974852], + [ 988.25803415, 11797.50830558, 2072.45974829], + [ 1001.2069514 , 11929.67193513, 2072.45974614], + [ 617.00546768, 11909.74814415, 2072.45987287]], + + [[ 988.25526001, 11767.47569788, 1968.8370285 ], + [ 988.25664711, 11782.49341979, 1968.8370282 ], + [ 988.25803415, 11797.50830558, 1968.83702789], + [ 1001.20695168, 11929.67193505, 1968.83702514], + [ 617.00544869, 11909.74814323, 1968.83719454]], + + [[ 988.25526001, 11767.4756979 , 1865.21416285], + [ 988.25664712, 11782.49341981, 1865.21416285], + [ 988.25803419, 11797.5083056 , 1865.21416285], + [ 1001.20695219, 11929.67193497, 1865.21416307], + [ 617.00542901, 11909.74814228, 1865.21416285]], + + [[ 988.25526001, 11767.47569792, 1761.59168997], + [ 988.25664714, 11782.49341983, 1761.59168981], + [ 988.25803422, 11797.50830562, 1761.59168964], + [ 1001.20695248, 11929.67193494, 1761.59168804], + [ 617.00542186, 11909.74814193, 1761.59177897]], + + [[ 988.25526001, 11767.47569791, 1657.96878652], + [ 988.25664713, 11782.49341982, 1657.96878681], + [ 988.25803421, 11797.50830561, 1657.96878709], + [ 1001.20695242, 11929.67193494, 1657.96878985], + [ 617.00542387, 11909.74814203, 1657.9686341 ]], + + [[ 988.25526001, 11767.47569779, 1554.3451148 ], + [ 988.25664712, 11782.4934197 , 1554.34511486], + [ 988.25803419, 11797.50830563, 1554.34511491], + [ 1001.20695222, 11929.67193496, 1554.34511473], + [ 617.00542959, 11909.7481423 , 1554.3451148 ]], + + [[ 988.25526001, 11767.47569743, 1450.72223448], + [ 988.25664657, 11782.49341931, 1450.72223507], + [ 988.25803307, 11797.50830563, 1450.72223566], + [ 1001.20695208, 11929.67193498, 1450.72224152], + [ 617.00543375, 11909.74814251, 1450.72191579]], + + [[ 988.25526001, 11767.47569778, 1347.09945061], + [ 988.25664712, 11782.49341969, 1347.09945133], + [ 988.25803418, 11797.50830562, 1347.09945204], + [ 1001.20695209, 11929.67193499, 1347.09945974], + [ 617.00543465, 11909.74814255, 1347.099039 ]], + + [[ 988.25526001, 11767.4756979 , 1243.47603532], + [ 988.25664712, 11782.49341981, 1243.47603532], + [ 988.25803419, 11797.5083056 , 1243.47603532], + [ 1001.20695215, 11929.67193498, 1243.47603534], + [ 617.0054336 , 11909.7481425 , 1243.47603532]], + + [[ 988.25526001, 11767.4756979 , 1139.85397982], + [ 988.25664713, 11782.49341981, 1139.85398077], + [ 988.25803419, 11797.5083056 , 1139.85398172], + [ 1001.20695219, 11929.67193498, 1139.85399122], + [ 617.00543206, 11909.74814242, 1139.85346726]], + + [[ 988.25526001, 11767.4756979 , 1036.22975319], + [ 988.25664713, 11782.49341981, 1036.22975214], + [ 988.25803419, 11797.5083056 , 1036.22975109], + [ 1001.2069522 , 11929.67193497, 1036.22974061], + [ 617.00543128, 11909.74814239, 1036.23032003]], + + [[ 988.25526001, 11767.4756979 , 932.60741582], + [ 988.25664712, 11782.49341981, 932.60741582], + [ 988.25803419, 11797.5083056 , 932.60741582], + [ 1001.20695219, 11929.67193497, 932.60741582], + [ 617.00543127, 11909.74814239, 932.60741582]], + + [[ 988.25526001, 11767.4756979 , 828.98431501], + [ 988.25664712, 11782.49341981, 828.98431479], + [ 988.25803419, 11797.5083056 , 828.98431457], + [ 1001.20695218, 11929.67193497, 828.98431236], + [ 617.00543165, 11909.7481424 , 828.98443381]], + + [[ 988.25526001, 11767.4756979 , 725.36146249], + [ 988.25664712, 11782.49341981, 725.36146324], + [ 988.25803419, 11797.5083056 , 725.361464 ], + [ 1001.20695217, 11929.67193498, 725.3614715 ], + [ 617.00543201, 11909.74814242, 725.36105669]], + + [[ 988.25526001, 11767.4756979 , 621.7380105 ], + [ 988.25664712, 11782.49341981, 621.7380105 ], + [ 988.25803419, 11797.5083056 , 621.7380105 ], + [ 1001.20695217, 11929.67193498, 621.7380105 ], + [ 617.00543213, 11909.74814243, 621.7380105 ]], + + [[ 988.25526001, 11767.4756979 , 518.11526049], + [ 988.25664712, 11782.49341981, 518.11526051], + [ 988.25803419, 11797.5083056 , 518.11526053], + [ 1001.20695217, 11929.67193498, 518.11526073], + [ 617.00543208, 11909.74814242, 518.11524941]], + + [[ 988.25526001, 11767.47569797, 414.49260033], + [ 988.25664712, 11782.49341988, 414.4926007 ], + [ 988.25803419, 11797.5083056 , 414.49260107], + [ 1001.20695218, 11929.67193498, 414.49260505], + [ 617.00543197, 11909.74814242, 414.49238498]], + + [[ 988.25525934, 11767.47569815, 310.86927403], + [ 988.25664679, 11782.49342008, 310.86927403], + [ 988.25803419, 11797.5083056 , 310.86927403], + [ 1001.20695218, 11929.67193498, 310.86927403], + [ 617.00543191, 11909.74814242, 310.86927403]], + + [[ 988.25526001, 11767.47569797, 172.70471848], + [ 988.25664712, 11782.49341988, 172.70471783], + [ 988.25803419, 11797.5083056 , 172.70471718], + [ 1001.20695218, 11929.67193498, 172.70471045], + [ 617.00543193, 11909.74814242, 172.70508202]], + + [[ 988.25526001, 11767.47569791, 69.08286521], + [ 988.25664712, 11782.49341981, 69.08286535], + [ 988.25803419, 11797.5083056 , 69.0828655 ], + [ 1001.20695218, 11929.67193498, 69.08286697], + [ 617.00543195, 11909.74814242, 69.0827857 ]], + + [[ 988.25526 , 11767.4756979 , 0. ], + [ 988.25664712, 11782.49341981, 0. ], + [ 988.25803419, 11797.5083056 , 0. ], + [ 1001.20695218, 11929.67193498, 0. ], + [ 617.00543194, 11909.74814242, 0. ]]]), + 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., 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., 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., 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., 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.], + [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., 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., 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., 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., 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.], + [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., 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., 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., 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., 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.], + [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., 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., 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., 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., 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.], + [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., 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., 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., 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., 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.], + [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., 1., 1., 1.], + [1., 1., 1., 1., 1.]]) +) + +surf11 = NURBSSurfaceTuple( + order_u=2, + order_v=2, + knot_u=np.array([ 0., 0., 12200., 12200.]), + knot_v=np.array([0., 0., 1., 1.]), + control_points=np.array([[[ 1071.54933309, 11931.78506755, 12200. ], + [ 1077.90493537, 11800.93929882, 12200. ]], + [[ 1071.54933309, 11931.78506755, 0. ], + [ 1077.90493537, 11800.93929882, -0. ]]]), + weights=np.array([[1., 1.], + [1., 1.]]) +) +surf12 = NURBSSurfaceTuple( + order_u=2, + order_v=2, + knot_u=np.array([ 0. , 0. , 131.00003387, 131.00003387]), + knot_v=np.array([ 0. , 0. , 3516.63007178, 3516.63007178]), + control_points=np.array([[[ 1084.33133643, 11668.63596229, 8720.36060565], + [ 1084.33133643, 11668.63596229, 5203.73053387]], + [[ 997.52510397, 13455.75663468, 8720.36060565], + [ 997.52510397, 13455.75663468, 5203.73053387]]]), + weights=np.array([[1., 1.], + [1., 1.]]) +) +surf21 = NURBSSurfaceTuple( + order_u=2, + order_v=2, + knot_u=np.array([ 0., 0., 12200., 12200.]), + knot_v=np.array([0., 0., 1., 1.]), + control_points=np.array([[[ 1548.17283308, 11955.05143429, 12200. ], + [ 1554.60201073, 11824.20911522, 12200. ]], + [[ 1548.17283308, 11955.05143429, 0. ], + [ 1554.60201073, 11824.20911522, -0. ]]]), + weights=np.array([[1., 1.], + [1., 1.]]) +) +surf22 = NURBSSurfaceTuple( + order_u=2, + order_v=2, + knot_u=np.array([ 0. , 0. , 131.00017857, 131.00017857]), + knot_v=np.array([ 0. , 0. , 1826.58091401, 1826.58091401]), + control_points=np.array([[[ 1510.93174833, 12712.95705789, 9609.29772107], + [ 1510.93174833, 12712.95705789, 7782.71680706]], + [[ 1569.43788559, 11522.27932289, 9609.29772107], + [ 1569.43788559, 11522.27932289, 7782.71680706]]]), + weights=np.array([[1., 1.], + [1., 1.]]) +) +surf31 = NURBSSurfaceTuple( + order_u=2, + order_v=2, + knot_u=np.array([ 0., 0., 12200., 12200.]), + knot_v=np.array([0., 0., 1., 1.]), + control_points=np.array([[[ 2024.78689434, 11978.70071438, 12200.00000001], + [ 2031.35246712, 11847.86531633, 12200. ]], + [[ 2024.78689434, 11978.70071438, 0.00000001], + [ 2031.35246712, 11847.86531633, -0. ]]]), + weights=np.array([[1., 1.], + [1., 1.]]) +) + + diff --git a/examples/ssx/case16.py b/examples/ssx/case16.py new file mode 100644 index 00000000..3a34be2c --- /dev/null +++ b/examples/ssx/case16.py @@ -0,0 +1,42 @@ +import os +import sys +sys.path.append(os.path.abspath(os.path.dirname(__file__))) +from _case16_data import surf0,surf11,surf12,surf21,surf22,surf31 + +def print_res_summary(res): + keys=[ + 'branches', + 'points', + 'singularities', + 'overlap_regions', + 'unresolved_regions', + ] + for k in keys: + print(k,res[k],sep=':',end='; ') + + + +from mmcore.numeric.intersection.ssx import nurbs_ssx +TOL=1e-3 + +res=nurbs_ssx(surf0,surf11, atol=TOL) +print_res_summary(res) + + +res=nurbs_ssx(surf0,surf12, atol=TOL) +print_res_summary(res) + + + +res=nurbs_ssx(surf0,surf21, atol=TOL) +print_res_summary(res) + + + +res=nurbs_ssx(surf0,surf22, atol=TOL) +print_res_summary(res) + + + +res=nurbs_ssx(surf0,surf31, atol=TOL) +print_res_summary(res) \ No newline at end of file From 552d9728558d603d4fe24376ce4bb22cc532d598 Mon Sep 17 00:00:00 2001 From: sth-v Date: Wed, 23 Sep 2026 02:06:18 +0300 Subject: [PATCH 3/8] case16 --- examples/ssx/_case16_data.py | 4337 +--------------------------------- examples/ssx/case16.py | 46 +- 2 files changed, 56 insertions(+), 4327 deletions(-) diff --git a/examples/ssx/_case16_data.py b/examples/ssx/_case16_data.py index 6c9241df..efd2aba5 100644 --- a/examples/ssx/_case16_data.py +++ b/examples/ssx/_case16_data.py @@ -1,4318 +1,10 @@ import numpy as np from mmcore.nurbs._nurbs_eval import NURBSSurfaceTuple -surf0= NURBSSurfaceTuple( - order_u=4, - order_v=3, - knot_u=np.array([0. , 0. , 0. , 0. , 0.00338983, - 0.00677966, 0.01016949, 0.01355932, 0.01694915, 0.02033898, - 0.02372881, 0.02711864, 0.03050847, 0.03389831, 0.03728814, - 0.04067797, 0.0440678 , 0.04745763, 0.05084746, 0.05423729, - 0.05762712, 0.06101695, 0.06440678, 0.06779661, 0.07118644, - 0.07457627, 0.0779661 , 0.08135593, 0.08474576, 0.08813559, - 0.09152542, 0.09491525, 0.09830508, 0.10169492, 0.10508475, - 0.10847458, 0.11186441, 0.11525424, 0.11864407, 0.1220339 , - 0.12542373, 0.12881356, 0.13220339, 0.13559322, 0.13898305, - 0.14237288, 0.14576271, 0.14915254, 0.15254237, 0.1559322 , - 0.15932203, 0.16271186, 0.16610169, 0.16949153, 0.17288136, - 0.17627119, 0.17966102, 0.18305085, 0.18644068, 0.18983051, - 0.19322034, 0.19661017, 0.2 , 0.20338983, 0.20677966, - 0.21016949, 0.21355932, 0.21694915, 0.22033898, 0.22372881, - 0.22711864, 0.23050847, 0.23389831, 0.23728814, 0.24067797, - 0.2440678 , 0.24745763, 0.25084746, 0.25423729, 0.25762712, - 0.26101695, 0.26440678, 0.26779661, 0.27118644, 0.27457627, - 0.2779661 , 0.28135593, 0.28474576, 0.28813559, 0.29152542, - 0.29491525, 0.29830508, 0.30169492, 0.30508475, 0.30847458, - 0.31186441, 0.31525424, 0.31864407, 0.3220339 , 0.32542373, - 0.32881356, 0.33220339, 0.33559322, 0.33898305, 0.34237288, - 0.34576271, 0.34915254, 0.35254237, 0.3559322 , 0.35932203, - 0.36271186, 0.36610169, 0.36949153, 0.37288136, 0.37627119, - 0.37966102, 0.38305085, 0.38644068, 0.38983051, 0.39322034, - 0.39661017, 0.4 , 0.40338983, 0.40677966, 0.41016949, - 0.41355932, 0.41694915, 0.42033898, 0.42372881, 0.42711864, - 0.43050847, 0.43389831, 0.43728814, 0.44067797, 0.4440678 , - 0.44745763, 0.45084746, 0.45423729, 0.45762712, 0.46101695, - 0.46440678, 0.46779661, 0.47118644, 0.47457627, 0.4779661 , - 0.48135593, 0.48474576, 0.48813559, 0.49152542, 0.49491525, - 0.49830508, 0.50169492, 0.50508475, 0.50847458, 0.51186441, - 0.51525424, 0.51864407, 0.5220339 , 0.52542373, 0.52881356, - 0.53220339, 0.53559322, 0.53898305, 0.54237288, 0.54576271, - 0.54915254, 0.55254237, 0.5559322 , 0.55932203, 0.56271186, - 0.56610169, 0.56949153, 0.57288136, 0.57627119, 0.57966102, - 0.58305085, 0.58644068, 0.58983051, 0.59322034, 0.59661017, - 0.6 , 0.60338983, 0.60677966, 0.61016949, 0.61355932, - 0.61694915, 0.62033898, 0.62372881, 0.62711864, 0.63050847, - 0.63389831, 0.63728814, 0.64067797, 0.6440678 , 0.64745763, - 0.65084746, 0.65423729, 0.65762712, 0.66101695, 0.66440678, - 0.66779661, 0.67118644, 0.67457627, 0.6779661 , 0.68135593, - 0.68474576, 0.68813559, 0.69152542, 0.69491525, 0.69830508, - 0.70169492, 0.70508475, 0.70847458, 0.71186441, 0.71525424, - 0.71864407, 0.7220339 , 0.72542373, 0.72881356, 0.73220339, - 0.73559322, 0.73898305, 0.74237288, 0.74576271, 0.74915254, - 0.75254237, 0.7559322 , 0.75932203, 0.76271186, 0.76610169, - 0.76949153, 0.77288136, 0.77627119, 0.77966102, 0.78305085, - 0.78644068, 0.78983051, 0.79322034, 0.79661017, 0.8 , - 0.80338983, 0.80677966, 0.81016949, 0.81355932, 0.81694915, - 0.82033898, 0.82372881, 0.82711864, 0.83050847, 0.83389831, - 0.83728814, 0.84067797, 0.8440678 , 0.84745763, 0.85084746, - 0.85423729, 0.85762712, 0.86101695, 0.86440678, 0.86779661, - 0.87118644, 0.87457627, 0.8779661 , 0.88135593, 0.88474576, - 0.88813559, 0.89152542, 0.89491525, 0.89830508, 0.90169492, - 0.90508475, 0.90847458, 0.91186441, 0.91525424, 0.91864407, - 0.9220339 , 0.92542373, 0.92881356, 0.93220339, 0.93559322, - 0.93898305, 0.94237288, 0.94576271, 0.94915254, 0.95254237, - 0.9559322 , 0.95932203, 0.96271186, 0.96610169, 0.96949153, - 0.97288136, 0.97627119, 0.97966102, 0.98305085, 0.98644068, - 0.98983051, 0.99322034, 0.99661017, 1. , 1. , - 1. , 1. ]), - knot_v=np.array([0. , 0. , 0. , 0.09092302, 0.09092302, - 1. , 1. , 1. ]), - control_points=np.array([[[46742.96936536, 10148.75700572, -0. ], - [46745.93489773, 10163.60610769, -0. ], - [46749.26552869, 10181.84805612, 0. ], - [46734.70673134, 10321.10384628, -0. ], - [47088.34613697, 10197.16411948, 0. ]], - - [[46742.96936536, 10148.75700572, 50.18844089], - [46745.93489773, 10163.60610769, 50.18844089], - [46749.26552869, 10181.84805612, 50.18844089], - [46734.70673134, 10321.10384628, 50.18844089], - [47088.34613697, 10197.16411947, 50.18844089]], - - [[46742.96936536, 10148.75700572, 150.56594279], - [46745.93489773, 10163.60610769, 150.56594279], - [46749.26552869, 10181.84805612, 150.56594279], - [46734.70673134, 10321.10384628, 150.56594279], - [47088.34613697, 10197.16411948, 150.56594279]], - - [[46742.96936536, 10148.75700572, 301.13231984], - [46745.93489773, 10163.60610769, 301.13231984], - [46749.26552869, 10181.84805612, 301.13231984], - [46734.70673134, 10321.10384628, 301.13231984], - [47088.34613697, 10197.16411948, 301.13231984]], - - [[46742.96936536, 10148.75700572, 426.60359013], - [46745.93489773, 10163.60610769, 426.60358928], - [46749.26552869, 10181.84805612, 426.60358844], - [46734.70673134, 10321.10384628, 426.60356824], - [47088.34613699, 10197.16411947, 426.60374173]], - - [[46742.96936536, 10148.75700572, 526.98100428], - [46745.93489773, 10163.60610769, 526.98100364], - [46749.26552869, 10181.84805612, 526.98100301], - [46734.70673134, 10321.10384628, 526.98098792], - [47088.34613701, 10197.16411946, 526.98111756]], - - [[46742.96936536, 10148.75700572, 602.2643804 ], - [46745.93489773, 10163.60610769, 602.2643804 ], - [46749.26552869, 10181.84805612, 602.2643804 ], - [46734.70673134, 10321.10384628, 602.2643804 ], - [47088.346137 , 10197.16411947, 602.2643804 ]], - - [[46742.96936536, 10148.75700572, 690.09481118], - [46745.93489773, 10163.60610769, 690.09481217], - [46749.26552869, 10181.84805612, 690.09481316], - [46734.70673135, 10321.10384628, 690.09483672], - [47088.34613694, 10197.16411948, 690.09463436]], - - [[46742.96936536, 10148.75700572, 790.4717258 ], - [46745.93489773, 10163.60610769, 790.47172665], - [46749.26552869, 10181.84805612, 790.4717275 ], - [46734.70673135, 10321.10384628, 790.47174769], - [47088.34613685, 10197.16411952, 790.47157425]], - - [[46742.96936536, 10148.75700572, 903.39663459], - [46745.93489773, 10163.60610769, 903.39663459], - [46749.26552869, 10181.84805612, 903.39663459], - [46734.70673134, 10321.10384628, 903.39663459], - [47088.34613684, 10197.16411952, 903.39663459]], - - [[46742.96936536, 10148.75700572, 1010.04736261], - [46745.93489773, 10163.60610769, 1010.04736289], - [46749.26552869, 10181.84805612, 1010.04736318], - [46734.70673134, 10321.10384628, 1010.04736995], - [47088.34613707, 10197.16411944, 1010.04731172]], - - [[46742.96936536, 10148.75700572, 1110.42529898], - [46745.93489773, 10163.60610769, 1110.42529928], - [46749.26552869, 10181.84805612, 1110.42529958], - [46734.70673133, 10321.10384628, 1110.42530667], - [47088.34613741, 10197.16411933, 1110.42524567]], - - [[46742.96936536, 10148.75700572, 1204.52858973], - [46745.93489773, 10163.60610769, 1204.52858973], - [46749.26552869, 10181.84805612, 1204.52858973], - [46734.70673133, 10321.10384628, 1204.52858973], - [47088.34613752, 10197.16411929, 1204.52858973]], - - [[46742.96936536, 10148.75700572, 1301.76940796], - [46745.93489773, 10163.60610769, 1301.76940734], - [46749.26552869, 10181.84805612, 1301.76940672], - [46734.70673133, 10321.10384629, 1301.76939197], - [47088.34613698, 10197.16411947, 1301.76951892]], - - [[46742.96936536, 10148.75700572, 1402.14655035], - [46745.93489773, 10163.60610769, 1402.14654895], - [46749.26552869, 10181.84805612, 1402.14654756], - [46734.70673135, 10321.10384629, 1402.14651448], - [47088.3461357 , 10197.16411991, 1402.14679895]], - - [[46742.96936536, 10148.75700571, 1505.66105268], - [46745.93489773, 10163.60610769, 1505.66105268], - [46749.26552869, 10181.84805612, 1505.66105268], - [46734.70673138, 10321.10384628, 1505.66105267], - [47088.34613487, 10197.16412018, 1505.66105268]], - - [[46742.96936536, 10148.75700571, 1607.60618385], - [46745.93489773, 10163.60610768, 1607.60618357], - [46749.2655287 , 10181.8480561 , 1607.60618328], - [46734.70673151, 10321.10384622, 1607.60617641], - [47088.34613636, 10197.16411968, 1607.60623476]], - - [[46742.96936536, 10148.75700571, 1707.98413819], - [46745.93489773, 10163.60610768, 1707.98413687], - [46749.2655287 , 10181.8480561 , 1707.98413556], - [46734.70673145, 10321.10384621, 1707.98410426], - [47088.34614083, 10197.16411817, 1707.98437219]], - - [[46742.96936536, 10148.75700572, 1806.79295733], - [46745.93489773, 10163.60610769, 1806.79295733], - [46749.26552868, 10181.84805613, 1806.79295733], - [46734.7067312 , 10321.10384627, 1806.79295734], - [47088.34614456, 10197.16411691, 1806.79295733]], - - [[46742.96936537, 10148.75700575, 1906.38663331], - [46745.9348977 , 10163.60610774, 1906.38663316], - [46749.26552862, 10181.84805622, 1906.38663301], - [46734.70673046, 10321.10384656, 1906.38662948], - [47088.34614154, 10197.16411793, 1906.38666172]], - - [[46742.96936537, 10148.75700576, 2006.76318292], - [46745.93489769, 10163.60610775, 2006.76318189], - [46749.2655286 , 10181.84805625, 2006.76318086], - [46734.70673044, 10321.10384669, 2006.76315678], - [47088.34612744, 10197.1641227 , 2006.7633669 ]], - - [[46742.96936535, 10148.75700569, 2107.92542482], - [46745.93489773, 10163.60610766, 2107.9254248 ], - [46749.26552869, 10181.84805607, 2107.92542478], - [46734.70673185, 10321.10384632, 2107.92542484], - [47088.34611102, 10197.16412825, 2107.92542481]], - - [[46742.96936532, 10148.75700555, 2208.69448068], - [46745.93489784, 10163.60610747, 2208.69448103], - [46749.26552897, 10181.84805565, 2208.69448139], - [46734.70673532, 10321.10384505, 2208.69448978], - [47088.34611517, 10197.16412685, 2208.69441261]], - - [[46742.96936532, 10148.75700551, 2309.07224754], - [46745.93489789, 10163.60610742, 2309.07224822], - [46749.26552908, 10181.84805553, 2309.07224889], - [46734.70673602, 10321.10384442, 2309.07226358], - [47088.34615732, 10197.16411259, 2309.07212736]], - - [[46742.96936537, 10148.7570058 , 2409.05741588], - [46745.93489773, 10163.60610778, 2409.05741596], - [46749.26552867, 10181.84805629, 2409.05741603], - [46734.70672967, 10321.10384614, 2409.05741557], - [47088.34622085, 10197.1640911 , 2409.05741593]], - - [[46742.96936549, 10148.75700638, 2509.23868709], - [46745.9348973 , 10163.60610855, 2509.23868704], - [46749.26552758, 10181.84805796, 2509.23868697], - [46734.70671554, 10321.10385106, 2509.238685 ], - [47088.34622949, 10197.16408818, 2509.23871276]], - - [[46742.96936552, 10148.75700655, 2609.61620825], - [46745.93489706, 10163.60610881, 2609.61620776], - [46749.26552705, 10181.84805855, 2609.61620728], - [46734.70671084, 10321.10385379, 2609.6162004 ], - [47088.34611578, 10197.16412664, 2609.61628705]], - - [[46742.9693653 , 10148.75700542, 2710.18969749], - [46745.9348977 , 10163.60610738, 2710.1896972 ], - [46749.26552874, 10181.84805553, 2710.18969693], - [46734.70673655, 10321.10384672, 2710.18969931], - [47088.3458832 , 10197.16420531, 2710.18969727]], - - [[46742.96936487, 10148.75700325, 2810.66449925], - [46745.93489931, 10163.6061045 , 2810.66449892], - [46749.26553275, 10181.84804932, 2810.66449861], - [46734.70679022, 10321.1038287 , 2810.66449504], - [47088.34577834, 10197.16424078, 2810.66451183]], - - [[46742.96936474, 10148.75700262, 2911.04314321], - [46745.93490021, 10163.60610352, 2911.04314344], - [46749.26553477, 10181.8480471 , 2911.04314364], - [46734.70681062, 10321.10381874, 2911.04313384], - [47088.34605608, 10197.16414684, 2911.04315395]], - - [[46742.96936555, 10148.75700667, 3011.32151294], - [46745.93489783, 10163.60610867, 3011.32151396], - [46749.26552855, 10181.84805807, 3011.3215149 ], - [46734.70671628, 10321.10384499, 3011.32150451], - [47088.34684001, 10197.16388167, 3011.32151384]], - - [[46742.96936706, 10148.75701423, 3111.65076962], - [46745.93489222, 10163.60611871, 3111.65076921], - [46749.26551459, 10181.84807962, 3111.65076872], - [46734.70652534, 10321.10390696, 3111.65073721], - [47088.34744288, 10197.16367775, 3111.65097589]], - - [[46742.96936749, 10148.75701641, 3212.02303714], - [46745.93488918, 10163.60612209, 3212.0230365 ], - [46749.26550774, 10181.84808708, 3212.02303592], - [46734.7064496 , 10321.10393974, 3212.02306591], - [47088.34690224, 10197.16386062, 3212.02292005]], - - [[46742.96936219, 10148.75698984, 3312.46867342], - [46745.93489476, 10163.60609175, 3312.46866402], - [46749.26552847, 10181.84801953, 3312.46865015], - [46734.70676068, 10321.1038487 , 3312.46856864], - [47088.34444316, 10197.16469241, 3312.46854996]], - - [[46742.96805308, 10148.75735307, 3412.83493744], - [46745.93366911, 10163.60642599, 3412.83428729], - [46749.26439947, 10181.84818522, 3412.83354587], - [46734.70761225, 10321.10351773, 3412.81685678], - [47088.34314868, 10197.16513027, 3412.94268848]], - - [[46742.94655217, 10148.7648539 , 3513.21040914], - [46745.91222181, 10163.6139084 , 3513.20910571], - [46749.24299705, 10181.85545605, 3513.20740106], - [46734.68823956, 10321.10995862, 3513.16914215], - [47088.34600707, 10197.16416343, 3513.4187214 ]], - - [[46742.86979392, 10148.79193855, 3613.74882688], - [46745.83502806, 10163.64114501, 3613.74689967], - [46749.16529175, 10181.88257225, 3613.74395711], - [46734.60380338, 10321.13841419, 3613.68032772], - [47088.34918754, 10197.16308768, 3614.02316661]], - - [[46742.69725828, 10148.85217193, 3713.94081028], - [46745.66235554, 10163.70142747, 3713.9348747 ], - [46748.99240931, 10181.94153819, 3713.92695368], - [46734.43670888, 10321.19453044, 3713.75243925], - [47088.37090718, 10197.15574112, 3714.8448614 ]], - - [[46742.38079436, 10148.96306685, 3814.42879916], - [46745.34561025, 10163.81242296, 3814.41882198], - [46748.67518755, 10182.05060819, 3814.40545945], - [46734.13066045, 10321.29764596, 3814.10803485], - [47088.38527161, 10197.1508826 , 3815.98228758]], - - [[46741.86581769, 10149.14524653, 3915.18535377], - [46744.82778689, 10163.9955961 , 3915.17159559], - [46748.15394435, 10182.23349903, 3915.15259839], - [46733.57232843, 10321.48704715, 3914.73239564], - [47088.31827609, 10197.17354431, 3917.30641053]], - - [[46741.09585641, 10149.41609169, 4015.59820703], - [46744.0553275 , 10164.26731587, 4015.57765237], - [46747.37836699, 10182.50247644, 4015.54953423], - [46732.78336367, 10321.75451872, 4014.9337758 ], - [47088.21766434, 10197.20757804, 4018.71990229]], - - [[46740.00308515, 10149.79931549, 4116.19402857], - [46742.96099502, 10164.65109165, 4116.16680454], - [46746.28177952, 10182.88044816, 4116.12913178], - [46731.71524956, 10322.1166246 , 4115.30414117], - [47088.06530234, 10197.25911981, 4120.33881362]], - - [[46738.518718 , 10150.32303342, 4216.95171358], - [46741.47006292, 10165.17710313, 4216.91828512], - [46744.78287983, 10183.40389963, 4216.870952 ], - [46730.15144044, 10322.64897933, 4215.83823914], - [47087.68799305, 10197.38677039, 4222.02672075]], - - [[46736.57940818, 10151.0056644 , 4317.45216708], - [46739.52401457, 10165.86209209, 4317.40955476], - [46742.82851838, 10184.08321953, 4317.34910111], - [46728.15590441, 10323.32855993, 4316.04389626], - [47087.16226127, 10197.56466665, 4323.81821051]], - - [[46734.10832038, 10151.87273212, 4418.14283696], - [46737.04846217, 10166.73073208, 4418.09132588], - [46740.34693013, 10184.94027927, 4418.01750828], - [46725.71909193, 10324.15824988, 4416.42800436], - [47086.51360735, 10197.7842073 , 4425.83073673]], - - [[46731.03961152, 10152.95388317, 4519.00604298], - [46733.96794035, 10167.81601301, 4518.94615659], - [46737.252023 , 10186.01848073, 4518.85896696], - [46722.53489447, 10325.24524637, 4516.98706817], - [47085.49019021, 10198.13069818, 4527.9470915 ]], - - [[46727.33050467, 10154.25922793, 4619.57403011], - [46730.24628279, 10169.12575051, 4619.50243073], - [46733.51495112, 10187.31753665, 4619.39827345], - [46718.73090165, 10326.54447347, 4617.18238517], - [47084.20992246, 10198.56433303, 4630.14984973]], - - [[46722.92517277, 10155.80563215, 4720.25802417], - [46725.83203105, 10170.67529087, 4720.17513248], - [46729.08900164, 10188.84799076, 4720.0542401 ], - [46714.36565369, 10328.03527877, 4717.48927058], - [47082.75007083, 10199.05903384, 4732.5466053 ]], - - [[46717.79558679, 10157.61201871, 4821.05048661], - [46720.68391355, 10172.48815837, 4820.95674798], - [46723.9183216 , 10190.64771767, 4820.8192955 ], - [46709.08181038, 10329.84326332, 4817.90974977], - [47080.79567896, 10199.72169814, 4835.02697759]], - - [[46711.94600438, 10159.67069748, 4921.49461858], - [46714.81494422, 10174.55362456, 4921.38603435], - [46718.02559834, 10192.69600052, 4921.22811477], - [46703.10218387, 10331.89041186, 4917.91286461], - [47078.51834271, 10200.49437212, 4937.6168773 ]], - - [[46705.33960366, 10161.99063134, 5022.03020548], - [46708.19456687, 10176.87846995, 5021.90742541], - [46711.38710445, 10194.99302888, 5021.72981919], - [46696.54877289, 10334.13421615, 5018.01187575], - [47076.03741081, 10201.33669705, 5040.42790853]], - - [[46697.96950421, 10164.58626571, 5122.6670116 ], - [46700.79839605, 10179.48322387, 5122.53029741], - [46703.95923846, 10197.57787848, 5122.33309677], - [46688.98766577, 10336.72739764, 5118.21639513], - [47072.95028487, 10202.38558105, 5143.33792969]], - - [[46689.87799102, 10167.4348211 , 5222.86818572], - [46692.68018282, 10182.34112944, 5222.71254818], - [46695.80838173, 10200.41148985, 5222.49111788], - [46680.73748678, 10339.55869775, 5217.91022385], - [47069.47119721, 10203.56850971, 5246.36669248]], - - [[46681.03329497, 10170.5419267 , 5323.15051101], - [46683.81669187, 10185.45484012, 5322.97701671], - [46686.920602 , 10203.48788765, 5322.73285791], - [46671.97552304, 10342.56644645, 5317.7020694 ], - [47065.75871771, 10204.83176403, 5349.64702405]], - - [[46671.44628866, 10173.91944442, 5423.54075985], - [46674.19597996, 10188.84415012, 5423.3493311 ], - [46677.2589986 , 10206.85013313, 5423.08222366], - [46662.17204794, 10345.93697512, 5417.60149705], - [47061.31337708, 10206.34567963, 5453.03738995]], - - [[46661.19752677, 10177.52899778, 5523.40651901], - [46663.91340914, 10192.4655461 , 5523.19084905], - [46666.93518122, 10210.43986339, 5522.89506019], - [46651.74958922, 10349.52281062, 5516.89169291], - [47056.39852276, 10208.02109001, 5556.5682656 ]], - - [[46650.24634646, 10181.37734005, 5623.38268251], - [46652.93963893, 10196.32183098, 5623.14454943], - [46655.93213303, 10214.24894841, 5622.82255911], - [46640.93401897, 10353.2452476 , 5616.32636154], - [47051.217523 , 10209.78899074, 5660.40372528]], - - [[46638.61339153, 10185.4774107 , 5723.5111743 ], - [46641.26585672, 10200.43618925, 5723.24993312], - [46644.20896418, 10218.32904963, 5722.90082072], - [46629.07447052, 10357.33285335, 5715.90149098], - [47045.16036652, 10211.85808409, 5764.37120519]], - - [[46626.40039436, 10189.78066119, 5823.0332449 ], - [46629.01276657, 10204.75348541, 5822.74091962], - [46631.90713253, 10222.60719228, 5822.35705194], - [46616.69129898, 10361.60382307, 5814.76382783], - [47038.54338537, 10214.12105186, 5868.50817747]], - - [[46613.44619202, 10194.3336463 , 5922.74370255], - [46616.03369199, 10209.31522112, 5922.42328427], - [46618.89569746, 10227.11111941, 5922.00786428], - [46603.95510568, 10365.99793241, 5913.85725277], - [47031.61910773, 10216.49194469, 5973.01553499]], - - [[46599.65720374, 10199.19520734, 6022.68067429], - [46602.19682705, 10214.19353748, 6022.33058535], - [46605.00140234, 10231.94700049, 6021.88091781], - [46589.94631802, 10370.83678185, 6013.13141161], - [47023.65328627, 10219.22287767, 6077.6863851 ]], - - [[46585.09129996, 10204.32882709, 6121.83976262], - [46587.5841504 , 10219.34353999, 6121.4497233 ], - [46590.33237189, 10237.04864071, 6120.95571452], - [46575.22933107, 10375.9228113 , 6111.49349754], - [47015.02384551, 10222.18521716, 6182.56836371]], - - [[46569.52800855, 10209.79881913, 6221.19174643], - [46571.9939097 , 10224.82301693, 6220.76645988], - [46574.70701589, 10242.45714108, 6220.23342772], - [46559.99068201, 10381.19007661, 6210.12349697], - [47006.03879523, 10225.27366913, 6287.91449163]], - - [[46552.87250553, 10215.67184669, 6320.7935745 ], - [46555.28223466, 10230.7157007 , 6320.32978435], - [46557.9286092 , 10248.29685546, 6319.75333234], - [46543.13009442, 10387.02345078, 6308.9292353 ], - [46995.81883211, 10228.79124407, 6393.47075309]], - - [[46535.2665417 , 10221.87745324, 6419.34764726], - [46537.62219376, 10236.94024332, 6418.83199076], - [46540.2039653 , 10254.46143753, 6418.19929204], - [46525.40465903, 10393.15844646, 6406.52947622], - [46984.80996923, 10232.58540632, 6499.28998222]], - - [[46516.41028673, 10228.5035615 , 6518.11078327], - [46518.73811842, 10243.57614635, 6517.55075383], - [46521.28327179, 10261.01041444, 6516.87051527], - [46507.01203433, 10399.52519624, 6504.46369016], - [46973.37842898, 10236.53023574, 6605.70336548]], - - [[46496.19484624, 10235.6314464 , 6617.15825412], - [46498.45793464, 10250.72667888, 6616.5479354 ], - [46500.92688705, 10268.09509393, 6615.81304015], - [46486.62529648, 10406.58777474, 6602.564675 ], - [46960.46975027, 10240.99017305, 6712.39197846]], - - [[46474.88993509, 10243.13999201, 6714.79698525], - [46477.09216847, 10258.25652205, 6714.11892703], - [46479.48894327, 10275.55104391, 6713.31287029], - [46465.25782578, 10413.99254547, 6699.06934628], - [46946.59858998, 10245.78804822, 6819.40884796]], - - [[46452.05950462, 10251.15909374, 6812.66835977], - [46454.23588349, 10266.28472718, 6811.93454353], - [46456.59751026, 10283.47355745, 6811.07128628], - [46443.07871071, 10421.67944933, 6796.00475016], - [46932.1899162 , 10250.77664249, 6927.19029631]], - - [[46427.56867708, 10259.79168664, 6910.86572529], - [46429.67274706, 10274.94259329, 6910.06618413], - [46431.94993266, 10292.05019125, 6909.13454835], - [46418.48582246, 10430.2085148 , 6893.08717358], - [46915.99215971, 10256.38901455, 7035.33994124]], - - [[46401.88873533, 10268.83836457, 7007.18102476], - [46403.92752766, 10284.01208872, 7006.29322773], - [46406.12757478, 10301.02954041, 7005.27211471], - [46392.84327781, 10439.10394639, 6988.06763611], - [46898.607565 , 10262.41586907, 7143.90137599]], - - [[46374.36721699, 10278.49683286, 7103.75012362], - [46376.38774006, 10293.67698476, 7102.79341171], - [46378.55990261, 10310.56771037, 7101.70504096], - [46366.22774436, 10448.33762674, 7083.60690497], - [46880.52996032, 10268.68500361, 7253.45226388]], - - [[46344.82712143, 10288.90125236, 7200.68533337], - [46346.77131948, 10304.1080346 , 7199.64385016], - [46348.85466549, 10320.90048856, 7198.47123443], - [46336.70713882, 10458.58443701, 7179.24867502], - [46860.2725756 , 10275.71224354, 7363.49271327]], - - [[46314.0520279 , 10299.73324126, 7295.12042618], - [46315.93177977, 10314.96248938, 7293.96541656], - [46317.93854951, 10331.647132 , 7292.68172689], - [46306.12907456, 10469.20017323, 7272.1337409 ], - [46838.52331295, 10283.25948035, 7474.03085813]], - - [[46281.04353301, 10311.30066476, 7389.82212471], - [46282.92165556, 10326.53046439, 7388.58447465], - [46284.91680079, 10343.06582034, 7387.22442035], - [46274.36892246, 10480.22648987, 7365.74034512], - [46815.82752695, 10291.13778192, 7585.84269343]], - - [[46245.56265937, 10323.78067203, 7484.90702817], - [46247.36702921, 10339.03610746, 7483.5624093 ], - [46249.27560596, 10355.45457969, 7482.10088663], - [46239.10931622, 10492.47201848, 7459.35556195], - [46790.42382992, 10299.95908323, 7698.29538523]], - - [[46208.881041 , 10336.67206828, 7576.70802519], - [46210.63014936, 10351.94664078, 7575.22149715], - [46212.47094555, 10368.2386693 , 7573.62592271], - [46202.86373353, 10505.06062377, 7549.40855992], - [46763.10070218, 10309.45025897, 7811.3011131 ]], - - [[46169.48564778, 10350.44709826, 7668.74920258], - [46171.26246624, 10365.71185581, 7667.17021547], - [46173.11997227, 10381.83259222, 7665.49449404], - [46165.15377227, 10518.15662753, 7640.37281566], - [46734.43486616, 10319.41136513, 7925.90932531]], - - [[46127.07909342, 10365.32956266, 7761.09647982], - [46128.79661336, 10380.61473858, 7759.38887567], - [46130.58127843, 10396.60130485, 7757.59663816], - [46123.27020628, 10532.70399861, 7731.11810462], - [46702.36331671, 10330.55989205, 8041.28126979]], - - [[46083.61692859, 10380.56566659, 7849.27551072], - [46085.30240219, 10395.86167748, 7847.39923927], - [46087.04155294, 10411.70591755, 7845.45275208], - [46080.57694228, 10547.53059869, 7817.40883174], - [46667.80412244, 10342.57746524, 8157.15194767]], - - [[46036.88962611, 10396.85056968, 7937.55155912], - [46038.64902986, 10412.12045061, 7935.58450433], - [46040.44885295, 10427.77670667, 7933.56571282], - [46036.06067256, 10562.98605854, 7904.76182154], - [46631.31668968, 10355.26993993, 8274.91438722]], - - [[45986.56386448, 10414.4526999 , 8025.87252717], - [45988.29433113, 10429.73214448, 8023.76299754], - [45990.04971295, 10445.24161858, 8021.62081904], - [45986.66826785, 10580.13177058, 7991.45120369], - [46590.5030423 , 10369.47203555, 8393.42414444]], - - [[45935.44841141, 10432.30687533, 8109.14733443], - [45937.18510598, 10447.58353668, 8106.85295113], - [45938.93088266, 10462.94061421, 8104.5465349 ], - [45936.73142708, 10597.45944996, 8072.82791957], - [46546.46100302, 10384.80231029, 8512.0871482 ]], - - [[45880.46841712, 10451.38277327, 8192.22199226], - [45882.33918674, 10466.6121182 , 8189.86013142], - [45884.20178133, 10481.7749207 , 8187.50855263], - [45884.51572377, 10615.56787208, 8155.33702058], - [46499.62059652, 10401.11111101, 8632.79277988]], - - [[45821.54593108, 10471.89568506, 8274.53878014], - [45823.4339081 , 10487.11818241, 8272.03992876], - [45825.29466752, 10502.13120254, 8269.57508074], - [45827.01066957, 10635.49967805, 8236.17519027], - [46447.46858677, 10419.27340996, 8753.50342716]], - - [[45761.71234829, 10492.69516846, 8351.72280476], - [45763.65098467, 10507.89912843, 8349.04812675], - [45765.54289998, 10522.76186718, 8346.43111507], - [45768.72809996, 10655.68523575, 8311.68842987], - [46391.44074437, 10438.78867396, 8873.19094867]], - - [[45697.92019611, 10514.71007182, 8427.72990742], - [45700.04756252, 10529.84752654, 8425.03920057], - [45702.10678835, 10544.52906213, 8422.42658038], - [45708.04770503, 10676.68111901, 8387.72197087], - [46331.90120746, 10459.5286901 , 8993.99627108]], - - [[45631.04889119, 10537.85405606, 8501.49177493], - [45633.23962259, 10552.96830167, 8498.69996659], - [45635.34019482, 10567.51406647, 8496.00842281], - [45642.92527543, 10699.19009516, 8460.46057957], - [46266.75889388, 10482.21966452, 9112.50113004]], - - [[45562.24355013, 10561.63703047, 8572.00210625], - [45564.51185868, 10576.72314572, 8569.07620866], - [45566.66878164, 10591.14003918, 8566.27095043], - [45575.71535448, 10722.39102296, 8529.84586277], - [46197.62410704, 10506.29735481, 9227.99559827]], - - [[45491.09726589, 10586.05029024, 8639.96802164], - [45493.567649 , 10601.06522762, 8637.08848976], - [45495.90569294, 10615.33987533, 8634.34140911], - [45507.45863368, 10745.91768644, 8598.46649916], - [46125.55198875, 10531.38998059, 9341.58359201]], - - [[45418.29785473, 10611.07576954, 8704.97425383], - [45420.85253086, 10626.06007417, 8702.04754645], - [45423.25466433, 10640.23293222, 8699.26736993], - [45436.36197748, 10770.38223056, 8663.01907127], - [46048.35524099, 10558.25325574, 9450.6208992 ]], - - [[45342.83800475, 10636.97438711, 8768.10212275], - [45345.48393484, 10651.92571899, 8765.09897056], - [45347.95924289, 10666.0047971 , 8762.25412395], - [45362.3806817 , 10795.79084711, 8725.58040427], - [45967.15338468, 10586.48992565, 9555.80969482]], - - [[45265.97619128, 10663.16665698, 8828.46775817], - [45268.81260487, 10678.05096467, 8825.56342748], - [45271.46330923, 10692.0367052 , 8822.81846282], - [45287.98930679, 10821.28467523, 8786.99990845], - [45883.65036887, 10615.49922039, 9657.86927154]], - - [[45188.20224332, 10689.68599442, 8885.93330103], - [45191.12791079, 10704.53796998, 8883.0319807 ], - [45193.85399725, 10718.46204066, 8880.29416141], - [45211.73505636, 10847.3548982 , 8844.44151412], - [45795.96307945, 10645.92541898, 9754.67837359]], - - [[45107.4887254 , 10717.14664375, 8942.11915143], - [45110.50761518, 10731.9650416 , 8939.1974125 ], - [45113.31596059, 10745.83486338, 8936.44135018], - [45132.3157263 , 10874.43555423, 8900.49329419], - [45705.13817217, 10677.3932165 , 9847.20937485]], - - [[45026.20965093, 10744.60680322, 8995.4943545 ], - [45029.39400763, 10759.36710833, 8992.70859869], - [45032.36198675, 10773.19179216, 8990.08063203], - [45053.04630774, 10901.3860426 , 8955.12017869], - [45613.09107288, 10709.22759494, 9936.16602012]], - - [[44944.64061513, 10772.14953484, 9046.11229955], - [44947.90680473, 10786.88023736, 9043.36960373], - [44950.95172206, 10800.68259796, 9040.78053763], - [44972.74445807, 10928.6020007 , 9005.99328411], - [45518.20729027, 10741.97452255, 10019.78821199]], - - [[44860.1027919 , 10800.6097834 , 9095.72765705], - [44863.45478818, 10815.30969034, 9093.00884931], - [44866.58349788, 10829.09483435, 9090.43840837], - [44889.28691747, 10956.79059872, 9055.75658795], - [45421.06156048, 10775.42082651, 10099.3478961 ]], - - [[44775.39394488, 10828.92775361, 9142.78071353], - [44778.88252083, 10843.57989417, 9140.21824822], - [44782.15171076, 10857.35970684, 9137.79107738], - [44806.17467491, 10984.75794714, 9104.11338281], - [45323.34854364, 10808.96990696, 10175.61346985]], - - [[44690.64291659, 10857.20985325, 9187.38897808], - [44694.20116715, 10871.83684918, 9184.89753593], - [44697.54422553, 10885.62683665, 9182.53217324], - [44722.45016242, 11012.82006157, 9149.14008637], - [45223.74359417, 10843.06304044, 10247.05899274]], - - [[44603.15981773, 10886.29362753, 9231.03365258], - [44606.79094253, 10900.89464977, 9228.59822834], - [44610.21341563, 10914.69818704, 9226.27941788], - [44635.83974375, 11041.72789161, 9193.10596834], - [45122.66612097, 10877.5408456 , 10314.83168802]], - - [[44515.75869715, 10915.14929089, 9272.36035664], - [44519.49636936, 10929.71336726, 9270.08903792], - [44523.03726812, 10943.54134564, 9267.91983822], - [44549.68081068, 11070.36098463, 9235.7082402 ], - [45021.48675266, 10911.92239309, 10379.75229065]], - - [[44428.49243245, 10943.88987529, 9311.49020644], - [44432.28391808, 10958.43470004, 9309.3059436 ], - [44435.88977919, 10972.29604427, 9307.21303407], - [44463.22256299, 11098.96856181, 9275.33597959], - [44919.17011321, 10946.5462525 , 10440.48313125]], - - [[44338.78578138, 10973.31547539, 9349.63440584], - [44342.63385372, 10987.84036058, 9347.52364646], - [44346.30866241, 11001.73520054, 9345.49403286], - [44374.20160158, 11128.29245028, 9313.88739196], - [44815.92606894, 10981.32594251, 10498.05411579]], - - [[44249.27944267, 11002.48305617, 9385.72734083], - [44253.20579234, 11016.98107077, 9383.7750988 ], - [44256.97504165, 11030.91623577, 9381.89121464], - [44285.64412961, 11157.33008726, 9351.17414279], - [44712.80487424, 11015.89478481, 10553.25593443]], - - [[44159.97258524, 11031.49991202, 9419.90481975], - [44163.93644129, 11045.98455386, 9418.04319402], - [44167.75777835, 11059.96396756, 9416.24031937], - [44196.951647 , 11186.2777773 , 9385.87164667], - [44608.99000005, 11050.51796393, 10604.91785406]], - - [[44068.60039684, 11061.06113232, 9453.09969329], - [44072.60458065, 11075.53166948, 9451.31600039], - [44076.48091875, 11089.55264736, 9449.58239392], - [44106.0993197 , 11215.79027967, 9419.50341285], - [44504.61575708, 11085.1457699 , 10653.90425212]], - - [[43977.48565276, 11090.35325107, 9484.5339444 ], - [43981.54413848, 11104.80530564, 9482.89399855], - [43985.49154569, 11118.8704449 , 9481.294516 ], - [44015.69487709, 11245.01554787, 9452.02572447], - [44400.4646128 , 11119.51644218, 10700.95639374]], - - [[43886.6129316 , 11119.46771977, 9514.33596548], - [43890.69444581, 11133.91149612, 9512.78139496], - [43894.67967656, 11148.02199307, 9511.26004796], - [43925.27096084, 11274.10493969, 9482.33481124], - [44295.91971964, 11153.83180476, 10745.04357224]], - - [[43794.01794787, 11148.9996094 , 9543.2214724 ], - [43798.12498596, 11163.43444937, 9541.7407967 ], - [43802.14986332, 11177.58615779, 9540.28705186], - [43833.04835761, 11303.62562045, 9511.65352889], - [44191.05828944, 11188.06332694, 10786.88432634]], - - [[43701.70246135, 11178.26088776, 9570.63646726], - [43705.8439455 , 11192.68405107, 9569.28044686], - [43709.91804002, 11206.87620734, 9567.9449874 ], - [43741.24379289, 11332.86373882, 9540.04512766], - [44086.45112517, 11222.02321446, 10827.13654349]], - - [[43609.64383907, 11207.32820595, 9596.69857491], - [43613.79603859, 11221.74731447, 9595.41768084], - [43617.8936274 , 11235.97975917, 9594.15258009], - [43649.49088657, 11361.93634324, 9566.58220618], - [43981.628323 , 11255.86283184, 10864.8909401 ]], - - [[43516.16245018, 11236.70323017, 9621.95724303], - [43520.32785974, 11251.117595 , 9620.74111298], - [43524.45032417, 11265.38539993, 9619.53678459], - [43556.25571712, 11391.32505077, 9592.25017198], - [43876.63217602, 11289.56617419, 10900.74353728]], - - [[43422.96150261, 11265.81107906, 9646.02501437], - [43427.14588265, 11280.2189629 , 9644.91295487], - [43431.29857482, 11294.51893136, 9643.80901251], - [43463.40390032, 11420.43813274, 9617.18492595], - [43771.88453663, 11322.99675038, 10935.27296418]], - - [[43330.00343837, 11294.71929872, 9669.00388926], - [43334.18869144, 11309.12641495, 9667.95411155], - [43338.35172937, 11323.45790086, 9666.90973644], - [43370.63534075, 11449.37003247, 9640.59547448], - [43667.02797044, 11356.26774156, 10967.69080801]], - - [[43235.85369059, 11323.84958077, 9691.27829656], - [43240.04219024, 11338.25525352, 9690.28175649], - [43244.21673322, 11352.61339326, 9689.28844304], - [43276.63428897, 11478.52726861, 9663.24284086], - [43562.08850501, 11389.36907669, 10998.50310598]], - - [[43141.93669176, 11352.72860139, 9712.52981405], - [43146.13237482, 11367.13165837, 9711.6179923 ], - [43150.32150802, 11381.51241425, 9710.70758097], - [43182.95061893, 11507.42582336, 9685.2569721 ], - [43457.37561256, 11422.20226913, 11028.21099581]], - - [[43048.21691631, 11381.41216859, 9732.80599076], - [43052.40612574, 11395.81684042, 9731.94429637], - [43056.59497902, 11410.22030759, 9731.08267153], - [43089.34169472, 11536.14070226, 9705.91712364], - [43352.61385394, 11454.85291825, 11056.11234492]], - - [[42953.49124066, 11410.24767274, 9752.38765504], - [42957.67632124, 11424.65327554, 9751.56887269], - [42961.86628771, 11439.07559553, 9750.74912557], - [42994.69531747, 11565.00964924, 9725.83338193], - [43247.8188045 , 11487.31467426, 11082.62699274]], - - [[42858.9608225 , 11438.8427068 , 9771.04664158], - [42863.14444736, 11453.24845054, 9770.29684292], - [42867.33725291, 11467.68564697, 9769.54535287], - [42900.30871439, 11593.63400735, 9745.16497608], - [43143.22022194, 11519.51557658, 11108.19082347]], - - [[42764.59638943, 11467.24315421, 9788.82498381], - [42768.76809168, 11481.65221382, 9788.11522994], - [42772.95252885, 11496.1050088 , 9787.40324774], - [42805.99067103, 11622.07176249, 9763.28472766], - [43038.60609016, 11551.52016395, 11132.18861697]], - - [[42669.38452367, 11495.73621979, 9805.93518206], - [42673.54677634, 11510.14786396, 9805.25957659], - [42677.7245759 , 11524.61307059, 9804.58137361], - [42710.80279818, 11650.60278182, 9780.69429862], - [42933.98416583, 11583.32475072, 11154.98497462]], - - [[42574.33765871, 11523.99654758, 9822.21653739], - [42578.4924165 , 11538.41019865, 9821.59709046], - [42582.66457848, 11552.88398653, 9820.9749214 ], - [42615.83116279, 11678.89883932, 9797.57127355], - [42829.5292722 , 11614.87541523, 11176.96166861]], - - [[42479.4278915 , 11552.06345881, 9837.7073051 ], - [42483.56683035, 11566.48158513, 9837.11951841], - [42487.72464097, 11580.96515624, 9836.52892179], - [42520.91613435, 11707.00734833, 9813.3660452 ], - [42725.07592055, 11646.22140248, 11197.57220546]], - - [[42383.80649334, 11580.17246509, 9852.56824393], - [42387.93181158, 11594.59441386, 9852.00738168], - [42392.07693157, 11609.08531679, 9851.44370206], - [42425.26943341, 11735.15897798, 9828.49579887], - [42620.631658 , 11677.35925083, 11217.12915926]], - - [[42288.3226242 , 11608.05484819, 9866.68935249], - [42292.43567215, 11622.48021618, 9866.17454874], - [42296.56859894, 11636.97508106, 9865.65709686], - [42329.79964557, 11763.08384105, 9843.14825586], - [42516.32449041, 11708.2500079 , 11235.96346892]], - - [[42192.95573861, 11635.74316622, 9880.10484938], - [42197.04990374, 11650.17386538, 9879.61501787], - [42201.16389069, 11664.67417508, 9879.1226707 ], - [42234.38138781, 11790.81834786, 9856.83569459], - [42412.02701217, 11738.93062433, 11253.58226593]], - - [[42096.99046673, 11663.43146597, 9892.93803634], - [42101.06809478, 11677.86680082, 9892.46931998], - [42105.16511947, 11692.37058809, 9891.99825083], - [42138.35136176, 11818.5527963 , 9869.91078445], - [42307.73941846, 11769.39988963, 11270.25768478]], - - [[42001.13739755, 11690.89804364, 9905.11505245], - [42005.19958059, 11705.33768343, 9904.6845295 ], - [42009.2800296 , 11719.84209429, 9904.25193411], - [42042.46701516, 11846.0661782 , 9882.56471872], - [42203.56139924, 11799.62783047, 11286.28663863]], - - [[41905.3739898 , 11718.17249801, 9916.65778383], - [41909.41520019, 11732.61799972, 9916.24705737], - [41913.47359744, 11747.12476385, 9915.83447371], - [41946.61823949, 11873.38969413, 9894.35201071], - [42099.39367066, 11829.64241546, 11301.23254226]], - - [[41809.10132779, 11745.41295947, 9927.64708145], - [41813.12373905, 11759.86368095, 9927.25322546], - [41817.16209512, 11774.37155306, 9926.85772407], - [41850.24973657, 11900.67932499, 9905.5605987 ], - [41995.23920863, 11859.44166242, 11315.33766851]], - - [[41712.91440638, 11772.43606085, 9938.02358971], - [41716.91886822, 11786.89173856, 9937.66254311], - [41720.93759249, 11801.39881574, 9937.30012408], - [41753.99523231, 11927.75350758, 9916.36924303], - [41891.17261965, 11889.00334346, 11328.85979476]], - - [[41616.79761285, 11799.26691855, 9947.8030421 ], - [41620.77951812, 11813.7288137 , 9947.45858408], - [41624.77422064, 11828.23709473, 9947.11295611], - [41657.76623654, 11954.63662261, 9926.37116922], - [41787.11501389, 11918.34897903, 11341.39716342]], - - [[41520.24958246, 11826.03411222, 9957.04553537], - [41524.21115045, 11840.50157584, 9956.71555262], - [41528.1840614 , 11855.01040515, 9956.38458271], - [41561.09865424, 11981.45618379, 9935.81518255], - [41683.06306557, 11947.47837022, 11353.16202104]], - - [[41423.76796978, 11852.58607345, 9965.71243676], - [41427.70983886, 11867.05890028, 9965.4120248 ], - [41431.66137691, 11881.5672013 , 9965.11083324], - [41464.52075466, 12008.06366586, 9944.87824444], - [41579.07574207, 11976.37404954, 11364.38653439]], - - [[41327.33489308, 11878.94673924, 9973.81860646], - [41331.25299112, 11893.42601253, 9973.5330917 ], - [41335.17935725, 11907.93581109, 9973.24694718], - [41367.952417 , 12034.48051817, 9953.18986228], - [41475.09044744, 12005.05244538, 11374.70756426]], - - [[41230.54072676, 11905.21753359, 9981.40786585], - [41234.43717592, 11919.70263867, 9981.13569639], - [41238.34059641, 11934.21365265, 9980.86302457], - [41271.01656231, 12060.80789415, 9960.9670017 ], - [41371.10746186, 12033.5123249 , 11384.31750041]], - - [[41133.79579763, 11931.27458373, 9988.45739056], - [41137.67104503, 11945.76536668, 9988.21292687], - [41141.55190636, 11960.27715183, 9987.96809255], - [41174.14916171, 12086.92555355, 9968.38342594], - [41267.17114569, 12061.74053595, 11393.42635764]], - - [[41037.08551977, 11957.13948526, 9994.98084828], - [41040.93590748, 11971.6368903 , 9994.75054405], - [41044.79081842, 11986.15133137, 9994.51996061], - [41077.28220829, 12112.85112061, 9975.09882881], - [41163.23249755, 12089.74936594, 11401.70220927]], - - [[40940.07488085, 11982.89195785, 10001.01047203], - [40943.90244931, 11997.39540006, 10000.79316885], - [40947.73359489, 12011.91241262, 10000.57565889], - [40980.10939595, 12138.66471621, 9981.30552778], - [41059.28906105, 12117.53829664, 11409.31828733]], - - [[40843.09632478, 12008.43215464, 10006.53516405], - [40846.90134759, 12022.94152491, 10006.34451212], - [40850.70903974, 12037.46109514, 10006.15372134], - [40882.98465235, 12164.27059863, 9987.17278704], - [40955.37452199, 12145.0972455 , 11416.4621352 ]], - - [[40746.13737929, 12033.77989434, 10011.56763956], - [40749.91652209, 12048.29602636, 10011.39092277], - [40753.69764379, 12062.81977461, 10011.21411147], - [40785.84928699, 12189.68343617, 9992.38613868], - [40851.44976727, 12172.43549517, 11422.8265145 ]], - - [[40648.93350012, 12058.9950287 , 10016.13091297], - [40652.68877195, 12073.51735362, 10015.96720546], - [40656.44546854, 12088.04520667, 10015.80343516], - [40688.465577 , 12214.96369053, 9997.11754156], - [40747.51191695, 12199.55258075, 11428.56843724]], - - [[40551.74553739, 12083.99910272, 10020.21566114], - [40555.4771008 , 12098.52753823, 10020.07784978], - [40559.20960844, 12113.05966778, 10019.94000199], - [40591.11318344, 12240.03715361, 10001.5240309 ], - [40643.58544646, 12226.44096712, 11433.85683001]], - - [[40454.56177832, 12108.81063501, 10023.82288954], - [40458.26651545, 12123.34593549, 10023.6988904 ], - [40461.97185882, 12137.88362636, 10023.5748705 ], - [40493.73917305, 12264.91656877, 10005.30253974], - [40539.64187101, 12253.10686431, 11438.40398375]], - - [[40357.18033843, 12133.47237087, 10026.95890172], - [40360.86006518, 12148.01402432, 10026.84788141], - [40364.54015443, 12162.55712292, 10026.73684969], - [40396.1647094 , 12289.64599918, 10008.59864586], - [40435.67769979, 12279.54990607, 11442.35290785]], - - [[40259.8018782 , 12157.92342109, 10029.61600132], - [40263.45656129, 12172.47139132, 10029.53001731], - [40267.11142754, 12187.02010162, 10029.44402823], - [40298.60601513, 12314.16912608, 10011.55885598], - [40331.71193092, 12305.76405182, 11445.86114028]], - - [[40162.41160339, 12182.18196537, 10031.79469021], - [40166.03832994, 12196.73693171, 10031.7222062 ], - [40169.6651327 , 12211.29221075, 10031.64972037], - [40201.01188888, 12338.49783873, 10013.89992891], - [40227.71698176, 12331.75502157, 11448.66398512]], - - [[40064.8786675 , 12206.27225049, 10033.49687616], - [40068.47912162, 12220.83373971, 10033.43714634], - [40072.07959375, 12235.39531012, 10033.37741563], - [40103.27232618, 12362.65804996, 10015.75507971], - [40123.68178835, 12357.52421882, 11450.89560378]], - - [[39967.33873586, 12230.15141983, 10034.71917087], - [39970.91271765, 12244.71943174, 10034.68346246], - [39974.48669556, 12259.28743596, 10034.64775347], - [40005.53608512, 12386.61159781, 10017.26444939], - [40019.63362579, 12383.06358138, 11452.69675612]], - - [[39869.72467008, 12253.84921123, 10035.4618553 ], - [39873.26945077, 12268.42435695, 10035.43912415], - [39876.81422484, 12282.99948127, 10035.41639274], - [39907.70345155, 12410.38170733, 10018.16162485], - [39915.49288189, 12408.39116858, 11453.81380975]], - - [[39772.1416313 , 12277.33210351, 10035.72381189], - [39775.65895602, 12291.91390048, 10035.71369713], - [39779.17627595, 12306.49568594, 10035.70358176], - [39809.90107502, 12433.9364579 , 10018.57409906], - [39811.23255508, 12433.51170293, 11454.36936729]], - - [[39674.4476345 , 12300.62579877, 10035.50479405], - [39677.9372637 , 12315.2142507 , 10035.51810952], - [39681.42688145, 12329.80266307, 10035.53142433], - [39711.9977812 , 12457.30627759, 10018.63605354], - [39706.85378552, 12458.42297256, 11454.48373874]], - - [[39576.6154812 , 12323.74914808, 10034.80281065], - [39580.0747141 , 12338.34483742, 10034.82867771], - [39583.5339084 , 12352.94036908, 10034.85454417], - [39613.93398481, 12480.50363453, 10018.08590222], - [39602.3105083 , 12483.13387408, 11453.8966838 ]], - - [[39478.91937918, 12346.62954489, 10033.6196918 ], - [39482.34990413, 12361.23200835, 10033.65783648], - [39485.78032279, 12375.83402502, 10033.69597932], - [39516.00549957, 12503.45753651, 10017.05274027], - [39497.63899942, 12507.63369474, 11452.72037498]], - - [[39381.09737573, 12369.32053316, 10031.95217858], - [39384.49893018, 12383.92977349, 10032.0129958 ], - [39387.9002416 , 12398.53797096, 10032.07380817], - [39417.96089794, 12526.22604415, 10015.66367089], - [39392.83891072, 12531.92049491, 11451.06965802]], - - [[39283.13919757, 12391.83648602, 10029.79766773], - [39286.50907706, 12406.45306409, 10029.87033503], - [39289.87853101, 12421.06778902, 10029.94299279], - [39319.75785974, 12548.81717803, 10013.65975745], - [39287.87364312, 12556.00059929, 11448.68604137]], - - [[39185.35258925, 12414.09913925, 10027.15653789], - [39188.69222923, 12428.72265474, 10027.24071402], - [39192.03117667, 12443.34312288, 10027.32487155], - [39221.72470144, 12571.15451563, 10011.16779568], - [39182.75960134, 12579.86800882, 11445.68209928]], - - [[39087.42730328, 12436.17166686, 10024.00781484], - [39090.73629806, 12450.80214687, 10024.11339538], - [39094.04419413, 12465.42773764, 10024.21894025], - [39123.56273435, 12593.30531703, 10008.29500829], - [39077.50537083, 12603.51861822, 11442.17071258]], - - [[38989.34418164, 12458.0697965 , 10020.33535512], - [38992.62002119, 12472.70773381, 10020.45163436], - [38995.89431026, 12487.33869393, 10020.56785703], - [39025.22364277, 12615.27856346, 10004.77137931], - [38972.06647751, 12626.96012915, 11437.89064225]], - - [[38891.50104817, 12479.69743658, 10016.14144572], - [38894.74532598, 12494.34239925, 10016.26809188], - [38897.98748935, 12508.97774694, 10016.39465149], - [38927.12569649, 12636.98007911, 10000.72587679], - [38866.46404458, 12650.18546107, 11432.95208684]], - - [[38793.4981934 , 12501.13591848, 10011.39625373], - [38796.71052347, 12515.78792109, 10011.54272505], - [38799.91997311, 12530.42668064, 10011.68906136], - [38828.87771306, 12658.49577858, 9996.25540472], - [38760.7012831 , 12673.19160092, 11427.46229463]], - - [[38695.31698161, 12522.40010145, 10006.08254279], - [38698.4948504 , 12537.05961506, 10006.23834664], - [38701.66906075, 12551.70211941, 10006.3939632 ], - [38730.43075583, 12679.83430432, 9991.08948892], - [38654.73306459, 12695.98589686, 11421.15256802]], - - [[38597.44923134, 12543.37653167, 10000.21455707], - [38600.59421697, 12558.04313267, 10000.37940149], - [38603.73464811, 12572.68832305, 10000.54399224], - [38632.29853348, 12700.88377546, 9985.36962008], - [38548.58949252, 12718.55933942, 11414.13132343]], - - [[38499.40295623, 12564.16410172, 9993.75034577], - [38502.51479853, 12578.83776959, 9993.9332968 ], - [38505.62093754, 12593.48431585, 9994.11589536], - [38533.9985219 , 12721.74726889, 9979.18014609], - [38442.26835405, 12740.90995577, 11406.49980263]], - - [[38401.16477952, 12584.77579836, 9986.67187584], - [38404.24098814, 12599.45697681, 9986.86273506], - [38407.31038981, 12614.10540497, 9987.05314303], - [38435.48476725, 12742.43234473, 9972.24928754], - [38335.72897952, 12763.04368265, 11397.97690506]], - - [[38303.30899839, 12605.08458931, 9979.0084463 ], - [38306.3508854 , 12619.77291534, 9979.20696345], - [38309.38475117, 12634.4221927 , 9979.40491191], - [38337.35147064, 12762.81388885, 9964.73420507], - [38229.00134705, 12784.95162734, 11388.6681677 ]], - - [[38205.2665496 , 12625.20253467, 9970.70338478], - [38208.27372673, 12639.89800551, 9970.91821488], - [38211.27137287, 12654.54650821, 9971.13230941], - [38239.03984968, 12783.00773268, 9956.70438343], - [38122.08808301, 12806.63069503, 11378.67534941]], - - [[38107.00547119, 12645.14570416, 9961.73590982], - [38109.97560492, 12659.84870728, 9961.95716827], - [38112.93481271, 12674.49719941, 9962.17753271], - [38140.48790983, 12803.02428013, 9947.88409663], - [38014.93413968, 12828.08928281, 11367.7087876 ]], - - [[38009.22972033, 12664.76512006, 9952.15872955], - [38012.16434379, 12679.47525137, 9952.38630885], - [38015.08655239, 12694.12266379, 9952.61281308], - [38042.42246954, 12822.71584847, 9938.45623041], - [37907.57580225, 12849.31727441, 11355.87783047]], - - [[37911.23478735, 12684.19662939, 9941.8911585 ], - [37914.13366804, 12698.91384701, 9942.1334557 ], - [37917.01831972, 12713.55824061, 9942.37442779], - [37944.14798712, 12842.22215655, 9928.4670192 ], - [37800.00497999, 12870.31351234, 11343.27858327]], - - [[37812.9918527 , 12703.45472312, 9930.90715215], - [37815.85259107, 12718.1794062 , 9931.15466756], - [37818.69749251, 12732.82196617, 9931.40063055], - [37845.60197094, 12861.55305495, 9917.63195067], - [37692.167843 , 12891.08549438, 11329.60120831]], - - [[37715.34326201, 12722.36859983, 9919.28099151], - [37718.16722561, 12737.10038236, 9919.5338122 ], - [37720.9736973 , 12751.74024026, 9919.78482979], - [37747.65077704, 12880.53882376, 9906.15775466], - [37584.11755959, 12911.6199514 , 11314.95175562]], - - [[37617.45493676, 12741.09481573, 9906.89294558], - [37620.24195173, 12755.83363685, 9907.15937157], - [37623.00950252, 12770.46873559, 9907.42365603], - [37649.47162953, 12899.33932705, 9894.05358419], - [37475.84384598, 12931.91596 , 11299.41996579]], - - [[37519.29583667, 12759.647428 , 9893.70849278], - [37522.04353116, 12774.39363882, 9893.97951665], - [37524.77011308, 12789.02575476, 9894.24810539], - [37550.9973592 , 12917.96471675, 9881.02176053], - [37367.29005217, 12951.98098448, 11282.67505428]], - - [[37421.85648016, 12777.83399816, 9879.8410497 ], - [37424.56598349, 12792.58728189, 9880.11706437], - [37427.25274651, 12807.21590001, 9880.39032971], - [37453.24021012, 12936.22413273, 9867.31107021], - [37258.51212199, 12971.80150921, 11264.82351605]], - - [[37324.15452926, 12795.83342545, 9865.13108822], - [37326.82553766, 12810.59373696, 9865.42045205], - [37329.47189459, 12825.2168653 , 9865.70664775], - [37355.23039919, 12954.29947077, 9852.89313374], - [37149.50011227, 12991.37633505, 11245.94979859]], - - [[37226.14200752, 12813.66204064, 9849.53840799], - [37228.77226958, 12828.42968487, 9849.8326085 ], - [37231.37642099, 12843.04981768, 9850.12329654], - [37256.88651722, 12972.20280343, 9837.45840445], - [37040.18489591, 13010.71452986, 11225.69309145]], - - [[37129.01897341, 12831.09670972, 9833.23547905], - [37131.60987634, 12845.87132638, 9833.53546907], - [37134.17332613, 12860.48842719, 9833.83159027], - [37159.43465178, 12989.71202725, 9821.32041251], - [36930.64556771, 13029.79746286, 11204.16191479]], - - [[37031.59149619, 12848.3478126 , 9816.0033311 ], - [37034.14276475, 12863.12934343, 9816.31804152], - [37036.66503747, 12877.74177731, 9816.6284121 ], - [37061.69073834, 13007.04114718, 9804.39531566], - [36820.85349697, 13048.62211682, 11181.42795044]], - - [[36933.81371775, 12865.43027612, 9797.79793351], - [36936.3226551 , 12880.21908097, 9798.11933634], - [36938.80165114, 12894.83037635, 9798.43604253], - [36963.56833917, 13024.20171897, 9786.35862744], - [36710.74505617, 13067.19034273, 11157.09293687]], - - [[36837.10263716, 12882.08941394, 9778.86619003], - [36839.56990128, 12896.88525847, 9779.19574812], - [36842.00651935, 12911.49626631, 9779.52026025], - [36866.50864062, 13040.94025273, 9767.60317069], - [36600.42844424, 13085.47493197, 11131.26698822]], - - [[36740.05773488, 12898.55915608, 9758.9171753 ], - [36742.4824212 , 12913.36207068, 9759.26460038], - [36744.87568074, 12927.97191938, 9759.60652032], - [36769.12380336, 13057.49412079, 9747.97908318], - [36489.8702979 , 13103.47268784, 11104.00976761]], - - [[36642.61580929, 12914.85318247, 9737.89897961], - [36644.99465229, 12929.66358219, 9738.25648241], - [36647.34200846, 12944.27688018, 9738.60818549], - [36671.31029821, 13073.8734652 , 9727.14142723], - [36378.99941602, 13121.18592074, 11074.87549239]], - - [[36546.45773962, 12930.68254363, 9716.15956394], - [36548.79127638, 12945.50019983, 9716.52929891], - [36551.09361665, 12960.11867817, 9716.89297794], - [36574.77976561, 13089.79029782, 9705.59313795], - [36267.95158348, 13138.58386005, 11043.97603876]], - - [[36449.90014203, 12946.31804719, 9693.28065212], - [36452.18717755, 12961.14300771, 9693.67364714], - [36454.44335437, 12975.76669658, 9694.06024379], - [36477.86715055, 13105.51805429, 9683.06601878], - [36156.67739241, 13155.66548631, 11011.3475901 ]], - - [[36352.88744055, 12961.77237456, 9669.12818973], - [36355.12403269, 12976.6051122 , 9669.53632378], - [36357.33084734, 12991.23931983, 9669.93793376], - [36380.47560983, 13121.06450398, 9659.11573497], - [36045.1153218 , 13172.43087372, 10976.4828501 ]], - - [[36257.43563046, 12976.71756953, 9644.10871972], - [36259.62183739, 12991.55790959, 9644.53414908], - [36261.77967899, 13006.20466758, 9644.95299653], - [36284.65024269, 13136.1030051 , 9634.31201638], - [35933.46326626, 13188.84149829, 10939.50109787]], - - [[36161.58642962, 12991.45526328, 9617.61239379], - [36163.72063861, 13006.30327615, 9618.06705944], - [36165.82793911, 13020.96290517, 9618.5150277 ], - [36188.44742628, 13150.93890164, 9608.20374441], - [35821.65670581, 13204.8986573 , 10900.40807392]], - - [[36065.29735674, 13005.99681711, 9589.4834059 ], - [36067.37535905, 13020.85294969, 9589.95778037], - [36069.42851904, 13035.53053331, 9590.42559286], - [36091.78175726, 13165.57746809, 9580.30255573], - [35709.65987196, 13220.59874388, 10858.62419698]], - - [[35970.92789798, 13019.97973211, 9560.31749516], - [35972.94949142, 13034.8438179 , 9560.81355024], - [35974.94855037, 13049.54132457, 9561.3032738 ], - [35997.03719175, 13179.65937613, 9551.37917219], - [35597.73557876, 13235.89730693, 10814.2816159 ]], - - [[35876.20101725, 13033.73804785, 9529.31622523], - [35878.16392888, 13048.61020127, 9529.84710396], - [35880.10664434, 13063.32836497, 9530.37187164], - [35901.94830144, 13193.52364531, 9520.8049031 ], - [35485.79614644, 13250.79963043, 10767.34532301]], - - [[35781.08961323, 13047.28286945, 9496.30718201], - [35782.98964727, 13062.16345754, 9496.86131278], - [35784.87225617, 13076.90687891, 9497.40982582], - [35806.45577281, 13207.17147021, 9488.04869289], - [35373.86088524, 13265.29608764, 10717.15648045]], - - [[35688.31435537, 13060.22156803, 9462.14576412], - [35690.15110004, 13075.11041496, 9462.72491434], - [35691.97332753, 13089.88099871, 9463.29906482], - [35713.2967071 , 13220.21579418, 9454.15595167], - [35262.2997606 , 13279.33386569, 10663.87791156]], - - [[35595.27412369, 13072.91574546, 9425.78527559], - [35597.04422799, 13087.81304585, 9426.40414017], - [35598.80273554, 13102.61219963, 9427.01868399], - [35619.8736749 , 13233.02520717, 9418.26259291], - [35150.99805177, 13292.92398755, 10607.40591365]], - - [[35501.98801423, 13085.37356887, 9387.0498197 ], - [35503.68749001, 13100.27957305, 9387.6940533 ], - [35505.37851944, 13115.11123875, 9388.33491913], - [35526.19109415, 13245.59280339, 9379.80151825], - [35040.08520424, 13306.04743202, 10547.00501979]], - - [[35411.46242543, 13097.1893103 , 9347.10907135], - [35413.09039031, 13112.10385862, 9347.78006759], - [35414.71307855, 13126.96997032, 9348.4488025 ], - [35435.25806672, 13257.52200085, 9340.14968049], - [34930.0544807 , 13318.64630702, 10482.91174314]], - - [[35320.8453905 , 13108.73639509, 9304.6280377 ], - [35322.39694705, 13123.65979528, 9305.34228428], - [35323.94637086, 13138.56270127, 9306.05553168], - [35344.21767825, 13269.19552584, 9298.17065258], - [34820.76316591, 13330.73963156, 10414.96143462]], - - [[35230.22754196, 13120.01922275, 9259.4509654 ], - [35231.6996658 , 13134.95151006, 9260.1907322 ], - [35233.17280724, 13149.89420797, 9260.93099972], - [35253.17559085, 13280.59569645, 9253.28116683], - [34712.51016884, 13342.29814496, 10342.39815348]], - - [[35142.72331888, 13130.64689411, 9213.07478775], - [35144.11438044, 13145.58794952, 9213.84066785], - [35145.50946972, 13160.57221271, 9214.60870818], - [35165.22154853, 13291.34590195, 9207.20094825], - [34605.92904309, 13353.26369697, 10265.56874637]], - - [[35055.38510204, 13140.98148126, 9163.90317876], - [35056.68810596, 13155.9317592 , 9164.71269212], - [35057.99797407, 13170.96027044, 9165.52624893], - [35077.39084 , 13301.81949505, 9158.54694501], - [34500.86230818, 13363.66502458, 10184.2397518 ]], - - [[34968.41106426, 13151.02306294, 9111.86198928], - [34969.62615086, 13165.9821425 , 9112.69238334], - [34970.85061935, 13181.05506091, 9113.52876605], - [34989.9528592 , 13311.98347937, 9106.78308658], - [34397.85674843, 13373.46305188, 10097.77824734]], - - [[34884.65142759, 13160.43975653, 9058.60907041], - [34885.77693898, 13175.40747317, 9059.45898048], - [34886.91396588, 13190.52486242, 9060.31687474], - [34905.69434228, 13321.52684186, 9053.80707379], - [34297.62999748, 13382.61208945, 10006.72649398]], - - [[34801.47550477, 13169.53299599, 9002.41459147], - [34802.5013387 , 13184.51003945, 9003.30050656], - [34803.54043087, 13199.67371543, 9004.19658599], - [34821.94147778, 13330.7659073 , 8998.10952477], - [34199.99617779, 13391.15478713, 9910.85791587]], - - [[34719.26127273, 13178.29455202, 8943.31514848], - [34720.19446391, 13193.2797183 , 8944.20919711], - [34721.1421013 , 13208.48538015, 8945.11531128], - [34739.24238777, 13339.64448889, 8939.25190779], - [34105.77615055, 13399.04849472, 9809.87493108]], - - [[34640.07277303, 13186.50111307, 8882.70146908], - [34640.91304217, 13201.49407168, 8883.60071596], - [34641.76844094, 13216.73946398, 8884.51378463], - [34659.53475366, 13347.96984672, 8878.87506506], - [34015.58339001, 13406.2785771 , 9704.51089159]], - - [[34562.14856056, 13194.34095199, 8819.12262506], - [34562.88194701, 13209.3426988 , 8820.04078586], - [34563.63065793, 13224.62858264, 8820.97459746], - [34580.96603614, 13355.95105008, 8815.73622114], - [33929.21544397, 13412.89952187, 9594.63931536]], - - [[34486.08468251, 13201.79875406, 8752.84020145], - [34486.72829171, 13216.80716161, 8753.7466084 ], - [34487.38690864, 13232.12475751, 8754.66983388], - [34504.4366784 , 13363.50826555, 8749.64442442], - [33847.64017589, 13418.87831073, 9480.5881585 ]], - - [[34412.51232938, 13208.80883511, 8684.23432055], - [34413.06960446, 13223.82324991, 8685.12647546], - [34413.64117199, 13239.16831104, 8686.03629808], - [34430.38556838, 13370.6152289 , 8681.22984013], - [33770.99954031, 13424.24977448, 9363.23722945]], - - [[34341.22542663, 13215.39464852, 8612.93605707], - [34341.67929709, 13230.4163292 , 8613.82433736], - [34342.14633377, 13245.78901903, 8614.73111542], - [34358.45174106, 13377.32376165, 8610.29935083], - [33698.89335115, 13429.08408115, 9242.82853332]], - - [[34272.9893347 , 13221.54141036, 8539.54907344], - [34273.36525902, 13236.56771689, 8540.40434494], - [34273.75300117, 13251.95639394, 8541.27806752], - [34289.8160288 , 13383.54411014, 8537.06929184], - [33632.00208982, 13433.37692368, 9120.49209522]], - - [[34206.46553099, 13227.36875593, 8462.33729984], - [34206.77274169, 13242.39862879, 8463.15847941], - [34207.09028124, 13257.79702034, 8463.99765324], - [34222.91904293, 13389.43555672, 8460.02070946], - [33569.95040622, 13437.19274622, 8996.75374595]], - - [[34143.53420733, 13232.71315359, 8383.18527636], - [34143.75570634, 13247.74789271, 8383.97874743], - [34143.98583385, 13263.15562377, 8384.78971299], - [34159.42749003, 13394.86972478, 8381.159608 ], - [33512.36965956, 13440.58926996, 8872.20982075]], - - [[34084.94638043, 13237.57163476, 8303.13609706], - [34085.1110384 , 13252.60870078, 8303.88117309], - [34085.2827795 , 13268.01441686, 8304.64253927], - [34100.53706095, 13399.77239092, 8301.25742579], - [33459.74527175, 13443.57105274, 8748.40651522]], - - [[34027.16471845, 13242.24189075, 8217.32150552], - [34027.28795931, 13257.279886 , 8218.02149998], - [34027.41692901, 13272.67663804, 8218.73635111], - [34042.50696314, 13404.47204345, 8215.57995036], - [33411.38638309, 13446.20743696, 8625.07631359]], - - [[33973.95379354, 13246.4184774 , 8131.50287496], - [33974.02246635, 13261.45849174, 8132.16173416], - [33974.09554243, 13276.8465576 , 8132.83413763], - [33988.87167217, 13408.69991663, 8129.93670673], - [33367.22663143, 13448.52763978, 8503.59103187]], - - [[33924.91119032, 13250.18341487, 8045.90795424], - [33924.94871312, 13265.22367927, 8046.5165705 ], - [33924.98962924, 13280.59624706, 8047.137198 ], - [33939.60113966, 13412.48529574, 8044.42856266], - [33326.94403977, 13450.57110589, 8383.90888289]], - - [[33876.76873392, 13253.7929376 , 7955.04689793], - [33876.78769068, 13268.83248196, 7955.61203049], - [33876.80925614, 13284.18561975, 7956.18766436], - [33891.28724363, 13416.10421437, 7953.66288486], - [33289.75277048, 13452.39559381, 8264.90117739]], - - [[33832.37024327, 13257.03321904, 7864.4787358 ], - [33832.35969995, 13272.07296638, 7865.002419 ], - [33832.35103177, 13287.40828097, 7865.53529533], - [33846.5791445 , 13419.37156091, 7863.19113332], - [33255.55856366, 13454.02017122, 8147.46082399]], - - [[33791.2850122 , 13259.97023316, 7774.13626164], - [33791.2608517 , 13275.00914386, 7774.61588699], - [33791.23806127, 13290.32361635, 7775.10341611], - [33805.32365746, 13422.3169326 , 7772.90342878], - [33224.09855913, 13455.46991943, 8031.28176792]], - - [[33751.44653 , 13262.75702338, 7679.77101597], - [33751.41725309, 13277.79455204, 7680.21362914], - [33751.38898644, 13293.0865814 , 7680.66296694], - [33765.3613131 , 13425.10520591, 7678.61014185], - [33195.01261911, 13456.77181455, 7915.9898895 ]], - - [[33714.66021798, 13265.26845907, 7585.96947648], - [33714.61742275, 13280.30531594, 7586.3755547 ], - [33714.57530573, 13295.57689029, 7586.78736173], - [33728.34968861, 13427.63129282, 7584.8582333 ], - [33168.16929101, 13457.94044332, 7802.11272316]], - - [[33680.5637049 , 13267.55244008, 7492.59276204], - [33680.51764023, 13282.58805181, 7492.96314104], - [33680.47204729, 13297.83842448, 7493.33834237], - [33694.12507891, 13429.91894113, 7491.51760655], - [33143.37720643, 13458.99164315, 7689.36868125]], - - [[33647.74745779, 13269.70778695, 7396.15575519], - [33647.70328134, 13284.74188762, 7396.4970122 ], - [33647.65944216, 13299.97101098, 7396.84229924], - [33661.21493835, 13432.07431184, 7395.1386027 ], - [33120.37609116, 13459.94267779, 7577.3386245 ]], - - [[33617.29469969, 13271.66479026, 7300.20966743], - [33617.24601297, 13286.69790892, 7300.52135597], - [33617.19753546, 13301.9078029 , 7300.83641598], - [33630.59530635, 13434.04109778, 7299.21847414], - [33099.05717835, 13460.80328233, 7466.31089849]], - - [[33588.93169076, 13273.45624416, 7204.60915084], - [33588.88480305, 13288.48808275, 7204.89319499], - [33588.83805479, 13303.67905594, 7205.18003538], - [33602.13044659, 13435.83620352, 7203.64198026], - [33079.30221859, 13461.58278229, 7356.06928114]], - - [[33561.77058519, 13275.14163559, 7106.79515068], - [33561.72845249, 13290.17208279, 7107.05678783], - [33561.68641964, 13305.34468937, 7107.32072944], - [33574.89045376, 13437.52360233, 7105.8707412 ], - [33060.95881598, 13462.29106896, 7246.44388061]], - - [[33536.43381793, 13276.68366448, 7009.43960302], - [33536.39179878, 13291.71309622, 7009.67793407], - [33536.34983765, 13306.86904403, 7009.91816214], - [33549.42416339, 13439.07465036, 7008.52565887], - [33043.92672397, 13462.93531958, 7137.59076966]], - - [[33512.73985943, 13278.10332035, 6912.41407645], - [33512.70184087, 13293.1315859 , 6912.63136007], - [33512.66386213, 13308.27162962, 6912.85019155], - [33525.64613901, 13440.49990969, 6911.5199019 ], - [33028.12509239, 13463.52146374, 7029.36179831]], - - [[33490.13422231, 13279.4362692 , 6813.73866445], - [33490.10169875, 13294.46334364, 6813.93902367], - [33490.06920666, 13309.58837255, 6814.14063582], - [33502.97235292, 13441.83803343, 6812.885842 ], - [33013.44950587, 13464.05585035, 6921.60515881]], - - [[33469.01102389, 13280.66049065, 6715.40816395], - [33468.98048266, 13295.6866514 , 6715.59043848], - [33468.94996037, 13310.79816942, 6715.77372268], - [33481.74569528, 13443.0725979 , 6714.56895509], - [32999.82734091, 13464.54325698, 6814.40128035]], - - [[33449.25333488, 13281.78962387, 6617.32365176], - [33449.22716127, 13296.81480095, 6617.48993524], - [33449.20100307, 13311.91371182, 6617.65702406], - [33461.91636331, 13444.21017362, 6616.51000002], - [32987.20719814, 13464.98739236, 6707.65224055]], - - [[33430.50854247, 13282.84570436, 6518.0168782 ], - [33430.48741023, 13297.86991341, 6518.1703313 ], - [33430.46629251, 13312.95715974, 6518.32442015], - [33443.11021969, 13445.27482496, 6517.24657388], - [32975.51224295, 13465.39258816, 6601.28179394]], - - [[33413.00783789, 13283.81676204, 6418.98769011], - [33412.98907552, 13298.84020936, 6419.12717892], - [33412.97032368, 13313.91706714, 6419.26716541], - [33425.52301296, 13446.25822955, 6418.23616002], - [32964.6777931 , 13465.7624871 , 6495.33245224]], - - [[33396.66579675, 13284.71238431, 6320.16334719], - [33396.65089257, 13299.73504107, 6320.29070455], - [33396.63599815, 13314.80242623, 6320.41844644], - [33409.11769992, 13447.16502273, 6319.44220709], - [32954.66538849, 13466.09962636, 6389.74050122]], - - [[33381.24852054, 13285.54676064, 6220.41655376], - [33381.23763761, 13300.56866191, 6220.53415358], - [33381.22676401, 13315.62749238, 6220.65204624], - [33393.64360555, 13448.01091893, 6219.7392714 ], - [32945.41978582, 13466.40693625, 6284.44480896]], - - [[33366.88796207, 13286.31361891, 6120.88800473], - [33366.87900277, 13301.33492211, 6120.99468375], - [33366.87005072, 13316.38625096, 6121.1015824 ], - [33379.20755941, 13448.79207443, 6120.23184054], - [32936.8937178 , 13466.68691463, 6179.46483854]], - - [[33353.51824552, 13287.02006714, 6021.52558688], - [33353.5122817 , 13302.04075722, 6021.6228739 ], - [33353.50632433, 13317.08535645, 6021.720322 ], - [33365.78068891, 13449.51185668, 6020.90064493], - [32929.06283634, 13466.94117455, 6074.76008435]], - - [[33340.98019556, 13287.67543608, 5921.46521909], - [33340.97720245, 13302.69554984, 5921.55497975], - [33340.97421493, 13317.73416191, 5921.64485516], - [33353.18975779, 13450.1808493 , 5920.88206282], - [32921.88120216, 13467.17191905, 5970.29484442]], - - [[33329.34163138, 13288.27678656, 5821.58699707], - [33329.33994929, 13303.29644537, 5821.66816303], - [33329.33827127, 13318.32985394, 5821.74940752], - [33341.4836993 , 13450.79785248, 5821.02529727], - [32915.30655879, 13467.38111685, 5866.07678978]], - - [[33318.54528818, 13288.82981241, 5721.84819069], - [33318.54582042, 13303.84900272, 5721.92200219], - [33318.54635575, 13318.87776991, 5721.99586386], - [33330.63573641, 13451.36549429, 5721.31959872], - [32909.31991264, 13467.56990416, 5762.08086297]], - - [[33308.52671975, 13289.33835274, 5621.54805033], - [33308.52937622, 13304.35710928, 5621.6159445 ], - [33308.53203472, 13319.38180919, 5621.68386696], - [33320.57029167, 13451.88850266, 5621.07141773], - [32903.88206728, 13467.73997881, 5658.28061166]], - - [[33299.42454388, 13289.79576844, 5521.36106099], - [33299.42787878, 13304.81419689, 5521.42206212], - [33299.43121462, 13319.83551757, 5521.48307559], - [33311.41203778, 13452.36123798, 5520.93263365], - [32898.95649364, 13467.8928758 , 5554.6755699 ]], - - [[33291.28759058, 13290.20191583, 5421.26319584], - [33291.29227765, 13305.22001629, 5421.31835062], - [33291.29696488, 13320.23857261, 5421.37350715], - [33303.23625986, 13452.78060401, 5420.90129044], - [32894.5301681 , 13468.02933789, 5451.2540883 ]], - - [[33284.10726625, 13290.55766238, 5320.71221503], - [33284.11301624, 13305.57548196, 5320.76272796], - [33284.1187658 , 13320.59193349, 5320.81323646], - [33296.02308264, 13453.14820288, 5320.43039017], - [32890.56922299, 13468.15069994, 5347.99638151]], - - [[33277.92895004, 13290.86108527, 5220.28037794], - [33277.93438447, 13305.87872474, 5220.32536706], - [33277.93981816, 13320.89375856, 5220.370349 ], - [33289.80513076, 13453.46306365, 5220.06548549], - [32887.04223965, 13468.25816752, 5244.90024298]], - - [[33272.71433415, 13291.11606209, 5119.9661204 ], - [33272.72006432, 13306.13352282, 5120.00640525], - [33272.7257936 , 13321.14767466, 5120.0466825 ], - [33284.56749686, 13453.72663493, 5119.82110959], - [32883.94108985, 13468.35219446, 5141.95897753]], - - [[33268.37536687, 13291.32704889, 5019.3321915 ], - [33268.38116785, 13306.34436989, 5019.36873511], - [33268.3869679 , 13321.35811646, 5019.40527197], - [33280.21086195, 13453.94443387, 5019.25506488], - [32881.23324579, 13468.43394024, 5039.16015801]], - - [[33264.83906743, 13291.49758459, 4918.85096444], - [33264.84381554, 13306.51484568, 4918.8829438 ], - [33264.84856292, 13321.52858838, 4918.91491778], - [33276.65008105, 13454.12127424, 4918.81783905], - [32878.88669413, 13468.50451073, 4936.49838379]], - - [[33261.98848065, 13291.63490292, 4818.5175009 ], - [33261.99296514, 13306.65208759, 4818.54556076], - [33261.997449 , 13321.66602198, 4818.57361663], - [33273.78945569, 13454.26249845, 4818.52062708], - [32876.89331594, 13468.56426399, 4833.97281322]], - - [[33259.69249322, 13291.74514587, 4717.93840269], - [33259.69673489, 13306.76227044, 4717.96340715], - [33259.70097604, 13321.77651042, 4717.98840876], - [33271.48676042, 13454.37557466, 4717.97028797], - [32875.22139375, 13468.6142425 , 4731.57813929]], - - [[33257.84638678, 13291.83301531, 4617.50476922], - [33257.84955145, 13306.85013394, 4617.52601379], - [33257.85271575, 13321.8647403 , 4617.54725635], - [33269.62544364, 13454.46662789, 4617.54427233], - [32873.83948825, 13468.65545593, 4629.30505592]], - - [[33256.36830184, 13291.90364098, 4517.20646058], - [33256.37126358, 13306.92072327, 4517.22444838], - [33256.37422504, 13321.9356423 , 4517.24243482], - [33268.14333155, 13454.53904606, 4517.25095334], - [32872.7399501 , 13468.68818612, 4527.16073109]], - - [[33255.19373915, 13291.95973741, 4416.67047226], - [33255.19662525, 13306.97678666, 4416.68600975], - [33255.1995111 , 13321.99198705, 4416.70154632], - [33266.96650167, 13454.59658614, 4416.72085908], - [32871.89122164, 13468.71341271, 4425.14183124]], - - [[33254.27709681, 13292.00297317, 4316.26273903], - [33254.27916722, 13307.02003285, 4316.27525537], - [33254.28123747, 13322.03551556, 4316.2877711 ], - [33266.03865584, 13454.6420803 , 4316.30652351], - [32871.25941819, 13468.73217027, 4323.23998941]], - - [[33253.57445329, 13292.03643109, 4215.9819273 ], - [33253.57645185, 13307.05347232, 4215.99179553], - [33253.57845028, 13322.06916692, 4216.00166336], - [33265.33345503, 13454.67692012, 4216.0192071 ], - [32870.83277808, 13468.74482618, 4221.46493048]], - - [[33253.04651919, 13292.06168946, 4115.46159106], - [33253.04864188, 13307.07870829, 4115.46963655], - [33253.05076446, 13322.09456883, 4115.4776818 ], - [33264.80501425, 13454.70331501, 4115.49682112], - [32870.57490363, 13468.75247141, 4119.82266537]], - - [[33252.66335222, 13292.07960957, 4015.06085371], - [33252.66497149, 13307.0966405 , 4015.06668211], - [33252.66659067, 13322.1126584 , 4015.07251035], - [33264.41371482, 13454.72308681, 4015.08598026], - [32870.43435938, 13468.75663648, 4018.29426231]], - - [[33252.39652769, 13292.0922303 , 3914.81921086], - [33252.39806314, 13307.10925693, 3914.82300553], - [33252.39959851, 13322.12537529, 3914.8268001 ], - [33264.14391367, 13454.73691957, 3914.83417905], - [32870.37617129, 13468.75836021, 3916.92582307]], - - [[33252.22037343, 13292.1007336 , 3814.24977919], - [33252.22207099, 13307.117747 , 3814.25253667], - [33252.22376848, 13322.1339212 , 3814.25529409], - [33263.96808976, 13454.74605289, 3814.26309615], - [32870.35864545, 13468.75887899, 3815.74512207]], - - [[33252.11423233, 13292.10566802, 3713.86396785], - [33252.11574813, 13307.12268678, 3713.86554885], - [33252.11726388, 13322.13890558, 3713.86712981], - [33263.85859224, 13454.75178775, 3713.8711875 ], - [32870.35776245, 13468.75890474, 3714.74518389]], - - [[33252.05850458, 13292.10821865, 3613.66483164], - [33252.05992333, 13307.1252403 , 3613.66532194], - [33252.06134203, 13322.14147954, 3613.66581223], - [33263.80093381, 13454.75485003, 3613.66500912], - [32870.36549492, 13468.75867533, 3613.93209526]], - - [[33252.03582176, 13292.1093069 , 3513.11007742], - [33252.03727505, 13307.1263262 , 3513.11039894], - [33252.03872827, 13322.14256754, 3513.11072045], - [33263.77826979, 13454.75607827, 3513.11196581], - [32870.37119421, 13468.75850631, 3513.28950413]], - - [[33252.03102709, 13292.10952281, 3412.75202122], - [33252.03246013, 13307.12654292, 3412.75217619], - [33252.03389312, 13322.14278487, 3412.75233116], - [33263.77312074, 13454.75636024, 3412.75352659], - [32870.37110316, 13468.75850898, 3412.84218755]], - - [[33252.03096628, 13292.10950729, 3312.46525513], - [33252.03238317, 13307.12652817, 3312.46525691], - [33252.03380001, 13322.1427707 , 3312.46525869], - [33263.7728819 , 13454.75636576, 3312.46529057], - [32870.36869502, 13468.75858036, 3312.46525752]], - - [[33252.03096628, 13292.10950191, 3212.0924425 ], - [33252.03238062, 13307.12652291, 3212.0924434 ], - [33252.0337949 , 13322.14276556, 3212.09244431], - [33263.77287508, 13454.7563573 , 3212.09246976], - [32870.36685275, 13468.75863496, 3212.09235382]], - - [[33252.03096628, 13292.10950244, 3111.7187719 ], - [33252.03238147, 13307.12652341, 3111.71877242], - [33252.0337966 , 13322.14276601, 3111.71877293], - [33263.77289129, 13454.75635432, 3111.71877847], - [32870.36655414, 13468.75864381, 3111.71896034]], - - [[33252.03096628, 13292.10950421, 3011.32153981], - [33252.03238317, 13307.1265251 , 3011.32153958], - [33252.0338 , 13322.14276762, 3011.32153935], - [33263.77290592, 13454.75635574, 3011.32152992], - [32870.36708776, 13468.758628 , 3011.32153887]], - - [[33252.03096628, 13292.10950515, 2910.9350783 ], - [33252.03238391, 13307.126526 , 2910.93507828], - [33252.03380149, 13322.14276849, 2910.93507826], - [33263.77290776, 13454.75635832, 2910.93507223], - [32870.36767049, 13468.75861072, 2910.93516713]], - - [[33252.03096628, 13292.10950499, 2810.55788676], - [33252.03238366, 13307.12652585, 2810.55788686], - [33252.03380098, 13322.14276834, 2810.55788696], - [33263.7729018 , 13454.75635961, 2810.55788683], - [32870.367837 , 13468.75860579, 2810.55795336]], - - [[33252.03096628, 13292.10950448, 2710.18951251], - [33252.03238317, 13307.12652536, 2710.18951258], - [33252.0338 , 13322.14276788, 2710.18951264], - [33263.77289695, 13454.75635946, 2710.189515 ], - [32870.36772939, 13468.75860898, 2710.18951275]], - - [[33252.03096628, 13292.10950422, 2609.81698701], - [33252.03238297, 13307.12652511, 2609.81698648], - [33252.0337996 , 13322.14276764, 2609.81698594], - [33263.77289661, 13454.75635872, 2609.81698253], - [32870.3675584 , 13468.75861405, 2609.81662951]], - - [[33252.03096628, 13292.10950426, 2509.43951971], - [33252.03238304, 13307.12652515, 2509.43951936], - [33252.03379974, 13322.14276768, 2509.43951901], - [33263.77289848, 13454.75635824, 2509.43951617], - [32870.36748708, 13468.75861616, 2509.43929742]], - - [[33252.03096628, 13292.1095044 , 2409.05738302], - [33252.03238317, 13307.12652528, 2409.05738301], - [33252.0338 , 13322.14276781, 2409.05738299], - [33263.77290014, 13454.75635819, 2409.05738246], - [32870.36750295, 13468.75861569, 2409.05738297]], - - [[33252.03096628, 13292.10950446, 2308.67781535], - [33252.03238322, 13307.12652535, 2308.67781547], - [33252.0338001 , 13322.14276786, 2308.67781559], - [33263.77290003, 13454.7563584 , 2308.67781625], - [32870.36754945, 13468.75861431, 2308.67788802]], - - [[33252.03096628, 13292.10950446, 2208.29985166], - [33252.0323832 , 13307.12652534, 2208.29985216], - [33252.03380007, 13322.14276786, 2208.29985266], - [33263.77289947, 13454.75635856, 2208.29985741], - [32870.36757646, 13468.75861351, 2208.30018556]], - - [[33252.03096628, 13292.10950442, 2107.92531005], - [33252.03238317, 13307.12652531, 2107.92531005], - [33252.0338 , 13322.14276784, 2107.92531006], - [33263.77289919, 13454.75635859, 2107.92531015], - [32870.36757661, 13468.75861351, 2107.92531006]], - - [[33252.03096628, 13292.10950441, 2007.54920414], - [33252.03238316, 13307.12652529, 2007.54920419], - [33252.03379998, 13322.14276781, 2007.54920424], - [33263.77289904, 13454.75635855, 2007.54920493], - [32870.3675649 , 13468.75861385, 2007.54924507]], - - [[33252.03096628, 13292.10950441, 1907.17195257], - [33252.03238316, 13307.12652529, 1907.17195258], - [33252.03379999, 13322.14276782, 1907.17195259], - [33263.77289934, 13454.75635849, 1907.17195281], - [32870.36755559, 13468.75861413, 1907.17196447]], - - [[33252.03096628, 13292.10950442, 1806.79287661], - [33252.03238317, 13307.1265253 , 1806.79287661], - [33252.0338 , 13322.14276782, 1806.79287661], - [33263.77289931, 13454.75635848, 1806.7928766 ], - [32870.36755412, 13468.75861417, 1806.79287661]], - - [[33252.03096628, 13292.10950442, 1706.41552307], - [33252.03238317, 13307.1265253 , 1706.41552285], - [33252.03380001, 13322.14276782, 1706.41552263], - [33263.77289931, 13454.75635849, 1706.41552042], - [32870.36755681, 13468.75861409, 1706.41537291]], - - [[33252.03096628, 13292.10950442, 1606.03771544], - [33252.03238317, 13307.1265253 , 1606.03771567], - [33252.03380001, 13322.14276783, 1606.03771589], - [33263.77289943, 13454.7563585 , 1606.03771807], - [32870.36755976, 13468.75861401, 1606.03786563]], - - [[33252.03096628, 13292.10950442, 1505.6607886 ], - [33252.03238317, 13307.1265253 , 1505.6607886 ], - [33252.0338 , 13322.14276782, 1505.6607886 ], - [33263.77289923, 13454.75635851, 1505.6607886 ], - [32870.36756061, 13468.75861398, 1505.6607886 ]], - - [[33252.03096628, 13292.10950442, 1405.28416972], - [33252.03238317, 13307.1265253 , 1405.28416941], - [33252.0338 , 13322.14276782, 1405.28416909], - [33263.77289924, 13454.75635851, 1405.28416593], - [32870.36756008, 13468.758614 , 1405.28395343]], - - [[33252.03096628, 13292.10950442, 1304.90670129], - [33252.03238317, 13307.1265253 , 1304.90670099], - [33252.0338 , 13322.14276782, 1304.90670069], - [33263.77289925, 13454.75635851, 1304.90669771], - [32870.36755921, 13468.75861402, 1304.90649676]], - - [[33252.03096628, 13292.10950442, 1204.52853411], - [33252.03238317, 13307.1265253 , 1204.52853411], - [33252.0338 , 13322.14276782, 1204.52853411], - [33263.77289925, 13454.7563585 , 1204.52853411], - [32870.36755884, 13468.75861403, 1204.52853411]], - - [[33252.03096628, 13292.10950442, 1104.15157359], - [33252.03238317, 13307.1265253 , 1104.15157375], - [33252.0338 , 13322.14276783, 1104.15157391], - [33263.77289942, 13454.7563585 , 1104.1515755 ], - [32870.36755892, 13468.75861403, 1104.15168256]], - - [[33252.03096628, 13292.10950442, 1003.77350086], - [33252.03238317, 13307.1265253 , 1003.77350126], - [33252.0338 , 13322.14276782, 1003.77350165], - [33263.77289925, 13454.75635851, 1003.77350559], - [32870.36755915, 13468.75861402, 1003.7737701 ]], - - [[33252.03096628, 13292.10950442, 903.39664581], - [33252.03238317, 13307.1265253 , 903.39664581], - [33252.0338 , 13322.14276782, 903.39664581], - [33263.77289925, 13454.75635851, 903.3966458 ], - [32870.36755929, 13468.75861402, 903.39664581]], - - [[33252.03096628, 13292.10950442, 803.01904187], - [33252.03238317, 13307.1265253 , 803.01904211], - [33252.0338 , 13322.14276782, 803.01904234], - [33263.77289925, 13454.75635851, 803.01904472], - [32870.3675593 , 13468.75861402, 803.01920446]], - - [[33252.03096628, 13292.10950442, 702.64223066], - [33252.03238317, 13307.1265253 , 702.6422305 ], - [33252.0338 , 13322.14276783, 702.64223034], - [33263.77289942, 13454.7563585 , 702.64222875], - [32870.36755924, 13468.75861402, 702.64212162]], - - [[33252.03096628, 13292.10950442, 602.26418692], - [33252.03238317, 13307.1265253 , 602.26418692], - [33252.0338 , 13322.14276782, 602.26418692], - [33263.77289925, 13454.75635851, 602.26418693], - [32870.36755919, 13468.75861402, 602.26418692]], - - [[33252.03096628, 13292.10950442, 501.8871908 ], - [33252.03238317, 13307.1265253 , 501.88719048], - [33252.0338 , 13322.14276782, 501.88719016], - [33263.77289925, 13454.75635851, 501.88718697], - [32870.36755918, 13468.75861402, 501.88697263]], - - [[33252.03096628, 13292.10950442, 401.50913703], - [33252.03238317, 13307.1265253 , 401.50913777], - [33252.0338 , 13322.14276783, 401.50913852], - [33263.77289942, 13454.7563585 , 401.509146 ], - [32870.36755919, 13468.75861402, 401.50964873]], - - [[33252.03096628, 13292.10950442, 301.13224176], - [33252.03238317, 13307.1265253 , 301.13224176], - [33252.0338 , 13322.14276782, 301.13224176], - [33263.77289925, 13454.75635851, 301.13224176], - [32870.36755921, 13468.75861402, 301.13224176]], - - [[33252.03096628, 13292.10950442, 167.29583259], - [33252.03238317, 13307.1265253 , 167.29583259], - [33252.0338 , 13322.14276782, 167.29583259], - [33263.77289925, 13454.75635851, 167.29583259], - [32870.36755921, 13468.75861402, 167.29583259]], - - [[33252.03096628, 13292.10950442, 66.91872373], - [33252.03238317, 13307.1265253 , 66.91872373], - [33252.0338 , 13322.14276782, 66.91872373], - [33263.77289925, 13454.75635851, 66.91872372], - [32870.36755921, 13468.75861402, 66.91872373]], - - [[33252.03096628, 13292.10950442, -0. ], - [33252.03238317, 13307.1265253 , 0. ], - [33252.0338 , 13322.14276784, 0. ], - [33263.77289942, 13454.7563585 , -0.00000001], - [32870.36755921, 13468.75861402, 0. ]]]), - 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., 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., 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., 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., 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.], - [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., 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., 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., 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., 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.], - [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., 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., 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., 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., 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.], - [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., 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., 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., 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., 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.], - [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., 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., 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., 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., 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.], - [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., 1., 1., 1.], - [1., 1., 1., 1., 1.]]) -) -import numpy as np -from mmcore.nurbs._nurbs_eval import NURBSSurfaceTuple - - -val = NURBSSurfaceTuple( - order_u=4, - order_v=3, - knot_u=np.array([0. , 0. , 0. , 0. , 0.00338983, - 0.00677966, 0.01016949, 0.01355932, 0.01694915, 0.02033898, - 0.02372881, 0.02711864, 0.03050847, 0.03389831, 0.03728814, - 0.04067797, 0.0440678 , 0.04745763, 0.05084746, 0.05423729, - 0.05762712, 0.06101695, 0.06440678, 0.06779661, 0.07118644, - 0.07457627, 0.0779661 , 0.08135593, 0.08474576, 0.08813559, - 0.09152542, 0.09491525, 0.09830508, 0.10169492, 0.10508475, - 0.10847458, 0.11186441, 0.11525424, 0.11864407, 0.1220339 , - 0.12542373, 0.12881356, 0.13220339, 0.13559322, 0.13898305, - 0.14237288, 0.14576271, 0.14915254, 0.15254237, 0.1559322 , - 0.15932203, 0.16271186, 0.16610169, 0.16949153, 0.17288136, - 0.17627119, 0.17966102, 0.18305085, 0.18644068, 0.18983051, - 0.19322034, 0.19661017, 0.2 , 0.20338983, 0.20677966, - 0.21016949, 0.21355932, 0.21694915, 0.22033898, 0.22372881, - 0.22711864, 0.23050847, 0.23389831, 0.23728814, 0.24067797, - 0.2440678 , 0.24745763, 0.25084746, 0.25423729, 0.25762712, - 0.26101695, 0.26440678, 0.26779661, 0.27118644, 0.27457627, - 0.2779661 , 0.28135593, 0.28474576, 0.28813559, 0.29152542, - 0.29491525, 0.29830508, 0.30169492, 0.30508475, 0.30847458, - 0.31186441, 0.31525424, 0.31864407, 0.3220339 , 0.32542373, - 0.32881356, 0.33220339, 0.33559322, 0.33898305, 0.34237288, - 0.34576271, 0.34915254, 0.35254237, 0.3559322 , 0.35932203, - 0.36271186, 0.36610169, 0.36949153, 0.37288136, 0.37627119, - 0.37966102, 0.38305085, 0.38644068, 0.38983051, 0.39322034, - 0.39661017, 0.4 , 0.40338983, 0.40677966, 0.41016949, - 0.41355932, 0.41694915, 0.42033898, 0.42372881, 0.42711864, - 0.43050847, 0.43389831, 0.43728814, 0.44067797, 0.4440678 , - 0.44745763, 0.45084746, 0.45423729, 0.45762712, 0.46101695, - 0.46440678, 0.46779661, 0.47118644, 0.47457627, 0.4779661 , - 0.48135593, 0.48474576, 0.48813559, 0.49152542, 0.49491525, - 0.49830508, 0.50169492, 0.50508475, 0.50847458, 0.51186441, - 0.51525424, 0.51864407, 0.5220339 , 0.52542373, 0.52881356, - 0.53220339, 0.53559322, 0.53898305, 0.54237288, 0.54576271, - 0.54915254, 0.55254237, 0.5559322 , 0.55932203, 0.56271186, - 0.56610169, 0.56949153, 0.57288136, 0.57627119, 0.57966102, - 0.58305085, 0.58644068, 0.58983051, 0.59322034, 0.59661017, - 0.6 , 0.60338983, 0.60677966, 0.61016949, 0.61355932, - 0.61694915, 0.62033898, 0.62372881, 0.62711864, 0.63050847, - 0.63389831, 0.63728814, 0.64067797, 0.6440678 , 0.64745763, - 0.65084746, 0.65423729, 0.65762712, 0.66101695, 0.66440678, - 0.66779661, 0.67118644, 0.67457627, 0.6779661 , 0.68135593, - 0.68474576, 0.68813559, 0.69152542, 0.69491525, 0.69830508, - 0.70169492, 0.70508475, 0.70847458, 0.71186441, 0.71525424, - 0.71864407, 0.7220339 , 0.72542373, 0.72881356, 0.73220339, - 0.73559322, 0.73898305, 0.74237288, 0.74576271, 0.74915254, - 0.75254237, 0.7559322 , 0.75932203, 0.76271186, 0.76610169, - 0.76949153, 0.77288136, 0.77627119, 0.77966102, 0.78305085, - 0.78644068, 0.78983051, 0.79322034, 0.79661017, 0.8 , - 0.80338983, 0.80677966, 0.81016949, 0.81355932, 0.81694915, - 0.82033898, 0.82372881, 0.82711864, 0.83050847, 0.83389831, - 0.83728814, 0.84067797, 0.8440678 , 0.84745763, 0.85084746, - 0.85423729, 0.85762712, 0.86101695, 0.86440678, 0.86779661, - 0.87118644, 0.87457627, 0.8779661 , 0.88135593, 0.88474576, - 0.88813559, 0.89152542, 0.89491525, 0.89830508, 0.90169492, - 0.90508475, 0.90847458, 0.91186441, 0.91525424, 0.91864407, - 0.9220339 , 0.92542373, 0.92881356, 0.93220339, 0.93559322, - 0.93898305, 0.94237288, 0.94576271, 0.94915254, 0.95254237, - 0.9559322 , 0.95932203, 0.96271186, 0.96610169, 0.96949153, - 0.97288136, 0.97627119, 0.97966102, 0.98305085, 0.98644068, - 0.98983051, 0.99322034, 0.99661017, 1. , 1. , - 1. , 1. ]), - knot_v=np.array([0. , 0. , 0. , 0.09239269, 0.09239269, - 1. , 1. , 1. ]), - control_points=np.array([[[32290.73847746, 13316.85530471, -0. ], - [32290.73851973, 13331.85533246, -0. ], - [32290.73856201, 13346.85541444, -0. ], - [32278.31713997, 13479.03091972, 0.00002093], - [32667.35727154, 13473.9367302 , 0. ]], - - [[32290.73847746, 13316.85530471, 53.77530268], - [32290.73851973, 13331.85533246, 53.77530268], - [32290.73856201, 13346.85541444, 53.77530268], - [32278.31713997, 13479.03091972, 53.77530269], - [32667.35727124, 13473.93673021, 53.77530269]], - - [[32290.73847746, 13316.85530471, 161.32657247], - [32290.73851973, 13331.85533246, 161.32657247], - [32290.73856201, 13346.85541444, 161.32657247], - [32278.31713996, 13479.03091972, 161.32657248], - [32667.35727161, 13473.9367302 , 161.32657248]], - - [[32290.73847746, 13316.85530471, 322.65361023], - [32290.73851973, 13331.85533246, 322.65361023], - [32290.73856201, 13346.85541444, 322.65361023], - [32278.31713996, 13479.03091971, 322.65361023], - [32667.35727259, 13473.93673018, 322.65361023]], - - [[32290.73847746, 13316.85530471, 457.092047 ], - [32290.73851973, 13331.85533246, 457.09204697], - [32290.73856201, 13346.85541444, 457.09204694], - [32278.31713997, 13479.03091972, 457.09204668], - [32667.35727151, 13473.9367302 , 457.0921137 ]], - - [[32290.73847746, 13316.85530471, 564.64322276], - [32290.73851973, 13331.85533246, 564.6432227 ], - [32290.73856201, 13346.85541444, 564.64322264], - [32278.31714002, 13479.03091974, 564.64322205], - [32667.35726852, 13473.93673027, 564.64337647]], - - [[32290.73847746, 13316.85530471, 645.30694263], - [32290.73851973, 13331.85533246, 645.30694263], - [32290.73856201, 13346.85541444, 645.30694263], - [32278.31714005, 13479.03091975, 645.30694267], - [32667.35726616, 13473.93673032, 645.30694264]], - - [[32290.73847746, 13316.85530471, 739.41442866], - [32290.73851973, 13331.85533246, 739.41442876], - [32290.73856201, 13346.85541444, 739.41442886], - [32278.31714006, 13479.03091975, 739.41442987], - [32667.35726481, 13473.93673035, 739.41417739]], - - [[32290.73847746, 13316.85530471, 846.96506919], - [32290.73851973, 13331.85533246, 846.96506918], - [32290.73856201, 13346.85541444, 846.96506917], - [32278.31714002, 13479.03091974, 846.96506899], - [32667.35726718, 13473.93673029, 846.9650812 ]], - - [[32290.73847746, 13316.85530471, 967.96048256], - [32290.73851973, 13331.85533246, 967.96048256], - [32290.73856201, 13346.85541444, 967.96048256], - [32278.31713986, 13479.03091968, 967.96048238], - [32667.35727751, 13473.93673007, 967.96048254]], - - [[32290.73847746, 13316.85530471, 1082.23331267], - [32290.73851973, 13331.85533246, 1082.23331268], - [32290.73856201, 13346.85541444, 1082.23331269], - [32278.31713968, 13479.03091961, 1082.23331262], - [32667.35729098, 13473.93672978, 1082.2333128 ]], - - [[32290.73847746, 13316.85530471, 1189.785048 ], - [32290.73851973, 13331.85533246, 1189.78504802], - [32290.73856201, 13346.85541444, 1189.78504803], - [32278.31713959, 13479.03091958, 1189.78504819], - [32667.35729753, 13473.93672964, 1189.78504808]], - - [[32290.73847746, 13316.85530471, 1290.61370196], - [32290.73851973, 13331.85533246, 1290.61370196], - [32290.73856201, 13346.85541444, 1290.61370196], - [32278.31713973, 13479.03091963, 1290.61370227], - [32667.35729022, 13473.9367298 , 1290.613702 ]], - - [[32290.73847746, 13316.85530471, 1394.80411595], - [32290.73851973, 13331.85533246, 1394.80411603], - [32290.73856201, 13346.85541444, 1394.80411611], - [32278.31714018, 13479.0309198 , 1394.80411757], - [32667.35726114, 13473.93673043, 1394.8038288 ]], - - [[32290.73847746, 13316.85530471, 1502.35500055], - [32290.73851973, 13331.85533246, 1502.35500059], - [32290.73856201, 13346.85541444, 1502.35500063], - [32278.31714084, 13479.03092004, 1502.35500172], - [32667.35721337, 13473.93673146, 1502.35479618]], - - [[32290.73847746, 13316.85530471, 1613.26746549], - [32290.73851973, 13331.85533246, 1613.26746549], - [32290.73856201, 13346.85541444, 1613.26746549], - [32278.31714131, 13479.03092021, 1613.26746567], - [32667.35717717, 13473.93673224, 1613.26746551]], - - [[32290.73847746, 13316.85530471, 1722.49839976], - [32290.73851974, 13331.85533245, 1722.49839972], - [32290.73856201, 13346.85541444, 1722.49839969], - [32278.31714106, 13479.03092011, 1722.49839852], - [32667.35718944, 13473.93673197, 1722.49851659]], - - [[32290.73847746, 13316.85530471, 1830.05015417], - [32290.73851974, 13331.85533245, 1830.05015411], - [32290.73856201, 13346.85541444, 1830.05015405], - [32278.31713965, 13479.03091959, 1830.05015131], - [32667.35728182, 13473.93672998, 1830.05033492]], - - [[32290.73847746, 13316.85530471, 1935.92063128], - [32290.73851973, 13331.85533246, 1935.92063128], - [32290.73856201, 13346.85541444, 1935.92063128], - [32278.31713723, 13479.03091871, 1935.92062865], - [32667.35745196, 13473.93672631, 1935.92063094]], - - [[32290.73847746, 13316.85530472, 2042.63205661], - [32290.73851973, 13331.85533246, 2042.63205672], - [32290.738562 , 13346.85541445, 2042.63205682], - [32278.31713512, 13479.03091796, 2042.63205654], - [32667.35760339, 13473.93672304, 2042.63205874]], - - [[32290.73847746, 13316.85530472, 2150.18230616], - [32290.73851973, 13331.85533246, 2150.18230639], - [32290.73856199, 13346.85541445, 2150.18230662], - [32278.31713527, 13479.03091802, 2150.18231091], - [32667.35761368, 13473.93672282, 2150.18230732]], - - [[32290.73847746, 13316.85530471, 2258.57439939], - [32290.73851973, 13331.85533246, 2258.57439938], - [32290.73856201, 13346.85541444, 2258.57439938], - [32278.31713955, 13479.03091957, 2258.57440628], - [32667.35735464, 13473.93672841, 2258.57440026]], - - [[32290.73847746, 13316.85530471, 2366.54520643], - [32290.73851974, 13331.85533245, 2366.5452059 ], - [32290.73856202, 13346.85541443, 2366.54520537], - [32278.3171481 , 13479.03092269, 2366.54520999], - [32667.3567574 , 13473.9367413 , 2366.54513346]], - - [[32290.73847746, 13316.85530471, 2474.09676134], - [32290.73851974, 13331.85533245, 2474.0967607 ], - [32290.73856202, 13346.85541443, 2474.09676006], - [32278.31715661, 13479.03092583, 2474.09676034], - [32667.35613067, 13473.93675482, 2474.09666729]], - - [[32290.73847746, 13316.85530471, 2581.22765368], - [32290.73851973, 13331.85533245, 2581.22765368], - [32290.73856201, 13346.85541444, 2581.22765368], - [32278.31715843, 13479.03092653, 2581.22764972], - [32667.35593238, 13473.9367591 , 2581.22765318]], - - [[32290.73847746, 13316.85530469, 2688.56866502], - [32290.73851975, 13331.85533243, 2688.56866589], - [32290.73856205, 13346.85541441, 2688.56866677], - [32278.31714706, 13479.03092224, 2688.56865408], - [32667.356618 , 13473.9367443 , 2688.56851164]], - - [[32290.73847746, 13316.85530468, 2796.11997442], - [32290.73851977, 13331.85533242, 2796.11997556], - [32290.73856208, 13346.8554144 , 2796.1199767 ], - [32278.31711885, 13479.03091176, 2796.11995218], - [32667.35857908, 13473.93670199, 2796.12000526]], - - [[32290.73847746, 13316.85530473, 2903.88116605], - [32290.73851973, 13331.85533247, 2903.88116606], - [32290.73856201, 13346.85541445, 2903.88116608], - [32278.31708451, 13479.03089925, 2903.88113578], - [32667.36105078, 13473.93664865, 2903.88116219]], - - [[32290.73847746, 13316.85530483, 3011.53709208], - [32290.73851963, 13331.85533257, 3011.53709306], - [32290.73856181, 13346.85541456, 3011.53709403], - [32278.31706798, 13479.03089359, 3011.53710658], - [32667.36232717, 13473.93662111, 3011.53723721]], - - [[32290.73847746, 13316.85530486, 3119.08804862], - [32290.73851958, 13331.8553326 , 3119.0880516 ], - [32290.73856171, 13346.85541459, 3119.08805458], - [32278.31709697, 13479.03090451, 3119.08814784], - [32667.36069088, 13473.93665642, 3119.0880082 ]], - - [[32290.73847746, 13316.8553046 , 3226.53554171], - [32290.73851973, 13331.85533235, 3226.53554164], - [32290.73856201, 13346.85541433, 3226.53554157], - [32278.31719105, 13479.03093857, 3226.5356672 ], - [32667.35449363, 13473.93679015, 3226.53555774]], - - [[32290.73847746, 13316.85530381, 3334.03405138], - [32290.7385204 , 13331.85533155, 3334.03404229], - [32290.73856335, 13346.85541355, 3334.0340332 ], - [32278.31732643, 13479.03098559, 3334.034036 ], - [32667.34518321, 13473.93699104, 3334.0511422 ]], - - [[32290.73794356, 13316.85530211, 3441.5596969 ], - [32290.73798745, 13331.85532985, 3441.55968617], - [32290.73803134, 13346.85541214, 3441.55967544], - [32278.31685843, 13479.03100514, 3441.55897016], - [32667.33880109, 13473.93712876, 3441.59341459]], - - [[32290.72890514, 13316.85527056, 3549.27000069], - [32290.72894859, 13331.85529832, 3549.26999161], - [32290.72899204, 13346.85538233, 3549.26998253], - [32278.30757305, 13479.03090553, 3549.26709383], - [32667.34175952, 13473.93706499, 3549.31844074]], - - [[32290.69694216, 13316.85513075, 3656.62321006], - [32290.6970675 , 13331.85515868, 3656.62316033], - [32290.69719284, 13346.85524783, 3656.62311059], - [32278.27575699, 13479.03056101, 3656.61553229], - [32667.36139019, 13473.93664162, 3657.23889759]], - - [[32290.62499737, 13316.8549513 , 3764.17638028], - [32290.62521592, 13331.85497948, 3764.17644294], - [32290.62543448, 13346.85507893, 3764.1765056 ], - [32278.20334281, 13479.02988918, 3764.16336939], - [32667.39371022, 13473.93594469, 3765.35876431]], - - [[32290.49279433, 13316.85482236, 3871.9696166 ], - [32290.49286234, 13331.85485046, 3871.96957629], - [32290.49293036, 13346.85496727, 3871.96953597], - [32278.06711858, 13479.02890258, 3871.94657562], - [32667.40519908, 13473.93569767, 3873.71429271]], - - [[32290.27765457, 13316.85433096, 3979.29880864], - [32290.27783699, 13331.85435952, 3979.29860362], - [32290.27801942, 13346.85450598, 3979.29839859], - [32277.8506299 , 13479.02762937, 3979.26250096], - [32667.36470615, 13473.93657293, 3982.29644231]], - - [[32289.94956954, 13316.85353485, 4086.88089848], - [32289.95012726, 13331.85356447, 4086.88099571], - [32289.95068498, 13346.85375745, 4086.88109295], - [32277.52392375, 13479.02606782, 4086.83249358], - [32667.23549891, 13473.93936285, 4091.13120037]], - - [[32289.47223313, 13316.85297856, 4194.66237847], - [32289.47240966, 13331.85300819, 4194.66229654], - [32289.4725862 , 13346.85326629, 4194.66221461], - [32277.03863567, 13479.02418005, 4194.59164644], - [32666.96889313, 13473.94511579, 4200.1579953 ]], - - [[32288.80334025, 13316.85170722, 4302.09886819], - [32288.80356849, 13331.85173784, 4302.09850454], - [32288.80379673, 13346.85209657, 4302.09814089], - [32276.36656204, 13479.02188157, 4301.99974739], - [32666.53010285, 13473.95457628, 4309.33600054]], - - [[32287.8817731 , 13316.84948907, 4409.69975214], - [32287.88286985, 13331.84952249, 4409.6999178 ], - [32287.88396662, 13346.8500345 , 4409.70008346], - [32275.45090757, 13479.0189926 , 4409.57224337], - [32665.87345012, 13473.96871684, 4418.66983574]], - - [[32286.62783104, 13316.84754537, 4517.4690079 ], - [32286.6283567 , 13331.84757962, 4517.46897996], - [32286.62888239, 13346.84829671, 4517.46895204], - [32274.18484538, 13479.01517672, 4517.29283112], - [32664.92675365, 13473.98906763, 4528.1506746 ]], - - [[32284.93832859, 13316.84414527, 4624.94960951], - [32284.93887677, 13331.84418213, 4624.9491649 ], - [32284.93942498, 13346.84521956, 4624.94872033], - [32272.48816509, 13479.0098021 , 4624.70671101], - [32663.66091322, 13474.0162138 , 4637.79394678]], - - [[32282.63507013, 13316.83827485, 4732.64716032], - [32282.63729326, 13331.83831882, 4732.64754136], - [32282.63951651, 13346.83987786, 4732.64792249], - [32270.19291841, 13479.00164555, 4732.32168414], - [32662.04955092, 13474.050662 , 4747.6056481 ]], - - [[32279.44752355, 13316.83149133, 4840.58111918], - [32279.44923406, 13331.83153995, 4840.5813982 ], - [32279.45094471, 13346.8338493 , 4840.58167741], - [32266.98086727, 13478.98874173, 4840.12528747], - [32660.02459467, 13474.09377961, 4857.58946628]], - - [[32275.10040523, 13316.82035496, 4948.20623222], - [32275.10257671, 13331.82041314, 4948.20601006], - [32275.10474847, 13346.82398436, 4948.20578832], - [32262.60963565, 13478.96878894, 4947.58436524], - [32657.58531198, 13474.14546177, 4967.73446515]], - - [[32269.34038822, 13316.80206676, 5055.97828212], - [32269.34613125, 13331.80214734, 5055.97930455], - [32269.35187537, 13346.80784129, 5055.980328 ], - [32256.84575991, 13478.93953361, 5055.18454074], - [32654.71904887, 13474.20582515, 5078.04435126]], - - [[32262.06587218, 13316.77868136, 5163.83548504], - [32262.0723679 , 13331.77878356, 5163.83657368], - [32262.07886543, 13346.78740274, 5163.83766446], - [32249.51879743, 13478.89931555, 5162.85276011], - [32651.33656416, 13474.27654119, 5188.53465614]], - - [[32253.40049557, 13316.7455129 , 5271.12663113], - [32253.40949066, 13331.74565009, 5271.12723192], - [32253.41848951, 13346.75862018, 5271.12783708], - [32240.80102976, 13478.84769404, 5269.97125671], - [32647.44409848, 13474.35720873, 5299.21241398]], - - [[32243.50634003, 13316.69916101, 5378.42503356], - [32243.52310472, 13331.6993652 , 5378.42779816], - [32243.53987967, 13346.71871832, 5378.43057147], - [32230.88994396, 13478.78488928, 5377.14875098], - [32643.03524309, 13474.44763817, 5410.08121039]], - - [[32232.62512503, 13316.64520557, 5485.79045182], - [32232.64562416, 13331.64547479, 5485.79397734], - [32232.66614013, 13346.67191908, 5485.79751763], - [32219.93485696, 13478.71136226, 5484.40208059], - [32637.99588608, 13474.54974461, 5521.16715126]], - - [[32221.01019902, 13316.58083514, 5592.5586583 ], - [32221.03492082, 13331.58118463, 5592.56253712], - [32221.05966785, 13346.61440574, 5592.56643855], - [32208.25253617, 13478.62792474, 5591.06955227], - [32632.34148351, 13474.66267692, 5632.47064885]], - - [[32208.57870315, 13316.50295255, 5699.46552335], - [32208.61087102, 13331.5034172 , 5699.47337555], - [32208.64307742, 13346.54214806, 5699.48125997], - [32195.80555379, 13478.53326888, 5697.90205619], - [32626.07374634, 13474.78578053, 5743.99539478]], - - [[32195.27096568, 13316.41656784, 5806.55274513], - [32195.30326344, 13331.4171206 , 5806.56209181], - [32195.33560334, 13346.45972744, 5806.57148 ], - [32182.41371233, 13478.42497883, 5804.88327134], - [32619.0495397 , 13474.92106309, 5855.7804347 ]], - - [[32181.15981832, 13316.31409757, 5912.91883818], - [32181.19340597, 13331.31475875, 5912.92875534], - [32181.2270414 , 13346.36168395, 5912.93872607], - [32168.22890764, 13478.3022075 , 5911.13775544], - [32611.29319247, 13475.06704789, 5967.83138281]], - - [[32166.08791043, 13316.18842622, 6019.44926087], - [32166.13035362, 13331.18927458, 6019.46473136], - [32166.17286384, 13346.24207871, 6019.48027379], - [32153.15658171, 13478.16245615, 6017.58984752], - [32602.81374975, 13475.22241662, 6080.15349951]], - - [[32149.97806577, 13316.04787317, 6126.1995066 ], - [32150.02029565, 13331.04885858, 6126.21772159], - [32150.06259681, 13346.10619901, 6126.23602666], - [32136.96219358, 13478.00188469, 6124.22182384], - [32593.42998943, 13475.38902989, 6192.80339975]], - - [[32132.95591259, 13315.88436779, 6232.0104888 ], - [32132.99902202, 13330.88551671, 6232.0304766 ], - [32133.04220908, 13345.94783736, 6232.05057738], - [32119.86545092, 13477.81994302, 6229.908849 ], - [32583.17017926, 13475.5647411 , 6305.78069681]], - - [[32114.78586143, 13315.68777602, 6338.02070404], - [32114.83899508, 13330.68920947, 6338.04891172], - [32114.89223373, 13345.75843138, 6338.07726708], - [32101.7102625 , 13477.61266876, 6335.83772311], - [32572.04942544, 13475.74761811, 6419.09750477]], - - [[32095.3492901 , 13315.46738771, 6444.29384748], - [32095.40158539, 13330.46902191, 6444.32666018], - [32095.4539889 , 13345.54347396, 6444.35965422], - [32082.18879622, 13477.37399228, 6441.97659908], - [32559.84476526, 13475.93929243, 6532.8319555 ]], - - [[32074.85439405, 13315.21447868, 6549.32061436], - [32074.90673062, 13330.21634504, 6549.35699111], - [32074.95917976, 13345.29644196, 6549.39359076], - [32061.61725638, 13477.10413792, 6546.86173362], - [32546.5853976 , 13476.1368875 , 6646.97476081]], - - [[32052.934501 , 13314.91425612, 6654.60042737], - [32052.99753599, 13329.91653061, 6654.64868342], - [32053.06071821, 13345.00459141, 6654.69722515], - [32039.72650223, 13476.79638701, 6652.05364449], - [32532.28357558, 13476.33767406, 6761.57200494]], - - [[32029.42186645, 13314.57622712, 6760.11433661], - [32029.48301351, 13329.57878037, 6760.16979753], - [32029.54430572, 13344.67279996, 6760.22560492], - [32016.12966847, 13476.44137196, 6757.40723375], - [32516.67895425, 13476.54223741, 6876.63240324]], - - [[32004.5824648 , 13314.1908715 , 6864.1388897 ], - [32004.64212539, 13329.19373576, 6864.20080348], - [32004.70192702, 13344.29406444, 6864.2631377 ], - [31991.2110583 , 13476.03981272, 6861.26161375], - [32499.77330177, 13476.74689339, 6992.34262215]], - - [[31977.9204007 , 13313.73714227, 6968.24861916], - [31977.99032843, 13328.74055655, 6968.32770994], - [31978.06043288, 13343.8499091 , 6968.40733234], - [31964.58966144, 13475.58090266, 6965.26828309], - [32481.5765634 , 13476.94763233, 7108.6606885 ]], - - [[31949.12504325, 13313.22188373, 7072.64843588], - [31949.19111036, 13328.22566055, 7072.73867465], - [31949.25733629, 13343.34169016, 7072.82955234], - [31935.71091548, 13475.04907087, 7069.4726782 ], - [32461.78622905, 13477.14315284, 7225.47439362]], - - [[31918.81741557, 13312.64043836, 7174.66916312], - [31918.87928381, 13327.64459854, 7174.7692364 ], - [31918.94128324, 13342.76747055, 7174.87007525], - [31905.31985875, 13474.451013 , 7171.28285677], - [32440.4108659 , 13477.32791033, 7342.87248883]], - - [[31886.08587963, 13311.95806608, 7277.02410946], - [31886.15603691, 13326.9629172 , 7277.14723814], - [31886.22634544, 13342.09573617, 7277.27131991], - [31872.63607703, 13473.76496858, 7273.5159768 ], - [32417.39004598, 13477.4965369 , 7461.21325358]], - - [[31850.59577566, 13311.17893242, 7379.69009004], - [31850.65884576, 13326.18421242, 7379.8293354 ], - [31850.7220137 , 13341.32431412, 7379.96971315], - [31837.06429463, 13472.96931449, 7375.95453749], - [32392.31869783, 13477.64479488, 7580.48516572]], - - [[31813.43002085, 13310.30892745, 7479.22884267], - [31813.48403232, 13325.31461076, 7479.38379168], - [31813.53806453, 13340.4618123 , 7479.54007627], - [31799.80538174, 13472.08164721, 7475.26051876], - [32365.21879665, 13477.76408629, 7700.49720348]], - - [[31773.32692418, 13309.29779174, 7578.82313547], - [31773.38347588, 13324.30421346, 7579.00991296], - [31773.44001826, 13339.46173412, 7579.19831856], - [31759.73931734, 13471.06715921, 7574.73868674], - [32336.0344363 , 13477.84564427, 7821.45688082]], - - [[31729.87279413, 13308.14460693, 7678.45492661], - [31729.91575798, 13323.15140458, 7678.66498134], - [31729.95858634, 13338.31617864, 7678.87693555], - [31716.19551258, 13469.89663257, 7674.13190094], - [32304.26209757, 13477.87998419, 7943.41060717]], - - [[31684.77240308, 13306.87604841, 7774.04799326], - [31684.79834283, 13321.88311836, 7774.27897967], - [31684.82397577, 13337.05436872, 7774.51215477], - [31670.9862797 , 13468.60794124, 7769.46563986], - [32269.85740375, 13477.85424998, 8066.2275526 ]], - - [[31636.13651929, 13305.41518757, 7869.55879073], - [31636.15572968, 13320.42285807, 7869.82752588], - [31636.17449859, 13335.60372744, 7870.09885411], - [31622.36717841, 13467.14216963, 7864.84683747], - [32232.66059806, 13477.75422526, 8190.26163345]], - - [[31583.51739985, 13303.75511394, 7964.84627633], - [31583.51386898, 13318.76291355, 7965.14461998], - [31583.50965448, 13333.95012811, 7965.44591634], - [31569.64845032, 13465.46330357, 7959.89337447], - [32192.07000055, 13477.56126286, 8315.49717189]], - - [[31529.69487065, 13301.96829794, 8054.97975878], - [31529.66345648, 13316.97599896, 8055.30205771], - [31529.63105237, 13332.1678156 , 8055.62764685], - [31515.68828089, 13463.65183963, 8049.75554618], - [32148.07521963, 13477.25562071, 8441.3884292 ]], - - [[31472.2133361 , 13299.95055216, 8144.60711857], - [31472.16269921, 13314.95842514, 8144.96643816], - [31472.11079047, 13330.15731047, 8145.3294837 ], - [31458.16508096, 13461.62292093, 8139.24831518], - [32100.34495124, 13476.81343519, 8568.47735072]], - - [[31410.88323984, 13297.70011428, 8233.56959504], - [31410.79874239, 13312.7075851 , 8233.96146078], - [31410.71260887, 13327.91039326, 8234.35740762], - [31396.68485665, 13459.34908505, 8228.0256578 ], - [32048.22386178, 13476.20288351, 8696.5790211 ]], - - [[31349.34043366, 13295.346144 , 8316.91283405], - [31349.21837823, 13310.3529474 , 8317.32366487], - [31349.09425982, 13325.55684425, 8317.73883795], - [31334.96877218, 13456.96287442, 8311.08195029], - [31991.78463267, 13475.39625156, 8824.68192138]], - - [[31284.30880062, 13292.7468807 , 8399.55446572], - [31284.16056703, 13307.75338648, 8399.98349446], - [31284.00985943, 13322.95972204, 8400.41716604], - [31269.87807862, 13454.34185524, 8393.45632994], - [31930.3987435 , 13474.3533796 , 8953.63003167]], - - [[31216.00842537, 13289.91179421, 8480.91171003], - [31215.82110298, 13304.91737436, 8481.36671039], - [31215.63087213, 13320.12426799, 8481.82659987], - [31201.43145644, 13451.47882742, 8474.61355841], - [31863.76003733, 13473.03394545, 9082.44127379]], - - [[31147.51058379, 13286.97649883, 8557.41874837], - [31147.282585 , 13301.98093899, 8557.88328609], - [31147.05121632, 13317.18523346, 8558.35281796], - [31132.79814161, 13448.50681251, 8550.77537382], - [31792.24877522, 13471.41105962, 9209.43983938]], - - [[31076.407265 , 13283.83223104, 8632.08506401], - [31076.15595472, 13298.83619921, 8632.53952953], - [31075.90084329, 13314.03786364, 8632.99903881], - [31061.71888241, 13445.33256487, 8624.95066715], - [31715.18193138, 13469.43463339, 9335.26101768]], - - [[31003.49344741, 13280.5096101 , 8704.18460109], - [31003.20591314, 13295.51241471, 8704.65466944], - [31002.91418063, 13310.71170722, 8705.12987308], - [30988.73647692, 13441.98224725, 8696.80681542], - [31633.04941533, 13467.08376642, 9458.2291393 ]], - - [[30928.92740963, 13277.03365264, 8773.40951107], - [30928.59874528, 13292.03494142, 8773.88795616], - [30928.26550726, 13307.22936629, 8774.37151131], - [30914.10234617, 13438.4724888 , 8765.70924571], - [31546.22535383, 13464.34246884, 9577.17219802]], - - [[30853.65062829, 13273.45866506, 8838.91712575], - [30853.29960466, 13288.45923015, 8839.37123878], - [30852.94369466, 13303.64705027, 8839.83028431], - [30838.92771863, 13434.86422796, 8830.62427364], - [31454.97782109, 13461.19910227, 9691.61299917]], - - [[30778.0777644 , 13269.79638194, 8900.94454861], - [30777.6996546 , 13284.79589851, 8901.40182622], - [30777.31639515, 13299.97913664, 8901.86397068], - [30763.42033335, 13431.1788084 , 8892.34330638], - [31359.75955147, 13457.6566723 , 9801.22169918]], - - [[30700.35529952, 13265.98099768, 8960.77962151], - [30699.94044044, 13280.97889636, 8961.24325774], - [30699.52021322, 13296.15589455, 8961.71163439], - [30685.74285276, 13427.33694184, 8951.87219505], - [31260.37480978, 13453.70139981, 9906.08523713]], - - [[30622.22860867, 13262.12081965, 9016.85147746], - [30621.78806613, 13277.11756224, 9017.28875579], - [30621.34204909, 13292.28509167, 9017.73043962], - [30607.74584191, 13423.44170994, 9007.3709993 ], - [31157.62962862, 13449.36664666, 10005.63020742]], - - [[30543.77888572, 13258.20862736, 9070.03051504], - [30543.32437772, 13273.20472732, 9070.45750147], - [30542.86433061, 13288.3659301 , 9070.88868284], - [30529.50055091, 13419.51052889, 9060.1786857 ], - [31052.18922984, 13444.69246276, 10100.18754361]], - - [[30462.22818565, 13254.13018904, 9122.02903238], - [30461.75283333, 13269.12525053, 9122.45176474], - [30461.271923 , 13284.27956762, 9122.87847275], - [30448.15540952, 13415.41394651, 9111.84001752], - [30943.9978008 , 13439.69550533, 10189.99734995]], - - [[30380.16588617, 13250.0431209 , 9170.84790325], - [30379.66561444, 13265.03689557, 9171.24567395], - [30379.15989148, 13280.18069268, 9171.64702972], - [30366.22909225, 13411.29326742, 9160.16490478], - [30833.8254319 , 13434.43728435, 10274.62901835]], - - [[30297.79039323, 13245.9391882 , 9217.34710714], - [30297.2744453 , 13260.93214691, 9217.73520537], - [30296.75313218, 13276.06881499, 9218.12663695], - [30284.01515163, 13407.16859073, 9206.36450398], - [30722.17772129, 13428.9750667 , 10354.62152226]], - - [[30211.94336666, 13241.66873067, 9263.12492189], - [30211.41009761, 13256.66079683, 9263.50721456], - [30210.871542 , 13271.79095393, 9263.8926265 ], - [30198.37395224, 13402.88284514, 9251.85823456], - [30608.79814322, 13423.3335993 , 10430.37421694]], - - [[30125.25076825, 13237.37670957, 9306.36880484], - [30124.69769441, 13252.36776312, 9306.72802797], - [30124.13950603, 13267.48885523, 9307.09004879], - [30111.82374424, 13398.56276363, 9294.68769744], - [30494.35410855, 13417.58717675, 10501.60982161]], - - [[30037.77560618, 13233.0463095 , 9347.98548163], - [30037.21186557, 13248.03680718, 9348.33408364], - [30036.64313749, 13263.15183919, 9348.68527544], - [30024.51200659, 13394.21586979, 9336.05094579], - [30379.14838393, 13411.79497088, 10568.91312879]], - - [[29946.31748365, 13228.52645731, 9389.28664327], - [29945.74209545, 13243.51635696, 9389.62791828], - [29945.1618323 , 13258.62594335, 9389.97160811], - [29933.26426001, 13389.68462144, 9377.11249075], - [30262.98480385, 13405.97335065, 10632.60112052]], - - [[29853.2632888 , 13223.94714135, 9428.76411795], - [29852.67200622, 13238.93623484, 9429.08493405], - [29852.07604503, 13254.03813619, 9429.40791234], - [29840.33947171, 13385.08207519, 9416.25344855], - [30146.38156912, 13400.14971823, 10692.51728642]], - - [[29758.5580898 , 13219.28945964, 9467.31517701], - [29757.95757804, 13234.27807806, 9467.6262507 ], - [29757.35253308, 13249.3747135 , 9467.93931933], - [29745.7625473 , 13380.40982743, 9454.60458847], - [30029.50778747, 13394.33211294, 10749.16961466]], - - [[29658.9785715 , 13214.40124228, 9506.03802905], - [29658.36879333, 13229.38938865, 9506.34170138], - [29657.75461119, 13244.48132241, 9506.64722734], - [29646.35692513, 13375.51166975, 9493.13716578], - [29912.14663722, 13388.5093361 , 10802.81267701]], - - [[29556.70488405, 13209.39968558, 9543.68719164], - [29556.08321254, 13224.38723279, 9543.97260237], - [29555.45733634, 13239.47271394, 9544.25967224], - [29544.19174899, 13370.49072458, 9530.51504917], - [29794.69483055, 13382.7006114 , 10853.36084424]], - - [[29453.20359343, 13204.34368422, 9580.51454708], - [29452.5748799 , 13219.33087233, 9580.79103143], - [29451.9421072 , 13234.41189195, 9581.06904542], - [29440.79836601, 13365.42259777, 9567.18225039], - [29677.20960394, 13376.90819721, 10901.24787165]], - - [[29346.61517406, 13199.14773559, 9616.89112774], - [29345.97908497, 13214.13455172, 9617.16056152], - [29345.33906533, 13229.21153774, 9617.43141474], - [29334.37310335, 13360.21904369, 9603.39994266], - [29559.51028185, 13371.12263817, 10946.65368715]], - - [[29239.41420047, 13193.94024095, 9651.54067584], - [29238.76902694, 13208.92660379, 9651.79389015], - [29238.12011009, 13223.99811665, 9652.04837564], - [29227.29688221, 13354.99677208, 9637.81590236], - [29441.89213781, 13365.35787341, 10989.53484568]], - - [[29131.82769205, 13188.72063009, 9685.08727471], - [29131.17712766, 13203.70672141, 9685.33236243], - [29130.52295556, 13218.77442567, 9685.57862214], - [29119.83293836, 13349.76837469, 9671.22194569], - [29324.36891733, 13359.61393492, 11030.24543329]], - - [[29021.71415555, 13183.38924775, 9717.93290025], - [29021.05778803, 13198.37504966, 9718.1713845 ], - [29020.39793354, 13213.4392896 , 9718.41095568], - [29009.89056441, 13344.43200775, 9703.92853947], - [29206.79840322, 13353.88325797, 11068.89966712]], - - [[28911.25218117, 13178.05787617, 9749.04790906], - [28910.58878913, 13193.04333117, 9749.27200387], - [28909.92208185, 13208.10289814, 9749.49707241], - [28899.56608152, 13339.08948019, 9734.84105508], - [29089.39206246, 13348.17552402, 11105.46734767]], - - [[28800.64138546, 13172.72608716, 9779.0306663 ], - [28799.97381135, 13187.71133425, 9779.24743146], - [28799.30304595, 13202.76763601, 9779.46509437], - [28789.08520031, 13333.75133834, 9764.70145074], - [28972.14795546, 13342.48997576, 11140.23422234]], - - [[28688.02826034, 13167.30803343, 9808.14233742], - [28687.35605937, 13182.2930524 , 9808.35301581], - [28686.68077811, 13197.34636934, 9808.56452634], - [28676.64509545, 13328.33017778, 9793.69258397], - [28854.95282374, 13336.82048282, 11173.28015461]], - - [[28575.32375515, 13161.90079197, 9835.55732002], - [28574.64609164, 13176.8855439 , 9835.75521368], - [28573.96550342, 13191.93484784, 9835.95385283], - [28564.08422466, 13322.91447984, 9820.93212577], - [28737.96546262, 13331.17408503, 11204.58290934]], - - [[28462.67912599, 13156.50278617, 9861.84518213], - [28461.99823428, 13171.48737969, 9862.03647751], - [28461.3145298 , 13186.53387255, 9862.22846005], - [28451.57262133, 13317.51193161, 9847.11374704], - [28621.16123607, 13325.54893331, 11234.37763273]], - - [[28348.52646835, 13151.0417867 , 9887.15401129], - [28347.84187026, 13166.02619948, 9887.33971369], - [28347.15456041, 13181.0701047 , 9887.5260525 ], - [28337.59114197, 13312.04915663, 9872.31831765], - [28504.44372932, 13319.9397486 , 11262.72383477]], - - [[28234.54383519, 13145.6022834 , 9910.82595112], - [28233.85495294, 13160.58648878, 9911.00022503], - [28233.16349942, 13175.62691435, 9911.17506855], - [28223.75172616, 13306.60307624, 9895.83810602], - [28387.92729644, 13314.35139246, 11289.60361791]], - - [[28121.03253006, 13140.19079418, 9933.38125585], - [28120.34114492, 13155.17487828, 9933.54957279], - [28119.64728948, 13170.2128623 , 9933.71841431], - [28110.36714045, 13301.18805759, 9918.30230596], - [28271.58932245, 13308.78213776, 11315.2040205 ]], - - [[28006.70058332, 13134.74810711, 9954.92002338], - [28006.00620983, 13149.73204689, 9955.08320595], - [28005.30945781, 13164.76776937, 9955.24687362], - [27996.19155495, 13295.74403137, 9939.75289735], - [28155.3628196 , 13303.22815239, 11339.56006453]], - - [[27892.91524374, 13129.34276492, 9974.97150129], - [27892.2175021 , 13144.32654302, 9975.12440875], - [27891.51750903, 13159.3592198 , 9975.27774949], - [27882.53428953, 13290.33293044, 9959.67391938], - [28039.33409788, 13297.69293768, 11362.66035001]], - - [[27779.77098526, 13123.97218608, 9994.06534545], - [27779.07131958, 13138.95587195, 9994.21285534], - [27778.36949366, 13153.98640865, 9994.36076354], - [27769.49986697, 13284.95909138, 9978.69084344], - [27923.48001974, 13292.17476404, 11384.65931501]], - - [[27666.14971783, 13118.58533041, 10012.24898783], - [27665.44763759, 13133.56890049, 10012.39177505], - [27664.74348052, 13148.59744129, 10012.53492977], - [27656.0152505 , 13279.57089706, 9996.80012777], - [27807.7390276 , 13286.67008812, 11405.57804062]], - - [[27553.1838548 , 13113.23882159, 10029.15415256], - [27552.47912417, 13128.22226524, 10029.28763882], - [27551.77243192, 13143.24811151, 10029.42145219], - [27543.1603263 , 13274.21907458, 10013.59258106], - [27692.17921838, 13281.18154109, 11425.40528093]], - - [[27440.92814158, 13107.92889328, 10045.26923054], - [27440.22194714, 13122.91226733, 10045.39779951], - [27439.51387368, 13137.93621744, 10045.52666832], - [27430.99728035, 13268.90595309, 10029.64216756], - [27576.77783336, 13275.70743463, 11444.27432802]], - - [[27328.43837545, 13102.61266061, 10060.59986146], - [27327.73022996, 13117.5959415 , 10060.72407205], - [27327.02028146, 13132.61811167, 10060.84855833], - [27318.62472639, 13263.58823808, 10044.90954633], - [27461.49181512, 13270.24512933, 11462.19525202]], - - [[27216.6501794 , 13097.33688618, 10074.85177526], - [27215.93995448, 13112.32006827, 10074.96751078], - [27215.22803146, 13127.33982961, 10075.08349041], - [27206.93030446, 13258.30738142, 10059.06313383], - [27346.37256543, 13264.79646722, 11479.15617084]], - - [[27105.59563355, 13092.09733645, 10088.47215664], - [27104.88431583, 13107.08046686, 10088.58338839], - [27104.17137516, 13122.09853024, 10088.69484294], - [27095.95160381, 13253.0645799 , 10072.62726578], - [27231.39829818, 13259.35982578, 11495.27195125]], - - [[26994.47194724, 13086.85772697, 10101.43867928], - [26993.7590586 , 13101.84078249, 10101.5458702 ], - [26993.04461664, 13116.85724128, 10101.65326497], - [26984.92680011, 13247.82327373, 10085.53942391], - [27116.53308205, 13253.93292265, 11510.54770051]], - - [[26883.91346471, 13081.6502107 , 10113.52118078], - [26883.19896112, 13096.6331896 , 10113.62059268], - [26882.48300027, 13111.64747256, 10113.72018371], - [26874.44783718, 13242.61087558, 10097.53501982], - [27001.81500281, 13248.51697761, 11524.96904639]], - - [[26773.81494911, 13076.46459981, 10125.11791797], - [26773.09965897, 13091.44754158, 10125.21317794], - [26772.38298032, 13106.46028939, 10125.3086004 ], - [26764.41531337, 13237.42217328, 10109.08234441], - [26887.22667866, 13243.11059317, 11538.63207525]], - - [[26663.49675451, 13071.27034624, 10136.15821175], - [26662.78021256, 13086.25322824, 10136.24970412], - [26662.06234615, 13101.26451699, 10136.34134415], - [26654.18810697, 13232.22640747, 10120.07451334], - [26772.73808051, 13237.71178146, 11551.5369671 ]], - - [[26553.61313897, 13066.10027274, 10146.41180256], - [26552.89536436, 13081.08309618, 10146.49610234], - [26552.17635375, 13096.09240257, 10146.58053051], - [26544.37968409, 13227.05198669, 10130.24966435], - [26658.38132847, 13232.32148947, 11563.67141343]], - - [[26444.17527795, 13060.95010435, 10156.22940848], - [26443.45697162, 13075.93290264, 10156.30985639], - [26442.73749257, 13090.94080913, 10156.39041977], - [26435.00410934, 13221.89908204, 10140.02297642], - [26544.13986661, 13226.93837209, 11575.1208722 ]], - - [[26334.61257144, 13055.79432914, 10165.53061867], - [26333.8932822 , 13070.7770803 , 10165.60753381], - [26333.17287962, 13085.7836476 , 10165.68455286], - [26325.52673378, 13216.74199601, 10149.28110296], - [26429.99026732, 13221.56076488, 11585.88309112]], - - [[26225.47173332, 13050.6604871 , 10174.11920515], - [26224.7515301 , 13065.64319467, 10174.1894218 ], - [26224.03029571, 13080.64793851, 10174.25972744], - [26216.45653829, 13211.60420925, 10157.79834929], - [26315.95731904, 13216.1892782 , 11595.94745066]], - - [[26116.76003102, 13045.54446626, 10182.32123722], - [26116.03951263, 13060.5271588 , 10182.38785752], - [26115.3180219 , 13075.53061365, 10182.45455687], - [26107.80281587, 13206.48569422, 10165.96045134], - [26202.02612811, 13210.82265601, 11605.38906922]], - - [[26007.99397038, 13040.42477437, 10190.05047852], - [26007.2726981 , 13055.40743055, 10190.11376646], - [26006.55050906, 13070.40964382, 10190.17712453], - [25999.11614086, 13201.36479031, 10173.65088992], - [26088.17733276, 13205.45943479, 11614.1992649 ]], - - [[25899.63514101, 13035.32481473, 10197.13663097], - [25898.91322318, 13050.30743998, 10197.19363502], - [25898.19046565, 13065.30795963, 10197.2506978 ], - [25890.82294202, 13196.26111092, 10180.67169243], - [25974.4314259 , 13200.10003117, 11622.36353002]], - - [[25791.68266736, 13030.24032655, 10203.88498855], - [25790.96062566, 13045.2229458 , 10203.93861207], - [25790.23779946, 13060.22226741, 10203.99228679], - [25782.92372232, 13191.1742398 , 10187.38385345], - [25860.77323733, 13194.74318436, 11629.94882704]], - - [[25683.73294441, 13025.15336758, 10210.20536571], - [25683.01035585, 13040.13596031, 10210.25582552], - [25682.28703535, 13055.13412001, 10210.3063298 ], - [25675.04749638, 13186.08621833, 10193.6690627 ], - [25747.18909612, 13189.38770602, 11636.94634987]], - - [[25576.07317082, 13020.07916594, 10215.95135377], - [25575.35016163, 13035.06173842, 10215.99587469], - [25574.62649354, 13050.05830947, 10216.04043157], - [25567.44953258, 13181.00865769, 10199.35467007], - [25633.69411339, 13184.0337673 , 11643.34300706]], - - [[25468.50938142, 13015.00502679, 10221.40471741], - [25467.78638103, 13029.98759961, 10221.44603892], - [25467.06277425, 13044.98304363, 10221.48739083], - [25459.93821434, 13175.93240899, 10204.77479499], - [25520.27622598, 13178.6803971 , 11649.20084065]], - - [[25360.69883353, 13009.91628259, 10226.45045507], - [25359.97541343, 13024.89883499, 10226.48875276], - [25359.25143733, 13039.89317737, 10226.52707596], - [25352.20230637, 13170.84277287, 10209.78807462], - [25406.9232819 , 13173.32698433, 11654.51120843]], - - [[25252.96305491, 13004.82989706, 10230.93158466], - [25252.23936607, 13019.81243638, 10230.9642272 ], - [25251.51519117, 13034.80527267, 10230.99688919], - [25244.53213415, 13165.75346464, 10214.21259399], - [25293.6465446 , 13167.97408333, 11659.2601914 ]], - - [[25145.30079995, 12999.74251456, 10235.09846183], - [25144.57720824, 13014.72505836, 10235.12805567], - [25143.85318086, 13029.71682387, 10235.15766512], - [25136.92689808, 13160.66447911, 10218.34848437], - [25180.43647914, 13162.62126591, 11663.50169243]], - - [[25037.43354896, 12994.64251242, 10238.83454385], - [25036.70963467, 13009.62504038, 10238.86122729], - [25035.98533343, 13024.61575167, 10238.887923 ], - [25029.13777789, 13155.56404775, 10222.05433462], - [25067.28153794, 13157.26800757, 11667.22283533]], - - [[24929.62738773, 12989.54423293, 10241.99582128], - [24928.90332463, 13004.5267535 , 10242.01707783], - [24928.17894212, 13019.51602363, 10242.03834273], - [24921.40128914, 13150.46336354, 10225.16215387], - [24954.19145961, 13151.91479038, 11670.40869904]], - - [[24821.88073056, 12984.44445786, 10244.82152069], - [24821.15683583, 12999.42698628, 10244.83985129], - [24820.43267044, 13014.41522976, 10244.85818788], - [24813.71592631, 13145.36238612, 10227.95881662], - [24841.15791862, 13146.56124157, 11673.11040491]], - - [[24713.96774622, 12979.33397988, 10247.19415298], - [24713.24361179, 12994.31649643, 10247.20966178], - [24712.51925417, 13009.30372228, 10247.22517464], - [24705.88454476, 13140.2517776 , 10230.30315595], - [24728.16913548, 13141.20682373, 11675.3150754 ]], - - [[24606.10930866, 12974.22491617, 10248.97982575], - [24605.38513264, 12989.20743043, 10248.99008544], - [24604.66079912, 13004.19326546, 10249.00034713], - [24598.09972619, 13135.14066793, 10232.03799864], - [24615.23826004, 13135.85218052, 11677.00814424]], - - [[24498.28840069, 12969.11341041, 10250.40935778], - [24497.5644572 , 12984.09593559, 10250.41678853], - [24496.84040381, 12999.08077687, 10250.42422025], - [24490.34426155, 13130.02827046, 10233.44017187], - [24502.34397428, 13130.49632127, 11678.23837327]], - - [[24390.35912647, 12963.99401796, 10251.36323609], - [24389.63501422, 12978.97653467, 10251.36789212], - [24388.91083892, 12993.9603794 , 10251.37254844], - [24382.50017635, 13124.90899138, 10234.36727006], - [24389.47086278, 13125.13853328, 11678.98911357]], - - [[24282.47236475, 12958.87548675, 10251.71588738], - [24281.74830785, 12973.8580058 , 10251.71539044], - [24281.02425274, 12988.84048711, 10251.71489348], - [24274.69054525, 13119.78867788, 10234.67093199], - [24276.63939938, 13119.77980722, 11679.23964065]], - - [[24174.57207692, 12953.75218088, 10251.69647953], - [24173.84830687, 12968.73471345, 10251.69320701], - [24173.12458592, 12983.71621319, 10251.68993467], - [24166.85860665, 13114.66466211, 10234.62545055], - [24163.78506894, 13114.41709615, 11679.03052162]], - - [[24066.7210473 , 12948.62863584, 10251.18851805], - [24065.99716215, 12963.61116252, 10251.18240544], - [24065.27337408, 12978.59164764, 10251.17629358], - [24059.09446694, 13109.54123515, 10234.09119077], - [24050.88108348, 13109.04914072, 11678.33982935]], - - [[23958.83505332, 12943.50237723, 10250.07887343], - [23958.11132151, 12958.48491096, 10250.06748835], - [23957.3877535 , 12973.46400135, 10250.05610542], - [23951.28582101, 13104.41306634, 10232.93237703], - [23937.92684862, 13103.67592523, 11677.14327887]], - - [[23850.89417227, 12938.36939032, 10248.60528232], - [23850.17080002, 12953.35194107, 10248.59106016], - [23849.44764039, 12968.33002414, 10248.57684166], - [23843.41279479, 13099.27923577, 10231.43294108], - [23824.90349141, 13098.29656377, 11675.47508191]], - - [[23743.07364284, 12933.23963568, 10246.64935927], - [23742.35021942, 12948.22218363, 10246.63219185], - [23741.62705859, 12963.19921574, 10246.61502999], - [23735.67922561, 13094.14945607, 10229.45087106], - [23711.82942835, 13092.91194478, 11673.30642677]], - - [[23635.22049214, 12928.10738444, 10244.0936435 ], - [23634.49732984, 12943.08994462, 10244.0710134 ], - [23633.77449959, 12958.06553195, 10244.0483922 ], - [23627.90327235, 13089.01507961, 10226.84517524], - [23598.70425489, 13087.52206159, 11670.60935735]], - - [[23527.31586514, 12922.96855468, 10241.1841714 ], - [23526.59314687, 12937.95113596, 10241.158609 ], - [23525.87081151, 12952.92567823, 10241.13305846], - [23520.06585402, 13083.8752238 , 10223.90981371], - [23485.51389713, 13082.12625579, 11667.41892493]], - - [[23419.56522756, 12917.83464623, 10237.79992818], - [23418.84253018, 12932.8172282 , 10237.77128995], - [23418.12026807, 12947.79067337, 10237.74266704], - [23412.40237482, 13078.74112217, 10220.49941016], - [23372.25601983, 13076.72442762, 11663.70358397]], - - [[23311.76717378, 12912.69765812, 10233.81584873], - [23311.04485944, 12927.68025821, 10233.781506 ], - [23310.32305316, 12942.65219513, 10233.74718414], - [23304.68150185, 13073.60175384, 10216.4641139 ], - [23258.92963566, 13071.31654146, 11659.43296247]], - - [[23203.89735059, 12907.55309586, 10229.4883157 ], - [23203.17557765, 12922.53572177, 10229.45091922], - [23202.45436613, 12937.50656621, 10229.41354822], - [23196.87825829, 13068.45595326, 10212.11045777], - [23145.51930639, 13065.90187155, 11654.6428213 ]], - - [[23096.22790183, 12902.41576025, 10224.69444167], - [23095.50623242, 12917.39839086, 10224.65380946], - [23094.78517966, 12932.36808156, 10224.61320797], - [23089.29640075, 13063.31823454, 10207.29022898], - [23032.03469253, 13060.48088992, 11649.29830946]], - - [[22988.50414554, 12897.27515643, 10219.29901288], - [22987.78300122, 12912.25781203, 10219.25237749], - [22987.06255033, 12927.22591632, 10219.20578095], - [22981.65015236, 13058.17496137, 10201.84189985], - [22918.47434559, 13055.05353853, 11643.36356632]], - - [[22880.55537269, 12892.11960474, 10213.56187867], - [22879.83488542, 12907.10229176, 10213.51204125], - [22879.11514786, 12922.06924616, 10213.46224927], - [22873.76859919, 13053.01798934, 10196.07830446], - [22804.82156265, 13049.61902966, 11636.87233296]], - - [[22772.57143323, 12886.96010285, 10207.33771588], - [22771.85115161, 12901.94279953, 10207.28445155], - [22771.13167827, 12916.90853272, 10207.23124 ], - [22765.87610705, 13047.85804449, 10189.82761507], - [22691.08248699, 13044.1776673 , 11629.78495556]], - - [[22664.23732432, 12881.78331628, 10200.46197925], - [22663.51774044, 12896.7660463 , 10200.4023503 ], - [22662.79904625, 12911.73009936, 10200.34278533], - [22657.62504957, 13042.67849253, 10182.89712256], - [22577.25533119, 13038.72937641, 11622.06296026]], - - [[22555.51541523, 12876.58367988, 10193.20377409], - [22554.79662594, 12891.566448 , 10193.1407652 ], - [22554.07878606, 12906.52928263, 10193.07782919], - [22548.97619227, 13037.4774626 , 10175.6122833 ], - [22463.31942065, 13033.27317716, 11613.74148388]], - - [[22446.81539834, 12871.3828771 , 10185.42096014], - [22446.09693767, 12886.36566086, 10185.35429707], - [22445.37948881, 12901.32719385, 10185.28771678], - [22440.37511737, 13032.27628279, 10167.80289268], - [22349.28769042, 13027.80969853, 11604.77784925]], - - [[22337.75304525, 12866.16433868, 10176.93159724], - [22337.0354901 , 12881.14716586, 10176.85813622], - [22336.31903366, 12896.10690614, 10176.78477287], - [22331.40301878, 13027.05488659, 10159.25656878], - [22235.15695498, 13022.33879787, 11595.13038756]], - - [[22228.28429663, 12860.92191325, 10168.02352908], - [22227.56770269, 12875.90478663, 10167.94647752], - [22226.85227113, 12890.86322654, 10167.86953535], - [22222.01346703, 13021.81105697, 10150.32163148], - [22120.90333989, 13016.85933985, 11584.83396123]], - - [[22118.90687625, 12855.68171673, 10158.55485202], - [22118.19076122, 12870.66461307, 10158.47387797], - [22117.47587543, 12885.62165553, 10158.39302631], - [22112.74280259, 13016.57050713, 10140.82629649], - [22006.54187773, 13011.37205432, 11573.83652135]], - - [[22009.15890788, 12850.42355462, 10148.3218398 ], - [22008.44395254, 12865.4065067 , 10148.23355183], - [22007.73031968, 12880.36162203, 10148.14540567], - [22003.09256044, 13011.30932257, 10130.53364862], - [21892.06977854, 13005.87681679, 11562.08723613]], - - [[21898.98631001, 12845.14045869, 10137.63433029], - [21898.27251976, 12860.1234669 , 10137.54220498], - [21897.56012025, 12875.07718462, 10137.45023665], - [21893.00535049, 13006.02475911, 10119.81898615], - [21777.45710097, 13000.37219831, 11549.620477 ]], - - [[21788.99581241, 12839.86403409, 10126.35368108], - [21788.28268828, 12854.84707438, 10126.25731814], - [21787.57102687, 12869.79928107, 10126.16112901], - [21783.12979451, 13000.74796116, 10108.5116002 ], - [21662.72604707, 12994.85927339, 11536.37890616]], - - [[21678.6328039 , 12834.56975325, 10114.2487299 ], - [21677.92115656, 12849.5528647 , 10114.1444408 ], - [21677.21107258, 12864.50298438, 10114.04035058], - [21672.87258663, 12995.45042613, 10096.34383125], - [21547.87273702, 12989.33786453, 11522.30424159]], - - [[21567.62652024, 12829.23982407, 10101.62910212], - [21566.91629285, 12844.22300404, 10101.52068847], - [21566.20770227, 12859.17161127, 10101.41249313], - [21561.96026182, 12990.11901973, 10083.69699082], - [21432.86179293, 12983.80627868, 11507.43109887]], - - [[21456.27208937, 12823.89115509, 10088.28542719], - [21455.56277372, 12838.87437913, 10088.17240736], - [21454.85517264, 12853.82134131, 10088.05962747], - [21450.73590028, 12984.77021967, 10070.32730848], - [21317.72133439, 12978.26587587, 11491.69566899]], - - [[21343.87948939, 12818.49281081, 10073.90547772], - [21343.17205729, 12833.47612578, 10073.7838062 ], - [21342.46644872, 12848.42081116, 10073.66240676], - [21338.46929038, 12979.36885414, 10055.88222417], - [21202.44586477, 12972.71640002, 11475.03027102]], - - [[21230.3635482 , 12813.03538853, 10058.80613875], - [21229.65785118, 12828.01878739, 10058.68000915], - [21228.95405687, 12842.96182402, 10058.55417637], - [21225.06958242, 12973.91052065, 10040.75746343], - [21086.99302122, 12967.15581871, 11457.46580009]], - - [[21116.63363057, 12807.56569715, 10042.82337465], - [21115.92915741, 12822.54915531, 10042.69220647], - [21115.22667103, 12837.4903874 , 10042.56136272], - [21111.49593206, 12968.44137024, 10024.75125676], - [20971.40220904, 12961.5860368 , 11438.92588847]], - - [[21001.8851648 , 12802.04746214, 10025.59253058], - [21001.1830892 , 12817.03103624, 10025.45184993], - [21000.48311865, 12831.96976455, 10025.31153481], - [20996.90069539, 12962.92052436, 10007.4536881 ], - [20855.66515681, 12956.00666386, 11419.33169428]], - - [[20886.04449399, 12796.47124106, 10007.45615038], - [20885.3445518 , 12811.45491847, 10007.31062888], - [20884.64680004, 12826.39183618, 10007.16550436], - [20881.20229229, 12957.34402726, 9989.29494401], - [20739.73193714, 12950.41529937, 11398.71300602]], - - [[20770.25928804, 12790.89579471, 9988.27686032], - [20769.56097177, 12805.87955104, 9988.12579606], - [20768.86493683, 12820.81447459, 9987.97516399], - [20765.60271643, 12951.76982194, 9970.09626651], - [20623.65623536, 12944.81453912, 11376.97853997]], - - [[20653.59239866, 12785.27868051, 9967.62684166], - [20652.89714262, 12800.26258505, 9967.46524664], - [20652.20429633, 12815.194734 , 9967.30413633], - [20649.11977696, 12946.15048541, 9949.37859545], - [20507.42870972, 12939.20394054, 11354.03189057]], - - [[20536.00150305, 12779.61125181, 9945.88227486], - [20535.30889106, 12794.59528462, 9945.71540532], - [20534.61878069, 12809.52543157, 9945.54906039], - [20531.70011631, 12940.48343385, 9927.61644759], - [20390.98576224, 12933.58043955, 11329.9018659 ]], - - [[20418.94783705, 12773.96798688, 9922.96565372], - [20418.25737224, 12788.95212404, 9922.79265523], - [20417.56950697, 12803.88005154, 9922.62022619], - [20414.86444585, 12934.84211396, 9904.68654913], - [20274.40378946, 12927.94773215, 11304.48260615]], - - [[20301.4429383 , 12768.30425696, 9898.39841373], - [20300.75639184, 12783.28858416, 9898.21367665], - [20300.07258419, 12798.21341283, 9898.02957619], - [20297.5761888 , 12929.17642026, 9880.05140435], - [20157.6684958 , 12922.30513626, 11277.65350541]], - - [[20184.79470246, 12762.67582504, 9872.90121377], - [20184.11145124, 12777.66031234, 9872.71071572], - [20183.43103724, 12792.58291258, 9872.52090493], - [20181.12612758, 12923.54877855, 9854.54263575], - [20040.70559106, 12916.6490669 , 11249.43711272]], - - [[20071.04144693, 12757.18588958, 9846.62034555], - [20070.36099777, 12772.17051317, 9846.42303369], - [20069.68349095, 12787.09062456, 9846.22646629], - [20067.61716298, 12918.06102553, 9828.25500086], - [19923.61714641, 12910.98446698, 11219.71230783]], - - [[19958.71315898, 12751.76699892, 9819.00848321], - [19958.03776921, 12766.75186814, 9818.79800489], - [19957.36547267, 12781.66849343, 9818.58835731], - [19955.52881671, 12912.63969028, 9800.57264729], - [19806.38801149, 12905.31061338, 11188.32692723]], - - [[19847.61630658, 12746.40132361, 9790.56483094], - [19846.94510687, 12761.38639666, 9790.34807238], - [19846.27710554, 12776.30052956, 9790.13220904], - [19844.64554647, 12907.27449232, 9772.12197968], - [19688.93187988, 12899.62333571, 11155.28905559]], - - [[19739.51573858, 12741.1797104 , 9761.43164128], - [19738.84822967, 12756.16496305, 9761.20730079], - [19738.18403143, 12771.07628947, 9760.98392873], - [19736.80856392, 12902.05477004, 9742.98790268], - [19571.39990057, 12893.92996162, 11120.45534549]], - - [[19632.70490804, 12736.02364671, 9730.93946419], - [19632.04401366, 12751.00922082, 9730.70029909], - [19631.3865927 , 12765.91660016, 9730.46221374], - [19630.25838728, 12896.89534807, 9712.42074751], - [19453.77032271, 12888.22945054, 11083.63970172]], - - [[19526.92057367, 12730.91051124, 9699.59781977], - [19526.26505634, 12745.89634719, 9699.3518513 ], - [19525.61312377, 12760.80092608, 9699.10704496], - [19524.70198404, 12891.78217977, 9681.07728744], - [19335.94109725, 12882.51686638, 11044.83578689]], - - [[19424.16663459, 12725.94378061, 9667.66650759], - [19423.51601353, 12740.92985495, 9667.41211621], - [19422.86909587, 12755.83125026, 9667.15898102], - [19422.23080266, 12886.81684593, 9649.15171718], - [19218.12482717, 12876.80252937, 11003.87615557]], - - [[19322.4770235 , 12721.03285679, 9634.32210302], - [19321.83513177, 12736.01935577, 9634.05097217], - [19321.19711754, 12750.91625084, 9633.78124207], - [19320.82448311, 12881.90138717, 9615.72642234], - [19100.29295404, 12871.08507284, 10960.52544143]], - - [[19221.53801419, 12716.15087275, 9600.07665004], - [19220.90308127, 12731.13771094, 9599.79824145], - [19220.27214187, 12746.03144661, 9599.52133924], - [19220.12818946, 12877.01874807, 9581.48573194], - [18982.33172576, 12865.3589915 , 10914.75843399]], - - [[19123.64228393, 12711.41664487, 9565.34736364], - [19123.01389622, 12726.40380201, 9565.05966225], - [19122.38962504, 12741.29390816, 9564.77358863], - [19122.53536569, 12872.28528225, 9546.77020148], - [18864.55490183, 12859.63953607, 10866.37943113]], - - [[19026.66730876, 12706.73260924, 9529.16446818], - [19026.05055019, 12721.72033258, 9528.85787282], - [19025.4380906 , 12736.605279 , 9528.55309385], - [19025.87665882, 12867.59567747, 9510.50203201], - [18746.93057776, 12853.92518568, 10815.07205465]], - - [[18930.75580232, 12702.09176118, 9492.1435082 ], - [18930.14810534, 12717.07992653, 9491.82933343], - [18929.54482193, 12731.96130845, 9491.51710941], - [18930.24689043, 12862.9547166 , 9473.50458187], - [18629.35109058, 12848.21073412, 10760.7951575 ]], - - [[18838.39957252, 12697.62433027, 9454.74156683], - [18837.80063631, 12712.61292279, 9454.41730641], - [18837.20623056, 12727.49016437, 9454.09515092], - [18838.25887105, 12858.48954087, 9436.14585521], - [18512.2673792 , 12842.51813136, 10703.3524675 ]], - - [[18747.00032531, 12693.21046901, 9415.57593115], - [18746.4170099 , 12708.19982249, 9415.2305017 ], - [18745.83840105, 12723.0711391 , 9414.88742001], - [18747.265988 , 12854.07151265, 9396.9145226 ], - [18395.64940269, 12836.84596249, 10642.33703275]], - - [[18656.11510933, 12688.81178032, 9374.96899552], - [18655.54364731, 12703.80171226, 9374.61611132], - [18654.97698967, 12718.66902987, 9374.26574083], - [18656.74138355, 12849.6752042 , 9356.37569435], - [18279.41700997, 12831.1903711 , 10577.69714035]], - - [[18568.48357667, 12684.57273293, 9333.60983914], - [18567.92386727, 12699.56323804, 9333.24638473], - [18567.36905642, 12714.42585387, 9332.88563485], - [18569.56205242, 12845.44070727, 9315.10924902], - [18164.20189993, 12825.58215096, 10509.26748292]], - - [[18481.39024867, 12680.36877913, 9289.87988279], - [18480.85157418, 12695.3603095 , 9289.49313865], - [18480.31794708, 12710.21613786, 9289.1094067 ], - [18482.97539207, 12841.23409011, 9271.34648929], - [18049.9907469 , 12820.02072627, 10436.53257452]], - - [[18394.457847 , 12676.16088742, 9244.0679651 ], - [18393.93455701, 12691.15316911, 9243.67471939], - [18393.41637584, 12706.00456864, 9243.28468314], - [18396.48265115, 12837.03129444, 9225.66635556], - [17936.77073581, 12814.50554374, 10359.45141341]], - - [[18310.6480309 , 12672.10718271, 9197.15789613], - [18310.14038006, 12687.10022716, 9196.75429796], - [18309.63788571, 12701.94634991, 9196.3541361 ], - [18313.20749701, 12832.9845481 , 9178.91845519], - [17825.3898686 , 12809.07800258, 10277.94911968]], - - [[18227.20342248, 12668.08234363, 9147.2551063 ], - [18226.72396455, 12683.07676307, 9146.82676542], - [18226.24975116, 12697.91517574, 9146.40223911], - [18230.37646087, 12828.95878434, 9129.03531738], - [17715.84493842, 12803.73805738, 10191.42308384]], - - [[18143.88740072, 12664.04928028, 9094.64072567], - [18143.42756424, 12679.04465887, 9094.20827753], - [18142.97296912, 12693.87828465, 9093.7798625 ], - [18147.5751741 , 12824.93407621, 9076.64362402], - [17608.25154045, 12798.49145354, 10099.97624837]], - - [[18063.60906574, 12660.16717554, 9040.5299129 ], - [18063.16955914, 12675.16354573, 9040.08867434], - [18062.73526183, 12689.99139349, 9039.65171888], - [18067.90515115, 12821.06173584, 9022.7911504 ], - [17503.65544897, 12793.38932186, 10003.72743648]], - - [[17983.88379296, 12656.32553742, 8982.88160289], - [17983.48138252, 12671.32371809, 8982.41580163], - [17983.08416053, 12686.14298495, 8981.9547229 ], - [17988.89912854, 12817.22151928, 8965.24299531], - [17402.08719462, 12788.43330763, 9902.02939419]], - - [[17904.66452569, 12652.49044693, 8922.03667941], - [17904.28623841, 12667.48980829, 8921.5714302 ], - [17903.91304077, 12682.30413174, 8921.11111585], - [17910.24754826, 12813.39864271, 8904.7408292 ], - [17303.85009098, 12783.6383682 , 9795.33529978]], - - [[17827.82084911, 12648.77465519, 8858.84528467], - [17827.46763192, 12663.77524017, 8858.37508289], - [17827.11936485, 12678.58354858, 8857.91005934], - [17834.03867411, 12809.69521976, 8841.91692991], - [17210.01148652, 12779.0567805 , 9684.01176763]], - - [[17751.75651429, 12645.11290875, 8791.76775862], - [17751.44995948, 12660.11577364, 8791.27621909], - [17751.14818937, 12674.9149024 , 8790.7903349 ], - [17758.74237385, 12806.03659686, 8775.02194406], - [17120.53116177, 12774.68677264, 9567.65585128]], - - [[17676.79082071, 12641.48251582, 8721.63565388], - [17676.51214669, 12656.4867473 , 8721.15193716], - [17676.23806215, 12671.28109461, 8720.67405445], - [17684.323193 , 12802.42134368, 8705.33962663], - [17035.55880186, 12770.53583779, 9447.45352828]], - - [[17603.77884846, 12637.95044969, 8648.7326144 ], - [17603.52831818, 12652.95605817, 8648.25068389], - [17603.28213074, 12667.74466974, 8647.77478214], - [17611.9039869 , 12798.90461067, 8632.90529114], - [16955.62244794, 12766.62995505, 9324.01558869]], - - [[17532.61114445, 12634.52656359, 8572.69876824], - [17532.41491328, 12649.53482787, 8572.20289458], - [17532.22269598, 12664.31425803, 8571.71350672], - [17541.51305152, 12795.48767482, 8557.15924762], - [16880.55053206, 12762.96091929, 9197.14887631]], - - [[17464.04753643, 12631.20438287, 8494.98643252], - [17463.88119153, 12646.21411178, 8494.50720526], - [17463.71857327, 12660.98928524, 8494.03456105], - [17473.45773316, 12792.18566198, 8480.016555 ], - [16810.71984171, 12759.54731402, 9069.16993977]], - - [[17395.84604508, 12627.89985165, 8412.47384023], - [17395.7057728 , 12642.91086082, 8412.004276 ], - [17395.56890053, 12657.68142643, 8411.54135834], - [17405.75666411, 12788.90230982, 8398.07611205], - [16746.03432654, 12756.38460379, 8940.30777832]], - - [[17331.26785706, 12624.78967286, 8328.76783757], - [17331.18024421, 12639.80325911, 8328.29466941], - [17331.09557698, 12654.56600574, 8327.82844569], - [17341.88364674, 12785.80688032, 8314.79214873], - [16686.70930221, 12753.48346439, 8811.58784043]], - - [[17270.29033461, 12621.83070927, 8244.61091177], - [17270.22851541, 12636.84555826, 8244.16067154], - [17270.1693072 , 12651.60512104, 8243.71732158], - [17281.32462705, 12782.8742256 , 8231.29935479], - [16632.17634758, 12750.81622959, 8684.02817056]], - - [[17209.49285438, 12618.8790771 , 8154.72021447], - [17209.45115981, 12633.89491774, 8154.2848487 ], - [17209.41172643, 12648.65125134, 8153.85626345], - [17220.90733522, 12779.94994781, 8142.05528596], - [16581.73780395, 12748.34888151, 8556.47172099]], - - [[17152.67221546, 12616.13806591, 8064.25297907], - [17152.67620483, 12631.15614317, 8063.82212652], - [17152.68194942, 12645.90629887, 8063.39813673], - [17164.6755853 , 12777.23109144, 8052.120444 ], - [16535.62885023, 12746.09301556, 8430.03287855]], - - [[17099.51190871, 12613.55436985, 7973.37233057], - [17099.53559964, 12628.57341171, 7972.96677155], - [17099.5607088 , 12643.32132772, 7972.56786149], - [17111.83155348, 12774.67837643, 7961.95963057], - [16493.08649344, 12744.01138058, 8304.85334811]], - - [[17047.525917 , 12611.02611363, 7877.29768686], - [17047.5636394 , 12626.04584861, 7876.90919524], - [17047.60245326, 12640.79162933, 7876.52708769], - [17060.11044708, 12772.18136279, 7866.56641568], - [16453.75408887, 12742.08658705, 8180.2046116 ]], - - [[16999.55740967, 12608.70903533, 7781.11023732], - [16999.63273894, 12623.7306125 , 7780.73120043], - [16999.7086764 , 12638.47163053, 7780.35844908], - [17012.59524617, 12769.8901276 , 7770.95249099], - [16417.75436858, 12740.32469538, 8056.85894613]], - - [[16955.12476207, 12606.54656614, 7684.91432354], - [16955.21401018, 12621.56882668, 7684.56030357], - [16955.30358667, 12636.30834513, 7684.21227207], - [16968.35897126, 12767.75902384, 7675.43743668], - [16384.44069048, 12738.69410362, 7934.77667104]], - - [[16912.2875468 , 12604.46001146, 7584.57045945], - [16912.38566589, 12619.48271137, 7584.23352874], - [16912.48386528, 12634.22092689, 7583.90226703], - [16925.66286998, 12765.70335917, 7575.70889162], - [16353.59399693, 12737.18412575, 7813.31845409]], - - [[16872.83242375, 12602.55199035, 7484.42081567], - [16872.96048184, 12617.57615795, 7484.09564217], - [16873.08821799, 12632.31076663, 7483.77595728], - [16886.5229292 , 12763.8212998 , 7476.08284396], - [16325.29401922, 12735.798697 , 7692.99596324]], - - [[16836.33207601, 12600.77339436, 7384.42660934], - [16836.4693388 , 12615.79801581, 7384.1245481 ], - [16836.60607574, 12630.53166624, 7383.82766689], - [16850.11906248, 12762.0724366 , 7376.67809362], - [16299.01263513, 12734.51199017, 7573.68556677]], - - [[16801.49984042, 12599.07444569, 7281.31375145], - [16801.64215138, 12614.09931832, 7281.02761935], - [16801.78376696, 12628.83223395, 7280.74635048], - [16815.33926547, 12760.40267097, 7274.08879932], - [16274.65454308, 12733.31936097, 7454.96789925]], - - [[16769.39058009, 12597.5201925 , 7178.53073521], - [16769.55637552, 12612.54621721, 7178.25694895], - [16769.72115865, 12627.27640066, 7177.98782036], - [16783.44106481, 12758.8732733 , 7171.75676876], - [16252.2802672 , 12732.2237925 , 7337.15267315]], - - [[16739.6638814 , 12596.07012314, 7075.99751035], - [16739.8354052 , 12611.09643182, 7075.74426741], - [16740.00577869, 12625.82602638, 7075.49539922], - [16753.74310299, 12757.45068627, 7069.71293884], - [16231.4731552 , 12731.2048997 , 7220.13094855]], - - [[16711.46330878, 12594.69300619, 6971.17537245], - [16711.63723943, 12609.71943602, 6970.93640006], - [16711.80991377, 12624.44866466, 6970.70152124], - [16725.5374577 , 12756.10059276, 6965.31911917], - [16212.19241971, 12730.26069855, 7103.61290305]], - - [[16685.37393327, 12593.42911922, 6866.71648185], - [16685.56617298, 12608.45644778, 6866.48947056], - [16685.75691429, 12623.18359528, 6866.26635971], - [16699.58376516, 12754.86012269, 6861.23214399], - [16194.49015823, 12729.39375263, 6987.784718 ]], - - [[16661.12850394, 12592.2453302 , 6762.53152945], - [16661.32405514, 12607.27282407, 6762.32237494], - [16661.51802058, 12621.99962777, 6762.11688447], - [16675.32282926, 12753.70181724, 6757.4396665 ], - [16178.03737169, 12728.58795997, 6872.5615127 ]], - - [[16638.14640527, 12591.12188107, 6656.69276177], - [16638.34263601, 12606.14941116, 6656.49614299], - [16638.53722115, 12620.87608191, 6656.30295857], - [16652.29899888, 12752.6037598 , 6651.94048234], - [16162.82179851, 12727.84272844, 6757.74672274]], - - [[16616.74293072, 12590.08418617, 6551.18984753], - [16616.95341978, 12605.11241657, 6551.00435252], - [16617.16208014, 12619.8374924 , 6550.82212628], - [16630.9767128 , 12751.58877933, 6546.73868402], - [16148.88548889, 12727.16012541, 6643.44984964]], - - [[16596.7642828 , 12589.10794559, 6445.93871498], - [16596.97650082, 12604.13626308, 6445.76857775], - [16597.18683571, 12618.86115326, 6445.60151223], - [16610.95583489, 12750.63715472, 6441.80628883], - [16135.96600957, 12726.52730343, 6529.62220573]], - - [[16577.86442531, 12588.18321512, 6339.44733495], - [16578.07624965, 12603.21151608, 6339.28806533], - [16578.28615865, 12617.9364165 , 6339.13167091], - [16591.99414333, 12749.73723994, 6335.59539948], - [16124.06654824, 12725.94442399, 6416.1297918 ]], - - [[16560.254268 , 12587.32880113, 6233.23791705], - [16560.47716724, 12602.35764631, 6233.08871757], - [16560.69801046, 12617.08132281, 6232.94224045], - [16574.42799253, 12748.905515 , 6229.64243733], - [16113.221557 , 12725.41317971, 6303.04331164]], - - [[16543.83464803, 12586.52593264, 6127.23777501], - [16544.05826181, 12601.5548146 , 6127.10167666], - [16544.27978548, 12616.27840566, 6126.96812767], - [16557.95171548, 12748.12681784, 6123.91041648], - [16103.2162262 , 12724.9230523 , 6190.32570819]], - - [[16528.37969527, 12585.76833076, 6020.30181793], - [16528.60236054, 12600.79716856, 6020.17544638], - [16528.82293312, 12615.52169724, 6020.05144451], - [16542.42488115, 12747.39442255, 6017.21216945], - [16094.06155287, 12724.4745839 , 6077.87527624]], - - [[16514.01791197, 12585.06008538, 5913.58187338], - [16514.24559215, 12600.08916881, 5913.46816607], - [16514.4712368 , 12614.82361361, 5913.35659662], - [16528.07361239, 12746.71942981, 5910.72043192], - [16085.79042492, 12724.06938884, 5965.73464645]], - - [[16500.74139241, 12584.37196273, 5807.02603484], - [16500.93327281, 12599.39929412, 5806.9300689 ], - [16501.12362746, 12614.17056905, 5806.83580205], - [16514.66188608, 12746.09005541, 5804.40580474], - [16078.22618009, 12723.69881527, 5853.8828708 ]], - - [[16488.33291435, 12583.72105847, 5699.76142071], - [16488.48966736, 12598.74667313, 5699.67621371], - [16488.64538667, 12613.55935015, 5699.59229016], - [16502.11031419, 12745.50248992, 5697.34909387], - [16071.37787652, 12723.36330913, 5742.25388909]], - - [[16476.86166959, 12583.12375537, 5592.68286979], - [16476.9930109 , 12598.14812816, 5592.60749214], - [16477.12364951, 12612.99871307, 5592.5330687 ], - [16490.57444158, 12744.96436302, 5590.46632645], - [16065.27193916, 12723.06416704, 5630.87367662]], - - [[16466.28200962, 12582.56918183, 5485.75387544], - [16466.38405854, 12597.59212222, 5485.68897436], - [16466.48568078, 12612.47805456, 5485.62475515], - [16479.87126514, 12744.46645551, 5483.73589462], - [16059.7628971 , 12722.79426374, 5519.73291149]], - - [[16456.49572323, 12582.05551185, 5378.25636773], - [16456.56969605, 12597.07708004, 5378.19906772], - [16456.64344002, 12611.99571267, 5378.14222889], - [16469.95726767, 12744.00660752, 5376.41816193], - [16054.85475472, 12722.55379674, 5408.77754749]], - - [[16447.58506915, 12581.59111386, 5270.89792173], - [16447.63866298, 12596.61168623, 5270.84765769], - [16447.69214295, 12611.56006728, 5270.79768274], - [16460.98491086, 12743.59204318, 5269.23950721], - [16050.56630252, 12722.34368788, 5298.02646064]], - - [[16439.59485489, 12581.17214568, 5163.6425731 ], - [16439.62609328, 12596.19162516, 5163.59969022], - [16439.65729323, 12611.16672018, 5163.55696346], - [16452.8921879 , 12743.21904603, 5162.17407854], - [16046.77777793, 12722.15807047, 5187.47559314]], - - [[16432.52427045, 12580.80080674, 5055.9088019 ], - [16432.53494233, 12595.81928117, 5055.87114119], - [16432.54561014, 12610.81799689, 5055.83354041], - [16445.72063752, 12742.88921809, 5054.6188359 ], - [16043.4889959 , 12721.99693621, 5077.08732344]], - - [[16426.43250773, 12580.48324233, 4948.29808393], - [16426.42920813, 12595.50103412, 4948.26533644], - [16426.42590859, 12610.52029116, 4948.2325854 ], - [16439.58495169, 12742.60784196, 4947.18437196], - [16040.71143975, 12721.86084838, 4966.87348836]], - - [[16421.30179901, 12580.21407651, 4840.80902743], - [16421.28400792, 12595.23115989, 4840.78145923], - [16421.26620425, 12610.26762833, 4840.75385025], - [16434.38405978, 12742.3695596 , 4839.87012959], - [16038.34605746, 12721.74495442, 4856.83641397]], - - [[16417.06389185, 12579.99121743, 4732.95505645], - [16417.03347369, 12595.00768349, 4732.93110017], - [16417.00301905, 12610.0583382 , 4732.90708567], - [16430.07948436, 12742.17250319, 4732.16999793], - [16036.386951 , 12721.64896572, 4746.94831402]], - - [[16413.65972515, 12579.81387671, 4625.2630604 ], - [16413.62182801, 12594.82997715, 4625.24264886], - [16413.58387046, 12609.89226954, 4625.22217485], - [16426.65382881, 12742.01607126, 4624.61858688], - [16034.83870229, 12721.57310711, 4637.21381347]], - - [[16410.97432942, 12579.67287072, 4517.74154051], - [16410.9287533 , 12594.68859562, 4517.72495917], - [16410.8830906 , 12609.75996088, 4517.70831885], - [16423.92838453, 12741.89176595, 4517.22386344], - [16033.62058976, 12721.51342376, 4527.6469361 ]], - - [[16408.8657197 , 12579.56168603, 4409.92226749], - [16408.8135205 , 12594.57708692, 4409.90833169], - [16408.76120924, 12609.65551428, 4409.89434406], - [16421.77969101, 12741.79405475, 4409.50496541], - [16032.71326033, 12721.46896759, 4418.22790148]], - - [[16407.23436321, 12579.4768772 , 4302.2755937 ], - [16407.17888404, 12594.49211765, 4302.26433112], - [16407.1232752 , 12609.57618598, 4302.25302533], - [16420.13949534, 12741.71999515, 4301.94759183], - [16032.09474038, 12721.43866208, 4308.95742526]], - - [[16406.00793684, 12579.41251124, 4194.80603338], - [16405.94896888, 12594.42758103, 4194.79767356], - [16405.88985486, 12609.51580499, 4194.78927972], - [16418.89231544, 12741.66399687, 4194.5600881 ], - [16031.68009378, 12721.41834568, 4199.86254944]], - - [[16405.11326023, 12579.36510472, 4087.01947822], - [16405.05118567, 12594.38002251, 4087.01297748], - [16404.98895064, 12609.4712174 , 4087.00645143], - [16417.97436571, 12741.62307043, 4086.83626563], - [16031.42833523, 12721.40601028, 4090.92948227]], - - [[16404.48966849, 12579.33276707, 3979.40500279], - [16404.42649817, 12594.34763125, 3979.40037793], - [16404.36315984, 12609.44102407, 3979.3957353 ], - [16417.3474224 , 12741.59551502, 3979.27715613], - [16031.31256818, 12721.40033805, 3982.14683527]], - - [[16404.07654526, 12579.31121694, 3872.01503796], - [16404.01234367, 12594.32603067, 3872.01244392], - [16403.94796844, 12609.42086247, 3872.00983815], - [16416.92741634, 12741.57729935, 3871.93631308], - [16031.27700115, 12721.39859537, 3873.58886779]], - - [[16403.81946653, 12579.2974967 , 3764.18689065], - [16403.75423527, 12594.31226006, 3764.18512321], - [16403.68882551, 12609.40794507, 3764.1833487 ], - [16416.66048428, 12741.56589598, 3764.13654415], - [16031.28783824, 12721.39912636, 3765.25858058]], - - [[16403.675768 , 12579.2900498 , 3656.62253475], - [16403.6103148 , 12594.30480229, 3656.6215846 ], - [16403.54468129, 12609.40101175, 3656.6206307 ], - [16416.51545365, 12741.55987973, 3656.59580615], - [16031.32075801, 12721.40073933, 3657.17713273]], - - [[16403.60750168, 12579.28661603, 3549.27121647], - [16403.54201457, 12594.30136687, 3549.27114589], - [16403.47634674, 12609.39784713, 3549.27107367], - [16416.44734748, 12741.55711132, 3549.26406482], - [16031.34087955, 12721.40172522, 3549.30862958]], - - [[16403.58394093, 12579.28538881, 3441.55541421], - [16403.51837638, 12594.30013587, 3441.55536843], - [16403.45263077, 12609.39671114, 3441.55532218], - [16416.42299223, 12741.55611293, 3441.55303659], - [16031.34365175, 12721.40186105, 3441.58417468]], - - [[16403.58071189, 12579.28521068, 3334.02567603], - [16403.51514026, 12594.29995739, 3334.02564935], - [16403.44938751, 12609.3965537 , 3334.02562261], - [16416.41975505, 12741.55596001, 3334.02524638], - [16031.33740869, 12721.40155516, 3334.04108503]], - - [[16403.58071183, 12579.28521958, 3226.5351637 ], - [16403.5151507 , 12594.2999668 , 3226.53516213], - [16403.44940848, 12609.39656655, 3226.53516056], - [16416.42001037, 12741.55592685, 3226.5352802 ], - [16031.32839538, 12721.40111353, 3226.53516375]], - - [[16403.5807118 , 12579.28522741, 3119.01255597], - [16403.51515795, 12594.29997499, 3119.01255593], - [16403.44942304, 12609.39657585, 3119.01255588], - [16416.42019638, 12741.55590086, 3119.01262098], - [16031.32239871, 12721.40081972, 3119.01233267]], - - [[16403.58071181, 12579.28522585, 3011.46107734], - [16403.51515549, 12594.29997331, 3011.46107767], - [16403.44941811, 12609.3965739 , 3011.461078 ], - [16416.42019424, 12741.55588933, 3011.46107836], - [16031.32077124, 12721.40073998, 3011.46126391]], - - [[16403.58071183, 12579.28522088, 2903.88111651], - [16403.5151507 , 12594.29996811, 2903.88111709], - [16403.44940848, 12609.39656798, 2903.88111768], - [16416.42011698, 12741.55589123, 2903.88108915], - [16031.32196991, 12721.40079871, 2903.8811165 ]], - - [[16403.58071184, 12579.28521848, 2796.31569448], - [16403.51514887, 12594.29996562, 2796.31569462], - [16403.44940479, 12609.39656517, 2796.31569475], - [16416.42005943, 12741.5559013 , 2796.31566054], - [16031.32436749, 12721.40091618, 2796.31566933]], - - [[16403.58071184, 12579.285219 , 2688.76449661], - [16403.51514967, 12594.29996617, 2688.76449701], - [16403.44940641, 12609.39656581, 2688.76449741], - [16416.42004141, 12741.55591135, 2688.76448248], - [16031.32629994, 12721.40101086, 2688.76416242]], - - [[16403.58071183, 12579.28521998, 2581.22757735], - [16403.5151507 , 12594.29996721, 2581.22757728], - [16403.44940848, 12609.39656699, 2581.22757722], - [16416.42004302, 12741.55591594, 2581.22757328], - [16031.32698521, 12721.40104444, 2581.22757735]], - - [[16403.58071183, 12579.28521995, 2473.68391385], - [16403.51515043, 12594.29996716, 2473.68391387], - [16403.44940793, 12609.39656693, 2473.6839139 ], - [16416.4200414 , 12741.55591513, 2473.68392039], - [16031.32678618, 12721.40103469, 2473.68391708]], - - [[16403.58071183, 12579.28521989, 2366.13294642], - [16403.51515034, 12594.2999671 , 2366.13294705], - [16403.44940775, 12609.39656686, 2366.13294767], - [16416.42004875, 12741.55591221, 2366.13296437], - [16031.32617035, 12721.40100452, 2366.13255922]], - - [[16403.58071183, 12579.28522026, 2258.57402713], - [16403.5151507 , 12594.29996748, 2258.57402707], - [16403.44940848, 12609.39656729, 2258.574027 ], - [16416.42006189, 12741.55590963, 2258.57403361], - [16031.32559167, 12721.40097616, 2258.57402714]], - - [[16403.58071183, 12579.28522064, 2151.01980327], - [16403.51515108, 12594.29996788, 2151.01980325], - [16403.44940923, 12609.39656774, 2151.01980323], - [16416.42007093, 12741.55590849, 2151.01980522], - [16031.32534267, 12721.40096396, 2151.01979975]], - - [[16403.58071183, 12579.28522058, 2043.468405 ], - [16403.51515099, 12594.29996782, 2043.46840503], - [16403.44940905, 12609.39656767, 2043.46840506], - [16416.42006995, 12741.55590837, 2043.4684038 ], - [16031.32535021, 12721.40096433, 2043.4684075 ]], - - [[16403.58071183, 12579.28522028, 1935.92059215], - [16403.5151507 , 12594.29996751, 1935.92059219], - [16403.44940848, 12609.39656732, 1935.92059223], - [16416.42006422, 12741.55590886, 1935.92058974], - [16031.32549627, 12721.40097149, 1935.92059215]], - - [[16403.58071183, 12579.2852201 , 1828.37190509], - [16403.51515054, 12594.29996731, 1828.3719053 ], - [16403.44940815, 12609.3965671 , 1828.37190552], - [16416.42005971, 12741.55590955, 1828.37190576], - [16031.3256634 , 12721.40097968, 1828.37178063]], - - [[16403.58071183, 12579.28522014, 1720.82029199], - [16403.5151506 , 12594.29996736, 1720.82029202], - [16403.44940829, 12609.39656716, 1720.82029206], - [16416.42005916, 12741.55591007, 1720.82029176], - [16031.32575317, 12721.40098407, 1720.82026305]], - - [[16403.58071183, 12579.28522024, 1613.2671892 ], - [16403.5151507 , 12594.29996746, 1613.26718919], - [16403.44940848, 12609.39656727, 1613.26718918], - [16416.42006013, 12741.55591022, 1613.26718935], - [16031.32576553, 12721.40098468, 1613.2671892 ]], - - [[16403.58071183, 12579.28522026, 1505.7155925 ], - [16403.51515071, 12594.29996749, 1505.71559274], - [16403.4494085 , 12609.3965673 , 1505.71559298], - [16416.4200607 , 12741.55591008, 1505.71559626], - [16031.3257316 , 12721.40098302, 1505.71544953]], - - [[16403.58071183, 12579.28522026, 1398.16444693], - [16403.5151507 , 12594.29996748, 1398.16444736], - [16403.44940848, 12609.39656729, 1398.16444779], - [16416.42006122, 12741.55590985, 1398.16445314], - [16031.32568456, 12721.40098071, 1398.16419025]], - - [[16403.58071183, 12579.28522026, 1290.61367613], - [16403.5151507 , 12594.29996748, 1290.61367612], - [16403.44940848, 12609.3965673 , 1290.61367612], - [16416.4200617 , 12741.5559097 , 1290.61367642], - [16031.32565476, 12721.40097925, 1290.61367613]], - - [[16403.58071183, 12579.28522027, 1183.0633879 ], - [16403.51515072, 12594.2999675 , 1183.0633879 ], - [16403.44940851, 12609.39656731, 1183.0633879 ], - [16416.42006206, 12741.55590966, 1183.0633879 ], - [16031.32564784, 12721.40097891, 1183.06338773]], - - [[16403.58071183, 12579.28522027, 1075.5121294 ], - [16403.51515072, 12594.2999675 , 1075.5121294 ], - [16403.44940851, 12609.39656731, 1075.5121294 ], - [16416.42006195, 12741.55590969, 1075.51212924], - [16031.32565487, 12721.40097926, 1075.51212952]], - - [[16403.58071183, 12579.28522026, 967.96020966], - [16403.5151507 , 12594.29996748, 967.96020966], - [16403.44940848, 12609.39656729, 967.96020966], - [16416.42006158, 12741.55590974, 967.96020949], - [16031.32566721, 12721.40097986, 967.96020966]], - - [[16403.58071183, 12579.28522025, 860.40950112], - [16403.51515069, 12594.29996747, 860.4095012 ], - [16403.44940846, 12609.39656728, 860.40950128], - [16416.4200613 , 12741.55590978, 860.40950203], - [16031.32567653, 12721.40098032, 860.40945484]], - - [[16403.58071183, 12579.28522025, 752.85763208], - [16403.51515069, 12594.29996747, 752.85763211], - [16403.44940847, 12609.39656728, 752.85763215], - [16416.42006129, 12741.5559098 , 752.85763252], - [16031.32567956, 12721.40098047, 752.85761081]], - - [[16403.58071183, 12579.28522026, 645.3070247 ], - [16403.5151507 , 12594.29996748, 645.3070247 ], - [16403.44940848, 12609.39656729, 645.3070247 ], - [16416.4200614 , 12741.5559098 , 645.30702474], - [16031.32567834, 12721.40098041, 645.3070247 ]], - - [[16403.58071183, 12579.28522026, 537.75565143], - [16403.5151507 , 12594.29996748, 537.75565103], - [16403.44940849, 12609.3965673 , 537.75565064], - [16416.42006147, 12741.55590979, 537.75564634], - [16031.32567522, 12721.40098026, 537.7558873 ]], - - [[16403.58071183, 12579.28522026, 430.20512896], - [16403.5151507 , 12594.29996748, 430.20512914], - [16403.44940848, 12609.39656729, 430.20512932], - [16416.4200615 , 12741.55590977, 430.20513129], - [16031.32567266, 12721.40098013, 430.20502356]], - - [[16403.58071183, 12579.28522026, 322.65324369], - [16403.5151507 , 12594.29996748, 322.65324369], - [16403.44940848, 12609.39656729, 322.65324369], - [16416.42006149, 12741.55590977, 322.65324369], - [16031.32567204, 12721.4009801 , 322.65324369]], - - [[16403.58071183, 12579.28522026, 179.25255722], - [16403.5151507 , 12594.29996748, 179.25255722], - [16403.44940848, 12609.39656729, 179.25255722], - [16416.42006147, 12741.55590977, 179.25255721], - [16031.32567286, 12721.40098014, 179.25255722]], - - [[16403.58071183, 12579.28522026, 71.70053582], - [16403.5151507 , 12594.29996748, 71.70053582], - [16403.44940848, 12609.39656729, 71.70053582], - [16416.42006146, 12741.55590977, 71.70053582], - [16031.32567336, 12721.40098016, 71.70053582]], - - [[16403.58071183, 12579.28522026, -0. ], - [16403.5151507 , 12594.29996748, -0. ], - [16403.44940848, 12609.39656729, -0. ], - [16416.42006147, 12741.55590977, 0.00000117], - [16031.32567306, 12721.40098015, 0.00000033]]]), - 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., 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., 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., 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., 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.], - [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., 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., 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., 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., 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.], - [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., 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., 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., 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., 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.], - [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., 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., 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., 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., 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.], - [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., 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., 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., 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., 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.], - [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., 1., 1., 1.], - [1., 1., 1., 1., 1.]]) -) import numpy as np from mmcore.nurbs._nurbs_eval import NURBSSurfaceTuple -val = NURBSSurfaceTuple( +surf0 = NURBSSurfaceTuple( order_u=4, order_v=3, knot_u=np.array([0. , 0. , 0. , 0. , 0.00338983, @@ -6465,6 +2157,8 @@ [1., 1., 1., 1., 1.]]) ) +import numpy as np +from mmcore.nurbs._nurbs_eval import NURBSSurfaceTuple surf11 = NURBSSurfaceTuple( order_u=2, order_v=2, @@ -6482,13 +2176,14 @@ order_v=2, knot_u=np.array([ 0. , 0. , 131.00003387, 131.00003387]), knot_v=np.array([ 0. , 0. , 3516.63007178, 3516.63007178]), - control_points=np.array([[[ 1084.33133643, 11668.63596229, 8720.36060565], - [ 1084.33133643, 11668.63596229, 5203.73053387]], - [[ 997.52510397, 13455.75663468, 8720.36060565], - [ 997.52510397, 13455.75663468, 5203.73053387]]]), + control_points=np.array([[[ 1077.90493537, 11800.93929882, 8720.36060565], + [ 1077.90493537, 11800.93929882, 5203.73053387]], + [[ 991.09870291, 13588.05997121, 8720.36060565], + [ 991.09870291, 13588.05997121, 5203.73053387]]]), weights=np.array([[1., 1.], [1., 1.]]) ) + surf21 = NURBSSurfaceTuple( order_u=2, order_v=2, @@ -6506,10 +2201,10 @@ order_v=2, knot_u=np.array([ 0. , 0. , 131.00017857, 131.00017857]), knot_v=np.array([ 0. , 0. , 1826.58091401, 1826.58091401]), - control_points=np.array([[[ 1510.93174833, 12712.95705789, 9609.29772107], - [ 1510.93174833, 12712.95705789, 7782.71680706]], - [[ 1569.43788559, 11522.27932289, 9609.29772107], - [ 1569.43788559, 11522.27932289, 7782.71680706]]]), + control_points=np.array([[[ 1496.09587347, 13014.88685022, 9609.29772107], + [ 1496.09587347, 13014.88685022, 7782.71680706]], + [[ 1554.60201073, 11824.20911522, 9609.29772107], + [ 1554.60201073, 11824.20911522, 7782.71680706]]]), weights=np.array([[1., 1.], [1., 1.]]) ) @@ -6525,5 +2220,13 @@ weights=np.array([[1., 1.], [1., 1.]]) ) +expected_endpoints=[ +[[1071.5493330924025, 11931.785067546291, 8376.1379083266693], [1077.9049353701703, 11800.939298819361, 5799.1039356666897]], + [[1071.5493255583958, 11931.785222652386, 8420.3468911277050], + [1077.9049353701705, 11800.939298819361, 5799.1039356666915]], +[[1548.1728330824903, 11955.051434292520, 9438.5277543415432], [1554.6020107296904, 11824.209115220261, 7848.1591260092209]], +[[1554.6020107296906, 11824.209115220261, 7848.1591260092191], [1548.1727540027368, 11955.053043670739, 9478.2415358005965]], +[[2024.7868943404787, 11978.700714376249, 10038.827694773325], [2031.3524671152225, 11847.865316333511, 8606.2816289614930]] +] diff --git a/examples/ssx/case16.py b/examples/ssx/case16.py index 3a34be2c..68068001 100644 --- a/examples/ssx/case16.py +++ b/examples/ssx/case16.py @@ -1,18 +1,43 @@ import os import sys + +import numpy as np + sys.path.append(os.path.abspath(os.path.dirname(__file__))) -from _case16_data import surf0,surf11,surf12,surf21,surf22,surf31 +from _case16_data import surf0,surf11,surf12,surf21,surf22,surf31,expected_endpoints -def print_res_summary(res): +def print_res_summary(res,expected_endpts): keys=[ 'branches', 'points', 'singularities', 'overlap_regions', - 'unresolved_regions', + #'unresolved_regions', ] + for k in keys: - print(k,res[k],sep=':',end='; ') + if k=='branches': + print(k, len(res[k]), sep=':') + if len(res[k])!=1: + print(f'WARNING: number of branches ({len(res[k])}) does not match expected (1)') + + for j,b in enumerate(res['branches']): + endpts_match=(np.linalg.norm(b.curve[1][0] - expected_endpts[0]) Date: Wed, 23 Sep 2026 02:48:09 +0300 Subject: [PATCH 4/8] Preserve CAD branches across nonlinear charts and review tolerance contracts --- .../2026-09-14-ssx-tolerance-repair.md | 51 ++ .../reviews/2026-09-23-ssx-test-contract.md | 88 ++++ mmcore/numeric/intersection/ccx/_bez_ccx4.py | 97 +++- mmcore/numeric/intersection/csx/_bez_csx4.py | 93 +++- mmcore/numeric/intersection/ssx/_bez_ssx5.py | 461 +++++++++--------- tests/test_bez_ccx4.py | 109 ++++- tests/test_bez_csx4.py | 57 +-- tests/test_bez_ssx5_singular.py | 48 +- tests/test_ccx4_exactness_contract.py | 21 +- tests/test_ccx_cad_affine_overlap_bound.py | 67 +++ tests/test_ccx_cad_endpoint_tangency.py | 16 +- tests/test_csx4_exactness_contract.py | 126 +++-- tests/test_csx_cad_contact_seeds.py | 48 ++ tests/test_csx_overlap_normal_gap.py | 36 ++ tests/test_nssx5.py | 59 ++- tests/test_nssx5_case16.py | 35 ++ tests/test_ssx5_boundary_continuation.py | 5 + tests/test_ssx5_isolines.py | 35 +- tests/test_ssx5_matched_isolines.py | 17 +- tests/test_ssx5_probe_split.py | 25 +- tests/test_ssx_assembly_chart_invariance.py | 49 ++ tests/test_ssx_cut_face_queries.py | 77 +++ tests/test_ssx_cut_overlap_contacts.py | 60 +++ ...est_ssx_nonplanar_projection_invariance.py | 8 +- tests/test_ssx_partial_boundary_witnesses.py | 8 +- 25 files changed, 1225 insertions(+), 471 deletions(-) create mode 100644 docs/superpowers/reviews/2026-09-23-ssx-test-contract.md create mode 100644 tests/test_ccx_cad_affine_overlap_bound.py create mode 100644 tests/test_csx_cad_contact_seeds.py create mode 100644 tests/test_csx_overlap_normal_gap.py create mode 100644 tests/test_nssx5_case16.py create mode 100644 tests/test_ssx_assembly_chart_invariance.py create mode 100644 tests/test_ssx_cut_face_queries.py create mode 100644 tests/test_ssx_cut_overlap_contacts.py diff --git a/docs/superpowers/reviews/2026-09-14-ssx-tolerance-repair.md b/docs/superpowers/reviews/2026-09-14-ssx-tolerance-repair.md index acd73d08..31aa34b4 100644 --- a/docs/superpowers/reviews/2026-09-14-ssx-tolerance-repair.md +++ b/docs/superpowers/reviews/2026-09-14-ssx-tolerance-repair.md @@ -2,6 +2,32 @@ Status: implementation and validation in progress; not yet integrated into `tiny`. +## September 23: case 16 and test-contract correction + +The user's new `case16.py` cases were compared using the same Python environment, +native extensions, and committed `_case16_data.py`. Local `tiny` returned the +whole curve for all five pairs. The repair initially lost 725–925 model units +from one end while reporting a complete result. + +The loss happened inside Bézier assembly, before NURBS stitching. A cleanup +filter compared the two surface evaluations at interpolated parameter midpoints +and treated their residual as distance from the intersection. This is not a +geometric chord-error measure: different surface charts need not interpolate +corresponding physical points. It deleted correctly traced fragments, including +ordinary transverse intersections. The filter and its empirical sagitta-credit +constant are removed. Geometry must be refined during tracing; deleting the +whole recovered component cannot repair an inaccurate chord. + +All five real cases now pass endpoint and original-source residual checks at +`1e-3`. Twelve independent straight-intersection cases with nonlinear parameter +correspondence reproduce the old deletion and pass after removal. The original +touch-plus-ring controls also pass with the corrected CAD contact admission. + +The accompanying [test audit](2026-09-23-ssx-test-contract.md) replaces excessive +output precision with physical-distance checks, while retaining coverage, +paired-source geometry, and actual work-limit checks. These focused results +do not replace the pending complete integration run. + ## Regression and responsibility The working reference is `76735f9fc11a5e8149fb60d171f46cacd0ff4ec9`. @@ -51,6 +77,13 @@ algebraic policy: - Default CAD CCX/CSX no longer wait for optional exact-root certificates. Constant curves and collapsed target isolines no longer fill the root list with duplicate representatives of one geometric contact. +- General continuation runs before the independent parameter-singularity + census, so an extended singular set cannot consume all remaining work before + an already seeded intersection branch is traced. +- CSX overlap sidedness tests the signed normal gap itself. Tangential + projection error no longer turns normal rounding noise into a false crossing. +- Validated point contacts absorbed into a CSX overlap remain available to + internal SSX cut faces, with their paired parameters intact. ## Test contract @@ -95,11 +128,29 @@ The endpoint-CSX example also does more work after repair because it now continues past the former early stop. Source hashes and call/cell measurements are recorded by the benchmark scripts; elapsed times depend on the machine. +A separate CCX cost was repeated polishing of identical subdivision-corner +starts against unchanged original curves. A bounded cache local to each search +reuses those calculations without changing tolerances, cell traversal, or +results. Four captured cut-face queries produced byte-identical serialized +results and identical cell counts with caching enabled and disabled. Their +polish counts changed from 353/853/1391/1390 to 179/422/683/682; elapsed time +decreased by approximately 31–38% in that comparison. + ## Validation Final full-suite results, clean Linux build results, and integration commit are pending. Focused results are diagnostic evidence only until those gates finish. +The first broad repair run at `9cea23c` was not green: macOS reported 6 failures +and 1241 passes; Linux aarch64 reported 7 failures, 1239 passes, and one xpass. +The original reported singular/overlap regressions passed. These broader runs +exposed the cone-census scheduling defect, Linux overlap-sidedness defect, +sheared-circle search cost, and two stale test-path/status expectations. The +case-10 expectation now includes an independent analytic check of its entire +curved tangential ruling; the boundary fault-injection test explicitly selects +general continuation and verifies that its injected calls occur. The remaining +production corrections require another complete run before integration. + The local clean Linux environment is Python 3.12 on Linux aarch64, not the GitHub-hosted x86_64 runner. A local pass must not be described as a successful GitHub Actions run. diff --git a/docs/superpowers/reviews/2026-09-23-ssx-test-contract.md b/docs/superpowers/reviews/2026-09-23-ssx-test-contract.md new file mode 100644 index 00000000..7fd66353 --- /dev/null +++ b/docs/superpowers/reviews/2026-09-23-ssx-test-contract.md @@ -0,0 +1,88 @@ +# SSX test contract audit + +The CAD intersection contract uses the caller's model-space tolerance, normally +`atol=1e-3`. Tests must not impose extra parameter digits or an algebraic proof +as conditions for accepting otherwise valid geometry. The checks below were +reviewed against that contract before modification; they were not adjusted to +make an observed geometric failure pass. + +## Corrections + +| Test | Change and retained protection | +| --- | --- | +| `test_ssx5_isolines.py`: multiple-root and nonbinary clipped rulings | Replace `1e-10`/`1e-11` output comparisons with Euclidean endpoint and line-position error at requested `atol`; also check both source evaluations and their paired residual. Branch count and type remain unchanged. | +| Same file: public collapsed isoline | Replace `1e-10` point/endpoint position checks with model-space `atol`; verify source evaluations. Preserve point versus real curve, fiber ownership, and full parameter coverage. | +| `test_ssx5_matched_isolines.py`: curved ruling and world-axis mixing | Replace exact endpoint coordinates and `1e-12`/`1e-11` geometry checks with model-space errors. Transformed geometry is checked in the world frame where `atol` applies. Preserve source/chord checks and the single tangential branch. | +| `test_ssx5_probe_split.py`: near-unit weights | Use the requested `1e-5` for line position and extent instead of `1e-9` and exact extent equality; add both-source residual checks. Both spatially separated branches are still required. The explicitly supplied tolerance is unchanged. | +| `test_ssx_nonplanar_projection_invariance.py` | Keep strict signed winding, one closed branch, complete circle coverage, and length checks. Replace `1e-6` radians of permitted reverse travel with at most one `atol` of reverse world-space travel. | +| `test_nssx5.py`: multispan crossing | Measure complete endpoint extent in world coordinates at `1e-3`, replacing a fixed `1e-6` parameter bound. Preserve one continuous branch and both-plane geometry checks. | +| Same file: seam tangent point | Allow successful completion instead of requiring historical partial reasons. Still require one tangent point at the independent expected position and consistency between completion and reasons. | +| Same file: case 10 | Preserve the newly added complete analytic contact and reference-curve coverage. Replace `1e-10`/`1e-12` parameter comparisons with per-vertex distance to `(5+15*t*(1-t),15*t,5)`, using the physical output y coordinate to identify t. Source residual checks remain. | +| Same file: boundary coincidence | Use `1e-3` for output distance from the plane instead of `1e-6`; add paired source evaluations. Preserve both branches, endpoint matching, and length checks. | +| `test_bez_ssx5_singular.py`: cusp ruling | Evaluate sample distance from the known spatial cusp curve at `1e-3`, replacing fixed `1e-6` parameter accuracy. Preserve cusp classification and extent. | +| Same file: collapsed-fiber branch, cases 13 and 14 | Allow successful completion instead of requiring old incomplete outcomes. Preserve resource caps, branch/touch counts, expected geometry, and source residuals. Cases 13/14 test names now describe the recovered geometry. | +| Same file: case 13 normal angle | Remove the additional `sin(angle)<=1e-6` requirement at approximate output parameters. The typed touch, independent expected location, and paired source residual remain checked; curvature and chart scale make a universal angular error inappropriate for a model-space tolerance. | +| Same file: identical overlap patches | Compare source evaluations of the interior witness within `atol`, rather than requiring `1e-6` equality of parameters. Require both parameter pairs to remain interior; preserve the whole region and closed rim checks. | + +## Checks intentionally retained + +- Component counts, distinct parameter sheets, full curve coverage, missing + sectors, closed-loop winding, and geometric errors larger than requested + tolerance are not relaxed. +- Exact copied data, joined endpoint identity, preservation of supplied weights, + knot/periodicity metadata, and fixture-construction arithmetic remain strict. +- Forced budget/depth tests still require the actual stop to be reported and + previously recovered geometry to remain available. +- Tests explicitly selecting optional exact raw backends remain separate from + default CAD behavior; this audit does not change their arithmetic assertions. + +## Raw CSX/CCX follow-up + +The initial audit found default-mode tests excluding contacts within `atol` and +requiring empty partial output for near-coincident curves. Those expectations +are being corrected together with the source policy, rather than hiding the +inconsistency by moving the default-mode examples into exact mode. + +- `test_csx4_exactness_contract.py` now checks collapsed-curve and exterior-edge + contact membership on both sides of `atol`, including translated inputs and + paired source residuals. Separate explicit `tolerance_tier=False` checks keep + the optional exact-set behavior scoped. The large-translation overlap test + uses the input ULP scale for its residual measurement comparison, preserves + the `1e-3` geometric bound, and no longer has a Linux xfail. +- `test_bez_csx4.py::test_case_13` now requires both the rounded endpoint contact + (about `2.3e-8` from the surface) and the separate interior crossing, with + source residual checks before and after translation. This is distinct from + the SSX case 13 fixture. +- The CCX close-root test now uses faithful dyadic controls at scale `2^14`, + with valley depth above `atol`, physical root-location checks, and the normal + solver allowance. The former `1e10` coefficient multiplication shifted its + intended roots by about `0.0078` and `0.1216` world units; its `1e-9` parameter + assertion allowed 10 world units. Neither the distorted reference nor the + arbitrary 2,000-cell cap was retained as a production requirement. +- The two near-coincident cubic tests in `test_bez_ccx4.py` now require real + overlap geometry. The offset twin has a full `[0,1]` pairing with a residual + near `1e-9`. The fitted pair has correspondence + `u=[0,0.827597762202295]`, `v=[0.1906907548416867,1]`, whose entire cubic + difference is bounded by `3.78e-9` from its Bernstein control hull. Its + unused tails end about `4.89` and `5.03` model units away. Returned overlap + spans are checked with the same independent continuous difference bound, + and omitted range is bounded by physical control-polygon arc length. + Complete output is allowed; an explicit partial result is allowed only if + its actual spans retain this geometry. An empty diagnostic cannot pass. + +## Verification + +- Isoline, matched-isoline, and probe tests: **41 passed** (3.93 seconds). +- Initial focused public NURBS, singular, and six transformed-circle cases: + **12 passed, 2 failed** (89.44 seconds). +- That run exposed source defects: case 13 returned 15 untyped points, and the + identity circle reported `depth_limit`. Their geometry/status assertions were + retained; neither is converted into an expected failure. +- Fixture cases 5, 8, and 10 after the final assertion correction: + **3 passed** (1.14 seconds). +- The revised near-coincident cubic tests initially both failed on empty + overlap lists (8.24 seconds), confirming that they expose missing geometry. + Their coverage helper accepts independently constructed valid complete and + partial spans and rejects a truncated span. Source-fix verification is + recorded in the main repair report. +- `git diff --check` passed. This note is not a full-suite green claim. diff --git a/mmcore/numeric/intersection/ccx/_bez_ccx4.py b/mmcore/numeric/intersection/ccx/_bez_ccx4.py index ef654b12..bc5b7588 100644 --- a/mmcore/numeric/intersection/ccx/_bez_ccx4.py +++ b/mmcore/numeric/intersection/ccx/_bez_ccx4.py @@ -572,6 +572,51 @@ def _project_point_on_curve(C, pt, rational, seed=None): return _invert_point_on_curve(C, pt, seed, rational) +def _cad_affine_overlap_bound(C1, C2, u_range, v_range, rational): + """Bound the whole paired span's distance, including rational weights. + + Both parameters vary affinely over their proposed ranges. The numerator + of their difference is a Bernstein curve; its control hull bounds its + norm, while positive weight minima bound the denominator from below. + This proves CAD proximity, without asserting an exact root census. + """ + points = _cartesian_curve_controls_for_exactness(C1, rational) + if points is None: + return None + sources = [] + for curve, parameter_range in ((C1, u_range), (C2, v_range)): + homogeneous, operands = _homogeneous_curve_for_identity( + curve, rational, points[0], with_source_scale=True) + if homogeneous is None: + return None + if np.all(homogeneous[:, -1] < 0.): + homogeneous = -homogeneous + if np.any(homogeneous[:, -1] <= 0.): + return None + source_size = np.max(np.abs(homogeneous[:, :-1]) + operands, axis=0) + weight_size = np.max(homogeneous[:, -1]) + restricted = _restrict_curve_interval(homogeneous, *parameter_range) + if restricted is None: + return None + sources.append((restricted, source_size, weight_size)) + (first, first_size, first_weight), (second, second_size, second_weight) = sources + w1, w2 = first[:, -1], second[:, -1] + denominator = np.min(w1) * np.min(w2) + if not np.isfinite(denominator) or denominator <= 0.: + return None + difference = np.column_stack([ + _bernstein_product_1d(first[:, axis], w2) + - _bernstein_product_1d(second[:, axis], w1) + for axis in range(first.shape[1] - 1)]) + # Restriction, centering and Bernstein products all use source + # floating data. Add their operand-sized roundoff to this upper bound. + operations = 32. * (len(first) + len(second)) ** 2 + error = (operations * np.finfo(float).eps + * (first_size * second_weight + second_size * first_weight)) + bound = np.max(np.linalg.norm(np.abs(difference) + error, axis=1)) / denominator + return float(bound) if np.isfinite(bound) else None + + 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). @@ -1349,6 +1394,19 @@ def _phase2_ccx(F, C1, C2, C1_orig, C2_orig, exhausted = False component_scale = _ccx_exactness_context( C1_orig, C2_orig, rational) + # Sibling cells repeatedly propose the same dyadic corners. Polishing + # depends only on that exact parameter pair and these original curves, + # not on the cell or the roots found so far. Reuse both successes and + # failures within this search; keep memory bounded independently of + # max_cells. Eviction changes cost only, never a geometric decision. + from functools import lru_cache + + @lru_cache(maxsize=256) + def polish_seed(u, v): + return _strict_polish_ccx( + C1_orig, C2_orig, u, v, rational, + component_scale=component_scale, require_newton=True) + # A constant input curve contributes a free parameter, so its contacts # cannot be isolated in the two-parameter product. In the modeling # tier, retain the ordinary point representatives and resolve the @@ -1574,9 +1632,7 @@ def attach_certificate(entry, box, certificate, known): v_mid = 0.5 * (v0 + v1) polished = None if not zero_free: - polished = _strict_polish_ccx( - C1_orig, C2_orig, u_mid, v_mid, rational, - component_scale=component_scale, require_newton=True) + polished = polish_seed(u_mid, v_mid) if polished is not None: u_sol, v_sol, pt = polished box, certificate, known = root_neighborhood_resolved(u_sol, v_sol) @@ -1661,9 +1717,7 @@ def attach_certificate(entry, box, certificate, known): for u_mid, v_mid in uv_candidates: if root_found: break - polished = _strict_polish_ccx( - C1_orig, C2_orig, u_mid, v_mid, rational, - component_scale=component_scale, require_newton=True) + polished = polish_seed(u_mid, v_mid) if polished is not None: u_sol, v_sol, pt = polished else: @@ -2209,6 +2263,37 @@ def _drain_tolerance_pool(): return accepted, undecided def _finalize(topology_complete=True): + # Fitting-noise crossings can defeat isolated-root polishing even + # though a whole paired span follows the same CAD geometry. Give + # the ordinary bounded root search its full chance first: actual + # isolated crossings must remain available to nested CSX callers. + # If it found none, a control-hull distance bound can still preserve + # useful overlap geometry. The search's partial status is retained; + # unvisited complementary spans are not declared empty. + if (tolerance_tier and non_affine_overlap_fallback + and budget_exhausted and not isolated and not overlaps + and non_affine_overlap_cells_remaining <= 0 + and int(max_results) > 0): + pairs = [(a, b) for i, a in enumerate(band_anchors) + for b in band_anchors[i + 1:]] + pairs.sort(key=lambda pair: -abs(pair[1][0] - pair[0][0])) + n, m = len(C1_orig), len(C2_orig) + bound_work = max(1, (3 * n * m + n * n + m * m + 127) // 128) + for (ua, va), (ub, vb) in pairs: + if cells.remaining < bound_work: + break + cells.spend(bound_work) + distance_bound = _cad_affine_overlap_bound( + C1_orig, C2_orig, (ua, ub), (va, vb), rational) + if distance_bound is not None and distance_bound <= atol: + overlaps.append({ + 'boundary_zeros': [], 'overlap_endpoints': [], + 'u_range': (float(ua), float(ub)), + 'v_range': (float(va), float(vb)), + 'certification': 'tolerance', + 'residual_max': distance_bound, + }) + break # A singular curve/line root need not admit a square Jacobian # certificate. Its exact GCD/Sturm census can resolve those root # obligations independently of the completed modeling search. diff --git a/mmcore/numeric/intersection/csx/_bez_csx4.py b/mmcore/numeric/intersection/csx/_bez_csx4.py index 20945654..2d46963a 100644 --- a/mmcore/numeric/intersection/csx/_bez_csx4.py +++ b/mmcore/numeric/intersection/csx/_bez_csx4.py @@ -868,15 +868,20 @@ def _pinned(t_end, outward): 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 + # (3) flip guard on the normal gaps INSIDE the span: root-like + # samples (abs(signed) <= 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] + # A stopped projection can retain a tangential residual larger + # than this floor even on a planar overlap. Its normal component + # can still be pure rounding noise, so the total distance must + # not make that component's sign meaningful (or BLAS rounding + # alone can reject a full overlap). + idx = [k for k in range(k0, k1 + 1) if abs(signed[k]) > tiny] flip = any(signed[a] * signed[b] < 0.0 for a, b in zip(idx, idx[1:]) if a != k0 and b != k1) @@ -1248,12 +1253,17 @@ def _project_point_on_surface(pt, S, u_seed, v_seed, atol, rational, max_it=20): def _collapsed_point_surface_membership( - C, S, t, u, v, atol, rational, strict_context, + C, S, t, u, v, atol, rational, strict_context, *, tolerance_tier=False, ): - """Certify one representative of a collapsed curve parameter fiber. + """Validate 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, + The modeling tier uses the same geometric tolerance as an ordinary + curve/surface contact. The curve is identically a point, so every + curve parameter represents that contact; no surface neighborhood is + inferred from the tolerance. + + The optional exact-only tier uses the roundoff-scale certificate in + :func:`_strict_csx_residual_ok`. Collapsed fibers retain its 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 @@ -1267,6 +1277,11 @@ def _collapsed_point_surface_membership( point = eval_curve(C, float(t), rational=rational) u, v, _dist = _project_point_on_surface( point, S, u, v, atol, rational, max_it=64) + if tolerance_tier: + residual = point - eval_surface(S, u, v, rational=rational) + return (bool(np.all(np.isfinite(residual)) + and np.linalg.norm(residual) <= atol), + float(u), float(v), residual) strict, residual = _strict_csx_residual_ok( C, S, t, u, v, rational, strict_context) if strict: @@ -2151,7 +2166,7 @@ def bez_csx( result. Positive-dimensional sets must be classified separately; this cap prevents an unrecognized set from turning dedup quadratic. tolerance_tier : bool - Allow residual-certified curve/surface overlaps within ``atol``. + Allow curve/surface contacts and overlaps within ``atol``. This modeling mode may merge isolated events at its parameter resolution; complete status is not an exhaustive exact-root census. Set False when a caller needs only exact intersection topology; @@ -2282,11 +2297,11 @@ def _append_fiber(candidate): kind = entity.get("kind", "min") if kind == "degenerate_surface": if surface_collapsed: - exact_member, u_member, v_member, _ = ( + 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: + strict_root_tol, tolerance_tier=tolerance_tier)) + if not member: continue if not _append_fiber({ "t_range": (0.0, 1.0), @@ -2309,11 +2324,11 @@ def _append_fiber(candidate): continue u_entity = float(entity.get("u", 0.5)) v_entity = float(entity.get("v", 0.5)) - exact_member, u_entity, v_entity, _ = ( + 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: + strict_root_tol, tolerance_tier=tolerance_tier)) + if not member: continue if not _append_fiber({ "t_range": (0.0, 1.0), @@ -2393,6 +2408,27 @@ def _append_fiber(candidate): csx_boundary_zeros = [] t_exclude = [] # t-intervals to cut from the curve + + def _absorb_boundary_contacts(overlap, low, high): + # A tolerance span can surround an isolated boundary contact. + # Keep its already-polished parameters for consumers such as SSX + # cut faces, which need that contact even when CSX represents the + # surrounding tolerance neighborhood as an overlap. These entries + # were admitted under max_results; no new roots are generated here. + nonlocal isolated + contacts, remaining = [], [] + for entry in isolated: + if low <= entry['t'] <= high: + contacts.append({key: entry[key] + for key in ('t', 'u', 'v', 'point', + 'certification', 'd_min') + if key in entry}) + else: + remaining.append(entry) + if contacts: + overlap['boundary_contacts'] = contacts + isolated = remaining + exact_topology = not tolerance_tier source_scale = _csx_residual_source_scale(C, S, rational) search_unresolved_boxes = list(boundary_diagnostics.get('unresolved_parameter_boxes', [])) @@ -2412,6 +2448,7 @@ def _append_fiber(candidate): truncation_cause = truncation_cause or 'results' break t_bz, u_bz, v_bz = _boundary_zero_to_tuv(bz, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0) + tolerance_contact = False boundary_proof = getattr(bz, '_root_proof', None) if exact_topology else None if boundary_proof is not None: t_r, u_r, v_r = boundary_proof['parameters'] @@ -2420,6 +2457,19 @@ def _append_fiber(candidate): 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 tolerance_tier and not root_ok: + # A face-constrained closest contact is valid CAD + # geometry even when its exact continuation root lies + # just outside the patch. Keep the bounded parameters; + # an isolated contact alone does not imply an overlap. + residual = (eval_curve(C, t_r, rational=rational) + - eval_surface(S, u_r, v_r, rational=rational)) + root_ok = bool( + np.all(np.isfinite([t_r, u_r, v_r])) + and all(0. <= p <= 1. for p in (t_r, u_r, v_r)) + and np.all(np.isfinite(residual)) + and np.linalg.norm(residual) <= atol) + tolerance_contact = root_ok if root_ok: pt_r = (boundary_proof['point'] if boundary_proof is not None else @@ -2430,6 +2480,12 @@ def _append_fiber(candidate): "t": float(t_r), "u": float(u_r), "v": float(v_r), "point": pt_r, } + if tolerance_contact: + # Downstream continuation must distinguish a nearest + # CAD contact from an equation root: the former alone + # does not establish a transversal crossing direction. + entry['certification'] = 'tolerance' + entry['d_min'] = float(np.linalg.norm(residual)) if exact_topology: if boundary_proof is not None: entry['_boundary_root_proof'] = boundary_proof @@ -2524,9 +2580,8 @@ def _append_fiber(candidate): t_hi_ovl + (0. if exact_topology else ptol_t))) # Remove isolated points inside the certified overlap. padding = 0. if exact_topology else atol - isolated = [iso for iso in isolated - if not (t_lo_ovl - padding - <= iso["t"] <= t_hi_ovl + padding)] + _absorb_boundary_contacts( + overlaps[-1], t_lo_ovl - padding, t_hi_ovl + padding) 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 @@ -2604,9 +2659,7 @@ def _append_fiber(candidate): 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)] + _absorb_boundary_contacts(_o, _t_lo - atol, _t_hi + atol) # the span(s) are certified: the L42 uncertified-span # fallback no longer applies to them. non_affine_overlap_span = None diff --git a/mmcore/numeric/intersection/ssx/_bez_ssx5.py b/mmcore/numeric/intersection/ssx/_bez_ssx5.py index 20fc2cba..c914eabd 100644 --- a/mmcore/numeric/intersection/ssx/_bez_ssx5.py +++ b/mmcore/numeric/intersection/ssx/_bez_ssx5.py @@ -1331,6 +1331,18 @@ def _normals_degenerate_at(S1h, S2h, x4) -> bool: crossing is TRANSVERSAL (bilinear cone apex vs plane). A vanishing normal is a C1 parameterization-cusp candidate, not a C2 tangency — c1_pass reports it as cusp/cusp_curve with its `surface` tag.""" + # A collapsed interior isoline is a positive-dimensional preimage of + # one point, just like a collapsed boundary edge. Evaluating its zero + # derivative can leave a roundoff-sized vector with an arbitrary + # direction, so a relative angle test alone can mislabel it as C2. + # Use the same control-polygon collapse test as the boundary-fiber path; + # this recognizes the C1 geometry without imposing a normal-size cutoff + # on otherwise regular, poorly conditioned charts. + for surface, parameters in ((S1h, x4[:2]), (S2h, x4[2:])): + for axis, value in enumerate(parameters): + if _curve_geometry_collapsed( + _extract_isoline(surface, axis, value), rational=True): + return True _, du1, dv1 = eval_surface_d1(S1h, x4[0], x4[1], rational=True) _, du2, dv2 = eval_surface_d1(S2h, x4[2], x4[3], rational=True) n1 = float(np.linalg.norm(np.cross(du1, dv1))) @@ -2147,18 +2159,15 @@ def _strict_ssx_root_tol(S1, S2, rational=True): _MARCH_PROGRESS_WINDOW = 32 _MARCH_PROGRESS_RATIO = 8.0 -# Dimensionless factors converting a measured/estimated chord SAGITTA into -# the slack a predicate must grant a polyline that carries it. Both exist -# because a chord is not the curve: the marcher may leave a sagitta of up +# Dimensionless factor converting an estimated chord SAGITTA into +# the slack a containment predicate must grant a polyline that carries it. +# A chord is not the curve: the marcher may leave a sagitta of up # to `sag_tol = 2*atol`, so any predicate measuring a chord against the -# curve is measuring discretization and must price it. Neither is a -# tolerance — each multiplies an operand measured from the geometry, so a +# curve is measuring discretization and must price it. This is not a +# tolerance — it multiplies an operand measured from the geometry, so a # finely sampled polyline keeps a tight bar and only a coarse one earns a # loose one. # -# VALLEY: ratio between `res / sin_ang` at the parametric midpoint and the -# sagitta itself; measured 1.9-4.0 on the toroidal SSI fixture. -_VALLEY_SAGITTA_CREDIT = 4.0 # CONTAINMENT: the duplicate test measures samples that lie ON the curve # against a KEEPER'S POLYLINE, so the gap it sees is the keeper's sagitta # (measured 9.602e-4 observed vs 9.620e-4 true — they agree). The factor @@ -3075,35 +3084,6 @@ def _mid_chord_deviates(S1, S2, stuv_a, stuv_b, xyz_a, xyz_b, atol, sag_tol, return float(np.linalg.norm(a3 + tt * ab - xm)) > sag_tol -def _mid_chord_sagitta(S1, S2, stuv_a, stuv_b, xyz_a, xyz_b, atol, rational): - """Measured chord sagitta, or None when the midpoint correction is - unreliable. - - Same quantity `_mid_chord_deviates` compares against `sag_tol`, returned - as a number. A polyline vertex lies ON the curve; its chords do not, and - the marcher is explicitly ALLOWED to leave them a sagitta of up to - `sag_tol = 2*atol`. Any downstream predicate that measures a chord - against the curve is therefore measuring discretization, and must size - its envelope by this value rather than assume the chord is exact. - """ - mid = 0.5 * (np.asarray(stuv_a, dtype=np.float64) - + np.asarray(stuv_b, dtype=np.float64)) - ms, mt, mu, mv, mres, msin = _ssx_correct(S1, S2, *mid, rational=rational) - # Accept-if (ledger L45): an unreliable correction measures nothing, so - # it earns no discretization credit — the conservative direction. - if not (np.isfinite(mres) and mres <= atol * max(msin, 1e-3)): - return None - xm = eval_surface(S1, ms, mt, rational=rational) - a3 = np.asarray(xyz_a, dtype=np.float64) - b3 = np.asarray(xyz_b, dtype=np.float64) - ab = b3 - a3 - denom = float(np.dot(ab, ab)) - if denom < 1e-30: - return None - tt = float(np.clip(np.dot(xm - a3, ab) / denom, 0.0, 1.0)) - return float(np.linalg.norm(a3 + tt * ab - xm)) - - def _polyline_sagitta_bound(xyz) -> float: """Sagitta a polyline carries, estimated from its OWN turn angles. @@ -5100,14 +5080,15 @@ def _clipped_corner(point): eval_bezier_surface_homog_with_derivs, project_surface_homog_to_cartesian, ) - derivatives = [] - for surface, parameters in ((cell.g1.surface, point[:2]), - (cell.g2.surface, point[2:])): - derivatives.append(project_surface_homog_to_cartesian( - *eval_bezier_surface_homog_with_derivs( - surface, *parameters, True))) - _, ds, dt, dss, dst, dtt = derivatives[0] - _, du, dv, duu, duv, dvv = derivatives[1] + def evaluate(q): + return [project_surface_homog_to_cartesian( + *eval_bezier_surface_homog_with_derivs(surface, *uv, True)) + for surface, uv in ((cell.g1.surface, q[:2]), + (cell.g2.surface, q[2:]))] + + derivatives = evaluate(point) + p1, ds, dt, dss, dst, dtt = derivatives[0] + p2, du, dv, duu, duv, dvv = derivatives[1] jacobian = np.column_stack((ds, dt, -du, -dv)) try: left_vectors, singular_values, vectors = np.linalg.svd( @@ -5116,6 +5097,33 @@ def _clipped_corner(point): return False margin = 4e-10 * singular_values[0] regular = bool(singular_values[-1] > margin) + if regular: + # A CSX witness can retain a small numerical residual. After + # mapping into a narrow child this is larger than the affine + # conversion roundoff used to recognize its faces. Correct a + # local copy before classifying tangent/curvature; enlarging + # the face tolerance would instead misclassify interior points. + correction = vectors[:3].T @ ( + (left_vectors.T @ (p2 - p1)) / singular_values) + candidate = np.asarray(point) + correction + if (np.all(np.isfinite(candidate)) + and np.all((0. <= candidate) & (candidate <= 1.))): + updated = evaluate(candidate) + a, b = updated[0][0], updated[1][0] + if (np.linalg.norm(a - b) < np.linalg.norm(p1 - p2) + and max(np.linalg.norm(a - p1), + np.linalg.norm(b - p2)) <= atol): + point = candidate + p1, ds, dt, dss, dst, dtt = updated[0] + p2, du, dv, duu, duv, dvv = updated[1] + jacobian = np.column_stack((ds, dt, -du, -dv)) + try: + left_vectors, singular_values, vectors = np.linalg.svd( + jacobian, full_matrices=True) + except np.linalg.LinAlgError: + return False + margin = 4e-10 * singular_values[0] + regular = bool(singular_values[-1] > margin) tangent = vectors[-1] if not regular: # At an incident point of a collapsed isoline, the free fiber @@ -6341,87 +6349,12 @@ def _chord_is_real(pa4, pc4): kept_idx.append(idx) branches = [branches[k] for k in sorted(kept_idx)] - # --- Drop valley-fiction branches (grazing-gap bridges) --- - # A march seeded at a near-touch grazing corner can exit-commit a single - # chord that slides along a sub-atol valley from the touch to the loop - # (the exit-commit gate bypasses the mid-chord check when there is no - # interior progress): every SAMPLE is Ψ-valid at tolerance, but the - # chord is not ON the intersection set. Per _ssx_correct's own contract - # the true-curve distance at a point is ≈ residual / sin_ang; drop a - # branch only when EVERY chord midpoint fails. Cheap: real branches - # exit at their first good chord. - # - # The bar is an ENVELOPE, not a constant. `res / sin_ang` is evaluated - # at the chord's PARAMETRIC midpoint, which by construction is not on - # the curve — it is off it by the chord's sagitta, which the marcher is - # allowed to grow to `sag_tol = 2*atol`. Judging it against a bare - # 2*atol therefore prices the verifier at exactly the producer's own - # allowance while measuring a quantity 1.9-4x larger, leaving NEGATIVE - # margin: measured on a toroidal SSI pair whose chords all cross at - # sin_ang = 0.9996 (~87 deg, maximally transversal — no valley at all) - # and whose sagittas were 0.42-0.76 of the allowance, all five chords - # scored 2.0-6.0e-3 against a 2.0e-3 bar and the arc was deleted. The - # loss was non-monotonic in atol (survives 2e-3, dies at 1e-3, survives - # 5e-4) and flipped under swapping the two surfaces, because which side - # of the knife edge a chord lands on is decided by the step sizes the - # adaptive marcher happens to pick. - # - # So credit each chord the discretization it actually carries. The - # credit is capped at the marcher's own `sag_tol` budget: a chord that - # exceeds its contract earns no more slack than one that honours it, - # and a valley bridge — whose corrected midpoint stays on the chord, so - # whose sagitta is ~0 — keeps the original bare 2*atol bar and is still - # rejected. `_VALLEY_SAGITTA_CREDIT` covers the measured 1.9-4x ratio - # between this estimator and the sagitta itself. - if S1_full is not None and branches: - _kept_v = [] - for b in branches: - stuv_b = np.asarray(b.curve[0], dtype=np.float64) - xyz_b = np.asarray(b.curve[1], dtype=np.float64) - if len(stuv_b) < 2: - _kept_v.append(b) - continue - sag_tol = 2.0 * atol_full - all_bad = True - for k in range(len(stuv_b) - 1): - if not _assembly_spend(work_budget): - # No remaining cleanup work is not evidence that a - # traced branch is invalid. Keep its existing samples. - all_bad = False - 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) - p2, du2, dv2 = eval_surface_d1(S2_full, mid[2], mid[3], - rational=rational_full) - res = float(np.linalg.norm(p1 - p2)) - N1 = np.cross(du1, dv1) - N2 = np.cross(du2, dv2) - n1 = float(np.linalg.norm(N1)) - n2 = float(np.linalg.norm(N2)) - sin_ang = (float(np.linalg.norm(np.cross(N1, N2))) / (n1 * n2) - if n1 > 1e-30 and n2 > 1e-30 else 1.0) - metric = res / max(sin_ang, 1e-3) - if metric <= sag_tol: - all_bad = False - break - sag = _mid_chord_sagitta( - S1_full, S2_full, stuv_b[k], stuv_b[k + 1], - xyz_b[k], xyz_b[k + 1], atol_full, rational_full) - if sag is None: - # A failed correction provides no distance estimate. - # In particular, a stationary midpoint can lie between - # two genuine nearby arcs while both are within the - # chord tolerance. It cannot justify deleting a trace. - all_bad = False - break - if metric <= sag_tol + _VALLEY_SAGITTA_CREDIT * min( - sag, sag_tol): - all_bad = False - break - if not all_bad: - _kept_v.append(b) - branches = _kept_v + # Parameter midpoints from two charts need not represent the same + # physical point, even along an exact straight intersection. Their + # residual (with or without an angle or sagitta factor) therefore + # cannot justify deleting an assembled branch. Chord accuracy belongs + # to continuation, where an excessive geometric deviation can trigger + # refinement rather than erase an entire component after tracing. # --- Drop short slivers that lie on top of another branch --- # When the fragment graph has a Y-junction (≥3 fragments meeting at the @@ -6559,6 +6492,14 @@ def _build_cell_partitions(owner_cell: "_Cell", def _on_axis_local(global_val: float, lo: float, hi: float, tol: float = 1e-8) -> Optional[int]: """Return 0 if `global_val` equals `lo`, 1 if it equals `hi`, else None.""" + if global_val == lo: + return 0 + if global_val == hi: + return 1 + # The two face neighborhoods must stay disjoint even after repeated + # subdivision; a narrow interval's upper endpoint is never its lower + # face merely because the global matching tolerance exceeds its width. + tol = min(tol, .25 * max(0., hi - lo)) if abs(global_val - lo) < tol: return 0 if abs(global_val - hi) < tol: @@ -6591,7 +6532,7 @@ def _classify_boundary_point(point: BoundaryPoint, cell: "_Cell") -> None: target_value = cell.box[i][local_param] match = None for p in cell.partitions: - if p.axis == i and abs(p.value - target_value) < 1e-8: + if p.axis == i and p.value == target_value: match = p break if match is None: @@ -6742,6 +6683,111 @@ def _split_tensor_multi(T, axis_4d, cut_values, cell_box): return pieces +def _cut_face_contacts(csx_result): + """Point proposals, including contacts absorbed into CSX spans.""" + yield from csx_result.get('isolated', ()) + for overlap in csx_result.get('overlaps', ()): + # A range box has no paired endpoint information and cannot seed + # continuation. Preserve the paired contacts CSX actually found; + # tagged proximity contacts are refined before registration. + yield from overlap.get('boundary_contacts', ()) + + +def _query_cut_face_contacts(csx_fn, curve, surface, pieces, split_axis, + local_cuts, atol, budget): + """Query a parent face once, refining only a locally depth-limited solve.""" + can_refine = len(pieces) > 1 + result = csx_fn(curve, surface, atol=atol, rational=True, + defer_local_depth=can_refine) + _surface_cut_face_fibers(result, budget) + contacts = list(_cut_face_contacts(result)) + if not (can_refine and result.get('budget_exhausted') + and result.get('truncation_cause') == 'depth'): + return contacts + boundaries = [0., *local_cuts, 1.] + for index, piece in enumerate(pieces): + if budget.exhausted: + break + child = csx_fn(curve, piece, atol=atol, rational=True) + _surface_cut_face_fibers(child, budget) + low, high = boundaries[index:index + 2] + key = 'u' if split_axis == 0 else 'v' + for point in _cut_face_contacts(child): + mapped = dict(point) + mapped[key] = low + float(point[key]) * (high - low) + contacts.append(mapped) + return contacts + + +def _register_cut_contacts(cell, contacts, own_axis, cut_value, cut_index, + other_axis, other_cuts, grid): + """Share each paired root across its closed incident child domains.""" + other_start = 2 if own_axis < 2 else 0 + free_axis = own_axis ^ 1 + boundaries = [cell.box[other_axis][0], *other_cuts, + cell.box[other_axis][1]] + for point in contacts: + if not all(np.isfinite(point[key]) and 0. <= point[key] <= 1. + for key in ('t', 'u', 'v')): + continue + xyz = np.asarray(point['point'], dtype=np.float64) + if not np.all(np.isfinite(xyz)): + continue + stuv = np.empty(4, dtype=np.float64) + stuv[own_axis] = cut_value + for axis, key in ((free_axis, 't'), (other_start, 'u'), + (other_start + 1, 'v')): + low, high = cell.box[axis] + stuv[axis] = low + float(point[key]) * (high - low) + local = _global_to_local(stuv, cell.box) + multiplicity_polished = False + if point.get('certification') == 'tolerance': + # A CAD nearest contact is a useful proposal, but its gap + # does not determine an SSI crossing direction. Refine on + # this fixed face, just as for an original domain boundary, + # before turning it into a continuation registration. + work_budget = getattr(cell, 'work_budget', None) + if (work_budget is not None + and not work_budget.charge_cells(1, 'cut_contact_refine')): + break + raw_local = local.copy() + local, residual, _ = _ssx_correct_fixed( + cell.g1.surface, cell.g2.surface, local, + fixed_axis=own_axis, fixed_value=float(local[own_axis]), + rational=True) + if not (np.all(np.isfinite(local)) + and np.all((0. <= local) & (local <= 1.)) + and np.isfinite(residual) + and residual <= _strict_ssx_root_tol( + cell.g1.surface, cell.g2.surface, rational=True)): + continue + for axis, (low, high) in enumerate(cell.box): + stuv[axis] = low + float(local[axis]) * (high - low) + stuv[own_axis] = cut_value + xyz = eval_surface(cell.g1.surface, local[0], local[1], + rational=True) + multiplicity_polished = bool(np.max(np.abs(local - raw_local)) + > 64. * np.finfo(float).eps) + tangent, _, _ = _ssx_tangent_4d( + cell.g1.surface, cell.g2.surface, *local, rational=True) + crossing = BoundaryPoint(stuv=stuv, xyz=xyz, + face=(own_axis, -1), tangent_raw=tangent, + multiplicity_polished=multiplicity_polished) + for index, (low, high) in enumerate(zip(boundaries, boundaries[1:])): + # Share a face root across rounding differences at a common + # cut, without assigning a nearby exterior root to that child. + margin = (64. * np.finfo(float).eps + * max(1., abs(low), abs(high), abs(stuv[other_axis]))) + if not low - margin <= stuv[other_axis] <= high + margin: + continue + if own_axis < 2: + grid[cut_index][index].append(crossing) + grid[cut_index + 1][index].append(crossing) + else: + grid[index][cut_index].append(crossing) + grid[index][cut_index + 1].append(crossing) + + def _surface_cut_face_fibers(csx_result, work_budget): """Ledger L49: name positive-dimensional cut-face preimages, never drop them. @@ -6760,11 +6806,11 @@ def _surface_cut_face_fibers(csx_result, work_budget): def _discover_c1_singularities(S1_h, S2_h, atol, ptol4, budget, existing=()): - """Locate parameter singularities before regular continuation spends its allowance. + """Locate parameter singularities within the call's remaining allowance. C1 is a property of the supplied surfaces, independent of tracing. Its existing shared tier runs once; publication is charged immediately - so a later trace or cleanup stop cannot erase already found cusps. + so a later cleanup stop cannot erase already found cusps. Branch links are attached only after branch assembly. Geometry already found by a reduction is retained; duplicate observations enrich those records before allocating any additional output item. @@ -6853,7 +6899,7 @@ def _link_c1_and_discover_c3(S1_h_top, S2_h_top, all_branches, atol, ptol4_global, budget): """Apply the same singularity postprocessing to every geometry path.""" # Attach C1 links after branch filters; the singularities themselves - # were discovered and published before continuation began. + # have already been published by their discovery path. for singularity in c1_singularities: if singularity.kind != "cusp": continue @@ -7213,7 +7259,8 @@ def _result(branches=None, points=None, singularities=None, uncertified_overlap_spans = [] def _run_csx( - curve, surface, *, local_truncation_is_soft=False, **kwargs, + curve, surface, *, local_truncation_is_soft=False, + defer_local_depth=False, **kwargs, ): """Bounded adapter used by every boundary and cut-face CSX call.""" if budget.remaining_cells <= 0 or not budget.charge_csx_call(): @@ -7294,14 +7341,14 @@ def _attempt(allowance): _cause = result.get('truncation_cause') _structural_depth = (_cause == 'depth' and not span_only) if _structural_depth: - budget.mark_incomplete(REASON_DEPTH_LIMIT) - return { - 'isolated': [], 'overlaps': [], - 'parameter_fibers': [], - 'budget_exhausted': True, - 'boundary_topology_complete': False, - 'cells_processed': used, - } + if not defer_local_depth: + budget.mark_incomplete(REASON_DEPTH_LIMIT) + # A parent cut can be retried on its already constructed + # child pieces. Keep its found roots in either case; only + # that parent's local depth reason is deferred, never a + # resource stop or an earlier unrelated reason. + return dict(result, boundary_topology_complete=False, + cells_processed=used) if local_truncation_is_soft and not budget.exhausted: budget.mark_incomplete( REASON_OVERLAP_REGION if span_only @@ -7518,9 +7565,6 @@ def _run_top_boundary_csx(curve, surface, **kwargs): all_singularities: list[SSXSingularity] = [] ptol4_global = np.maximum(np.array( [float(_gp_s), float(_gp_t), float(_gp_u), float(_gp_v)]), 1e-9) - c1_singularities = _discover_c1_singularities( - S1_h_top, S2_h_top, atol, ptol4_global, budget) - all_singularities.extend(c1_singularities) # A failed parent's output is provisional while descendants search the # same domain. Charge it when found, but publish it only if that search # remains partial; a completed replacement must not duplicate its prefix. @@ -8192,114 +8236,39 @@ def _enqueue_probe_children(parent) -> bool: else: F_sq_pieces.append([None] * (len(s2_cuts) + 1)) - # --- CSX: cut_line vs each piece of the opposite surface --- - # Split first, then CSX. Each cut line is intersected with each - # piece of the opposite surface separately, so crossings are found - # on the refined geometry and map deterministically to sub-cells. - s1_other = 1 - s1_axis if s1_axis < 2 else 1 - (s1_axis - 2) - s1_local_axis = s1_axis if s1_axis < 2 else s1_axis - 2 - s2_other_global = ({2: 3, 3: 2})[s2_axis] - s2_local_axis = s2_axis - 2 - - # Per-sub-cell new crossings: new_cx_grid[i1][i2] = list - n1 = len(g1_pieces) - n2 = len(g2_pieces) + # Query each new isoline against the opposite parent once. A local + # CSX depth stop falls back to the already constructed pieces. + n1, n2 = len(g1_pieces), len(g2_pieces) new_cx_grid = [[[] for _ in range(n2)] for _ in range(n1)] - - # a/b: CSX(cut_line_s1, S2_piece) for each S1 cut × each S2 piece - for cut_idx, cv in enumerate(s1_cuts): + s1_local_cuts = [(value - cell.box[s1_axis][0]) + / (cell.box[s1_axis][1] - cell.box[s1_axis][0]) + for value in s1_cuts] + s2_local_cuts = [(value - cell.box[s2_axis][0]) + / (cell.box[s2_axis][1] - cell.box[s2_axis][0]) + for value in s2_cuts] + for cut_index, (value, local) in enumerate(zip(s1_cuts, s1_local_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 = _run_csx( - isoline_s1, s2_piece_surf, atol=atol, rational=True) - _surface_cut_face_fibers(csx_r, budget) - - s2_lo = cell.box[s2_axis][0] if s2_idx == 0 else s2_cuts[s2_idx - 1] - s2_hi = s2_cuts[s2_idx] if s2_idx < len(s2_cuts) else cell.box[s2_axis][1] - s2_other_lo, s2_other_hi = cell.box[s2_other_global] - - for iso_pt in csx_r.get('isolated', []): - # The cut face is closed. Its endpoint roots belong - # to the adjoining children just like interior roots. - if not all(np.isfinite(iso_pt[key]) and 0. <= iso_pt[key] <= 1. - for key in ('t', 'u', 'v')): - continue - stuv = np.zeros(4, dtype=np.float64) - stuv[s1_axis] = cv - s1_other_lo, s1_other_hi = cell.box[s1_other] - stuv[s1_other] = s1_other_lo + float(iso_pt['t']) * (s1_other_hi - s1_other_lo) - if s2_local_axis == 0: - stuv[s2_axis] = s2_lo + float(iso_pt['u']) * (s2_hi - s2_lo) - stuv[s2_other_global] = s2_other_lo + float(iso_pt['v']) * (s2_other_hi - s2_other_lo) - else: - stuv[s2_other_global] = s2_other_lo + float(iso_pt['u']) * (s2_other_hi - s2_other_lo) - stuv[s2_axis] = s2_lo + float(iso_pt['v']) * (s2_hi - s2_lo) - - xyz = np.asarray(iso_pt['point'], dtype=np.float64) - stuv_local = _global_to_local(stuv, cell.box) - tang, _, _ = _ssx_tangent_4d( - cell.g1.surface, cell.g2.surface, - stuv_local[0], stuv_local[1], stuv_local[2], stuv_local[3], - rational=True) - bp = BoundaryPoint(stuv=stuv, xyz=xyz, face=(s1_axis, -1), tangent_raw=tang) - new_cx_grid[cut_idx][s2_idx].append(bp) - new_cx_grid[cut_idx + 1][s2_idx].append(bp) + curve = _extract_isoline(cell.g1.surface, s1_axis, local) + contacts = _query_cut_face_contacts( + _run_csx, curve, cell.g2.surface, + [piece.surface for piece in g2_pieces], s2_axis - 2, + s2_local_cuts, atol, budget) + _register_cut_contacts(cell, contacts, s1_axis, value, cut_index, + s2_axis, s2_cuts, new_cx_grid) 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): + for cut_index, (value, local) in enumerate(zip(s2_cuts, s2_local_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 = _run_csx( - isoline_s2, s1_piece_surf, atol=atol, rational=True) - _surface_cut_face_fibers(csx_r, budget) - - s1_lo = cell.box[s1_axis][0] if s1_idx == 0 else s1_cuts[s1_idx - 1] - s1_hi = s1_cuts[s1_idx] if s1_idx < len(s1_cuts) else cell.box[s1_axis][1] - s1_other_lo, s1_other_hi = cell.box[s1_other] - - for iso_pt in csx_r.get('isolated', []): - if not all(np.isfinite(iso_pt[key]) and 0. <= iso_pt[key] <= 1. - for key in ('t', 'u', 'v')): - continue - stuv = np.zeros(4, dtype=np.float64) - stuv[s2_axis] = cv - s2_other_lo2, s2_other_hi2 = cell.box[s2_other_global] - stuv[s2_other_global] = s2_other_lo2 + float(iso_pt['t']) * (s2_other_hi2 - s2_other_lo2) - if s1_local_axis == 0: - stuv[s1_axis] = s1_lo + float(iso_pt['u']) * (s1_hi - s1_lo) - stuv[s1_other] = s1_other_lo + float(iso_pt['v']) * (s1_other_hi - s1_other_lo) - else: - stuv[s1_other] = s1_other_lo + float(iso_pt['u']) * (s1_other_hi - s1_other_lo) - stuv[s1_axis] = s1_lo + float(iso_pt['v']) * (s1_hi - s1_lo) - - xyz = np.asarray(iso_pt['point'], dtype=np.float64) - stuv_local = _global_to_local(stuv, cell.box) - tang, _, _ = _ssx_tangent_4d( - cell.g1.surface, cell.g2.surface, - stuv_local[0], stuv_local[1], stuv_local[2], stuv_local[3], - rational=True) - bp = BoundaryPoint(stuv=stuv, xyz=xyz, face=(s2_axis, -1), tangent_raw=tang) - new_cx_grid[s1_idx][cut_idx].append(bp) - new_cx_grid[s1_idx][cut_idx + 1].append(bp) + curve = _extract_isoline(cell.g2.surface, s2_axis - 2, local) + contacts = _query_cut_face_contacts( + _run_csx, curve, cell.g1.surface, + [piece.surface for piece in g1_pieces], s1_axis, + s1_local_cuts, atol, budget) + _register_cut_contacts(cell, contacts, s2_axis, value, cut_index, + s1_axis, s1_cuts, new_cx_grid) if budget.exhausted: break @@ -8379,6 +8348,14 @@ def _enqueue_probe_children(parent) -> bool: all_fragments.extend(fragments) all_points.extend(points) + # Trace the intersection geometry before the independent C1 census. + # A positive-dimensional singular set can consume its entire remaining + # allowance; it must not prevent already seeded branches from being + # followed. C1 output is still published before optional cleanup. + c1_singularities = _discover_c1_singularities( + S1_h_top, S2_h_top, atol, ptol4_global, budget) + all_singularities.extend(c1_singularities) + # --- §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 diff --git a/tests/test_bez_ccx4.py b/tests/test_bez_ccx4.py index 56a91764..3677bbd2 100644 --- a/tests/test_bez_ccx4.py +++ b/tests/test_bez_ccx4.py @@ -185,22 +185,80 @@ def test_near_coincident_pair_ships_tolerance_overlap(reverse): 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.""" +def _cubic_interval(control, low, high): + """Independent de Casteljau restriction for the two cubic fixtures.""" + def split(points, parameter): + rows = [np.asarray(points, dtype=float)] + while len(rows[-1]) > 1: + rows.append((1.-parameter)*rows[-1][:-1]+parameter*rows[-1][1:]) + return (np.array([row[0] for row in rows]), + np.array([row[-1] for row in rows[::-1]])) + + if high < low: + return _cubic_interval(control, high, low)[::-1] + result = np.asarray(control) + if low > 0.: + _, result = split(result, low) + if high < 1.: + result, _ = split(result, (high-low)/(1.-low)) + return result + + +def _assert_cubic_interval_coverage(control, expected, intervals, atol): + # Parameter gaps are measured by their physical control-polygon length, + # an upper bound on omitted arc length, rather than arbitrary UV digits. + start, end = expected + cursor = start + for low, high in sorted(tuple(sorted(interval)) for interval in intervals): + low, high = max(start, low), min(end, high) + if high < low or high < cursor: + continue + if low > cursor: + gap = _cubic_interval(control, cursor, low) + assert np.linalg.norm(np.diff(gap, axis=0), axis=1).sum() <= atol + cursor = max(cursor, high) + if cursor < end: + gap = _cubic_interval(control, cursor, end) + assert np.linalg.norm(np.diff(gap, axis=0), axis=1).sum() <= atol + + +def _assert_cubic_cad_overlap(result, first, second, expected_u, expected_v, atol): + # This verifies the entire reference correspondence, not merely sampled + # near-coincidence: a polynomial difference lies in its control hull. + reference_difference = (_cubic_interval(first, *expected_u) + - _cubic_interval(second, *expected_v)) + assert np.linalg.norm(reference_difference, axis=1).max() <= atol + overlaps = result['overlaps'] + assert overlaps, 'A typed partial diagnostic alone does not carry overlap geometry' + for overlap in overlaps: + u, v = np.asarray(overlap['u_range']), np.asarray(overlap['v_range']) + assert np.all(np.isfinite(np.r_[u, v])) + assert np.all((0. <= np.r_[u, v]) & (np.r_[u, v] <= 1.)) + difference = _cubic_interval(first, *u)-_cubic_interval(second, *v) + assert np.linalg.norm(difference, axis=1).max() <= atol + _assert_cubic_interval_coverage(first, expected_u, + [o['u_range'] for o in overlaps], atol) + _assert_cubic_interval_coverage(second, expected_v, + [o['v_range'] for o in overlaps], atol) + if result['boundary_topology_complete']: + assert not result['budget_exhausted'] + else: + # A partial search is allowed only with retained valid geometry and + # an explicit reason; successful completion is not a regression. + assert result['budget_exhausted'] or result.get('truncation_cause') + + +def test_offset_twin_with_vertical_tangent_keeps_the_whole_cad_overlap(): + """A 1e-9-offset cubic has a full same-parameter pairing at atol=1e-3. + + Normal-side changes near its vertical tangent do not erase that actual + tolerance-coincident geometry. The endpoint-to-endpoint correspondence + is checked independently through its difference control polygon. + """ 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) + _assert_cubic_cad_overlap(r, curve1, C2, (0., 1.), (0., 1.), 1e-3) def test_exact_affine_overlap_certification_is_exact(): @@ -236,21 +294,18 @@ def test_non_affine_reparameterized_exact_overlap_certifies(): 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.""" +def test_realistic_woven_near_coincident_keeps_its_full_cad_overlap(): + """Rounded fitted cubics share a long CAD span, with disjoint tails. + + The affine pairing below has a whole-interval difference-control bound + below 4e-9. It starts at curve1's first endpoint and ends at curve2's + last endpoint; the unused tails end about 4.9 and 5.0 model units away. + Require that actual span, not an empty partial result naming its range. + """ 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_cubic_cad_overlap(r, curve1, curve2, + (0., 0.827597762202295), + (0.1906907548416867, 1.), 1e-3) assert r["cells_processed"] < 5_000 diff --git a/tests/test_bez_csx4.py b/tests/test_bez_csx4.py index 94738bb3..a4431f3c 100644 --- a/tests/test_bez_csx4.py +++ b/tests/test_bez_csx4.py @@ -503,19 +503,8 @@ def test_degree_one_line_no_false_positive_variants(): ) def test_case_13(): - """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. - """ - - - expected = {'t': 0.654374, 'u': 0.633137, 'v': 0.163511} - - - + """Rounded CAD endpoint contact and the separate interior crossing survive.""" + from mmcore.numeric._bezier_common import eval_curve, eval_surface C = np.array([[8.446359931193093, -39.19858842345994, 0.7182627669008318], [10.622854420468764, -38.91014606375564, 2.0882761678970088], [12.48389559934674, -38.81378122751128, 3.469350420794018], [15.0, -38.62105155502256, 5.071820879820981]]) @@ -524,26 +513,28 @@ def test_case_13(): # Line parallel to y-axis at various (x, z) that don't satisfy z=x 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"]) == 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}" + atol = 1e-3 + expected_interior = eval_curve(C, .654374, rational=False) + for shift in (0., 1e6): + curve, surface = C+shift, S+shift + result = bez_csx(curve, surface, atol=atol, rational=False) + assert not result['budget_exhausted'], result + assert len(result['isolated']) == 2, result + assert result['overlaps'] == [] + points = np.array([entry['point'] for entry in result['isolated']]) + # The input endpoint is about 2.3e-8 from the surface, well inside + # the requested CAD tolerance. It must not be rejected by an + # exact-set test on independently rounded source coefficients. + assert np.linalg.norm(points-(C[0]+shift), axis=1).min() <= atol + assert np.linalg.norm(points-(expected_interior+shift), axis=1).min() <= atol + for entry in result['isolated']: + q = np.array([entry[key] for key in ('t', 'u', 'v')]) + assert np.all((0. <= q) & (q <= 1.)) + a = eval_curve(curve, q[0], rational=False) + b = eval_surface(surface, q[1], q[2], rational=False) + assert np.linalg.norm(a-b) <= atol + assert np.linalg.norm(a-entry['point']) <= atol + assert np.linalg.norm(b-entry['point']) <= atol def test_case_14(): diff --git a/tests/test_bez_ssx5_singular.py b/tests/test_bez_ssx5_singular.py index fa89bbc3..5df2d1fe 100644 --- a/tests/test_bez_ssx5_singular.py +++ b/tests/test_bez_ssx5_singular.py @@ -909,8 +909,11 @@ def test_cusp_curve_on_split_plane_not_knifed_out(): "cusp curve mistyped as isolated cusp(s)" samples = np.concatenate([np.asarray(g.samples) for g in curves]) 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) + # Check the singular locus in model units. A fixed parameter tolerance + # overconstrains flat charts and underconstrains stretched charts. + for parameters in samples: + point = eval_surface(S1, *parameters[:2], rational=False) + assert np.linalg.norm(point-[0., 0., parameters[1]]) <= 1e-3 assert samples[:, 1].max() - samples[:, 1].min() > 0.8 @@ -1868,8 +1871,9 @@ def test_transversal_branch_between_two_collapsed_rational_fibers_is_preserved() S1, S2, 1e-3, rational=True, max_cells=60_000, max_csx_calls=2_000, ) - assert r["complete"] is False - assert r["status"]["reasons"] + # Keep useful geometry whether the remaining search finishes or reports + # a real stopping reason; do not pin a historical incomplete outcome. + assert r["complete"] == (not r["status"]["reasons"]) assert r["status"]["work"]["cells_processed"] <= 60_000 assert len(r["branches"]) == 1 assert r["points"] == [] @@ -1921,18 +1925,16 @@ def test_transversal_branch_between_two_collapsed_rational_fibers_is_preserved() 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(): +def test_case14_rational_cones_return_the_whole_tangent_generator(): 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 + # A successful numerical completion is allowed. Budget/status behavior + # has dedicated forced-stop tests; the contract here is the whole curve. + assert r["complete"] == (not r["status"]["reasons"]) assert r["status"]["work"]["cells_processed"] <= 60_000 assert len(r["branches"]) == 1 assert [g for g in r["singularities"] @@ -1977,7 +1979,7 @@ def test_case14_tangent_generator_is_homogeneous_scale_invariant(): assert _pt_poly((1.0 - q) * a + q * b, xyz) <= 5e-3 -def test_case13_rational_tangency_terminates_with_explicit_partial_status(): +def test_case13_rational_tangency_terminates_and_keeps_the_tangent_point(): """A deduplicated tangency must not re-run Phi seeding per descendant.""" from examples.ssx.bez_ssx5_case13 import S1, S2 @@ -1986,9 +1988,8 @@ def test_case13_rational_tangency_terminates_with_explicit_partial_status(): 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 + # Completion may improve; termination and the recovered touch must stay. + assert r["complete"] == (not r["status"]["reasons"]) assert r["status"]["work"]["cells_processed"] <= 30_000 assert r["branches"] == [] and r["points"] == [] tangencies = [g for g in r["singularities"] @@ -2003,12 +2004,10 @@ def test_case13_rational_tangency_terminates_with_explicit_partial_status(): 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 + # The typed touch and its independent reference position are the CAD + # contract. Normal agreement at its approximate parameters does not + # have a universal 1e-6 angular tolerance: curvature and chart scale + # convert the accepted model-space location error into normal rotation. def test_bez_ssx_global_soft_budget_returns_partial_result(monkeypatch): @@ -3011,8 +3010,13 @@ def test_overlap_region_identical_patches(): 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) + # Identical charts must evaluate the interior witness to the same model + # point within atol; the API does not promise six parameter digits. + witness = np.asarray(reg.interior_stuv) + assert np.all((0. < witness) & (witness < 1.)) + assert np.linalg.norm( + eval_surface(S1, *witness[:2], rational=False) + - eval_surface(S1, *witness[2:], rational=False)) <= 1e-3 assert reg.normal_agreement == 1 assert r["status"]["reasons"] == [] assert r["complete"] is True diff --git a/tests/test_ccx4_exactness_contract.py b/tests/test_ccx4_exactness_contract.py index f21d80e9..9e6eed90 100644 --- a/tests/test_ccx4_exactness_contract.py +++ b/tests/test_ccx4_exactness_contract.py @@ -255,10 +255,11 @@ def test_float_built_quadratic_subcurve_remains_an_overlap(): 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. +def test_tolerant_fitted_overlap_retains_geometry_when_search_is_partial(): + # Rounded fitted copies differ far below the requested CAD accuracy. + # A search limit may retain partial status, but cannot substitute an + # empty diagnostic span for the overlap geometry already established. + from tests.test_bez_ccx4 import _assert_cubic_cad_overlap first = np.array([ [-19.77608536, 23.10065701, 0.0], [-14.86834768, 28.69713066, 0.0], @@ -274,13 +275,13 @@ def test_tolerant_non_affine_overlap_candidate_returns_typed_partial(): 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_cubic_cad_overlap(result, first, second, + (0., 0.827597762202295), + (0.1906907548416867, 1.), ATOL) 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) + if not result["boundary_topology_complete"]: + assert result["budget_exhausted"] + assert result.get("truncation_cause") # --------------------------------------------------------------------------- diff --git a/tests/test_ccx_cad_affine_overlap_bound.py b/tests/test_ccx_cad_affine_overlap_bound.py new file mode 100644 index 00000000..6bc4ca28 --- /dev/null +++ b/tests/test_ccx_cad_affine_overlap_bound.py @@ -0,0 +1,67 @@ +"""Whole-span CAD bounds cannot be inferred from endpoints or XYZ alone.""" + +import numpy as np +import pytest + +from mmcore.numeric._bezier_common import eval_curve +from mmcore.numeric.intersection.ccx._bez_ccx4 import ( + _cad_affine_overlap_bound, bez_ccx, +) + + +def test_close_endpoints_do_not_bound_an_out_of_tolerance_interior(): + first = np.array([[0., 0., 0.], [.5, 0., 0.], [1., 0., 0.]]) + second = first.copy() + second[1, 1] = .01 + bound = _cad_affine_overlap_bound(first, second, (0., 1.), (0., 1.), False) + assert bound > 1e-3 + assert np.linalg.norm(eval_curve(first, .5, rational=False) + - eval_curve(second, .5, rational=False)) > 1e-3 + + +def test_rational_weighted_parameter_map_is_not_cartesian_control_identity(): + # Same line locus, different parameter maps. Equal dehomogenized + # controls do not establish the proposed affine parameter pairing. + first = np.array([[0., 0., 0., 1.], [1., 0., 0., 1.]]) + second = np.array([[0., 0., 0., 1.], [4., 0., 0., 4.]]) + bound = _cad_affine_overlap_bound(first, second, (0., 1.), (0., 1.), True) + assert bound > 1e-3 + + +@pytest.mark.parametrize('reverse', [False, True]) +def test_positive_rational_weights_preserve_the_whole_distance_bound(reverse): + points = np.array([[0., 0., 0.], [.5, 1., 0.], [1., 0., 0.]]) + weights = np.array([1., 2., 3.]) + first = np.column_stack([points * weights[:, None], weights]) + second = np.column_stack([(points + [0., 0., 1e-5]) * weights[:, None], weights]) * 7. + v_range = (0., 1.) + if reverse: + second = second[::-1].copy() + v_range = (1., 0.) + bound = _cad_affine_overlap_bound(first, second, (0., 1.), v_range, True) + assert bound is not None and bound <= 1e-3 + for t in np.linspace(0., 1., 101): + v = 1. - t if reverse else t + gap = np.linalg.norm(eval_curve(first, t, rational=True) + - eval_curve(second, v, rational=True)) + assert gap <= bound + + +def test_sign_changing_weights_cannot_use_a_positive_denominator_bound(): + first = np.array([[0., 0., 0., 1.], [1., 0., 0., 1.]]) + second = np.array([[0., 0., 0., 1.], [-1., 0., 0., -1.]]) + assert _cad_affine_overlap_bound(first, second, (0., 1.), (0., 1.), True) is None + + +@pytest.mark.parametrize('limits', [dict(max_cells=0), dict(max_results=0)]) +def test_near_coincident_overlap_does_not_bypass_denied_allowances(limits): + first = np.array([[-19.77608536, 23.10065701, 0.], + [-14.86834768, 28.69713066, 0.], + [-5.8568525, 25.12677787, 0.], + [-12.62581769, 15.26478654, 0.]]) + result = bez_ccx(first, first + [0., 1e-9, 0.], atol=1e-3, + rational=False, **limits) + assert result['overlaps'] == [] and result['isolated'] == [] + assert result['budget_exhausted'] + if limits.get('max_cells') == 0: + assert result['cells_processed'] == 0 diff --git a/tests/test_ccx_cad_endpoint_tangency.py b/tests/test_ccx_cad_endpoint_tangency.py index 31a7f6d5..6fc29c90 100644 --- a/tests/test_ccx_cad_endpoint_tangency.py +++ b/tests/test_ccx_cad_endpoint_tangency.py @@ -33,18 +33,26 @@ def test_surface_endpoint_tangency_uses_the_same_cad_contract(): entry['t_range'][1] == 1. for entry in result['overlaps']) -@pytest.mark.parametrize('gap', [2.**-12, 2.**-16, 2.**-20]) +@pytest.mark.parametrize('gap', [2.**-7, 2.**-9, 2.**-11]) def test_metric_separated_close_parameter_roots_remain_distinct(gap): - scale = 1e10 + # A CAD-sized domain with parameter-near but spatially separated roots. + # Dyadic data keeps the stated analytic roots faithful to the actual + # float control net. The old 1e10 fixture's rounded coefficients moved + # its intended roots by up to .12 model units at an atol of .001. + # Even the closest pair here has a valley deeper than atol, so these + # are two contacts, not one connected tolerance-coincidence span. + scale = 2.**14 + atol = 1e-3 first = np.array([[0., 0., 0.], [.5*scale, 0., 0.], [scale, 0., 0.]]) low, high = .5-gap, .5+gap second = first.copy() second[:, 1] = scale*np.array([low*high, low*high-(low+high)/2, (1-low)*(1-high)]) - result = bez_ccx(first, second, atol=1e-3, rational=False, max_cells=2000) + assert scale*gap*gap > atol + result = bez_ccx(first, second, atol=atol, rational=False) assert len(result['isolated']) == 2, result roots = sorted(entry['u'] for entry in result['isolated']) - assert np.allclose(roots, [low, high], atol=1e-9, rtol=0) + assert np.max(np.abs(np.asarray(roots)-[low, high])*scale) <= atol assert np.linalg.norm(result['isolated'][0]['point']- result['isolated'][1]['point']) > 1e-3 for root in result['isolated']: diff --git a/tests/test_csx4_exactness_contract.py b/tests/test_csx4_exactness_contract.py index 012e1d02..3fba8d4d 100644 --- a/tests/test_csx4_exactness_contract.py +++ b/tests/test_csx4_exactness_contract.py @@ -1,7 +1,6 @@ -"""Exact-topology contracts for curve/surface intersections.""" +"""CAD contact tolerance and optional exact-mode regression contracts.""" import numpy as np -import sys import pytest from mmcore.numeric.intersection.csx._bez_csx4 import bez_csx @@ -102,57 +101,73 @@ def test_exact_curved_affine_parameter_overlap_is_preserved(): assert result["budget_exhausted"] is False -def test_collapsed_rational_curve_requires_exact_surface_membership(): - point = np.array([0.5, 0.5, 5e-4]) +@pytest.mark.parametrize("gap, expected", [(0., 1), (5e-4, 1), (2e-3, 0)]) +@pytest.mark.parametrize("origin", [0., 1e8]) +def test_collapsed_rational_curve_uses_cad_surface_membership(gap, expected, origin): + from mmcore.numeric._bezier_common import eval_curve, eval_surface + + point = np.array([0.5, 0.5, origin + gap]) curve = _homogeneous_curve([point, point], weights=[1.0, 2.0]) - result = bez_csx( - curve, _homogeneous_surface(_plane()), - atol=1e-3, rational=True) + surface = _plane() + surface[..., 2] += origin + surface = _homogeneous_surface(surface) + result = bez_csx(curve, surface, atol=1e-3, rational=True) - assert result["parameter_fibers"] == [] - assert result["isolated"] == [] + assert len(result["parameter_fibers"]) == expected + assert result["isolated"] == [] and result["overlaps"] == [] 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], - ]) - + for fiber in result["parameter_fibers"]: + assert fiber["t_range"] == (0., 1.) + assert 0. <= fiber["u"] <= 1. and 0. <= fiber["v"] <= 1. + for t in (0., .5, 1.): + assert np.linalg.norm( + eval_curve(curve, t, rational=True) + - eval_surface(surface, fiber["u"], fiber["v"], rational=True)) <= 1e-3 + + strict = bez_csx(curve, surface, atol=1e-3, rational=True, + tolerance_tier=False) + assert len(strict["parameter_fibers"]) == int(gap == 0.) + assert strict["isolated"] == [] and strict["overlaps"] == [] + assert strict["budget_exhausted"] is False + + +@pytest.mark.parametrize("gap, expected", [(1e-8, 1), (5e-4, 1), (2e-3, 0)]) +@pytest.mark.parametrize("origin", [0., 1e6]) +def test_exterior_boundary_contact_respects_cad_tolerance(gap, expected, origin): + from mmcore.numeric._bezier_common import eval_curve, eval_surface + + surface = _plane() + surface[..., 0] += origin + # The exact continuation root is outside the patch. CAD keeps a + # contact on its edge when the actual geometric gap is within atol. + curve = np.array([[origin - gap, .5, -1.], [origin - gap, .5, 1.]]) result = bez_csx(curve, surface, atol=1e-3, rational=False) - assert result["isolated"] == [] - assert result["overlaps"] == [] + assert len(result["isolated"]) == expected + assert result["overlaps"] == [] and result["parameter_fibers"] == [] assert result["budget_exhausted"] is False + for root in result["isolated"]: + assert root["certification"] == "tolerance" + assert root["d_min"] <= 1e-3 + assert all(0. <= root[p] <= 1. for p in ("t", "u", "v")) + assert np.linalg.norm(eval_curve(curve, root["t"], rational=False) + - eval_surface(surface, root["u"], root["v"], + rational=False)) <= 1e-3 + assert np.linalg.norm(root["point"] - [origin, .5, 0.]) <= 1e-3 + + strict = bez_csx(curve, surface, atol=1e-3, rational=False, + tolerance_tier=False) + assert strict["isolated"] == [] and strict["overlaps"] == [] + assert strict["budget_exhausted"] is False -@pytest.mark.xfail(sys.platform == "linux", strict=False, reason= - "Platform sensitivity, measured on CI's first-ever suite run (2026-08-18, x86-64 linux/gcc): the certified measurement drifts 3.8e-6 under translation (tol 5e-7) while arm64 darwin/clang holds exactly. The e0ab4a0 invariance contract is FP-pipeline-dependent — derived-envelope program follow-up; needs a linux box to localize.") def test_translated_sub_tolerance_line_certification_is_translation_invariant(): - """L59: certification must not use world magnitude (the CCX-L56 twin). + """Translation preserves CAD classification within input resolution. - 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. + At 2e10 one float64 ULP is about 3.8e-6, already larger than the + old 5e-7 residual-comparison requirement. A few input ULPs are an + appropriate allowance for this measurement; the public residual + must still remain below the unchanged 1e-3 CAD tolerance. """ origin = 2.0e10 gap = 5.0e-4 @@ -177,7 +192,10 @@ def test_translated_sub_tolerance_line_certification_is_translation_invariant(): 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) + measurement_allowance = 4. * np.spacing(origin) + assert measurement_allowance < 0.02 * 1e-3 + assert abs(far["residual_max"] - near["residual_max"]) <= measurement_allowance + assert max(far["residual_max"], near["residual_max"]) <= 1e-3 exact_curve = curve.copy() exact_curve[:, 0] = origin @@ -187,26 +205,6 @@ def test_translated_sub_tolerance_line_certification_is_translation_invariant(): 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). diff --git a/tests/test_csx_cad_contact_seeds.py b/tests/test_csx_cad_contact_seeds.py new file mode 100644 index 00000000..13759aa1 --- /dev/null +++ b/tests/test_csx_cad_contact_seeds.py @@ -0,0 +1,48 @@ +"""A CAD proximity contact proposes, but does not establish, an SSI crossing.""" + +from types import SimpleNamespace + +import numpy as np +import pytest + +from mmcore.numeric._bezier_common import eval_surface +from mmcore.numeric.intersection.ssx._bez_ssx5 import _register_cut_contacts + + +@pytest.mark.parametrize("has_crossing", [False, True]) +def test_cut_contact_requires_a_continuation_root(has_crossing): + plane = np.array([[[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]]]) + first = plane.copy() + if has_crossing: + # Fixed s=.5 has a root at t=.0005; the CAD proposal at t=0 + # must move onto that intersection before it can seed tracing. + first[..., 2] = first[..., 0] + first[..., 1] - .5005 + else: + # The same sub-atol gap on parallel planes has no crossing + # direction anywhere on this face. + first[..., 2] = .0005 + first_h = np.concatenate([first, np.ones((2, 2, 1))], axis=2) + second_h = np.concatenate([plane, np.ones((2, 2, 1))], axis=2) + cell = SimpleNamespace(box=((0., 1.),) * 4, + g1=SimpleNamespace(surface=first_h), + g2=SimpleNamespace(surface=second_h)) + point = dict(t=0., u=.5, v=0., + point=eval_surface(first_h, .5, 0., rational=True), + certification='tolerance', d_min=.0005) + grid = [[[] for _ in range(2)] for _ in range(2)] + _register_cut_contacts(cell, [point], 0, .5, 0, 2, [.5], grid) + roots = [root for row in grid for entries in row for root in entries] + if not has_crossing: + assert roots == [] + return + assert roots + for root in roots: + assert np.all((0. <= root.stuv) & (root.stuv <= 1.)) + # This tests the numerical refinement step, independently of the + # public CAD tolerance used to admit the original proposal. + assert root.stuv[1] > 0. + first_point = eval_surface(first_h, *root.stuv[:2], rational=True) + second_point = eval_surface(second_h, *root.stuv[2:], rational=True) + assert np.linalg.norm(first_point - second_point) <= 1e-8 + assert np.linalg.norm(root.xyz - first_point) <= 1e-3 diff --git a/tests/test_csx_overlap_normal_gap.py b/tests/test_csx_overlap_normal_gap.py new file mode 100644 index 00000000..fc4d2039 --- /dev/null +++ b/tests/test_csx_overlap_normal_gap.py @@ -0,0 +1,36 @@ +"""Overlap sidedness uses the normal gap, independently of projection lag.""" + +import numpy as np +import pytest + +import mmcore.numeric.intersection.csx._bez_csx4 as csx + + +@pytest.mark.parametrize("normal_gap, overlap", [ + (4.0 * np.finfo(float).eps, True), + (1e-7, False), +]) +def test_tangential_projection_error_does_not_amplify_normal_signs( + monkeypatch, normal_gap, overlap): + plane = np.array([[[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]]]) + curve = np.array([[0.1, 0.5, -normal_gap], + [0.9, 0.5, normal_gap]]) + + def lagging_projection(point, surface, u, v, atol, rational): + # The numerical projector may stop with an in-plane residual. + # This must not promote sub-roundoff normal signs to a crossing. + projected = np.array([point[0] + 1e-8, point[1], 0.]) + return (*projected[:2], float(np.linalg.norm(projected - point))) + + monkeypatch.setattr(csx, "_project_point_on_surface", lagging_projection) + result = csx._tolerance_csx_overlap_certificate( + curve, plane, 1e-3, False, 1e-5, None) + + if overlap: + assert len(result) == 1 + assert result[0]["t_range"] == (0., 1.) + else: + # A resolved normal sign change remains a crossing even though + # the whole curve lies inside the modeling tolerance band. + assert result is None diff --git a/tests/test_nssx5.py b/tests/test_nssx5.py index 26bc2101..e6376bd4 100644 --- a/tests/test_nssx5.py +++ b/tests/test_nssx5.py @@ -319,9 +319,8 @@ def test_multispan_crossing_stitches_to_one_branch(): # continuity: no vertex gap wildly above the largest in-fragment step gaps = np.linalg.norm(np.diff(xyz, axis=0), axis=1) assert gaps.max() <= 10 * np.median(gaps) + 1e-12 - # full extent: line x=0,z=0 runs y in [-1,1] -> t from 0 to 1 - ts = stuv[:, 1] - assert ts.min() <= 1e-6 and ts.max() >= 1 - 1e-6 + # Full extent is a model-space requirement, not six parameter digits. + np.testing.assert_allclose(np.sort(xyz[[0, -1], 1]), [-1., 1.], atol=1e-3, rtol=0.) # geometry: on both planes assert np.abs(xyz[:, 0]).max() <= 2e-3 assert np.abs(xyz[:, 2]).max() <= 2e-3 @@ -462,17 +461,10 @@ def test_seam_straddling_tangency_dedups_to_one(): tp = tps[0] assert np.linalg.norm(np.asarray(tp.xyz) - np.zeros(3)) <= 5e-3 assert abs(tp.stuv[0] - 0.5) <= 0.05 and abs(tp.stuv[1] - 0.5) <= 0.05 - # The certified tangent_point ships WITH typed structural caveats: - # bez_ssx reports complete=False + unresolved_multiplicity (split - # patches; the unsplit single-patch case additionally reports - # unresolved_tangential_zone) for isolated C2 tangencies — verified - # against a direct bez_ssx call on the unsplit surfaces. The adapter - # must surface that honestly (spec: AND of completes, union of - # reasons), never claim completeness the engine didn't certify. - assert res['complete'] is False - assert set(res['status']['reasons']) <= { - 'unresolved_multiplicity', 'unresolved_tangential_zone'} - assert len(res['status']['reasons']) >= 1 + # A solver improvement may finish this geometry. Partial child-status + # propagation is tested separately with forced failures; this case must + # not require an old search limitation to remain in the public result. + assert res['complete'] == (not res['status']['reasons']) def test_point_on_neighbor_pair_branch_is_filtered(): @@ -912,10 +904,9 @@ def _load_case(num): @pytest.mark.parametrize("case,expect_complete,expect_reasons", [ (5, True, set()), (8, True, set()), - # pins CURRENT engine truth: if bez_ssx later resolves this tangential - # zone, update this expectation (an improvement will fail this line, by - # design) - (10, False, {'unresolved_tangential_zone'}), + # The common projected chart reduces this contact to its full s=u=.5 + # isoline; the analytic coverage check below verifies that resolution. + (10, True, set()), ]) def test_fixture_case_residual_certificate(case, expect_complete, expect_reasons): @@ -931,8 +922,8 @@ def test_fixture_case_residual_certificate(case, expect_complete, coverage runs in examples/ssx/nurbs_ssx5_coverage_check.py (Task 7) against an isoline x nurbs_csx reference cloud. - Case 10 is a genuine tangential contact (s1 has z>=5, s2 has z<=5, - touching at z=5): typed-partial per established engine truth. + Case 10 has a common injective XY chart and height difference + 40*(s-.5)**2. Its complete contact is the curved s=u=.5 ruling. """ from mmcore.numeric.intersection.ssx._nssx5 import nurbs_ssx atol = 1e-3 @@ -941,6 +932,25 @@ def test_fixture_case_residual_certificate(case, expect_complete, assert res['branches'], f"case {case}: no branches" assert res['complete'] is expect_complete, res['status']['reasons'] assert set(res['status']['reasons']) == expect_reasons + if case == 10: + branch, = res['branches'] + assert branch.kind == 'tangential' and not branch.closed + xyz = np.asarray(branch.curve[1]) + # The common contact is x=5+15*t*(1-t), y=15*t, z=5. + # Check its physical location rather than extra parameter digits. + sample_t = xyz[:, 1]/15. + contact = np.column_stack((5.+15.*sample_t*(1.-sample_t), + xyz[:, 1], np.full(len(xyz), 5.))) + assert np.max(np.linalg.norm(xyz-contact, axis=1)) <= atol + np.testing.assert_allclose(np.sort(xyz[[0, -1], 1]), [0., 15.], atol=atol, rtol=0.) + t = np.linspace(0., 1., 101) + reference = np.column_stack([5.+15.*t*(1.-t), 15.*t, np.full_like(t, 5.)]) + delta = np.diff(xyz, axis=0) + offsets = reference[:, None, :]-xyz[None, :-1, :] + fraction = np.clip(np.einsum('ijk,jk->ij', offsets, delta) + / np.einsum('ij,ij->i', delta, delta), 0., 1.) + distance = np.linalg.norm(offsets-fraction[..., None]*delta, axis=-1) + assert np.max(np.min(distance, axis=1)) <= atol for b in res['branches']: stuv = np.asarray(b.curve[0], dtype=float) xyz = np.asarray(b.curve[1], dtype=float) @@ -1083,8 +1093,13 @@ def test_boundary_coincidence_two_edge_branches(): for b in res["branches"]: xyz = np.asarray(b.curve[1], dtype=float) assert len(xyz) >= 2, b.kind - # The whole locus is the z=0 plane. - assert np.max(np.abs(xyz[:, 2])) <= 1e-6, np.max(np.abs(xyz[:, 2])) + # The whole locus is the z=0 plane, at the requested model tolerance. + assert np.max(np.abs(xyz[:, 2])) <= 1e-3, np.max(np.abs(xyz[:, 2])) + for q, point in zip(b.curve[0], xyz): + sources = [evaluate_nurbs_surface(surface, *uv)['S'] + for surface, uv in ((s1, q[:2]), (s2, q[2:]))] + assert np.linalg.norm(sources[0]-sources[1]) <= 1e-3 + assert all(np.linalg.norm(source-point) <= 1e-3 for source in sources) got.append(xyz) # Match each analytic segment to one branch by endpoint proximity, then diff --git a/tests/test_nssx5_case16.py b/tests/test_nssx5_case16.py new file mode 100644 index 00000000..48182af1 --- /dev/null +++ b/tests/test_nssx5_case16.py @@ -0,0 +1,35 @@ +"""Real CAD sections: complete endpoints and paired surface geometry at 1e-3.""" + +import numpy as np +import pytest + +from examples.ssx import _case16_data as data +from mmcore.numeric.intersection.ssx import nurbs_ssx +from mmcore.nurbs._nurbs_eval import evaluate_nurbs_surface + + +@pytest.mark.parametrize('index,name', enumerate( + ('surf11', 'surf12', 'surf21', 'surf22', 'surf31'))) +def test_case16_section_keeps_both_domain_endpoints(index, name): + atol = 1e-3 + first, second = data.surf0, getattr(data, name) + result = nurbs_ssx(first, second, atol=atol) + + assert len(result['branches']) == 1 + branch = result['branches'][0] + assert not branch.closed + parameters, xyz = map(np.asarray, branch.curve) + expected = np.asarray(data.expected_endpoints[index]) + forward = np.linalg.norm(xyz[[0, -1]] - expected, axis=1).max() + reverse = np.linalg.norm(xyz[[-1, 0]] - expected, axis=1).max() + assert min(forward, reverse) <= atol + assert np.isfinite(parameters).all() and np.isfinite(xyz).all() + # Endpoint coverage alone could accept a straight shortcut or unrelated + # path. Every returned vertex must evaluate on both original NURBS + # surfaces at the requested physical tolerance, in their native domains. + for point, q in zip(xyz, parameters): + for surface, uv in ((first, q[:2]), (second, q[2:])): + bounds = np.asarray(surface.interval()) + assert np.all(uv >= bounds[:, 0]) and np.all(uv <= bounds[:, 1]) + source = evaluate_nurbs_surface(surface, *uv, d_order=0)['S'] + assert np.linalg.norm(source - point) <= atol diff --git a/tests/test_ssx5_boundary_continuation.py b/tests/test_ssx5_boundary_continuation.py index 3c9ba3b5..02d363da 100644 --- a/tests/test_ssx5_boundary_continuation.py +++ b/tests/test_ssx5_boundary_continuation.py @@ -34,6 +34,11 @@ def test_pinched_incident_branches_keep_both_sides_in_changed_charts(variant): result = bez_ssx(first, second, atol=1e-3, rational=False) assert 'cusp_curve' in [item.kind for item in result['singularities']] + # The collapsed ruling is a C1 parameter fiber. It has no surface + # normal and must not acquire an ordinary C2 tangent-point marker + # merely because a chart transpose changes derivative roundoff. + assert not any(item.kind == 'tangent_point' + for item in result['singularities']) assert not {'work_budget', 'output_cap', 'depth_limit'} & set(result['status']['reasons']) polylines = [branch.curve[1]@rotation for branch in result['branches']] assert len(polylines) == 2 diff --git a/tests/test_ssx5_isolines.py b/tests/test_ssx5_isolines.py index a4389fe2..b85c7ef8 100644 --- a/tests/test_ssx5_isolines.py +++ b/tests/test_ssx5_isolines.py @@ -22,16 +22,30 @@ def _solve(first, second, atol=1e-3, cells=10000, outputs=100): return result,budget +def _assert_paired_source_accuracy(branch, first, second, atol): + # The geometry contract is in model units, not parameter digits. + for parameters, point in zip(*branch.curve): + sources = [eval_surface(net, *uv, rational=True) + for net, uv in ((first, parameters[:2]), + (second, parameters[2:]))] + assert np.linalg.norm(sources[0]-sources[1]) <= atol + assert all(np.linalg.norm(source-point) <= atol for source in sources) + + @pytest.mark.parametrize('degree',[2,4,8,10,12]) def test_multiple_root_yields_one_complete_ruling(degree): graph=_h(np.array([[[s,j/degree,(-1.)**(degree-j)*2.**-degree] for j in range(degree+1)] for s in (0.,1.)])) - result,budget=_solve(graph,_plane(),atol=min(1e-3,.1*2.**-degree)) + atol = min(1e-3, .1*2.**-degree) + result,budget=_solve(graph,_plane(),atol=atol) assert result is not None and not budget.incomplete assert len(result['branches'])==1 branch=result['branches'][0] assert branch.kind=='tangential' - np.testing.assert_allclose(branch.curve[1],[[0.,.5,0.],[1.,.5,0.]],atol=1e-10) + assert np.max(np.linalg.norm( + branch.curve[1][[0, -1]]-[[0., .5, 0.], [1., .5, 0.]], axis=1)) <= atol + assert np.max(np.linalg.norm(branch.curve[1][:, 1:]-[.5, 0.], axis=1)) <= atol + _assert_paired_source_accuracy(branch, graph, _plane(), atol) @pytest.mark.parametrize('same_image',[False,True]) @@ -50,8 +64,11 @@ def test_nonbinary_ruling_is_clipped_at_both_plane_sides(): result,budget=_solve(graph,_plane()) assert result is not None and not budget.incomplete assert len(result['branches'])==1 - np.testing.assert_allclose(result['branches'][0].curve[1], - [[0.,1/3,0.],[1.,1/3,0.]],atol=1e-11) + branch = result['branches'][0] + assert np.max(np.linalg.norm( + branch.curve[1][[0, -1]]-[[0., 1/3, 0.], [1., 1/3, 0.]], axis=1)) <= 1e-3 + assert np.max(np.linalg.norm(branch.curve[1][:, 1:]-[1/3, 0.], axis=1)) <= 1e-3 + _assert_paired_source_accuracy(branch, graph, _plane(), 1e-3) @pytest.mark.parametrize('swap',[False,True]) @@ -172,13 +189,15 @@ def test_public_collapsed_isoline_is_a_point_and_nearby_ruling_stays_a_curve(swa if width: assert result['points'] == [] branch, = result['branches'] - np.testing.assert_allclose(branch.curve[1][[0, -1]], - [[0., 0., 0.], [0., width, 0.]], - atol=1e-10, rtol=0.) + assert np.max(np.linalg.norm( + branch.curve[1][[0, -1]]-[[0., 0., 0.], [0., width, 0.]], axis=1)) <= 1e-3 + _assert_paired_source_accuracy(branch, *map(_h, pair), 1e-3) else: assert result['branches'] == [] point, = result['points'] - np.testing.assert_allclose(point.xyz, np.zeros(3), atol=1e-10, rtol=0.) + assert np.linalg.norm(point.xyz) <= 1e-3 + for surface, uv in ((pair[0], point.stuv[:2]), (pair[1], point.stuv[2:])): + assert np.linalg.norm(eval_surface(surface, *uv, rational=False)-point.xyz) <= 1e-3 assert 'parameter_fiber' in result['status']['reasons'] owner = 2 if swap else 0 curves = [g for g in result['singularities'] diff --git a/tests/test_ssx5_matched_isolines.py b/tests/test_ssx5_matched_isolines.py index b8cdc040..d1b0d5c4 100644 --- a/tests/test_ssx5_matched_isolines.py +++ b/tests/test_ssx5_matched_isolines.py @@ -33,9 +33,12 @@ def test_curved_tangent_ruling_is_complete_in_both_parameter_charts(swap,flip,tr branch,=result['branches'] assert branch.kind=='tangential' q,xyz=branch.curve - assert xyz[0,1]==0. and xyz[-1,1]==3. + # Endpoint coverage and curve accuracy use the requested model units. + assert np.max(np.linalg.norm( + xyz[[0, -1]]-[[.5, 0., 0.], [.5, 3., 0.]], axis=1)) <= 1e-3 t=xyz[:,1]/3 - np.testing.assert_allclose(xyz[:,0],.5+3*t*(1-t),atol=1e-12) + reference = np.column_stack((.5+3*t*(1-t), 3*t, np.zeros(len(t)))) + assert np.max(np.linalg.norm(xyz-reference, axis=1)) <= 1e-3 for i in range(len(q)-1): for fraction in np.linspace(0.,1.,9): parameters=(1-fraction)*q[i]+fraction*q[i+1] @@ -52,8 +55,14 @@ def test_common_chart_survives_world_axis_mixing(): result=try_matched_isoline_ssx(*pair,1e-3,budget) assert result is not None and not budget.incomplete assert len(result['branches'])==1 - xyz=result['branches'][0].curve[1]@np.linalg.inv(matrix).T - np.testing.assert_allclose(xyz[[0,-1]],[[.5,0.,0.],[.5,3.,0.]],atol=1e-11) + parameters, xyz = result['branches'][0].curve + reference = np.array([[.5, 0., 0.], [.5, 3., 0.]])@matrix.T + assert np.max(np.linalg.norm(xyz[[0, -1]]-reference, axis=1)) <= 1e-3 + for q, point in zip(parameters, xyz): + sources = [eval_surface(net, *uv, rational=True) + for net, uv in zip(pair, (q[:2], q[2:]))] + assert np.linalg.norm(sources[0]-sources[1]) <= 1e-3 + assert all(np.linalg.norm(source-point) <= 1e-3 for source in sources) def test_noninjective_common_projection_uses_general_search(): diff --git a/tests/test_ssx5_probe_split.py b/tests/test_ssx5_probe_split.py index 7c0262cd..ab158103 100644 --- a/tests/test_ssx5_probe_split.py +++ b/tests/test_ssx5_probe_split.py @@ -47,8 +47,9 @@ def test_near_unit_weight_valley_is_excluded_without_probe_queue_explosion(swap, second = to_homogeneous_2d(plane, np.ones((2, 2))) if swap: first, second = second, first - result = (nurbs_ssx(first, second, atol=1e-5, max_cells=15000) if adapter - else bez_ssx(first, second, atol=1e-5, rational=True, max_cells=15000)) + atol = 1e-5 + result = (nurbs_ssx(first, second, atol=atol, max_cells=15000) if adapter + else bez_ssx(first, second, atol=atol, rational=True, max_cells=15000)) assert result["complete"], result["status"] assert len(result["branches"]) == 2 assert not result["singularities"] @@ -57,7 +58,21 @@ def test_near_unit_weight_valley_is_excluded_without_probe_queue_explosion(swap, positions = [] for branch in result["branches"]: xyz = np.asarray(branch.curve[1]) - assert np.ptp(xyz[:, 0]) < 1e-9 - assert np.ptp(xyz[:, 1]) == 2. + # Measure line coverage in world coordinates at the requested atol. + # The two roots remain over 300 tolerances apart. + assert np.ptp(xyz[:, 0]) <= atol + np.testing.assert_allclose(np.sort(xyz[[0, -1], 1]), [-1., 1.], atol=atol, rtol=0.) + assert np.max(np.abs(xyz[:, 2])) <= atol + for q, point in zip(branch.curve[0], xyz): + if adapter: + from mmcore.nurbs._nurbs_eval import evaluate_nurbs_surface + sources = [evaluate_nurbs_surface(net, *uv)['S'] + for net, uv in ((first, q[:2]), (second, q[2:]))] + else: + from mmcore.numeric._bezier_common import eval_surface + sources = [eval_surface(net, *uv, rational=True) + for net, uv in ((first, q[:2]), (second, q[2:]))] + assert np.linalg.norm(sources[0]-sources[1]) <= atol + assert all(np.linalg.norm(source-point) <= atol for source in sources) positions.append(float(xyz[:, 0].mean())) - np.testing.assert_allclose(sorted(positions), [-expected, expected], atol=1e-9, rtol=0.) + np.testing.assert_allclose(sorted(positions), [-expected, expected], atol=atol, rtol=0.) diff --git a/tests/test_ssx_assembly_chart_invariance.py b/tests/test_ssx_assembly_chart_invariance.py new file mode 100644 index 00000000..4634b017 --- /dev/null +++ b/tests/test_ssx_assembly_chart_invariance.py @@ -0,0 +1,49 @@ +"""Assembly preserves a geometric path under regular nonlinear charts. + +These are geometry and topology checks at the public CAD tolerance. The +Bernstein coefficients define analytic intersections independently of the +SSX implementation; no equality of Newton iterates is required. +""" +import numpy as np +import pytest + +from mmcore.numeric.intersection.ssx import _bez_ssx5 as ssx + + +@pytest.mark.parametrize('quadratic', [.2, .8]) +@pytest.mark.parametrize('sample_count', [2, 3, 5]) +@pytest.mark.parametrize('swap', [False, True]) +def test_straight_intersection_survives_nonlinear_parameter_correspondence( + quadratic, sample_count, swap): + # f(s)=(1-a)*s+a*s^2 is strictly increasing on [0,1]. The + # surfaces (f(s),t,0) and (u,0,v) intersect along the entire x-axis. + # At a parameter midpoint f((s0+s1)/2) != (f(s0)+f(s1))/2, even + # though every world-space chord lies on both surfaces exactly. + x_controls = [0., (1.-quadratic)/2., 1.] + first = np.array([[[x, t, 0.] for t in [0., 1.]] + for x in x_controls]) + second = np.array([[[u, 0., v] for v in [0., 1.]] + for u in [0., 1.]]) + s = np.linspace(0., 1., sample_count) + x = (1.-quadratic)*s+quadratic*s*s + stuv = np.column_stack((s, np.zeros_like(s), x, np.zeros_like(s))) + xyz = np.column_stack((x, np.zeros_like(x), np.zeros_like(x))) + if swap: + first, second = second, first + stuv = stuv[:, [2, 3, 0, 1]] + fragment = ssx._Fragment(None, None, stuv, xyz) + atol = 1e-3 + branches = ssx._assemble_fragments( + [fragment], S1_full=first, S2_full=second, + rational_full=False, atol_full=atol) + assert len(branches) == 1 + assert not branches[0].closed + actual = np.asarray(branches[0].curve[1]) + assert np.linalg.norm(actual[0]-[0., 0., 0.]) <= atol + assert np.linalg.norm(actual[-1]-[1., 0., 0.]) <= atol + assert abs(np.linalg.norm(np.diff(actual, axis=0), axis=1).sum()-1.) <= atol + for parameters, point in zip(branches[0].curve[0], actual): + on_first = ssx.eval_surface(first, *parameters[:2], rational=False) + on_second = ssx.eval_surface(second, *parameters[2:], rational=False) + assert np.linalg.norm(on_first-point) <= atol + assert np.linalg.norm(on_second-point) <= atol diff --git a/tests/test_ssx_cut_face_queries.py b/tests/test_ssx_cut_face_queries.py new file mode 100644 index 00000000..f82a0fd6 --- /dev/null +++ b/tests/test_ssx_cut_face_queries.py @@ -0,0 +1,77 @@ +"""Parent cut queries retain contacts and refine only unfinished local work.""" + +from types import SimpleNamespace + +import numpy as np + +from mmcore.numeric.intersection.ssx import _bez_ssx5 as ssx + + +def _root(t=.25, u=.5, v=.75): + return dict(t=t, u=u, v=v, point=np.array([t, u, v])) + + +def test_complete_parent_cut_does_not_repeat_child_queries(): + calls = [] + root = _root() + def query(curve, surface, **kwargs): + calls.append((surface, kwargs)) + return dict(isolated=[root], overlaps=[], budget_exhausted=False) + result = ssx._query_cut_face_contacts( + query, 'curve', 'parent', ['left', 'right'], 0, [.3], + 1e-3, SimpleNamespace(exhausted=False)) + assert result == [root] + assert [surface for surface, _ in calls] == ['parent'] + + +def test_parent_depth_fallback_retains_and_remaps_all_contacts(): + calls = [] + parent_contact = _root() + def query(curve, surface, **kwargs): + calls.append((surface, kwargs)) + if surface == 'parent': + return dict(isolated=[parent_contact], overlaps=[], + budget_exhausted=True, truncation_cause='depth') + return dict(isolated=[_root(u=.5)], overlaps=[], budget_exhausted=False) + result = ssx._query_cut_face_contacts( + query, 'curve', 'parent', ['left', 'right'], 0, [.3], + 1e-3, SimpleNamespace(exhausted=False)) + assert [surface for surface, _ in calls] == ['parent', 'left', 'right'] + assert calls[0][1]['defer_local_depth'] + assert result[0] is parent_contact + # These are arithmetic affine mappings, not CAD accuracy demands. + np.testing.assert_allclose([root['u'] for root in result], [.5, .15, .65]) + + +def test_resource_stop_does_not_start_child_retries(): + calls = [] + def query(curve, surface, **kwargs): + calls.append(surface) + return dict(isolated=[_root()], overlaps=[], budget_exhausted=True, + truncation_cause='cells') + result = ssx._query_cut_face_contacts( + query, 'curve', 'parent', ['left', 'right'], 1, [.3], + 1e-3, SimpleNamespace(exhausted=True)) + assert calls == ['parent'] + assert len(result) == 1 + + +def test_paired_contact_object_is_shared_only_with_closed_incident_children(monkeypatch): + monkeypatch.setattr(ssx, '_ssx_tangent_4d', lambda *a, **kw: (np.ones(4), None, None)) + cell = SimpleNamespace(box=((0., 1.),)*4, + g1=SimpleNamespace(surface=None), + g2=SimpleNamespace(surface=None)) + grid = [[[], []], [[], []]] + ssx._register_cut_contacts(cell, [_root(u=.5)], 0, .4, 0, 2, [.5], grid) + assert all(len(owner) == 1 for row in grid for owner in row) + assert len({id(owner[0]) for row in grid for owner in row}) == 1 + exterior_grid = [[[], []], [[], []]] + ssx._register_cut_contacts(cell, [_root(u=.5+1e-5)], 0, .4, 0, 2, [.5], exterior_grid) + assert all(not row[0] and len(row[1]) == 1 for row in exterior_grid) + + +def test_narrow_parameter_interval_keeps_distinct_face_identities(): + low, high = .5, .5+5e-9 + assert ssx._on_axis_local(low, low, high) == 0 + assert ssx._on_axis_local(high, low, high) == 1 + assert ssx._on_axis_local(.5*(low+high), low, high) is None diff --git a/tests/test_ssx_cut_overlap_contacts.py b/tests/test_ssx_cut_overlap_contacts.py new file mode 100644 index 00000000..21aaa6be --- /dev/null +++ b/tests/test_ssx_cut_overlap_contacts.py @@ -0,0 +1,60 @@ +"""CSX tolerance spans retain the point witnesses needed by SSX cuts.""" + +import numpy as np +import pytest + +from mmcore.numeric._bezier_common import eval_curve, eval_surface +from mmcore.numeric.intersection.csx._bez_csx4 import bez_csx +from mmcore.numeric.intersection.ssx._bez_ssx5 import _cut_face_contacts + + +@pytest.mark.parametrize("matrix", [np.eye(3), + np.array([[0., 1., 0.], [0., 0., 1.], [1., 0., 0.]])]) +def test_tolerance_span_keeps_its_polished_endpoint_contact(matrix): + # C(t)=(t,0,(1-t)^2) meets S(u,v)=(u,v,0) only at t=u=1,v=0. + # Its CAD tolerance neighborhood can be a valid CSX overlap span, + # but that representation must not erase the SSX cut's point seed. + curve = np.array([[0., 0., 1.], [.5, 0., 0.], [1., 0., 0.]]) @ matrix.T + surface = np.array([[[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]]]) @ matrix.T + result = bez_csx(curve, surface, 1e-3, rational=False, max_results=8) + + assert not result['budget_exhausted'] + assert result['isolated'] == [] + assert len(result['overlaps']) == 1 + contacts = list(_cut_face_contacts(result)) + assert len(contacts) == 1 + point = contacts[0] + np.testing.assert_allclose([point[k] for k in ('t', 'u', 'v')], + [1., 1., 0.], atol=1e-6) + np.testing.assert_allclose(eval_curve(curve, point['t'], rational=False), + point['point'], atol=1e-12) + np.testing.assert_allclose( + eval_surface(surface, point['u'], point['v'], rational=False), + point['point'], atol=1e-12) + + +@pytest.mark.parametrize("max_results", [0, 1]) +def test_preserved_contacts_respect_the_original_result_allowance(max_results): + curve = np.array([[0., 0., 1.], [.5, 0., 0.], [1., 0., 0.]]) + surface = np.array([[[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]]]) + result = bez_csx(curve, surface, 1e-3, rational=False, + max_results=max_results) + assert len(list(_cut_face_contacts(result))) <= max_results + assert result['budget_exhausted'] + + +def test_cut_range_boxes_cannot_invent_paired_contact_parameters(): + result = {'isolated': [], 'overlaps': [ + {'t_range': (0., 1.), 'u_range': (.2, .8), 'v_range': (.3, .7)}]} + assert list(_cut_face_contacts(result)) == [] + + +def test_cut_contacts_preserve_distinct_surface_preimages(): + first = dict(t=.5, u=.2, v=.3, point=np.zeros(3)) + second = dict(t=.5, u=.8, v=.7, point=np.zeros(3)) + result = {'isolated': [first], 'overlaps': [ + {'boundary_contacts': [second]}]} + contacts = list(_cut_face_contacts(result)) + assert contacts[0] is first and contacts[1] is second diff --git a/tests/test_ssx_nonplanar_projection_invariance.py b/tests/test_ssx_nonplanar_projection_invariance.py index 6a57a678..7ecd2115 100644 --- a/tests/test_ssx_nonplanar_projection_invariance.py +++ b/tests/test_ssx_nonplanar_projection_invariance.py @@ -43,13 +43,15 @@ def test_curved_graph_intersection_covers_the_whole_source_circle(matrix, swap): s, t = .5+radius*np.cos(angle), .5+radius*np.sin(angle) reference = np.column_stack((s, t, s*t))@matrix.T assert distances_to_polylines(reference, [xyz]).max() < 4*atol - # Count full angular travel, not vertex density. A partial loop, - # duplicate traversal, or backtracking cannot satisfy this invariant. + # Signed winding is a topological invariant of this closed curve. + # Reverse travel is a geometric error: allow at most the requested + # world-space tolerance, rather than 1e-6 radians in an arbitrary chart. source_xyz = np.linalg.solve(matrix, xyz.T).T theta = np.arctan2(source_xyz[:, 1]-.5, source_xyz[:, 0]-.5) increments = np.arctan2(np.sin(np.diff(theta)), np.cos(np.diff(theta))) assert abs(abs(increments.sum())-2*np.pi) < 1e-6 - assert abs(np.abs(increments).sum()-2*np.pi) < 1e-6 + backwards = np.sign(increments.sum())*increments < 0. + assert np.linalg.norm(np.diff(xyz, axis=0), axis=1)[backwards].sum() <= atol expected_length = np.linalg.norm(np.diff(reference, axis=0), axis=1).sum() output_length = np.linalg.norm(np.diff(xyz, axis=0), axis=1).sum() assert abs(output_length-expected_length) < 8*np.pi*atol diff --git a/tests/test_ssx_partial_boundary_witnesses.py b/tests/test_ssx_partial_boundary_witnesses.py index 25b4b0f1..2e578874 100644 --- a/tests/test_ssx_partial_boundary_witnesses.py +++ b/tests/test_ssx_partial_boundary_witnesses.py @@ -8,18 +8,24 @@ def test_partial_boundary_roots_remain_available_for_validated_tracing(monkeypat first = np.array([[[s, t, 0.] for t in (0., 1.)] for s in (0., 1.)]) second = np.array([[[u, v, v-.5] for v in (0., 1.)] for u in (0., 1.)]) actual_csx = ssx.bez_csx + partial_calls = [] def partial_csx(*args, **kwargs): result = actual_csx(*args, **kwargs) if result['isolated']: + partial_calls.append(result) result = dict(result, budget_exhausted=True, boundary_topology_complete=False, truncation_cause='results') return result monkeypatch.setattr(ssx, 'bez_csx', partial_csx) - result = ssx.bez_ssx(first, second, atol=1e-3, rational=False, max_depth=0) + # Exercise general continuation: this pair otherwise uses the isoline + # reduction and never calls the CSX solver being fault-injected here. + result = ssx.bez_ssx(first, second, atol=1e-3, rational=False, + max_depth=0, max_xyz_step=.1) + assert partial_calls, "the test must exercise partial CSX boundary output" assert not result['complete'] assert result['branches'], result['status'] points = np.vstack([branch.curve[1] for branch in result['branches']]) From 97d32e6346dd3d8537b153aecb56d04c46236b87 Mon Sep 17 00:00:00 2001 From: sth-v Date: Wed, 23 Sep 2026 02:57:09 +0300 Subject: [PATCH 5/8] Resolve regular clipped corners before reporting multiplicity --- .../2026-09-14-ssx-tolerance-repair.md | 33 ++++++++++++--- .../reviews/2026-09-23-ssx-test-contract.md | 6 +++ mmcore/numeric/intersection/ssx/_bez_ssx5.py | 14 ++++--- tests/test_bez_ssx5_singular.py | 40 ++++++++++++------- tests/test_ssx5_c1_regular_normal.py | 8 +++- 5 files changed, 73 insertions(+), 28 deletions(-) diff --git a/docs/superpowers/reviews/2026-09-14-ssx-tolerance-repair.md b/docs/superpowers/reviews/2026-09-14-ssx-tolerance-repair.md index 31aa34b4..b98b84d7 100644 --- a/docs/superpowers/reviews/2026-09-14-ssx-tolerance-repair.md +++ b/docs/superpowers/reviews/2026-09-14-ssx-tolerance-repair.md @@ -1,6 +1,7 @@ # SSX tolerance repair -Status: implementation and validation in progress; not yet integrated into `tiny`. +Repair implementation for [PR #48](https://github.com/contextmachine/mmcore/pull/48). +The PR records integration and the final GitHub validation outcome. ## September 23: case 16 and test-contract correction @@ -26,7 +27,7 @@ touch-plus-ring controls also pass with the corrected CAD contact admission. The accompanying [test audit](2026-09-23-ssx-test-contract.md) replaces excessive output precision with physical-distance checks, while retaining coverage, paired-source geometry, and actual work-limit checks. These focused results -do not replace the pending complete integration run. +do not replace complete integration validation. ## Regression and responsibility @@ -138,8 +139,28 @@ decreased by approximately 31–38% in that comparison. ## Validation -Final full-suite results, clean Linux build results, and integration commit are -pending. Focused results are diagnostic evidence only until those gates finish. +The integration gates are `python -m pytest tests -m 'not slow'`, import health, +layering, and the GitHub build matrix. The [PR checks](https://github.com/contextmachine/mmcore/pull/48/checks) +record the final run outcomes; focused checks alone do not establish a green +integration result. + +After the September 23 corrections, focused checks passed: + +- 260 raw CCX family tests, including both near-coincident CAD spans. +- 24 nested CSX/SSX controls after the CCX change. +- 28 combined real case16, nonlinear-chart, transformed-circle, and cut-face + controls; 19 additional contact/boundary checks. +- 17 case11 and regular-normal/corner tests. Case11 is compared against its + supplied Rhino reference in both directions, replacing a fixed vertex count. + Its false multiplicity flag came from regular corners already classified as + having no arc inside the child; the classification now precedes the flag. +- Import health: six optional dependencies allowlisted, no unexpected failures. + Layering: no upward import edges. + +The near-coincident CCX recovery supplies a whole-span distance-bounded overlap +after the existing limited root search fails to return geometry. It retains +partial status for unsearched tails; it does not claim that this remaining +search cost or all difficult-case completeness limitations are solved. The first broad repair run at `9cea23c` was not green: macOS reported 6 failures and 1241 passes; Linux aarch64 reported 7 failures, 1239 passes, and one xpass. @@ -148,8 +169,8 @@ exposed the cone-census scheduling defect, Linux overlap-sidedness defect, sheared-circle search cost, and two stale test-path/status expectations. The case-10 expectation now includes an independent analytic check of its entire curved tangential ruling; the boundary fault-injection test explicitly selects -general continuation and verifies that its injected calls occur. The remaining -production corrections require another complete run before integration. +general continuation and verifies that its injected calls occur. Complete +validation is required for the corrected implementation before integration. The local clean Linux environment is Python 3.12 on Linux aarch64, not the GitHub-hosted x86_64 runner. A local pass must not be described as a successful diff --git a/docs/superpowers/reviews/2026-09-23-ssx-test-contract.md b/docs/superpowers/reviews/2026-09-23-ssx-test-contract.md index 7fd66353..a8ec7559 100644 --- a/docs/superpowers/reviews/2026-09-23-ssx-test-contract.md +++ b/docs/superpowers/reviews/2026-09-23-ssx-test-contract.md @@ -72,6 +72,12 @@ inconsistency by moving the default-mode examples into exact mode. ## Verification +The subsequent case11 check replaces its fixed 32-vertex minimum with +bidirectional coverage of the supplied Rhino reference and paired-source +residuals. The positive-gap endpoint-touch controls retain their two analytic +contact locations and allow completion when corner classification resolves +their local arcs. The source fix and these checks passed 17 tests. + - Isoline, matched-isoline, and probe tests: **41 passed** (3.93 seconds). - Initial focused public NURBS, singular, and six transformed-circle cases: **12 passed, 2 failed** (89.44 seconds). diff --git a/mmcore/numeric/intersection/ssx/_bez_ssx5.py b/mmcore/numeric/intersection/ssx/_bez_ssx5.py index c914eabd..83678a14 100644 --- a/mmcore/numeric/intersection/ssx/_bez_ssx5.py +++ b/mmcore/numeric/intersection/ssx/_bez_ssx5.py @@ -5663,19 +5663,21 @@ def evaluate(q): # Both directions failed (genuine corner touch or marcher # failure). Surface the crossing as an isolated point instead # of silently dropping it. + clipped_corner = _clipped_corner(start_local) if (start_cx.multiplicity_polished + and not clipped_corner 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). + # Numerical polishing located this contact, but collapsing + # a CSX tolerance cluster does not establish whether + # a branch leaves it. A successful trace or a local corner + # classification resolves that question; only an unexplained + # point-only fallback remains partial. 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)) - if not _clipped_corner(start_local): + if not clipped_corner: cell.trace_incomplete = True if work_budget is not None and work_budget.exhausted: diff --git a/tests/test_bez_ssx5_singular.py b/tests/test_bez_ssx5_singular.py index 5df2d1fe..a591f54a 100644 --- a/tests/test_bez_ssx5_singular.py +++ b/tests/test_bez_ssx5_singular.py @@ -16,27 +16,37 @@ def test_result_has_singularities_key_and_branch_kind(): def test_case11_default_nested_csx_budget_preserves_closed_loop(): - """A sound global budget must not make the historical case 11 partial. + """The shared budget preserves the complete CAD reference loop.""" + from examples.ssx.bez_ssx5_case11 import S1, S2, EXPECTED_BRANCH_CURVE + from examples.ssx.ssx5_analytic_audit import distances_to_polylines + from mmcore.nurbs._nurbs_eval import evaluate_nurbs_curve - 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. + atol = 1e-3 result = bez_ssx( - S1, S2, 1e-3, rational=False, max_cells=60_000) + S1, S2, atol, 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 + branch = result["branches"][0] + parameters, xyz = map(np.asarray, branch.curve) + assert branch.closed and len(xyz) >= 3 + assert np.linalg.norm(xyz[0] - xyz[-1]) <= 2*atol assert np.linalg.norm(np.diff(xyz, axis=0), axis=1).sum() > 1.0 + # Reference sampling measures the physical loop, not the implementation's + # choice of output vertex count. The Rhino fit has CAD accuracy and the + # SSX polyline allows 2*atol chord deviation: compare with their combined + # 3*atol allowance in both directions, so partial loops cannot pass. + reference = np.array([ + evaluate_nurbs_curve(EXPECTED_BRANCH_CURVE, float(t), d_order=0)["C"] + for t in np.linspace(*EXPECTED_BRANCH_CURVE.interval(), 1025) + ]) + assert distances_to_polylines(reference, [xyz]).max() <= 3*atol + assert distances_to_polylines(xyz, [reference]).max() <= 3*atol + for parameter, point in zip(parameters, xyz): + first = eval_surface(S1, *parameter[:2], rational=False) + second = eval_surface(S2, *parameter[2:], rational=False) + assert np.linalg.norm(first-point) <= atol + assert np.linalg.norm(second-point) <= atol # --------------------------------------------------------------------------- diff --git a/tests/test_ssx5_c1_regular_normal.py b/tests/test_ssx5_c1_regular_normal.py index 6595374d..c8b92a23 100644 --- a/tests/test_ssx5_c1_regular_normal.py +++ b/tests/test_ssx5_c1_regular_normal.py @@ -239,7 +239,13 @@ def test_positive_gap_between_endpoint_touches_is_not_a_branch(h): assert result["branches"] == [] assert len(result["points"]) == 2 - assert result["complete"] is False + expected = np.array([[.5, 0., 0.], [.5, 1., 0.]]) + found = np.asarray([point.xyz for point in result["points"]]) + found = found[np.argsort(found[:, 1])] + assert np.max(np.linalg.norm(found-expected, axis=1)) <= 1e-3 + # For h>0, (s-.5)^2+4*h*t*(1-t) vanishes only at these two + # boundary contacts. A regular corner classification can resolve them + # completely; do not require the former polishing fallback's partial flag. def test_zero_gap_control_remains_a_tangent_line(): From ff86c8680b43e48dddc047a9ce00d55206d44940 Mon Sep 17 00:00:00 2001 From: sth-v Date: Wed, 23 Sep 2026 04:43:59 +0300 Subject: [PATCH 6/8] Keep searching for regular components after tracing tangencies --- .../2026-09-14-ssx-tolerance-repair.md | 31 +++ mmcore/numeric/intersection/ssx/_bez_ssx5.py | 171 +++++++++----- .../intersection/ssx/_ssx_bernstein_clip.py | 187 ++++++++++++++++ .../intersection/ssx/_ssx_trace_coverage.py | 208 ++++++++++++++++++ tests/test_ssx5_tangent_regular_remainder.py | 72 ++++++ tests/test_ssx_bernstein_clip.py | 121 ++++++++++ tests/test_ssx_tangent_remainder.py | 63 ++++++ tests/test_ssx_trace_coverage.py | 129 +++++++++++ 8 files changed, 933 insertions(+), 49 deletions(-) create mode 100644 mmcore/numeric/intersection/ssx/_ssx_bernstein_clip.py create mode 100644 mmcore/numeric/intersection/ssx/_ssx_trace_coverage.py create mode 100644 tests/test_ssx5_tangent_regular_remainder.py create mode 100644 tests/test_ssx_bernstein_clip.py create mode 100644 tests/test_ssx_tangent_remainder.py create mode 100644 tests/test_ssx_trace_coverage.py diff --git a/docs/superpowers/reviews/2026-09-14-ssx-tolerance-repair.md b/docs/superpowers/reviews/2026-09-14-ssx-tolerance-repair.md index b98b84d7..d7b3bb2b 100644 --- a/docs/superpowers/reviews/2026-09-14-ssx-tolerance-repair.md +++ b/docs/superpowers/reviews/2026-09-14-ssx-tolerance-repair.md @@ -29,6 +29,37 @@ output precision with physical-distance checks, while retaining coverage, paired-source geometry, and actual work-limit checks. These focused results do not replace complete integration validation. +## Tangent components and the remaining domain + +An independent polynomial family exposed another real loss after the earlier +integration run was green: a tangent line through the cell center caused a +disjoint ordinary circle to disappear. The tangency arm traced the line, +searched only for other tangencies, and then discarded the whole cell. A +regular loop does not belong to that tangency system, so this exit could never +find it. The same defect also lost two separate regular loops. + +Finding a tangent component now retains the remaining domain for ordinary +subdivision. Known seeds are reused only when their paired parameters and XYZ +match an existing path. Numerical Bernstein clipping bounds the remaining +domain, and a cell can be covered by existing geometry only when both source +control graphs fit the same published segment at the CAD tolerance. This uses +floating-point geometry internally and adds no rational public diagnostics. +Failed or interrupted coverage checks leave the ordinary search available. + +Tangent paths use half-atol chord sampling so reuse is consistent with their +geometric accuracy. A later Phi-seeded retrace is classified over its whole +path before assembly, preventing a regular-backend retrace from replacing the +correct tangential label. Microscopic regular corner arcs are bounded against +both source hulls before being represented by an already found CAD point. + +The independent line/loop family passes with surface order reversed, parameter +direction reversed, and two disjoint loops. Coverage controls also check gaps, +different parameter sheets, endpoint caps, nonuniform weights, and work denial. +The final focused remainder/singularity run passed 47 tests; the analytic +coverage and boundary/case16 runs passed 36 and 34 tests respectively. Curved +tangency remainder searches still cost more than the former incorrect early +exit; this repair does not claim a universal speed improvement. + ## Regression and responsibility The working reference is `76735f9fc11a5e8149fb60d171f46cacd0ff4ec9`. diff --git a/mmcore/numeric/intersection/ssx/_bez_ssx5.py b/mmcore/numeric/intersection/ssx/_bez_ssx5.py index 83678a14..677e27cc 100644 --- a/mmcore/numeric/intersection/ssx/_bez_ssx5.py +++ b/mmcore/numeric/intersection/ssx/_bez_ssx5.py @@ -1700,13 +1700,11 @@ def _emit_offcurve_tangent_roots(cell, fragments_local, atol, unify_tol, With no fragments (nothing traced), this degrades to the plain budget-bounded full enumeration. - KNOWN LIMIT (review 2d030bb+7ed47c0): this enumeration recovers only - Δ-ROOTS (tangencies). A coexisting TRANSVERSAL feature that is not on - Δ — e.g. a small transversal LOOP with no boundary crossings sharing - this crossing-bearing cell — is still lost: the arm `continue`s - without subdividing, and the Φ∩L loop seeding runs only on the - crossing-LESS arm (the Mexican-hat treatment). Subdividing instead - costs 1349x on tangent curves (see the arm's comment); accepted gap. + This enumeration recovers only Δ-roots (tangencies). It cannot find a + coexisting regular component that does not lie on Δ, such as a closed + transversal loop. The caller therefore retains the ordinary remainder + search after this pass; finding a tangent component does not resolve + the whole cell. """ from mmcore.numeric.intersection.ssx._ssx5_singular import ( ShiftedPositiveNet, VectorBoxNet, psi_vector_net, solve_zero_dim, @@ -2308,6 +2306,7 @@ def _march_intersection_curve( atol=1e-3, rational=True, h_max=None, + sag_tol=None, min_step=1e-6, max_step=0.25, angle_threshold=0.1, # radians — target angle between consecutive tangents @@ -2346,7 +2345,8 @@ def _march_intersection_curve( xyz_start = eval_surface(S1, stuv_start[0], stuv_start[1], rational=rational) xyz_pts = [xyz_start] - sag_tol = 2.0 * atol + 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 = 0.25 * h_max @@ -3557,6 +3557,7 @@ def _march_phi_curve( atol=1e-3, rational=True, h_max=None, + sag_tol=None, min_step=1e-6, max_step=0.25, angle_threshold=0.1, @@ -3576,7 +3577,8 @@ def _march_phi_curve( xyz_start = eval_surface(S1, stuv_start[0], stuv_start[1], rational=rational) xyz_pts = [xyz_start] - sag_tol = 2.0 * atol + 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 = 0.25 * h_max @@ -4435,6 +4437,16 @@ def _bounded_closed_march(marcher, *args, **kwargs): if frag is not None or phantom: break if frag is not None: + if not frag.tangential: + # A seed slightly off a repeated root can select the Psi + # backend even though its completed path lies on the + # tangent locus. Measure the whole path before publishing + # its kind; otherwise a longer numerical retrace can erase + # correctly typed tangent pieces during containment dedup. + kind_work = max(1, len(frag.stuv_path)) + if (cell.work_budget is None or cell.work_budget.charge_cells( + kind_work, "singular_kind")): + frag.tangential = _fragment_on_tangent_locus(cell, frag, atol) fragments.append(frag) return fragments @@ -4525,6 +4537,9 @@ def _valid_endpoint_path(stuv_path, xyz_path, target): # 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. + # Retained tangent paths also guide later remainder searches. Match + # the closed-loop marcher's half-atol chord budget so an on-curve + # witness remains within the coverage predicate's atol of the path. for start_idx, target_idx in ((i, j), (j, i)): start = np.asarray( crossings[start_idx].stuv, dtype=np.float64) @@ -4541,6 +4556,7 @@ def _valid_endpoint_path(stuv_path, xyz_path, target): stuv_path, xyz_path = _march_intersection_curve( S1, S2, start, target, atol=atol, rational=rational, h_max=h_max, + sag_tol=0.5 * atol, max_points=trace_limit, stats=trace_stats, ) if cell is not None and cell.work_budget is not None: @@ -4597,6 +4613,7 @@ def _valid_endpoint_path(stuv_path, xyz_path, target): 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, + sag_tol=0.5 * atol, max_points=trace_limit, stats=trace_stats, ) if cell is not None and cell.work_budget is not None: @@ -5013,6 +5030,39 @@ def _cell_has_unused_direction(point: BoundaryPoint, cell, direction: str) -> bo ) +def _cell_geometry_confined_to_point(cell, point, xyz, ptol, atol): + """Bound all possible paired roots by one already found CAD point. + + A numerical cut can leave a microscopic regular arc near a corner. + Residual control-hull clipping bounds the entire remaining parameter + domain; both restricted surface hulls must fit the found point. + """ + from mmcore.numeric.intersection.ssx._ssx_bernstein_clip import ( + clip_residual_box, residual_coordinate_scale, restrict_source_pair, + ) + first, second = cell.g1.surface, cell.g2.surface + originals = getattr(cell, 'trace_surfaces', (first, second)) + scale = residual_coordinate_scale(*originals) + bounds, stats = clip_residual_box( + first, second, source_scale=scale, + charge=_charge_hook(getattr(cell, 'work_budget', None), 'point_extent')) + if bounds is None or not stats['valid'] or stats['denied']: + return False + bounds = np.asarray(bounds) + if np.any(np.abs(bounds - np.asarray(point)[:, None]) > np.asarray(ptol)[:, None]): + return False + for surface in restrict_source_pair(first, second, bounds): + if not (np.all(np.isfinite(surface)) and np.all(surface[..., 3] > 0.)): + return False + controls = (surface[..., :3] / surface[..., 3:]).reshape(-1, 3) + error = (64. * np.finfo(float).eps + * max(1., float(np.max(np.abs(controls))), + float(np.max(np.abs(xyz))))) + if np.max(np.linalg.norm(controls - xyz, axis=1)) + error > atol: + return False + return True + + def _trace_cell_by_registrations(cell, atol, h_max=None): """Trace all branch segments inside a certified cell. @@ -5076,6 +5126,8 @@ def _clipped_corner(point): if (work_budget is not None and not work_budget.charge_cells(1, "branch_trace")): return False + original_point = np.asarray(point).copy() + original_xyz = eval_surface(cell.g1.surface, *original_point[:2], rational=True) from mmcore.numeric._bern_homog import ( eval_bezier_surface_homog_with_derivs, project_surface_homog_to_cartesian, @@ -5218,10 +5270,13 @@ def evaluate(q): except np.linalg.LinAlgError: return False curvature_margin = 4e-10*max(1., float(np.linalg.norm(acceleration))) - return any(abs(tangent[axis]) <= 1e-10 - and (regular or axis != fiber_axis) - and sign*acceleration[axis] < -curvature_margin - for axis, sign in faces) + if any(abs(tangent[axis]) <= 1e-10 + and (regular or axis != fiber_axis) + and sign*acceleration[axis] < -curvature_margin + for axis, sign in faces): + return True + return regular and _cell_geometry_confined_to_point( + cell, original_point, original_xyz, ptol_local, atol) for i, start_cx in enumerate(cell.crossings): if work_budget is not None and work_budget.exhausted: @@ -7020,6 +7075,10 @@ class _Cell: # Continuation can produce a useful prefix without resolving the cell. # The caller must then keep the unsearched remainder in the frontier. trace_incomplete: bool = False + # A tangent component has already been traced in an ancestor. The + # remaining domain still needs searching; this flag only enables + # coverage checks against geometry that has actually been retained. + tangency_remainder: bool = False def _probe_children(cell): @@ -7072,6 +7131,7 @@ def _split2(T): new_crossings=[], F_sq=Fs[i1][i2], w_scale=cell.w_scale, probe_only=True, work_budget=cell.work_budget, csx_fn=cell.csx_fn, + tangency_remainder=cell.tangency_remainder, )) return out @@ -7574,11 +7634,11 @@ def _run_top_boundary_csx(curve, surface, **kwargs): 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). + # Memoize productive Phi seeding passes so descendants do not repeat + # the same four-plane search. These boxes are not absence or coverage + # proofs: the ordinary domain search still owns the remaining geometry. + # A tangent loop need not emit an isolated tangent_point, so productive + # passes cannot be identified only by those point records. 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 @@ -7605,10 +7665,30 @@ def _enqueue_probe_children(parent) -> bool: queue.extend(_probe_children(parent)) return True + from mmcore.numeric.intersection.ssx._ssx_bernstein_clip import residual_coordinate_scale + from mmcore.numeric.intersection.ssx._ssx_trace_coverage import TangentialTraceCoverage + tangent_coverage = TangentialTraceCoverage( + atol, unify_tol, residual_coordinate_scale(S1_h_top, S2_h_top), + charge=lambda amount: budget.charge_cells(amount, "tangent_coverage")) + + def _known_tangent_crossings(cell): + return bool(cell.tangency_remainder and cell.crossings + and tangent_coverage.covers_points( + [point.stuv for point in cell.crossings], + [point.xyz for point in cell.crossings])) + while queue: if not budget.charge_cells(1, "ssx"): break cell = queue.popleft() + if cell.tangency_remainder: + tangent_coverage.update(all_fragments) + if tangent_coverage.covers_cell( + cell.g1.surface, cell.g2.surface, cell.box, + allow_empty=not cell.crossings): + continue + if budget.exhausted: + break # P1c soundness guard (2026-07-21): a cell carrying registered # crossings holds strict-certified Psi roots ON it — any exclusion @@ -7944,7 +8024,8 @@ def _enqueue_probe_children(parent) -> bool: is_clearly_transversal = True break - if cell.trace_incomplete or is_clearly_transversal: + if (cell.trace_incomplete or is_clearly_transversal + or _known_tangent_crossings(cell)): tangency = False else: local_box = ((0.0, 1.0),) * 4 @@ -7993,6 +8074,15 @@ def _enqueue_probe_children(parent) -> bool: _phi_already_attempted = any( np.all(np.abs(_seed_anchor - prior) <= unify_tol) for prior in phi_seed_attempts) + if cell.tangency_remainder and _root_globals: + # Reuse a known tangent path only for these actual roots. + # Do not mark its whole ancestor box searched: another + # unknown tangent loop can coexist in the same domain. + _phi_already_attempted = ( + _phi_already_attempted or tangent_coverage.covers_points( + _root_globals, + [eval_surface(S1_h_top, point[0], point[1], rational=True) + for point in _root_globals])) 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 @@ -8127,39 +8217,21 @@ def _enqueue_probe_children(parent) -> bool: # pt_local's SSXPoint.stuv is already global — we passed # `originals` so _deflate_tangent_cell copied from them. 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 - # converges into the CURVE's basin — a coexisting ISOLATED - # touch in the same cell (z = (2t-1)^2*((s-0.7)^2+(t-0.2)^2): - # tangent line at t=0.5 PLUS a touch at (0.7,0.2)) is off every - # traced fragment and the plain `continue` deleted it with NO - # descendants ever seeing it (this cell was the only holder — - # there is no "some other cell covers it" on this path). - # Subdividing instead (the crossing-less arm's e1db506 - # treatment) is correct but measured 1349x slower on the legacy - # crossed-saddles case (0.15 s -> 200 s): cells along a - # 1-dimensional tangent curve can never be certified away, so - # the size gate forces a full dyadic descent along the curve's - # length. Enumerate the cell's REMAINING Δ-roots here instead: - # hull-exclusion subdivision with the Newton attempts SKIPPED - # inside the traced fragments' tube (those roots are curve - # samples the subsumption filter would delete anyway), - # far-from-tube boxes explored FIRST, and only Newton attempts - # charged against the budget (skip-exempt charging — under - # per-pop charging the flood's excluded siblings starved the - # budget and touches at 5-15*atol from the curve fell into a - # blind band). - # KNOWN LIMIT: this recovers only Δ-roots. A coexisting - # TRANSVERSAL loop (not on Δ) with no boundary crossings in - # this cell is still lost — the `continue` below skips - # subdivision, and the Φ∩L loop seeding runs only on the - # crossing-LESS arm. Accepted (the subdivision alternative is - # the 1349x path above). + # The traced tangency is one component, not the solution of + # this whole cell. Search for off-curve isolated touches, then + # retain the ordinary subdivision frontier for regular branches + # or closed loops that do not lie on the deflated system. _emit_offcurve_tangent_roots(cell, fr_local, atol, unify_tol, all_singularities, overlap_boxes=overlap_boxes) - continue + cell.tangency_remainder = True + tangent_coverage.update(all_fragments) + if tangent_coverage.covers_cell( + cell.g1.surface, cell.g2.surface, cell.box, + allow_empty=not cell.crossings): + continue + if budget.exhausted: + break if cell.depth >= max_depth: for c in cell.crossings: @@ -8335,6 +8407,7 @@ def _enqueue_probe_children(parent) -> bool: 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, + tangency_remainder=cell.tangency_remainder, ) scell.partitions = _build_cell_partitions(scell) for c in sub_cx: diff --git a/mmcore/numeric/intersection/ssx/_ssx_bernstein_clip.py b/mmcore/numeric/intersection/ssx/_ssx_bernstein_clip.py new file mode 100644 index 00000000..0bfe4a56 --- /dev/null +++ b/mmcore/numeric/intersection/ssx/_ssx_bernstein_clip.py @@ -0,0 +1,187 @@ +"""Numerical necessary boxes for a surface residual in Bernstein form. + +Clipping narrows a copy of the residual net. It does not assign roots to +cells, prove that roots exist, or decide whether a CAD contact is absent. +Callers must validate their geometric coverage on both restricted sources. +""" +from __future__ import annotations + +import numpy as np + +from mmcore.numeric._bezier_common import restrict_net_axis_v +from mmcore.numeric.intersection.ssx._ssx5_singular import ( + _HULL_MARGIN_K_EPS, + psi_vector_net, +) + +_EPS = np.finfo(float).eps +_UNIT_BOX = ((0.0, 1.0),) * 4 + + +def residual_coordinate_scale(first, second): + """Scalar operand scale for forming the homogeneous XYZ residual. + + Preserve this value from the original surfaces when clipping a child. + A child's small z coefficients do not bound cancellation inherited from + forming and restricting the original surface pair. + """ + first, second = np.asarray(first), np.asarray(second) + return float( + np.max(np.abs(first[..., :3])) * np.max(np.abs(second[..., 3])) + + np.max(np.abs(second[..., :3])) * np.max(np.abs(first[..., 3])) + ) + + +def _zero_hull_interval(lower, upper, margin): + """Axis interval where a scalar coefficient hull can meet zero.""" + lower = np.asarray(lower, dtype=float) - margin + upper = np.asarray(upper, dtype=float) + margin + if not np.isfinite(lower).all() or not np.isfinite(upper).all(): + return 0.0, 1.0 + if float(lower.min()) > 0.0 or float(upper.max()) < 0.0: + return None + if len(lower) == 1: + return 0.0, 1.0 + + coordinate = np.linspace(0.0, 1.0, len(lower)) + zeros = coordinate[(lower <= 0.0) & (upper >= 0.0)] + values = np.concatenate((lower, upper)) + coordinates = np.tile(coordinate, 2) + scale = max(float(np.max(np.abs(values))), np.finfo(float).tiny) + negative, positive = values < 0.0, values > 0.0 + bounds = [] + if len(zeros): + bounds.extend((float(zeros.min()), float(zeros.max()))) + if np.any(negative) and np.any(positive): + a = (-values[negative] / scale)[:, None] + b = (values[positive] / scale)[None, :] + intersections = ( + coordinates[negative, None] * b + + coordinates[None, positive] * a + ) / (a + b) + bounds.extend((float(intersections.min()), + float(intersections.max()))) + if not bounds: + return 0.0, 1.0 + # These are unit-coordinate convex interpolations. Expand their bounds + # rather than letting arithmetic roundoff exclude an endpoint root. + return (max(0.0, min(bounds) - 64.0 * _EPS), + min(1.0, max(bounds) + 64.0 * _EPS)) + + +def clip_residual_box(first, second, *, source_scale=None, max_passes=12, + resolution=None, charge=None): + """Return a necessary local STUV box and bounded-work diagnostics. + + Inputs are homogeneous surface nets. For each residual component and + axis, the graph (parameter, residual) lies in the convex hull of the + coefficient points (k/degree, coefficient). Intersecting that hull with + zero gives a necessary axis interval. Restriction propagates the new + interval to the other axes before another pass. + + ``source_scale`` may carry ``residual_coordinate_scale`` from the original + surfaces. The roundoff margin is scalar across XYZ and never decreases + during restriction. ``max_passes`` and ``resolution`` only stop this + optional contraction early; a coarse return is still a necessary box. + + ``charge(n)`` must approve setup before residual allocation and each + axis operation before it executes. Denial returns the box accumulated + so far. ``None`` denotes an empty residual hull, not a CAD absence + decision. Callers using this for point/trace coverage should fall back + on empty, invalid, or denied results. + """ + stats = dict(passes=0, axes=0, work=0, empty=False, denied=False, valid=True) + first, second = np.asarray(first, dtype=float), np.asarray(second, dtype=float) + if (first.ndim != 3 or second.ndim != 3 + or first.shape[-1] != 4 or second.shape[-1] != 4 + or not all(first.shape) or not all(second.shape) + or not np.isfinite(first).all() or not np.isfinite(second).all()): + stats['valid'] = False + return _UNIT_BOX, stats + scale = residual_coordinate_scale(first, second) + if source_scale is not None: + if not np.isfinite(source_scale) or source_scale < 0.0: + stats['valid'] = False + return _UNIT_BOX, stats + scale = max(scale, float(source_scale)) + if not np.isfinite(scale): + stats['valid'] = False + return _UNIT_BOX, stats + if max_passes <= 0: + return _UNIT_BOX, stats + tolerance = (None if resolution is None else + np.broadcast_to(np.asarray(resolution, dtype=float), (4,))) + if tolerance is not None and (not np.isfinite(tolerance).all() + or np.any(tolerance < 0.0)): + stats['valid'] = False + return _UNIT_BOX, stats + + coefficient_count = (int(np.prod(first.shape[:2])) + * int(np.prod(second.shape[:2])) * 3) + work_unit = max(1, (coefficient_count + 127) // 128) + if charge is not None and not charge(work_unit): + stats['denied'] = True + return _UNIT_BOX, stats + stats['work'] += work_unit + net = psi_vector_net(first, second) + if not np.isfinite(net).all(): + stats['valid'] = False + return _UNIT_BOX, stats + margin = _HULL_MARGIN_K_EPS * scale + box = np.array(_UNIT_BOX) + for _ in range(max_passes): + previous = box.copy() + stats['passes'] += 1 + for axis in range(4): + if charge is not None and not charge(work_unit): + stats['denied'] = True + return tuple(map(tuple, box)), stats + stats['axes'] += 1 + stats['work'] += work_unit + coefficients = np.moveaxis(net, axis, 0).reshape(net.shape[axis], -1, 3) + lower, upper = coefficients.min(axis=1), coefficients.max(axis=1) + interval = [0.0, 1.0] + for component in range(3): + part = _zero_hull_interval(lower[:, component], upper[:, component], margin) + if part is None: + stats['empty'] = True + return None, stats + interval[0] = max(interval[0], part[0]) + interval[1] = min(interval[1], part[1]) + if interval[0] > interval[1]: + stats['empty'] = True + return None, stats + + low, high = box[axis] + width = high - low + if width <= 64.0 * _EPS * max(1.0, abs(low), abs(high)): + continue + new_low = max(low, float(np.nextafter(low + interval[0] * width, -np.inf))) + new_high = min(high, float(np.nextafter(low + interval[1] * width, np.inf))) + if new_low <= low and new_high >= high: + continue + # Preserve original cancellation error and accumulate the + # additional error from restriction of the current coefficients. + margin += (4.0 * _EPS * (net.shape[axis] - 1) + * float(np.max(np.abs(net)))) + net = restrict_net_axis_v(net, axis, new_low, new_high, low, high) + box[axis] = new_low, new_high + if tolerance is not None and np.all(box[:, 1] - box[:, 0] <= tolerance): + break + if np.max(np.abs(box - previous)) <= 64.0 * _EPS: + break + return tuple(map(tuple, box)), stats + + +def restrict_source_pair(first, second, local_box): + """Restrict both homogeneous sources using a shared local STUV box.""" + if local_box is None: + return None + result = [] + for surface, offset in ((first, 0), (second, 2)): + current = np.asarray(surface, dtype=float) + for axis in range(2): + low, high = local_box[offset + axis] + current = restrict_net_axis_v(current, axis, low, high, 0.0, 1.0) + result.append(current) + return tuple(result) diff --git a/mmcore/numeric/intersection/ssx/_ssx_trace_coverage.py b/mmcore/numeric/intersection/ssx/_ssx_trace_coverage.py new file mode 100644 index 00000000..2622b21e --- /dev/null +++ b/mmcore/numeric/intersection/ssx/_ssx_trace_coverage.py @@ -0,0 +1,208 @@ +"""Paired geometric coverage by tangential paths already found by SSX. + +These optional shortcuts avoid rediscovering an existing path. A point +requires the same segment location in parameters and XYZ. A cell requires +both restricted source control graphs to fit one existing paired segment; +an empty residual may also resolve a remainder without registrations. +Clipping only narrows a copy, and never changes queue ownership. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +import numpy as np + +from mmcore.numeric.intersection.ssx._ssx_bernstein_clip import ( + clip_residual_box, + restrict_source_pair, +) +from mmcore.numeric.intersection.ssx._ssx_polyline import point_matches_polyline + +_EPS = np.finfo(float).eps + + +def _segment_graph_bounds(parameters, xyz, q0, q1, x0, x1, + parameter_roundoff=0.): + direction = x1 - x0 + denominator = float(direction @ direction) + direction_error = np.where(x1 == x0, 0., _EPS * (abs(x0) + abs(x1))) + den_error = (8 * _EPS * denominator + + 2 * np.sum(abs(direction) * direction_error) + + float(direction_error @ direction_error)) + if not np.isfinite(denominator) or denominator <= den_error: + return None + delta = xyz - x0 + delta_error = np.where(xyz == x0, 0., _EPS * (abs(xyz) + abs(x0))) + first_terms = delta * direction + first_error = (8 * _EPS * np.sum(abs(first_terms), axis=1) + + np.sum(delta_error * abs(direction) + + abs(delta) * direction_error, axis=1)) + first = np.sum(first_terms, axis=1) + alpha = first / denominator + alpha_error = (first_error + abs(alpha) * den_error) / (denominator - den_error) + # Distance to a closed segment is convex. Distances at every source + # control therefore bound its whole graph, including a CAD-sized + # extension into an endpoint cap. + clamped = np.clip(alpha, 0., 1.) + perpendicular = delta - clamped[:, None] * direction + perp_error = (delta_error + abs(clamped[:, None]) * direction_error + + alpha_error[:, None] * (abs(direction) + direction_error) + + 8 * _EPS * (abs(delta) + abs(clamped[:, None] * direction))) + xyz_bound = float(np.max(np.linalg.norm(perpendicular, axis=1) + + np.linalg.norm(perp_error, axis=1))) + displacement = q1 - q0 + param_residual = parameters - (q0 + alpha[:, None] * displacement) + param_error = (parameter_roundoff + alpha_error[:, None] * abs(displacement) + + 16 * _EPS * (abs(parameters) + abs(q0) + + abs(alpha[:, None] * displacement))) + # Parameter residuals before clamping are affine Bernstein graphs. + # Add an upper bound for the endpoint clamping correction rather than + # treating clamped control parameters as a new affine mapping. + excess = max(0., -float(np.min(alpha - alpha_error)), + float(np.max(alpha + alpha_error)) - 1.) + return (xyz_bound, np.max(abs(param_residual) + param_error, axis=0) + + excess * abs(displacement)) + + +def _polynomial_surface(surface): + net = np.asarray(surface) + return bool(net.ndim == 3 and net.shape[-1] == 4 + and min(net.shape[:2]) >= 2 and np.isfinite(net).all() + and net[0, 0, 3] != 0. + and np.all(net[..., 3] == net[0, 0, 3])) + + +def _control_graph(surface, box): + xyz = (surface[..., :3] / surface[..., 3:]).reshape(-1, 3) + u = np.linspace(*box[0], surface.shape[0]) + v = np.linspace(*box[1], surface.shape[1]) + parameters = np.stack(np.meshgrid(u, v, indexing='ij'), axis=-1).reshape(-1, 2) + error = 16 * _EPS * np.array([abs(a) + abs(b) for a, b in box]) + return parameters, xyz, error + + +@dataclass +class _Trace: + parameters: np.ndarray + xyz: np.ndarray + low: np.ndarray + high: np.ndarray + candidates: list + + +class TangentialTraceCoverage: + """Cache only published tangent paths, sharing the caller's work cap.""" + + def __init__(self, atol, parameter_tolerance, source_scale, + charge: Callable[[int], bool]): + self.atol = float(atol) + self.ptol = np.asarray(parameter_tolerance, dtype=float) + self.source_scale = float(source_scale) + self.charge = charge + self.traces: list[_Trace] = [] + self._seen_fragments = 0 + + def update(self, fragments): + """Account for new immutable paths without rescanning old samples.""" + fresh = fragments[self._seen_fragments:] + self._seen_fragments = len(fragments) + for fragment in fresh: + if not fragment.tangential: + continue + parameters = np.asarray(fragment.stuv_path, dtype=float) + xyz = np.asarray(fragment.xyz_path, dtype=float) + if (parameters.shape != (len(xyz), 4) or len(xyz) < 2 + or not np.isfinite(parameters).all() or not np.isfinite(xyz).all()): + continue + if not self.charge(max(1, (len(xyz) * 7 + 127) // 128)): + return + candidates = [] + bounds = _segment_graph_bounds(parameters, xyz, parameters[0], + parameters[-1], xyz[0], xyz[-1]) + if (bounds is not None and bounds[0] < self.atol + and np.all(bounds[1] < self.ptol)): + # Charge the approximation of a full straight span once; + # it cannot widen the accepted tube by another tolerance. + candidates.append((0, len(xyz) - 1, self.atol - bounds[0], + self.ptol - bounds[1])) + candidates.extend((i, i + 1, self.atol, self.ptol) + for i in range(len(xyz) - 1)) + self.traces.append(_Trace( + parameters, xyz, + parameters.min(axis=0), parameters.max(axis=0), candidates)) + + def covers_points(self, parameters, xyz): + parameters, xyz = np.asarray(parameters), np.asarray(xyz) + if (len(parameters) == 0 or parameters.shape != (len(xyz), 4) + or not np.isfinite(parameters).all() or not np.isfinite(xyz).all()): + return False + for point4, point3 in zip(parameters, xyz): + covered = False + for trace in self.traces: + if np.any(point4 < trace.low - self.ptol) or np.any(point4 > trace.high + self.ptol): + continue + if not self.charge(max(1, (len(trace.xyz) * 7 + 127) // 128)): + return False + # A parameter least-squares projection alone can miss a + # different segment fraction satisfying both bounds. + if point_matches_polyline(point4, point3, trace.parameters, + trace.xyz, self.ptol, self.atol): + covered = True + break + if not covered: + return False + return True + + def covers_cell(self, first, second, box, *, allow_empty=False): + """Resolve an existing trace's remainder without altering its cell. + + ``allow_empty`` is restricted to remainder cells without registered + contacts. A source-scaled empty residual hull then excludes another + equation branch; it never changes public CAD contact membership or + contradicts a registration the caller already validated. + """ + if not self.traces or not (_polynomial_surface(first) and _polynomial_surface(second)): + return False + bounds = np.asarray(box) + possible = [trace for trace in self.traces + if np.all(bounds[:, 1] >= trace.low - self.ptol) + and np.all(bounds[:, 0] <= trace.high + self.ptol)] + if not possible: + return False + necessary, stats = clip_residual_box(first, second, source_scale=self.source_scale, + charge=self.charge) + if (allow_empty and necessary is None and stats['empty'] + and stats['valid'] and not stats['denied']): + return True + if necessary is None or not stats['valid'] or stats['denied']: + return False + if not self.charge(max(1, (2 * (first.size + second.size) + 127) // 128)): + return False + restricted = restrict_source_pair(first, second, necessary) + global_box = tuple((lo + a * (hi - lo), lo + b * (hi - lo)) + for (a, b), (lo, hi) in zip(necessary, box)) + graphs = [_control_graph(restricted[0], global_box[:2]), + _control_graph(restricted[1], global_box[2:])] + cost = max(1, (7 * sum(len(g[0]) for g in graphs) + 127) // 128) + for trace in possible: + for left, right, allowed_xyz, allowed_parameters in trace.candidates: + valid = True + for index, (parameters, xyz, error) in enumerate(graphs): + axes = slice(2 * index, 2 * index + 2) + q0, q1 = trace.parameters[left, axes], trace.parameters[right, axes] + if (np.any(parameters < np.minimum(q0, q1) - allowed_parameters[axes]) + or np.any(parameters > np.maximum(q0, q1) + allowed_parameters[axes])): + valid = False + break + if not self.charge(cost): + return False + measured = _segment_graph_bounds(parameters, xyz, q0, q1, + trace.xyz[left], trace.xyz[right], error) + if (measured is None or measured[0] > allowed_xyz + or np.any(measured[1] > allowed_parameters[axes])): + valid = False + break + if valid: + return True + return False diff --git a/tests/test_ssx5_tangent_regular_remainder.py b/tests/test_ssx5_tangent_regular_remainder.py new file mode 100644 index 00000000..4468a650 --- /dev/null +++ b/tests/test_ssx5_tangent_regular_remainder.py @@ -0,0 +1,72 @@ +"""Finding a tangent component must preserve the regular remainder of a cell.""" +from math import comb + +import numpy as np +import pytest + +from examples.ssx.ssx5_analytic_audit import distances_to_polylines +from mmcore.numeric._bezier_common import eval_surface +from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx + + +def _line_and_circles(circles): + # Independent polynomial construction: y=.5 is a repeated component; + # each disjoint circle is a regular zero of its own quadratic factor. + power = {(0, 0): .75, (0, 1): -3., (0, 2): 3.} + for x, y, radius in circles: + factor = {(0, 0): x*x+y*y-radius*radius, (1, 0): -2*x, + (2, 0): 1., (0, 1): -2*y, (0, 2): 1.} + product = {} + for (i, j), a in power.items(): + for (k, l), b in factor.items(): + product[i+k, j+l] = product.get((i+k, j+l), 0.)+a*b + power = product + nu = max(i for i, _ in power) + nv = max(j for _, j in power) + surface = np.empty((nu+1, nv+1, 3)) + for i in range(nu+1): + for j in range(nv+1): + z = sum(value*comb(i, k)/comb(nu, k)*comb(j, l)/comb(nv, l) + for (k, l), value in power.items() if k <= i and l <= j) + surface[i, j] = [i/nu, j/nv, z] + plane = np.array([[[s, t, 0.] for t in (0., 1.)] for s in (0., 1.)]) + return surface, plane + + +@pytest.mark.parametrize('variant', ['identity', 'swap', 'reverse_v', 'two_loops']) +def test_tangent_line_through_cell_center_preserves_every_regular_loop(variant): + atol = 1e-3 + circles = [(.3125, .1875, .125)] + if variant == 'two_loops': + circles.append((.75, .8125, .09375)) + first, second = _line_and_circles(circles) + if variant == 'swap': + first, second = second, first + elif variant == 'reverse_v': + first, second = first[:, ::-1].copy(), second[:, ::-1].copy() + result = bez_ssx(first, second, atol=atol, rational=False) + branches = result['branches'] + assert len(branches) == len(circles)+1, result['status'] + lines = [b for b in branches if not b.closed] + loops = [b for b in branches if b.closed] + assert len(lines) == 1 and len(loops) == len(circles) + line = np.column_stack((np.linspace(0., 1., 1025), + np.full(1025, .5), np.zeros(1025))) + assert distances_to_polylines(line, [lines[0].curve[1]]).max() <= 4*atol + for x, y, radius in circles: + theta = np.linspace(0., 2*np.pi, 2049) + reference = np.column_stack((x+radius*np.cos(theta), + y+radius*np.sin(theta), np.zeros_like(theta))) + candidates = [np.asarray(b.curve[1]) for b in loops + if np.max(np.abs(np.linalg.norm( + np.asarray(b.curve[1])[:, :2]-[x, y], axis=1)-radius)) <= 4*atol] + assert len(candidates) == 1 + assert distances_to_polylines(reference, candidates).max() <= 4*atol + length = np.linalg.norm(np.diff(candidates[0], axis=0), axis=1).sum() + assert abs(length-2*np.pi*radius) <= 8*np.pi*atol + for branch in branches: + for q, point in zip(*branch.curve): + a = eval_surface(first, *q[:2], rational=False) + b = eval_surface(second, *q[2:], rational=False) + assert np.linalg.norm(a-point) <= atol + assert np.linalg.norm(b-point) <= atol diff --git a/tests/test_ssx_bernstein_clip.py b/tests/test_ssx_bernstein_clip.py new file mode 100644 index 00000000..3ce19ef3 --- /dev/null +++ b/tests/test_ssx_bernstein_clip.py @@ -0,0 +1,121 @@ +"""Necessary residual boxes preserve known geometry at CAD resolution.""" +import numpy as np +import pytest + +from examples.ssx.ssx5_analytic_audit import case_components, graph_pair +from mmcore.numeric.intersection.ssx import _ssx_bernstein_clip as clip + + +def _homogeneous(surface): + return np.concatenate((surface, np.ones(surface.shape[:2] + (1,))), axis=-1) + + +@pytest.mark.parametrize('swap', [False, True]) +def test_closed_circle_samples_remain_in_necessary_box(swap): + first, second = map(_homogeneous, graph_pair(case_components('one_circle'))) + angle = np.linspace(0., 2*np.pi, 2049) + st = np.column_stack((.5 + .25*np.cos(angle), .5 + .25*np.sin(angle))) + uv = (st + .25)/1.5 + roots = np.column_stack((st, uv)) + if swap: + first, second = second, first + roots = roots[:, [2, 3, 0, 1]] + box, stats = clip.clip_residual_box(first, second) + assert box is not None and stats['valid'] and not stats['denied'] + bounds = np.asarray(box) + # This is a containment check, not a demand for precise returned digits. + assert np.all(roots >= bounds[:, 0]) + assert np.all(roots <= bounds[:, 1]) + + +def test_tangent_corner_contracts_both_source_hulls_to_cad_point(): + graph, plane = map(_homogeneous, graph_pair(case_components('two_circles'))) + scale = clip.residual_coordinate_scale(plane, graph) + # The first circle's rightmost point is (.4375,.5). This child lies + # outside that circle and away from the other circle; only the corner + # contact remains. Plane and graph use different parameter charts. + owner = ((11/24, .48), (.48, .5), (.4375, .46), (.47, .5)) + first, second = clip.restrict_source_pair(plane, graph, owner) + box, stats = clip.clip_residual_box(first, second, source_scale=scale) + assert box is not None and stats['valid'] and not stats['denied'] + restricted = clip.restrict_source_pair(first, second, box) + point = np.array([.4375, .5, 0.]) + for source in restricted: + xyz = source[..., :3]/source[..., 3:] + assert np.linalg.norm(xyz-point, axis=-1).max() <= 1e-3 + + +def test_margin_uses_xyz_and_preserves_original_operand_scale(): + plane = np.array([[[s, t, 0., 1.] for t in (0., 1.)] for s in (0., 1.)]) + nearby = plane.copy() + nearby[..., 2] = 1e-15 + # A z-only error scale would incorrectly exclude a cancellation-sized + # residual despite order-one coordinates in the supplied sources. + box, stats = clip.clip_residual_box(plane, nearby) + assert box is not None and not stats['empty'] + tiny_plane, tiny_nearby = plane.copy(), nearby.copy() + tiny_plane[..., :3] *= 1e-6 + tiny_nearby[..., :3] *= 1e-6 + tiny_nearby[..., 2] = 1e-15 + box, stats = clip.clip_residual_box(tiny_plane, tiny_nearby, source_scale=2.) + assert box is not None and not stats['empty'] + + +def test_disjoint_residual_hull_returns_empty_without_claiming_a_root(): + first, second = map(_homogeneous, graph_pair(case_components('one_circle'))) + second[..., 2] = 2. + box, stats = clip.clip_residual_box(first, second) + assert box is None and stats['empty'] + + +def test_setup_denial_precedes_residual_allocation(monkeypatch): + first, second = map(_homogeneous, graph_pair(case_components('one_circle'))) + def forbidden(*args): + raise AssertionError('residual allocated after setup was denied') + monkeypatch.setattr(clip, 'psi_vector_net', forbidden) + box, stats = clip.clip_residual_box(first, second, charge=lambda n: False) + assert box == ((0., 1.),)*4 + assert stats['denied'] and stats['work'] == 0 + + +def test_early_work_stop_returns_a_coarse_box_containing_the_contact(): + plane = np.array([[[s, t, 0., 1.] for t in (0., 1.)] for s in (0., 1.)]) + crossing = plane.copy() + crossing[..., 2] = [[-.5, .5], [-.5, .5]] + remaining = [2] + def charge(n): + if remaining[0] < n: + return False + remaining[0] -= n + return True + box, stats = clip.clip_residual_box(plane, crossing, charge=charge) + assert stats['denied'] + roots = np.array([[s, .5, s, .5] for s in np.linspace(0., 1., 101)]) + bounds = np.asarray(box) + assert np.all(roots >= bounds[:, 0]) and np.all(roots <= bounds[:, 1]) + + +@pytest.mark.parametrize('swap', [False, True]) +def test_nonuniform_weights_keep_paired_roots_in_different_charts(swap): + # S1(s,t)=(2*s/(1+s),t,0), represented homogeneously with weights + # 1 and 2. S2(a,b)=(.4+.2*a,.5,b-.5). Restricting the second chart + # makes ignoring the first chart's weights exclude genuine roots. + first = np.array([ + [[0., 0., 0., 1.], [0., 1., 0., 1.]], + [[2., 0., 0., 2.], [2., 2., 0., 2.]], + ]) + second = np.array([[[u, .5, v-.5, 1.] for v in (0., 1.)] + for u in (.4, .6)]) + a = np.linspace(0., 1., 257) + x = .4+.2*a + s = x/(2.-x) + roots = np.column_stack((s, np.full(len(s), .5), a, + np.full(len(s), .5))) + if swap: + first, second = second, first + roots = roots[:, [2, 3, 0, 1]] + box, stats = clip.clip_residual_box(first, second) + assert box is not None and stats['valid'] and not stats['denied'] + bounds = np.asarray(box) + assert np.all(roots >= bounds[:, 0]) + assert np.all(roots <= bounds[:, 1]) diff --git a/tests/test_ssx_tangent_remainder.py b/tests/test_ssx_tangent_remainder.py new file mode 100644 index 00000000..7d87d26b --- /dev/null +++ b/tests/test_ssx_tangent_remainder.py @@ -0,0 +1,63 @@ +"""Tracing one tangency must retain the search for other curve components.""" +from math import comb + +import numpy as np + +from examples.ssx.ssx5_analytic_audit import distances_to_polylines +from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx + + +def _line_and_circle(): + # Independent polynomial construction: z=3*(t-.5)^2*circle(s,t). + # The circle lies strictly below, and is disjoint from, the tangent line. + cx, cy, radius = .3125, .1875, .125 + circle = {(0, 0): cx*cx + cy*cy - radius*radius, + (1, 0): -2*cx, (2, 0): 1., (0, 1): -2*cy, (0, 2): 1.} + line = {(0, 0): .25, (0, 1): -1., (0, 2): 1.} + powers = np.zeros((3, 5)) + for (i, j), a in circle.items(): + for (k, l), b in line.items(): + powers[i+k, j+l] += 3*a*b + graph = np.array([[[i/2, j/4, + sum(powers[k, l]*comb(i, k)/comb(2, k)*comb(j, l)/comb(4, l) + for k in range(i+1) for l in range(j+1))] + for j in range(5)] for i in range(3)]) + plane = np.array([[[s, t, 0.] for t in (0., 1.)] for s in (0., 1.)]) + angle = np.linspace(0., 2*np.pi, 2049) + ring = np.column_stack((cx+radius*np.cos(angle), cy+radius*np.sin(angle), + np.zeros_like(angle))) + tangent = np.column_stack((np.linspace(0., 1., 1001), np.full(1001, .5), np.zeros(1001))) + return graph, plane, ring, tangent + + +def test_tangent_line_does_not_consume_a_disjoint_regular_circle(): + graph, plane, ring, tangent = _line_and_circle() + result = bez_ssx(graph, plane, atol=1e-3, rational=False) + branches = result['branches'] + assert len(branches) == 2 + assert sum(branch.closed and branch.kind == 'transversal' for branch in branches) == 1 + assert sum(not branch.closed and branch.kind == 'tangential' for branch in branches) == 1 + paths = [np.asarray(branch.curve[1]) for branch in branches] + for reference in (ring, tangent): + assert distances_to_polylines(reference, paths).max() <= 1e-3 + for branch in branches: + points = np.asarray(branch.curve[1]) + circle_distance = abs(np.linalg.norm(points[:, :2] - [.3125, .1875], axis=1) - .125) + line_distance = abs(points[:, 1] - .5) + assert np.maximum(np.minimum(circle_distance, line_distance), abs(points[:, 2])).max() <= 1e-3 + + +def test_whole_known_tangent_line_can_resolve_before_depth_limit(monkeypatch): + import mmcore.numeric.intersection.ssx._bez_ssx5 as ssx + # Exercise the general engine, independently of its optional isoline + # reduction. The whole contact is already represented at this depth. + monkeypatch.setattr(ssx, '_try_isoline_intersection', lambda *args, **kwargs: None) + graph = np.array([[[s, t, z] for t, z in zip( + (0., .5, 1.), (.25, -.25, .25))] for s in (0., 1.)]) + plane = np.array([[[s, t, 0.] for t in (0., 1.)] for s in (0., 1.)]) + result = ssx.bez_ssx(graph, plane, atol=1e-3, rational=False, max_depth=0) + assert len(result['branches']) == 1 + assert result['branches'][0].kind == 'tangential' + assert 'depth_limit' not in result['status']['reasons'] + reference = np.column_stack((np.linspace(0., 1., 101), np.full(101, .5), np.zeros(101))) + assert distances_to_polylines(reference, [result['branches'][0].curve[1]]).max() <= 1e-3 diff --git a/tests/test_ssx_trace_coverage.py b/tests/test_ssx_trace_coverage.py new file mode 100644 index 00000000..51c3237f --- /dev/null +++ b/tests/test_ssx_trace_coverage.py @@ -0,0 +1,129 @@ +"""Existing trace coverage must preserve paired geometry and search budgets.""" +from types import SimpleNamespace + +import numpy as np +import pytest + +from mmcore.numeric.intersection.ssx._ssx_bernstein_clip import ( + residual_coordinate_scale, restrict_source_pair, +) +from mmcore.numeric.intersection.ssx._ssx_trace_coverage import TangentialTraceCoverage + + +def _fragment(low=0., high=1.): + parameters = np.array([[low, .5, low, .5], [high, .5, high, .5]]) + xyz = np.array([[low, .5, 0.], [high, .5, 0.]]) + return SimpleNamespace(stuv_path=parameters, xyz_path=xyz, tangential=True) + + +def _sources(): + graph = np.array([[[s, t, z, 1.] for t, z in zip( + (0., .5, 1.), (.25, -.25, .25))] for s in (0., 1.)]) + plane = np.array([[[s, t, 0., 1.] for t in (0., 1.)] for s in (0., 1.)]) + return graph, plane + + +def _coverage(fragments=None, charge=lambda amount: True): + coverage = TangentialTraceCoverage(1e-3, np.full(4, 1e-3), + residual_coordinate_scale(*_sources()), charge) + coverage.update([_fragment()] if fragments is None else fragments) + return coverage + + +def test_clipped_both_source_graphs_cover_existing_tangent_line(): + first, second = _sources() + box = ((.25, .75), (.5, .75), (.25, .75), (.5, .75)) + restricted = restrict_source_pair(first, second, box) + # Raw source hulls extend far outside the line tube. The residual + # forces both transverse parameters close to the existing line. + assert np.max(abs(restricted[1][..., 1] - .5)) > 1e-3 + assert _coverage().covers_cell(*restricted, box) + + +def test_same_xyz_on_a_different_partner_chart_is_not_covered(): + first, second = _sources() + local_box = ((.25, .75), (.5, .75), (.25, .75), (.5, .75)) + restricted = restrict_source_pair(first, second, local_box) + other_chart = ((.25, .75), (.5, .75), (.65, .95), (.5, .75)) + assert not _coverage().covers_cell(*restricted, other_chart) + + +def test_separate_trace_fragments_do_not_fill_their_gap(): + first, second = _sources() + box = ((.45, .55), (.5, .6), (.45, .55), (.5, .6)) + restricted = restrict_source_pair(first, second, box) + assert not _coverage([_fragment(0., .4), _fragment(.6, 1.)]).covers_cell(*restricted, box) + + +def test_whole_source_bulge_cannot_be_hidden_by_covered_endpoints(): + surface = np.array([[[s, t, z, 1.] for t in (.5, .50001)] + for s, z in zip((0., .5, 1.), (0., .1, 0.))]) + box = ((0., 1.), (.5, .50001), (0., 1.), (.5, .50001)) + assert not _coverage().covers_cell(surface, surface, box) + + +def test_nonuniform_weights_leave_the_cell_to_ordinary_search(): + first, second = _sources() + first[0] *= 2. + assert not _coverage().covers_cell(first, second, ((0., 1.),) * 4) + + +@pytest.mark.parametrize('parameters, xyz', [ + ([[.2, .5, .2, .5]], [[.8, .5, 0.]]), + ([[.2, .5, .8, .5]], [[.2, .5, 0.]]), + ([], []), +]) +def test_known_points_require_one_paired_segment_location(parameters, xyz): + assert not _coverage().covers_points(parameters, xyz) + + +def test_unknown_seed_keeps_the_tangency_discovery_path_enabled(): + coverage = _coverage() + assert coverage.covers_points([[.2, .5, .2, .5]], [[.2, .5, 0.]]) + assert not coverage.covers_points( + [[.2, .5, .2, .5], [.8, .2, .8, .2]], + [[.2, .5, 0.], [.8, .2, 0.]]) + + +def test_denied_optional_coverage_does_not_retire_a_cell(monkeypatch): + from mmcore.numeric.intersection.ssx import _ssx_bernstein_clip as clip + enabled = [True] + coverage = _coverage(charge=lambda amount: enabled[0]) + enabled[0] = False + first, second = _sources() + def forbidden(*args): + raise AssertionError('residual setup after work denial') + monkeypatch.setattr(clip, 'psi_vector_net', forbidden) + assert not coverage.covers_cell(first, second, ((0., 1.),) * 4) + + +@pytest.mark.parametrize('extension, expected', [(5e-4, True), (2e-3, False)]) +def test_closed_segment_endpoint_caps_use_cad_distance(extension, expected): + first, second = _sources() + box = ((.2-extension, .5), (.5, .75), (.2-extension, .5), (.5, .75)) + restricted = restrict_source_pair(first, second, box) + coverage = _coverage([_fragment(.2, .8)]) + assert coverage.covers_cell(*restricted, box) is expected + + +def test_empty_residual_cannot_overrule_a_registered_contact(): + first, second = _sources() + # The XY equations force t=v strictly above .5; the graph's height + # is then positive. The box begins within the known path's tolerance + # neighborhood but does not carry another equation branch. + box = ((0., .01), (.5, .515625), (0., .01), (.5005, .53125)) + restricted = restrict_source_pair(first, second, box) + coverage = _coverage() + assert not coverage.covers_cell(*restricted, box) + assert coverage.covers_cell(*restricted, box, allow_empty=True) + + +def test_point_match_searches_the_shared_parameter_and_xyz_interval(): + fragment = SimpleNamespace( + stuv_path=np.array([[0., 0., 0., 0.], [1., 0., 1., 0.]]), + xyz_path=np.array([[0., 0., 0.], [10., 0., 0.]]), tangential=True) + # The parameter least-squares fraction is .5004; its XYZ error is + # above atol. Fraction .5008 satisfies all four parameter bounds and + # the world-space bound simultaneously. + coverage = _coverage([fragment]) + assert coverage.covers_points([[.5, .0008, .5008, .0008]], [[5.008, .0008, 0.]]) From 37be77504fda8c7a7d8be4fe37bf3041297e84dd Mon Sep 17 00:00:00 2001 From: sth-v Date: Wed, 23 Sep 2026 05:08:30 +0300 Subject: [PATCH 7/8] Resolve complete planar overlap regions before tangent remainder search --- .../2026-09-14-ssx-tolerance-repair.md | 8 ++ mmcore/numeric/intersection/ssx/_bez_ssx5.py | 6 +- .../numeric/intersection/ssx/_ssx5_overlap.py | 67 ++++++++++---- tests/test_ssx_planar_overlap_clipping.py | 89 +++++++++++++++++++ 4 files changed, 151 insertions(+), 19 deletions(-) diff --git a/docs/superpowers/reviews/2026-09-14-ssx-tolerance-repair.md b/docs/superpowers/reviews/2026-09-14-ssx-tolerance-repair.md index d7b3bb2b..70661125 100644 --- a/docs/superpowers/reviews/2026-09-14-ssx-tolerance-repair.md +++ b/docs/superpowers/reviews/2026-09-14-ssx-tolerance-repair.md @@ -46,6 +46,14 @@ control graphs fit the same published segment at the CAD tolerance. This uses floating-point geometry internally and adds no rational public diagnostics. Failed or interrupted coverage checks leave the ordinary search available. +Convex planar bilinear pairs use the existing polygon clipper and overlap +region assembler before the general search. Such a region represents the +whole pair; its two-dimensional interior must not be repeatedly searched as +a one-dimensional tangent remainder. The early return requires the checked +whole-pair planar result, including paired rim geometry. Unsupported charts +retain the general path, and an output limit cannot publish a region with +missing boundary references. + Tangent paths use half-atol chord sampling so reuse is consistent with their geometric accuracy. A later Phi-seeded retrace is classified over its whole path before assembly, preventing a regular-backend retrace from replacing the diff --git a/mmcore/numeric/intersection/ssx/_bez_ssx5.py b/mmcore/numeric/intersection/ssx/_bez_ssx5.py index 677e27cc..f48b76b4 100644 --- a/mmcore/numeric/intersection/ssx/_bez_ssx5.py +++ b/mmcore/numeric/intersection/ssx/_bez_ssx5.py @@ -309,11 +309,11 @@ def _distance_net_lower_bound(S1, S2, F, rational): def _try_isoline_intersection(first, second, atol, budget): - """Use a numerical one-variable reduction when the surface charts allow it.""" + """Use planar or one-variable reductions when the surface charts allow it.""" from mmcore.numeric.intersection.ssx._ssx_isolines import try_isoline_ssx, try_plane_coincidence from mmcore.numeric.intersection.ssx._ssx_matched_isolines import try_matched_isoline_ssx - from mmcore.numeric.intersection.ssx._ssx5_overlap import try_planar_boundary_intersection - result = try_planar_boundary_intersection(first, second, atol, budget) + from mmcore.numeric.intersection.ssx._ssx5_overlap import try_planar_intersection + result = try_planar_intersection(first, second, atol, budget) if result is not None or budget.exhausted: return result result = try_plane_coincidence(first, second, atol, budget) diff --git a/mmcore/numeric/intersection/ssx/_ssx5_overlap.py b/mmcore/numeric/intersection/ssx/_ssx5_overlap.py index 047c26cb..eefd2b34 100644 --- a/mmcore/numeric/intersection/ssx/_ssx5_overlap.py +++ b/mmcore/numeric/intersection/ssx/_ssx5_overlap.py @@ -147,7 +147,7 @@ def value(u, v): return float(np.max(np.linalg.norm(elevated-line, axis=1))/np.min(c[:, 3])) -def _planar_convex_rims(S1_h, S2_h, atol, charge, context, *, boundary_only=False): +def _planar_convex_rims(S1_h, S2_h, atol, charge, context, *, include_boundary_contacts=False): """Clip convex bilinear chart images before inverting their shared rims. Positive-weight planar bilinear charts map the parameter square onto @@ -237,8 +237,6 @@ def quad(p): area = abs(sum(cross(a, b) for a, b in zip(polygon, np.roll(polygon, -1, axis=0)))) if len(polygon) >= 3 and area > eps: - if boundary_only: - return None dimension = 2 else: dimension = 0 @@ -252,7 +250,7 @@ def quad(p): polygon = polygon[:1] context.update(origin=origin, scale=scale, basis=basis, polygon=polygon, dimension=dimension) - if not boundary_only or not len(polygon): + if not include_boundary_contacts or not len(polygon): return [] # Use normalized projected homogeneous coordinates for Newton. This @@ -352,24 +350,55 @@ def append_interval(lo, hi, left, right): return rims -def try_planar_boundary_intersection(S1_h, S2_h, atol, budget): - """Handle a shared edge or corner of regular convex planar charts.""" +def try_planar_intersection(S1_h, S2_h, atol, budget): + """Resolve convex planar chart intersections in dimensions zero to two. + + A verified positive-area polygon owns the whole pair's intersection. + Its interior is not a one-dimensional tangency remainder to subdivide. + Unsupported charts still return ``None`` to the general SSX search. + """ from mmcore.numeric.intersection.ssx._ssx_substrate import SSXBranch, SSXPoint context = {} try: rims = _planar_convex_rims( S1_h, S2_h, atol, lambda n: budget.charge_cells(n, 'planar_contact'), context, - boundary_only=True) + include_boundary_contacts=True) except _OverlapWorkStopped: return None if rims is None or not context: return None result = dict(branches=[], points=[], singularities=[], overlap_regions=[]) - for rim in rims: - branch = SSXBranch(curve=(rim['stuv'], rim['xyz']), - kind='overlap', overlap=True) - budget.append_output(result['branches'], branch, 'planar_contact') + + def retain_rims(): + for rim in rims: + branch = SSXBranch(curve=(rim['stuv'], rim['xyz']), + kind='overlap', overlap=True) + budget.append_output(result['branches'], branch, 'planar_contact') + + if context['dimension'] == 2: + from mmcore.nurbs._nurbs_param_tol import bez_surface_param_tolerance + ptol4 = np.array([*bez_surface_param_tolerance(S1_h, atol, rational=True), + *bez_surface_param_tolerance(S2_h, atol, rational=True)]) + assembled = assemble_overlap_regions( + S1_h, S2_h, atol=atol, ptol4=ptol4, + charge=lambda n: budget.charge_cells(n, 'planar_contact'), + _planar_data=(context, rims)) + if not assembled['regions'] or not assembled['planar_pair_covered']: + if budget.exhausted: + # Every rim already passed paired source and chord checks. + # A denied interior-witness step cannot erase that geometry. + retain_rims() + return result + return None + budget.extend_output(result['branches'], assembled['rim_branches'], 'planar_contact') + # A region's boundary indices refer to this complete rim list. + # Preserve partial rim geometry on output denial, without emitting + # a region whose references would point beyond the returned list. + if len(result['branches']) == len(assembled['rim_branches']): + budget.extend_output(result['overlap_regions'], assembled['regions'], 'planar_contact') + return result + retain_rims() if 'contact' in context: q, xyz, _ = context['contact'] budget.append_output(result['points'], SSXPoint(q, xyz), 'planar_contact') @@ -684,6 +713,7 @@ def assemble_overlap_regions( uncertified_spans=(), overlap_boxes=(), charge=None, + _planar_data=None, ): """Assemble certified SSXOverlapRegion entities from rim evidence. @@ -710,11 +740,16 @@ def _charge(n): "unmatched_intersection_branches": intersections, "planar_pair_covered": False, "covered": False} - planar = {} - try: - rims = _planar_convex_rims(S1_h, S2_h, atol, _charge, planar) - except _OverlapWorkStopped: - return empty + if _planar_data is None: + planar = {} + try: + rims = _planar_convex_rims(S1_h, S2_h, atol, _charge, planar) + except _OverlapWorkStopped: + return empty + else: + # The early planar entry in this module has already validated and + # sampled these exact sources. Reuse its work, not a second clip. + planar, rims = _planar_data if rims is None: # 8 edges x (coarse + dense) inversions, each a bounded GN solve. if not _charge(8 * 33 + 8 * 17): diff --git a/tests/test_ssx_planar_overlap_clipping.py b/tests/test_ssx_planar_overlap_clipping.py index 971eec58..6524254b 100644 --- a/tests/test_ssx_planar_overlap_clipping.py +++ b/tests/test_ssx_planar_overlap_clipping.py @@ -137,3 +137,92 @@ def test_planar_offset_is_judged_at_the_requested_cad_tolerance(): result = assemble_overlap_regions(first, second, atol=atol, ptol4=np.full(4, atol)) assert len(result['regions']) == 1 and result['planar_pair_covered'] _assert_paired_chords(result, first, second, atol) + + +@pytest.mark.parametrize('output_limit', [0, 2, 4, 5]) +def test_early_planar_region_never_references_unreturned_rims(output_limit): + from examples.ssx.bez_ssx5_case12 import S1, S2 + from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx + + result = bez_ssx(S1, S2, atol=1e-3, rational=False, + max_output_items=output_limit, max_postprocess_work=0) + count = sum(len(result[key]) for key in + ('branches', 'points', 'singularities', 'overlap_regions')) + assert count <= output_limit + for region in result['overlap_regions']: + for loop in region.boundary: + for index, _reverse in loop: + assert 0 <= index < len(result['branches']) + assert result['branches'][index].kind == 'overlap' + if output_limit < 5: + assert result['overlap_regions'] == [] + assert result['complete'] is False + else: + assert len(result['overlap_regions']) == 1 + assert len(result['branches']) == 4 + assert result['complete'] is True + assert result['status']['work']['postprocess_work'] == 0 + + +@pytest.mark.parametrize('allowance', [0, 25, 100]) +def test_early_planar_region_respects_shared_work_denial(allowance): + from examples.ssx.bez_ssx5_case12 import S1, S2 + from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx + + result = bez_ssx(S1, S2, atol=1e-3, rational=False, max_cells=allowance) + assert result['status']['work']['cells_processed'] <= allowance + assert result['complete'] is False + for region in result['overlap_regions']: + assert all(0 <= index < len(result['branches']) + for loop in region.boundary for index, _reverse in loop) + + +@pytest.mark.parametrize('kind', ['folded', 'nonplanar', 'higher_degree']) +def test_early_planar_entry_leaves_unsupported_charts_to_general_search(kind): + from mmcore.numeric.intersection.ssx._ssx5_overlap import try_planar_intersection + from mmcore.numeric.intersection.ssx._bez_ssx5 import _SSXSoftBudget + + points = np.array([[[0., 0., 0.], [0., 1., 0.]], + [[1., 0., 0.], [1., 1., 0.]]]) + first = _homogeneous(points, np.ones((2, 2))) + second = first.copy() + if kind == 'folded': + first[1] = first[1, ::-1] + elif kind == 'nonplanar': + first[1, 1, 2] = .1 + else: + first = np.stack((first[0], .5*(first[0]+first[1]), first[1])) + budget = _SSXSoftBudget(max_cells=1000, max_csx_calls=10) + assert try_planar_intersection(first, second, 1e-3, budget) is None + assert not budget.exhausted + + +def test_early_planar_witness_denial_retains_completed_rim_geometry(monkeypatch): + from mmcore.numeric.intersection.ssx import _ssx5_overlap as overlap + from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx + + first = np.array([[[0., 0., 0.], [0., 1., 0.]], + [[2., 0., 0.], [2., 1., 0.]]]) + second = first + [1., 0., 0.] + surfaces = [_homogeneous(points, np.ones((2, 2))) for points in (first, second)] + used = 0 + def measure(amount): + nonlocal used + used += amount + return True + context = {} + rims = overlap._planar_convex_rims( + *surfaces, 1e-3, measure, context, include_boundary_contacts=True) + assert context['dimension'] == 2 and len(rims) == 4 + + def forbidden(*args, **kwargs): + raise AssertionError('interior validation ran after work denial') + monkeypatch.setattr(overlap, '_interior_witness', forbidden) + result = bez_ssx(first, second, atol=1e-3, rational=False, max_cells=used) + assert result['complete'] is False + assert result['status']['work']['cells_processed'] <= used + assert 'work_budget' in result['status']['reasons'] + assert result['overlap_regions'] == [] + assert len(result['branches']) == 4 + assert all(branch.kind == 'overlap' for branch in result['branches']) + _assert_paired_chords({'rim_branches': result['branches']}, *surfaces, 1e-3) From b29584c71c5b0b3eb4188d390ea3293c3f603e6a Mon Sep 17 00:00:00 2001 From: sth-v Date: Wed, 23 Sep 2026 05:29:23 +0300 Subject: [PATCH 8/8] Check circle coverage against the established chord tolerance --- .../reviews/2026-09-23-ssx-test-contract.md | 10 ++++++++++ tests/test_ssx_tangent_remainder.py | 19 ++++++++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/reviews/2026-09-23-ssx-test-contract.md b/docs/superpowers/reviews/2026-09-23-ssx-test-contract.md index a8ec7559..77f650e2 100644 --- a/docs/superpowers/reviews/2026-09-23-ssx-test-contract.md +++ b/docs/superpowers/reviews/2026-09-23-ssx-test-contract.md @@ -72,6 +72,16 @@ inconsistency by moving the default-mode examples into exact mode. ## Verification +The new tangent-line/regular-circle regression initially imposed `atol` on +the circle's polyline coverage. General continuation already allowed +`2*atol` chord sagitta at the working baseline `76735f9`; this was an +inappropriate new assertion. Linux returned both complete, correctly typed +components with `1.568*atol` maximum circle-to-polyline distance. The circle +coverage check now uses the existing `2*atol` chord allowance. Straight-line +coverage and analytic vertex accuracy remain at `atol`, and both-source +vertex residual checks at `atol` are added. Component counts and kinds are +unchanged; the solver and its tolerances are unchanged by this correction. + The subsequent case11 check replaces its fixed 32-vertex minimum with bidirectional coverage of the supplied Rhino reference and paired-source residuals. The positive-gap endpoint-touch controls retain their two analytic diff --git a/tests/test_ssx_tangent_remainder.py b/tests/test_ssx_tangent_remainder.py index 7d87d26b..d1d7f87d 100644 --- a/tests/test_ssx_tangent_remainder.py +++ b/tests/test_ssx_tangent_remainder.py @@ -4,6 +4,7 @@ import numpy as np from examples.ssx.ssx5_analytic_audit import distances_to_polylines +from mmcore.numeric._bezier_common import eval_surface from mmcore.numeric.intersection.ssx._bez_ssx5 import bez_ssx @@ -31,20 +32,28 @@ def _line_and_circle(): def test_tangent_line_does_not_consume_a_disjoint_regular_circle(): + atol = 1e-3 graph, plane, ring, tangent = _line_and_circle() - result = bez_ssx(graph, plane, atol=1e-3, rational=False) + result = bez_ssx(graph, plane, atol=atol, rational=False) branches = result['branches'] assert len(branches) == 2 assert sum(branch.closed and branch.kind == 'transversal' for branch in branches) == 1 assert sum(not branch.closed and branch.kind == 'tangential' for branch in branches) == 1 - paths = [np.asarray(branch.curve[1]) for branch in branches] - for reference in (ring, tangent): - assert distances_to_polylines(reference, paths).max() <= 1e-3 + ring_path = next(np.asarray(b.curve[1]) for b in branches if b.closed) + line_path = next(np.asarray(b.curve[1]) for b in branches if not b.closed) + # General continuation has always allowed 2*atol chord sagitta. + # This checks the polyline representation, not the source accuracy of + # its vertices. A straight tangent line needs no sagitta allowance. + assert distances_to_polylines(ring, [ring_path]).max() <= 2*atol + assert distances_to_polylines(tangent, [line_path]).max() <= atol for branch in branches: points = np.asarray(branch.curve[1]) circle_distance = abs(np.linalg.norm(points[:, :2] - [.3125, .1875], axis=1) - .125) line_distance = abs(points[:, 1] - .5) - assert np.maximum(np.minimum(circle_distance, line_distance), abs(points[:, 2])).max() <= 1e-3 + assert np.maximum(np.minimum(circle_distance, line_distance), abs(points[:, 2])).max() <= atol + for q, point in zip(branch.curve[0], points): + assert np.linalg.norm(eval_surface(graph, *q[:2], rational=False) - point) <= atol + assert np.linalg.norm(eval_surface(plane, *q[2:], rational=False) - point) <= atol def test_whole_known_tangent_line_can_resolve_before_depth_limit(monkeypatch):