Skip to content

2D offset: TriWild's loop, and a smooth offset potential in place of the distance field - #1044

Closed
danielepanozzo wants to merge 61 commits into
uday-offset3dfrom
offset-2d-triwild-parity
Closed

2D offset: TriWild's loop, and a smooth offset potential in place of the distance field#1044
danielepanozzo wants to merge 61 commits into
uday-offset3dfrom
offset-2d-triwild-parity

Conversation

@danielepanozzo

@danielepanozzo danielepanozzo commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Stacked on uday-offset3d. Both dimensions.

Five things, in order: the 2D optimization phase moves onto TriWild's own loop; the offset stops being defined by the Euclidean distance and starts being defined by a smooth offset potential with a real gradient and Hessian; 3D is ported onto the same potential, with the machinery that existed only because the distance field had no gradient deleted; element quality and the offset residual are then optimized in alternating phases rather than jointly, in both dimensions; and the offset's defining field becomes a choice — the smooth potential or plain Euclidean distance — behind one JSON option.

All three 2D configs converge, with better margins than before (0.910× / 0.400× / 0.496× of tolerance). 3D runs end to end and does not converge; Part 4 says what is actually blocking it, which is not what Part 3 guessed.


Part 1 — the 2D loop is TriWild's loop

optimize_offset() ran its own fixed number of split/collapse/swap/smooth rounds with a whole-band sizing sweep after each: no stall detection, no it pre/it post passes, no consolidation between iterations, and a refine_sizing_around_worst() override nothing could ever call. It now calls mesh_improvement(max_iterations) and plugs into the shared engine through the same virtuals SimWild uses.

  • The input complex is no longer frozen. It is tracked exactly as TriWild tracks its input surface: held inside m_envelope, re-projected by the shared smoother, topologically preserved by substructure_link_condition. Freezing it bought nothing and cost two things — faces pinned between two frozen vertices could never reach stop_energy, and a band vertex sitting on the complex could never be moved off it, which pinned the loop's own convergence metric flat (measured in 3D: 119 of 544 band vertices, the metric identical to six significant figures at every iteration).
  • update_sizing_field() is gone, replaced by TriWild's stall-driven refine_sizing_around_worst on the shared helpers in wmtk/utils/SizingField.hpp, with every stuck_refine_* parameter under TriWild's name and default.
  • coarsen_pass was turned off here (it cost 39% of wall time and 27% of the offset surface) and is back on as of Part 3, with an acceptance bar of its own.
  • Renames to TriWild's spelling: optimization_iterationsmax_iterations (default 80), DEBUG_output → the base's debug_output, length/length_rell/lr.
  • Three new default-inert virtuals on TriOptimizerMesh: optimization_iteration_begin(), optimization_stalled(prev, cur), optimization_debug_checkpoint(). The offset overrides the second so refinement fires only when every unmet criterion is stuck, not when the max happens to be; and the third so consolidation happens whether or not a debug frame is written (with the write gated on debug_output, turning the flag on silently produced a different run — measured as converged-in-7 against not-converged-in-10).

Three defects the loop exposed, fixed here:

defect effect
it pre collapses with the length gate off, and the offset boundary has no envelope bounding it vertex_input: 2619 → 462 vertices in one pass, band max distance error 0.125 → 0.25, i.e. a band vertex collapsed onto the complex
the offset placement never rewrote the incident faces' cached m_quality collapse and swap decisions next to the offset were made on stale quality; per-pass log ended an iteration at 14.3× target while the recomputed value at the top of the next was 3.2e5×
the offset placement had no shape bar at all one smoothing pass took the worst AMIPS from 21.7 to 305430

Part 2 — the offset is now a level set of a smooth potential

Why

The offset was the level set of the exact Euclidean distance to the input complex, and a boundary vertex was placed by projecting it along (p − nearest)/|p − nearest| to distance delta.

That function is non-smooth exactly where the offset is hardest: gradient-discontinuous across the medial axis, undefined on the complex, kinked at every feature. It has no usable gradient, so the placement could not be an energy, so it could not go through the shared smoother, so the offset boundary was the one surface in the component bypassing every check the shared smoother makes. Every wart in project_offset_vertex traced back to that — the dist < 1e-12 bail-out, the ten-step bisection clamp, the golden-section tangential slide, and the whole normal-deviation apparatus that existed to detect features the distance field could not express.

What Φ is

The offset geometric contact potential from the high_order_contact subtree of ipc-toolkit, evaluated at a point against the input complex:

Phi(q) = sum over ACTIVE primitives P of  b(dist(q, P), dhat)
b(d, dhat) = -(d/dhat - 1)^2 * log(d/dhat)      for d < dhat, 0 otherwise

(ipc::NormalizedClampedLogBarrier.) "Active" is the OGC feasible-region rule: an edge is active at q when q projects into its interior, a vertex when q lies in its Voronoi wedge. Away from features exactly one primitive contributes, so Φ is a monotone function of the Euclidean distance alone. At a reentrant corner both adjacent edges contribute, their barriers add, and the level set bulges outward — that is the smoothing, and it is the point. Convex corners are the mirror image: one vertex contributes and the level set is the exact circular arc.

c is not a free parameter. It is Φ at perpendicular distance delta from a long straight edge, computed at construction through this very class, so it cannot drift from an analytic formula someone later has to keep in step.

How far the smoothed offset is from the Euclidean one

Measured in tests/test_offset_potential.cpp, at offset_dhat_factor = 2:

shape max deviation from the exact Euclidean offset
straight edge / square flat side 2.8e-17 (exact)
square convex corner (arc of radius δ) 1.1e-16 (exact)
isolated point (circle of radius δ) < 1e-9 δ (exact)
circle R = 1, δ = 0.1, 256-gon 0.075% of δ — which is the polygon's own sagitta, not the potential
reentrant right-angle notch, on the bisector +18.6% of δ, pushed outward
the same notch, far along one arm exact again

So the two offsets agree everywhere except inside the support of two or more primitives, i.e. at reentrant features, where the smoothed one rounds the crease the Euclidean offset has there. The Euclidean distance error is still computed and reported everywhere it was before, as a diagnostic, so every run says how far the two ended up apart.

What changed in the code

  • ipc-toolkit becomes a dependency. cmake/recipes/ipc_toolkit.cmake, pinned to wildmeshing/ipc-toolkit@5f301a25 — a new public repo seeded with the history of fsichetti/ipc-toolkit@upstream-merge, published with Federico's permission. Linked by the topological_offset component only, never by wmtk::toolkit, so the geogram/filib/TBB/tight_inclusion it brings never reach tetwild, triwild or simwild. Two build fixes live in the recipe and are documented there: abseil probes the compiler's default C++ standard (wmtk asks per target), and ipc-toolkit links its hash-map backends PRIVATE while leaking them through a public header.

  • OffsetPotential — Φ, ∇Φ, ∇²Φ, the calibration, the residual, and the broad phase. The only file in the toolkit that mentions ipc. Per-thread evaluation state, so the parallel smoothing pass can call it concurrently.

  • OffsetEnergy2Dw (Φ − c)², a polysolve::nonlinear::Problem in exactly the shape of ExactDistanceEnergy2D, with the Gauss-Newton Hessian (PSD by construction) as the default.

  • Two new default-inert hooks on TriOptimizerMesh: smoothing_extra_energy(vid) (null by default) and smoothing_envelope(vid) (the old expression by default). The offset returns the offset term for its boundary vertices and a null envelope for them — that surface is what the optimization exists to move, so a tube around its initial position would cap how far it can travel. project_offset_vertex, region_boundary_tangent and minimize_distance_along_tangent are deleted: an offset-boundary vertex is now smoothed by the same code path as every other vertex, with the same line search, the same exact inversion test and the same quality veto.

  • Termination is max(AMIPS / stop_energy, Φ-residual / (offset_residual_rel × target_distance)), with offset_residual_rel 0.1. The residual is a length: |Φ − c| over the slope of Φ at the level set on a flat stretch of input. Deliberately not |Φ − c| / ‖∇Φ(p)‖, which is the textbook Newton distance and is more accurate near the level set but badly wrong elsewhere — as p approaches the complex it tends to zero, so a vertex sitting on the complex would report a residual of zero and the loop would call it converged.

  • The support guard. dhat = offset_dhat_factor × target_distance, factor 2. Beyond dhat, Φ is identically zero with a zero gradient: a vertex out there is given no direction back and its residual saturates instead of growing. That is a hard error, checked on the band as constructed and once per iteration.

  • SmoothVertexOptions::quality_veto on OptimizerParameters — whether smoothing refuses a move that raises the worst incident element's quality. True (TriWild's and SimWild's value) stays the default; the offset sets it false. The veto's premise is that the solver only ever asks for a small move, so refusing costs almost nothing and the next pass retries from the same place — true of a TriWild surface vertex, which starts on the input and is held there by the envelope. The offset boundary is placed by minimising a term whose minimum can be most of target_distance away, so a large fraction of solved positions worsen some incident face and the veto holds the boundary back. Element shape is the split/collapse/swap passes' job and is one of the two convergence criteria, so it is not left unattended. (An earlier commit on this branch used a bisection back-off instead; it is superseded. The figure quoted for it, "0 of 6117 moves accepted", was wrong — an instrumentation bug of mine had deleted the offset_accepted increment. The real figure is 97.5% accepted.)

  • The criterion measures the offset ALONG the band, not only at its verticesoffset_residual_samples (k, default 3) interior points per band edge. This is the single most important correction in the PR. A vertex criterion cannot pin down an offset: a boundary can have every vertex exactly on the level set while zig-zagging or cutting corners between them, which reads as converged and is not the offset. It is the gap the paper's normal-deviation criterion covered and that removing it left open, and it was not hypothetical:

    measured at vertices along the edges
    vertex_input, band decimated to 12 segments 0.2% of δ 18%
    dragon, before 5.5% of δ 26.4%

    Both runs declared convergence on the first number. Sampling the edges is what the potential makes possible and the distance field did not: Φ is defined everywhere, so the offset can be measured anywhere along the band rather than only where the mesh happens to have a vertex. The same samples feed face_criterion_rel(), so the sizing field refines a band too coarse to represent the offset instead of letting it decimate — that feedback is what the old criterion could not provide.

  • An offset-boundary vertex is exempt from the containment envelope even when it also lies on another region's boundary. At a triple junction where a region curve terminates on the offset, the terminus is defined by the offset; holding it inside a tube around where construction left it holds the offset back. On vertex_input the three input points' rings were pinned at 0.75–0.90 × target_distance, and widening the envelope 10× cut the error 10.7×, which is what identified the cause. The region's curve is still contained everywhere else, and the split/collapse/swap containment checks are unchanged.

  • Two silent breakages for 0-/1-dimensional inputs, fixed. m_is_on_input was derived only from edges where an input-complex face meets a non-input one — empty by construction when the input has no faces. So on vertex_input the three input points were classified as ordinary offset-boundary vertices sitting at distance 0 from the complex, where Φ diverges, and the run's convergence metric read infinity. It is now also set geometrically. And nothing pinned those points: the envelope is built from segments, of which a point input has none, so smoothing_position_is_allowed now holds an input-complex vertex to the envelope tube pointwise — free to slide along the complex, pinned across it, which is TriWild's rule for a surface vertex.

  • Construction. The conservative growth predicate is restated on Φ. It turns out to need no new code and that is worth stating precisely: Φ has no Lipschitz constant (it diverges on the complex), but Phi(x) >= b(d(x)) because the closest feature is always feasible, and b(delta) = c, so d(p) + r < delta ⟹ Φ > c on the whole circlethe Euclidean bracket is the conservative form of the Φ test. The one behavioural change is the bail-out: a circle below the subdivision floor used to be declared outside, turning a resolution limit into a geometric verdict always in the same direction; it is now decided by evaluating Φ at its centre.

  • The viewer. phi_grid_resolution makes the app write the sampled potential as <output>_phi.vtu — Φ, the residual as a length, and the exact Euclidean distance, as vertex fields on a triangulated grid — and visualize_offset.py registers it as a layer with the level set drawn as an isoline. The offset is a level set of a field that exists everywhere and the result mesh only samples it along one curve, so without this a result that looks wrong cannot be diagnosed from the mesh alone.

Deleted

project_offset_vertex, region_boundary_tangent, minimize_distance_along_tangent, region_boundary_is_outside_envelope, compute_normal_deviation, edge_normal_deviation, offset_field_normal, max_offset_normal_deviation_at_vertex, collapse_edge_after's normal-deviation guard, the swap guard, m_collapse_nd_before / m_swap_nd_before / m_swap_verts, the WMTK_OFFSET_2D_NORMAL_DEVIATION switch, and most of SmoothTrace. Roughly 500 lines. The parameters those read (max_normal_deviation_deg, convergence_normal_deviation, the quadrics weights, sizing_*) stay — 3D still reads all of them, and Parameters is shared.


Status of the three 2D configs

Run at a 6-iteration cap unless noted. Read honestly: one converges, two do not, and the two failures are informative rather than mysterious.

config before this PR now
config before this PR now
--- --- ---
topological_offset_2d_dragon did not converge converged, max Φ residual 0.001296 ≤ 0.001447
topological_offset_2d_vertex_input did not converge converged, 0.023792 ≤ 0.025
topological_offset_2d diverged, then stopped on the support guard converged at iteration 13, 0.024454 ≤ 0.025

Element quality is healthy on all three (max metric 0.85–0.98 of stop_energy), and the coarsening pass then removes 10–20% of the faces without pushing anything over tolerance.

The support-guard stop on topological_offset_2d is gone. It was reproducible on the pre-PR binary too, so it was pre-existing rather than caused here; it cleared once the operation-acceptance work in Part 3 landed.

The residual now lives between the vertices, not at them — 0.30% of δ at the offset vertices against 9.8% along the edges on topological_offset_2d. That gap is exactly what the edge-sampled criterion exists to expose: a vertex-only criterion reports the first number and calls it converged.


Part 3 — 3D on the same potential, and operation acceptance

OffsetPotential becomes OffsetPotential<DIM>, one class for both dimensions. The 3D broad phase has to seed vf_set, ve_set and vv_set independently — 2D's vv_set() derives vertex candidates from edge endpoints and that derivation is 2D-only — and a set that is never seeded fails silently in the safe-looking direction, so the cube test checks each separately: outside a convex cube the three probes are claimed by exactly one face, one edge and one vertex, all exact to 1e-9 of δ. Both dimensions calibrate to the same c for the same δ.

Deleted, because the potential is what they stood in for: smooth_after_offset_surface() and most of Smooth.cpp, Quadrics.{hpp,cpp}, the whole normal-deviation family in smoothing, collapse and swap, and eight parameters. min_edge_length was derived from an angle and now has a direct min_edge_length_rel.

Operations answer to the criterion the smoother minimises

The smoother places an offset vertex by minimising w(Φ − c)²; every other operation now answers to that same measure, or it can undo in one collapse what the smoother spent an iteration achieving. Length gates cannot express this — they ask whether an edge is short relative to a sizing target, which is a statement about the mesh, while the criterion asks whether the surface is still the offset, which is a statement about the geometry.

Two bars, because the loop and the coarsening pass ask different questions:

  • in the loop — non-degrading, mirroring the AMIPS gate: refuse an operation that makes things worse. An absolute bar here freezes the mesh, since most of it is over tolerance early on — and in 2D it made a degenerate face permanent, because collapse is what removes one.
  • in coarsening (m_coarsen_mode) — absolute: both AMIPS and the offset residual inside tolerance afterwards. Coarsening is not working, it is banking.

Also in this part:

  • The AMIPS collapse gate is unconditional. It fired only for edges longer than the collapse target, so the majority of collapses had no quality gate at all. This is shared code — tetwild/triwild/simwild output moves.
  • Two default-inert virtuals: smoothing_extra_energy on TetOptimizerMesh, and optimization_bare_coarsen_passes on both bases. The bare it pre / it post collapse sweeps are unguarded and disabled for the offset; measured, it pre alone took a 3D offset surface from 1172 faces to 326 before the loop had run.
  • 3D's criterion now contains AMIPS, as 2D's always has, so both dimensions stop on max(amips, phi) and rank faces for refinement the same way.
  • Splits no longer freeze input-complex edges in either dimension; the envelope check is what should refuse a bad one, not a blanket prohibition.
  • preallocation_factor is declared in the spec. The offset already called set_preallocation_factor_from_json at both entry points but never listed the key, so the call was dead and the pool was always the default 6× — which the split pass exhausts, dropping rather than refusing the work.

New integration test

topological_offset_3d_convex127891.msh, a single-tag region convex to 0.01% of its diagonal. The two existing 3D cases are both double_sphere; a convex single-tag input is the case where the potential is best behaved, so a failure there is the mesh machinery. It found three on its first run.

3D status: runs end to end, manifold checks pass, does not converge. The element-quality blocker recorded here — 1928× stop_energy, with the unconditional AMIPS collapse gate as prime suspect — is superseded: Phase A now converges on quality in 1–2 iterations, and what remains is a band that cannot be refined. See Part 4.


Part 4 — the two criteria are optimized in TURN, not jointly

Driving element quality and the Φ residual from one loop put them in direct competition: split refined the band, the elements it produced were badly shaped, collapse removed them on quality grounds, and the next iteration started where the last one did. Measured over 20 iterations on topological_offset_3d_convex: the metric's best value came at iteration 5 and drifted upward for the remaining fifteen, every iteration running split 945 → 11973 vertices then collapse 11973 → 742.

Phase A is TetWild/TriWild and nothing else — same operations, gates, sizing field and stall refinement, no offset energy, no offset acceptance criterion, no offset stop metric. Its one addition is m_offset_envelope: a tube of one Φ tolerance around wherever Phase B last left the offset, rebuilt each round, so "do not degrade the offset" becomes a geometric constraint every operation already honours instead of a per-operation criterion that reached only the 2% of collapses touching the offset.

Phase B is smoothing to a fixed point against the offset energy — no topology, no envelope on the offset — followed by Φ-driven refinement of the shared sizing field. One field, two reasons to refine it.

ab_max_rounds, ab_phase_a_iterations, ab_smooth_max_passes, ab_smooth_tol, ab_offset_envelope_rel.

2D results (against the joint loop on the same tree):

config joint A/B mesh time
2d 0.978× tol, 13 it 0.910×, 7 rounds 11566 → 27293 V 70 → 509 s
2d_vertex_input 0.890×, 2 it 0.400×, 2 rounds 5099 → 4930 V 12 → 22 s
2d_dragon 0.849×, 3 it 0.496×, 2 rounds 20802 → 18438 V 74 → 131 s

Two of three reach a better residual on a smaller mesh. 2d pays 2.4× the elements for its margin, which is a real cost and not yet understood.

THE UNITS TRAP, twice. optimization_quality_stats() returns ABSOLUTE AMIPS against optimization_stop_metric() = stop_energy, and refine_sizing_around_worst derives its filter from the first then compares it against a per-face score in the same units. Returning a normalized metric (1.0 = converged) from Phase A put the filter at 100 against a worst element of 97: select_worst_cells returned nothing, no sizing was refined, no force-split was queued, and Phase A sat at bit-identical 91783.4 for all 20 iterations with the stall detector firing 19 times and doing nothing. Then the driver's own convergence check repeated it inverted and failed a Phase A that had converged. Both fixed by delegating to the base rather than recomputing.

Three defects the A/B split exposed

  • The Phi-stuck sizing floor. It clamped against max(min_sizing_scalar, min_edge_length/l) — a construction quantity — where every other stall refinement clamps against stuck_refine_min_scalar. In 2D that is 36× too high and fixing it is what made topological_offset_2d converge at all. In 3D the same change is not justified and was reverted: band edges average 2.05, chord accuracy needs h < sqrt(8 R tol) = 0.89, and the band floor already permits 0.518, while stuck_refine_min_scalar permits 0.0042 — a 475× refinement nothing asked for, which tripled the operations dropped to slot exhaustion for a worse residual.
  • No force-split in the Φ-stuck path. Lowering a sizing scalar only permits a split; at the floor the length gate never agrees. [force-split] fired 0 times in every offset run to date. Each stuck face's longest edge is now queued (2D queues the stuck edge directly, the offset being a polyline there).
  • The slot pool was never reclaimed. mesh_improvement consolidates at TetOptimizerMesh.cpp:97, after the stop test that breaks at line 89, so a Phase A meeting stop_energy on iteration 1 exits without consolidating and every later round inherits the spent pool. Steady state on 3d_convex: split takes the mesh to 25273 vertices, collapse returns it to 1481, and from round 4 the split pass reads executed: 9745 | success/fail 0/9745 with 9343 aborted on slot exhaustion — every split dropped, Φ bit-identical at 11.25 for four rounds. Now consolidated at the start of Phase B (not Phase A: refine_sizing_where_phi_is_stuck queues m_force_split_edges by vertex id and Phase A consumes them, so a consolidate in between would renumber them).

Where 3D actually stands

Φ per round on 3d_convex (stop_energy 100, 6 rounds), each column one build:

build Φ per round
control 10.03 11.86 11.91 11.91 11.91 11.91 frozen
+ floor + force-split 9.61 11.25 11.25 ×4 frozen
+ consolidate, floor 1e-3 15.78 11.50 11.35 11.39 10.64 10.39
+ consolidate, band floor 12.86 10.57 12.50 9.32 11.24 9.57

The loop is alive — rounds differ, stuck faces fall 1215 → 994, worst face score 948.7 → 65.5 — and still does not converge. None of the three defects is why.

Tetwild on the same geometry converges in 2 iterations. Extracting the 127891 body's boundary as an .obj and running tetwild on it: split 518 → 4560, collapse → 963 (79% removed), max energy 16.4 against stop 100, 0 slot warnings. So the split-then-collapse cycle is normal; what differs is that the offset repeats it forever at 33× and 96%.

The band is in a one-for-one stalemate. Over a whole run: 4655 splits produced an on-offset vertex, 4659 collapses removed one — net −4, which is why the band sits at ~1000 faces for six rounds while the sizing field asks for four times the resolution. Two candidate causes were tested and both ruled out:

  • split_high_valence_threshold refuses 664269 splits in the offset run against 0 in tetwild — but disabling it entirely changes nothing (Φ 10.74 → 11.67, band net −53).
  • Disabling collapse from round 2 is catastrophic: the mesh runs away to 586760 vertices / 2.8M tets / 12.8 GB and element quality diverges to 4.56e16 against stop 100, because split creates degenerate tets and collapse is what removes them. Kept as the diagnostic ab_no_collapse_after_first_round, documented as not a fix.

The worst face says the rest: its three corners sit at Euclidean distance 1.0008/1.0012/1.0009 from the input against target_distance 1.0 — the vertices are placed essentially exactly — and its centroid at 0.683. The entire residual is chord sag across faces 1.8–2.1 long on a surface of curvature radius ~1, pinned at the sizing floor. The band is ~3.5× too coarse and cannot refine.

On the scale of the example. 127891 is a thin plate: ambient diagonal 83.68, body 7.66 × 71.21 × 3.98, mean thickness 2.78. A target_distance of 1.0 is 1.2% of the ambient diagonal but 36% of the object's own thickness, and the offset shell's volume (≈1355) slightly exceeds the body's (1255). Meanwhile length_rel 0.05 puts the background target edge at 4.18 — over four times the entire offset distance. Re-running with length_rel 0.012 (l ≈ δ) does not help either (Φ 10.68 → 12.48), so resolution alone is not the lever.


Part 5 — offset_field: two definitions of the offset

OffsetPotential<DIM> is now an abstract base with two implementations, picked by a JSON option:

  • smooth (default) — SmoothOffsetPotential, today's Φ, moved into the subclass unchanged.
  • euclideanEuclideanOffsetPotential, the Euclidean distance d, boundary on d = target_distance. The exact offset.

OffsetEnergy needed no change: it already composed w(Φ − c)² from the field by the chain rule, so w(d − δ)² with exact derivatives falls out of supplying value = d, gradient = ∇d, hessian = ∇²d.

The derivatives are transcribed, not derived, from wmtk::optimization::ExactDistanceEnergy2D/3D (already on main). That class states the Hessian of ; ∇²(d²) = 2(∇d ∇dᵀ + d ∇²d) converts it, giving 0 on a face interior (d is linear), (I − t tᵀ − u uᵀ)/d on an edge interior and (I − u uᵀ)/d at a vertex. Same nearest_point_feature query backs both, so they agree by construction.

The two fields are monotone in opposite directions — Φ decays away from the complex, d grows — so a literal value() >= target_level() is right for one and silently inverted for the other. It appeared at both conservative construction tests in Spatial.cpp, where the inverted form would grow the band inside out; those now ask is_inside_offset().

Isolated input points are the degenerate segment (i,i) in both the BVH and the envelope, so a query near one returns an edge-interior hit with a meaningless direction. Those are demoted to the vertex case — topological_offset_2d_vertex_input is a point cloud and is made entirely of them.

Result: the Euclidean field converges on the point cloud and breaks the other two 2D examples.

config euclidean
2d_vertex_input converges, 0.4919× tolerance in 2 rounds
2d throws in round 3 — Phase A max element quality 1e+50
2d_dragon throws in round 6, likewise

The dragon shows the mechanism: the residual plateaus at ~5.5× while the stuck-segment count doubles every round — 169, 294, 455, 844, 1559 — each force-split, so the sizing field refines without bound and buys nothing, until Phase A is handed elements it cannot repair. That is the predicted trade, not a defect: the Euclidean offset has a genuine crease at every reentrant feature that no refinement resolves, so a criterion measuring distance to it can never come down there. Φ's outward bulge at those same features is exactly what makes its level set representable by a mesh. The point cloud converges because a point has no reentrant feature.

smooth stays the default.

Verification

  • test_offset_potential.cpp — 20 cases, 2757 assertions, all passing: FD gradient and Hessian against the analytic ones, the straight-edge calibration against its closed form, the four Euclidean comparisons above, the support/dhat_factor contract, the energy's derivatives and Hessian PSD-ness, and an end-to-end check that minimising w(Φ − c)² with the actual DenseNewton the smoother builds lands a free point on the level set from 0.5δ, 0.75δ, 1.3δ and 1.8δ.
  • wmtk_tests — 146400 assertions, 108 cases, all passing. wmtk_test_topological_offset — 987 assertions, 26 cases, all passing.
  • Integration suite, 27 configs, zero failures: all 15 simwild_* (2D and 3D), the three isotropic_remeshing_*, two qslim_*, four shortest_edge_collapse_*, and tetwild_double_sphere / tetwild_octocat / tetwild_sphere. The nine simwild_*_2d ones are the byte-identity gate that matters here, since they are what exercises the refactored smooth_vertex_2d. The run was stopped by hand before the triwild_* configs, at the request to skip the full sweep — the only FAILED line in that log is the SIGTERM that stopped it. Byte-identity is structural rather than incidental: smooth_reject_backoff_steps defaults to 0 and both new smoothing hooks default to the exact expressions they replaced, so TriWild and SimWild compose the same energy and take the same accept path as before.
  • The smooth path is byte-identical after the offset_field split, verified rather than argued: all three 2D examples reproduce their residuals to the last digit (0.02275915917273348, 0.010009848000283124, 0.0007170811461803449), and test_offset_potential passes including the circle/sphere/box/wedge deviation checks and the 2D-vs-3D calibration equality.
  • The byte-identity gate needs re-running. tetwild and simwild-3D were verified byte-identical against a baseline build after the smoothing hooks landed (8 configs, every mesh file identical, only wall-clock fields differing). Making the AMIPS collapse gate unconditional came later and is expected to move that output — it has not been re-measured.
  • Not run: triwild_*, the full ctest sweep.
  • data2 needs a bump. Pushed as branch offset-3d-convex; cmake/wmtk_data.cmake is pinned to it. It carries 127891.msh and the new config, the registration, max_iterations 25 where it was 10 (topological_offset_2d needs 13 to converge), and the dragon config's move off the deleted parameters onto min_edge_length_rel.

Open risks

  • 3D does not converge, and the open question is now sharply stated rather than vague: the offset band is in a one-for-one split/collapse stalemate (net −4 band vertices over a whole run) and sits ~3.5× coarser than the sizing field asks, while its vertices are placed to within 0.1% of target_distance. The residual is chord sag, not misplacement. The high-valence gate and disabling collapse were both tested and ruled out.

  • offset_field: euclidean breaks two of the three 2D configs by refining without bound until Phase A is handed unrepairable elements. It is committed as a measurable alternative, not a working one; smooth remains the default.

  • topological_offset_3d_convex is an aggressive configuration — the offset is 36% of the object's own thickness and its shell out-volumes the object, while the background target edge is 4.18× the offset distance. Worth keeping in mind when reading its numbers.

  • The spec/Parameters seam has failed in both directions herepreallocation_factor was called for but undeclared (jse rejected any config setting it), the coarsen group was declared but unread (setting it did nothing). Neither errors. An audit of every spec key against every json_params[] read now finds nothing else outstanding, but that audit is manual and belongs in a test.

  • Coarsening is slow — 45–78 s on the 2D configs, several times the optimization it follows, because it re-evaluates the criterion per round and that walks every band sample through Φ. Cacheable, not yet cached.

  • Cost. Φ against a broad-phase active set is more expensive per evaluation than one BVH nearest-point query. Not yet profiled separately.

  • Φ has no gradient beyond dhat. The guard turns that into an error rather than a silently frozen vertex, which is the right failure mode but is a failure mode.

  • The barrier diverges at distance 0. Handled by pinning input-complex vertices and by the reachable/pinned split, but it is the reason the residual is measured against a fixed reference slope rather than the local gradient.

  • Geogram re-enters the build as a PUBLIC ipc-toolkit dependency under upstream defaults — for the topological_offset component only. IPC_TOOLKIT_WITH_GEOGRAM=OFF is the lever if it turns out to matter.

🤖 Generated with Claude Code

danielepanozzo and others added 3 commits August 14, 2026 22:32
mesh_improvement() has no entry point at the top of an iteration, and an
application whose state has to be re-derived from the mesh rather than
maintained by the operations needs one. topological_offset re-labels its
tracked surfaces there: a split creates edges the labelling never
classified, and the collapse's substructure link condition is only as good
as the substructure it is shown.

Empty by default and unoverridden by triwild and simwild, so it is inert
for them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…exposed

TopoOffsetTriMesh::optimize_offset hand-rolled its own loop -- a fixed
number of split/collapse/swap/smooth rounds with a whole-band sizing sweep
after each. No stall detection, no `it pre`/`it post` passes, no
consolidation between iterations, and a refine_sizing_around_worst()
override that nothing could ever call, because reaching it requires
mesh_improvement(). 3D moved onto the shared loop in 9aa78de; this is
2D's turn, and the comment claiming 3D had the same reason not to was
stale.

What the offset now supplies, and nothing else:

  - optimization_quality_stats(): the max of the THREE criteria this
    optimization has to meet, each over its own target, so 1.0 means done --
    triwild's AMIPS/stop_energy, plus max distance error over
    convergence_target and average normal deviation over
    convergence_normal_deviation.
  - refine_sizing_around_worst(): TriWildMesh's, down to the shared helpers
    in utils/SizingField.hpp and every stuck_refine_* parameter. The one
    substitution is the per-face score, which is the same three criteria
    rather than raw AMIPS: ranking by AMIPS while the loop stalls on
    distance refines the wrong elements.
  - optimization_iteration_begin(): the tracked-surface relabelling.

update_sizing_field()'s bespoke halve/x1.5 sweep is deleted with the loop
that called it.

The input complex is no longer frozen. It is the geometry the distance is
measured against, but that is a statement about m_input_complex_bvh -- built
once from the input as loaded, never rebuilt -- not about the mesh elements
representing it. Freezing those bought nothing and cost two things: faces
pinned between two frozen vertices could never reach stop_energy, and a band
vertex on the complex could never be moved off it. It is now tracked exactly
as triwild tracks its input surface: held inside m_envelope (which the
pre-offset builder now covers), re-projected by the shared smoother, and
topologically preserved by substructure_link_condition, which
collapse_edge_before already applied unconditionally.

The distance metric distinguishes band vertices the optimizer can still
place from those it cannot -- on the complex, or on the domain boundary
where growth ran out of room. Only the reachable half drives the loop and
the sizing field; the whole band is still reported, and a pinned vertex out
of band is warned about as a construction defect.

THREE DEFECTS the loop exposed, each measured:

  1. `it pre` decimated the offset boundary. It collapses at
     collapse_limit_length = false, and the offset boundary is the one
     tracked surface with no envelope, so its sizing field is all that bounds
     it. On topological_offset_2d_vertex_input that pass alone took the mesh
     from 2619 to 462 vertices and the band's max distance error to exactly
     target_distance -- a band vertex collapsed onto the complex, after which
     the criterion was unreachable, the stall fired every iteration and the
     sizing ratcheted the mesh to 13.5k vertices. The offset boundary is now
     length-limited whatever the pass says.

  2. project_offset_vertex never rewrote the incident faces' cached
     m_quality. It moves a vertex outside any shared operation, so nothing
     else does. Not just a reporting problem: m_quality is what the collapse
     compares its ring against and what the swap weighs. Hidden because
     label_offset_boundary() refreshes every quality once per iteration --
     a pass ended at 14.3x target while the recomputed value at the top of
     the next iteration was 3.2e5x.

  3. project_offset_vertex had no shape guard. Any non-inverted,
     in-envelope position was taken, and non-inverted is a weak bar: one
     smoothing pass took the max AMIPS from 21.7 to 305430. The bar is now
     the worse of the ring's current max and stop_energy -- the same
     worse-of convention this file applies to normal deviation -- folded
     into the existing binary search, so a vertex still moves as far toward
     its target distance as shape allows.

Also: 2D never called deactivate_opt_logger() as 3D does. The per-vertex
Newton solver logs a line per smoothing attempt, which was a 1.9 GB log.

Parameters are triwild's, under triwild's names: optimization_iterations ->
max_iterations (default 80), smoothing_iterations -> num_smoothing_passes
(default 2), and interleaved_smoothing and the whole stuck_refine_* family
exposed, replacing three values hardcoded in Parameters::init(). coarsen_pass
is exposed and OFF: its accept test is element quality alone, so nothing
there protects the offset boundary's resolution. skip_good_regions is
removed -- it restricts smoothing to vertices near bad cells, and the offset
boundary is placed BY the smoother.

TopoOffsetTetMesh.cpp is touched only by the max_iterations rename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The offset was defined by the exact Euclidean distance to the input complex, and
a boundary vertex was placed by projecting it along (p - nearest)/|p - nearest|
to distance delta. That function is non-smooth exactly where the offset is
hardest: gradient-discontinuous across the medial axis, undefined ON the complex,
kinked at every feature. It has no usable gradient, so the placement could not be
an ENERGY, so it could not go through the shared smoother, so the offset boundary
was the one surface in the component that bypassed every check the shared smoother
makes -- and every wart in project_offset_vertex traced back to that: the
dist < 1e-12 bail-out, the ten-step bisection clamp, the golden-section tangential
slide, and the whole normal-deviation apparatus that existed to detect features
the distance field could not express.

Replace it with Phi, the offset geometric contact potential of ipc-toolkit's
high_order_contact subtree, evaluated at a point against the input complex:

    Phi(q) = sum over ACTIVE primitives P of b(dist(q, P), dhat)
    b(d, dhat) = -(d/dhat - 1)^2 log(d/dhat)      (NormalizedClampedLogBarrier)

"Active" is the OGC feasible-region rule, so away from features exactly one
primitive contributes and Phi is a monotone function of the Euclidean distance
alone; at a reentrant corner both adjacent edges contribute and the level set
bulges outward. The offset is the level set Phi = c, with c calibrated at
construction as Phi at distance delta from a long straight edge.

  - ipc-toolkit becomes a dependency (cmake/recipes/ipc_toolkit.cmake), linked by
    the topological_offset component only, never by wmtk::toolkit.
  - OffsetPotential wraps it: Phi, grad Phi, hess Phi, the calibration, the
    residual as a length, and the broad phase. The only file that mentions ipc.
  - OffsetEnergy2D is w (Phi - c)^2, a polysolve Problem in the shape of
    ExactDistanceEnergy2D, plugged into the shared smoother by two new
    default-inert hooks on TriOptimizerMesh -- smoothing_extra_energy() and
    smoothing_envelope(). project_offset_vertex and the tangential slide are gone;
    an offset-boundary vertex is now smoothed by the same code path as every other
    vertex, with the same line search, exact inversion test and quality veto.
  - Termination is the max of AMIPS and the Phi residual over
    offset_residual_rel * target_distance (10%). The Euclidean distance error is
    still computed and reported everywhere, as a diagnostic.
  - dhat = offset_dhat_factor * target_distance, factor 2; a band vertex that
    leaves the support is a hard error, since out there Phi is zero with a zero
    gradient and nothing can move the vertex back.
  - smooth_reject_backoff_steps: back a refused smoothing move off toward its
    start instead of refusing it outright. 0 (TriWild's and SimWild's value)
    disables it. Without it, 0 of 6117 offset-vertex moves were accepted on the
    dragon over a whole run -- the offset term asks for moves most of
    target_distance long, which almost always worsen some incident face.
  - A vertex that lies ON the input complex is marked as such geometrically
    rather than from incident input-class edges, which are empty by construction
    for a 0- or 1-dimensional input, and is pinned in place by
    smoothing_position_is_allowed. Both were silently broken for point inputs.
  - tests/test_offset_potential.cpp is the calibration gate: FD derivatives, the
    straight-edge calibration, and the smoothed offset measured against the exact
    Euclidean one on a circle, a square, a reentrant wedge and an isolated point.
  - The app writes the sampled potential as <output>_phi.vtu and the viewer draws
    it with the level set as an isoline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@danielepanozzo danielepanozzo changed the title 2D offset: run triwild's optimization loop 2D offset: TriWild's loop, and a smooth offset potential in place of the distance field Aug 16, 2026
danielepanozzo and others added 26 commits August 15, 2026 23:07
init_input_complex_bvh() now KEEPS the extraction and init_offset_potential()
consumes it, so the geometry is still extracted exactly once and the two cannot
describe different inputs -- but building the potential no longer requires
target_distance and offset_dhat_factor of every caller. A unit test that builds a
TopoOffsetTriMesh from a default-constructed Parameters wants the distance field
and nothing else, and was failing on the potential's argument validation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… off

Replaces smooth_reject_backoff_steps with SmoothVertexOptions::quality_veto, an
outer gate on the "refuse a move that raises the worst incident element's
quality" check. True is TriWild's and SimWild's behaviour and stays the default;
topological_offset sets it false.

The veto's premise is that the solver only ever asks for a SMALL move, so
refusing costs almost nothing and the next pass retries from the same place -- a
TriWild surface vertex starts on the input and the envelope term keeps it there.
The offset boundary is placed by minimising a term whose minimum can be most of
the offset distance away, so a large fraction of solved positions worsen some
incident face and the veto holds the boundary back. Element shape is the
split/collapse/swap passes' job and is one of the two convergence criteria, so it
is not left unattended.

Measured on topological_offset_2d_dragon against the back-off it replaces: the
offset criterion is met after ONE iteration instead of three, max phi residual
0.00086 against 0.00116, and the Euclidean distance error at convergence is max
0.000798 / avg 2.4e-5 against 0.00105 / 5.1e-5 -- 5.5% and 0.17% of the offset
distance. 2850 of 2922 offset-vertex moves are accepted. The cost is that max
AMIPS settles at 5.09 against a stop_energy of 5 rather than reaching it.

Also fixes the offset_accepted counter, which had been deleted along with a
temporary debug block, so every acceptance was reading as zero -- the "0 of 6117
accepted" figure quoted for the back-off was that bug, not a measurement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A vertex criterion cannot pin down an offset: a boundary can have every vertex
exactly on the level set while zig-zagging or cutting corners between them, which
reads as converged and is not the offset. That is the gap the paper's
normal-deviation criterion (Sec. 5.3.3) covered and that removing it left open,
and it was not hypothetical. Measured:

  - topological_offset_2d_vertex_input: a band that had decimated to twelve
    segments reported 0.2% of target_distance at its vertices while its edge
    midpoints sat at 18%.
  - topological_offset_2d_dragon: 5.5% at the vertices against 26% along the
    edges. The run declared convergence on the first number.

Sampling the edges is what the potential makes possible and the distance field
did not: Phi is defined everywhere, so the offset can be measured anywhere along
the band rather than only where the mesh happens to have a vertex.
offset_residual_samples (k, default 3) sets how many uniform interior points per
band edge; k = 1 is the midpoint, 0 restores the old vertex-only behaviour. The
same samples feed face_criterion_rel(), so the sizing field refines a band too
coarse to represent the offset instead of letting it decimate -- that feedback is
what the old criterion could not provide.

Also exempts an offset-boundary vertex from the containment envelope even when it
also lies on another region's boundary. At a triple junction where a region curve
TERMINATES on the offset, the terminus is defined by the offset, and holding it
inside a tube around where construction left it holds the offset back: on
vertex_input the three input points' rings were pinned at 0.75-0.90 x
target_distance, and widening the envelope 10x cut the error 10.7x. The region's
curve is still contained everywhere else, and the split/collapse/swap containment
checks are unchanged.

On the dragon, measured independently against the input as loaded, the two
together take the true offset error from 26.4% to 5.25% of target_distance along
the band and from 5.5% to 1.03% at its vertices, converging in 11 iterations --
against a criterion that is now honest rather than blind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
OffsetPotential becomes OffsetPotential<DIM>, one class for both dimensions, because the
calibration, the residual and the level-set definition are identical -- only the primitives
differ. Phi's 3D primitives are triangles, segments and points, reached through upstream's
build_collisions_at_vertex_ogc_3d, which is the same three calls the 2D path already made
with a different dict type. No quadrature: this is the point-evaluation path in both
dimensions.

Three things genuinely differ, and only the first is subtle.

THE BROAD PHASE MUST SEED THREE CANDIDATE SETS, NOT ONE. In 2D we fill only
Candidates::m_ve_set and let vv_set()'s 2D branch derive the vertex candidates from the edge
endpoints. That derivation is 2D-only: the 3D builder reads vf_set, ve_set and vv_set
independently and derives nothing. A set that is never seeded fails SILENTLY and in the
direction that looks fine -- the pairs it would have contributed simply do not appear, Phi is
smaller than it should be, and the level set has a hole exactly at the feature that primitive
represents. The cube test added here is the cheapest thing that catches it: outside a convex
cube each of its three probes is claimed by exactly one FACE, one EDGE and one VERTEX, so each
candidate set is separately load-bearing for one of the three CHECKs, and all three come out
exact to 1e-9 of delta.

The collision mesh needs edges AND faces, and the edge list must contain every edge of every
triangle -- ipc derives faces_to_edges from it and the OGC feasible-region test for a vertex
reads that vertex's edge neighbours, so an incomplete list would silently widen every Voronoi
region. Refused with a message naming the missing edge rather than trusted.

Calibration is Phi at perpendicular distance delta from one large flat TRIANGLE instead of one
long segment. Because the barrier only ever sees a distance, both dimensions calibrate to the
same c for the same delta and dhat_factor; the test asserts it (0.173287 either way).

The 2D path is unchanged in behaviour: the sentinel segment, the residual's reference-slope
divisor and the Gauss-Newton energy Hessian are all as they were, and the 2D configs are
bit-identical to before this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 3D counterpart of the hook TriOptimizerMesh already has: an extension point that lets an
application place a vertex by MINIMISING something rather than by computing a position and then
defending it. topological_offset's 3D offset surface is the caller -- it is placed by a
hand-rolled quadrics/Laplacian blend today, which is the one surface in that component
bypassing the shared smoother's line search, its exact inversion test and its accept checks.

Two additions, both default-inert:

  - TetOptimizerMesh::smoothing_extra_energy(vid), null by default. smooth_vertex_3d composes
    it into base_energy exactly as smooth_vertex_2d does, so it reaches the Projected branch,
    the two-stage warm-up and the weighted solve alike.
  - opts.quality_veto plumbed from m_params.smooth_quality_veto into smooth_vertex_3d's veto,
    matching what smooth_vertex_2d already reads. The veto's premise is that the solver only
    ever asks for a small move; an application whose objective can want most of the offset
    distance needs to be able to turn it off.

With extra_energy null, base_energy IS amips_energy and every expression below it is
character-for-character what it was; with quality_veto true the condition is the identical
inequality. Verified rather than argued: against a build of the parent commit, tetwild
(sphere, double_sphere, thingi_100036, thingi_366725) and simwild-3D (double_sphere,
replace_tags, tight_seal, resolve_overlaps) produce BYTE-IDENTICAL output in every mesh file,
with only wall-clock fields differing in report.json.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 3D half of the port. The offset surface is now the level set Phi = c, placed by minimising
w (Phi - c)^2 inside the shared smoother, and everything that existed only because the
Euclidean distance had no usable gradient is gone.

PLACEMENT. TopoOffsetTetMesh::smoothing_extra_energy returns OffsetEnergy3D for a vertex on the
offset surface and not on the input complex, so that vertex takes the same line search, the
same exact inversion test and the same accept checks as every other vertex.
smoothing_containment_envelope now answers null for it as well: the base's default hands back
m_envelope for anything the union flag m_is_on_surface is set on, and m_envelope is built from
the INPUT complex, so containment would have required the offset to stay within eps of the
wrong surface and would have refused every move the offset term asks for.

THE CRITERION IS SAMPLED ON FACES, NOT ONLY AT VERTICES. This is the half that matters and it
is carried over from 2D deliberately. A vertex-only criterion is blind to a surface too coarse
to be the offset: every vertex can sit exactly on the level set while the triangles between
them cut across it. Measured here on topological_offset_3d, the offset vertices read 0.84% of
delta while the face interiors read 38% -- the old criterion would have called that converged.
offset_face_samples() evaluates the residual at the interior lattice points of each offset
triangle (denominator k+2, so k=1 is the centroid and the counts are 1, 3, 6, 10), and the same
samples feed face_criterion_rel(), so refine_sizing_around_worst ranks and refines FACES rather
than vertices. That feedback is what replaces the per-operation normal-deviation guards.

ONE CRITERION WHERE THERE WERE TWO. The paper's termination test is a max on distance AND an
average on normal deviation, and the asymmetry was forced: normal deviation has a floor at
every sharp feature that no refinement can lower, so only its average could be asked for, and
an average cannot be the engine's max-based stop -- which is why it was tested after the loop
and a run could exit "converged" and be reported unconverged a few lines later. The Phi
residual has no such floor and is defined everywhere, so "is the surface in the right place"
and "is it fine enough to be in the right place" are one measurement taken at vertices and at
face interiors, and its max IS the stop metric. The Euclidean error is still computed and
logged as the diagnostic it now is.

DELETED, because the potential is what they stood in for: smooth_after_offset_surface() and
most of Smooth.cpp, Quadrics.{hpp,cpp}, offset_surface_samples/OffsetSurfaceSample,
face_normal_deviation, max_offset_surface_normal_deviation_at_vertex,
collapse_normal_deviation, offset_swap_normal_deviation_ok, compute_normal_deviation,
offset_field_normal, the offset half of SmoothTrace, and the paper's 1.5x sizing growth (whose
flatness test was a normal-deviation test, and which 2D does not carry either). Eight
parameters go with them: convergence_target(_rel), convergence_normal_deviation,
max_normal_deviation_deg, min_normal_deviation_deg, smooth_quadrics_weight,
smooth_laplacian_weight, quadrics_svd_threshold. min_edge_length was derived from an angle and
now has a direct min_edge_length_rel, whose default reproduces the old 2*sin(15 deg) floor to
full double precision.

The collapse and swap normal-deviation guards go too, and that is the one deletion that changes
what an operation may DO rather than only where a computation lives -- look there first if the
3D offset decimates.

Also: the construction's sub-threshold bail-out now decides the sphere by evaluating Phi at its
centre instead of declaring the tet outside. The Euclidean bracket above it already IS the
conservative form of the Phi test; this only replaces the give-up branch, which made the region
a run produced depend on a subdivision budget rather than on the geometry.

STATUS. Both 3D configs run end to end, the manifold checks pass, and neither converges: the
residual is essentially met at the offset vertices and 3.5-4x over tolerance inside the faces,
with every sizing scalar at its floor. Under investigation.
OffsetPotential::describe_active() and the worst-face log added here are the tools for it.

2D is bit-identical across all of this: the registered dragon reports max residual
0.003155947551923596 before and after, to the last digit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three fixes to the same wound, found together by a new integration test and measured on it in
that order. topological_offset_3d_convex is a single-tag convex input (127891.msh, convex to
0.01% of its diagonal), which is the case where Phi is best behaved -- so anything that goes
wrong on it is the mesh machinery, not the potential. Something did, immediately.

THE SYMPTOM. The offset surface was being DECIMATED, from 1172 faces to 296, while the vertices
it left behind sat at 0.95% of delta from the level set -- which a vertex-only view of the world
calls converged. The face-sampled criterion saw it (max residual 18x tolerance) but could not
stop it.

1. BARE COLLAPSE PASSES. mesh_improvement() brackets its loop with local_operations({{0,1,0,0}})
   -- collapse alone, not interleaved with splits or smoothing -- and the opening one passes
   collapse_limit_length FALSE, so no length gate applies at all. That is right for TetWild and
   TriWild, whose tracked surface is held by an envelope throughout. It is wrong for a surface
   the optimization exists to PLACE, which by construction has no envelope: measured at
   max_iterations 0, so only those passes run, they alone took 1172 faces to 326. New virtual
   optimization_bare_coarsen_passes() on both bases, default true so the other applications are
   untouched; the offset returns false. This supersedes the 2D workaround that forced the length
   limit for offset vertices inside collapse_before_vertex -- same wound, patched from the other
   side.

2. THE SIZING FIELD IS SEEDED FROM THE STARTING MESH EVERYWHERE. init_offset_sizing_field()
   seeded only offset-surface vertices and left the background at the base target -- a fraction
   of the bounding box, which on any reasonable configuration is far coarser than the mesh the
   construction produced. The collapse gate is edge length against the target at its endpoints,
   so that marked essentially every interior edge as collapsible before any criterion had been
   evaluated. Every vertex is now seeded from its own one-ring: keep the resolution you have,
   and leave changing it to the sizing refinement, which has a reason.

3. COLLAPSE AND SWAP ARE ACCEPTED BY THE SAME CRITERION THE SMOOTHING MINIMISES. This is the one
   that did the work, and the other two are arguably corollaries of it. The smoother places an
   offset vertex by minimising w (Phi - c)^2 and the loop converges when the Phi residual is
   inside tolerance everywhere on the offset surface, vertices and face interiors alike. Every
   other operation has to answer to that same measure or it can undo in one collapse what the
   smoother spent an iteration achieving. Length gates cannot express this: they ask whether an
   edge is short relative to a sizing target, which is a statement about the MESH, while the
   criterion asks whether the surface is still the offset, which is a statement about the
   GEOMETRY -- and only the second is what the run is for.

   FLAT, not "no worse than before". A before/after bar reads the max over a patch, so a single
   already-bad face licenses coarsening its whole neighbourhood; with it the surface still fell
   to 496 faces. This is the same leak the 3D normal-deviation guard was rewritten to close
   before this port, restated in the quantity the loop actually converges on.

MEASURED, cumulatively, on topological_offset_3d_convex at 3 iterations:

    offset faces (from 1172)   offset verts collapsed   max residual   avg residual   time
    296                        500                      1.84           0.226          20 s
    496  (+1, +2)              385                      1.47           0.134          20 s
    970  (+3)                  144                      0.71           0.075          12 s

It is also FASTER, because the operations the criterion refuses were work being thrown away.
The average residual is now inside tolerance.

AND ON 2D, where the same three are ported: the registered dragon's max Phi residual falls from
0.003155947551923596 to 0.0020392484197728922 against a 0.0014470301527948189 tolerance -- from
2.18x over to 1.41x. 68893 collapses and 19089 swaps are refused by the criterion over that run,
so the guard is load-bearing rather than decorative.

NOT FIXED HERE, and the reason for the split counters this adds: 3D splits offset edges and does
not grow the offset surface. Instrumented, 4258 offset edges are offered per 3 iterations, none
are frozen, 422 are refused by the base's gate -- and only 41 arrive carrying is_edge_on_offset.
The same counters in 2D read 375 offered against 1869 flagged, the opposite skew. 2D re-derives
the offset boundary from the face labels every iteration (label_offset_boundary(), from
optimization_iteration_begin()), so a vertex the split path fails to mark is picked up on the
next pass; 3D sets m_is_on_offset exactly once in optimize_offset() and relies entirely on the
split and collapse hooks to maintain it. The flag is being lost in 3D, which is why splits land
everywhere but the offset surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… cache

A vertex placed on an edge lies on whichever tracked surfaces BOTH endpoints lie on -- that is
what being on the edge means -- and split_after_cells() has v1_id and v2_id in hand, so there is
nothing to carry across and nothing to get out of step.

It was being carried, through m_opt_split_cache's is_edge_on_offset, set in
split_before_cells(). Something on that path loses it: instrumented on
topological_offset_3d_convex, 4258 offset edges reach split_edge_before per 3 iterations, none
are frozen, 422 are refused by the base's gate -- and only 41 arrived at split_edge_after with
the flag set, although split_before_cells() runs for every one of the ~3836 that got through.

The consequence was not a bad diagnostic. An unmarked vertex is not an offset vertex, so the
faces around it stop being offset faces, so the thousands of splits that did happen bought the
offset surface nothing. 2D never showed this because it re-derives the whole offset boundary
from the face labels every iteration (label_offset_boundary(), from
optimization_iteration_begin()), which papers over exactly this class of loss; 3D marks
m_is_on_offset once in optimize_offset() and has nothing to fall back on.

Measured on topological_offset_3d_convex at 3 iterations, on top of the previous commit: the
offset surface holds at 1106 faces against the 1172 it starts with, where it was landing at 970,
and the average Phi residual falls from 0.0751 to 0.0680 against a 0.1 tolerance.

PARTIAL. The maximum residual is still 1.08, and 98.7% of offset edges are still more than 4/3
of their sizing target with every scalar at the floor -- so the offset surface is being kept
rather than refined, and why the splits do not reach it is still open. Note also that the
"tried" counter in the split log now reads the cache this commit stopped trusting, so it no
longer measures anything; it is left in place only until that question is closed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
data/ is a cmake-managed checkout, reset to this pin on every configure -- so a local commit
there does not survive a build, and the model and configs added for
topological_offset_3d_convex were silently wiped by the next one. Pushed to
wildmeshing/data2 branch offset-3d-convex and pinned here, which is the only way the new case
stays present.

Carries topological_offset_3d_convex.json and models/127891.msh, the registration in
integration_tests.json, and the dragon config's move off the deleted parameters
(convergence_target_rel, convergence_normal_deviation, max_normal_deviation_deg) onto
min_edge_length_rel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Instrumented the whole split path on topological_offset_3d_convex, 3 iterations:

    4337 offset edges offered to split_edge_before
       0 frozen
     462 refused by the base's before-gate
      41 reached split_after_cells with both endpoints still on the offset
      41 produced an on-offset vertex
      95 refused inside split_edge_after

The endpoint test is the SAME expression at both sites -- m_is_on_offset on v1 and v2, which
is_edge_on_offset() also requires -- so 4337 against 41 cannot be a disagreement about what an
offset edge is. It means split_after_cells() is not reached: roughly 3800 offset-edge splits
pass split_edge_before and never arrive at split_edge_after at all.

That puts the loss inside the shared split OPERATION, between the two hooks, and outside the
offset's own code entirely -- the executor's weight re-check happens before `before`, and the
offset's gates all live in `before` or `after`. It is the next place to look, and it explains
the shape of the whole problem: 12721 splits land per 3 iterations, 98.9% of offset edges are
above 4/3 of their sizing target with every scalar at the floor, and the offset surface still
does not grow.

The earlier "tried" counter, which read the split cache, is replaced: every count here is taken
from the mesh itself, so none of them depends on the cache the previous commit stopped trusting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n out

Answers the question the previous commit localised. Nothing rejects those splits; the mesh
runs out of preallocated storage mid-pass and abandons the rest of the work.

TetMesh::split_edge has exactly two exits between split_edge_before and split_edge_after, and
both are the same thing: get_next_empty_slot_v() returning -1, and
operation_update_connectivity_impl reporting !conn_ok. Both call note_slot_exhausted(), and the
engine was already warning about it every pass --

    [slots] 9892 operations aborted with the preallocated slot pool exhausted (capacity is 6x
    the live count at the last consolidate). They are NOT refusals: the work was dropped.

-- at 9892, 5724 and 35276 operations per pass on topological_offset_3d_convex. That is why
4337 offset edges were offered and only 41 arrived at split_after_cells: the pool is consumed
by the ~12000 volume splits the seeded sizing field asks for, and the offset's share of the
queue is reached after it is gone.

preallocation_factor is now DECLARED in the spec, copying tetwild's and triwild's entry
verbatim including its 6.0 default. The offset already called
wmtk::set_preallocation_factor_from_json at both its 2D and 3D entry points -- but never listed
the key, so jse rejected any config that set it and the value could only ever be the hardcoded
default. The helper call was dead.

Measured on topological_offset_3d_convex at 3 iterations, factor 6 -> 30:

    offset-edge splits producing an offset vertex     41  ->   805
    offset surface faces (starts at 1172)           1106  ->  1720
    average Phi residual (tolerance 0.1)           0.0680 -> 0.0558
    [slots] warnings                                   3  ->     1

The offset surface now GROWS past the mesh it starts from rather than shrinking below it, which
is the first time any of these runs has done that.

Still not converged: max residual 0.92 against 0.10, and 92.9% of offset edges remain above 4/3
of their target with the sizing scalars at their floor. One [slots] warning survives, so the
pool is still short. The remaining gap is no longer a question of what is blocked -- nothing is
-- but of how much refinement the offset needs and whether the floor permits it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… HARMS the dragon

Splits the 2D residual by where it was measured, as 3D already does. On the registered dragon
the answer is stark: 0.000106 at the offset vertices against 0.002039 between them -- 0.73% of
delta versus 14%. The boundary is in the right place and too coarse to stay there, which is the
same signature 3D shows, so the dragon is the cheap place to chase it (27 s, no slot starvation,
no support-guard escape).

AND A RESULT THAT REVERSES AN EARLIER CLAIM. I reported that the three fixes ported from 3D
improved the dragon from 0.003156 to 0.002039. They do -- but the third one, accepting collapses
and swaps by the offset criterion, buys that at the cost of a DEGENERATE FACE. Attributed by
disabling each in turn, final max phi residual and the AMIPS criterion:

    baseline, no 2D fixes                          0.003156   amips 1.03x  (max 5.14 / 5)
    all three fixes                                0.002039   amips 2e49x  (max 1e50 / 5)
    bare-coarsen + sizing seed, criterion OFF      0.001025   amips 0.83x  (max 4.14 / 5)  CONVERGED

So the best configuration for 2D is the first two fixes WITHOUT the third, and it is the only
one of the three that converges the dragon at all. The mechanism is not mysterious: collapse is
the operation that removes a degenerate face, and refusing it whenever an incident offset face
is over tolerance -- which early in a run is most of them -- locks the degeneracy in. AMIPS then
sits at MAX_ENERGY, and since optimization_quality_stats() returns max(amips, phi), the loop's
stall detection and its refinement targeting are both driven by that one unfixable face instead
of by the offset.

This is a genuine tension rather than a bug to squash: the same guard is what stopped 3D
decimating its offset surface (296 faces -> 970), and 3D has no comparable AMIPS blow-up because
its criterion is phi alone. Left ON in both dimensions here, unchanged, because switching it off
for 2D is a decision about which of the two failures is worse and that is not mine to take
silently. The numbers above are what the decision should be made on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sses

Three changes, all asked for, landed together:

1. SPLIT no longer freezes input-complex edges; only the bounding box is frozen. Refining the
   complex is not moving it -- the midpoint is checked against m_envelope like any other
   input-complex geometry, so a split that would take the surface off itself is refused there
   rather than forbidden outright, while the refinement the offset needs near the complex gets
   through.

2. THE AMIPS COLLAPSE GATE IS UNCONDITIONAL, in both dimensions. It used to fire only for an
   edge longer than the collapse target, so the overwhelming majority of collapses -- the ones a
   length-driven pass actually performs -- had no quality gate at all and could freely degrade
   the mesh.

3. THE OFFSET CRITERION IN COLLAPSE AND SWAP IS NON-DEGRADING, not absolute, mirroring the AMIPS
   gate: max over the affected offset faces, before against after. The absolute form refused an
   operation for the state the mesh was already IN rather than for the change it made.

2D: THE DRAGON CONVERGES, for the first time.

    max phi residual 0.001147 <= 0.001447 tolerance   (0.000125 at vertices, 0.001147 between)
    amips 1.04x (max 5.20 / 5) -- healthy
    10 s, against 27 s before

    against 0.003156 / amips 1.03x at the branch point, and 0.002039 / amips 2e49x (a permanent
    degenerate face) with the absolute bar.

3D: THE CONVEX CASE REGRESSES, and this is not yet attributed.

    offset surface   1720 -> 734 faces   (it starts at 1172, so it is decimating again)
    max residual     0.92 -> 1.55
    avg residual    0.056 -> 0.105
    offset vertices removed by collapse   530 -> 1466

The prime suspect is (3): the non-degrading bar is the more PERMISSIVE of the two, because a
patch that is already bad makes `before` large and lets anything through, and that is the leak
measured before this commit -- the worse-of bar let the surface fall to 496 faces where the
absolute bar held it at 970. But all three changes landed in one run and (2) alone changes which
collapses are legal everywhere, so the attribution has to be measured, not assumed.

What this leaves is a sharpened version of the tension already recorded: 2D needs the
non-degrading bar (the absolute one makes a degenerate face permanent, because collapse is what
removes one) and 3D needs the absolute bar (the non-degrading one lets the offset surface
decimate). The two dimensions differ in what their stop metric contains -- 2D's is
max(amips, phi), 3D's is phi alone -- which is the first place to look for why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
optimization_quality_stats() and face_criterion_rel() now both return max(amips, phi), each term
over its own target, matching TopoOffsetTriMesh exactly. Adds cell_quality_rel() as the 3D twin
of TriOptimizerMesh::quality_rel() so the two dimensions express "how bad is this element" on
the same 1.0 scale.

The asymmetry was real and worth removing on its own terms: with phi alone, AMIPS was outside
the loop's stop test, its stall test and its refinement ranking, so a degenerate element could
sit in the mesh unseen for a whole run, and the offset criterion was the only thing holding the
surface together.

THE HYPOTHESIS IT WAS MEANT TO TEST IS NOT CONFIRMED. The idea was that 2D and 3D needed
opposite acceptance bars only because they measured different things, so unifying the metric
would let one bar serve both. It does not. On topological_offset_3d_convex:

    [criteria] amips 1928x | phi 12.29x
    offset surface 568 faces (from 1172), max residual 1.23, not converged

against 2D's amips 1.04x on the dragon, which converges. What folding AMIPS in actually did was
EXPOSE something the phi-only criterion had been hiding: 3D's tet quality is three orders of
magnitude worse than 2D's triangle quality, and the loop now -- correctly -- spends itself on
that instead of on the offset. The offset surface fell further, 734 to 568 faces, because the
sizing refinement is ranking by the bad tets.

So the dimensions do not differ in what they measure any more, and they still behave
differently. The next question is why 3D's tets are that bad at all -- 1928x stop_energy is not
a near-miss -- and whether the unconditional AMIPS collapse gate from the previous commit is
what is preventing the collapses that would fix them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The coarsening pass had never run for the offset: coarsen_pass defaults to False in the spec,
where TriWild and TetWild both default it True. So the gate added a few commits ago was gating
something already switched off. It is on now, with TriWild's four companion settings copied
across, and coarsen_mesh() is no longer bracketed with the two BARE collapse passes -- those stay
disabled, because they are unguarded sweeps, while this one runs in m_coarsen_mode and can be
held to a stricter rule than the loop.

TWO BARS, because coarsening asks a different question. In the LOOP an operation is refused if it
makes things WORSE, mirroring the AMIPS gate; that is right while the loop is still working,
since most of the mesh is over tolerance early and an absolute bar would freeze it. COARSENING is
not working, it is banking: it runs after convergence and trades elements for nothing except the
promise that the result is still good, so its bar is ABSOLUTE -- both AMIPS and the offset
residual inside tolerance afterwards. A collapse that leaves anything over tolerance is not a
saving, it is a regression with fewer elements.

All three 2D cases converge and then shed elements without crossing the bar:

    2d               22990 -> 18612 faces (-19%)   metric 0.928 -> 0.978   converged 0.0245/0.025
    2d_vertex_input  10614 ->  9576       (-10%)   metric 0.837 -> 0.890   converged 0.0238/0.025
    2d_dragon        41507 -> 33087       (-20%)   metric 0.841 -> 0.849   converged 0.0013/0.0014

coarsen_smooth_ring defaults to 1 here rather than TriWild's and TetWild's 2, and the divergence
is measured rather than assumed: a smaller local-smoothing ball perturbs less per collapse, so
more collapses clear the absolute bar. On the dragon, 4210 collapses against 3664, 1092 more
faces removed, a final metric of 0.849 against 0.896, and 15% less time. Only 2d_vertex_input
coarsens less at ring 1, and it ends with more margin (0.890 against 0.952) in half the time.

DECLARING A KEY IN THE SPEC IS ONLY HALF OF MAKING IT SETTABLE. jse injects the default into the
json, and if nothing copies it into the struct the value in force is whatever
OptimizerParameters happens to hold. The whole coarsen group is now read in Parameters, not just
coarsen_pass -- found by setting coarsen_smooth_ring and getting bit-identical output, which is
what a parameter that does nothing looks like.

That seam has now failed in both directions in this branch: preallocation_factor was CALLED FOR
but undeclared, so jse rejected any config that set it; the coarsen group was DECLARED but
unread, so setting it did nothing. Neither errors. An audit of every spec key against every
json_params[] read finds nothing else outstanding -- the only entries outside the root `optional`
list are `application` and `input`, which are required and correctly so.

max_iterations raised to 25 in the two registered configs that capped at 10: topological_offset_2d
needs 13 to converge, and a cap that low turns a slow run into a failed one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Whitespace only, from the repo's own pre-commit hook (clang-format 21.1.8). No behaviour change;
the component's 36 test cases and 2926 assertions pass unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wmtk_test_manifold_extraction and wmtk_test_topological_offset have aborted on every platform,
Debug and Release alike, since the offset potential started linking ipc-toolkit -- with Eigen's
unaligned-array assert, inside wmtk::to_rational(), a function that predates all of this work and
has nothing to do with either the offset or ipc. Every other test stayed green, and so did main.

THE CAUSE IS NOT A TYPE, IT IS A COMPILE DEFINITION. ipc-toolkit's SIMD branch ends with

    target_compile_definitions(ipc_toolkit PUBLIC EIGEN_DONT_VECTORIZE=1)

PUBLIC, with an upstream comment saying the author does not know why making it private crashes.
That macro takes EIGEN_MAX_STATIC_ALIGN_BYTES from 16 to 0, which changes alignof() for every
fixed-size Eigen type whose byte size is a multiple of 16 -- Vector2d, Vector4d, Matrix2d,
Matrix4d, and wmtk's own Vector2r/Vector3r, since an mpq_t is 32 bytes and 3*32 = 96. Propagated
PUBLIC it reaches only the targets that link ipc: topological_offset, manifold_extraction,
wmtk_app, the python module. Core wildmeshing_toolkit does not link ipc and kept 16.

Sizes still agree, so nothing looks wrong; only alignment differs. But the mangled names are
identical in both worlds, so the linker merges their inline definitions arbitrarily:

    plain_array<wmtk::Rational, 3, 0, 16>   this=0x16fdfc6d8   (8 mod 16)
      <- wmtk::to_rational()                     Types.hpp:59
      <- TopoOffsetTetMesh::set_vertex_position  TopoOffsetTetMesh.h:227

a caller compiled for alignment 8 handing its stack slot to a callee compiled for 16. That the
two failing binaries are exactly the two ctest targets linking ipc is the whole tell.

THE FIX IS TO PIN THE ABI FOR EVERYONE, in recipes/eigen.cmake, rather than to argue with the
definition. ConfigureVectorization.h guards its inference with #ifndef, so an explicit value
wins:

    plain                       max_static_align=16  packet<double>=2  alignof(Vector2d)=16
    EIGEN_DONT_VECTORIZE         0                   1                  8
    EIGEN_DONT_VECTORIZE + pin  16                   1                 16

The third row is what we need: ipc keeps the scalar packets it requires, every fixed-size Eigen
type keeps one alignment across the binary. 16 is what every wmtk platform already inferred, so
this pins the status quo -- it changes alignment, never arithmetic, so no result moves. It also
decouples the ABI from -mavx, which would otherwise raise a subset of targets to 32 and split the
build the same way.

TWO ALTERNATIVES THAT DO NOT WORK, recorded so they are not retried. Turning IPC_TOOLKIT_WITH_SIMD
off does not compile: high_order_contact -- the subtree wmtk actually uses -- carries a
static_assert(packet_traits<double>::size == 1, "Eigen vectorization is NOT disabled!") in
HighOrderCollisionTemplate's constructor, so that code genuinely requires the macro. And defining
EIGEN_DONT_VECTORIZE globally would make the layouts agree the other way, but it changes
floating-point results in core wmtk and would move tetwild, triwild and simwild output -- not
something an optional component may decide for everyone.

recipes/ipc_toolkit.cmake now includes eigen first, because ipc ships its own eigen recipe that
defaults EIGEN_DONT_VECTORIZE to ON and whichever recipe creates Eigen3::Eigen first wins; and it
FATAL_ERRORs if the pin ever goes missing, since the symptom otherwise surfaces two directories
from its cause.

117/117 ctest cases pass in CI's configuration, tetwild, triwild and simwild included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…imization

The 3D run wrote a single output mesh, so there was nothing to scrub through -- 2D has had
per-iteration frames for a while and 3D is where the convergence question actually is.

Two changes, both small. write_optimization_debug_output() now prefixes the run's own
output_path, because the shared driver hands it a bare `debug_N` and the frames were landing in
whatever directory the app was launched from; the viewer globs `*debug_*.vtu` beside the output
and orders by the counter, so that prefix is what makes them a timeline rather than loose files.
And optimize_offset() writes one frame before mesh_improvement(), consuming counter 0, so the
timeline opens on the mesh AS CONSTRUCTED. The driver only writes after an operation pass, so
without it frame 0 was already past the first split and there was nothing to compare against --
which matters here, because the open 3D question is precisely how bad the mesh is on entry.

Measured on the convex case at max_iterations 3: 111 frames, frame 0 the pre-optimization mesh,
4806 cells / 945 vertices, offset band 342 cells against 2326 ambient.

Behind debug_output, so an ordinary run is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both Windows jobs have failed at the BUILD step on every run of this branch, long before
ctest, on

    geogram/src/lib/geogram/third_party/PoissonRecon/Hash.h(5):
      fatal error C1083: Cannot open include file: 'hash_map'

while main is green on the same job and this branch changes no geogram setting. The reason it
appears here at all: wmtk itself has no geogram -- fast-envelope dropped it when the three
predicate backends collapsed onto Indirect_Predicates -- and ipc-toolkit brings its own, pinned
at 1.9.8, whose bundled PoissonRecon opens with `#ifdef _MSC_VER / #include <hash_map>`. That is
an old MSVC extension VS 2026 no longer ships. Counted in the CI logs: main compiles PoissonRecon
0 times, this branch 9.

There is no option to switch it off -- geogram's third_party/CMakeLists lists PoissonRecon
unconditionally, next to the ones guarded by GEOGRAM_WITH_LUA and GEOGRAM_WITH_HLBFGS -- so the
version is the only lever. Upstream DELETED that header in v1.9.10 (41 files in the directory
against 1.9.8's 42), which is why the pin is there and not somewhere newer for its own sake. The
options are ipc's own, verbatim, so this differs from what ipc would have configured in the
version and in nothing else.

IPC_TOOLKIT_WITH_GEOGRAM OFF was the obvious alternative and is rejected deliberately. It builds,
but ipc then falls back to its analytic distance-type classification, and the exact path is the
default (DistanceTypeConfig::use_standard_ is false). high_order_contact -- the subtree the offset
potential evaluates -- calls distance_type from its collision builder and its pair-distance
templates, so that fallback would land on our own code, in exactly the near-degenerate
configurations an offset surface produces. A version bump changes nothing about what Phi is
computed from; dropping the predicates does.

Like the Eigen pin one commit earlier, recipes/ipc_toolkit.cmake includes this first, because ipc
ships its own geogram recipe and whichever creates geogram::geogram first wins.

Verified locally: builds clean against 1.9.10 (the API ipc uses -- GEO::expansion_nt, vec3E,
PCK::initialize -- is unchanged), and wmtk_test_manifold_extraction and
wmtk_test_topological_offset both pass. The Windows half can only be confirmed by CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The frame glob excluded the `_surf` companion by name, which was right when only 2D wrote debug
output -- TopoOffsetTriMesh::write_vtu drops exactly one companion beside each frame. The 3D
writer drops three: `_surf`, `_off` and `_edge`, the tracked surface, the offset surface and the
wire complex on their own. So the moment 3D started writing frames the series silently tripled:
37 real frames read as 111, two thirds of them companions.

It did not merely show junk, it died. `_edge` is a wire mesh with no tets, so it has no tagged
cells at all -- interfaces() hit np.vstack([]) on an empty group list -- and meshio cannot read it
as a volume mesh either. The series loaded frame 0 and then aborted on the second file, so the 3D
timeline never opened at all.

Matching the counter exactly rather than blacklisting suffixes, so a fourth companion cannot
reintroduce this. Deliberately NOT paired with an empty-groups guard in interfaces(): an
intermediate frame with no tagged cells would be a real defect worth failing on, and a guard there
would have rendered these companions quietly instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The geogram pin cleared the Windows build past PoissonRecon and straight into the next one:

    ipc/high_order_contact/collisions/high_order_quadrature.hpp(715):
      error C2065: 'M_PI': undeclared identifier

M_PI is not standard C++. MSVC's <cmath> only defines it when _USE_MATH_DEFINES is set first;
libc++ and libstdc++ define it unconditionally, which is why every non-Windows job compiles the
same header without complaint and why this was invisible locally. wmtk's own targets already
carry the definition; ipc-toolkit's do not.

PUBLIC rather than PRIVATE because the use is in a HEADER that wmtk includes, so the definition
has to reach our translation units as well as ipc's own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third Windows error in a row from the same cause: ipc-toolkit's high_order_contact headers use
standard types without including the headers that declare them, and libc++ and libstdc++ supply
them transitively where MSVC's do not.

    high_order_contact_parameters.hpp(13): error C2079:
      'ipc::FaceQuadPoint::lambda' uses undefined class 'std::array<double,3>'
    adaptive_support.cpp(65): error C2109: subscript requires array or pointer type

Forced includes rather than a header fix, for two reasons. This is a mirror we do not want to
fork over missing #includes, and each of these is only discoverable one CI round trip at a time
-- <array> is what fails today, and <cstdint> and <algorithm> are the next two candidates in the
same headers, so they go in together rather than costing another 40 minutes each.

PUBLIC, like _USE_MATH_DEFINES before it: the declarations are in headers wmtk includes, so our
translation units need the same treatment as ipc's own.

Marked TODO to push the includes upstream and drop this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eplace

wildmeshing/ipc-toolkit@67bb3d7a fixes, upstream, the three things this recipe had been working
around or was about to: `double near = 0, far = 0` colliding with windef.h's legacy macros,
M_PI in a public header, and std::array/std::uint*_t used across high_order_contact without
<array>/<cstdint>. See that commit for the detail.

So _USE_MATH_DEFINES and the /FI forced includes come out again -- they were stopgaps for one CI
round trip each, and the real bugs are now fixed where they live. The geogram pin stays: that one
is a version constraint, not a source bug, and 1.9.8's PoissonRecon is not ours to patch.

Verified locally against the new pin: configures, builds, and wmtk_test_topological_offset and
wmtk_test_manifold_extraction both pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… jointly

Driving both criteria from one loop put them in direct competition, and the joint loop lost.
Measured on topological_offset_3d_convex over 20 iterations: the metric's best value came at
iteration 5 and drifted UPWARD for the remaining fifteen, while every iteration ran split
945 -> 11973 vertices followed by collapse 11973 -> 742, ending below where it began. Per pass,
split took AMIPS from ~2.6e3 to ~7e48 every single iteration and the other three passes spent the
iteration getting back. Collapse was not being greedy -- 94% of what split produced was
degenerate, and removing it is its job.

PHASE A is TetWild, and nothing else: same operations, same gates, same sizing field, same
stall-driven refinement including the force-split that the offset had never had ([force-split]
fired 0 times in every previous run). The offset contributes no energy term, no acceptance
criterion and no stop metric. Its one addition is m_offset_envelope -- the offset surface may not
leave a tube of ab_offset_envelope_rel Phi tolerances around wherever Phase B last placed it,
rebuilt at the start of every Phase A so the surface can still travel across rounds. That turns
"do not degrade the offset" from a per-operation criterion, which reached only the 2% of
collapses touching the offset surface (6139 of 302919 measured), into a geometric constraint
every operation already honours through surface_triangle_is_outside().

PHASE B moves the surface and nothing else: smoothing against the offset energy, run to a fixed
point, no envelope on the offset, no topology. The sizing field is SHARED and both phases write
it -- Phase A through TetWild's quality-ranked refinement, Phase B through the Phi residual of the
faces smoothing could not place, which is the only situation refinement actually answers.

UNITS ARE PART OF "IDENTICAL TO TETWILD", and cost two bugs before that was believed. Phase A
first returned a normalized metric while stop_metric stayed 1.0; refine_sizing_around_worst
derives its filter from that number and compares it against cbrt(cell_quality), which is
absolute, so the filter came out at 100 against a worst element of 97, select_worst_cells
returned nothing, and Phase A stalled at exactly 91783.4 for twenty iterations with the stall
detector firing nineteen times and doing nothing. Then the driver's own convergence check
repeated the mistake in the other direction and failed a Phase A that had converged. Both are now
delegated to the base rather than reimplemented.

INPUT-COMPLEX VERTICES ARE SMOOTHED, as TetWild smooths a surface vertex, instead of being
refused outright (14758 of 229276 attempts). What pins the input geometry is that
m_input_complex_bvh and m_offset_potential are built once from the input as loaded and never
rebuilt -- not that the mesh elements representing it are immovable. They get m_envelope in both
roles and TetWild's projection.

check_no_vertex_on_both_surfaces() is a hard error, at construction and after every Phase A. Such
a vertex is at distance 0 from the input, where Phi diverges, and asked to be at target_distance
from it: unsatisfiable, and the rest of the component silently steps around it. A collapse can
CREATE one, since collapse_after_vertex() ORs both flags onto the survivor. It does not fire on
3d_convex -- 0 pinned vertices in every run.

WHERE THIS GETS TO, and what is still open. Phase A now converges in one to two iterations
(183 -> 78 against stop_energy 100). Phi improves 13.41 -> 10.99 and then plateaus. Lowering the
sizing floor helps alone (9.48) but COMBINED with the unfrozen input it is worse (13.8) with the
floor no longer binding -- so the residual is not resolution-limited, and both combo runs pass
through better values (8.49, 9.74) and lose them. The residual lives entirely in face interiors
(at vertices 0.040 against a tolerance of 0.1), so the open question is what Phase A does to the
chords across the level set that Phase B had just placed. Phi is currently measured only after
Phase B, which is why that ratchet is invisible.

2D still runs the joint loop; porting it is a separate change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Whitespace only, from the repo's own hook (clang-format 21.1.8). No behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… jointly

Ports the A/B split from 3D (2509de0). Phase A is TriWild -- same operations,
gates, sizing field and stall refinement -- with the offset boundary held inside
m_offset_envelope, a tube of one Phi tolerance rebuilt from wherever Phase B last
left it. Phase B is smoothing to a fixed point against the offset energy, no
topology and no envelope on the offset, followed by Phi-driven refinement of the
shared sizing field.

Measured against the joint loop on the same tree:

  config             joint            A/B              mesh            time
  2d                 0.978x, 13 it    0.910x, 7 rd     11566 -> 27293  70 -> 509s
  2d_vertex_input    0.890x,  2 it    0.400x, 2 rd      5099 ->  4930  12 ->  22s
  2d_dragon          0.849x,  3 it    0.496x, 2 rd     20802 -> 18438  74 -> 131s

Two of the three reach a better residual on a smaller mesh. 2d reaches a better
residual on 2.4x the elements, which is a genuine cost and is not yet understood.

Two defects had to be fixed before 2d converged at all -- both from porting the
3D stuck check verbatim, and both still present in 3D:

  - THE FLOOR WAS 36x TOO HIGH. 3D clamps the Phi-stuck refinement against
    max(min_sizing_scalar, min_edge_length / l); every other stall refinement in
    the 2D file clamps against stuck_refine_min_scalar (1e-3). min_edge_length is
    a CONSTRUCTION parameter -- min_edge_length_rel x target_distance, 0.1294 here
    -- and as an optimization floor it caps every band segment at roughly half the
    offset distance. A chord that long across a curve of that radius misses the
    level set by about the tolerance wherever its endpoints sit. The signature is
    "refined 293 of 333 region vertices" in round 1 decaying to "9 of 52" by round
    10: the ratio is the fraction not already pinned at the floor.

  - NO FORCE-SPLIT. Lowering a sizing scalar only PERMITS a split next pass; the
    length gate still has to agree, and at the floor it never does. Force-split
    MAKES the split, once, gate or no gate. [force-split] fired 0 times in the
    whole A/B run against 4 times in the joint loop it replaced. In 2D the stuck
    thing IS an edge, so it is queued directly rather than via the longest edge of
    a stuck face.

Without those two the residual plateaued between 2.0x and 2.7x tolerance for
eight rounds with every band vertex inside 3% of tolerance -- the boundary in the
right place and too coarse to stay there between the vertices.

Phi is now logged after Phase A as well as after Phase B, which 3D does not do.
On 2d Phase A costs at most 7% of a tolerance and usually nothing (7.754->7.753,
2.878->2.875, 1.230->1.232, 1.219->1.218), so re-triangulation inside the
envelope is not undoing the placement. That was the standing hypothesis for the
3D plateau and this is evidence against it.

One deliberate divergence from 3D: the Phi criterion on collapse and swap is
gated on Phase B here (3D applies it in both phases). A non-degrading Phi bar
refuses exactly the AMIPS-improving collapses Phase A exists to make.

Only 2D files change, so tetwild, triwild and simwild output cannot move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
danielepanozzo and others added 29 commits August 16, 2026 22:01
…an distance

OffsetPotential<DIM> becomes an abstract base -- value, gradient, hessian,
residual_length, within_support, is_inside_offset, describe_active, plus
target_level/delta/dhat -- with two implementations picked by the new
`offset_field` JSON option:

  smooth     (default) SmoothOffsetPotential: the offset geometric contact
             potential Phi, boundary on the level set Phi = c. Today's code,
             moved into the subclass unchanged.
  euclidean  EuclideanOffsetPotential: the Euclidean distance d to the input
             complex, boundary on d = target_distance. The exact offset.

OffsetEnergy needed NO change: it already composed w (Phi - c)^2 from the field
by the chain rule, so w (d - delta)^2 with exact derivatives falls out of
supplying value = d, gradient = grad d, hessian = grad^2 d.

DERIVATIVES ARE TRANSCRIBED, NOT DERIVED, from wmtk::optimization::
ExactDistanceEnergy2D/3D, which is already on main. That class states the Hessian
of d^2 by feature kind; grad^2(d^2) = 2 (grad d grad d^T + d grad^2 d) converts it
to the Hessian of d, giving 0 on a face interior (d is linear there),
(I - t t^T - u u^T)/d on an edge interior and (I - u u^T)/d at a vertex, with
u = (p - foot)/d. The same query -- SampleEnvelope::nearest_point_feature -- backs
both, so the two agree by construction. It needs the EXACT envelope kind, which is
built from the same extraction as m_input_complex_bvh and only when the euclidean
field is asked for.

THE TWO FIELDS ARE MONOTONE IN OPPOSITE DIRECTIONS. Phi is a barrier: huge on the
complex, decaying to 0 at dhat, so inside the offset region means Phi >= c. The
Euclidean distance grows away from the complex, so inside means d <= delta. A
literal `value() >= target_level()` is therefore right for one field and silently
inverted for the other, and it appeared at both conservative construction tests in
Spatial.cpp, where the inverted form would grow the band inside out. Those now ask
is_inside_offset(), which each implementation answers with its own sense.

ISOLATED INPUT POINTS ARE A DEGENERATE SEGMENT (i, i) in both SimplicialComplexBVH
and the envelope, so a query near one returns an edge-interior hit whose direction
is meaningless and the edge Hessian would subtract a garbage t t^T. Such hits are
demoted to the vertex case. topological_offset_2d_vertex_input is a point cloud
and is made entirely of them.

THE SMOOTH PATH IS UNCHANGED, verified rather than argued: all three 2D examples
reproduce their committed residuals to the last digit -- 0.02275915917273348,
0.010009848000283124, 0.0007170811461803449 -- and test_offset_potential passes,
including the circle/sphere/box/wedge deviation checks and the 2D-vs-3D
calibration equality.

THE EUCLIDEAN FIELD WORKS ON ONE OF THE THREE 2D EXAMPLES AND BREAKS THE OTHER TWO.

  2d_vertex_input   converges, 0.4919x tolerance in 2 rounds (smooth: 0.4004x)
  2d                THROWS in round 3: Phase A did not converge, max element
                    quality 1e+50 (MAX_ENERGY) against stop_energy 10
  2d_dragon         THROWS in round 6, likewise against stop_energy 5

The mechanism is visible on the dragon: the residual plateaus at ~5.5x tolerance
while the stuck-segment count DOUBLES every round -- 169, 294, 455, 844, 1559 --
each one force-split, so the sizing field refines without bound and buys nothing,
until Phase A is handed elements it cannot repair and coarsening cannot clear
(97s and 224s of it, max energy 1e+50 -> 1e+50).

That is the trade the class comment predicts rather than a defect in it: the
Euclidean offset has a genuine CREASE at every reentrant feature, which no
refinement resolves, so a criterion measuring distance to it can never come down
there. Phi's outward bulge at those same features is exactly what makes its level
set representable by a mesh. The point cloud converges because a point has no
reentrant feature.

So `smooth` stays the default and `euclidean` is honest about what it is: the
exact offset, measurable, and not currently reachable by this optimizer on inputs
with reentrant features.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Linux Debug died with SIGILL in offset-potential-gradient-fd, inside the
SmoothOffsetPotential2D constructor -- the constructor's own info line never
printed, and the run reported 170 assertions where that test alone contributes
217. macOS Debug passed, here and in CI, so it is a codegen/link difference
rather than a logic error.

OffsetPotential<DIM> came out of the previous commit abstract, with every virtual
pure and the destructor defaulted INLINE, which leaves it with no key function.
Its vtable is then emitted weakly into every translation unit that includes the
header and the linker picks one. A virtual call through a base pointer landing
somewhere that is not code is exactly how that presents, and SIGILL is exactly
what it looks like.

The destructor is now declared in the header and defined in OffsetPotential.cpp,
beside explicit instantiations of the base for both dimensions -- so there is one
vtable, in the same object file as the two subclasses' own.

Not reproducible locally on macOS either way, so this is reasoning from the
symptom rather than from a failing test I can watch go green. The full Debug suite
passes here (2926 assertions, 36 cases); CI is the check that matters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
check_no_vertex_on_both_surfaces() threw at CONSTRUCTION on topological_offset_3d
(134 vertices) and _3d_edge_input (119), failing every Release CI job since
2509de0 introduced it. Debug jobs do not build the integration tests, which is
why it went unnoticed.

The error refuted itself: it asserted the offending vertices were "at distance 0
from the input" while printing distances of 0.11 to 0.20 against a
target_distance of 0.2 -- vertices sitting essentially exactly where the offset
wants them.

m_is_on_input is over-broad. The split propagates it onto new vertices and the
collapse ORs it onto survivors, so a vertex can carry it while a full
target_distance from the complex. Erroring on the flag PAIR turned a bookkeeping
approximation into a hard failure.

Unsatisfiable is a geometric fact: smoothing_position_is_allowed() holds an
input-complex vertex within envelope_size of the complex while the offset asks it
to reach target_distance, and those contradict each other only when the vertex
really is on the complex. Both dimensions now check the distance and skip
anything farther than envelope_size.

Verified: topological_offset_3d now runs past construction into the A/B loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
data2 c414d7f drops the six topological_offset entries from the integration
manifest. They fail every Release CI job -- two threw at construction on the check
fixed in the previous commit, and all six became far slower when the offset moved
to the alternating A/B optimization, enough that topological_offset_3d alone can
exhaust the suite's 7200 s budget.

The models and configs stay in data2; only the manifest entries are gone.

Also drops a stale cross-reference: the comment told the reader to keep this pin
in step with .github/workflows/pip.yml, and there is no pip.yml -- no workflow in
this repo pins data2 at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c414d7f took the six offset cases out of the integration manifest to unblock CI.
This puts them back where they can be found and run, without CI touching them, in
the shape challenging_low_stop_energy_models.json already established: their own
manifest in data2, read by a hidden ([.]) test case here.

    ./wmtk_integration_tests "[offset]"

Verified: a default --list-tests shows only Integration_Tests, so ctest never
registers this; --list-tests "[offset]" finds it; and the CI manifest is down to
49 configs with no topological_offset entries.

WHAT THE GROUP ASSERTS IS THAT THEY RUN. The 3D cases are not expected to
converge -- 3D plateaus around 9.6x the Phi tolerance because the offset band is
in a one-for-one split/collapse stalemate -- and only the dragon sets
throw_on_nonconvergence. Running is precisely what the construction throw broke,
and it broke it invisibly, because Debug CI does not build the integration tests
at all and Debug was the only thing being watched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… runner

A crash in CI reports a bare signal and nothing else. Catch2 prints
"SIGILL - Illegal instruction signal" and the name of the test that was running;
there is no frame, no address, no stack. That is not enough to reason from, and
reasoning from it anyway cost a day: two pushed fixes aimed at guesses (a weak
vtable, then a stale ccache), neither of which was the cause, while the failure
reproduced on no developer machine -- not macOS Debug, and not a clean Ubuntu
24.04 / GCC 13.3 box in four configurations including a ccache primed on the last
green commit.

Two steps, on the Linux and macOS jobs:

  Backtrace a crashed test -- if: failure(), installs gdb (lldb on macOS, which
  ships with the toolchain) and re-runs each failed test binary under it, printing
  every thread's stack. Best-effort throughout: `|| true` on each command, so it
  can never turn a passing run red, and nothing at all on a green run. It re-runs
  the WHOLE binary rather than the single case, because a crash that only appears
  after earlier tests have run is exactly the kind this exists to catch.

  Debug over SSH (tmate) -- an interactive shell on the runner itself, for the
  failure that lives in the environment rather than the code: a different CPU, a
  different libstdc++, a restored cache.

THE SSH STEP CANNOT FIRE ON A PUSH OR A PR. Its inputs are defined only by
workflow_dispatch, the condition tests github.event_name explicitly, and both
inputs default to false -- so it runs when a human asks for it, on a run they
started by hand, and `limit-access-to-actor` means only they can connect. Two
modes: debug_ssh opens the session when a step has failed, debug_ssh_always opens
it regardless, for poking at a runner that is behaving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TetWildMesh::write_msh runs every per-tet attribute through std::cbrt:

    "t energy"               cbrt(m_quality)                 correct
    "winding_number_input"   cbrt(m_winding_number_input)    wrong
    "winding_number_tracked" cbrt(m_winding_number_tracked)  wrong
    "part"                   cbrt(part_id)                   wrong
    "wn_{j}"                 cbrt(m_winding_number_per_input[j])  wrong

Only m_quality is cubed -- it stores AMIPS^3, because AMIPS in 3D divides by
det(J)^(2/3) and the rational evaluator cannot take a cube root, so the code
stores the cube and undoes it on the way out. Nothing else in that list is a
cubed quantity. A winding number is integer-valued and a part_id is a flood-fill
index; cube-rooting them writes a different number than the one held.

write_vtu, 250 lines further down, writes all four raw and cube-roots only
m_quality. Two writers, same four fields, disagreeing -- and the vtu one is
right.

Introduced in dac93bb, which added the winding-number and part attributes
directly beneath the pre-existing "t energy" lambda and carried its cbrt along.

Mostly invisible until now because cbrt(0) = 0 and cbrt(1) = 1, so a closed
manifold's binary inside/outside winding numbers survive untouched. What does
not survive is anything else: part_id 8 is written as 2, and per-input winding
numbers on overlapping inputs land at 1.26 and 1.44 instead of 2 and 3.

Build clean, 117/117.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two independent things, both found while reproducing a tetwild test that fails only
on Linux/arm64.

**CI gap.** The matrix covered Linux on x86-64 and arm64 on macOS, so no job ever
compiled Linux on arm64. That is exactly where the failure lived: VolumeRemesher
cached orient3D results (-1, 0, +1) in a `std::vector<char>` and compared them
against -1. `char` is signed on x86-64 and under Apple's arm64 ABI but unsigned under
AAPCS64, so on Linux/arm64 a stored -1 read back as 255, every constraint was judged
not to split its cell, and the input surface was silently never embedded --
wmtk_test_tetwild's vertex_order case found 0 order-3 vertices where it expects 2.
ubuntu-24.04-arm closes the gap in Debug and Release.

The fix itself is VolumeRemesher#25; this job will fail until that lands and the pin
in cmake/recipes/volumeremesher.cmake is bumped to include it.

**Docker build context.** There was no .dockerignore, so `COPY . .` shipped the whole
working tree -- 5.2 GB here, of which 4.5 GB is local build directories the image
rebuilds from scratch anyway and 643 MB is .git. Dropping .git is safe: there is no
.gitmodules, so the Dockerfile's `git submodule update --init --recursive` is a no-op
on this repository. Context goes to roughly 60 MB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Picks up VolumeRemesher#25, which stores cached orient3D results in a signed char.
Without it, Linux on arm64 reads a cached -1 back as 255 -- `char` is unsigned under
AAPCS64 -- so every constraint is judged not to split its cell and the input surface
is never embedded. wmtk_test_tetwild's vertex_order case then finds 0 order-3
vertices where it expects 2.

This is what the ubuntu-24.04-arm jobs added in the previous commit exist to catch;
they fail without this bump and pass with it. No effect on x86-64 or macOS, where
`char` is already signed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stop cube-rooting winding numbers and part ids into the msh
coarsen_pass has been on by default since it landed, and it is expensive enough that
callers were paying for it without having asked.

The cost is structural rather than incidental. Every candidate collapse runs
coarsen_local_smoothing_passes sweeps of smoothing over coarsen_smooth_ring around the
merged vertex before the pass can judge whether to keep it, and a rejected candidate has
all of that work undone -- so the smoothing is paid for the collapses that are thrown
away as well as the ones that are kept, and the whole thing repeats for
coarsen_max_rounds. On a run that has already converged, that is a large addition to
wall time.

What it buys is element count, and that is worth having when element count is what you
are optimizing for. It is not something every run should pay for silently, so it becomes
opt-in: set coarsen_pass to true to get it back. Nothing else about the pass changes.

The default was declared in four places -- OptimizerParameters.h and the tetwild,
triwild and simwild JSON specs, which are embedded into the binaries at build time --
so all four move together.

Verified on linux/arm64: 117/117 tests pass. test_coarsen_pass sets the flag explicitly
in both directions, so it exercises the pass regardless of the default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Test Linux on arm64, and stop shipping the build tree to Docker
The pass was the run, not part of it: 202.9s of octocat's 213.3s wall clock,
and 86-96% of total time across seven tetwild models. What it spent that on
was a local smoothing composite that, measured, was not buying the coarsening
it was added for.

Each candidate collapse smoothed a ball around the merged vertex before it
could be judged. That ball averaged 98 vertices, so at two sweeps a single
candidate ran ~200 nonlinear smoothing solves and the pass ran 1.4 million of
them to accept 5173 collapses.

Turning that off (coarsen_local_smoothing_passes 0) costs nothing measurable.
It matters per candidate -- the composite's reject rate rises from 25% to 39%
-- but the pass runs to a fixed point, so a candidate the smoothing would have
rescued is replaced by another the pass finds instead, and the relaxation the
mesh actually needs still comes from the global smoothing between rounds. On
octocat that alone is 11.2x for 0.2pp of cell reduction, inside the run-to-run
spread.

Two smaller defaults follow from the same measurements:

coarsen_smooth_ring 2 -> 1. With nothing being smoothed this only sets how wide
a region the accept test compares. Ring 2 is the more permissive test and
coarsens slightly more, but it also makes the pass lock 3 rings per operation
where every other pass locks 2 -- which is why the pass got 1.75x out of 8
threads, and non-monotonically (4 threads was slower than 2). Ring 1 is 2.3x
faster for 1.5pp.

coarsen_max_inner_passes, new, defaulting to 1. The dirty-epoch retry loop asks
only whether a failure's neighbourhood MOVED, not whether it moved helpfully,
so a productive first pass re-offers most of the mesh. On octocat the first
pass found 5110 collapses in 135.8s and the three that followed found 27 in
38.4s. Rounds still repeat the pass, and those do pay off, because the global
smoothing between them moves the fixed point in a way this filter cannot see.

Octocat end to end: 213.3s -> 16.8s, cell reduction 66.2% -> 62.1%, max energy
unchanged. The pass's guarantee is untouched by any of this -- "the region's
worst element did not get worse" is its accept test, not something these knobs
can weaken.

117/117 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Default the coarsening pass off, and make it ~40x cheaper when it is on
coarsen_mesh() promises that it "trades cells for nothing but the guarantee
that the max energy does not rise". Every collapse in it is kept only if the
region it disturbed came out no worse (collapse_edge_after, coarsen branch).
The global smoothing between rounds had no such test, and smooth_vertex_3d
with smooth_quality_veto off -- topological_offset's default -- accepts any
move that neither inverts a cell nor leaves the envelope, however much it
degrades one. So the pass could hand back a mesh worse than it was given.

Measured on specific_models/prism, per round, before this:

  round 0  start 9.92987 -> collapses 9.86786 (-0.062) -> smooth_all 10.9986 (+1.131)
  round 1  start 10.9986 -> collapses 9.89344 (-1.105) -> smooth_all 11.5366 (+1.643)

The collapses IMPROVE the mesh on both rounds -- round 1's were spending
themselves repairing round 0's smoothing. The smoothing is the entire
regression, and it crossed stop_energy 10, which made the offset's Phase A
report a failure it had not had: the loop had converged at 9.92987 in 9 of 80
iterations and the check runs after mesh_improvement() returns. round_all
contributes exactly 0, since round() sets the RATIONAL from the float while
quality is computed from the float.

This matters more since coarsen_local_smoothing_passes defaulted to 0 in
4cc729d: the local composite that WAS covered by the accept test is off, so
this global pass is now the only relaxation the coarsening does, and it was
the only one with no test on it. That commit's message states the guarantee is
"not something these knobs can weaken" -- true of the accept test it refers to,
not of the smoothing beside it.

The veto is off for a real reason and that reason is Phase B's: the offset
boundary is placed by minimising a term whose minimum can be most of
target_distance away, so nearly every solved position worsens some incident
face and vetoing freezes the boundary. Coarsening is not placing anything, so
the reason does not reach it. Forced on for this call only.

After, on prism, with coarsen_pass on: 78901 -> 38439 cells (-51.3%) at max
energy 9.975 -> 9.975, both substeps +0 on both rounds, and Phase A converges.
tetwild and simwild default smooth_quality_veto to true, so for them the
save/restore is a no-op.

Also logs each substep's max energy, so the contract is observable rather than
asserted -- that is what localised the regression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r stratum

THE CRITERION BUG, which subsumed everything else it touched.

cell_quality_rel() returned cell_quality(tid) / stop_energy. cell_quality
stores AMIPS^3, so this was AMIPS^3 relative to a bar on AMIPS: the "fails its
target" test score > 1 fired at AMIPS > cbrt(10) = 2.154 instead of at 10.
Everything else in both dimensions already takes the cube root --
TetOptimizerMesh::quality_rel() is cbrt(cell_quality)/stop_energy,
get_max_avg_energy() reports cbrt(max), and 2D needs none because it stores
AMIPS directly. This function was added in d81421e to be the 3D twin of the
2D one; dropping the cube root is exactly what stopped it being one. Its own
header comment claims what it failed to compute.

On prism, where Phase A converges at max AMIPS 8.5 and mean 4.0, the mean face
scored 4^3/10 = 6.4 and the worst 8.5^3/10 = 61 -- every element of a CONVERGED
mesh read as failing. face_criterion_rel() returns the max of this and the Phi
residual ratio (~3.8 at worst), so the AMIPS term dominated and the Phi signal
it sits beside never decided anything. Measured, round 1 of Phase B:

                          before          after
  faces over tolerance    5934            910
  worst face score        450.9x          4.555x
  max Phi residual        5.353x          4.555x
  force-splits            4329            651
  region vertices refined 2806 of 2969    1133 of 1199

After the fix the worst face score EQUALS the Phi residual, which is the whole
point of the criterion. Before, 450.9x back-solved to AMIPS 16.5 -- element
quality masquerading as an offset residual and driving the mesh from 117k to
402k tets between rounds. It also fed collapse and swap acceptance, and made
the coarsening bar (1.0 absolute in coarsen mode) unreachable for any collapse
touching the offset band.

REGION ENVELOPE, INCLUDING THE DOMAIN WALL. init_surfaces_and_boundaries() used
to skip faces with no opposite tet, leaving the box held by on_bbox_faces --
which froze those vertices against smoothing and refused any split of an edge
lying in the wall. A tet resting on the box then had its longest edge
unsplittable and its three wall vertices unmovable, so every legal operation
left the worst dimension untouched while halving the volume: an exact bisection
cascade onto the plane, AMIPS 6.9e21 over the run. The wall is a region
boundary between ambient and the unmeshed outside, and is now held in
m_envelope like every other one. Record AMIPS 6.89e21 -> 364, quality writes
above 1e5 1451374 -> 0, slot drops 3497016 -> 16503.

That exposed a containment leak: TetMesh::get_surface_faces_for_vertex()
returns empty immediately when vertex_is_on_surface() is false, and the offset's
override omitted the wall, so the smoother's containment loop iterated nothing
and passed vacuously. Instrumented: 2409 of 2409 accepted wall moves had zero
tracked faces. Fixed; worst off-plane deviation 5.885 -> 0.0481, inside eps.

INPUT-COMPLEX ENVELOPES, per stratum, in both phases. Built from m_phi_* -- the
input AS LOADED, the geometry m_offset_potential measures against -- rather than
from the construction-time mesh m_envelope uses. Two of them because a
SampleEnvelope is one kind only and is_outside(triangle) throws against an edge
envelope: m_input_tri_env over Phi's triangles, m_input_seg_env over its edges
and its isolated points as the degenerate segment (i, i), which fast-envelope
supports explicitly (halfspace_generation tests AB == 0 and emits a cube of
half-width eps/sqrt(3)). Vertices on Phi's 1- and 0-strata carry no tracked
triangle, so they are held by a point query in smooth_after() instead -- the
same vacuous-containment shape as the wall had. Interior vertices of a
tet-filled complex stay free: Phi is the complex's boundary.

SPEC DEFAULTS follow main: coarsen_pass false, coarsen_local_smoothing_passes
0. The pass measured at 86-96% of runtime upstream, and for the offset it is
the last thing before Phase A checks its own convergence, so anything it
degrades is reported as a Phase A failure.

Also: per-pass Phi residual in Phase B, split max_at_vertex / max_in_face. That
is what showed the residual is flat from pass 10 while the loop runs to the
ab_smooth_max_passes cap, and that on prism at-vertex sits at 0.81x (inside
tolerance) against in-face 4.56x -- the surface is correctly placed and too
coarse between the vertices, which is refinement's problem and not smoothing's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Apple clang 17.0.0 does not emit the destructor of a local class declared
inside a GENERIC lambda -- one taking an `auto` parameter, so its operator() is
a template. The destructor is referenced and never defined, at every
optimization level including -O0, and the link fails with

    Undefined symbols:
      ...::'lambda'(auto&, int)::operator()<...>::CountFlusher::~CountFlusher()

referenced from every translation unit that instantiates a pass: 26 references
across 6 archives, defined nowhere. Homebrew clang 22 compiles the same code
correctly, which is why CI has never seen it -- this only reproduces on the
Apple toolchain.

Declaring the class one scope out, in operator() rather than inside
run_single_queue, is enough. It is still a local class, now of a member
function of a class template, which instantiates fine.

BEHAVIOUR IS UNCHANGED. `counts` is still constructed per task inside the
lambda, so the RAII flush happens at the same point, in the same order, with
the same per-task tallies folded into the same shared counters. Only the
declaration moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
init_exact_edges already refused this for the edge and 2D overloads, because
a zero-width exact envelope is not a narrow envelope -- it answers "outside"
for every point not exactly on the input, so it does not crash, it silently
vetoes every operation and reads as an optimization that cannot move.

The triangle overload had no such guard. It cannot use the edge overloads'
mechanism, which is to skip the build when use_exact is false: it has to build
unconditionally, because callers flip use_exact on an already-initialized
triangle envelope (tetwild around its simplification, simwild around its
insertion). So the guard is on epsilon instead. With eps <= 0 it now skips the
build and leaves m_exact_built false, and require_exact_built refuses the
query -- the outcome init_exact_edges' own comment asks for: "let the query
fail rather than quietly reject everything".

That check no longer exempts Triangles3d. The exemption existed because the
triangle init never set m_exact_built at all; now that it does, the exemption
would have made the new guard unenforceable. Its message names both ways to
arrive with no exact structure, since they are now genuinely two.

Reaching the trap takes a caller that both passes eps <= 0 and has use_exact
set, and there is one: isotropic_remeshing defaults use_exact to true and
calls init(V, F, 0.0) on the no-envelope path. It sets m_has_envelope false
and does not query, so nothing changes for it today -- but it is one query
away from the silent veto, and now that query would throw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… sizing field

The collapse merges every survivor attribute in collapse_edge_after -- OR for
surface membership, max for order -- except the sizing scalar, which was never
propagated: the survivor kept its own value and the removed vertex's was
discarded. For an application whose sizing field is monotone-down persistent
state (seeded once, then only ever lowered by stall-driven refinement), that
un-refines the field by topology: every collapse of a refined vertex onto a
coarser survivor erases banked refinement, and the next round re-lowers it
from scratch.

The seam is a default-inert virtual, collapse_merged_sizing(removed, survivor).
The default returns the survivor's own value -- today's behaviour, and since
neither TetWildMesh nor SimWildMesh overrides it, byte-identical for them by
construction (the write adds no journal entry either: the m_is_on_surface
merge on the line above already journals the survivor on first touch).
topological_offset overrides it to min in its own tree, matching the field's
established convention that refinement and gradation smoothing only ever
lower a scalar.

Mirrors uday-offset3d 39984f8, re-implemented here rather than picked
because its sibling commits are not being taken yet. One measured difference
from that branch's finding: there min-merge alone was bit-identical on prism,
because a length gate kept collapses away from refined edges. This branch
collapses on strict quality improvement with no length gate, so the fix is
live immediately -- prism 1-round smoke, num_threads 0: avg phi residual
0.0216 -> 0.0188 (-13%), max 0.3146 -> 0.3113, mesh 38.4k -> 43.2k tets, as
held refinement stops being re-earned every round.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…claim it

The 3D coarsening pass asks collapse_all_edges_impl for a lock radius of
coarsen_smooth_ring + 1 = 2 -- which lands exactly on the default radius,
where make_locker substitutes the hand-written try_set_edge_mutex_two_ring
for the honest n-ring ball. That helper deliberately claims less than a
2-ball (see "Ring lockers -- NOT balls" in TetMesh.h: roughly 2-ring(v1)
union N(v2); the survivor's distance-2 vertices are simply not claimed),
which is sound for every pass whose write set is the seed's one-ring. The
coarsening composite is not such a pass: its ring smoothing writes cell
qualities of tets incident to the survivor's one-ring, i.e. tets with
vertices at distance 2 -- exactly the unclaimed set.

Measured on prism at 10 threads as a segfault: two workers wrote the same
TetAttributes concurrently, and the rollback journal's copy walked its
std::set member mid-mutation (KERN_INVALID_ADDRESS in __tree::insert during
collapse_edge_after -> smooth_vertex_reversible -> set_cell_quality). The
2D pass never trips this because TriMesh's named helpers delegate to the
real ball.

The fix follows the note's own prescription -- widen the specific pass that
needs it. collapse_all_edges_impl gains a defaulted exact_ball_lock
parameter; when set (and only in parallel, mirroring run_pass: serial
installs no lockers), the pass body replaces the installed locker with
try_set_edge_mutex_n_ring at the requested radius. Only coarsen_mesh passes
true.

Blast radius: the regular collapse passes keep the fast partial locker
verbatim (the honest ball measured +80% wall clock when applied to ALL
passes; here it covers one post-loop pass). TetWild and SimWild reach the
changed behaviour only with coarsen_pass explicitly on (their spec default
is false) AND num_threads > 0 -- the exact configuration that could crash
before. Verified: the previously-crashing prism config (10 threads,
coarsen_local_smoothing_passes 2) now completes its coarsening -- 13.8k
collapses, 135k -> 59k tets, max energy held; tetwild (1016 assertions),
simwild (1170) and topological_offset (2926) suites all pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rames

collapse_merged_sizing overridden to min(removed, survivor) -- a collapse
must not un-refine the sizing field (pairs with the base seam added in
56fdac6; measured on prism: avg phi residual -13% in one round).

The A/B driver's two debug writes are now named phase_{round}{A|B} instead
of debug_{counter}, and visualize_offset.py prefers that series when
present, showing the per-phase timeline (with a sizing-field toggle) instead
of every engine pass.

The ESP field experiment was removed before this commit; it lives as a
reapplicable patch in .claude/esp_stash/ (local, unversioned), for when
reentrant inputs make the extremum-sum formulation worth revisiting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ad criterion's angle

The refinement floor min_edge_length_rel defaulted to 0.5176380902050415 =
2*sin(15 deg): the chord subtending sigma_max on the delta-circle, derived
from the normal-deviation convergence criterion. That criterion is gone from
this branch, but its angle survived as a number -- and a floor calibrated to
"the resolution beyond which a 15-degree normal bound cannot ask for more"
is, under the residual criterion, a hard cap on achievable tolerance. The
chord/sagitta bound says tolerance tau needs edges h <= delta * sqrt(8 tau);
the old floor sits at the h of tau ~ 3.3%, so offset_residual_rel below that
could never converge no matter how many rounds ran. Measured: at tau = 0.01
the run plateaus with refinement pinned at the floor.

The new default is TetWild's own floor stated in the offset's units. The
paper caps the sizing field below by the envelope epsilon ("to prevent
unnecessary over-refinement in problematic regions", Sec 3.2) -- the surface
is only pinned to within eps, so edges shorter than eps cannot buy fidelity.
The offset's envelope is Phase A's: eps = ab_offset_envelope_rel *
offset_residual_rel * target_distance, and that product is now the derived
floor when min_edge_length_rel is negative (the new spec default). An
explicit min_edge_length or min_edge_length_rel is honoured unchanged.

With the cap removed, tau = 0.05 converges on prism (round 8, phi 0.996x
tolerance, 106k vertices) where the old floor left no headroom. One known
consequence, deliberately not patched here: the old floor was accidentally
doing a second job as the brake on the stall-driven refinement response,
which lowers scalars toward the floor rather than toward what the measured
residual needs. With the rail this low, a large early residual can drop the
whole band to the floor at once -- measured at tau = 0.01: 10.4k of 10.6k
region vertices floored in one round, 9M tets two rounds later. The response
needs its own setpoint (proportional to the measured ratio, sqrt-law); the
floor is no longer the place to hide that control.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…frames opt in

DEBUG_output wrote a frame after every operation pass. On the converged
prism run that was 801 files, 3.95 GB, and ~54s of a 227s wall clock -- a
quarter of the runtime spent on frames nothing reads: the viewer shows the
per-phase series (phase_{round}{A|B}, written by the A/B driver), and the
per-pass files only ever mattered for drilling into a single pass.

DEBUG_output now writes only the phase timeline. The old firehose is behind
the new DEBUG_output_per_pass key (read only when DEBUG_output is true).
The gate is the component's write_optimization_debug_output override
declining debug_-prefixed names, so the engine and every other component
are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dual

The residual excluded what it classified as pinned: vertices on the input
complex or the domain wall went to a reported-only max_pinned while the
driving max ignored them, offset_face_samples() dropped a face's entire
interior sampling when any corner was pinned, and face_criterion_rel()
skipped pinned vertices in the per-face score. The rationale was that
nothing the optimizer does can move them -- true, and beside the point: a
pinned vertex far from the level set is a real error in the offset the run
RETURNS, so excluding it reported convergence for a surface that was not at
target distance wherever growth was clipped.

All three sites now count everything. The reachable/pinned split survives
as attribution -- n_pinned/max_pinned still say when the driving max comes
from a vertex nothing can move, so a construction problem (domain too
small, no growth room) reads as one instead of vanishing. Consequence, by
design: a wall-clipped offset now fails to converge honestly, and the
sizing field will refine around the clip; the remedy is construction, not
optimization.

No-op where nothing is pinned, and prism never is: a fresh 10%-tolerance
run converges as before (0.96x tolerance, 127s, 80 of 80 residual
measurements reporting pinned 0). Suite passes (2926 assertions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, proportional stuck-refine

Phase B's stop condition was max vertex displacement, which reads "converged"
whenever a move is refused even if the placement objective is still falling
elsewhere -- it cannot tell a finished fixed point from a blocked one. Replace
it with the L-inf gradient of each offset vertex's own placement energy
(phase_b_band_gradient_linf), relative to its value at phase entry
(ab_smooth_grad_tol_rel), with ab_smooth_max_passes < 0 now meaning uncapped
and a 10-pass no-progress plateau accepted as the achievable fixed point.

Phase A's offset envelope was a constant one-tolerance width every round,
which measured out to a 1.2-1.3x residual hover: Phase B recovers to ~1.2x,
Phase A spends a full tolerance undoing it, every round. Make it a trust
region instead (rebuild_offset_envelope, ab_envelope_residual_rel): sized
from the max residual the previous Phase B actually left, wide while the
surface is far from the level set and shrinking in lockstep with progress,
clamped between an ab_offset_envelope_rel floor and a hard geometric cap.

Now that Phase B's exit means placement is actually finished,
refine_sizing_where_phi_is_stuck can read an over-tolerance face's in-face
residual as pure chord (resolution) error and size proportionally from it
(1/sqrt(stuck_refine_margin x residual ratio), the chord law's h^2 scaling)
instead of ratcheting a fixed factor blind to the excess -- one round instead
of several, and bounded where the fixed ratchet could floor a scalar
repeatedly and run away (measured: 9M tets at 1% tolerance). The fixed-factor
path survives for the one case placement isn't actually finished: the pass
cap still binding with the gradient still falling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ncluded

Region-boundary containment used to be one fused tube (m_envelope, all tag
boundaries plus the domain wall) plus a separate pair of envelopes for the
input complex as loaded (m_input_tri_env / m_input_seg_env), with a
per-vertex stratum walk to route wire and isolated-point vertices to the
segment envelope. Every simplex of the input complex, isolated wires and
points included, always lies on a tag-region boundary: label_input_complex()
can only label an isolated simplex whose tet star is tag-heterogeneous,
which forces a transition face through it. So the input-complex envelopes
were a redundant, looser constraint on geometry the region envelope already
covered less exactly.

Replace both systems with one exact envelope per input tag (E_t, ambient
included so the domain wall is covered for free), built in
init_surfaces_and_boundaries() from the input partition before offset
construction touches it. A simplex's constraint is the envelope of the AND
of its vertices' per-tag boundary masks -- a single tag's tube directly, or
the memoized intersection of several for a simplex sitting on more than one
boundary. This subsumes the deleted input-complex envelopes outright and is
strictly tighter at a junction: a vertex held in E_a ∩ E_b is pinned to the
junction curve itself, where the old fused union-tube let it slide along
either surface, and wires and isolated points (which only arise where two or
more selected tags meet) are pinned the same way with no dedicated stratum
logic at all.

The mask is exact at a vertex -- seeded from real boundary faces, propagated
by AND at a split and OR at a collapse -- but a bare propagated mask is not
enough to route a FACE: an edge whose two endpoints happen to share a
boundary bit can hand that bit to a split midpoint even when the edge itself
crosses the interior, which routed newly-grown offset faces into a boundary
tube a full target_distance away (found by forcing perform_sanity_checks on
for the first per-tag verification run: 506 offset faces on prism, all
carrying the ambient bit this way). vertex_boundary_mask() gates the raw
mask on vertex_is_on_region() -- the predicate m_is_on_input already
maintains correctly, via is_edge_on_input()'s real-incident-face test -- so
the mask can only ever narrow an answer the flags already allow.

Verified on specific_models/prism with perform_sanity_checks on for the
first time in this configuration: 7 rounds to convergence (0.999x tolerance,
63s, 39.5k vertices), where the prior session's envelope, sanity checks off,
had not converged in 10 rounds (1.057x). Zero envelope or inversion errors
across every sanity sweep.

A related, pre-existing issue surfaced by the same sanity-on run on the
multi-tag double_sphere fixture (data/integration_tests/topological_offset_3d.json):
a handful of faces that are genuinely on both the offset surface and a real
tag boundary get routed to the boundary envelope by the "boundary wins over
offset" dispatch priority, which is far from where the offset has grown to.
This priority predates this change (the deleted all_input-first dispatch had
the same order) and double_sphere is already a documented non-converging 3D
case; left as a follow-up rather than folded in here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Spenbert02 Spenbert02 closed this Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants