Skip to content

Add Split feature: cut models into two halves with peg/dowel connectors#2

Merged
rtwfroody merged 54 commits into
mainfrom
split-feature
May 1, 2026
Merged

Add Split feature: cut models into two halves with peg/dowel connectors#2
rtwfroody merged 54 commits into
mainfrom
split-feature

Conversation

@rtwfroody
Copy link
Copy Markdown
Owner

Summary

  • Adds the Split pipeline stage and frontend panel: cut a model along an axis-aligned plane into two halves, lay them out side-by-side on the bed, and bake optional peg or dowel connectors into the cut faces (CGAL Polygon_mesh_processing::clip for the cut, CGAL boolean ops for connectors).
  • 3MF export now emits one <object> per half (non-Bambu path); split panel state is saved/restored with the JSON settings file; alpha-wrap is force-coupled with Split since the cut needs a watertight input.
  • Pipeline infrastructure improvements that landed alongside Split: persistent zstd-compressed on-disk caches for Load/Decimate/Alpha-wrap across app restarts, cost- and recency-aware disk-cache eviction, demand-driven Make-style stage driver, per-stage timestamped logging with cache hit/miss + duration, errors shown as a final line in the output stage list, and collapsible sidebar sections.
  • README documents all of the above, including a step-by-step Split walkthrough and a controls reference table in the appendix.

Test plan

  • go test -timeout 10m ./... passes
  • Open a model, enable Split, verify the live cut-plane overlay, export 3MF, and confirm the slicer sees two independent build items
  • Toggle Pegs / Dowel holes / None and confirm connectors render correctly on each half
  • Disable alpha-wrap while Split is enabled — Split should auto-disable with a toast
  • Restart the app and re-open a recent model; Load/Decimate/Alpha-wrap stages should hit the persistent disk cache
  • Confirm a deliberate pipeline error surfaces as a final line in the stage list

🤖 Generated with Claude Code

rtwfroody and others added 30 commits April 28, 2026 15:48
Extracts SettingsSection — a small wrapper around <details>/<summary>
that renders a chevron, title, optional help tooltip, and divider. The
chevron rotates 90° when the section is open. App.svelte now uses it
for all six settings sections (Printer, Model, Stickers, Color,
Filament, Advanced). Stickers and Advanced default to collapsed; the
rest default to open.

HelpTip now stops click and Enter/Space propagation on its trigger,
so opening the tooltip inside a <summary> no longer toggles the
parent <details>.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Alpha-wrap is the dominant cost of the Load stage when enabled, so
the progress label is misleading. Append a parenthetical when
AlphaWrap is on so the user can tell why Loading is taking longer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Several stage methods called BeginStage at the top of their
runStageCached body and then resolved prerequisites inside that
span. On a cold cache that overlapped progress reports — e.g.
"Loading" was reported as running while Parse() was still doing
its own work, with both stages showing as in-flight in the UI.

Move BeginStage past the prerequisite calls in Load,
ColorAdjust, ColorWarp, and Palette so a stage's progress
indicator only fires once all upstream stages have completed
(or shown their own progress). Cache-hit fast path is unchanged
because runStageCached already skips the body entirely on hits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous score (costMs / sizeBytes) * recency was pure
cost-per-byte. Two large entries with similar density tied on
score so recency picked the winner, which evicted older
expensive Load outputs (alpha-wrap, hundreds of MB) in favor
of similarly large but fresher ones. Recency's 24h halflife
also crushed even very-expensive entries within a few days.

Score is now (costMs / sqrt(max(size, 64KiB))) * 2^(-age/7d):

  - sqrt size penalty so a 1000× larger entry that took 1000×
    longer beats a tiny cheap one (~32× margin) instead of
    tying.
  - 64 KiB size floor so a swarm of small fresh entries can't
    push out a huge expensive one through compounding penalty
    at trivial sizes.
  - 7-day halflife so cost dominates within the live window
    (matches the maxAge cutoff).

Sidecar metadata also now carries a short human-readable
Description ("Load: foo.glb (alpha-wrap)", "Voxelize: foo.glb
@ 0.40/0.20mm", etc.) populated by stageDescription. A new
Cache.OnEvict callback fires for every entry Sweep deletes;
app.go wires it to a stderr line showing description, size,
and generation time so the operator can see what's being
removed and why.

Two new tests pin the formula's intent — large-expensive
beats small-cheap (the user's 1KB/1s vs 1000KB/1000s rule)
and huge-expensive beats tiny-cheap (the floor's job).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extracts cacheblob (gob+zstd encode/decode) and cachepolicy (the
score formula and FitToBudget ranking primitive) into their own
packages so each is unit-testable without dragging in storage.
diskcache now exposes SetBlob/GetBlob and ranks via cachepolicy.

The pipeline's per-stage cap-2 FIFO of decoded structs is gone.
That cache held live alpha-wrapped meshes — gigabytes resident at
high model scales, the original OOM trigger. A briefly-considered
replacement that held compressed bytes in-process turned out to be
largely redundant with the OS page cache (both hold the same
bytes; decode dominates hit latency anyway), so the cleaner answer
is no in-process tier at all. pipelineRun's per-run memoization
of decoded structs (run.go) still covers the within-run case.

Side effects:
- A stage hit always pays a decode (~1–2s for the largest payloads,
  microseconds for small ones). Within a single pipeline run the
  decoded struct is held in pipelineRun, so decode is paid at most
  once per stage per run.
- Corrupted blobs now self-heal: getWithSource removes the file on
  a decode failure so the next access recomputes cleanly.
- The set/stampCost split queues two independent disk-write
  goroutines per miss; the orphan-meta case Sweep already handled
  remains correct, and same-key contention is best-effort by design.
- Disk budget doubled to 2 GiB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After dropping the in-process compressed-byte cache tier, the
existing pattern of `body() { cache.setX(opts, out) }; r.x =
cache.getX(opts)` raced the async disk-write goroutine: getX hit
disk, the file wasn't there yet, getX returned nil, the next
stage dereferenced nil, and the process took SIGSEGV. The
"non-Go signal handler without SA_ONSTACK" diagnostic in the
crash output was real but downstream of our nil deref.

Fix: memoize the body's output into pipelineRun's slot
synchronously before kicking the async cache.set. Within-run
consumers read the slot via pipelineRun's existing memoization
and never need to round-trip through the cache. The cache write
remains async and matters only for cross-run / cross-session
hits.

While in there: every stage method had the same eight lines of
plumbing (slot check, runStageCached, body, slot+cache writes,
cache-hit fallback). Extract a generic runStage[T any] helper
that handles all of it; each stage method becomes a thin shell
around its computational body returning (*T, error). Drops the
11 typed cache.setX wrappers (now redundant with the generic
c.set inside runStage) and 7 unused typed getters; keeps the 4
typed getters that callers outside the per-run flow still use
(getParse, getLoad, getPalette, getMerge).

Net: ~190 fewer lines, the get-after-set ordering is structurally
enforced in one place, and the doc on runStage explicitly calls
out the slot-then-cache-set ordering as load-bearing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Split feature lets users cut a model into two halves that print
side-by-side and assemble back together with peg-and-pocket alignment
features. The full design (9 implementation phases) lives in
docs/SPLIT.md.

This commit covers phase 1 only: the standalone geometry primitive in
a new internal/split/ package. It cuts a watertight mesh by an
axis-aligned plane, caps each half with a planar polygon-with-holes
triangulation, and returns two closed-watertight halves plus the
indices of the cap faces. No connectors, no layout, no pipeline
integration yet — those land in subsequent phases.

Algorithm: classify vertices by signed plane distance (with bbox-scaled
epsilon), split crossing triangles into 1+2 sub-triangles with
linearly-interpolated midpoints (deduplicated by edge key), walk
cut-edge directed graphs into closed loops, project to 2D via a plane
basis whose handedness matches each half's cap normal, classify outer
vs holes by signed area, bridge holes via Mapbox-earcut style visibility
search, and ear-clip the merged polygon. Cap vertices/faces conform to
LoadedModel's parallel-array invariants using the loader's no-texture
sentinel (FaceTextureIdx = len(Textures)).

Strict preconditions: input must be watertight; no model vertex may lie
exactly on the cut plane (the frontend nudges the offset on collision);
the cut must produce a single connected component per side. All three
preconditions surface as clear errors with actionable user-facing text;
no half is returned on failure.

Tests cover unit-cube cut, sphere-at-equator, watertight-edge invariant,
tangent-plane and missing-mesh error paths, UV preservation through
midpoints, vertex-color preservation through midpoints, cap-face
planarity, on-plane-vertex rejection, multi-component rejection, and
hollow-cube polygon-with-holes (verified by signed enclosed volume).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the Cut(model, plane, ConnectorSettings) signature with three
connector styles:

- NoConnectors: flat caps (phase 1 behavior preserved as zero-value).
- Pegs: solid cylindrical peg on half 0 (male side), matching
  cylindrical pocket sized at peg-radius + clearance on half 1.
- Dowels: matching cylindrical pockets on both halves; user prints
  separate dowel pins.

Connector placement uses the polylabel pole-of-inaccessibility
algorithm (Mapbox-style priority-queue subdivision over the cap
bbox). Auto-count keys on the inscribed-circle radius R divided by
the connector diameter D: 1 connector if R<4D, 3 if R>12D, else 2.

Phase 2 limitation: auto-count and explicit Count are temporarily
clamped to 1 inside placeConnectors. When two connectors land at
near-equal Y-coordinates, the second hole's bridge crosses the first
hole's bridge spike and earClip fails to find an ear. Removing this
cap requires either porting a more robust Mapbox-style earcut,
perturbing placements off shared Y-rows, or processing all hole
bridges in one combined pass. Tracked as "Phase 2 follow-ups" in
docs/SPLIT.md.

Geometry: each connector produces a 16-segment polygonal hole on
each cap (sized per-half: peg radius for the male side, peg-radius +
clearance for the female / dowel sides), plus cylinder/pocket walls
and a closing top/floor disk. The cap polygon-with-holes
triangulator naturally leaves the connector circles as holes; the
body geometry then closes them, keeping each half watertight.

Tests: polylabel sanity (square, L-shape with concave corner),
dowel-hole volume, peg/pocket asymmetric volume (half 0 grows by
peg volume, half 1 shrinks by pocket volume), no-connectors
passthrough, "connector too small" rejection, auto-count produces
exactly 1 connector. All existing phase 1 tests updated to pass
ConnectorSettings{} as the new third argument.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Reshape the count-clamp in placeConnectors so the auto heuristic
  branch isn't immediately overwritten. Phase 2 cap is now a single
  named constant at the end with a clear comment.
- poleOfInaccessibility documents and enforces dist <= 0 as "no
  interior point found" (returns 0 distance), and falls back to
  max(width, height) for sliver bboxes instead of bailing early.
- Move appendCapVertex from connectors.go to cut.go, rename to
  appendNewVertex (it's a general "fresh vertex" helper, not specific
  to caps; called for both connector circles and body-ring vertices).
  Add a comment noting the basis-convention coupling between
  triangulateCaps, addConnectorHoles, and addConnectorBodies.
- New tests: TestCut_PegWallNormalsRadialOutward (direct face-normal
  check, localises wall-winding regressions instead of inferring from
  volume), TestPolylabel_BoundaryRejection (just-too-small vs
  just-big-enough polygons exercise the R<2D rejection threshold
  exactly).
- TestCut_TiltedPlanePeg added then skipped: tilted-plane connectors
  hit the same earClip degeneracy as multi-connector. Documented in
  docs/SPLIT.md so we re-engage the test when the earClip fix lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tilted (non-axis-aligned) cuts are explicitly out of scope for v1 per
the design doc, so testing connectors on a (1, 1, 1) tilt was
speculative — it tested capability we don't ship. Removing the test
and the associated follow-up entry; the multi-connector earClip
limitation remains documented.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
internal/split/layout.go implements:

- Transform: 3×3 row-major rotation + 3-vec translation, with Apply
  (orig → bed) and ApplyInverse (bed → orig). The inverse path is
  what phase 6's voxelize uses to map bed-space cell centroids back
  into original-mesh coordinates for color sampling on the unmoved
  ColorModel/SampleModel/sticker meshes.

- Layout(result, plane, gapMM): rotates each half so its outward
  cap normal points to -Z (cap face flat on the build plate),
  translates so bbox.min.z = 0, and places the two halves side by
  side along +X with the requested gap. Both halves are centred on
  Y = 0. Mutates result.Halves in place; returns the per-half
  Transform.

- rotationToNegZ: Rodrigues' formula for the unit-vector → -Z
  rotation, special-cased for the antipodal cases (a = ±Z) where the
  cross product would be zero.

Tests: layout invariants on unit cube cut at z=0.5 (cap on z=0,
disjoint halves with gap, centred on y=0, watertight, inverse
round-trip), volume preservation across layout (rotations are
isometries), Transform.Apply matches the in-place mutation, and
rotationToNegZ correctness for all six axis-aligned inputs.

Phase 3 is unblocked by phase 2's known earClip limitation since
layout is purely about rigid-body transforms; it doesn't touch
triangulation. Phase 4 (Voxelize signature with optional splitInfo)
is the natural next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Required:
- Replace TestLayout_TransformOnPlanePoints' mis-scoped check with a
  per-vertex equality between xforms[h].Apply(orig) and the mutated
  vertex (TestLayout_TransformMatchesMutation). The original test
  only verified pBed.Z == 0 for plane points; it didn't actually
  assert the transform matches the in-place mutation.

API tightening:
- CutResult.Plane stores the cut plane that produced the result.
  Layout drops the redundant plane parameter; signature is now
  Layout(result, gapMM). Eliminates the foot-gun where a caller
  could pass a different plane.

Test additions:
- TestLayout_RoundTripCloud: every laid-out vertex round-trips
  through Apply+ApplyInverse to its original coords (~12 vertices
  for the cube, exercising the full Apply/ApplyInverse pair instead
  of two hand-picked points).
- TestLayout_NonZAxisCut: cube cut along X and Y axes exercise the
  Rodrigues body of rotationToNegZ at the Layout level (axis-aligned
  but non-Z input), not just the antipodal special cases.
- TestLayout_PegOnBed: Layout combined with a peg connector. Found
  a real semantic surprise: cap-down layout puts the peg tip on the
  bed and elevates the cap by peg depth, since the peg extends
  past the cap in +cap_normal direction. Test now asserts the
  correct (peg-tip-on-bed, cap-elevated) geometry and round-trips
  the peg tip back to (~25, ~25, 30) in original coords.

Doc additions:
- Layout's comment now spells out the cap-down vs peg-up tradeoff.
- New "Phase 3 follow-ups" section in docs/SPLIT.md tracks two
  future paths for the male-peg orientation: cap-up layout for the
  male side specifically, or a clear frontend-side warning.
- 180°-around-X choice in rotationToNegZ explicitly noted as
  arbitrary-but-consistent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the per-half geometry + inverse-transform path to
squarevoxel.VoxelizeTwoGrids while keeping the unsplit path
bit-identical. The pipeline currently passes nil; phase 6 will plumb
SplitInfo through from StageSplit's output.

Changes:
- voxel.ActiveCell gains HalfIdx uint8 (0 in the unsplit path; 0 or
  1 in the split path; downstream Merge/Export will use this to
  partition cells per half for the two-object 3MF emission).
- squarevoxel.SplitInfo carries [2]*loader.LoadedModel (the
  laid-out half geometry meshes) plus [2]split.Transform (forward
  transforms; voxelize calls ApplyInverse to map cell centroids
  back into original-mesh coords for color sampling on the unmoved
  colorModel/sampleModel/stickerModel).
- VoxelizeTwoGrids: when splitInfo == nil, behavior is unchanged
  (single-mesh path with identity inverse transform). When
  splitInfo != nil, iterates two geometry meshes, builds a
  shared-bbox cell grid over their union, and calls colorCells
  per-half with that half's halfIdx and inverse transform. Each
  ActiveCell records the halfIdx of the mesh that produced it.
- Pipeline call site updated to pass nil for the SplitInfo
  parameter (no behavior change yet).

Tests cover the unsplit no-op path, the per-half tagging on a
two-cube spatial split, the inverse-transform color sampling
correctness (translated geometry mesh, colors recovered from
original coords via inverse transform), and the explicit-colorModel
requirement when splitInfo is non-nil.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Required:
- Rename SplitInfo.InverseTransform → Xform. The previous name was
  load-bearing-misleading: the field stores the FORWARD transform
  (orig → bed), and voxelize calls ApplyInverse internally. Reviewer
  flagged this; matches splitOutput.Xform in docs/SPLIT.md and
  eliminates the trap I hit during initial implementation.
- New TestVoxelize_SplitInfoNonIdentityRotation covers the
  rotation-plus-translation inverse-transform path. Phase 6 will run
  real cap-to-bed rotations, and previously every test used pure
  translation or identity.

Test improvements:
- TestVoxelize_SplitInfoInverseTransformDistinctHalves replaces the
  previous test that reused the same geom for both halves (which
  exercised a degenerate same-place case). Now half 0 sits at +100
  in bed coords and half 1 at +200, each with its own Xform; the
  test asserts color recovery on each half independently.
- TestVoxelize_SplitInfoEmptyHalfRejected covers the empty/degenerate
  half error path (was raised but untested).
- Dropped the trivial TestVoxelize_ActiveCellHalfIdxFieldExists; a
  missing field is a compile error, not a runtime failure.

Code quality:
- Document that `model` is ignored when splitInfo != nil and that
  colorModel is required (no fallback) in the split path.
- Tighten the bbox-union loop using min/max builtins instead of the
  `first := true` flag pattern.
- Suppress the "(half %d)" log tag when there's only one entry
  (unsplit path) so the legacy log format is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
squarevoxel.DecimateHalves wraps DecimateMesh to operate on Split's
two-half output. Total target cell count is split between halves
proportional to face count; each half is decimated independently via
the existing voxel.Decimate path (no per-face attribute carry, no
constraint set, no cross-half collapse policy — each half is
closed-watertight on its own).

Per docs/SPLIT.md phase 5: cap planarity is preserved by QEM's
planar-affinity bias rather than an explicit pinned-vertex
extension. TestDecimate_HalfPreservesCapPlanarity validates this on
a real Split-produced cube: after decimation to 70% face count, no
vertex drifts off the cap plane within the (1e-4, 1e-2) drift band.
The "70% not 30%" choice is deliberate — over-aggressive decimation
legitimately collapses the cap entirely (not a regression), and the
test asserts the planarity invariant in the regime where the cap
survives.

Pipeline integration is deferred to phase 6 (StageSplit + the
decimateOutput shape change to carry [2]*LoadedModel). Phase 5 is
just the helper plus the validation test.

Tests:
- TestDecimate_HalfPreservesCapPlanarity validates QEM planar-
  affinity preserves cap-plane vertices.
- TestDecimateHalves_ProportionalTargets confirms the wrapper
  reduces face counts on both halves.
- TestDecimateHalves_NoSimplifyPassthrough confirms noSimplify=true
  returns each half unchanged (matching DecimateMesh's contract).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous test fixture (12-tri cube → 6 collapses) didn't
exercise QEM's planar-affinity bias enough to be load-bearing —
caught by the reviewer. Replaced with a subdivision-2 icosphere
(~320 tris, ~80 collapses per half) so cap-perimeter edges
genuinely compete in the heap against body edges.

The real fixture immediately surfaced new information: QEM's
planar-affinity bias preserves the cap plane LOOSELY — surviving
cap-perimeter vertices drift by ~3% of cellSize (1.5 μm at
cellSize=50 μm). This is well below printer resolution and
acceptable for v1, but it is non-zero. The test threshold is now
0.1 × cellSize (a 30× headroom over observed drift; would catch a
true regression that disabled the planar bias).

Documented the measurement in docs/SPLIT.md so future maintainers
know the planar-affinity is "good enough for v1" rather than
"exact". The deferred fix path (optional pinnedVertices parameter
to voxel.Decimate) is referenced.

Also dropped the dead nil-half guard in DecimateHalves: split.Cut's
contract guarantees both halves are non-nil, and the guard was
defensive code with no real caller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the split feature into the live pipeline behind
Options.Split.Enabled (default false). When disabled, the pipeline
runs bit-identically to the pre-Split path: StageSplit emits a
no-op output, Decimate calls DecimateMesh as before, Voxelize
passes nil splitInfo to VoxelizeTwoGrids, and Clip uses the
single-mesh path. Toggling other Split fields while Enabled=false
does not invalidate downstream caches (verified by
TestSplitDisabled_NoCacheKeyChange).

When enabled:
- pipelineRun.Split() calls split.Cut + split.Layout, returning a
  splitOutput with Halves[2] (in bed coords) and Xform[2] (forward
  transforms).
- pipelineRun.Decimate() routes through DecimateHalves with
  per-half proportional targets; decimateOutput.Halves is populated
  and DecimModel is nil.
- pipelineRun.Voxelize() builds a SplitInfo from the splitOutput
  and passes it to VoxelizeTwoGrids; cells get tagged with HalfIdx
  and color sampling routes through ApplyInverse to the unmoved
  ColorModel/SampleModel/sticker meshes.
- pipelineRun.Clip() surfaces a clear "phase 7 not yet shipped"
  error rather than crashing on a nil DecimModel. Phase 7 will
  partition dither patches by halfIdx and call
  ClipMeshByPatchesTwoGrid per half.

Caching: stageKey(StageSplit) hashes only the Enabled bit when
disabled, so the disabled-passthrough path uses the same downstream
keys as the pre-Split path. When enabled, all Split fields hash in
and downstream stages cascade.

Side fix: loader.LoadedModel.GobEncode now handles nil receivers,
so a *LoadedModel inside an array (e.g. an uninitialised Halves
slot in the disabled-passthrough path) round-trips through the
disk cache without panicking.

Tests cover cache-key cascade in both directions (off→on toggle,
each individual field change), description strings, and the
disabled-toggle invariant.

Frontend wiring (Options.Split UI, alpha-wrap coupling) is
deferred to phase 9 alongside the rest of the frontend changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewer-identified gaps:
- Add AlphaWrap precondition assertion in pipelineRun.Split: when
  Split.Enabled=true and AlphaWrap=false, the user gets a clear
  "Split: requires AlphaWrap=true" error instead of a downstream
  "non-manifold cut polygon" message from split.Cut. Previously
  this was only enforced by docstring (frontend coupling); now the
  backend asserts it too.
- Drop dead `totalFaces` calculation and `_ =` swallow in Decimate;
  the proportional-split logic lives entirely inside DecimateHalves.
- splitOutput doc comment now warns that Halves[i] is non-nil after
  a disk-cache round-trip even when Enabled=false (a consequence of
  loader.LoadedModel.GobEncode encoding nil receivers as empty
  models). Consumers MUST gate on Enabled, never on Halves[i]==nil.
- Unify the disabled-passthrough Split() to a single BeginStage
  call so the UI shows "Splitting (off)" ticking by even on the
  no-op path.
- Clip's phase-7 error message now points at docs/SPLIT.md.
- Stage description renders ConnectorCount=0 as "×auto" instead of
  the misleading "×0".

Test additions:
- TestStageSplitDescription updated to cover the auto-count case.

Note: an end-to-end "Split disabled produces bit-identical output"
test would be the highest-value coverage addition but requires a
real model fixture and pipeline plumbing setup that's substantial.
Deferred — phase 7 will need the same infrastructure for its own
end-to-end testing, so it's better to build it once there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pipeline now runs end-to-end with Options.Split.Enabled=true.
Clip and Merge operate per-half; the resulting mergeOutput carries
ShellHalfIdx parallel to ShellFaces, which buildOutputModel lifts
into FaceMeshIdx + NumMeshes=2 on the output LoadedModel.

Implementation:
- clipOutput/mergeOutput grow ShellHalfIdx []byte. Nil in the
  unsplit path (no behavior change).
- pipelineRun.Clip routes through clipSplit when split is enabled.
  clipSplit filters PatchMap by halfIdx (cells in different halves
  are spatially separated by the gap, so flood-fill never joins
  patches across halves), calls ClipMeshByPatchesTwoGrid per half
  with that half's PatchMap and decimated mesh, and concatenates
  results with per-face HalfIdx tags.
- pipelineRun.Merge routes through mergeSplitFaces when split. Faces
  in clipSplit's output are grouped by half (h=0 first, h=1 second);
  mergeSplitFaces splits at the boundary, runs MergeCoplanarTriangles
  per half slice, then concatenates results and rebuilds the
  per-face HalfIdx parallel array.
- buildOutputModel sets FaceMeshIdx (from ShellHalfIdx) and
  NumMeshes=2 when Split is enabled. Consumers that don't read
  FaceMeshIdx (preview rendering, current single-object 3MF
  export) treat the result as one mesh.

Phase 7 still defers (per docs/SPLIT.md "Phase 7 follow-up"):
3MF two-object emission. The ExportFile path produces a single
<object> entry containing both halves; slicers handle this fine
but lose the ability to apply per-object settings to each half.
The per-mesh-idx info is available on LoadedModel; export3mf
(export3mf.go:215, bambu.go:249) needs to iterate per FaceMeshIdx
group to emit per-object output. Tracked as the v1 finishing
piece.

Tests:
- TestMergeSplitFaces_PerHalfMergeAndConcat verifies the per-half
  merge+concat preserves HalfIdx tagging in correct grouping order.
- TestClipSplit_FiltersPatchMapByHalf validates the load-bearing
  PatchMap filtering step (cells routed to per-half maps by
  HalfIdx).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Critical:
- floodFillTwoGrids now partitions by (Grid, HalfIdx) instead of
  just Grid. The reviewer found a real correctness bug: flood fill
  operates on CellKey index-arithmetic adjacency, not spatial
  adjacency, so two halves whose CellKey columns happen to be
  index-adjacent (which can happen when GapMM < cellSize) would
  produce patches that bridge across the bed-layout gap. With this
  partition, patches are guaranteed to live in exactly one
  (Grid, HalfIdx) pair regardless of GapMM. clipSplit's PatchMap
  filtering now operates on a sound invariant.

- clipSplit short-circuits empty halves (nil mesh, zero faces, or
  empty patch map). Without the guard, ClipMeshByPatchesTwoGrid
  would still iterate the half's mesh and emit SeamZ-only clipped
  geometry tagged with whatever default the implementation picked —
  garbage output that nobody validated.

Documentation:
- buildOutputModel's doc comment now states explicitly that NO
  CURRENT CONSUMER reads FaceMeshIdx/NumMeshes; the wiring is
  preparatory for the Phase 7 follow-up in export3mf. Previous
  wording implied a current consumer might already use them.

Tests:
- TestFloodFillTwoGrids_PartitionsByHalfIdx — column-adjacent cells
  in different halves with the same color assignment must NOT
  bridge into one patch (would silently corrupt the Clip stage).
- TestFloodFillTwoGrids_PartitionsByGridAndHalf — verify 4
  (Grid, HalfIdx) combos with the same color produce 4 separate
  patches.

Deferred (acknowledged in design doc): end-to-end Split=true
integration test running through the full pipeline. Same
infrastructure investment as the deferred phase-6 disabled-
passthrough test; will be built once for the phase-7 follow-up
3MF emission work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The non-bambu export path now emits one top-level <object> per
Split half with one <item> per object in <build>. Slicers see two
independent build items and can apply per-object settings
(different filaments, orientations, etc.) — the user-facing reason
the Split feature exists.

Implementation:
- splitModelByMesh (new) partitions a multi-mesh LoadedModel
  (FaceMeshIdx + NumMeshes>1) into per-mesh parts with compacted
  vertex tables and remapped face indices. Vertices referenced by
  faces from multiple meshes are duplicated so each part is
  self-contained. Returns nil for single-mesh models.
- Export now builds a uniform []*part list (1 in single-mesh, N
  in multi-mesh) and loops over it for the main-model <object>
  entries, inner .model file writes, and model_settings per-object
  metadata.
- buildModelSettingsParts replaces buildModelSettings; emits one
  <object> block per part. Multi-part exports name parts
  "ditherforge_output_partN".
- The single-mesh path produces output bit-identical to pre-feature
  for non-bambu exports (one part, same UUID structure, same XML
  shape).

Bambu-Studio path (bambu.go) still emits a single <object>
containing both halves; documented in docs/SPLIT.md as a follow-up.
The Bambu format has additional plate metadata, multiple
thumbnails, and project_settings structure that needs per-object
replication beyond the simple <object>+<item> pattern.

Tests:
- TestSplitModelByMesh_SingleMeshReturnsNil — unsplit path returns
  nil so caller takes the unchanged code path.
- TestSplitModelByMesh_PartitionsAndCompactsVertices — two-mesh
  model produces two parts with compacted vertex tables and
  remapped face indices.
- TestSplitModelByMesh_SharedVerticesAreDuplicated — vertices used
  by faces from multiple meshes get a copy in each part.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The frontend Settings panel will need cut-plane geometry to render
the translucent overlay quad as the user drags the offset slider.
Phase 8 exposes that via two pieces:

- pipeline.ComputeSplitPreview(cache, opts, splitSettings) → SplitPreviewResult.
  Reads the StageLoad output from the cache (the most recently
  loaded model), computes the plane origin and normal from the
  axis-aligned settings, builds an orthonormal (U, V) basis with
  U × V = Normal, projects the model's vertices onto (U, V), and
  returns the plane-local bbox half-extents. The frontend renders a
  quad with corners at Origin ± HalfExtentU·U ± HalfExtentV·V.

- (*App).SplitPreview is the Wails-bound thin wrapper. It pulls
  lastOpts under the lock and delegates to the pipeline-level
  function. No pipeline run is triggered; the call is read-only
  against the cache and meant to be cheap enough for slider-drag
  rates.

The (U, V) basis is fixed per axis (axis=0 → U=+Y, V=+Z; axis=1 →
U=+Z, V=+X; axis=2 → U=+X, V=+Y) so the frontend gets a stable
orientation as the user toggles axes. All three choices are
right-handed (U × V = Normal).

Tests:
- TestComputeSplitPreview_NoCachedLoad — a clear error when the
  pipeline hasn't run yet, no crash.
- TestSplitPreview_AxisOrigins — the returned origin satisfies
  Normal·origin == Offset (the cut plane equation) for all three
  axes and a range of offsets.
- TestSplitPreview_BasisOrthonormality — for each axis, U × V ==
  Normal verified to f32 tolerance (when the cache injection
  helper succeeds).
- TestProjectAxis_DotProduct — sanity check on the helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Critical:
- Switch App.lastOpts from a mutex-protected struct to
  atomic.Pointer[pipeline.Options]. The pipeline worker holds
  App.mu for the entire duration of a run (often seconds for
  alpha-wrapped meshes); SplitPreview was blocking on that mutex,
  which meant the slider would freeze whenever a run was in
  flight. The atomic pointer lets SplitPreview read lastOpts
  without contending with the worker, restoring drag-rate
  responsiveness.

  ProcessPipeline updated to Store(&optsCopy); Export3MF reads
  via Load() with a nil-check.

Tests:
- Replace the dead orthonormality test (which fell through to
  t.Skip because cache.set is a no-op without disk) with proper
  end-to-end tests against a new pure helper:
  computeSplitPreviewFromVertices(verts, settings). The pure
  helper is the cache-independent core of ComputeSplitPreview.
- New tests cover: empty vertices, plane equation invariant
  across all 3 axes × 5 offsets, basis orthonormality
  (U·N=V·N=0 and U×V=N), half-extents on a unit cube,
  asymmetric-bbox origin centering, invalid-axis fallback to Z,
  concurrent-call goroutine safety (64 goroutines × 100 calls).

Documentation:
- SplitPreviewResult.Origin doc now says "centre of the model's
  silhouette projected onto the cut plane" instead of just "a
  point on the cut plane" — the centering is load-bearing for
  the rendered quad position.
- Top-level comment notes that fields are in original-mesh world
  coords (same frame as the input mesh viewer), not bed coords.
- Comment on the projection loop explains why we project all
  vertices (silhouette, not cross-section) for stable overlay
  rendering as the user drags the offset slider.
- Goroutine-safety contract documented on ComputeSplitPreview.

Per-call cost: still iterates all vertices per call. The
reviewer flagged this as a phase-9 follow-up — the bbox-on-plane
is purely a function of (model, axis), not Offset, so it could
be cached on loadOutput. Deferred until phase 9 actually
demonstrates a perf issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Split feature is now user-accessible. A new collapsible Split
section in the Settings panel exposes the design doc's controls:
- Master toggle (off by default)
- Cut axis (X/Y/Z radio)
- Offset (number input + slider, range from model bbox)
- Connector style (None / Pegs / Dowel holes)
- Connector count (Auto / 1 / 2 / 3)
- Diameter / Depth / Clearance (mm)
- Bed gap (mm)

Forward coupling: toggling Split on triggers
onAlphaWrapForced — App.svelte sets alphaWrap=true so the
pipeline's AlphaWrap precondition (split.Cut needs a watertight
input) is satisfied automatically.

Reverse coupling: a $effect cascades alphaWrap=false to
splitEnabled=false, so the user can't end up in the broken state
where Split runs without alpha-wrap.

Wails plumbing:
- App.d.ts and App.js gain the SplitPreview binding for the
  cut-plane overlay (currently exposed but the visualizer code
  is a phase-9 follow-up; the backend math + tests landed in
  phase 8).
- models.ts gains SplitSettings and SplitPreviewResult classes
  plus an optional Split field on Options.

Settings persistence:
- serializeSettings() and applySettings() carry the 9 split fields
  so save/load and recent-file loading round-trip the split state.
- The pipeline-rerun reactivity array picks up changes to any
  split field so an enabled-Split run reruns when the user adjusts
  the cut.

Out of scope for this phase (acknowledged):
- The cut-plane overlay quad in the 3D viewer. The SplitPreview
  backend method is wired in App.d.ts/App.js but no Svelte
  component currently calls it. The viewer's Three.js scene needs
  a quad overlay added; deferred since the controls are usable
  without it (the user sees results post-run).

svelte-check: 0 errors. go test ./...: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The reviewer flagged three related issues that all stem from the
frontend not knowing the model's per-axis bbox: the offset slider's
min/max defaulted to 0..100 (wrong for any model outside that
range), there was no sensible default offset (so first-time users
got broken cuts when the model's origin wasn't at its centre), and
the cut-plane overlay deferral compounds the problem because users
have no in-viewer feedback to recover from a bad offset.

Backend changes:
- pipeline.Callbacks.OnInputMesh signature gains bboxMin/bboxMax
  [3]float32 args. The pipeline computes the bbox from
  lo.ColorModel.Vertices (post-scale, post-normalizeZ) and passes
  it through alongside the existing nativeExtentMM.
- app.go's meshEvent struct gains BBoxMin/BBoxMax fields;
  OnInputMesh forwards them into the input-mesh Wails event.

Frontend changes:
- App.svelte adds modelBBoxMin/Max state, populated from the
  input-mesh event.
- splitOffsetMin/Max are now $derived from the bbox along
  splitAxis (was: hardcoded 0..100).
- A $effect recentres splitOffset to the bbox midpoint when the
  axis flips, so toggling X→Y→Z keeps the cut plane somewhere
  reasonable rather than at offset=0 (which is often outside the
  model along the new axis).
- input-mesh handler also recentres splitOffset if the new model
  invalidates the previous offset.

Minor UX cleanup:
- SplitControls now hides Connector Count when style=none,
  matching how Diameter/Depth/Clearance are hidden. Previously
  Count was disabled but visible — inconsistent.

What this does NOT fix (deferred to a separate commit):
- The cut-plane overlay quad in the 3D viewer. SplitPreview
  backend math + tests landed in phase 8; the overlay's Three.js
  geometry in ModelViewer is still pending. Without it the user
  has to click Process to see where the cut lands.

Both go test ./... and svelte-check --threshold error are clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Help text now mentions removing hard-to-reach supports instead of painting.
- Cut axis select shows the cut plane (YZ/XZ/XY); underlying numeric axis
  values stay 0/1/2 so saved settings and the backend are unaffected.
- Input viewer renders a translucent quad showing where the cut will
  happen, derived client-side from the input-mesh bbox so it tracks the
  Split offset slider with no RPC. Bbox is nullable to suppress the quad
  before any model has loaded; (origin, halfExtents) are scaled by
  previewScale so the quad lines up with the rendered mesh, while (u, v,
  normal) directions stay scale-invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cutPlaneRev++ desugars to `cutPlaneRev = cutPlaneRev + 1`, and the RHS
read inside an $effect makes the counter a tracked dep of that very
effect. The trailing write retriggers it, blowing past Svelte's update
depth cap and throwing during mount — which aborted further script
setup, leaving the File menu unbound and ListPrinters() never resolving.

Wrap the read in untrack() via a bumpCutPlaneRev() helper so the
effect no longer tracks the counter it writes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Go Settings struct never grew Split fields when the panel was added,
so SaveSettings round-tripped Split state through Wails' JSON decoder
into the void. Cut, axis, offset, connector style/count/diam/depth,
clearance, and bed gap all reset to in-memory defaults on reload.

Add nine plain-value fields to match the rest of the struct. Files saved
before this change lack the keys; the frontend's applySettings already
guards each split field with `!== undefined`, so older files preserve
in-memory state on load instead of being clobbered by Go's zero values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pipeline failures previously surfaced only in the side-panel status bar.
The output viewer kept showing the green-checkmarked stage list,
hiding which stage actually failed. Add the error as a red `! …` line
appended below the existing stages so the user sees it where they're
already looking, and the green checkmarks above it identify the failing
stage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
rtwfroody and others added 24 commits April 29, 2026 20:39
Dense alpha-wrapped meshes have a high enough vertex density along the
cut axis that ~75% of random offsets land within the on-plane epsilon
of at least one vertex, hard-rejecting the cut. cut_fish.json reproduced
this at every offset between 15 mm and 16 mm.

Two changes in internal/split/split.go:

- Shrink eps from 1e-6·bbDiag to 4e-7·bbDiag (~3.4 ULPs of float32 at
  bbox magnitude). Anything tighter risks t = -d0/(d1-d0) producing a
  midpoint that snaps onto an existing vertex — exactly the
  degeneracy the check is meant to prevent.
- Auto-perturb: when on-plane vertices are still detected, shift
  plane.D by 4·eps and double on each retry (8 attempts max). Total
  shift bounded at ~60 μm on a 150 mm model. Print a one-line
  notice when a perturb actually fired.

Renamed TestCut_OnPlaneVertexFails → TestCut_OnPlaneVertexAutoPerturbs
and flipped its assertion: a unit cube cut at z=0 (which hits all
four bottom vertices) now succeeds via the rescue path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The user reported the same alpha-wrap running twice in one session
without an obvious explanation, and asked for better console output to
diagnose stalls and unexpected cache misses.

- New internal/plog package: a tiny timestamped logger
  (`[HH:MM:SS.sss] msg`) that all pipeline-stage console output flows
  through. Replaces every fmt.Print* in pipeline/run.go,
  pipeline/stepcache.go, pipeline/pipeline.go, split/split.go,
  squarevoxel/squarevoxel.go, voxel/decimate.go, and app.go.
- The Parsing/Alpha-wrap continuation patterns ("Parsing X..." then
  " N verts in T") are split into two timestamped lines so start and
  completion are both visible.
- Pipeline gen N starting: <input> (reloadSeq=N) at the top of every
  run, so file opens and reloads are obvious.
- LoadSettingsFile logs "Opening settings file: <path>".
- Cache hits/misses now print the short stage cache key
  (first 12 hex of the SHA), letting the user compare keys across runs
  to see what changed when a stage unexpectedly recomputed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Repeated "disk cache evict (size): Stickers: cut_fish.glb (0)" lines
in the dev terminal didn't say whether the same blob was being evicted
many times or many distinct blobs shared the same description. Add the
12-hex-prefix cache key to evict and error logs so duplicates are
visible at a glance, and route both through plog so they get
timestamped like the rest of the pipeline output.

OnEvict and the disk-cache reportEvict signature gain a key parameter;
cacheEntry tracks the key derived from the data/meta basename so the
sweep can pass it through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old recency factor used a 7-day half-life, which gave a fresh
entry (factor 1.0) and an hour-old one (factor 0.996) effectively
identical weight. A just-completed 25 s, 68 MB Clip output could be
evicted seconds after writing because score collapsed to cost / sqrt(size)
with a near-flat recency curve.

Sharpen the curve: HalfLife = 1h so age strongly differentiates fresh
from "earlier this session". A new RecencyFloor = 0.05 prevents
day-old-and-older entries from collapsing to score 0, so cost still
ranks them against each other when the cache is over budget.

This is a single-formula change — FitToBudget keeps its plain
"sort ascending by score, evict until budget fits" shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Exponential decay halves the weight per fixed time slice regardless of
how old the entry already is, which is the wrong shape: an extra hour
of staleness on a fresh entry should matter much more than an extra
hour on a day-old entry. Power-law

  factor = 1 / (1 + age/HalfLife)

makes the marginal decay rate shrink with absolute age, matching the
intuition. As a side effect the tail never reaches zero, so the
ad-hoc RecencyFloor clamp goes away.

At age=HalfLife the factor is still 0.5 (constant name kept). 1d → 0.04,
1w → 0.006, 30d → 0.0014 — small but non-zero, so cost still ranks
ancient entries against each other.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the auto-perturb retry loop with a single-pass snap: when
classification finds vertices within ±eps of the cut plane, shallow-
clone the model and nudge just those vertex coordinates by 2·eps along
plane.Normal. The cut plane stays at the user's chosen offset (no
drift), only the offending vertices move, and the displacement is
sub-micron so it's invisible in the output.

This is more robust than perturbation on dense alpha-wrapped meshes:
the apollo case that hit "8 perturbation attempts (cumulative shift
0.064 mm)" had vertex clusters dense enough that doubling the plane
offset couldn't escape them. Snapping individual vertices off the
plane is local — it doesn't matter how dense the cluster is.

Tests:
- TestCut_OnPlaneVertexAutoPerturbs → TestCut_OnPlaneVertexSnapsOff,
  using a stacked-cubes fixture (interior vertices on the plane,
  geometry on both sides) to exercise the actual fan-junction
  scenario rather than a boundary case.
- TestCut_TangentPlaneFails → TestCut_TangentPlaneSnapsThrough.
  Tangent cuts now succeed via snap, producing a near-degenerate
  sliver as one half. Behavior change documented in the test
  comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Centralize stage start/end logging in runStageCached so every stage
emits a "starting" line on cache miss and a "done in T" line when
body returns. Cache hits keep their existing one-liner. Failures
get a "failed after T — err" line so error paths are visible too.

Drop the redundant "Splitting...", "Decimating...", "Voxelizing..."
prints that duplicated the generic start line. Stage-internal stat
prints (Extent, Alpha-wrap dims/counts, Voxelized cell counts, etc.)
stay — they carry information the generic line doesn't.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the "largest outer + everything else is a hole" logic in
triangulateCaps with a proper containment-depth classification:

  depth = number of other loops that contain a loop's first vertex
  even depth → outer of a region
  odd depth  → hole, attached to the smallest enclosing even-depth ancestor

Each (outer, holes-of-this-outer) group triangulates independently and
contributes to the cap area, so a cut that intersects the model in
several disjoint regions (apollo capsule with thrusters; barbell
shapes; alpha-wrap shells with multiple bumps) caps cleanly instead
of erroring out.

The "Phase 1 doesn't support multi-component cuts" rejection is gone.
TestCut_MultiComponentRejected → TestCut_MultiComponentSupported,
asserting the cut succeeds and both halves cap with multiple regions.

Connector placement still picks the largest region as the outer for
inscribed-circle search; secondary regions get plain caps. That's
defensible v1 behavior — connectors on the biggest piece, no
connectors on the small ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ReloadSeq is a frontend-only counter the Svelte form watcher bumps to
re-trigger its $effect when the same path is re-selected. It has no
backend meaning: file content changes are caught by inputContentHash
(mtime+size memoization, rehashes when either changes).

Including it in the parse cache key produced spurious cache misses on
real workflows: opening a .glb directly via File→Open bumps reloadSeq,
but loading a settings .json that references the same .glb does not.
Same .glb, same settings, but different reloadSeq → different cache
key → re-parse, re-alpha-wrap, redo everything. On the apollo model
that's 5+ minutes of avoidable work each time.

Update the existing TestParseStageKeyDependsOnInputOnly to assert the
new invariant: ReloadSeq must NOT affect the cache key. The other
properties (Input/ObjectIndex matter; Scale/AlphaWrap don't) stay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two defensive cleanups so dense alpha-wrapped meshes (e.g. apollo)
can triangulate even when their cap polygons have float-roundoff
artifacts the strict ear test rejects.

1. dedupConsecutive: collapse runs of vertices within 1e-9·bbox of
   each other before earclip. Cap polygons recovered from triangle-
   midpoint cuts can have adjacent points that round to the same 2D
   coordinate, giving the ear test cross == 0 and stalling.

2. Tolerance ladder in earClip: when the strict pass (cross > 0)
   stalls on near-collinear sequences, retry with progressively
   relaxed lower bounds (-1e-12, -1e-9, -1e-6 × bbox²). Each relaxed
   ear still goes through pointInTriangle's interior check, so the
   triangulation never self-intersects — relaxed ears are just
   thinner than ideal.

Error message now reports the relaxation level reached and remaining
vertex count, so genuinely-degenerate polygons surface a clearer
diagnostic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When triangulate() fails on a single (outer, holes) region — usually
hole-bridge interference producing a self-intersecting merged
polygon, which no ear-test threshold can recover from — log a warning
and skip that region rather than tear down the entire cut. Other
regions still cap and the user gets a half that's non-watertight at
the skipped spot but otherwise valid: enough to judge whether the cut
location is worth pursuing.

Aborting only happens if every region in a half fails (= no cap at
all = an unusable half).

This is a stop-gap until we port a fully robust triangulator (Mapbox
earcut). On dense alpha-wrapped meshes like apollo, a single
problematic region was previously enough to take down the whole
pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(1) OnEvict callback now receives the entry's mtime; the disk-cache
    log line ends with "X.Xs/Xm/Xh/Xd old" so the user can see at a
    glance whether a fresh result is being thrown away or an
    ancient one. Previously the log gave size and gen-time but no
    way to tell recency of the eviction victim.

(2) Replace the per-region "skip on triangulation failure" with a
    fan-from-vertex-0 fallback that always closes the cap. The fan
    is geometrically wrong for non-convex outers (some triangles
    wind CW and extend past the cut polygon), but every outer-loop
    edge appears in exactly one cap triangle so the half stays
    watertight at the boundary. Better than visible holes through
    the cut surface looking into the model interior.

Holes inside a fanned region are dropped because reproducing them
would require the same hole-bridging that triggered the failure;
the cap will be slightly "fatter" than the cut shape there. A
log line counts how many regions used the fallback so the user
knows when they should consider moving the cut.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fan fallback used to drop holes when the strict triangulation
failed, capping cavities solid that should stay open. Now also
fan-triangulate each hole (in its natural CW winding) so every
cut-polygon edge — outer or hole — appears in exactly one cap
triangle. The half stays manifold at every boundary, and hole
fan-triangles have CW winding (inverted relative to the cap normal)
so slicers can interpret them as voids rather than additional solid
material.

Geometry inside the fanned region is still approximate (overlapping
fan triangles for non-convex outers, inverted hole patches), but the
cap correctly represents which cross-section regions are solid and
which are hollow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old per-triangle classification + cap triangulation pipeline
(cut.go, loops.go, cap.go, earclip.go, polylabel.go, connectors.go,
~1900 LOC) was a stack of approximations that kept failing on dense
alpha-wrapped meshes. Each fix — on-plane vertex snapping, plane
auto-perturbation, multi-component cap support, near-collinear
ear-clip tolerance, fan-fallback closures, hole-preserving inverted
patches — bought another step before the next pathological input
broke things.

Replace the entire pipeline with CGAL's Polygon_mesh_processing::clip,
called once per half (concurrently) via new internal/cgalclip with
its own CGO bindings (mirroring the existing alphawrap/cgalwrap
pattern). The CGAL clip uses exact predicates with inexact
constructions (EPIC kernel) so cuts succeed on inputs the old code
gave up on, and the cap surface is added by clip itself — no more
hand-rolled triangulator.

Behavior changes:
- Tangent cuts (plane lying exactly on a boundary face) now produce
  an empty-half error instead of the old snap-and-sliver hack.
- UVs and vertex colors are not carried through the cut — CGAL
  operates on geometry only. Pipeline color sampling already
  happens before the cut, so this is fine for the current pipeline.
- Connector pegs/pockets are stubbed: the old connector code reached
  into cut-builder internals that no longer exist. Connectors will
  come back as boolean ops on the clipped halves in a follow-up.
  TestLayout_PegOnBed is t.Skip'd until then.
- CutResult.CapFaces removed; cap faces are no longer tracked
  separately. Callers can identify them via face-normal matching.

Tests gated on a TestMain that skips when the cgal build tag isn't
set, and via skipIfNoCGAL in squarevoxel decimate tests, so dev
builds on machines without CGAL installed stay green. Release CI
already builds with -tags cgal on every platform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address the two blocking items plus several recommended cleanups from
the post-rip-out review:

- split.Cut: when ConnectorSettings.Style != NoConnectors, log a
  warning via plog so users with "Pegs" or "Dowels" saved in their
  settings know the request is a temporary no-op. Previously
  swallowed silently.
- docs/SPLIT.md: front-matter banner explains that the
  hand-rolled design described below has been replaced by
  internal/cgalclip, and that connectors are stubbed pending
  reimplementation as boolean ops on the clipped halves.
- split.Cut: replace the buffered-channel goroutine pattern with
  a sync.WaitGroup. Cleaner and matches Go idioms.
- internal/cgalclip docs: package comment now documents the EPIC
  kernel choice (cap vertices are float64 with rounding error, fine
  for printing but don't assume bit-exact equality across halves)
  and the throw_on_self_intersection failure mode (alpha-wrap with
  tighter offset).
- internal/cgalclip/cgalclip: drop the duplicate Go-side empty-mesh
  check; the C side already returns that error string.
- split tests: replace the TestMain-based skip with a per-test
  skipIfNoCGAL helper so test runs report which tests skipped
  rather than silently exiting 0. The whole test list now shows up
  in `go test -v` either way.
- layout_test.go: clean up the dead PegOnBed loop that referenced
  the removed CapFaces field.

No behavior change for production builds (release CI sets `-tags cgal`
and CGAL is always available there).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The tag was historical baggage from when alphawrap had a Python
sidecar fallback (pre-0.6.0). Every shipping binary is built with
-tags ...,cgal anyway, and the smoke-test step in CI was running
the test suite without -tags cgal — which silently skipped every
split/cgalclip integration test. Net result: we shipped CGAL-backed
code but only tested the no-CGAL stub.

Rip out the tag and its dependencies:

- Delete internal/alphawrap/cgo.go, fallback.go.
- Delete internal/cgalclip/cgo.go, fallback.go.
- Inline doWrap / doClip into the public alphawrap.go / cgalclip.go.
- Drop //go:build cgal directives from the cgo binding files.
- Remove HasCGAL / hasCGAL constants and the skipIfNoCGAL helpers
  in split and squarevoxel tests; the tests now run unconditionally.

CGAL is required at build time; the release CI already installs it
on every platform (libcgal-dev / cgal homebrew formula / mingw
package). Local dev needs the same — on Manjaro `sudo pacman -S
cgal gmp mpfr`.

Also add per-half + per-export-part size logging so a future
"two-objects-look-the-same" diagnostic has data to anchor on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multi-object 3MF exports were emitting <object id="1"> in every
inner .model file, with the outer .model's <component> tags all
referencing objectid="1" (each at a different p:path). Bambu Studio's
importer keys deduplication on inner-object id, so two inner files
both using id=1 collapsed into a single visual object — even though
each held distinct geometry. The user reported "two objects with
identical mesh, different coloring," which is exactly this symptom:
both build items rendered the same inner mesh blob (the first one
loaded), with different paint_color assignments per outer object
giving the two visual instances different colors.

Fix: use the outer <object id> as the inner <object id> too. Since
outer ids are already unique across the 3MF, reusing them inside the
inner files means every object id in the entire 3MF is unique, which
is what the importer wants. Bambu's single-mesh export keeps inner
id=1 (matches its existing main-model component reference).

buildObjectModel grows an objectID parameter; call sites pass the
right value (outer's objectID for the generic multi-mesh path,
literal 1 for the Bambu single-mesh path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pipeline stages write their outputs to disk asynchronously, but
ExportFile reads them back from disk. After a fresh RunCached on a
large model (e.g. apollo's 1M-face merge), the writes are still in
flight when the user clicks Export, and the lookups miss with
"pipeline has not been run yet". Block on WaitForDiskWrites at the
top of ExportFile so the lookups see the just-written blobs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds back peg/dowel alignment features that were stubbed during the
hand-rolled-to-CGAL cutter rewrite. Each connector is realized as a
cylinder mesh applied to the relevant half by CGAL's
corefine_and_compute_{union,difference}.

New package internal/cgalbool/ wraps CGAL's mesh boolean operations,
mirroring cgalclip's CGO + Go-wrapper structure.

Inside internal/split/:

- cylinder.go: closed-cylinder triangle-mesh generator (32 segments).
- cap_polygon.go: walk a clipped half's cap faces (those with normal
  ≈ +cap normal and centroid on the plane), trace boundary edges into
  loops, classify by enclosure depth (point-in-polygon), normalize
  windings into outer/hole pairs.
- placement.go: rasterize the cap polygon-with-holes to a 200-pixel
  mask, place pegs greedily — first near centroid, then farthest-point
  on the existing-pegs distance transform. Multi-component caps split
  the count by area.
- connectors.go: orchestration. Pegs unions a male cylinder onto half
  0 and differences a clearance-offset female cylinder from half 1;
  Dowels differences clearance-offset cylinders from both halves. Per-
  connector failures isolate: a single boolean error warns and the
  rest of the run continues.

split.Cut now calls applyConnectors after the two halves come back
from cgalclip.Clip. The previous "connectors are temporarily not
supported" warning is gone.

Tests cover placement spread (unit-square N=4 pairwise distance,
L-shape N=2 distance, hole avoidance, single-peg centroid), cylinder
watertightness and bounding box, cap polygon recovery for a cube cut,
and end-to-end volume change for Pegs and Dowels styles. The
previously-skipped TestLayout_PegOnBed is un-skipped and passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The frontend writes splitConnectorCount: 0 to mean "auto" (per the
comment on Options.Split.ConnectorCount), but applyConnectors was
treating count <= 0 as "skip connectors entirely." Result: a saved
settings file with style=pegs and count=0 silently ran with flat
caps. Default count to 2 in that case and log the actual connector
count so the warm-cache path makes it visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Greedy farthest-point placement was free to park pegs at corners or
along the cap polygon boundary. With a 5 mm peg in a roughly
rectangular cap, that meant the peg cylinder protruded through the
side wall on half 0 and the matching pocket sliced off a corner on
half 1.

Fix: erode the eligible-pixel mask by one peg diameter before
greedy-picking, so every peg center has at least peg-diameter of
clear interior between it and the cap boundary (a circle of 2× the
peg diameter fits fully inside the polygon around each peg). The
mask is shrunk via a chamfer-3-4 distance transform.

The chamfer scan needs outside seed pixels to propagate from. A
polygon that fully fills its bbox (axis-aligned rectangle) has no
outside cells in the original grid; pad the grid by one pixel on
each side so the perimeter is always-outside and the scan can start.

If clearance erodes the polygon to nothing (cap too small for the
requested peg), fall back to the un-eroded mask so the user gets at
least one peg in the most-interior spot rather than a silent
no-pegs run.

Adds TestPlacePegs_BoundaryClearance regression covering the
10×10-square / clearance-2 case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pegs printed cap-down had the peg cylinder hanging in air below the
cap, requiring print supports or assembly tricks. Flip the male
half (Halves[0] under the Pegs style) cap-up: outward cap normal
points to +Z instead of -Z, so the peg tips reach the highest z
on that half and print pointing skyward. Dowels and NoConnectors
keep the original cap-down orientation since their caps don't
overhang anything.

CutResult gains CapUp [2]bool, set by Cut when style==Pegs. Layout
honours it via a new rotationToPosZ helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a "How to Split a Model into Two Halves" section, slots Split
into the pipeline overview, and updates the appendix and CLI notes
to cover the controls, panel-state persistence, two-object 3MF
emission, persistent on-disk caches, collapsible sidebar sections,
and live cache-status / error display in the stage list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
These targets used the no-CGO cross-compile path because the runners
don't have cross-compiled GMP/MPFR available. After 979f67a removed
the cgal build tag (and the no-CGAL fallback paths), every CGO
binding package becomes empty under CGO_ENABLED=0, so the no-CGO
build now fails to compile internal/alphawrap, internal/cgalbool,
internal/cgalclip, and the internal/split package that imports them.

Aligning CI with that commit's stated intent ("CGAL is required at
build time"): drop the no-CGAL CLI step, drop the darwin/amd64 leg
of the macOS universal binary, rename the macOS artifacts to
macos-arm64, and update the release notes accordingly. Intel Mac
and Linux ARM users build from source.

Also drop the now-stale `-tags cgal` from the smoke-test step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rtwfroody rtwfroody merged commit 9cecc98 into main May 1, 2026
4 checks passed
@rtwfroody rtwfroody deleted the split-feature branch May 2, 2026 00:04
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