feat: Multi-chart UV packing (PackCharts)#99
Merged
Conversation
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>
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>
csparker247
commented
Jun 22, 2026
- 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>
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements F2 — Multi-chart UV packing (closes #18): geometry-only free functions
PackCharts<MeshType>andMergeMeshes<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'svertex_map/face_mapto 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)
pos; topology and indices unchanged, so anyExtractedComponentback-maps the caller holds stay valid.UVMaptype. The updatedMultiChartFlattenexample documents how to build a per-corner UV map from the packed atlas using back-maps, keyed by vertex identity (not corner position).minimize_bounding_box = false.normalizeapplies a single global uniform scale to fit[0,1]², preserving relative chart sizes and cross-chart texel density.paddingoption surrounds every chart on all four sides, including against the atlas boundary. Perimeter charts are inset from the packed extent bypadding, not merely separated from neighbors. Library default ispadding = 0; the example sets a visiblepadding = 0.1fto prevent texture filtering bleed.sqrt(Σ chart bbox area), overridable viaPackOptions.PackOptions<T>{minimize_bounding_box, normalize, target_width, padding}andPackResult<VecType>{min, max}(packed atlas extent).PackResultcarries the meshes' own position vector type so the extent needs no conversion to compare against vertex positions; onlyu/vare meaningful.MergeMeshesreturnsMergedMesh{mesh, vertex_source, face_source}. Both functions takestd::vector<std::shared_ptr<MeshType>>, soMeshTypeis deduced at the call site —PackCharts(charts)— with the explicitPackCharts<Mesh>(charts)spelling still supported.padding, or non-positivetarget_width→std::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()andconnected_components()marked a facevisitedwhen 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:
include/OpenABF/ChartPacking.hpp—PackCharts,PackOptions,PackResult,detail::MinimizeChartBoundingBoxinclude/OpenABF/MeshMerge.hpp—MergeMeshes,MergedMeshinclude/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 publicPositionTypealiasinclude/OpenABF/Vec.hpp— publicstatic constexpr std::size_t Dimensionsfor the position-dimensionstatic_assertinclude/OpenABF/OpenABF.hppandCMakeLists.txt— includes and multiheader install entries for both new headerssingle_include/OpenABF/OpenABF.hpp— regenerated via amalgamationTests:
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_widthwrapping, degenerate and rejected inputs, end-to-end tear→extract→flatten→pack with per-wedge recoverytests/src/TestMeshMerge.cpp— 9 cases: concatenation counts, vertex/face provenance including index alignment, merged-mesh edge ownership, degenerate inputs, round-trip extract→merge identity recoverytests/src/TestHalfEdgeMesh.cpp—ConnectedComponentsVisitEachFaceOnceregression test for extract_connected_components throws on any mesh with mutually-adjacent interior faces (BFS marks visited on dequeue) #103Example:
examples/src/MultiChartFlatten.cpp— tears a mesh, extracts and parameterizes components, packs them into a single atlas usingPackChartswith visiblepadding, merges back viaMergeMeshes, and writes a single packed atlas .obj. Chart flattening is wrapped intry/catchonSolverException: 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:
.gitignorenow coversopenabf_example_*.obj/.plyso running the examples no longer dirties the tree.Review follow-ups
Applied after code review of the initial implementation:
PackChartsno 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.PackResult::minwas hard-coded to the origin; the atlas corner is now measured from actual placements and applied as part of the transform.paddingand non-positivetarget_widthare rejected rather than silently producing a degenerate layout.MergeMeshesno longer depends on implicit copy-constructor behavior to avoid carryingVertex::edgepointers across meshes; it nullsedgeexplicitly, matchingclone()andextract_connected_components().MeshTypeis deducible for both functions;HalfEdgeMesh::PositionTypereplaces adecltype/declvalexpression inPackCharts's return type.target_widthwrapping,face_sourceindex 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_TestChartPackingat 22 cases andOpenABF_TestMeshMergeat 9).-DOPENABF_MULTIHEADER=OFF) configures, compiles, and passes all 8 suites.single_include/OpenABF/OpenABF.hppverified byte-identical to a fresh amalgamation run.MultiChartFlattenexample builds and runs, writing a packed 12-vertex / 8-face atlas.#103regression test verified Red againstdevelop's traversal and Green with the fix. The three validation tests were likewise verified Red against the pre-review implementation.clang-formatclean.Complexity
O(n log n)in chart count +O(V)in total vertices;O(n)memory. Withminimize_bounding_box, each chart additionally costs anO(v log v)convex hull plus anO(h²)orientation search over itshhull points.🤖 Generated with Claude Code