Skip to content

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

Open
csparker247 wants to merge 15 commits into
developfrom
f2-multi-chart-packing
Open

feat: Multi-chart UV packing (PackCharts)#99
csparker247 wants to merge 15 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 translates (and optionally uniformly scales) each chart's 2D vertex positions in place using shelf packing, without touching topology or indices. Charts are sorted by height; layout wraps 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.

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).
  • 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.
  • API. PackOptions{normalize, target_width, padding} and PackResult{min, max} (packed atlas extent); MergeMeshes returns MergedMesh{mesh, vertex_source, face_source}.
  • Edge cases. Empty list → empty extent; zero-area chart placed; null/empty chart → std::invalid_argument; static_assert(Dim >= 2).

Changes

Core Implementation:

  • New include/OpenABF/ChartPacking.hppPackCharts, PackOptions, PackResult
  • New include/OpenABF/MeshMerge.hppMergeMeshes, MergedMesh
  • Updated include/OpenABF/OpenABF.hpp — includes for both new headers
  • Updated single_include/OpenABF/OpenABF.hpp — regenerated via amalgamation

Tests:

  • New tests/src/TestChartPacking.cpp — 12 cases: bbox correctness, non-overlap with padding, absolute/normalize scaling, normalization fits [0,1]², extent containment, degenerate inputs, end-to-end tear→extract→flatten→pack with per-wedge recovery
  • New tests/src/TestMeshMerge.cpp — concatenation counts, vertex/face provenance, degenerate inputs, round-trip extract→merge identity recovery

Example:

  • Completely rewritten examples/src/MultiChartFlatten.cpp — now 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. Includes extensive reference documentation for building a per-wedge UV map from the merged result and back-maps.

Documentation:

Verification

  • ctest: 7/7 suites pass (incl. new OpenABF_TestChartPacking and OpenABF_TestMeshMerge).
  • Single-header build compiles and runs standalone.
  • clang-format clean.

Complexity

O(n log n) in chart count + O(V) in total vertices; O(n) memory.

🤖 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

template <typename U, std::size_t N>
struct VecDimensions<Vec<U, N>> {
static constexpr std::size_t value = N;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't we just add Dimensions as a static Vec property properly?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 1013328 — added a static Vec::Dimensions member and dropped the detail::VecDimensions trait; the static_assert now reads VecType::Dimensions >= 2.

* @tparam T Floating-point scalar type
*/
template <typename T>
struct PackResult {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should just inherit the Vec type of the input HEMs, no?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 1013328PackResult is now templated on the mesh vertex position vector type (PackResult<VecType> with VecType min/max), deduced from MeshType::Vertex::pos, instead of a hardcoded Vec<T,2>. Note the extent now carries the full mesh vector type (e.g. Vec<T,3>), with only the u/v components meaningful — documented on the struct. Shout if you would rather it stay strictly 2D.

Comment thread include/OpenABF/ChartPacking.hpp Outdated
auto mxX = std::numeric_limits<T>::lowest();
auto mxY = std::numeric_limits<T>::lowest();
for (const auto& v : chart->vertices()) {
mnX = std::min(mnX, v->pos[0]);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not std::minmax with a structured binding?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 1013328 — replaced the manual loop with two std::minmax_element calls (comparing pos[0]/pos[1]) and structured bindings.

}
// Re-emit each face against the offset vertex indices, preserving the
// source face's corner order.
for (const auto& face : src->faces()) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess insert_faces doesn't work here? You're not remembering to call mesh.update_boundary()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 1013328. MergeMeshes now gathers every face into one list and inserts them with a single out->insert_faces(faces), which rebuilds the boundary via update_boundary() once at the end.

width[i] = mxX - mnX;
height[i] = mxY - mnY;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At least with this chart packing method, we need to add some sort of axis-aligned bounding box area minimization here (rotate in plane until AABB is minimized) before the we pack the charts. It should be a pack option, defaulting to being enabled.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 1013328 as the minimize_bounding_box pack option (default enabled). Each chart is rotated to its minimum-area orientation before packing: convex hull (monotone chain) + rotating-caliper search over hull-edge orientations. It runs in place and preserves topology/vertex identity, so back-maps stay valid.

Per your follow-up, it also stands each chart on its long axis (larger extent vertical) so orientation is deterministic and aligns with the tallest-first shelf strategy — narrower charts pack more per shelf, fewer shelves, less inter-shelf padding. Covered by new tests MinimizeBoundingBoxTightensRotatedChart, MinimizeBoundingBoxStandsWideChartUpright, and MinimizeBoundingBoxCanBeDisabled.

- 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>
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.

[F2] Multi-chart packing in a common world coordinate frame

1 participant