Skip to content

topological_offset 3D: curve offsets, every feature curve held - #1161

Draft
udaykusupati wants to merge 6 commits into
uday/offset-hold-region-boundariesfrom
uday/offset-curves
Draft

udaykusupati wants to merge 6 commits into
uday/offset-hold-region-boundariesfrom
uday/offset-curves

Conversation

@udaykusupati

Copy link
Copy Markdown
Collaborator

Draft, stacked on #1159 (uday/offset-hold-region-boundaries); its base becomes main when #1159 merges.

What this does

A curve offset: topological_offset 3D grows a band (a tube) of target_distance around a feature curve, as it grows one around a surface or a volume. Also, every feature curve is held during any offset, as tetwild and simwild hold theirs.

  • Input: a tet mesh's edge block, the feature curves tetwild and simwild write, is read as edge tags. It used to be refused. offset_selection can name an edge tag alone; the band then grows all around that curve. A tag expression whose input complex has segments but no triangles (for example the curve where three regions meet) is a curve offset too.
  • Energy: the exact band integral (EXPERIMENTAL_band_volume_rule exact, the default, and exact_swept) now takes segments and points. Where two segments' regions overlap (inside a bend, at a junction), the cell is split exactly by their bisector planes; before, such a piece took rule (a), an upper bound. corner_bound also works on curves.
  • Hold: every edge tag, selected or not, is tagged with the engine's feature kind and held in a tube of half-width feature_envelope_ratio x envelope_size. feature_envelope_ratio is a new key, default 1, as in tetwild and simwild. The ends and junctions of each curve are anchored. With a labelled reference envelope (topological_offset 3D: hold every region boundary and a passed envelope; the march carries the edge tags #1159), the tubes are built around the reference's curves.
  • Output: the edge tags are written back as the result's edge block.
  • Fixes found on the way:

Commits

  1. ExactBandIntegral: segment and point inputs; pieces of edges split exactly.
  2. The face swap caches the edge tags it restores (= topological_offset 3D: hold every region boundary and a passed envelope; the march carries the edge tags #1159's patch).
  3. Curve offsets: the band integral over segments, the curve held, the march's split carries the tags.
  4. Edge tags: a feature curve offset by name, every one held, written back.
  5. Hidden test curve-offset-converges.
  6. Merge of topological_offset 3D: hold every region boundary and a passed envelope; the march carries the edge tags #1159, with three changes the two need together (see the merge commit):
    • the feature-curve names now live in the engine base;
    • with a reference, the curves are held to the reference's curves;
    • untrack_covered_boundaries() recomputes the region flag of the vertices it leaves on no tracked face (a curve has no faces of its own).

Measurements

  • Rod held, the rod where three blocks meet, 7 turns: before, its vertices drifted 0.43 target_distance off the input. After, they stay on it to 3e-16 under exact, exact_swept and corner_bound.
  • Rod as an edge tag, every tet ambient, exact: resolved at turn 9, front at 0.9986–1.009 target_distance, 0 folds. exact_swept resolved at turn 10.
  • Through tetwild:
    • Every curve-only case resolved: a 90 and a 30 degree corner, three arms meeting, and a second curve 0.15 away inside the band.
    • The target_distance sweep 0.1, 0.2 and 0.4 resolved within 15 turns; 0.05 resolved at turn 16.
    • No curve edge left its tube, and no anchor was lost.
  • Hidden test curve-offset-converges: resolved at turns 6, 5 and 11.
  • Exact band integral on the 100 stored cells of the five real cases: 15 cells change, every new value is at or below the Python reference's bound, and additivity now holds on more cells.

Known limit

A front that meets another tracked surface does not resolve: a curve 0.1 from a sheet that crosses its band, or the rod's caps where the blocks' junction curves are held. This is the same class as the double_sphere junction (#1159's hidden test double-sphere-other-region-held-or-protected). A reproducer is saved.

Not in this PR

🤖 Generated with Claude Code

udaykusupati and others added 6 commits November 5, 2026 16:57
…actly

The exact band integral (EXPERIMENTAL_band_volume_rule "exact", the default, and "exact_swept")
took triangles only: with segments alone the constructor returned with no features, and every
value was 0. A curve offset's input is segments and points, so the integral now takes them:

- Input: the triangles, or the segments and the points (one kind, as the envelope whose box
  queries it reads holds one; a complex of both throws). A segment's features are its interior
  (an edge with no incident face, whose region is the slab between its ends) and its two ends; a
  point is a vertex, whose region is all of space. The distance bound U and the candidate search
  run over the primitives of either kind, through an Edges3d envelope for segments and points
  (points as degenerate segments, as the offset's euclidean_query_envelope() holds them).
  Triangle inputs build the same features in the same order as before.
- Pieces whose members are all edges are split exactly by the bisectors of the pairs whose
  distances cross on them, planes for edges meeting at a point or parallel (bisector_planes()),
  each part labelled with its nearest edge by resolve(), the function the front form already
  labels a face's pieces with. Until now such a piece took rule (a), an upper bound. On a rod the
  two segments' slabs overlap on the inside of every bend and at every junction, so this is what
  makes a rod exact; value() and the front form's face labels now cut alike there, which
  "exact_swept"'s search model relies on. A crossing pair of skew edges still takes rule (a).
  The split is in value(), value_grad_hess() and max_distance(), counted by
  Stats::edge_split_pieces / edge_split_vol and MaxInfo::edge_split_pieces.

Triangle inputs change only on cells that had such pieces. MEASURED on the 100 stored cells of
the five real cases: 15 cells change (cylinder_out 3, slot 7, t100026 5), all of them rule-(a)
cells in the Python reference; every new value is at or below the Python's bound, at most
3.0e-7 delta vol below it; the rule-(a) volume falls to 0 on 11 of them. Additivity over the red
children now holds on slot's 20 cells (12 before) and t100026's 16 (11), to 2.3e-14 and 1.1e-13;
the front form's identity (oint G.n + D_t = J) now covers slot's 20 cells (12) and t100026's 16
(11), to 2.3e-14 and 7.2e-14 delta vol.

Tests:
- Parity with the Python on cells and faces with split pieces: the Python's value is an upper
  bound there, and its rule-(a) volume and piece count are this port's plus the split pieces'
  (the partition is the same). Derivative parity is skipped on those cells only.
- The finite-difference errors of the gradient and Hessian are measured in the cell's own unit,
  S / L and S / L^2 with S = I + delta vol (the two terms J is the difference of), instead of
  relative to |G| and |H|: a gradient near 0 read ordinary rounding as a failure (slot cell 7:
  |G| L = 3e-3 S, rounding 3e-15 S in J, relative error 1.7e-7 against the bound 1e-7). The
  kink classification stays relative to |H|; every corner keeps its class on every case.
- New: segments and points (straight, bend, acute corner, planar and non-planar junctions, a
  non-planar polyline, two points; random band cells and cells inside every bend): no rule (a)
  inside bends and junctions; parity with the triangle path through a vertical fence below the
  planar curves (to 1.3e-15); quadrature at 32768 children (its own error at most 7.7e-7 S, the
  bound 1e-5; rule (a) would be 1.1e-2 to 3.4e-2 S off on the bend cells); additivity; the front
  form; finite differences at every corner; the elementary-piece cache; max_distance() against a
  grid and exact at its argmax; the one-kind refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The same change as the offset PR's (uday/offset-hold-region-boundaries), byte for byte, so the
two merge without a conflict; its test, swap-face-keeps-edge-tags, comes with that PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts, the curve held

An input complex of segments and points (an offset_selection without triangles: a rod, a curve
network, points) was refused by the band term. Now:

- The band term takes it, under every EXPERIMENTAL_band_volume_rule: the exact integral over
  segments and points (ExactBandIntegral, previous commit), with no offset side, since a curve
  bounds no solid. offset_in is refused for such a complex: it encloses no volume.
- The curve is held as tetwild holds its input_edges (hold_curves_and_points()): the edges
  tiling each input segment tagged with the feature kind, held in the tube around the segments
  as extracted, of half-width feature_envelope_ratio x envelope_size (new key, default 1, as
  tetwild's and simwild's), its ends and junctions, and the complex's points, anchored at that
  radius (anchor_feature_points(), once, after both kinds are tagged). A vertex on such an edge
  is pulled toward the tube by the base's rule in smoothing, whether or not a held face reaches
  it. After the loop audit_input_curves() reports tagged edges outside their tube, untagged edges
  of the complex and anchors lost, as a warning iff there is one. MEASURED (the 2026-10-16 rod
  fixture, the rod where three blocks meet, exact rule, 7 turns): before, none of the rod's 6-9
  edges tagged, its vertices 0.43 target_distance off the input, moved by the smoothing; after,
  under exact, exact_swept and corner_bound alike, every vertex on the input to 3e-16, no tagged
  edge outside its tube, both end anchors held (7 and 15 turns).
- The march's split (EdgeSplittingTet, the construction's own) carries the engine's edge tags as
  the shared split does: it carried m_edge_attribute only, and the tags, an edge collection of
  their own, stayed at the old edge numbers, on other edges. This was live for every offset with
  order-2 edges (hold_curves_and_points() tags them before the march). MEASURED (rod fixture):
  before, neither of the rod's 2 edges tagged after the march, and 12 of the 14 order-2 tags
  still on order-2 edges; after, both, and all 20 (with the halves of the split ones).
  construct_offset() now checks a curve complex's edges kept their tags, rather than assuming it.

Tests: curve-offset-rod-held (the rod on a 384-tet grid, every rule, one turn: no throw, the
complex in its tube, both ends kept), curve-offset-refuses-offset-in. The offset suite: 117 of
117.

Not resolved on the rod fixture at 15 turns: 9 front vertices stay over the bar, at the rod's two
ends, where the fixture's blocks end on the ambient region and the junction curves of their
boundaries, held in order-2 tubes, cross the front's caps (the double_sphere junction's class).
MEASURED: every turned face and fold at |z| = 0.50-0.54; with those tubes widened past the domain
(order2_envelope_ratio 1e4, the only change), 0 folds, 0 turned faces, the front at 0.996-1.021
target_distance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…very one held

A tet mesh's edge block, the feature curves tetwild and simwild write, was refused: the offset held
no feature curve, and they would have been dropped. Now:

- Load: each edge tag is a name offset_selection can use, as a face tag is, its edges carrying the
  tag's bit (EdgeAttributes::feature_inputs) through the construction's own splits, which copy
  the attribute whole.
- Selection: an edge tag selected alone is the input complex, the band growing all around it (a
  curve offset); within an expression it is refused (one tag at a time).
- Hold: hold_curves_and_points() tags every edge tag, selected or not, with the feature kind and
  its own input bit, held in the tube around its curves as read, one tube per tag, its ends and
  junctions anchored (tetwild's rule); the curve a tag expression selects (the previous commit's)
  is one more input. From there on the engine's tags carry them (edge_has_tag()), the per-edge
  bits cleared. construct_offset() checks after the march that every feature-tagged edge is still
  in its tube, as well as that a curve complex kept its tags (input_curve_counts(), which the
  after-loop audit shares).
- Output: the edge tags written back as the result's edge block (io::collect_feature_edge_tags(),
  as tetwild and simwild write it).
- Feature points (a point block) are refused: nothing in a tet mesh tags them.

MEASURED (the 2026-10-16 rod fixture's mesh, 665 tets, the rod given as an edge tag with every tet
ambient instead of as the meeting of three blocks; exact rule, 7 turns, the same config): the front
at 0.997-1.018 target_distance (blocks: 0.877-1.020), 0 turned faces and 0 folds (17 and 2), ring
max 1.12x the bar (6.45x), max AMIPS 8.4 (26). The blocks' junction curves, held in order-2 tubes,
were what the front's end caps stuck on.

Tests: curve-offset-edge-tag (an edge tag selected, a second one held: both in their tubes in the
result's edge block, end to end), curve-offset-edge-tag-alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
curve-offset-converges, hidden ([.]) for the runs before a merge: three curves given as edge tags
on an all-ambient grid -- a corner, three arms meeting out of a plane, a line with a second edge tag
held inside its band -- each offset to the settle exit (throw_on_nonconvergence), every edge tag in
its tube in the result. MEASURED (exact rule, 1 thread, Release, laptop): resolved at turns 6, 5 and
11, 22 s, 21 s and 47 s. The grid's cubes and tets (cube_grid()) are shared with rod_grid().

Similar shapes through tetwild (a 90 and a 30 degree corner, three arms meeting, a second curve 0.15
away inside the band at target_distance 0.2), and a sweep of target_distance 0.05-0.4 on tetwild's
feature curve (2026-11-05): every curve-only case resolved, within 15 turns but target_distance
0.05, at turn 16 (312 s; its front refines longer, marked vertices 810, 393, 10, 0 over turns
13-16); no curve edge outside its tube, no anchor lost. Not resolved: a curve 0.1 from a sheet that
crosses its band -- a tracked surface, its rims held in order-2 tubes (on main the loop holds no
surface but the input complex's boundary): the front meeting another tracked surface, the
double_sphere junction's class.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both sides kept in the five files that conflict (TopoOffsetTetMesh.cpp and .h,
topological_offset.cpp, its spec, the tests), and three changes the two branches need together:

- TopoOffsetTetMesh's own m_feature_edge_names is dropped: #1159 puts the list in the engine base
  (TetOptimizerMesh), which the reference checks and the output's edge block read.
- With a reference (an `envelope` input that is a labelled reference), hold_curves_and_points()
  rebuilds the feature tubes around the reference's feature curves after tagging them
  (init_reference_feature_tubes()) and checks every feature edge received lies in them
  (check_reference_containment()), as simwild and tetwild hold a reference's curves. #1159 built
  no feature tube, the offset then taking no feature curve. A curve a tag expression selects is
  this mesh's, with no curve in the reference: its tube stays around it as extracted. MEASURED
  (cube, sphere and square with a 16-segment curve through simwild, then three offsets, each
  holding the envelope the stage before returned): every offset built the tubes around the
  reference's 16 segments and passed the check; the handed-on envelope keeps the curve's 16 rows
  through every stage.
- untrack_covered_boundaries() recomputes m_is_on_region for the corners of the faces it
  untracks: a vertex is on a region boundary iff a face of it is still tracked. A curve complex
  has no faces of its own, so where the band covers every face around one of its vertices, the
  vertex keeps the flag with no tracked face, and TetMesh::compute_vertex_order() throws for it.
  MEASURED: curve-offset-rod-held (the rod where three blocks meet) threw "Vertex must have
  incindent surface faces" without it; with it, 3 corners, the rod's vertices, are left on no
  region boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant