Skip to content

topological_offset 3D: hold every region boundary and a passed envelope; the march carries the edge tags - #1159

Open
udaykusupati wants to merge 15 commits into
mainfrom
uday/offset-hold-region-boundaries
Open

udaykusupati wants to merge 15 commits into
mainfrom
uday/offset-hold-region-boundaries

Conversation

@udaykusupati

Copy link
Copy Markdown
Collaborator

The topological offset (3D) holds every region boundary in its loop and holds a passed envelope, as tetwild and simwild do, so it can sit in a chain of components: within eps of the first component's input. On the way, two defects that are on main too: the march scrambled the edge tags, and simwild 2D/3D crashed building an envelope around nothing.

Commits

  1. The loop holds every tracked surface. Before, only the input complex's boundary was held in the loop; other region boundaries were tracked but held by nothing, and the band pushed them aside. Now every tracked face but the domain wall and the front is held to the envelopes of its own labels.
  2. A split face's halves keep their orientation. The march's edge split copied a face tag's orientation onto both halves unchanged, which turned a half over whenever the new vertex sorted differently; each half now takes it along its own vertices, as the engine's split does.
  3. Test: double_sphere with the other region held or protected — hidden. The front meets the other region along a junction curve; neither variant converges reliably until the junction is handled (left for a later PR).
  4. Engine: one boundary check, require_box_boundary(). Every boundary face a face of the box, untracked; tetwild's and triwild's tagged routes and the offset call it (tetwild and triwild had their own copies).
  5. The domain wall is never tracked; a mesh that does not fill its box is refused. The offset used to track its wall and then exempt it from holding; the wall is now held by the box rule only, as in the wilds.
  6. Hold a passed envelope and hand it on. As simwild's reference route: start checks (every label present, received faces inside), the held surfaces' envelopes built around the reference, and the reference handed on with the offset's new surface.
  7. Tests: tetwild → topological_offset → simwild with envelopes, and the offset's refusals.
  8. The loop's face swap caches the edge tags (feature_edges_cache was missing in swap_face_before): 2–3 swaps dropped or misplaced order-2 tags.
  9. Hand on the band's whole boundary. Where the band wraps a region, its inner boundary carries the region's label and the band's; with the offset surface alone, the next simwild found those faces outside the band's envelope (72 on a unit tet). Every label's envelope now covers its region's boundary.
  10. Data pin e085b8f (data2 Parameters init() must take bbox as input #14): the hidden [offset] group drops its two slowest cube cases (45 of its 49 min) and parks the 2D cases while the offset refuses 2D input.
  11. The march carries the edge tags; a face tag offsets with the optimization. The order-2 edges (open boundaries) are tagged before the march, and the march's edge split did not carry the tags, which are stored by edge slot. Measured on an open sheet: at the loop's start 0 of its 16 open edges were tagged and 16 tagged edges lay outside their tube, and the sheet lost 7% of its area (corners 0.087 off at eps 0.0035). Now every open edge stays tagged; area 0.9999, worst boundary point 0.0012. The refusal of a face-tag selection under optimize_offset is lifted; its Python test is the regression test.
  12. simwild: a tagged mesh must fill its box. Its tagged route runs require_box_boundary(): an object's own tet mesh (boundary in no plane of the box) was taken and its boundary held by nothing. Three toy-mesh tests now use the unit square / the unit cube in six tets, their checks unchanged; a new test checks the refusal.
  13. Engine: an envelope around no primitives contains nothing. simwild given one region and no envelope tracks nothing and crashed in SimpleBVH::BVH::init (2D and 3D). Envelope::empty(): is_outside() answers true, nearest-point queries refuse with a message.

Behaviour changes

Input Before Now
Offset, other region boundaries in the loop tracked, not held held to their labels' envelopes
Offset, envelope given carried unchanged held to, handed on with the band's boundary
Offset, a mesh that does not fill its box wall tracked, then exempted throws
Offset, a face-tag selection with optimize_offset throws runs; the sheet's open boundary held
simwild, a tagged mesh whose boundary is in no plane of its box remeshed, boundary held by nothing throws
simwild, one region, no envelope segfault runs

Tests

macOS arm64, Release: topological_offset 115/115, component_io 57/57, simwild 50/50, tetwild 50/50, triwild 61/61, wmtk_tests 188/188, Integration_Tests (55 configs), python/tests 62 passed. Hidden run (every [.] case) on the mac-mini: running; the result follows as a comment, before the merge.

🤖 Generated with Claude Code

udaykusupati and others added 15 commits November 4, 2026 15:30
…ld and simwild do

Until now the loop held only the input complex's boundary; every other region boundary was tracked
(the operations kept its topology) but held by nothing, so the band pushed it aside, and only the
final TetWild pass held it where the loop had left it. Since 1c247b2 (2026-10-13). Now every
tracked face but the domain wall (the box rule's) and the front is held to the envelopes of its
own labels, in the loop as in a TetWild pass. No key: the free behaviour is gone.

- face_is_held(): every tracked face off the wall and the front. build_final_envelopes() adds only
  the front's envelope; the region boundaries are held where build_envelopes() captured them,
  before the march. face_is_complex_boundary(), release_final_envelopes() and the final pass's
  envelope swap (m_held_*, m_loop_*, m_final_envelopes) are removed: nothing else read them.
- After the march, the load-time tracking rule again (face_is_tag_boundary(),
  untrack_covered_boundaries()): the retagging writes cell tags only, so a region boundary the band
  covers -- band cells on both sides -- kept its tracked flag, and held now it would be a surface
  inside the band. One rule for cell and face tags: on a face the band covers, the face tags and
  the sheet group are cleared unless protected_tags names them, as the cells' tags are; never on
  the input complex's own faces.
- The face split (simplicial embedding) gives the split face's three pieces its attributes: a
  piece of a split simplex lies on every surface the simplex was on, as the edge split's halves
  and the 2D edge split's do. The pieces got fresh attributes, so a tracked face split there lost
  its tracking. The new vertex is flagged on the surface, its orientation converted per piece.
- A front vertex held only by another region's boundary keeps the offset term and is reachable
  (vertex_is_held_on_input()): the term is left out, and the vertex pinned, only on the input
  complex, where d = 0 and has a kink. Otherwise the front could not settle where it meets
  another region.

Measured against main (ab4fe46): the offset suite passes; the data repository's 9 3D cube
configs give byte-identical outputs where they finish (8; cube_small_offset_conv0.025 times out
on both). double_sphere (offset tag_0, tag_1 the other region): tag_1's boundary moves 0.25 eps
(main pushes it 20.3 eps). OPEN: double_sphere does not converge (5 turns, and not in 15): 40
front faces end turned more than 30 degrees from the field (main 1), 28 of them touching tag_1's
held boundary and the rest beside them, in two clusters where the front, which can no longer push
tag_1 aside, ends on its surface at a shallow angle; the exit's fold test counts them. To discuss:
accept it as the cost of holding, or what a fold means for a face at a held junction.

Tests: held_faces_and_their_envelopes (every region boundary held), band-covered-region-boundaries
and band-covered-face-tags (untracked unless protected; a face-tag selection's own faces kept),
face-split-keeps-the-surface (the corner pocket).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…their own vertices

The march's edge split restored the split face's attributes as they were on both halves. A face
tag's orientation is stored against the face's ascending vertex ids, so the copy was right on a
half only when the new vertex sorted into the place of the corner it replaced; otherwise the half
came out turned over, and so did the triangle the output orients from it
(io::collect_input_set_tags()). Each half now gets the parent's orientation along its own vertex
order, as the engine's split converts it (TetOptimizerMeshSplit.cpp). The 2D edge split had the
same copy and gets the same conversion; it changes no 2D result today (the 2D offset sets no
orientation, and the driver refuses 2D).

Test band-crossed-sheet-keeps-its-orientation: a protected face-tagged sheet the band crosses
keeps its normal on every split half, inside the band and beyond it. It fails on the previous
commit (2 of 17 assertions) and passes here; the offset suite passes (114 test cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… protected, hidden

double_sphere, offset of tag_0 at 0.05 with tag_1 held (every region boundary is held since
2026-11-04) or protected: the front meets tag_1's surface along a junction curve. Neither
variant converges reliably until the junction is handled (2026-11-04, 15 turns, 4 threads:
held 13 folds left, protected 3), so the case is hidden ([.]) and recorded by the hidden runs;
it moves into CI once it passes.

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

TetOptimizerMesh::require_box_boundary() and its 2D twin on TriOptimizerMesh throw unless
the mesh fills its box: every boundary face (2D: edge) a face of the box (m_is_bbox_fs >= 0)
and untracked, naming the count of each kind found otherwise. tetwild's
init_from_tagged_mesh() and triwild's tagged route call it in place of their own copies;
the messages keep their wording ("tracked faces of the tagged mesh lie on its boundary",
filter "none"). simwild keeps its own check of the tracked kind only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…does not fill its box is refused

The offset tracked every boundary face as a region boundary (face_is_tag_boundary()) and then
exempted it from the envelopes (face_is_held(), 2fa364b): the box rule held the wall, as in
tetwild and simwild, while the operations still kept it as a tracked surface. Now a boundary
face bounds no second region and is untracked, unless a face tag or the sheet tracks it -- a
filtered mesh's boundary -- and the driver refuses a mesh that does not fill its box right
after loading it (TetOptimizerMesh::require_box_boundary(), tetwild's and triwild's check).

What relied on the wall being tracked:
- vertex_is_on_surface() counted the wall through vertex_is_on_region(); the substructure
  queries then found a wall vertex with no tracked face (TetMesh::compute_vertex_order()
  threw "Vertex must have incindent surface faces" in frame-gather-threads-identical). It
  counts the tracked faces only now: a region boundary or the offset surface.
- The march's edge and face splits raised m_is_on_surface for a midpoint on the wall; they
  raise it for a region boundary only, as no wall vertex carries it at load.
- m_is_on_region was kept off a tracked face without a tet across (the wall); every tracked
  face is a region boundary now.
- Comments describing the wall as tracked or held, here and in the header.

Tests: held_faces_and_their_envelopes checks the wall untracked (it checked it tracked and not
held); "Offset accepts named tagged meshes" takes the unit cube in six tets, a single tet's
fourth face lying in no plane of its box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e offset surface

An `envelope` that is a labelled reference (InputData::envelope_reference), the first
component's input, was carried unchanged while the offset built its envelopes around the mesh
it received. It is held to now, as simwild's reference route holds one (simwild.cpp's run_3D,
the engine's checks from #1157):

- Its triangles are not the offset's envelope surface group (init_from_image()'s V_env/F_env,
  m_sheet_*): the driver passes none, and keeps the reference's rows of this mesh's labels
  (io::held_rows()) on the mesh (m_reference_V/F/labels, m_has_reference).
- build_envelopes() builds m_envelope and the label envelopes around those rows instead of the
  held faces, after require_reference_face_labels(), and runs check_reference_containment()
  before anything moves. label_name() names the labels as the output does.
- The order-2 tubes and curve-end anchors (hold_curves_and_points()) around the reference's
  curves: the driver calls set_input_curves(), as simwild does. No feature tube: the offset
  takes no feature curve, so init_reference_feature_tubes() would hold nothing.
- Handed on: the reference's rows plus the final offset surface's triangles (a band cell
  against a cell neither band nor input complex), labelled by the band's tags
  (offset_output_tags, as io::tag_labels() names them): the offset surface is new, so it is its
  own reference. Built with io::append_envelope_block() / append_envelope_points(); unchanged
  where there is no offset surface. File route: <output>_envelope.msh, as before.

Docs: the /envelope spec doc and the tutorial; the /input doc also states the box requirement
of the previous commit. python test_envelope_is_passed_on_only_by_the_caller gives the offset
simwild's eps as envelope_size: at the default (1e-3 of the diagonal, against simwild's 1e-2)
the offset now refuses simwild's faces, 391 outside, as tetwild and simwild refuse a narrower
eps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ffset's refusals

- The offset holds tetwild's envelope (memory route, filter none, at tetwild's eps) and hands
  it on with labels a, b, off: a's and b's rows as given, off's exactly the faces between the
  band and the rest; the file route writes the same as o_envelope.msh; simwild holds the
  offset's mesh to it and returns it as given.
- The offset refuses tetwild's envelope moved by 10 of its eps (faces of a and b outside, by
  label) and one with b renamed c (no triangle labelled b).
- The offset refuses a filtered tetwild output: a nonzero count of tracked boundary faces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TopoOffsetTetMesh::swap_face_before(), the loop's copy of the base's face swap, cached the two
cells' face attributes but not their edge tags (feature_edges_cache()), which the base's
swap_face_after() -- the offset has none of its own -- restores on the three new cells: they took
whatever the thread's previous operation had left in the cache. hold_curves_and_points() tags the
order-2 edges, so a loop 2-3 swap could drop or misplace a curve's tags. Measured on the rod
branch (33548e2, the same copy): 2 tagged edges at the start of the loop, 8 rod edges at its
end with 1 still tagged.

Test swap-face-keeps-edge-tags: the admitted swap of swap-face-gate-on-energy with (a,d) tagged
order-2; before the fix the tag and its input were gone after the swap (0 tagged edges), after it
(a,d) is the one tagged edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The offset handed on, under the band's labels, only the offset surface. Where the band wraps a
region, the band's boundary also has an inner part, its interface with that region, and the
next component labels each face there with both the region and the band: it must lie in both
envelopes. With the offset surface alone, simwild found those faces outside the band's
envelope (72 on a unit tet). Every face between a band cell and a cell outside the band is now
handed on, as every other label's envelope covers its region's boundary. The spec and the
tutorial say so.

Test: simwild -> topological_offset of region a -> simwild; failed before (72 faces outside
off), passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
data2 #14 drops the group's two slowest cube cases, cube_small_offset_conv0.1 (13 min, 1.0M
tets) and conv0.025 (32 min, 2.85M tets), 91% of its 49 min, and parks the nine 2D cases,
which throw while the offset refuses 2D input. The seven cube cases left take 4.3 min. The
test's comment says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fsets with the optimization

hold_curves_and_points() tags the order-2 edges (open boundaries) before marching_tets(), and
the march's edge split cached only the offset's own edge attributes. The tags are stored by edge
slot, so after the march each sat on whatever edge took its slot over, and the tubes held the
wrong edges. Measured on an open sheet offset with the optimization: at the loop's start none of
its 16 open edges was tagged and 16 tagged edges lay outside their tube; the construction pass
then cut its boundary (area 1.0 -> 0.894, corners up to 0.087 off at eps 0.0035), 7% of the
sheet lost at the end. The march's edge split now caches and restores the tags as the base's
split does, its halves inheriting the split edge's tags: every open edge stays tagged, the area
is 0.9999, the worst boundary point 0.0012 off. The face and tet splits run only in the
simplicial embedding, before the tagging. Main has the same order.

With that, the refusal of a face-tag selection under optimize_offset (the optimization did not
hold face tags, efbf410) is lifted. Its Python test becomes the regression test: the offset
of an open sheet converges, and the sheet, the other face tag and the region stay within the
envelope. The component chain tests run the offset with the optimization, as users do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simwild refused a tagged mesh whose tracked faces (2D: edges) lie on its boundary, and took one
whose untracked boundary lies in no plane of its box -- an object's own tet mesh -- whose
boundary nothing then held: smooth_before() freezes only the vertices on the box's planes. Its
tagged route (init_from_image(), both overloads, 3D and 2D) now runs the engine's
require_box_boundary(), as tetwild's and triwild's tagged routes and the offset do, after
init_surfaces_and_boundaries() rather than inside it, so the conformance tests that build a tet
ring by hand keep testing the operations.

Tests: three component_io tests gave simwild meshes that do not fill their box (one tet, two
tets); they now use the unit square and the unit cube in six tets (the 3D curve becomes the
cube's main diagonal, an interior edge of the tagged cell), their checks unchanged. New: simwild
refuses one tagged tet (triangle) with its boundary as the envelope, naming the count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simwild given a tagged mesh of one region and no envelope tracks nothing, and builds its
envelope from the tracked faces (2D: edges): none. Envelope::init() then crashed in
SimpleBVH::BVH::init, in 2D and in 3D. init() now builds neither the BVH nor the exact
structure when given no primitives (Envelope::empty()); such an envelope contains nothing, so
every is_outside() answers true, and it has no nearest point, which the nearest-point and
distance queries refuse with a message (require_primitives()), as the primitive queries refused
an envelope no init() had filled in.

Test: simwild remeshes the unit square (2 triangles) and the unit cube (6 tets), one region,
no envelope given; segfaulted before in both dimensions, passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_memory_io.py gave simwild one tagged triangle and one tagged tet, and the offset one tagged
tet: meshes that do not fill their box, which simwild and the offset now refuse
(require_box_boundary()). They use the unit square in two triangles and the unit cube in six
tets, with their boundaries as the envelope, as the C++ component IO tests do; tagged() tags every
cell. Linux CI failed python_memory_io on them; ctest 262/262 locally.

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