Skip to content

feat: Multi-chart UV packing (PackCharts)#99

Merged
csparker247 merged 21 commits into
developfrom
f2-multi-chart-packing
Jul 24, 2026
Merged

feat: Multi-chart UV packing (PackCharts)#99
csparker247 merged 21 commits into
developfrom
f2-multi-chart-packing

Conversation

@csparker247

@csparker247 csparker247 commented Jun 20, 2026

Copy link
Copy Markdown
Member

Summary

Implements F2 — Multi-chart UV packing (closes #18): geometry-only free functions PackCharts<MeshType> and MergeMeshes<MeshType> for laying out and merging already-parameterized charts into a shared coordinate frame.

PackCharts rotates, translates, and (optionally) uniformly scales each chart's 2D vertex positions in place using shelf packing, without touching topology or indices. Each chart is first rotated within its UV plane to its minimum-area bounding box and stood on its long axis; charts are then sorted by height and laid out left-to-right, wrapping to a new shelf when the row width exceeds the target (defaults to sqrt(total area) for a roughly square atlas).

MergeMeshes concatenates a vector of charts into a single mesh, returning provenance maps (vertex_source, face_source) so the atlas can be traced back through the component's vertex_map/face_map to the original torn mesh.

Also fixes a pre-existing correctness bug in HalfEdgeMesh's connected-component traversal (#103) that made multi-chart extraction unusable on real meshes — see Carried bug fix below.

Design (resolved via design review)

  • Geometry-only / in-place. Both functions mutate only vertex pos; topology and indices unchanged, so any ExtractedComponent back-maps the caller holds stay valid.
  • Per-wedge UV recipe documented, not owned. OpenABF does not ship a UVMap type. The updated MultiChartFlatten example documents how to build a per-corner UV map from the packed atlas using back-maps, keyed by vertex identity (not corner position).
  • Bounding-box minimization (default on). Shelf packing reasons about axis-aligned boxes, so a chart that arrives rotated wastes atlas area equal to the slack between its AABB and its true footprint. Each chart is rotated in-plane to its minimum-area box (monotone-chain convex hull, then test the orientation induced by each hull edge — the min-area enclosing rectangle always has an edge collinear with a hull edge), then stood on its long axis to match the tallest-first shelf strategy. The transform is a rigid rotation (determinant +1), so winding order and texture handedness are preserved along with topology and vertex identity. Disable with minimize_bounding_box = false.
  • Scaling. Absolute (physical) scale preserved by default (translate-only); opt-in normalize applies a single global uniform scale to fit [0,1]², preserving relative chart sizes and cross-chart texel density.
  • Padding. The padding option surrounds every chart on all four sides, including against the atlas boundary. Perimeter charts are inset from the packed extent by padding, not merely separated from neighbors. Library default is padding = 0; the example sets a visible padding = 0.1f to prevent texture filtering bleed.
  • Layout. Shelf packing, charts sorted by height; target shelf width defaults to sqrt(Σ chart bbox area), overridable via PackOptions.
  • Measured extent. The returned atlas extent is measured from where the charts actually landed and the transform shifts by that corner, so "the atlas lower corner sits on the origin" holds by construction rather than by assumption about the layout loop.
  • API. PackOptions<T>{minimize_bounding_box, normalize, target_width, padding} and PackResult<VecType>{min, max} (packed atlas extent). PackResult carries the meshes' own position vector type so the extent needs no conversion to compare against vertex positions; only u/v are meaningful. MergeMeshes returns MergedMesh{mesh, vertex_source, face_source}. Both functions take std::vector<std::shared_ptr<MeshType>>, so MeshType is deduced at the call site — PackCharts(charts) — with the explicit PackCharts<Mesh>(charts) spelling still supported.
  • Validation. All options and chart pointers are checked before any chart is modified, so a rejected input leaves every chart untouched. Empty list → empty extent; zero-area chart placed; null/empty chart, negative padding, or non-positive target_widthstd::invalid_argument; static_assert(PositionType::Dimensions >= 2) on the vertex position type.

Carried bug fix (#103)

Found while running this pipeline on a real partitioned mesh. num_connected_components() and connected_components() marked a face visited when it was dequeued rather than when it was enqueued, so a face adjacent to two or more still-queued faces was enqueued once per incident interior edge and appeared multiple times in the component's face list.

This was a correctness bug, not just redundant work: on a 4×4 grid (18 faces) the single component came back with 38 faces, and extract_connected_components() then cloned the same face twice and threw "Attempted to add non-manifold face". Any mesh with mutually-adjacent interior faces hit it; the existing component tests used one- and two-triangle meshes, which have no mutually-adjacent neighbors, so nothing caught it.

The fix marks faces visited at push time in both traversals. It ships here rather than in a standalone PR because F2's pipeline cannot run without it; it is tracked as its own bug issue (#103, Conductor track B10) with a regression test confirmed Red against develop's traversal.

Changes

Core Implementation:

  • New include/OpenABF/ChartPacking.hppPackCharts, PackOptions, PackResult, detail::MinimizeChartBoundingBox
  • New include/OpenABF/MeshMerge.hppMergeMeshes, MergedMesh
  • Updated include/OpenABF/HalfEdgeMesh.hpp — BFS visit-on-enqueue fix in both component traversals (extract_connected_components throws on any mesh with mutually-adjacent interior faces (BFS marks visited on dequeue) #103); new public PositionType alias
  • Updated include/OpenABF/Vec.hpp — public static constexpr std::size_t Dimensions for the position-dimension static_assert
  • Updated include/OpenABF/OpenABF.hpp and CMakeLists.txt — includes and multiheader install entries for both new headers
  • Updated single_include/OpenABF/OpenABF.hpp — regenerated via amalgamation

Tests:

  • New tests/src/TestChartPacking.cpp — 22 cases: bbox correctness, non-overlap with padding, perimeter padding inset, absolute/normalize scaling, normalization fits [0,1]², extent containment, bounding-box minimization (tightens a rotated chart, stands a wide chart upright, can be disabled), topology/index invariance, face-orientation preservation, target_width wrapping, degenerate and rejected inputs, end-to-end tear→extract→flatten→pack with per-wedge recovery
  • New tests/src/TestMeshMerge.cpp — 9 cases: concatenation counts, vertex/face provenance including index alignment, merged-mesh edge ownership, degenerate inputs, round-trip extract→merge identity recovery
  • Updated tests/src/TestHalfEdgeMesh.cppConnectedComponentsVisitEachFaceOnce regression test for extract_connected_components throws on any mesh with mutually-adjacent interior faces (BFS marks visited on dequeue) #103

Example:

  • Rewritten examples/src/MultiChartFlatten.cpp — tears a mesh, extracts and parameterizes components, packs them into a single atlas using PackCharts with visible padding, merges back via MergeMeshes, and writes a single packed atlas .obj. Chart flattening is wrapped in try/catch on SolverException: not every chart topology is solvable, and one unsolvable chart should not abort the atlas; if no chart survives, the example exits with a message rather than writing an empty atlas. Includes extensive reference documentation for building a per-wedge UV map from the merged result and back-maps.

Documentation / project bookkeeping:

Review follow-ups

Applied after code review of the initial implementation:

  • PackCharts no longer partially mutates on throw. Validation was interleaved with the in-place rotation, so a null chart partway through the list threw only after earlier charts had already been rotated — with no way for the caller to tell which. All inputs are now validated in a dedicated pass first.
  • The returned extent can no longer disagree with the geometry. PackResult::min was hard-coded to the origin; the atlas corner is now measured from actual placements and applied as part of the transform.
  • Negative padding and non-positive target_width are rejected rather than silently producing a degenerate layout.
  • MergeMeshes no longer depends on implicit copy-constructor behavior to avoid carrying Vertex::edge pointers across meshes; it nulls edge explicitly, matching clone() and extract_connected_components().
  • MeshType is deducible for both functions; HalfEdgeMesh::PositionType replaces a decltype/declval expression in PackCharts's return type.
  • New regression coverage for the invariants the documentation promises but CI did not enforce: topology/index invariance across a pack, rigid-rotation (no reflection) of the bbox minimization, target_width wrapping, face_source index alignment, and merged-vertex edge ownership.

Known and tracked, not addressed here: the default shelf-width heuristic and the wrap test disagree on padding, so the atlas wastes area and can be far from square (#104, track P7). The layout stays valid and non-overlapping.

Verification

  • ctest: 8/8 suites pass (incl. OpenABF_TestChartPacking at 22 cases and OpenABF_TestMeshMerge at 9).
  • Single-header build (-DOPENABF_MULTIHEADER=OFF) configures, compiles, and passes all 8 suites.
  • Amalgamated single_include/OpenABF/OpenABF.hpp verified byte-identical to a fresh amalgamation run.
  • MultiChartFlatten example builds and runs, writing a packed 12-vertex / 8-face atlas.
  • #103 regression test verified Red against develop's traversal and Green with the fix. The three validation tests were likewise verified Red against the pre-review implementation.
  • clang-format clean.

Complexity

O(n log n) in chart count + O(V) in total vertices; O(n) memory. With minimize_bounding_box, each chart additionally costs an O(v log v) convex hull plus an O(h²) orientation search over its h hull points.

🤖 Generated with Claude Code

csparker247 and others added 8 commits June 20, 2026 10:14
Add TestChartPacking.cpp covering edge cases, absolute/normalize scaling,
non-overlap, extent, padding, and an end-to-end tear/extract/flatten/pack
test with per-wedge recovery via the back-maps. ChartPacking.hpp provides
PackOptions/PackResult and a stubbed PackCharts so the suite compiles and
fails (red); implementation follows in Phase 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implement multi-chart UV packing as a geometry-only free function that
translates (and optionally uniformly scales) each chart's vertex positions
in place. Charts are laid out with shelf packing using a sqrt-area target
width (overridable); absolute scale is preserved by default with opt-in
normalize to [0,1]^2 via a single global scale. Returns the packed atlas
extent. Documents the vertex-identity per-wedge recipe and complexity.

Wire ChartPacking.hpp into OpenABF.hpp and regenerate the single header.
All 12 PackCharts tests pass (full suite 7/7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The multiheader install enumerates public headers explicitly; ChartPacking.hpp
was missing, so the installed OpenABF.hpp failed to find it (CI install-test,
Multiheader=ON). Add it to the install list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The direct include of OpenABF/ChartPacking.hpp broke the single-header
build (Multiheader=OFF), where only the amalgamated OpenABF.hpp exists.
ChartPacking is included transitively via OpenABF.hpp, matching the other
test files; drop the direct include.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Update MultiChartFlatten to demonstrate PackCharts: flatten each connected
component, pack the charts into a shared [0,1]^2 frame, merge them into one
mesh, and write a single packed-atlas .obj instead of one file per chart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add MergeMeshes<MeshType> -> MergedMesh{mesh, vertex_source, face_source}:
concatenates meshes into one and returns provenance maps (merged idx ->
{input mesh, source idx}) so the back-map chain survives a merge (compose
with each component's vertex_map/face_map to reach the torn source mesh).
Preserves vertex positions/traits; edge/face traits are default-constructed.

Switch the MultiChartFlatten example to use MergeMeshes instead of an inline
concatenate that discarded provenance. Wire the header into OpenABF.hpp and
the multiheader install set; regenerate the single header. New test suite
TestMeshMerge covers counts, provenance, throws, and an extract->merge
round-trip that recovers original face identity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a non-compiled reference comment to MultiChartFlatten showing how to
populate an educelab::core UVMap from the merged atlas: walk atlas faces
back to the torn source mesh via face_source/face_map, take UVs from the
packed chart vertices, and resolve corner positions by vertex identity
(winding is not guaranteed stable). Demonstrates the optional WithChart
trait for per-coordinate chart tagging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
WithChart's chart index denotes independent packing domains (separate
[0,1]^2 atlases / usemtl texture pages), not the connected components of a
single shared atlas. This example packs all CCs into one atlas via a single
PackCharts call, so the snippet now uses the default UVMap (one domain) and
documents that WithChart applies only when running multiple independent
packings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
csparker247 and others added 6 commits June 20, 2026 14:13
Document that the per-wedge UVMap is valid for both the torn and the untorn
(pre-split) mesh because corners are resolved by vertex identity against the
target mesh's own faces, which absorbs any insert_face winding reversal at
build/extract/merge. Note the caveat that as-built corner order may differ
from the raw input face list (tracked separately).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…100)

insert_face auto-reverses mis-wound faces (intended, makes almost-manifold
input manifold) but silently changes a face's corner order vs the raw input
and records no input->as-built permutation. Surfaced during F2; downstream
per-corner round-trips can silently desync. Track B9 / issue #100 capture the
problem and candidate fixes (reversal flag, permutation accessor, strict mode,
or docs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… topology

Rescope B9 beyond insert_face rewinding to the full problem: there is no
recoverable path from a torn/parameterized/packed result back to the caller's
original input topology. Two unrecorded identity shifts compose — insert_face
corner-order reversal and split_edge seam-vertex duplication (original ->
duplicate). Require B9 to provide invertible mappings for both, composing with
F2's vertex_map/face_map and vertex_source/face_source so a consumer can name
the original input face, corner position, and vertex for any atlas corner.
Update issue #100 to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PackCharts applied `padding` only as a gutter between charts, so charts on
the atlas perimeter still touched the boundary (left/bottom at the origin,
rightmost/topmost at the extent). For a texture atlas that lets edge charts
bleed across the boundary/seam under filtering, mipmapping, or wrap
addressing.

Inset the whole shelf layout: the cursor starts and wraps at `pad`, and `pad`
is added to the far extents, so every chart has >= padding of empty space on
all four sides, including against the atlas boundary. The atlas lower corner
stays at the origin. normalize now fits the padded atlas into [0,1]^2. The
library default stays padding = 0 (flush); the MultiChartFlatten example sets
a visible padding to demonstrate the gutter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Document that `padding` surrounds every chart on all four sides (incl. the
atlas perimeter) in Design Decision 5 and the acceptance criteria, and add
Phase 6 to the plan capturing the review question and resolution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread include/OpenABF/ChartPacking.hpp Outdated
Comment thread include/OpenABF/ChartPacking.hpp
Comment thread include/OpenABF/ChartPacking.hpp Outdated
Comment thread include/OpenABF/MeshMerge.hpp
Comment thread include/OpenABF/ChartPacking.hpp
- Add static Vec::Dimensions; drop the detail::VecDimensions trait
- Template PackResult on the mesh's vertex Vec type instead of Vec<T,2>
- Use std::minmax_element with structured bindings for chart bounds
- MergeMeshes: batch faces through insert_faces so update_boundary runs
- PackCharts: add minimize_bounding_box option (default on) that rotates
  each chart to its minimum-area orientation and stands its long axis
  vertical to match the tallest-first shelf strategy

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds HalfEdgeMesh.ConnectedComponentsVisitEachFaceOnce, the missing guard for
the BFS fix in 21a6ed1. Pre-fix, an 18-face grid reported 38 faces in its
component list and extract_connected_components() threw "non-manifold face";
the test was confirmed Red against develop's traversal. Filed as #103.

Also brings the F2 track docs back in line with the branch:
- spec Decision 7 records in-plane bounding-box minimization (default on) and
  Decisions 1/5/6 are updated for the rotation step, the PackResult VecType,
  and Vec::Dimensions; acceptance criteria extended to match
- plan gains Phase 7 (bbox minimization) and Phase 8 (the out-of-scope BFS fix
  this PR carries), so 33/33 no longer under-describes the work
- MultiChartFlatten documents why chart flattening is wrapped in try/catch:
  unsolvable chart topologies are expected and must not abort the atlas
- ChartPacking complexity note corrected to O(h^2) for the orientation search
- gitignore openabf_example_*.obj/.ply so examples stop dirtying the tree

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
csparker247 and others added 5 commits July 24, 2026 16:00
Files #104: the sqrt-area target width is computed from padded chart areas
while the wrap test measures the unpadded right edge against a cursor already
inset by `pad`, so a row of k charts is budgeted k gutters when it spends k+1.
Two 1x2 charts with padding 0.1 wrap into a 1.2x4.3 column instead of an
almost-square 2.3x2.2 atlas. Marked blocked_by #18 on GitHub.

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

Adds the track artifacts for issue #104: PackCharts's sqrt-area shelf-width
heuristic counts k padded chart areas while the wrap test measures the unpadded
right edge against a cursor already inset by pad, so a row of k charts is
budgeted k gutters instead of the k+1 it spends. Rows wrap early and waste atlas
area once the extent is fit to a square texture.

spec.md records the worked two-chart case, the chosen fix (greedy wrap on
lexicographic (max(W,H), W*H) cost over the stay/wrap candidate extents, with
target_width keeping its current override semantics), expected extents, and
acceptance criteria. plan.md phases the work TDD-first behind a gate on PR #99,
where the code under change lands.

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

Addresses PR #99 review findings in PackCharts and MergeMeshes.

PackCharts mutates caller-owned meshes in place, but validation was
interleaved with the bounding-box rotation, so a null or empty chart
partway through the list threw only after earlier charts had already been
rotated, with no way for the caller to tell which. Validate all options
and chart pointers in a dedicated pass first. Negative `padding` and
non-positive `target_width` are now rejected as well, rather than
silently producing a degenerate layout.

PackResult::min was hard-coded to the origin on the assumption that the
shelf cursor never goes negative, which made the returned extent
silently wrong for inputs that broke the assumption. Measure the atlas
corner from where the charts actually landed and shift by it when
applying the transform, so the lower-corner-at-origin contract holds by
construction instead of by assumption and survives future changes to
the layout strategy. With the current shelf layout the shift is zero, so
placement is unchanged.

MergeMeshes relied on Vertex's copy constructor happening to drop
`edge`; null it explicitly as clone() and extract_connected_components()
do, so a merged vertex can never reference a half-edge in a source mesh.
Vertex::is_boundary(), wheel(), and is_unreferenced() all read `edge`
directly, so a carried-over pointer would report the source chart's
neighborhood.

Also from review:
- PackCharts/MergeMeshes take std::vector<std::shared_ptr<MeshType>> so
  MeshType is deduced; the explicit spelling keeps working.
- Add HalfEdgeMesh::PositionType, replacing the decltype/declval dance
  in PackCharts's return type.
- MergeMeshes validates and counts up front, enabling reserve().
- MultiChartFlatten: add missing includes and exit with a message when
  no chart could be flattened, rather than writing an empty atlas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ChartPacking (16 -> 22 cases):
- NegativePaddingThrows, NonPositiveTargetWidthThrows and
  InvalidInputLeavesChartsUntouched cover the validation fixes; all three
  are Red against the previous implementation.
- TopologyAndIndicesUnchanged asserts the guarantee every caller's
  back-maps depend on: element counts, per-face corner order, and dense
  vertex indices all survive a pack, including the rotation.
- PackingPreservesFaceOrientation checks signed-area sign and magnitude
  per face. Bounding-box minimization must be a rigid rotation; a
  reflection would flip texture handedness while leaving every
  bounding-box assertion in the file happy.
- TargetWidthWrapsCharts exercises target_width for its actual purpose
  rather than only as a way to force a single row.

MeshMerge (7 -> 9 cases):
- FaceSourceIndexAlignsWithMergedFace compares centroids to prove
  face_source[i] describes merged face i; the existing test only checked
  that provenance was unique and in range.
- MergedVerticesReferenceMergedMeshEdges asserts merged vertices point at
  edges of the merged mesh and that is_boundary() traversal works. This
  guards the Vertex copy-constructor dependency that MergeMeshes relies
  on.

Two call sites use the newly deducible form so the build covers it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@csparker247
csparker247 merged commit 984b89e into develop Jul 24, 2026
16 checks passed
@csparker247
csparker247 deleted the f2-multi-chart-packing branch July 24, 2026 23:12
csparker247 added a commit that referenced this pull request Jul 25, 2026
F2 is complete: all 8 phases (43/43 tasks) landed in PR #99 (984b89e) and
issue #18 auto-closed. Move it to conductor/tracks/_archive/.

Also reconcile the 13 tracks that were marked closed in tracks.md but whose
directories were left in conductor/tracks/ (A7-A10, E2, F3, P4-P6, T5-T8),
so archived status and directory location agree project-wide. Backfill
metadata.json for the 13 that lacked one; P4's existing metadata is updated
in place, preserving its created date and dependencies.

Registry corrections caused by F2 landing:
- Next Up no longer lists F2; notes P7's Phase 0 gate is satisfied
- P7 row no longer claims it is blocked on PR #99 merging
- B10 row notes issue #103 is closed and the track has no directory

conductor/tracks/ now holds exactly the 10 active tracks with directories;
_archive/ holds 42, matching the 42 archived rows in tracks.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
csparker247 added a commit that referenced this pull request Jul 25, 2026
Remove the B10 registry orphan: issue #103 is closed and the fix (BFS marked
visited at enqueue, plus the ConnectedComponentsVisitEachFaceOnce regression
test) shipped as F2 Phase 8 in PR #99, but the track never had a directory.
The work stays recorded in _archive/F2/plan.md tasks 8.1-8.3 and in issue #103.

Backfill the 11 missing metadata.json files:
- 10 active tracks (B8, B9, F5-F11, P7) with status open, title from spec.md,
  type, github_issue, and created/updated from tracks.md
- _archive/M5, the last archived track without metadata; it covers three
  issues (#76, #77, #78), so github_issue holds the first and github_issues
  records all three

Dependency fields are spec-stated, not inferred: F8, F9, and F10 each declare
"Dependencies: F7 (Policy-Based Math Backends)", and P7 declares F2 must merge
first. P4's pre-existing dependency on E1 is untouched.

Registry and filesystem now agree: 10 open rows = 10 active dirs, 42 closed
rows = 42 archived dirs, and all 52 metadata.json files have an id matching
their directory and a status matching their location.

Not treated as a defect: _archive/P6 retains 6 incomplete tasks, correct for
a track archived with reason "Obsolete" that was intentionally never built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant