Skip to content

3D offset, A/B optimization - #1055

Open
Spenbert02 wants to merge 90 commits into
mainfrom
offset-2d-triwild-parity
Open

3D offset, A/B optimization#1055
Spenbert02 wants to merge 90 commits into
mainfrom
offset-2d-triwild-parity

Conversation

@Spenbert02

Copy link
Copy Markdown
Collaborator

No description provided.

Spenbert02 and others added 30 commits August 14, 2026 12:07
Convergence and measurement
- port compute_distance_deviation() / compute_normal_deviation(), per-iteration
  optimization_metrics and op_counts, the convergence test, the per-criterion
  did-not-converge warnings and throw_on_nonconvergence, matching Optimize2d.cpp
- port SmoothTrace and log_smooth_trace(); three counters have no 3D mechanism
  to raise them yet and stay at 0 so the two logs diff cleanly
- face_normal_deviation() is now paper Definition 5 (offset field normal at the
  triangle centre vs the field normal at three near-corner samples), and the
  sizing field is driven by it
- init_offset_sizing_field() seeds the field from the offset's current edge
  lengths; l_min = 2*delta*sin(sigma_max) floors the sizing scalar
- the collapse guard uses max(sigma_max, nd_before) rather than switching off
  once a patch is already over the bar

Correctness
- freeze the input complex and the bounding box: split, collapse and swap all
  refuse them via vertex_is_frozen() / edge_is_frozen(). Measured on geneva_base,
  383 of 709 input-surface vertices were being deleted outright and survivors
  displaced by up to 2.3x the target distance
- consolidate_mesh() once per iteration in optimize_offset(). TetMesh hands out a
  fixed preallocated slot pool and a collapse only marks its slots removed, so
  the split pass exhausted it and TetMesh::split_edge() then bailed before
  split_edge_after() -- silently, with no application hook involved. Splits went
  [10883, 0, 0, 0, 0] -> [10883, 7436, 5093, 5339, 5412] on prism
- cell_in_region() / cell_is_offset_band() / cell_is_input_complex() read the
  per-cell label instead of tags, so an offset output tag that already exists in
  the input mesh cannot masquerade as the band; swap_capture_tag() carries the
  label across, which it previously did only for the tag
- split_adjust_position() writes the new vertex's tracked-surface membership
  before the shared split's containment check reads it
- tag_tet_consistent_topology() applies the domain-boundary rule to its vertex
  test as well as its edge test
- two consolidate asymmetries removed, so execute_offset() no longer changes
  numerically when DEBUG_output is on

Removals, matching 2D
- the SphereTracing marching pass, the BinarySearch/LogRootFind/SphereTracing
  modes and their helpers, and edge_search_termination_len
- the optimize=false path; optimize_offset() is now unconditional

BREAKING: the spec is strict, so any config still carrying `optimize` or
`edge_search_termination_len` now fails to parse. Delete those keys.

Known gap: vertex relocation raises avg_dist_err every pass, which is what keeps
prism, box and geneva from converging. A measured alternative (selecting among
the damped blend, the quadric optimum, the distance projection and the current
position by distance error) is implemented but disabled behind #if 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two changes to the offset-surface smoother, both taken from the paper's
reference implementation (wildmeshing/topological-offsets).

Pool the quadric over all incident faces. Follows the reference's
vertex-level Quadrics constructor, which sums one quadric over every
sample of every incident offset face weighted by w*area, and places all
sample planes at a single shared distance. Behaviour-neutral on its own:
that sum is algebraically identical to the per-face quadric scaled by
area which this built before. It is adopted because it is the shape a
spatially adapted delta-hat would attach to.

The reference's damping of that shared distance, 0.5*(delta + dist_min),
is deliberately NOT applied. Measured on prism, final iteration:

  undamped delta                   max 0.1647  avg 0.0595
  0.5 * (delta + dist_min)         max 0.3540  avg 0.0459
  0.5 * (delta + own distance)     max 0.3351  avg 0.1218

It buys average distance error at more than twice the maximum, and the
maximum is the criterion the run is failing. The reference can afford it
because distance adaptation (paper 5.3.1) runs first, so its offset
starts near-correct and dist_min is already close to delta-hat; this
implementation skips adaptation, so the term becomes a systematic inward
brake. All three variants are recorded at the site.

Reject a smoothing search that finds no legal step. The reference backs
the move off geometrically and, if every fraction inverts, restores p0
and returns false. This returned true unconditionally, so a vertex that
received none of its correction was counted as an accepted smooth and
its unchanged error was averaged into "err over moved verts" alongside
vertices that had actually moved. Partial fractions are still accepted,
as in the reference; only a search reaching nothing at all is refused.

Numerically identical on prism, value-for-value across all five
iterations, since move_to() already left the vertex at p0. What changes
is that 19 rejections across the run become visible where the log had
read "accepted N/N, inverted 0" every iteration.

Note this diverges from 2D, whose project_offset_vertex() still accepts
its clamped result and returns success. Deliberate, on the reference's
authority rather than forced by the dimension; the log line is unchanged
so the two still diff field-for-field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
m_vertex_attribute[].m_is_on_surface was never set anywhere in the 3D
component -- not at init_surfaces_and_boundaries() for the input complex,
not in optimize_offset()'s labelling loop for the offset boundary. It is a
different field from m_vertex_extra[].m_is_on_input / m_is_on_offset, which
say WHICH tracked surface a vertex belongs to; this one says that it belongs
to one at all, and it is the field the shared operations read. 2D sets it in
label_offset_boundary().

TetOptimizerMesh::is_edge_on_surface() short-circuits on it before it looks
at the face attributes, so no offset edge was ever recognised as carrying
tracked geometry. That left dead, for the whole 3D optimization:

  - the collapse's surface link condition and preserve_topology, both gated
    on VA[v1_id].m_is_on_surface (TetOptimizerMeshCollapse.cpp);
  - the split's propagation of the flag to the vertex it creates
    (TetOptimizerMeshSplit.cpp: m_is_on_surface = cache.is_edge_on_surface,
    which was therefore always false);
  - the surface-face caching the collapse and swaps do under the same guard.

Measured on specific_models/prism, with the split path instrumented: 0 of
~1700 offset-edge split attempts per iteration were seen as surface edges;
1727 with this change.

Behaviour: 2D is bit-identical (it already set the flag) and so is prism,
whose failure is dominated by the sizing and slot-pool defects that follow.
The two 3D integration tests shift slightly -- double_sphere's final avg
dist err 0.008250 -> 0.008096, edge_input's 0.019986 -> 0.020220 -- which is
what turning on surface handling that was previously skipped does to the
order operations are applied in. Both still pass their manifold checks, and
max_dist_err is unchanged on both, since the vertex that pins it is not one
any operation was reaching either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
request_tet_slots() / request_vert_slots() return -1 when the reservation
would exceed the storage preallocated at the last consolidate, and the
caller then aborts before mutating anything. That abort is invisible: it
happens inside the connectivity update, before split_edge_after(), so no
application hook runs, no rejection is recorded, and nothing is logged. A
pass that delivered a fraction of the work it was asked for looks exactly
like one that had nothing to do.

mesh_improvement() is insulated -- it consolidates every iteration and the
queue is re-collected, so the work is merely deferred. A caller that runs
split_all_edges() once loses it outright. The 3D offset optimization does
exactly that: on topological_offset_3d two of its three split passes drop
8020 and 5789 operations.

Counted at the abort sites, one per aborted operation, covering edge split,
face split and the swaps. Deliberately NOT counted inside the two
allocators, which would be the tighter place to catch every caller: one
operation makes several slot requests -- a 2D split asks for one vertex slot
plus one triangle slot per incident face, all unconditionally before
checking -- so counting requests overstates the number of operations lost by
roughly the valence, 3x on the 2D split path.

Reset at the top of split_all_edges() and reported at the bottom, next to
the high-valence line. Note the figure is still an upper bound on distinct
work lost, not a count of it: the executor retries a failed operation, so
the same edge can be counted more than once.

Observation only -- no behaviour change, verified bit-identical on prism and
on all four offset integration tests.

Deliberately NOT paired with a consolidate-and-repeat loop here. Repeating
the pass until the pool stops running dry is the obvious fix, and it wants
re-measuring rather than assuming: the one attempt so far sent prism to 1.6M
tets, but that was measured against a sizing field that has since been left
alone, so the result does not carry over. The retry, when it comes, is
offset-scoped and needs nothing from this file beyond the accessor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 2D counterpart of the TetMesh counter, same mechanism and same reason:
the reservation fails, the caller aborts inside the connectivity update
before any application hook, and nothing downstream can tell the operation
apart from one that was never worth doing. Counted at the abort sites, one
per aborted operation.

Added because the reasoning about 2D that preceded it was wrong, and this is
what shows it. The argument was that 2D does not hit this: its offset loop
never consolidates -- the only consolidate_mesh() in TriOptimizerMesh is
inside mesh_improvement(), which the offset does not call -- so its pool is
still sized from a high-water mark reached during construction, and 2D
converges on both integration tests. The first run with this counter reports
22534 operations dropped in a single split pass of topological_offset_2d.

What that does and does not mean. The same pass performed 15428 successful
splits and took the mesh to 16261 vertices, and the run converged two
iterations later at max dist err 0.0141 against a target of 0.025. So 2D is
losing the tail of a split pass, not its substance, and nothing here is
evidence that 2D is broken. The margin protecting it is incidental rather
than designed, which is the reason to have the warning: it would vanish the
moment a consolidate were added to the 2D loop, as 3D has one.

Observation only: all four offset integration tests are bit-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ywhere

init_offset_sizing_field() applied the paper's rule -- the sizing field "is
INITIALIZED WITH THE CURRENT LENGTH OF EACH EDGE" -- to offset-surface
vertices only, and left every other vertex at the default scalar of 1, i.e.
a target length of m_params.l = length_rel * bbox_diagonal. That is a number
with no relation to the mesh: on specific_models/prism it is 4.18 against a
background whose own edges are longer, so background edges above (4/3)l were
split and those below (4/5)l collapsed, and ~20 of the 2640 vertices each
split pass created were on the offset.

The paper's sentence is not about the offset specifically, so apply it to
every vertex. l is then derived rather than read: once each vertex carries
its own resolution, l is only the constant those scalars are expressed
against -- what reaches the split and collapse gates is l * s_v, the
vertex's own mean incident edge length, whatever l is. Setting it to the
largest of those means makes max_sizing_scalar = 1 mean something real (no
vertex may be asked for an edge longer than the coarsest place in the mesh
already has) and, being an upper bound, keeps the top clamp from binding.

Independence from the shared engine was checked rather than assumed:
m_params.l has no reader anywhere in src/wmtk outside
OptimizerParameters::init_lengths_from_diagonal(), which derives
splitting_l2 and collapsing_l2 from it -- both re-derived here alongside it,
and both read only by the two length gates. min_sizing_scalar and
max_sizing_scalar occur in this component and nowhere else. The four engine
readers of m_sizing_scalar are the split gate, the split's mean-of-endpoints
propagation, the collapse gate and the gradation helper. This runs after all
construction, so marching and growth are untouched.

WHAT IT DOES NOT DO. It does not stop the split/collapse churn: prism still
goes ~800 -> ~4400 -> ~800 per iteration and the slot pool still runs dry.
Seeding from current lengths does not imply that no edge starts in band --
the seed is a vertex's MEAN incident length while the gates compare a single
edge against it, and prism's edge lengths span a factor of 125, so a large
fraction sit outside (4/5, 4/3) of their own local mean either way. The
comment at the site records this.

What it buys is that the background stops being driven toward a length it
never had, so less of the split budget is spent there. Prism's final
iteration: max dist err 0.1647 -> 0.1257, avg 0.0595 -> 0.0455, avg normal
deviation 19.7 -> 17.9 deg, live vertices after the collapse pass 630 -> 794.
Both 3D integration tests improve on every reported figure and neither
regresses: double_sphere avg dist 0.008096 -> 0.008044, max normal deviation
23.51 -> 22.94; edge_input avg dist 0.020220 -> 0.019440, max normal
deviation 65.86 -> 62.99. All four manifold checks pass. 2D is bit-identical
-- it has the same omission in its own init_offset_sizing_field(), left
alone here because it converges today and this change is not the thing that
would make it converge better.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…on, everywhere"

This reverts commit 08ced6d.

Two things wrong with it, both structural rather than numerical.

It silently voided length_rel. The commit derived m_params.l from the mesh
and overwrote whatever length_rel had produced, so a documented parameter --
"Target edge length relative to the bounding box diagonal" -- stopped having
any effect on the 3D optimization. Changing what a knob means is not a thing
to do as a side effect of a fix.

It replaced the engine's sizing strategy rather than using it. The
post-refactor design is that the field starts uniform at 1, i.e. at the
target resolution length_rel asks for, and is only ever ratcheted DOWN
(stuck_refine_factor, floored at stuck_refine_min_scalar, graded by
gradation_smooth_sizing) where the optimizer is stuck --
TetWildMesh::refine_sizing_around_worst() is the reference implementation of
that. Seeding every vertex from its own current edge length is a different
strategy, not an extension of that one.

The offset already plugs into the engine's contract: it implements
refine_sizing_around_worst() in terms of its own update_sizing_field(), so
its criterion is normal deviation and distance error where tetwild's is
AMIPS, and it grades with the same wmtk::utils::gradation_smooth_sizing().
Whatever the offset needs next should extend that, not go around it.

Worth recording, since it does not survive the revert: the change measured
better on every 3D figure and worse on none (prism final iteration max dist
err 0.1647 -> 0.1257, avg 0.0595 -> 0.0455; both 3D integration tests
improved; 2D bit-identical). It is reverted because of how it got there, not
because of where it got to.

And one claim it rested on does not survive either. "The offset spends its
split budget on the background" was written up as a defect. Under the
engine's strategy, remeshing the background toward length_rel is the
configured behaviour -- that is what the parameter asks for. What remains a
defect is that the work is then silently dropped when the slot pool runs
out, which the two counter commits now report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The split pass abandons operations once the preallocated slot pool runs dry
(see TetMesh::slot_exhausted), and only consolidate_mesh() returns the pool.
One split pass per iteration therefore delivered a fraction of the
refinement the sizing field asked for: on specific_models/prism, 19 of the
~723 offset-surface edges the field wanted split were split, and the offset
surface coarsened from 1172 to 490 faces over five iterations while the
sizing target went unmet by a factor of 7.5.

Repeat the split pass, consolidating between attempts, until it stops
reporting exhaustion (bounded at 8 attempts so a pass refusing splits for
any other reason cannot spin). With splits landing, prism's worst vertex
stops being the same pinned location every iteration and its max_dist_err
moves for the first time: 0.34 after one iteration against 0.40-0.16
flatlined before.

Also add two component-local diagnostics used to establish the above and
kept for the work that follows: a histogram of OFFSET-surface edges against
their own l*s target (the global histogram is dominated by background edges,
whose target is the configured length_rel scale and whose churn is
configured behaviour, not a defect), and a counter of accepted collapses
that removed an offset-surface vertex.

Known cost, deliberately not hidden: with refinement actually landing, the
split/collapse pair does far more work per iteration and the runtime rises
accordingly; the collapse-side follow-up in the next commit is what makes
the work stick rather than be re-done every iteration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paper Sec. 5.3.3, Step 2: "a collapse is only performed if the user-defined
maximum normal deviation is not exceeded." The reference implementation
ships this rule FLAT -- its NormalDeviationAfterInvariant has a
compare_with_before mode, and the offset collapse constructs it with the
mode OFF (OffsetOptimization.cpp:1135), so the softer variant exists there
and is deliberately unused.

This component softened the bar to max(sigma_max, nd_before) -- refuse only
a collapse that leaves the patch worse than it already was -- to avoid
freezing patches that sit over sigma_max on a genuine feature. Measured on
specific_models/prism, that softening is the leak that was undoing the
refinement the previous commit made land: nd_before is the MAX over the
patch, so a single 35-degree face at a sharp edge licenses coarsening of its
entire neighbourhood (nothing there can raise the max), and the guard fired
only 36-270 times per iteration against ~2800 accepted offset-vertex
removals. Each collapse pass took the offset surface 4x past its own sizing
target -- 50.2% of offset edges beyond 4/3 of l*s afterwards, with the
sizing field unchanged across the pass -- and destroyed 75% of its faces.

Flat, on prism: the guard fires 3800-22600 times per iteration, the collapse
keeps 52% of offset faces where it kept 25%, and max_dist_err falls
monotonically 0.676 -> 0.247 -> 0.104 where the worse-of bar plateaued
around 0.11. avg dist err 0.0127 and avg normal deviation 10.0 deg, both
better than any worse-of run. The four offset integration tests: both 2D
bit-identical (this file is 3D-only), both 3D unchanged in their outcome --
their max_dist_err is pinned by a construction defect this cannot touch (see
below).

Costs and open questions, for whoever picks this up:

- RUNTIME. A face over sigma_max at a genuine feature can no longer be
  coarsened, so crease bands refine to the l_min floor and gradation drags
  that fine sizing into the surrounding volume, x8 tets per halving. On
  prism, iteration 4 reached 2.8M edges and >10 minutes; the run was killed
  there with the convergence trend pointing down but unproven. The
  refinement is bounded (l_min = 2*delta*sin(sigma_max), the paper's own
  floor) but the constant is heavy. Where the time actually goes has not
  been profiled.

- 2D DIVERGENCE. 2D keeps the worse-of bar: it converges with it, and its
  recorded counterexample (429 -> 80 decimation, 8 refusals) was against the
  ratchet rule, not against flat. If flat is ever wanted in 2D it needs its
  own measurement.

- CONSTRUCTION DEFECT, separate from all of the above: on both 3D
  integration tests the worst vertex is on_offset AND on_input -- the
  constructed offset touches the input complex. Such a vertex is frozen by
  design (smoothing refuses on-input vertices, collapse may not remove
  them), so its distance error is ~delta forever and the tests cannot
  converge under ANY optimization change. The paper's offset by definition
  never touches S, so this is a marching/growth-phase defect, present since
  before this branch. It caps what convergence can mean here until fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
optimize_offset() ran a hand-rolled loop: one split/collapse/swap/smooth
round per iteration, then halve the sizing scalar at EVERY offset vertex
failing a criterion, every iteration. The unconditional halving is what made
the runtime explode once refinement actually landed: sigma at a genuine
crease exceeds sigma_max at any resolution, so the crease bands ratcheted to
the floor regardless of whether refinement was helping, gradation dragged
the fine sizing into the surrounding volume at x8 tets per halving, and
prism reached 2.8M edges by iteration 4.

Replace it with mesh_improvement(), plugged in the way simwild is, through
the three virtuals:

  - optimization_quality_stats(): (max, avg) distance error over the band's
    outer vertices, normalized by convergence_target;
  - optimization_stop_metric(): 1.0;
  - refine_sizing_around_worst(): the engine's stall-driven ratchet --
    TetWildMesh::refine_sizing_around_worst() with distance error in place
    of AMIPS energy -- selecting the worst stuck_refine_num_worst band
    vertices, growing stuck_refine_rings, x0.5 to the l_min floor, graded.
    The paper's 1.5x growth where the surface is flat, in-band and
    well-shaped is KEPT (a recorded decision, to revisit): it now runs on
    stall rather than every iteration, a change of frequency, not rule.

DISTANCE ONLY drives the loop: the criterion's other half is an AVERAGE
normal deviation, an average cannot be a max-based stop, so it is tested
after the loop instead -- a run can exit converged-on-distance and be
reported unconverged. Deliberate and recorded.

The stall machinery is the offset's PRIMARY refinement mechanism, not an
escape hatch, so Parameters::init() turns it on (num_worst 100, rings 2 --
starting points, not tuned; force_split off because the override selects
worst band VERTICES and the engine's force-split contract wants worst
cells). update_sizing_field() is absorbed and deleted; the split-retry loop
from the previous commit is superseded by the engine's
consolidate-per-iteration.

Where this leaves prism (7 iterations unless said):

  - 20 seconds, was minutes-to-killed. Stall-driven sizing removes the
    volume blowup entirely.
  - max_dist_err 0.162 against target 0.042: a FIXED POINT, bit-stable over
    30 iterations. The remaining blocker is measured precisely: the split
    queue is longest-first in ABSOLUTE length, so the offset's edges -- the
    shortest, 96% above their own gate -- sit exactly in the tail that the
    slot pool's exhaustion cuts off, every pass, while the halo churns
    (split-to-convergence leaves children below 4/5 of target, collapse
    frees them, survivors land above 4/3, repeat).
  - The budget is the one lever that measurably moves it:
    preallocation_factor 6 -> 0.162, 40 -> 0.140, 100 -> 0.109 at iteration
    13 and still descending, cost roughly linear in the factor. 2D is the
    precedent: its loop never consolidates, so it runs on the high-water
    pool from construction -- effectively unbounded, which is why its splits
    all land (833 -> 16261 in one pass, 19x, impossible under 6x).
  - A gradation-boundary leak in the collapse length gate (mean-of-endpoints
    sizing across a sizing step) was hypothesized and REFUTED by count: 399
    of 200245 accepted collapses were mean-free but not min-free.

Open, in order: the split priority for non-uniform sizing fields (relative
to target rather than absolute would serve the offset first, but that is
engine semantics and needs its own discussion); the pool budget policy; the
construction defect that pins both 3D integration tests (offset touching the
input complex -- unreachable by any optimization change, unchanged here).
All four offset integration tests behave as before this commit: both 2D
converge with identical numbers, both 3D fail on their pinned vertex, with
double_sphere's avg dist err improving 0.0080 -> 0.0091 vs the previous
commit's run under a different iteration structure (neither converges nor
can, see above).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
scripts/visualize_offset.py, the topological_offset counterpart of
visualize_triwild.py: point it at a run directory or a .msh (plus optionally
the config) and get the input surface, the band's outer offset surface, and
the inner interface as toggleable layers, with distance-to-input and
|dist - delta|/delta scalars on the offset surface.

Three things the obvious implementation got wrong, each caught by
cross-checking the error layer against the C++ log rather than eyeballing:

- The physical groups OVERLAP (the writer puts a cell into every group whose
  tag set contains that tag) and the input complex is not "every non-ambient
  group": for "tag_0 & tag_1" it is the intersection region only. Cells are
  collapsed to the C++'s three labels by evaluating the config's
  offset_selection expression on each cell's own group memberships.
- Point-sampling the reference surface is not good enough: the input complex
  is frozen at construction resolution, so its edges are comparable to delta
  and the sampling gap inflated avg err from 0.08 to 0.30. Distances are
  exact point-to-triangle (point-to-segment in 2D).
- The reference is the band's INNER interface, which hugs the actual complex
  whatever the selection was; the union boundary of the named groups is the
  wrong surface entirely.

Validation, viewer vs the C++ log's final metrics (avg, max of err/delta):
prism 0.0481/0.1939 vs 0.0481/0.1939 (exact); topological_offset_3d
0.0832/0.8722 vs 0.0805/0.8722 (max exact; avg differs by the
inner-interface-vs-BVH reference, ~3%). Inputs whose complex is a curve or
vertex set (both 2D tests, 3D edge_input) have no region to measure against;
the layer is omitted with a message rather than faked.

Same dependencies and venv as visualize_triwild.py (polyscope, meshio,
numpy).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It already carries polyscope, meshio and numpy, so no separate venv is
needed; verified the validation numbers reproduce under it exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…range

The error scalar (reds colormap) is enabled by default, so the surface never
renders blue -- but the checkbox said blue. The panel now has an explicit
colour-mode radio for the offset surface, each option naming its colormap
and the value range it maps (err is pinned to [0, max] so white is exactly
zero error), with solid blue as the third mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
set_mode() called ps.get_surface_mesh unconditionally, but in 2D the offset
is registered as a curve network and carries no scalar layers, so opening
any 2D result crashed. Guard moved to the top of set_mode; main() now
exercised headlessly on a 2D and a 3D case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 2D integration models' selections have no cell region, so the input and
inner layers are legitimately empty -- and the viewer then showed nothing
but the offset. Two layers fix that: the EnvelopeSurface line entity the 2D
writer already emits (the region-boundary geometry the envelope was built
from, the closest thing in the file to the input), and the facets where the
two sides' tag memberships differ -- the same rule label_offset_boundary()
classifies by -- shown in green and on by default whenever the input-surface
layer is empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
Spenbert02 and others added 24 commits August 18, 2026 21:24
…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>
- New convergence criterion: max |grad (Phi - c)^2| over the offset surface,
  replacing the Phi-residual bound (offset_gradient_rel, default 0.2). It is the
  stationarity condition of Phase B's own objective, so it means the same thing
  for any Phi and carries to ESP without a length conversion.
- Measured at band vertices AND at face-interior samples, on the residual's own
  lattice, reported split as at-vertex vs in-face. A vertex-only test calls a
  chording surface converged: on prism tau=0.01 the vertex term is under
  tolerance from round 4, while in-face needs four more and is still 10.7x
  larger at convergence.
- Phase B's stop test stays vertex-only -- its sweeps cannot move a face sample.
- Phase B places the offset with a 1-D root find on Phi(x) = c along the normal
  (smooth_offset_vertex_backtracking) instead of the shared AMIPS-blended 3-D
  solve, whose offset Hessian is rank one.
- Both Botsch-Kobbelt collapse gates behind env flags, default off:
  WMTK_OFFSET_COLLAPSE_LENGTH_GATE (4/5) and WMTK_OFFSET_CREATED_EDGE_GUARD.
- Phase A's envelope trust region and the proportional stuck-refine margin are
  gone (ab_envelope_residual_rel, stuck_refine_margin).
- Split-churn instrumentation (m_op_epoch); ipc-toolkit pin bumped.

Report gains max_grad, avg_grad, max_grad_at_vertex, max_grad_in_face,
offset_gradient_tolerance and collapse_gates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
23 commits of shared-optimizer and simwild work. Conflicts were in src/wmtk
only; main's version taken throughout, per the merge instruction.

Resolutions:

- TetOptimizerMeshCollapse.cpp, TriOptimizerMeshCollapse.cpp -- took main's
  b2f216e wholesale. The length gate moves back to the candidate list
  (is_weight_up_to_date) and our acceptance-time block, which admitted an
  over-length edge on strict improvement of the ring's worst element, is
  removed. That block existed only to make an unfiltered candidate list safe,
  so with the filter restored its rationale is gone; collapse_quality_allowed
  still gates per cell. Main's known triwild20k 189017 @ eps_rel 1e-4
  regression comes with it -- accepted there deliberately, chased separately.
  Side effect for the offset: the 4/5 gate is now unconditional, which is the
  cell the prism 2x2 measured as the winner, and it makes the component's
  WMTK_OFFSET_COLLAPSE_LENGTH_GATE flag redundant.

- TriOptimizerMesh.cpp -- took main's structure (debug_output plumbing, the
  ops[i] > 0 guard, update_attributes()), keeping our two hooks that the
  offset component overrides: optimization_bare_coarsen_passes() and
  optimization_debug_checkpoint().

Auto-merged and kept from main, worth noting: 54b12ae removes the split
quality guard and max_quality_before from both SplitCaches (nothing here
referenced it), update_attributes() lands as a defaulted no-op virtual so the
offset needs no implementation, and stuck_refine_cooldown now defaults to 1.

wmtk_test_topological_offset 2957/37, wmtk_test_simwild 1180/29,
wmtk_test_tetwild 1016/22 all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tput mutating the mesh

Sizing field. update_band_sizing_from_tolerance() replaces
refine_sizing_where_phi_is_stuck() in the A/B loop. It halves a band vertex's
sizing scalar only when the vertex AND every surface one-ring neighbour are in
tolerance while some interior sample of an incident face is not -- pure chord
error, the one thing a finer sizing field can fix -- and otherwise leaves it
alone. A vertex that is itself out of tolerance is misplaced rather than
under-resolved, so refining around it only grows the mesh where Phase B has not
finished. "In tolerance" is the convergence criterion itself, sampled on the
lattice for_each_offset_face_sample() defines, so the rule responds to the
quantity that decides the run rather than to a proxy. An earlier version also
doubled the scalar for misplaced vertices; that branch made every round from the
third onward worse and is not here.

Prism 127891 at tau = 0.01 converges in 9 rounds, 771s, 1.07M tets, max_grad
0.016723 against a tolerance of 0.016736, max_dist_err 4.9e-4.

Both optional collapse gates are gone. The 4/5 length gate is unconditional in
the shared pass as of main b2f216e, applied to the candidate list, so the
component's copy was the same test on an already-filtered list -- except in
coarsen_mesh(), where a length gate is exactly wrong. The created-edge guard
from uday-offset3d c82383e did not converge prism at all: 4 rounds, 358k
operations, 4468s, max energy rising to 3.06e20, and with the 4/5 gate as well
it threw in round 3 on a trapped sliver with no legal exit. Both removals keep
the measurements as comments so neither gets re-added by inference.

Debug output is observational again. write_vtu() no longer calls
consolidate_mesh(); it packs its cell arrays locally instead (3D keeps
capacity-sized point arrays and slot vids, 2D needs a slot -> packed remap
because it sized by live count while indexing by slot). That removes the reason
TopoOffsetTriMesh::optimization_debug_checkpoint() existed -- it consolidated on
every pass whether or not anything was written, so that turning DEBUG_output on
would not change the run -- and the override is deleted. The renumbering was not
cosmetic: under kPartition, get_partition_id() is keyed on vertex id, so
compacting moves vertices between threads and changes operation order.

write_msh_groups() now emits `sizing` as a vertex attribute, so the result file
carries the field the per-phase VTUs already did and the viewer can show it.

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

Phase B no longer solves each offset vertex to its own minimum. Each pass gives
every eligible vertex ONE local iteration -- an offset vertex a damped Newton step
(eigenvalue-shifted Hessian) with an Armijo line search on w_amips*AMIPS +
w_off*(Phi-c)^2, a background vertex one Newton step on its one-ring AMIPS -- and
the phase runs ab_phase_b_iterations (new spec key, default 20) of them, exiting
early only on the run's own convergence criterion. Solving each vertex to
convergence let a vertex race to a fixed point its neighbours had not seen; where
two offset fronts approach the same curve that crushed the elements between them.

kStepFrac and kMaxDescentIters are GONE. The placement's hard-coded 1e-3 x delta
step existed because (Phi-c)^2 alone has a rank-1 Hessian -- singular along the
level set's tangent -- so Newton had no direction and only a normalised gradient
plus an arbitrary length was usable. AMIPS being back in the objective fills that
nullspace, and the shift makes it robust rather than assumed.

Three experiment toggles, all default-off, all documented at their definition:
  WMTK_OFFSET_PLACEMENT=custom|descent -- the offset term. `custom` is the
    normal-projected gradient ((Phi-c)(grad Phi . n))^2 confined to {Phi >= c};
    the constraint is load-bearing, since without it the term's global minimum sits
    outside dhat where Phi and grad Phi both vanish (measured: 20 vertices escaped
    to 4.74x target_distance and the run aborted).
  WMTK_OFFSET_NORMAL=surface -- offset_vertex_normal() is now the single definition
    of an offset vertex's normal; default projects to the input complex.
  WMTK_OFFSET_INTERFERENCE_PIN -- books offset vertices whose level set does not
    exist (Phi > c, grad Phi tangential to n, Phi a minimum along n) out of
    max_reachable. Correct diagnosis, too narrow to act on: 1 vertex of 213, 5 of
    80 passes. Kept for the diagnosis, off by design.

log_refine_block_census() attributes every element above the energy filter to the
first gate refusing each of its edges, and locates it (distance to complex, Phi/c).
It answers the question the stuck-refine census does not: not what is broken, but
what stops the mesh fixing it. On two_circles at delta 0.1 it refutes both standing
hypotheses -- 2 containment refusals in 21528 edges, zero offset-class, and 87% of
bad elements have a freely splittable edge. They are exactly-collinear zero-area
triangles, so splitting makes two more.

BEHAVIOUR CHANGE: Phase B now THROWS on an offset-boundary vertex that a
containment envelope also holds, matching 3D. It used to skip and count. Under
one-step-per-pass a skipped vertex never moves while the rest of the front
advances, which is worse than the skip was under solve-to-convergence. This will
abort topo_annots_groups and dragon_rectangle until the constrained placement is
written; two_circles has no such vertex.

Data pin -> cb73d04, which adds the two_circles fixture: two unit circles whose
offsets are exactly tangent at delta 0.1 (origin-to-polyline distance measured at
1.000000x delta). Registered commented-out -- its config parses against the strict
spec, unlike the other six; it is off because the case fails.

Shared code, both additive and default-off: OptimizerParameters gains
sizing_propagate_min (false = TetWild's mean, byte-identical to before) and
TriOptimizerMeshSplit reads it.

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

Five changes, all in the 2D component bar the data2 pin.

CONTAINMENT IS COMPOSED, NOT CHOSEN. surface_envelope_for_edge() and
smoothing_containment_envelope() both go through one new
containment_for(region_mask, on_offset), which returns the INTERSECTION of
the tag tubes and the Phase A offset envelope. A simplex can be on both --
that is the point of tracking the two families separately -- and treating it
as an either/or was two defects at once. An offset vertex that also lay on a
region boundary silently LOST its Phase A offset tube; and an offset EDGE
whose endpoints merely SHARED a tag bit was held to that tag's tube, because
edge_mask() is the AND of the endpoints' masks, which is necessary but never
sufficient for the SEGMENT lying on a boundary. Measured on
topo_annots_groups (tag_0 & tag_2, delta 1.2): edge [2624, 2700], own class
OFFSET, masks 0x6 and 0xc, AND 0x4, midpoint 0.25 outside a 0.0707 tube. One
at construction, 21 by the end of phase A, and 67 "Edge is outside!" errors.
Now zero, with the converged offset unchanged to 5 significant figures.

The ambiguous case (mask != 0 AND both endpoints on the offset) asks the
edge's own stored class. Safe against the split's stale-slot window because a
split child can never reach it: split_adjust_position() already makes the
midpoint's mask and its offset flag mutually exclusive.

LABELS, NOT RAW TAG SETS. edge_is_on_surface() returns m_is_surface_fs
instead of comparing the incident faces' tags -- the last functional reader
of input tag sets in the optimization phase. execute_offset() RETAGS every
face the band grows through, so a swallowed region boundary reads as "not on
a surface" and substructure_link_condition() was evaluating against a
substructure missing exactly those edges (81 of 257 on two_circles).

THE OFFSET ENVELOPE'S LIFETIME. Built when the offset is, refreshed at the
end of every phase B, and never nulled. The phase test lives in
containment_for() alone, so the pointer answers "has an offset been built"
and the phase answers "does its tube constrain now"; nulling it conflated the
two. rebuild_offset_envelope() clears the composed-intersection memo first
thing on every path out, or an entry would pin geometry to a tube one round
stale.

THE CHORD TERM GATES. Convergence is now max(max_at_vertex, max_in_edge)
against the one bar, at both decision sites. The old argument for excluding
it -- a sample is not a variable, so no placement can reduce it -- is true of
PLACEMENT and false of the LOOP: refinement changes it, which is what
update_band_sizing_from_tolerance() is for. Excluding it made the loop refine
on a quantity it then refused to be judged by, and at rel 1e-3 this model was
declared CONVERGED at 0.0338 <= 0.0339 while its chord term stood at 8.33 --
246x the same bar. Two fixes came with the promotion: the edge samples are
now the FULL norm, as at the vertices, rather than the normal projection
(which the sizing rule was already ignoring in favour of the full norm); and
only edges with BOTH endpoints reachable can gate, since a chord to a pinned
vertex is unfixable by refinement. Phase B's own per-pass stop stays
vertex-only and is documented as a deliberate asymmetry -- placement cannot
reduce a chord.

The sizing rule is restated per EDGE: both endpoints in tolerance and any
interior sample out of it marks BOTH endpoints, and the mark is a boolean so
halvings never compound (1/2, never 1/4). Narrower than the rule it replaces,
which demanded the vertex's whole one-ring be in tolerance and so let one
misplaced vertex veto refinement on edges that were themselves fine.

ENVELOPE-HELD OFFSET VERTICES ARE PINNED. A vertex on the offset front that a
region envelope also holds must be within envelope_size of the input boundary
AND at target_distance from the complex; not satisfiable. It is left where
phase A put it and dropped from max_reachable, still reported in the pinned
half of every band measure. THIS IS A RETREAT and is labelled as one in the
code, the log and CLAUDE.md: it converges by declining to measure what it
cannot place. Three placements were written and none works -- refusal freezes
them, projection to the curve is strictly stronger than the constraint and
made a run worse, and the tangential arclength solve has an anchoring bug
(the walk starts at the foot while Armijo compares against x_orig, so no step
is ever accepted). The placement call is kept, commented out, at the restore
point. walk_along_curve/curve_tangent/tangent_curve_tag/TagPolyline2d and
project_into_containment all survive unused.

Measured on tag_0 & tag_2, delta 1.2, ESP:
  rel 1e-2  converged in 2 rounds, vertices 0.283 and edges 0.266 against a
            bar of 0.339 -- 16% and 21% of margin
  rel 1e-3  does not converge; both halves oscillate 1.0x-8.0x for 10 rounds
Pinned, unchanged by tolerance: vertices 5.35, edges 8.33.

The data2 pin moves to 5ec0988, adding the inward interference fixture
(tag_4 offset into its own medial axis), parked with the rest.

Both open problems are written up in .claude/CLAUDE.md under OPEN PROBLEMS,
with every formulation tried and its measurement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 2D base also admits any collapse whose result scores under stop_energy,
a clause carried from the original TriWild that TetWild never had. With
mesh_improvement()'s opening length-blind collapse sweep running at the top
of every phase A, that clause demolished the mesh each round regardless of
the sizing field (two_circles, delta 0.02: 1939 -> 476 and 9510 -> 755
vertices), and the split pass then rebuilt it 10-80x against a field halved
since, until the rebuild manufactured MAX_ENERGY faces. Requiring no worse
than the ring's worst keeps ~97% of the mesh through the sweep. Ablated
against every other candidate: necessary in all combinations.

The other half of the fix is config, in the data repo: the two_circles
fixture sets sizing_propagate_min: true and must not -- the spec default is
false, and 3D's split always averages. With min, a split child inherits the
finer parent's target undiluted, so one fine vertex bisects a coarse
triangle generation after generation within a single split pass until the
midpoints coincide to float noise and the children are collinear. With both
halves, two_circles converges (delta 0.04: 12 rounds, 4 s).

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

The shared driver wrote a checkpoint frame after every operation group, even one
whose op count was 0, on top of the group's own frame, and Phase B wrote a third
frame of the background sweep's mesh again: 18 frames per Phase A iteration where
6 passes ran, identical in runs of three. TriOptimizerMesh now records which pass
it is about to write (m_debug_pass_name), the 2D offset carries it into the file
name (step_00042_r3A2_split, ..._B-offset, ..._construction), and the duplicates
are gone; the base's own debug_<N> names are unchanged.

Viewer: the panel says "after split", reports the EFFECTIVE stride (it printed the
requested one after --max-frames raised it), and --lazy reads frames on demand --
a run of several hundred passes took minutes to open and dragged on scrubbing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
value = d / delta, level c = 1, so |grad| = 1 / delta. The raw distance made the
placement's pull, 2 (d - delta) |grad d|, about 1/delta^2 weaker than the smooth
potential's at the same misplacement (two_circles at 0.1: 0.023 against ~86), so
the 1e-4 AMIPS term was no longer negligible and construction zigzags 12% off the
offset survived as exact balance points. Every consumer works in ratios of value
to c or through level_set_slope(), so nothing else changes; residual_length()
converts back through the slope, so the reported distance is still d - delta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase A ends every round with max AMIPS <= stop_energy; the placement's only shape
guard was the inversion test. Where the level set is unreachable -- two fronts
meeting -- the offset term pushed both fronts through the background strip until
inversion, and Phase A remeshed the crushed strip into flat faces every round
(two_circles at target_distance 0.1: strip width 0.0009, 626 coincident vertex
pairs, 92 folded edges, edge error cycling 143x -> 65x -> 52x -> 79x, never a
joint fixed point). A trial step may worsen the ring but not past stop_energy, and
not make a face already over it worse: shrunk, not refused, so the front stops in
the state Phase A accepts and the alternation can settle.

A vertex held that way is PRESSED (m_placement_pressed, a state, not a stop
reason: it may still take a hair of a step). The pass log counts them, and the
sizing update leaves pressed vertices and edges touching one alone -- the seam is
not a resolution question, and halving there refined it forever.

The bar is stop_energy itself. Measured with the bound OFF (per-region fields on):
every case where the fronts push into each other fails -- smooth 0.15: strip
0.002, quality 76, folds of 164 deg; Euclidean 0.15: runaway. (0.02 loses
convergence too, but only to one 6e-4-long edge folded across the curve -- 2.5%
of delta, not a visible kink.) At 2x and 10x stop_energy the strip crushes to 0.0014-0.003 and the seam
folds (15-29 folded edges, no convergence in 30 rounds; 10x smooth: a degenerate
element from a split). At 1x every case converges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The union field -- the SUM of every region's barrier for the smooth potential,
the distance to the NEAREST region for the Euclidean field -- is not the field a
front should be placed on where two regions are close. two_circles at 0.1 (unit
circles, gap 0.2, so the offsets are exactly tangent): the sum's level set is one
merged curve with Phi = 2c across the whole gap, no level set exists there, and
both fronts were pushed through the strip; at 0.15 the neck is wider still. The
Euclidean min only behaved because a front on its own side of the medial axis
sees its own region as nearest.

A band grows from ONE input region and its front is that region's offset, so it
is placed on that region's field alone: Phi_A = c for band A, Phi_B = c for band
B. Region per complex primitive from the selection's tags
(init_input_complex_bvh), one potential per tag (init_region_potentials), band
faces to regions by a flood fill from the input face each band touches, redone
after every Phase A (assign_band_regions); a face reached from two regions or a
vertex on faces of two regions falls back to the union field, and is reported.
Every per-vertex consumer -- the placement, residuals, gradient split, sizing
update, stalled-vertex pin, refine census -- goes through potential_for(). The
union field stays for what is not per-vertex: the support, the viewer's grid,
the report.

Result: every two_circles case converges under both fields, with a symmetric
strip of background between the fronts (0.15: smooth in 4 rounds, Euclidean 2).

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

convergence_criterion = "gradient" (default; byte-identical to before) or
"dist_and_orient": every reachable offset vertex and every edge-interior sample
within convergence_distance_rel x target_distance of the level set (first order,
|Phi - c| / |grad Phi|), and every offset edge's outward normal within
convergence_orientation_max_deg of the field's outward direction -- signed, so a
fold fails outright, however short.

Why: the gradient reference is measured on the band AS CONSTRUCTED and is zero
for a perfectly placed vertex, so the better the construction the stricter the
bar -- two_circles at 0.02 with 0.01 demanded 0.37% of target_distance while
Phase A's own envelope holds the offset to 2.5% -- and a fraction of it says
nothing in geometric units; it is also blind to a folded or zigzag offset, which
is zero anywhere ON the level set. Phase B's exit, the sizing update and the A/B
loop all judge by the one bar; pressed vertices and edges touching one are
excluded (their level set does not exist). Both criteria are logged every round
whichever gates, so one run compares them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* main:
  The `split_high_valence_threshold` seems to be unnecessary by now. I left it in there but set the default to 0, which effectively turns it off.
  Simplification in the topo.preserv. SimWild does not ignore element quality anymore and the pre-process also performs swaps.
  SimWild envelope was not exact. Aligned it now with the 2D code.
  Fix formatting.
  Optionally treat each connected component of an input as its own input
  Fix formatting.
  Consolidate and retry when a pass runs out of preallocated slots
  Fix assert.
  Unify slot allocation behind a SlotPool class
  Align the hashes in Tri and TetMesh.
  Add tests for slot allocation.

# Conflicts:
#	components/topological_offset/wmtk/components/topological_offset/topological_offset_spec.json
#	src/wmtk/TetMeshEdgeSplittingConn.cpp
#	src/wmtk/TetMeshSwapMeshConnectivity.cpp
#	src/wmtk/TetOptimizerMesh.cpp
…l placement

Checkpoint of 2026-08-25. Measured on two_circles (Euclidean, stop_energy 10, cap 20,
10 rounds, rel 0.001): 0.04 / 0.10 / 0.15 / 0.20 / 0.40 / 0.50 all converge, no
degenerate faces, final max AMIPS < 10. In the order the pieces depend on each other:

1. Exact envelopes for the 2D tag tubes and the offset tube (SampleEnvelope(exact=true);
   the class name is a TetWild inheritance). The sampled test decided by where its sample
   points fell: on 0.15 a 0.151 input chord at the tube wall passed the collapse's
   whole-segment test by 4e-5 and failed the split's half-segment test by 2e-5, leaving an
   edge TriWild could neither split nor collapse; the split pass then gnawed around it
   (alternating midpoints converging onto the chord line) into 1203 zero-area faces.
   TetWild and TriWild build theirs exact by default; the offset component was the odd one
   out. 3D still builds its three envelopes sampled -- separate PR.

2. Convergence means a local minimum of Phase B's front energy, judged per vertex by the
   remaining 1-D Newton step along the field normal (phase_b_conv_criterion =
   step_size_rel, default; decrement and gradient_norm_rel kept as options) against
   phase_b_conv_rel (default 0.001). The gradient is stiffness x displacement: where two
   fronts meet, the strip between them is a sliver at the energy's minimum and a vertex
   1e-4 delta from its minimum read 13-19x the bar while nothing moved. The edge test is
   the second difference of (Phi - c)/c along each live front edge -- how far the level set
   curves away from the chord -- against ab_offset_envelope_rel x c; its previous form,
   the interpolation error of the offset term's gradient, was r x (kink of grad Phi) and
   refined pressed seams forever (2.7x the bar at the 0.2 seam vs 0.74x). Tying the edge
   bar to rel over-refined (0.04: 5906 vertices for 2903) and rel 0.1 false-exited at 0.4
   with the front at 0.22 -- a per-vertex step test cannot see a creeping front, so rel
   stays small and no longer sets the resolution. convergence_criterion (gradient,
   dist_and_orient) and its keys are removed; the energy criterion is the loop test.

3. Energy terms: the offset term is ((Phi - c)/c)^2 (normalised by the target level), and an
   alignment term sum_e (1 - n_e . ghat(m_e))^2 over the vertex's live front edges keeps the
   edge normals on the field (Gauss-Newton Hessians, no third derivatives of Phi). Without
   it the seam is rougher (worst edge angle 21 -> 39 deg at 0.2). Exact vs Gauss-Newton
   Hessian for the offset term: no difference on these cases.

4. phase_b_normal_only (default true) is a true one-dimensional solve: the same objective
   restricted to the line x0 + s n, n = grad Phi/|grad Phi|, same solver, line search and
   accept test (exact ring inversion). It replaces a stiff tangential penalty (k = 1e6)
   that stood in for the restriction. The free 2-D placement also converges under the step
   test, with 1-2 more rounds; its tangential sliding leaves the two fronts' densities
   unequal where they meet and the seam wanders (0.1 delta at the 0.4 tip), so 1-D is
   the default. The vertex test reads the step along n in every mode.

5. A/B driver. Phase A runs in round 1, after the criterion halved edges, or when Phase B
   left edges over TriWild's split threshold (the front drags the band when it travels).
   A final Phase A runs after convergence when max AMIPS >= stop_energy, with the front
   frozen: its AMIPS-only smoothing inside the tube had zigzagged the converged front
   (turn per vertex 3.1 -> 10.2 deg at 0.4). The verdict is the measurement at
   convergence, not a re-measurement after that pass. ab_phase_b_iterations default 20.

6. Dead code removed: the bespoke front placement (place_offset_vertex_step and its
   trace, ~800 lines), the placement no-normal counter; ab_conv_criterion,
   convergence_distance_rel, convergence_orientation_max_deg. The gradient-criterion
   machinery still feeds the JSON report and is left for the report's own cleanup.

3D: touched only by the parameter rename (convergence_gradient_norm_rel ->
phase_b_conv_rel), behaviour unchanged. src/wmtk untouched.

Known and not addressed here: the annots slot case (integration_tests/topological_offset_2d)
closes the 2-delta-wide channel mouths at band construction, so the front there is a
closed loop no placement can reopen; pre_optimize_input reports "quality 0 -> 0" on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dmeshing-toolkit into offset-2d-triwild-parity

# Conflicts:
#	components/topological_offset/wmtk/components/topological_offset/Optimize2d.cpp
#	components/topological_offset/wmtk/components/topological_offset/Parameters.h
#	components/topological_offset/wmtk/components/topological_offset/TopoOffsetTriMesh.h
#	components/topological_offset/wmtk/components/topological_offset/topological_offset_spec.json
…daries, test fixes

Measured against a worktree at Daniele's Aug 17 commit 366c038 on
integration_tests/topological_offset_2d.json (slot-shaped complex, delta 0.25), which
had converged there and squeezed its 1-wide slot mouths shut on today's tree.

1. pre_optimize_input seeds the complex's own vertices (label == 1), not only edges
   bounding a label-1 REGION. This complex is a curve between two regions, so the seed
   found 0 vertices and the pre-pass was a silent no-op ("max element quality 0 -> 0");
   the band was then marched one coarse input cell thick (front at 0.5 = 2 delta) and
   two such bands touch across a 1-wide slot. Seeded: 130 vertices, band at 0.125 as on
   Aug 17 (which stepped delta/2 in at construction, removed in 51183f6), slot mouths
   stay at their true width 0.50, the 140-156 degree kinks are gone. Uday's config:
   pre_optimize_sizing_from_edges must be false for this (true keeps the input's own
   resolution and refined the whole domain instead: 24k vertices for 9.4k).

2. A front vertex an input envelope holds (it lies on another tag boundary or the domain
   wall -- the offset of a curve's endpoint is a half-circle cap that crosses that boundary)
   is placed ALONG that boundary: the same one-unknown solve, direction = the boundary's
   tangent, then the boundary's exact tube checked on its two edges. Phase B used to skip
   these vertices outright (1800 visits per run), leaving them at 0.125 and bending the
   placed neighbours toward them; the convergence tests now include them.
   (front_vertex_move_direction, smooth_before, smooth_front_vertex_phase_b.)

3. wmtk_test_topological_offset: init_input_complex_bvh() dereferenced the selection
   expression, which the unit tests do not set -- a segfault since 2f793c5
   (2026-08-24). Guarded. The per-tag envelopes are built exact only for a finite positive
   eps (the tests construct without params.init(); fast-envelope's 2D init crashed on an
   uninitialised eps where the sampled path had merely built nonsense). The PSD check of
   the Gauss-Newton offset Hessian is relative to the matrix's scale, which grew by 1/c^2
   with the energy's normalisation. All 124 ctest cases pass; the offset integration
   group is hidden and unaffected.

Tried and rejected, recorded in the code: the polyline's own normal as the move
direction (frees a concave-corner vertex under the Euclidean field but slides front
vertices into each other around convex corners). Open: the Euclidean field's undefined
normal on the input's medial axis (one vertex at 3.1x the bar on this fixture); the
smooth field converges (5-7 rounds).

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

@daniel-zint daniel-zint left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I am not comfortable with merging the code in its current state. Let's discuss these comments as soon as possible. Most should be easy to fix.

Comment thread cmake/recipes/geogram.cmake
Comment thread cmake/recipes/ipc_toolkit.cmake
# ipc-toolkit links its hash-map backends PRIVATE, but leaks them through the PUBLIC header
# ipc/utils/unordered_map_and_set.hpp, so any consumer including it fails to find
# <tsl/robin_map.h> / <absl/hash/hash.h>. Upstream's own test target works around this by
# linking them again; do it once here instead, so every wmtk consumer inherits the fix.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

That sounds like a fix that should be done on the ipc-toolkit side.

Comment thread cmake/wmtk_data.cmake
# does not exist and the stuck-refine calls they generate run the ambient mesh away to 1e50.
# See "OPEN PROBLEMS" in .claude/CLAUDE.md.
#
GIT_TAG 5ec0988783795586ad58de404d152393bdabc96f

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should just update the main branch of the data repo. I am not a huge fan of having different branches of the data repo. It might make sense in the short term, but it should be fixed before this PR is merged.

Comment thread src/wmtk/envelope/Envelope.cpp
partition_mesh_morton();

if (m_params.debug_output) {
m_debug_pass_name = "improve-entry";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I am fine with improving the debug output, but this seems like the wrong way. It is really hacky and hard to follow. I still vote for a standardized output name and a JSON that may contain additional information for the specific output file. Then the Polyscope script could also just read the JSON instead of a folder. I think that makes it cleaner.

Comment thread src/wmtk/TriOptimizerMesh.cpp Outdated
Comment thread src/wmtk/TriOptimizerMesh.cpp
retry_count);
}
timer.start();
m_debug_pass_name = ops[i] > 0 ? std::string(names[i]) : std::string(names[i]) + "-skipped";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

As mentioned already, I dislike this.

if (i == 0) {
for (int n = 0; n < ops[i]; ++n) {
logger().info("==splitting {}==", n);
++m_op_epoch; // see m_op_epoch: one epoch per split pass

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sounds like debug code. Let's clean that up.

A collapse always keeps the min of the two sizing scalars.
…o offset-2d-triwild-parity

* 'main' of github.com:wildmeshing/wildmeshing-toolkit:
  Add `remove_duplicate_eps` (default 0) to SimWild. Now, vertices that are at the exact same position are merged by default.

@daniel-zint daniel-zint left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's clean up the debug output and instrumentation variables at some point, but I think the code is fine for now.

For the geogram/ipc-toolkit business, we need to wait until that repo is cleaned up, so nothing we can do for now on this side.

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.

4 participants