3D offset, A/B optimization - #1055
Conversation
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>
…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
left a comment
There was a problem hiding this comment.
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.
| # 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. |
There was a problem hiding this comment.
That sounds like a fix that should be done on the ipc-toolkit side.
| # 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 |
There was a problem hiding this comment.
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.
| partition_mesh_morton(); | ||
|
|
||
| if (m_params.debug_output) { | ||
| m_debug_pass_name = "improve-entry"; |
There was a problem hiding this comment.
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.
| retry_count); | ||
| } | ||
| timer.start(); | ||
| m_debug_pass_name = ops[i] > 0 ? std::string(names[i]) : std::string(names[i]) + "-skipped"; |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Sounds like debug code. Let's clean that up.
A collapse always keeps the min of the two sizing scalars.
…rror the Tri-version.
…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
left a comment
There was a problem hiding this comment.
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.
No description provided.