Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
aefbf4a
test(packing): add F2 PackCharts suite (TDD red) + header scaffold
csparker247 Jun 20, 2026
9612c61
feat(packing): implement PackCharts shelf packing (TDD green)
csparker247 Jun 20, 2026
3ef7a75
fix(build): install ChartPacking.hpp in multiheader install set
csparker247 Jun 20, 2026
af2e52c
fix(test): include only OpenABF.hpp in TestChartPacking
csparker247 Jun 20, 2026
85290a8
docs(example): pack multi-chart flatten output into a single atlas
csparker247 Jun 20, 2026
697b897
feat(merge): add MergeMeshes, inverse of extract_connected_components
csparker247 Jun 20, 2026
02e5030
docs(example): add UVMap construction reference (comment only)
csparker247 Jun 20, 2026
dead335
docs(example): correct UVMap WithChart usage in reference comment
csparker247 Jun 20, 2026
fafe4d9
docs(example): note UVMap validity on torn and untorn mesh
csparker247 Jun 20, 2026
9972af4
docs(conductor): file B9 track for insert_face rewinding discrepancy …
csparker247 Jun 20, 2026
9abcb4a
docs(conductor): broaden B9 to require mapping back to original input…
csparker247 Jun 20, 2026
59aaf20
feat: surround packed charts with padding on all sides (F2)
csparker247 Jun 20, 2026
3a4dea5
docs(conductor): record F2 perimeter-padding refinement (Phase 6)
csparker247 Jun 20, 2026
21a6ed1
Fix BFS bugs
csparker247 Jun 22, 2026
1013328
refactor(packing): address PR #99 review feedback
csparker247 Jun 22, 2026
073eb43
test(mesh): regression-test BFS visit-once; record F2 phases 7-8
csparker247 Jul 24, 2026
542d468
docs(conductor): register P7 for PackCharts shelf-wrap padding mismatch
csparker247 Jul 24, 2026
faf22d7
docs(conductor): add P7 spec and plan for PackCharts squareness-based…
csparker247 Jul 24, 2026
2ec18bf
fix(packing): validate inputs before mutating charts; measure atlas e…
csparker247 Jul 24, 2026
66dabe0
test(packing): cover pack/merge invariants from PR #99 review
csparker247 Jul 24, 2026
5c6ec7c
Merge branch 'develop' into f2-multi-chart-packing
csparker247 Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,9 @@ venv/*
docs/latex/
docs/html/

# Example programs write their output meshes to the working directory
openabf_example_*.obj
openabf_example_*.ply

# CLion
cmake-build*/
2 changes: 2 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ if(OPENABF_MULTIHEADER)
include/OpenABF/HalfEdgeMesh.hpp
include/OpenABF/HierarchicalLSCM.hpp
include/OpenABF/AngleBasedLSCM.hpp
include/OpenABF/ChartPacking.hpp
include/OpenABF/MeshMerge.hpp
include/OpenABF/Math.hpp
include/OpenABF/Vec.hpp
include/OpenABF/MeshIO.hpp
Expand Down
5 changes: 4 additions & 1 deletion conductor/tracks.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ F2 or F5–F11 — based on priority at the time of starting.
| open | F9 | Implement OpenBLAS/LAPACK Backend | [#57](https://github.com/educelab/OpenABF/issues/57) | 2026-03-20 | 2026-03-20 |
| open | F10 | Implement Ceres Solver Backend | [#58](https://github.com/educelab/OpenABF/issues/58) | 2026-03-20 | 2026-03-20 |
| open | F11 | Implement ACVD | [#62](https://github.com/educelab/OpenABF/issues/62) | 2026-03-20 | 2026-03-20 |
| open | F2 | Multi-chart UV packing | [#18](https://github.com/educelab/OpenABF/issues/18) | 2026-03-13 | 2026-06-20 |
| open | F2 | Multi-chart UV packing | [#18](https://github.com/educelab/OpenABF/issues/18) | 2026-03-13 | 2026-07-24 |
| open | B10 | `extract_connected_components` throws on meshes with mutually-adjacent interior faces (BFS marked visited on dequeue) — fix + regression test carried by PR #99 (F2 Phase 8) | [#103](https://github.com/educelab/OpenABF/issues/103) | 2026-07-24 | 2026-07-24 |
| open | P7 | PackCharts shelf-wrap test and sqrt-area target width disagree on padding, causing premature wraps and narrow atlases (blocked on F2/PR #99 merging) | [#104](https://github.com/educelab/OpenABF/issues/104) | 2026-07-24 | 2026-07-24 |
| open | B8 | LSCM area-preserving rescale of flattening output | [#98](https://github.com/educelab/OpenABF/issues/98) | 2026-06-20 | 2026-06-20 |
| open | B9 | No recoverable mapping from torn/parameterized mesh back to original input topology (insert_face rewind + split_path duplication) | [#100](https://github.com/educelab/OpenABF/issues/100) | 2026-06-20 | 2026-06-20 |

## Archived Tracks

Expand Down
45 changes: 45 additions & 0 deletions conductor/tracks/B9/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# B9 Implementation Plan

Make every torn/extracted vertex name its pre-HEM (input) vertex, so a consumer
who wraps their own mesh in a HalfEdgeMesh to flatten can build a per-wedge UV
map that lands back on their pre-HEM mesh — seam-duplicated corners included.
The `insert_face` winding reversal is absorbed by identity-keyed corner
resolution and needs no separate record (see spec.md and issue #100).

## Phase 1: Investigation & decision
- [ ] 1.1 Reproduce the gap: tear a seam, confirm the duplicate vertex has no
recorded link to its pre-HEM origin and that an atlas seam corner
cannot be resolved to the consumer's `(face, corner)` today
- [ ] 1.2 Confirm the already-recoverable hops to keep scope tight: face index
(insert_faces order + split_path never re-inserts), non-seam vertex
index (insert_vertices order + tearing only appends), and that
identity-keyed corner resolution absorbs the winding reversal
- [ ] 1.3 Survey duplication/copy sites: split_edge/split_path (duplication) and
clone_face_ (extraction vertex copy) — confirm an `origin` field rides
the copy path; choose vertex-origin tracking vs returned remaps and
record the decision in spec.md

## Phase 2: Tests (write first)
- [ ] 2.1 Duplicate → pre-HEM vertex recovery across split_path (incl. multi-seam)
- [ ] 2.2 Full round-trip: packed/merged atlas corner → F2 maps → torn-HEM corner
→ B9 mapping → pre-HEM face, corner, vertex; assert a seam corner lands
on the correct pre-HEM (face, corner) by identity
- [ ] 2.3 Mis-wound input face: confirm the winding reversal is absorbed — the
round-trip lands the correct corner with no corner-order record
- [ ] 2.4 Identity/no-op case: an untorn, correctly-wound mesh round-trips to the
identity mapping

## Phase 3: Implementation
- [ ] 3.1 Implement duplicate → pre-HEM vertex mapping in split_edge/split_path
(origin set at construction, copied to duplicates)
- [ ] 3.2 Ensure the mapping survives extract_connected_components (clone_face_
vertex copy) and composes with F2's vertex_map/vertex_source
- [ ] 3.3 Document behavior + recovery API on split_*; update the
MultiChartFlatten.cpp reference comment to key corners against the
pre-HEM mesh using the vertex origin
- [ ] 3.4 Regenerate single header (and update install list if a header is added)

## Phase 4: Verify
- [ ] 4.1 Run `ctest` — all suites pass
- [ ] 4.2 Run clang-format on changed files
- [ ] 4.3 Confirm single-header build compiles and runs
108 changes: 108 additions & 0 deletions conductor/tracks/B9/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# B9 — No recoverable mapping from torn seam-duplicate vertices back to the consumer's pre-HEM mesh

## GitHub Issue
https://github.com/educelab/OpenABF/issues/100

## Use case
Downstream consumers do **not** use `HalfEdgeMesh` as their primary mesh type.
They keep their own mesh (the "raw" / pre-HEM mesh — their own vertex array and
face list) and *wrap* it in a `HalfEdgeMesh` only to flatten: build the HEM with
`insert_vertices`/`insert_faces`, tear it (`split_path`), extract components,
parameterize, and pack/merge. They then build a **per-wedge UV map** from the
packed result and expect to apply it back onto their **pre-HEM mesh**, keyed by
their own `(face, corner)` identities.

## Summary
That round-trip is `atlas corner → (F2 maps) → torn-HEM corner → pre-HEM mesh`.
Three of the four hops already round-trip cleanly:

- **Face index.** `insert_faces` inserts faces in order
(`include/OpenABF/HalfEdgeMesh.hpp:1040-1049`) and `split_path` only tears
edges (it never re-inserts faces), so pre-HEM face `i` == HEM face `i`, and
F2's `face_map`/`face_source` carry it back.
- **Corner order.** `insert_face` may auto-reverse a mis-wound face to keep the
mesh manifold (`include/OpenABF/HalfEdgeMesh.hpp:1706-1748`), permuting a
face's stored corner order relative to the raw input. This is **not** a
blocker: the documented per-wedge recipe resolves corners by *vertex
identity* against the consumer's own face, never by raw traversal index
(`include/OpenABF/ChartPacking.hpp:108-123`), so the reversal is **absorbed**,
not inverted. (See "Out of scope".)
- **Non-seam vertices.** `insert_vertices` preserves order and tearing only
*appends* new vertices, so a non-duplicated HEM vertex keeps its pre-HEM
index; F2's `vertex_map`/`vertex_source` carry it back.

The one hop that breaks is **seam-vertex identity**. Tearing duplicates seam
vertices via `insert_vertex(oldStart->pos)`
(`include/OpenABF/HalfEdgeMesh.hpp:1445,1465`), appending a new index whose only
link to its origin is a copied position. `split_path`/`split_edge` return `void`
and record no `duplicate → original` vertex map. So a torn-HEM corner that lands
on a seam duplicate **cannot be expressed in the consumer's pre-HEM vertex
namespace** — and identity-based corner resolution against the consumer's own
face (the very mechanism that absorbs the winding reversal) fails for exactly
those corners.

## Why this matters
The per-wedge UV recipe in `MultiChartFlatten.cpp` works entirely *within* the
torn HEM's own namespace, which is self-consistent. But the consumer's goal is
to land UVs on their **pre-HEM** mesh. For every non-seam corner that already
works; for a seam corner it silently cannot, because the duplicate has no
recoverable pre-HEM vertex identity. The same gap blocks scattering per-vertex
attributes captured on the pre-HEM mesh and emitting output indexed by the
consumer's original vertices. Discovered during F2 (PR #99).

## Goal
Make every torn/extracted vertex — original or seam duplicate — name its
**pre-HEM (input) vertex**, so the documented identity-keyed per-wedge recipe
resolves corners against the consumer's own faces for *all* corners, seams
included. Composed with F2's `vertex_map`/`face_map` and
`vertex_source`/`face_source`, a consumer can take any corner of a packed/merged
atlas and name the pre-HEM face, corner, and vertex it came from.

## Out of scope
- Removing auto-rewinding or seam duplication (both are intentional).
- Recording the `insert_face` corner-order permutation / a reversed flag.
Identity-keyed corner resolution (`ChartPacking.hpp:108-123`) absorbs the
reversal, so it does **not** need to be inverted for this use case. The corner
order is recovered implicitly once seam-duplicate vertices carry their pre-HEM
identity (this AC), by locating each vertex within the consumer's own face.

## Acceptance Criteria
- [ ] `split_edge`/`split_path` expose a recoverable **duplicate → pre-HEM**
vertex mapping (e.g. an `origin` index stored on every vertex, set at
construction and copied to duplicates, that survives extraction; or a
returned/accumulated remap).
- [ ] The mapping survives `extract_connected_components` (`clone_face_` copies
vertices, so an `origin` field rides along) and composes with F2's
`vertex_map`/`vertex_source`.
- [ ] A worked path demonstrates the full round-trip: packed/merged atlas corner
→ (F2 maps) → torn-HEM corner → (B9 mapping) → pre-HEM face, corner, and
vertex index — resolving the corner by vertex identity against the
consumer's own face, with seam-duplicate corners resolving correctly.
- [ ] Unit tests on a mesh with at least one torn seam assert the duplicate →
pre-HEM vertex mapping recovers the original identity, and that a seam
corner of the packed atlas lands on the correct pre-HEM `(face, corner)`.
Include a mis-wound input face to confirm the winding reversal is absorbed
(the round-trip still lands the right corner without a corner-order record).
- [ ] An untorn, correctly-wound mesh round-trips to the identity mapping.
- [ ] `split_*` documentation describes the behavior and points to the recovery
API; the `MultiChartFlatten.cpp` reference comment is updated to key
corners against the pre-HEM mesh using the new vertex origin.
- [ ] Single-header regenerated; multiheader install list updated if a new
header is introduced.

## Candidate approaches (decide in Phase 1)
1. **Vertex origin tracking (primary).** Store an `origin` (pre-HEM vertex index)
set on construction and copied to duplicates by `split_edge`, so every vertex
— original or duplicate — names its pre-HEM vertex. Survives
`clone_face_`/extraction via the vertex copy path. With this, corners are
located by identity in the consumer's own face and the winding reversal needs
no separate record.
2. **Returned remaps.** `split_edge`/`split_path` return/accumulate
`duplicate → original` pairs. Lighter-weight but does not survive extraction
without the caller threading it through, and does not give a uniform
"every vertex names its input vertex" accessor.

## Dependencies
- Independent of F2 (PR #99), but motivated by it; the F2 maps
(`vertex_map`/`face_map`, `vertex_source`/`face_source`) are the downstream
half of the chain B9 completes back to the pre-HEM mesh.
105 changes: 88 additions & 17 deletions conductor/tracks/F2/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,96 @@ Design resolved 2026-06-20 (see spec.md → Design Decisions).
- [x] 1.6 Resolve edge cases: empty list no-op, zero-area placed, null/empty throws, Dim>=2

## Phase 2: Tests (write first)
- [ ] 2.1 Synthetic 2D charts: bbox computation correctness (min/max per mesh)
- [ ] 2.2 Assert packed chart bounding boxes do not overlap (padding respected)
- [ ] 2.3 Assert absolute-mode preserves relative chart sizes (no per-chart distortion)
- [ ] 2.4 Assert normalize=true fits all UVs within [0,1]² via single global scale
- [ ] 2.5 Assert returned PackResult extent bounds all packed charts
- [ ] 2.6 Degenerate cases: empty list, single chart, zero-area chart, null/empty throw
- [ ] 2.7 End-to-end: tear → extract_connected_components → LSCM → PackCharts, and
- [x] 2.1 Synthetic 2D charts: bbox computation correctness (min/max per mesh)
- [x] 2.2 Assert packed chart bounding boxes do not overlap (padding respected)
- [x] 2.3 Assert absolute-mode preserves relative chart sizes (no per-chart distortion)
- [x] 2.4 Assert normalize=true fits all UVs within [0,1]² via single global scale
- [x] 2.5 Assert returned PackResult extent bounds all packed charts
- [x] 2.6 Degenerate cases: empty list, single chart, zero-area chart, null/empty throw
- [x] 2.7 End-to-end: tear → extract_connected_components → LSCM → PackCharts, and
verify per-wedge recovery via (face_map[f], vertex_map[corner.vertex.idx])

## Phase 3: Implementation
- [ ] 3.1 Create `include/OpenABF/ChartPacking.hpp` with PackOptions, PackResult
- [ ] 3.2 Implement per-chart bbox + sqrt-area target width + shelf placement
- [ ] 3.3 Implement absolute (translate-only) and normalize (global uniform scale) modes
- [ ] 3.4 Implement padding, degenerate-input handling, static_assert(Dim>=2)
- [ ] 3.5 Document the vertex-identity per-wedge recipe in the header + complexity notes
- [ ] 3.6 Add include to `include/OpenABF/OpenABF.hpp`
- [ ] 3.7 Update `single_include.json` and run amalgamation script
- [x] 3.1 Create `include/OpenABF/ChartPacking.hpp` with PackOptions, PackResult
- [x] 3.2 Implement per-chart bbox + sqrt-area target width + shelf placement
- [x] 3.3 Implement absolute (translate-only) and normalize (global uniform scale) modes
- [x] 3.4 Implement padding, degenerate-input handling, static_assert(Dim>=2)
- [x] 3.5 Document the vertex-identity per-wedge recipe in the header + complexity notes
- [x] 3.6 Add include to `include/OpenABF/OpenABF.hpp`
- [x] 3.7 Update single-header via amalgamation script (single_include.json unchanged —
it already tracks OpenABF.hpp transitively)

## Phase 4: Verify
- [ ] 4.1 Run `ctest` — all tests pass
- [ ] 4.2 Run clang-format on changed files
- [ ] 4.3 Confirm single-header build matches multi-header
- [x] 4.1 Run `ctest` — all suites pass (incl. OpenABF_TestChartPacking, OpenABF_TestMeshMerge)
- [x] 4.2 Run clang-format on changed files
- [x] 4.3 Confirm single-header build compiles and runs

## Phase 5: MergeMeshes helper (added during review)
Rationale: the inline atlas merge in the example severs the back-map chain.
MergeMeshes is the inverse of extract_connected_components — it returns
provenance maps so merged → chart → M' composition keeps working.
- [x] 5.1 Tests first: concatenation counts, vertex/face provenance, null/empty
throw, round-trip extract→merge recovers original (torn-mesh) identity
- [x] 5.2 Implement `MergeMeshes<MeshType>` → `MergedMesh{mesh, vertex_source, face_source}`
in `include/OpenABF/MeshMerge.hpp` (preserves vertex traits/positions;
edge/face traits default-constructed)
- [x] 5.3 Wire into OpenABF.hpp + multiheader install list; regenerate single header
- [x] 5.4 Switch MultiChartFlatten example to use MergeMeshes
- [x] 5.5 Verify: full ctest, single-header build, install-test all pass

## Phase 6: Perimeter padding (added during review)
Rationale: review question "shouldn't we add padding around the packed
charts?". The original layout applied `padding` only as a gutter *between*
charts — perimeter charts still touched the atlas boundary (left/bottom at the
origin, rightmost/topmost at the extent). For a texture atlas this lets edge
charts bleed across the boundary/seam under filtering, mipmapping, or wrap
addressing. Resolution (user-confirmed): inset the whole layout so `padding`
surrounds every chart on all four sides; keep the library default `padding = 0`
and instead set a visible padding in the example.
- [x] 6.1 Tests first: assert padding insets charts from the atlas perimeter
(new PaddingSurroundsChartsAtPerimeter); update single-row extent
expectation (pad + w0 + pad + w1 + pad)
- [x] 6.2 Implement perimeter inset: cursor starts/wraps at `pad`; add `pad` to
far extents; normalize fits the padded atlas into [0,1]²
- [x] 6.3 Update header docs (padding surrounds charts; atlas lower corner stays
at origin) + spec Decision 5 / acceptance criteria
- [x] 6.4 Set a visible `padding` in the MultiChartFlatten example
- [x] 6.5 Regenerate single header; verify full ctest, example run, single-header
build, clang-format all pass

## Phase 7: In-plane bounding-box minimization (added during review)
Rationale: shelf packing works on axis-aligned boxes, so a chart that arrives
rotated wastes atlas area equal to the slack between its AABB and its true
footprint. Rotating each chart to its min-area box (and standing it on its long
axis) makes the tallest-first shelf strategy far more effective. Default is
`true` — see spec.md Decision 7 for why that default is safe.
- [x] 7.1 Tests first: MinimizeBoundingBoxTightensRotatedChart (off-axis 4x1
rectangle collapses to its true 4x1 area, long axis vertical),
MinimizeBoundingBoxStandsWideChartUpright, MinimizeBoundingBoxCanBeDisabled
- [x] 7.2 Implement `detail::MinimizeChartBoundingBox` — monotone-chain convex
hull, orientation search over hull edges, 90-degree stand-up composition,
in-place rotation of the first two position components
- [x] 7.3 Add `PackOptions::minimize_bounding_box` (default true) and call it
during the bbox measurement pass
- [x] 7.4 Add `Vec::Dimensions` so PackCharts can static_assert on the position
type; document rotation + revised complexity in the header
- [x] 7.5 Record the feature in spec.md (Decision 1/5/6/7 + acceptance criteria)
- [x] 7.6 Update pre-existing translation/layout tests to disable rotation so
they still exercise layout in isolation
- [x] 7.7 Regenerate single header; full ctest green

## Phase 8: HalfEdgeMesh BFS fix (out of scope, carried by this PR)
Rationale: found while running the packing pipeline on a real partitioned mesh.
`num_connected_components()` and `connected_components()` marked a face visited
when it was *dequeued*, so a face adjacent to two or more still-queued faces was
enqueued once per incident interior edge. This is a correctness bug, not just
wasted work: the duplicate entries reach `connected_components()`'s face lists,
and `extract_connected_components()` then clones the same face twice and throws
"Attempted to add non-manifold face". Measured on a 4x4 grid: 38 faces reported
for an 18-face mesh. Kept in this PR (user-confirmed) because F2's pipeline is
unusable without it; tracked separately as its own bug issue.
- [x] 8.1 Mark faces visited at enqueue time in both BFS traversals
- [x] 8.2 Regression test `ConnectedComponentsVisitEachFaceOnce`: component face
list has no duplicates and matches num_faces(); extracted face_map is a
permutation of [0, num_faces). Verified Red against develop's BFS.
- [x] 8.3 File the bug issue (#103, track B10) and link it from tracks.md
Loading