Add Split feature: cut models into two halves with peg/dowel connectors#2
Merged
Conversation
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>
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>
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
Polygon_mesh_processing::clipfor the cut, CGAL boolean ops for connectors).<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.Test plan
go test -timeout 10m ./...passes🤖 Generated with Claude Code