{#each stages as stage}
@@ -1074,6 +1149,12 @@
{/if}
{/each}
+ {#if pipelineError}
+
+ !
+ {pipelineError}
+
+ {/if}
{:else if loading}
diff --git a/frontend/src/lib/components/SettingsSection.svelte b/frontend/src/lib/components/SettingsSection.svelte
new file mode 100644
index 0000000..486e15b
--- /dev/null
+++ b/frontend/src/lib/components/SettingsSection.svelte
@@ -0,0 +1,39 @@
+
+
+
+
+ ▸
+ {title}
+ {#if tip}{@render tip()}{/if}
+
+
+
+ {@render children()}
+
+
+
+
diff --git a/frontend/src/lib/components/SplitControls.svelte b/frontend/src/lib/components/SplitControls.svelte
new file mode 100644
index 0000000..338dcc9
--- /dev/null
+++ b/frontend/src/lib/components/SplitControls.svelte
@@ -0,0 +1,209 @@
+
+
+
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
new file mode 100644
index 0000000..f8fba05
--- /dev/null
+++ b/frontend/src/lib/types.ts
@@ -0,0 +1,15 @@
+// Shared frontend types.
+
+// Cut-plane preview overlay payload. Mirrors the backend's
+// pipeline.SplitPreviewResult shape (see internal/pipeline/splitpreview.go),
+// but is currently computed client-side from the input-mesh event's bbox
+// to avoid RPC churn on the Split offset slider. Coordinates are in the
+// rendered-mesh frame (i.e. already scaled by previewScale).
+export type CutPlanePreview = {
+ origin: [number, number, number];
+ normal: [number, number, number];
+ u: [number, number, number];
+ v: [number, number, number];
+ halfExtentU: number;
+ halfExtentV: number;
+};
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts
index e3f7caf..7a80a5e 100755
--- a/frontend/wailsjs/go/main/App.d.ts
+++ b/frontend/wailsjs/go/main/App.d.ts
@@ -48,4 +48,6 @@ export function SaveSettings(arg1:string,arg2:main.Settings):Promise
;
export function SaveSettingsDialog(arg1:main.Settings):Promise;
+export function SplitPreview(arg1:pipeline.SplitSettings):Promise;
+
export function Version():Promise;
diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js
index 7738e92..ddab73a 100755
--- a/frontend/wailsjs/go/main/App.js
+++ b/frontend/wailsjs/go/main/App.js
@@ -90,6 +90,10 @@ export function SaveSettingsDialog(arg1) {
return window['go']['main']['App']['SaveSettingsDialog'](arg1);
}
+export function SplitPreview(arg1) {
+ return window['go']['main']['App']['SplitPreview'](arg1);
+}
+
export function Version() {
return window['go']['main']['App']['Version']();
}
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
index f67a9bb..0feff24 100755
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -138,6 +138,15 @@ export namespace main {
alphaWrap: boolean;
alphaWrapAlpha: string;
alphaWrapOffset: string;
+ splitEnabled: boolean;
+ splitAxis: number;
+ splitOffset: number;
+ splitConnectorStyle: string;
+ splitConnectorCount: number;
+ splitConnectorDiamMM: number;
+ splitConnectorDepthMM: number;
+ splitClearanceMM: number;
+ splitGapMM: number;
static createFrom(source: any = {}) {
return new Settings(source);
@@ -169,6 +178,15 @@ export namespace main {
this.alphaWrap = source["alphaWrap"];
this.alphaWrapAlpha = source["alphaWrapAlpha"];
this.alphaWrapOffset = source["alphaWrapOffset"];
+ this.splitEnabled = source["splitEnabled"];
+ this.splitAxis = source["splitAxis"];
+ this.splitOffset = source["splitOffset"];
+ this.splitConnectorStyle = source["splitConnectorStyle"];
+ this.splitConnectorCount = source["splitConnectorCount"];
+ this.splitConnectorDiamMM = source["splitConnectorDiamMM"];
+ this.splitConnectorDepthMM = source["splitConnectorDepthMM"];
+ this.splitClearanceMM = source["splitClearanceMM"];
+ this.splitGapMM = source["splitGapMM"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -276,6 +294,34 @@ export namespace main {
export namespace pipeline {
+ export class SplitSettings {
+ Enabled: boolean;
+ Axis: number;
+ Offset: number;
+ ConnectorStyle: string;
+ ConnectorCount: number;
+ ConnectorDiamMM: number;
+ ConnectorDepthMM: number;
+ ClearanceMM: number;
+ GapMM: number;
+
+ static createFrom(source: any = {}) {
+ return new SplitSettings(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.Enabled = source["Enabled"];
+ this.Axis = source["Axis"];
+ this.Offset = source["Offset"];
+ this.ConnectorStyle = source["ConnectorStyle"];
+ this.ConnectorCount = source["ConnectorCount"];
+ this.ConnectorDiamMM = source["ConnectorDiamMM"];
+ this.ConnectorDepthMM = source["ConnectorDepthMM"];
+ this.ClearanceMM = source["ClearanceMM"];
+ this.GapMM = source["GapMM"];
+ }
+ }
export class Sticker {
ImagePath: string;
Center: number[];
@@ -348,6 +394,7 @@ export namespace pipeline {
AlphaWrap: boolean;
AlphaWrapAlpha: number;
AlphaWrapOffset: number;
+ Split?: SplitSettings;
static createFrom(source: any = {}) {
return new Options(source);
@@ -384,6 +431,7 @@ export namespace pipeline {
this.AlphaWrap = source["AlphaWrap"];
this.AlphaWrapAlpha = source["AlphaWrapAlpha"];
this.AlphaWrapOffset = source["AlphaWrapOffset"];
+ this.Split = this.convertValues(source["Split"], SplitSettings);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -404,6 +452,29 @@ export namespace pipeline {
return a;
}
}
+ export class SplitPreviewResult {
+ origin: number[];
+ normal: number[];
+ u: number[];
+ v: number[];
+ halfExtentU: number;
+ halfExtentV: number;
+
+ static createFrom(source: any = {}) {
+ return new SplitPreviewResult(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.origin = source["origin"];
+ this.normal = source["normal"];
+ this.u = source["u"];
+ this.v = source["v"];
+ this.halfExtentU = source["halfExtentU"];
+ this.halfExtentV = source["halfExtentV"];
+ }
+ }
+
}
diff --git a/internal/alphawrap/alphawrap.go b/internal/alphawrap/alphawrap.go
index a53a1c0..3920dc3 100644
--- a/internal/alphawrap/alphawrap.go
+++ b/internal/alphawrap/alphawrap.go
@@ -1,20 +1,22 @@
// Package alphawrap cleans up a triangle mesh by wrapping it with a
// watertight, orientable, manifold surface using CGAL's Alpha_wrap_3
-// (Portaneri et al., 2022). When built with the "cgal" build tag the
-// wrapping is done in-process via CGO; otherwise a Python sidecar
-// (scripts/alpha_wrap.py) invoked via `uv run` is used as a fallback.
+// (Portaneri et al., 2022). The wrapping is done in-process via CGO;
+// CGAL is required at build time (system package on Linux/Windows,
+// homebrew on macOS — see the release workflow for details).
package alphawrap
import (
"fmt"
+ "github.com/rtwfroody/ditherforge/internal/alphawrap/cgalwrap"
"github.com/rtwfroody/ditherforge/internal/loader"
)
-// Wrap returns a geometry-only LoadedModel whose surface is the alpha-wrap
-// of the input model. alpha and offset are in model coordinate units (mm
-// after pipeline scaling). The returned model has only Vertices and Faces
-// populated; UVs, colors, and textures are not carried through.
+// Wrap returns a geometry-only LoadedModel whose surface is the
+// alpha-wrap of the input model. alpha and offset are in model
+// coordinate units (mm after pipeline scaling). The returned model
+// has only Vertices and Faces populated; UVs, colors, and textures
+// are not carried through.
func Wrap(model *loader.LoadedModel, alpha, offset float32) (*loader.LoadedModel, error) {
if alpha <= 0 || offset <= 0 {
return nil, fmt.Errorf("alpha-wrap: alpha and offset must be positive (got alpha=%g offset=%g)", alpha, offset)
@@ -22,5 +24,12 @@ func Wrap(model *loader.LoadedModel, alpha, offset float32) (*loader.LoadedModel
if len(model.Faces) == 0 {
return nil, fmt.Errorf("alpha-wrap: input mesh has no faces")
}
- return doWrap(model, alpha, offset)
+ verts, faces, err := cgalwrap.AlphaWrap(model.Vertices, model.Faces, float64(alpha), float64(offset))
+ if err != nil {
+ return nil, err
+ }
+ return &loader.LoadedModel{
+ Vertices: verts,
+ Faces: faces,
+ }, nil
}
diff --git a/internal/alphawrap/alphawrap_test.go b/internal/alphawrap/alphawrap_test.go
index b5cb219..c0d85b2 100644
--- a/internal/alphawrap/alphawrap_test.go
+++ b/internal/alphawrap/alphawrap_test.go
@@ -1,21 +1,14 @@
package alphawrap
import (
- "os/exec"
"testing"
"github.com/rtwfroody/ditherforge/internal/loader"
)
-// TestWrapTetrahedron wraps a simple tetrahedron. Skipped when using the
-// Python fallback and `uv` is not installed (CI without uv still passes).
+// TestWrapTetrahedron wraps a simple tetrahedron via CGAL's
+// alpha_wrap_3.
func TestWrapTetrahedron(t *testing.T) {
- if !hasCGAL {
- if _, err := exec.LookPath("uv"); err != nil {
- t.Skip("uv not installed and cgal build tag not set; skipping alpha-wrap integration test")
- }
- }
-
model := &loader.LoadedModel{
Vertices: [][3]float32{
{0, 0, 0}, {1, 0, 0}, {0, 1, 0}, {0, 0, 1},
diff --git a/internal/alphawrap/cgalwrap/cgalwrap.go b/internal/alphawrap/cgalwrap/cgalwrap.go
index f7f05a2..f117be1 100644
--- a/internal/alphawrap/cgalwrap/cgalwrap.go
+++ b/internal/alphawrap/cgalwrap/cgalwrap.go
@@ -1,5 +1,3 @@
-//go:build cgal
-
package cgalwrap
/*
diff --git a/internal/alphawrap/cgo.go b/internal/alphawrap/cgo.go
deleted file mode 100644
index cc48631..0000000
--- a/internal/alphawrap/cgo.go
+++ /dev/null
@@ -1,21 +0,0 @@
-//go:build cgal
-
-package alphawrap
-
-import (
- "github.com/rtwfroody/ditherforge/internal/alphawrap/cgalwrap"
- "github.com/rtwfroody/ditherforge/internal/loader"
-)
-
-var hasCGAL = true
-
-func doWrap(model *loader.LoadedModel, alpha, offset float32) (*loader.LoadedModel, error) {
- outVerts, outFaces, err := cgalwrap.AlphaWrap(model.Vertices, model.Faces, float64(alpha), float64(offset))
- if err != nil {
- return nil, err
- }
- return &loader.LoadedModel{
- Vertices: outVerts,
- Faces: outFaces,
- }, nil
-}
diff --git a/internal/alphawrap/fallback.go b/internal/alphawrap/fallback.go
deleted file mode 100644
index 920d24b..0000000
--- a/internal/alphawrap/fallback.go
+++ /dev/null
@@ -1,153 +0,0 @@
-//go:build !cgal
-
-package alphawrap
-
-import (
- "bytes"
- "errors"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
-
- "github.com/hschendel/stl"
- "github.com/rtwfroody/ditherforge/internal/loader"
-)
-
-// hasCGAL reports whether this binary was built with CGO-based CGAL support.
-var hasCGAL = false
-
-// ErrNoUV indicates the `uv` tool is not installed or not on PATH.
-var ErrNoUV = errors.New("alpha-wrap requires `uv` on PATH: install from https://docs.astral.sh/uv/")
-
-func doWrap(model *loader.LoadedModel, alpha, offset float32) (*loader.LoadedModel, error) {
- if _, err := exec.LookPath("uv"); err != nil {
- return nil, ErrNoUV
- }
-
- script, err := locateScript()
- if err != nil {
- return nil, err
- }
-
- tmpDir, err := os.MkdirTemp("", "ditherforge-alphawrap-")
- if err != nil {
- return nil, fmt.Errorf("alpha-wrap: temp dir: %w", err)
- }
- defer os.RemoveAll(tmpDir)
-
- inPath := filepath.Join(tmpDir, "in.stl")
- outPath := filepath.Join(tmpDir, "out.stl")
-
- if err := writeSTL(inPath, model); err != nil {
- return nil, fmt.Errorf("alpha-wrap: write input STL: %w", err)
- }
-
- cmd := exec.Command("uv", "run", "--script", script,
- "--in", inPath, "--out", outPath,
- "--alpha", fmt.Sprintf("%g", alpha),
- "--offset", fmt.Sprintf("%g", offset))
- var stderr bytes.Buffer
- cmd.Stderr = &stderr
- if out, err := cmd.Output(); err != nil {
- return nil, fmt.Errorf("alpha-wrap: sidecar failed: %w\nstderr: %s\nstdout: %s", err, stderr.String(), string(out))
- }
-
- wrapped, err := readSTL(outPath)
- if err != nil {
- return nil, fmt.Errorf("alpha-wrap: read output STL: %w", err)
- }
- return wrapped, nil
-}
-
-// locateScript returns the absolute path to scripts/alpha_wrap.py. Searches
-// the module root (for development runs) and the directory of the running
-// executable (for packaged builds alongside the binary).
-func locateScript() (string, error) {
- candidates := []string{}
- // Dev: relative to this package's source directory.
- if _, thisFile, _, ok := runtime.Caller(0); ok {
- candidates = append(candidates, filepath.Join(filepath.Dir(thisFile), "..", "..", "scripts", "alpha_wrap.py"))
- }
- // Packaged: next to the binary.
- if exe, err := os.Executable(); err == nil {
- dir := filepath.Dir(exe)
- candidates = append(candidates,
- filepath.Join(dir, "scripts", "alpha_wrap.py"),
- filepath.Join(dir, "alpha_wrap.py"),
- filepath.Join(dir, "..", "Resources", "scripts", "alpha_wrap.py"), // macOS bundle
- )
- }
- // CWD fallback.
- candidates = append(candidates, filepath.Join("scripts", "alpha_wrap.py"))
-
- for _, c := range candidates {
- if abs, err := filepath.Abs(c); err == nil {
- if _, err := os.Stat(abs); err == nil {
- return abs, nil
- }
- }
- }
- return "", fmt.Errorf("alpha-wrap: could not locate scripts/alpha_wrap.py (searched %d locations)", len(candidates))
-}
-
-func writeSTL(path string, model *loader.LoadedModel) error {
- solid := &stl.Solid{}
- solid.SetBinaryHeader(make([]byte, 80))
- solid.Triangles = make([]stl.Triangle, 0, len(model.Faces))
- for _, f := range model.Faces {
- v0 := model.Vertices[f[0]]
- v1 := model.Vertices[f[1]]
- v2 := model.Vertices[f[2]]
- solid.Triangles = append(solid.Triangles, stl.Triangle{
- Vertices: [3]stl.Vec3{
- {v0[0], v0[1], v0[2]},
- {v1[0], v1[1], v1[2]},
- {v2[0], v2[1], v2[2]},
- },
- })
- }
- return solid.WriteFile(path)
-}
-
-func readSTL(path string) (*loader.LoadedModel, error) {
- solid, err := stl.ReadFile(path)
- if err != nil {
- return nil, err
- }
- n := len(solid.Triangles)
- if n == 0 {
- return nil, fmt.Errorf("sidecar produced empty STL")
- }
-
- // Dedup vertices by snapped position.
- const snap = 1e5
- type key [3]int64
- idx := make(map[key]uint32, n*3)
- verts := make([][3]float32, 0, n*3)
- faces := make([][3]uint32, 0, n)
- for _, tri := range solid.Triangles {
- var face [3]uint32
- for j := range 3 {
- p := tri.Vertices[j]
- k := key{int64(p[0] * snap), int64(p[1] * snap), int64(p[2] * snap)}
- vi, ok := idx[k]
- if !ok {
- vi = uint32(len(verts))
- idx[k] = vi
- verts = append(verts, [3]float32{p[0], p[1], p[2]})
- }
- face[j] = vi
- }
- if face[0] == face[1] || face[1] == face[2] || face[0] == face[2] {
- continue // degenerate after dedup
- }
- faces = append(faces, face)
- }
-
- return &loader.LoadedModel{
- Vertices: verts,
- Faces: faces,
- }, nil
-}
diff --git a/internal/cacheblob/cacheblob.go b/internal/cacheblob/cacheblob.go
new file mode 100644
index 0000000..58451ec
--- /dev/null
+++ b/internal/cacheblob/cacheblob.go
@@ -0,0 +1,42 @@
+// Package cacheblob implements the cache wire format: gob-encoded
+// values inside a zstd stream. Extracted as a separate package so the
+// encode/decode pair can be reused outside the diskcache directly
+// (e.g. by the pipeline layer when it wants to encode once and hand
+// the resulting bytes off to a write goroutine).
+package cacheblob
+
+import (
+ "bytes"
+ "encoding/gob"
+
+ "github.com/klauspost/compress/zstd"
+)
+
+// Encode gob-encodes val and zstd-compresses the result. Returns the
+// final blob suitable for storage in either cache tier.
+func Encode(val any) ([]byte, error) {
+ var buf bytes.Buffer
+ zw, err := zstd.NewWriter(&buf, zstd.WithEncoderLevel(zstd.SpeedDefault))
+ if err != nil {
+ return nil, err
+ }
+ if err := gob.NewEncoder(zw).Encode(val); err != nil {
+ zw.Close()
+ return nil, err
+ }
+ if err := zw.Close(); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+// Decode is the inverse of Encode: zstd-decompresses blob and
+// gob-decodes the result into out (which must be a pointer).
+func Decode(blob []byte, out any) error {
+ zr, err := zstd.NewReader(bytes.NewReader(blob))
+ if err != nil {
+ return err
+ }
+ defer zr.Close()
+ return gob.NewDecoder(zr).Decode(out)
+}
diff --git a/internal/cachepolicy/cachepolicy.go b/internal/cachepolicy/cachepolicy.go
new file mode 100644
index 0000000..b38384a
--- /dev/null
+++ b/internal/cachepolicy/cachepolicy.go
@@ -0,0 +1,121 @@
+// Package cachepolicy holds the value-scoring formula and eviction
+// ranking primitive used by the disk cache. Extracted from diskcache
+// so the formula can be unit-tested independently of file I/O.
+package cachepolicy
+
+import (
+ "math"
+ "sort"
+ "time"
+)
+
+// HalfLife is the age at which an entry's recency factor reaches 0.5.
+// One hour is short enough that fresh entries dominate over
+// "earlier this session" peers; the power-law tail (see
+// RecencyFactor) keeps older entries ranked sensibly without a
+// clamp. Tied to time-since-access (mtime), which the tiers bump on
+// every cache hit.
+const HalfLife = 1 * time.Hour
+
+// SizeFloor is the minimum size used in the score's sqrt denominator.
+// Entries smaller than this cluster together, so absolute cost decides
+// among "small enough that size barely matters" entries. Without a
+// floor, a tiny-but-fresh entry can outrank a huge expensive one of
+// similar density via compounding penalty at trivial sizes.
+const SizeFloor = 64 * 1024
+
+// Entry is the minimum a tier needs to expose for ranking. Each tier
+// builds these from its underlying storage (file walks for disk, the
+// live map for memory) and feeds them to FitToBudget.
+type Entry struct {
+ Stage string
+ Key string
+ Description string
+ SizeBytes int64
+ CostMs int64
+ Mtime time.Time
+}
+
+// RecencyFactor returns the multiplier in (0, 1] that age contributes
+// to an entry's score. age <= 0 (clock skew) yields 1.0.
+//
+// Shape: power-law decay,
+//
+// factor = 1 / (1 + age / HalfLife)
+//
+// chosen over exponential decay because the marginal penalty for an
+// extra unit of age should *decrease* as the entry gets older — going
+// from 1h to 2h is meaningful (entry doubled in age), but going from
+// 24h to 25h barely changes the entry's "still-about-a-day-old"
+// status. Exponential decay treats both transitions identically (each
+// halves the weight per HalfLife), which is wrong for cache eviction.
+// The factor never reaches zero, so cost still ranks ancient entries
+// against each other without needing an explicit floor.
+//
+// At age=HalfLife the factor is exactly 0.5 (matching the constant's
+// name); at 1d (24·HL) it's ~0.040; at 1w it's ~0.006.
+func RecencyFactor(age time.Duration) float64 {
+ if age <= 0 {
+ return 1.0
+ }
+ return 1.0 / (1.0 + age.Seconds()/HalfLife.Seconds())
+}
+
+// Score is the value an entry contributes to the cache. Higher is more
+// valuable. The shape:
+//
+// score = (costMs / sqrt(max(sizeBytes, SizeFloor))) * 2^(-age/HalfLife)
+//
+// Entries with no recorded cost (legacy / aborted writes) get score 0
+// and fall to the front of the eviction queue.
+func Score(e Entry, now time.Time) float64 {
+ if e.SizeBytes <= 0 {
+ return 0
+ }
+ size := float64(e.SizeBytes)
+ if size < float64(SizeFloor) {
+ size = float64(SizeFloor)
+ }
+ base := float64(e.CostMs) / math.Sqrt(size)
+ return base * RecencyFactor(now.Sub(e.Mtime))
+}
+
+// FitToBudget returns indices into entries identifying which entries
+// to evict so the survivors total at most maxBytes. Ranking is by
+// Score ascending; ties break by oldest-mtime-first (preserving LRU
+// semantics among legacy zero-cost entries). Returns nil if the input
+// already fits.
+//
+// The caller is responsible for actually deleting. Returning indices
+// (rather than copies of Entry) lets the caller index back into its
+// own richer per-entry storage without a key-lookup map.
+func FitToBudget(entries []Entry, maxBytes int64, now time.Time) []int {
+ var total int64
+ for _, e := range entries {
+ total += e.SizeBytes
+ }
+ if total <= maxBytes {
+ return nil
+ }
+ idx := make([]int, len(entries))
+ for i := range idx {
+ idx[i] = i
+ }
+ sort.Slice(idx, func(a, b int) bool {
+ ea, eb := entries[idx[a]], entries[idx[b]]
+ sa, sb := Score(ea, now), Score(eb, now)
+ if sa != sb {
+ return sa < sb
+ }
+ return ea.Mtime.Before(eb.Mtime)
+ })
+ var out []int
+ for _, i := range idx {
+ if total <= maxBytes {
+ break
+ }
+ out = append(out, i)
+ total -= entries[i].SizeBytes
+ }
+ return out
+}
diff --git a/internal/cachepolicy/cachepolicy_test.go b/internal/cachepolicy/cachepolicy_test.go
new file mode 100644
index 0000000..71baa45
--- /dev/null
+++ b/internal/cachepolicy/cachepolicy_test.go
@@ -0,0 +1,126 @@
+package cachepolicy
+
+import (
+ "testing"
+ "time"
+)
+
+func TestScoreShape(t *testing.T) {
+ now := time.Now()
+ // 1KB / 1s vs 1000KB / 1000s: large-expensive must win even
+ // though it's bigger. With sqrt size penalty the 1000s entry
+ // wins by ~32×.
+ tiny := Entry{SizeBytes: 1024, CostMs: 1000, Mtime: now}
+ huge := Entry{SizeBytes: 1024 * 1000, CostMs: 1000 * 1000, Mtime: now}
+ if Score(tiny, now) >= Score(huge, now) {
+ t.Errorf("expected huge-expensive to outscore tiny-cheap; got tiny=%.3f huge=%.3f",
+ Score(tiny, now), Score(huge, now))
+ }
+}
+
+func TestSizeFloorPreventsInversion(t *testing.T) {
+ now := time.Now()
+ // 5KB / 0.5s Parse-like vs 600MB / 60s alpha-wrap Load. Without
+ // the floor, the tiny entry's lower size penalty would outrank
+ // the much-more-expensive Load.
+ parse := Entry{SizeBytes: 5 * 1024, CostMs: 500, Mtime: now}
+ load := Entry{SizeBytes: 600 * 1024 * 1024, CostMs: 60 * 1000, Mtime: now}
+ if Score(parse, now) >= Score(load, now) {
+ t.Errorf("size floor failed: parse=%.3f load=%.3f", Score(parse, now), Score(load, now))
+ }
+}
+
+func TestRecencyFactor(t *testing.T) {
+ if got := RecencyFactor(0); got != 1.0 {
+ t.Errorf("zero age must yield 1.0, got %v", got)
+ }
+ if got := RecencyFactor(HalfLife); got > 0.51 || got < 0.49 {
+ t.Errorf("one-halflife age must yield ~0.5, got %v", got)
+ }
+}
+
+// TestRecencyMarginalDecayDiminishes pins the power-law shape: the
+// drop in factor over a fixed time slice gets smaller as the starting
+// age grows. (An exponential curve would make every slice halve the
+// remaining factor — same proportion, but a much larger absolute drop
+// when freshly born than when already a day old. A linear or
+// step-function shape gets this directionally right but with hard
+// edges. Power-law is the smooth shape that matches the intuition.)
+func TestRecencyMarginalDecayDiminishes(t *testing.T) {
+ hl := HalfLife
+ dropFreshHour := RecencyFactor(0) - RecencyFactor(hl) // ~0 to ~0.5
+ dropDayLater := RecencyFactor(24*hl) - RecencyFactor(25*hl) // 24h to 25h
+ dropWeekLater := RecencyFactor(168*hl) - RecencyFactor(169*hl) // 1w to 1w+1h
+ if !(dropFreshHour > dropDayLater && dropDayLater > dropWeekLater) {
+ t.Errorf("expected marginal recency drop to shrink with age: fresh=%g day=%g week=%g",
+ dropFreshHour, dropDayLater, dropWeekLater)
+ }
+}
+
+func TestFitToBudgetSelectsLowestScore(t *testing.T) {
+ now := time.Now()
+ // Three entries, identical size, costs 1/100/10000 ms. Cap fits
+ // only two — the cheapest must be evicted.
+ entries := []Entry{
+ {Key: "cheap", SizeBytes: 1000, CostMs: 1, Mtime: now},
+ {Key: "mid", SizeBytes: 1000, CostMs: 100, Mtime: now},
+ {Key: "expensive", SizeBytes: 1000, CostMs: 10000, Mtime: now},
+ }
+ got := FitToBudget(entries, 2500, now)
+ if len(got) != 1 || entries[got[0]].Key != "cheap" {
+ t.Errorf("expected eviction of [cheap], got indices %v", got)
+ }
+}
+
+func TestFitToBudgetNoOpWhenWithinBudget(t *testing.T) {
+ now := time.Now()
+ entries := []Entry{
+ {Key: "a", SizeBytes: 100, CostMs: 1, Mtime: now},
+ {Key: "b", SizeBytes: 100, CostMs: 1, Mtime: now},
+ }
+ if got := FitToBudget(entries, 1000, now); len(got) != 0 {
+ t.Errorf("expected no eviction, got %v", got)
+ }
+}
+
+// TestFitToBudgetFreshBeatsStaleHigherCost reproduces the user's
+// real-world scenario: a just-completed Clip output (25.4s, 68 MB)
+// vs an hours-old Clip output (6.2s, 23 MB) used to evict the fresh
+// one because the recency-factor difference was negligible on a
+// 7-day half-life. With the 1-hour half-life the fresh entry's
+// recency factor stays at 1.0 while the stale one drops to 0.5,
+// so cost*recency favors the fresh entry even though its raw cost
+// per sqrt-byte is lower than the older one's.
+func TestFitToBudgetFreshBeatsStaleHigherCost(t *testing.T) {
+ now := time.Now()
+ staleMtime := now.Add(-3 * HalfLife) // recency factor ~0.125
+ entries := []Entry{
+ {Key: "fresh-clip", SizeBytes: 68 << 20, CostMs: 25400, Mtime: now},
+ {Key: "stale-merge", SizeBytes: 22 << 20, CostMs: 12300, Mtime: staleMtime},
+ }
+ // Cumulative size > 80 MiB; budget = 70 MiB forces one eviction.
+ got := FitToBudget(entries, 70<<20, now)
+ if len(got) != 1 {
+ t.Fatalf("expected one eviction, got %v", got)
+ }
+ if entries[got[0]].Key != "stale-merge" {
+ t.Errorf("expected stale-merge to evict, got %s", entries[got[0]].Key)
+ }
+}
+
+// TestRecencyTailPreservesRanking verifies that the power-law tail
+// keeps ancient entries from collapsing to score 0. A high-cost
+// ancient entry must still outrank a cheap ancient one of the same
+// size, just as it would when both are fresh.
+func TestRecencyTailPreservesRanking(t *testing.T) {
+ now := time.Now()
+ old := now.Add(-30 * 24 * time.Hour) // many half-lives — clamped to floor
+ entries := []Entry{
+ {Key: "old-cheap", SizeBytes: 1000, CostMs: 100, Mtime: old},
+ {Key: "old-expensive", SizeBytes: 1000, CostMs: 60000, Mtime: old},
+ }
+ got := FitToBudget(entries, 1500, now)
+ if len(got) != 1 || entries[got[0]].Key != "old-cheap" {
+ t.Errorf("expected old-cheap to evict first, got %v", got)
+ }
+}
diff --git a/internal/cgalbool/cgalbool.go b/internal/cgalbool/cgalbool.go
new file mode 100644
index 0000000..8c52f4a
--- /dev/null
+++ b/internal/cgalbool/cgalbool.go
@@ -0,0 +1,62 @@
+// Package cgalbool computes boolean operations on closed triangle
+// meshes using CGAL's Polygon_mesh_processing::corefine_and_compute_*.
+//
+// This package is a thin Go-facing wrapper around the CGO binding in
+// internal/cgalbool/cgalbool. Like cgalclip it is geometry-only:
+// inputs/outputs use loader.LoadedModel for shape, but only Vertices
+// and Faces are read/written. UVs, vertex colors, and textures are
+// not carried through — connector geometry inherits the surrounding
+// half's appearance after the boolean lands.
+//
+// CGAL is required at build time. See cgalclip's package doc for the
+// system-dependency story; both packages link against the same
+// libraries.
+//
+// Numerical notes mirror cgalclip: EPIC kernel, exact predicates
+// with float64 constructions. Results are watertight and
+// topologically robust; vertex coordinates carry rounding error at
+// the ULP scale (irrelevant for the printing pipeline downstream).
+//
+// Failure modes:
+//
+// - Either input is non-orientable: surfaces as a clear error
+// before the boolean runs.
+// - Self-intersecting input or coplanar shared facets:
+// corefine_and_compute_* returns false and we surface an error.
+// - Empty or degenerate result: surfaces as an error rather than
+// returning a non-mesh.
+package cgalbool
+
+import (
+ "fmt"
+
+ "github.com/rtwfroody/ditherforge/internal/cgalbool/cgalbool"
+ "github.com/rtwfroody/ditherforge/internal/loader"
+)
+
+// Union returns a ∪ b as a geometry-only LoadedModel.
+func Union(a, b *loader.LoadedModel) (*loader.LoadedModel, error) {
+ return run(a, b, cgalbool.Union)
+}
+
+// Difference returns a \ b as a geometry-only LoadedModel.
+func Difference(a, b *loader.LoadedModel) (*loader.LoadedModel, error) {
+ return run(a, b, cgalbool.Difference)
+}
+
+func run(a, b *loader.LoadedModel, op cgalbool.Op) (*loader.LoadedModel, error) {
+ if a == nil || len(a.Faces) == 0 {
+ return nil, fmt.Errorf("cgalbool: input A is empty")
+ }
+ if b == nil || len(b.Faces) == 0 {
+ return nil, fmt.Errorf("cgalbool: input B is empty")
+ }
+ verts, faces, err := cgalbool.Compute(a.Vertices, a.Faces, b.Vertices, b.Faces, op)
+ if err != nil {
+ return nil, err
+ }
+ return &loader.LoadedModel{
+ Vertices: verts,
+ Faces: faces,
+ }, nil
+}
diff --git a/internal/cgalbool/cgalbool/cgalbool.cpp b/internal/cgalbool/cgalbool/cgalbool.cpp
new file mode 100644
index 0000000..bd91459
--- /dev/null
+++ b/internal/cgalbool/cgalbool/cgalbool.cpp
@@ -0,0 +1,170 @@
+// Boolean operations on closed triangle meshes via CGAL's
+// Polygon_mesh_processing::corefine_and_compute_{union,difference}.
+//
+// Inputs are oriented through the same polygon-soup pipeline that
+// cgalclip uses, so callers may pass triangle soups (we orient them).
+// Failures (self-intersection, non-orientable input, non-closed mesh)
+// surface as a CResult with .error set.
+
+#include "cgalbool.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+typedef CGAL::Exact_predicates_inexact_constructions_kernel K;
+typedef K::Point_3 Point_3;
+typedef CGAL::Surface_mesh Mesh;
+namespace PMP = CGAL::Polygon_mesh_processing;
+
+namespace {
+
+// soup_to_mesh constructs a Surface_mesh from a triangle soup, orienting
+// the soup first. Returns false and sets *err on failure.
+bool soup_to_mesh(
+ const float *vertices, int num_vertices,
+ const int *faces, int num_faces,
+ Mesh &out, char **err)
+{
+ std::vector pts;
+ pts.reserve(num_vertices);
+ for (int i = 0; i < num_vertices; i++) {
+ pts.emplace_back(vertices[i*3], vertices[i*3+1], vertices[i*3+2]);
+ }
+ std::vector> tris;
+ tris.reserve(num_faces);
+ for (int i = 0; i < num_faces; i++) {
+ tris.push_back({(std::size_t)faces[i*3],
+ (std::size_t)faces[i*3+1],
+ (std::size_t)faces[i*3+2]});
+ }
+ if (!PMP::orient_polygon_soup(pts, tris)) {
+ *err = strdup("input mesh is non-orientable");
+ return false;
+ }
+ PMP::polygon_soup_to_polygon_mesh(pts, tris, out);
+ return true;
+}
+
+// mesh_to_cresult fills r with vertices/faces from mesh. Surface_mesh
+// indices may have gaps after edits; remap to contiguous output.
+bool mesh_to_cresult(const Mesh &mesh, struct CResult &r) {
+ if (mesh.number_of_faces() == 0) {
+ r.error = strdup("boolean produced empty mesh");
+ return false;
+ }
+ std::vector vmap(mesh.num_vertices() +
+ mesh.number_of_removed_vertices(), -1);
+ r.num_vertices = (int)mesh.number_of_vertices();
+ r.num_faces = (int)mesh.number_of_faces();
+ r.vertices = (float*)malloc(r.num_vertices * 3 * sizeof(float));
+ r.faces = (int*)malloc(r.num_faces * 3 * sizeof(int));
+ if (!r.vertices || !r.faces) {
+ free(r.vertices); free(r.faces);
+ r.vertices = NULL; r.faces = NULL;
+ r.num_vertices = 0; r.num_faces = 0;
+ r.error = strdup("out of memory");
+ return false;
+ }
+
+ int vi = 0;
+ for (auto v : mesh.vertices()) {
+ auto p = mesh.point(v);
+ r.vertices[vi*3] = (float)p.x();
+ r.vertices[vi*3+1] = (float)p.y();
+ r.vertices[vi*3+2] = (float)p.z();
+ vmap[(std::size_t)v] = vi;
+ vi++;
+ }
+ int fi = 0;
+ for (auto f : mesh.faces()) {
+ auto h = mesh.halfedge(f);
+ auto h1 = mesh.next(h);
+ auto h2 = mesh.next(h1);
+ r.faces[fi*3] = vmap[(std::size_t)mesh.target(h)];
+ r.faces[fi*3+1] = vmap[(std::size_t)mesh.target(h1)];
+ r.faces[fi*3+2] = vmap[(std::size_t)mesh.target(h2)];
+ fi++;
+ }
+ return true;
+}
+
+enum BooleanOp { OP_UNION, OP_DIFFERENCE };
+
+struct CResult run_boolean(
+ const float *a_vertices, int a_num_vertices,
+ const int *a_faces, int a_num_faces,
+ const float *b_vertices, int b_num_vertices,
+ const int *b_faces, int b_num_faces,
+ BooleanOp op)
+{
+ struct CResult r = {};
+ try {
+ Mesh A, B, out;
+ if (!soup_to_mesh(a_vertices, a_num_vertices, a_faces, a_num_faces, A, &r.error)) return r;
+ if (!soup_to_mesh(b_vertices, b_num_vertices, b_faces, b_num_faces, B, &r.error)) return r;
+
+ bool ok = false;
+ switch (op) {
+ case OP_UNION:
+ ok = PMP::corefine_and_compute_union(A, B, out);
+ break;
+ case OP_DIFFERENCE:
+ ok = PMP::corefine_and_compute_difference(A, B, out);
+ break;
+ }
+ if (!ok) {
+ r.error = strdup("CGAL boolean failed (likely self-intersection or non-closed input)");
+ return r;
+ }
+ mesh_to_cresult(out, r);
+ } catch (const std::exception &e) {
+ free(r.vertices); free(r.faces);
+ r.vertices = NULL; r.faces = NULL;
+ r.num_vertices = 0; r.num_faces = 0;
+ r.error = strdup(e.what());
+ } catch (...) {
+ r.error = strdup("unknown C++ exception in boolean");
+ }
+ return r;
+}
+
+} // namespace
+
+extern "C" {
+
+struct CResult cb_union(
+ const float *a_vertices, int a_num_vertices,
+ const int *a_faces, int a_num_faces,
+ const float *b_vertices, int b_num_vertices,
+ const int *b_faces, int b_num_faces)
+{
+ return run_boolean(a_vertices, a_num_vertices, a_faces, a_num_faces,
+ b_vertices, b_num_vertices, b_faces, b_num_faces,
+ OP_UNION);
+}
+
+struct CResult cb_difference(
+ const float *a_vertices, int a_num_vertices,
+ const int *a_faces, int a_num_faces,
+ const float *b_vertices, int b_num_vertices,
+ const int *b_faces, int b_num_faces)
+{
+ return run_boolean(a_vertices, a_num_vertices, a_faces, a_num_faces,
+ b_vertices, b_num_vertices, b_faces, b_num_faces,
+ OP_DIFFERENCE);
+}
+
+void cb_free(struct CResult r) {
+ free(r.vertices);
+ free(r.faces);
+ free(r.error);
+}
+
+}
diff --git a/internal/cgalbool/cgalbool/cgalbool.go b/internal/cgalbool/cgalbool/cgalbool.go
new file mode 100644
index 0000000..3e073f2
--- /dev/null
+++ b/internal/cgalbool/cgalbool/cgalbool.go
@@ -0,0 +1,100 @@
+// Package cgalbool is the CGO binding layer for CGAL's
+// Polygon_mesh_processing::corefine_and_compute_{union,difference}.
+// The Go-side public API lives one directory up.
+package cgalbool
+
+/*
+#cgo CXXFLAGS: -std=c++17 -O3 -DNDEBUG
+#cgo darwin CXXFLAGS: -I/opt/homebrew/include -I/usr/local/include
+#cgo linux LDFLAGS: -lgmp -lmpfr
+#cgo darwin LDFLAGS: /opt/homebrew/lib/libmpfr.a /opt/homebrew/lib/libgmp.a
+#include "cgalbool.h"
+*/
+import "C"
+
+import (
+ "fmt"
+ "unsafe"
+)
+
+// Op selects the boolean operation to perform.
+type Op int
+
+const (
+ Union Op = iota
+ Difference
+)
+
+// Compute runs the requested boolean op on (a, b). Inputs are triangle
+// soups. Both must describe closed (or orientable) meshes. Returns the
+// result as flat (vertices, faces) arrays.
+func Compute(
+ aVerts [][3]float32, aFaces [][3]uint32,
+ bVerts [][3]float32, bFaces [][3]uint32,
+ op Op,
+) ([][3]float32, [][3]uint32, error) {
+ if len(aVerts) == 0 || len(aFaces) == 0 {
+ return nil, nil, fmt.Errorf("cgalbool: input A is empty")
+ }
+ if len(bVerts) == 0 || len(bFaces) == 0 {
+ return nil, nil, fmt.Errorf("cgalbool: input B is empty")
+ }
+
+ aV := make([]C.float, len(aVerts)*3)
+ for i, v := range aVerts {
+ aV[i*3] = C.float(v[0])
+ aV[i*3+1] = C.float(v[1])
+ aV[i*3+2] = C.float(v[2])
+ }
+ aF := make([]C.int, len(aFaces)*3)
+ for i, f := range aFaces {
+ aF[i*3] = C.int(f[0])
+ aF[i*3+1] = C.int(f[1])
+ aF[i*3+2] = C.int(f[2])
+ }
+ bV := make([]C.float, len(bVerts)*3)
+ for i, v := range bVerts {
+ bV[i*3] = C.float(v[0])
+ bV[i*3+1] = C.float(v[1])
+ bV[i*3+2] = C.float(v[2])
+ }
+ bF := make([]C.int, len(bFaces)*3)
+ for i, f := range bFaces {
+ bF[i*3] = C.int(f[0])
+ bF[i*3+1] = C.int(f[1])
+ bF[i*3+2] = C.int(f[2])
+ }
+
+ var r C.struct_CResult
+ switch op {
+ case Union:
+ r = C.cb_union(
+ &aV[0], C.int(len(aVerts)), &aF[0], C.int(len(aFaces)),
+ &bV[0], C.int(len(bVerts)), &bF[0], C.int(len(bFaces)))
+ case Difference:
+ r = C.cb_difference(
+ &aV[0], C.int(len(aVerts)), &aF[0], C.int(len(aFaces)),
+ &bV[0], C.int(len(bVerts)), &bF[0], C.int(len(bFaces)))
+ default:
+ return nil, nil, fmt.Errorf("cgalbool: unknown op %d", op)
+ }
+ defer C.cb_free(r)
+
+ if r.error != nil {
+ return nil, nil, fmt.Errorf("cgalbool: %s", C.GoString(r.error))
+ }
+
+ onv := int(r.num_vertices)
+ onf := int(r.num_faces)
+ outVerts := make([][3]float32, onv)
+ rv := unsafe.Slice((*C.float)(unsafe.Pointer(r.vertices)), onv*3)
+ for i := range onv {
+ outVerts[i] = [3]float32{float32(rv[i*3]), float32(rv[i*3+1]), float32(rv[i*3+2])}
+ }
+ outFaces := make([][3]uint32, onf)
+ rf := unsafe.Slice((*C.int)(unsafe.Pointer(r.faces)), onf*3)
+ for i := range onf {
+ outFaces[i] = [3]uint32{uint32(rf[i*3]), uint32(rf[i*3+1]), uint32(rf[i*3+2])}
+ }
+ return outVerts, outFaces, nil
+}
diff --git a/internal/cgalbool/cgalbool/cgalbool.h b/internal/cgalbool/cgalbool/cgalbool.h
new file mode 100644
index 0000000..6453aea
--- /dev/null
+++ b/internal/cgalbool/cgalbool/cgalbool.h
@@ -0,0 +1,39 @@
+#ifndef CGALBOOL_CGALBOOL_H
+#define CGALBOOL_CGALBOOL_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* CResult mirrors cgalclip's CResult: caller-owned C buffers with
+ * vertices flat as 3 floats each, faces flat as 3 ints each, and an
+ * error string (NULL on success). Free with cb_free. */
+struct CResult {
+ float *vertices; /* 3 floats per vertex */
+ int num_vertices;
+ int *faces; /* 3 ints per face */
+ int num_faces;
+ char *error; /* NULL on success */
+};
+
+/* Compute (a ∪ b). Both inputs are closed triangle meshes. Returns the
+ * union as a closed triangle mesh. */
+struct CResult cb_union(
+ const float *a_vertices, int a_num_vertices,
+ const int *a_faces, int a_num_faces,
+ const float *b_vertices, int b_num_vertices,
+ const int *b_faces, int b_num_faces);
+
+/* Compute (a \ b). Returns a minus b as a closed triangle mesh. */
+struct CResult cb_difference(
+ const float *a_vertices, int a_num_vertices,
+ const int *a_faces, int a_num_faces,
+ const float *b_vertices, int b_num_vertices,
+ const int *b_faces, int b_num_faces);
+
+void cb_free(struct CResult result);
+
+#ifdef __cplusplus
+}
+#endif
+#endif
diff --git a/internal/cgalclip/cgalclip.go b/internal/cgalclip/cgalclip.go
new file mode 100644
index 0000000..8d84f81
--- /dev/null
+++ b/internal/cgalclip/cgalclip.go
@@ -0,0 +1,69 @@
+// Package cgalclip cuts a triangle mesh against a plane using CGAL's
+// Polygon_mesh_processing::clip. The kept half is closed-watertight
+// by construction (the cap surface is added automatically), replacing
+// the hand-rolled triangle-classification + ear-clip pipeline that
+// used to live in internal/split/.
+//
+// CGAL is required at build time. The release workflow installs it
+// via the system package manager (apt/brew/pacman); dev machines need
+// the same.
+//
+// Numerical kernel: CGAL's EPIC kernel — exact predicates with
+// inexact (float64) constructions. Cuts are topologically robust
+// (every triangle is unambiguously above/below/on the plane), but
+// the resulting cap-vertex coordinates are float64 with rounding
+// error. For two halves of the same cut, cap vertex positions match
+// up to a few ULPs but not bit-exactly. This is fine for the printing
+// pipeline downstream — alpha-wrap, voxelize, and merge tolerate
+// micron-scale jitter — but downstream code should not assume cap
+// vertex equality across halves.
+//
+// Failure modes worth knowing about:
+//
+// - Self-intersecting input. Clip is configured with
+// throw_on_self_intersection(true) so a non-watertight input
+// surfaces a CGAL exception ("Self_intersection_exception")
+// rather than producing garbage. Alpha-wrapped meshes are
+// supposed to be self-intersection-free; if you hit this,
+// re-run alpha-wrap with a tighter offset.
+// - Plane misses the input (no triangles cross). The clipped half
+// is empty and Clip returns an error.
+// - Plane lies tangent to a face. CGAL is strict; one half
+// ends up empty and Clip returns an error. The previous
+// hand-rolled cut "snapped" tangent vertices off the plane to
+// produce a sliver — that hack is gone.
+package cgalclip
+
+import (
+ "fmt"
+
+ "github.com/rtwfroody/ditherforge/internal/cgalclip/cgalclip"
+ "github.com/rtwfroody/ditherforge/internal/loader"
+)
+
+// Clip returns the half of model on the negative side of the plane
+// (where normal·p <= d). To get the other half, flip both normal and
+// d.
+//
+// The plane normal must be unit-length; CGAL's clip is robust to
+// non-unit normals but the kernel runs faster with normalised input
+// and downstream code sometimes assumes |normal|=1.
+//
+// Returns a geometry-only LoadedModel: only Vertices and Faces are
+// populated. UVs, vertex colors, and textures are not carried through —
+// the cap geometry has no source UVs, and the surrounding pipeline
+// re-derives color information from the original mesh after the cut.
+func Clip(model *loader.LoadedModel, normal [3]float64, d float64) (*loader.LoadedModel, error) {
+ if model == nil || len(model.Faces) == 0 {
+ return nil, fmt.Errorf("cgalclip: input mesh is empty")
+ }
+ verts, faces, err := cgalclip.Clip(model.Vertices, model.Faces,
+ normal[0], normal[1], normal[2], d)
+ if err != nil {
+ return nil, err
+ }
+ return &loader.LoadedModel{
+ Vertices: verts,
+ Faces: faces,
+ }, nil
+}
diff --git a/internal/cgalclip/cgalclip/cgalclip.go b/internal/cgalclip/cgalclip/cgalclip.go
new file mode 100644
index 0000000..198ed42
--- /dev/null
+++ b/internal/cgalclip/cgalclip/cgalclip.go
@@ -0,0 +1,74 @@
+// Package cgalclip is the CGO binding layer for CGAL's
+// Polygon_mesh_processing::clip. The Go-side public API lives one
+// directory up.
+package cgalclip
+
+/*
+#cgo CXXFLAGS: -std=c++17 -O3 -DNDEBUG
+#cgo darwin CXXFLAGS: -I/opt/homebrew/include -I/usr/local/include
+#cgo linux LDFLAGS: -lgmp -lmpfr
+#cgo darwin LDFLAGS: /opt/homebrew/lib/libmpfr.a /opt/homebrew/lib/libgmp.a
+#include "clip.h"
+*/
+import "C"
+
+import (
+ "fmt"
+ "unsafe"
+)
+
+// Clip cuts (vertices, faces) by the plane defined by normal·p == d
+// and returns the kept half (where normal·p <= d). The output is a
+// closed triangle mesh: cap surface is added automatically by CGAL's
+// clip routine.
+//
+// Caller is responsible for ensuring the input mesh is reasonably
+// well-formed (oriented or orientable triangle soup). Self-intersecting
+// inputs surface a clear error rather than producing garbage.
+func Clip(vertices [][3]float32, faces [][3]uint32, nx, ny, nz, d float64) ([][3]float32, [][3]uint32, error) {
+ nv := len(vertices)
+ nf := len(faces)
+ if nv == 0 || nf == 0 {
+ return nil, nil, fmt.Errorf("CGAL clip: input mesh is empty")
+ }
+
+ cVerts := make([]C.float, nv*3)
+ for i, v := range vertices {
+ cVerts[i*3] = C.float(v[0])
+ cVerts[i*3+1] = C.float(v[1])
+ cVerts[i*3+2] = C.float(v[2])
+ }
+ cFaces := make([]C.int, nf*3)
+ for i, f := range faces {
+ cFaces[i*3] = C.int(f[0])
+ cFaces[i*3+1] = C.int(f[1])
+ cFaces[i*3+2] = C.int(f[2])
+ }
+
+ r := C.cc_clip(
+ &cVerts[0], C.int(nv),
+ &cFaces[0], C.int(nf),
+ C.double(nx), C.double(ny), C.double(nz), C.double(d))
+ defer C.cc_free(r)
+
+ if r.error != nil {
+ return nil, nil, fmt.Errorf("CGAL clip: %s", C.GoString(r.error))
+ }
+
+ // The C side already returns an "empty mesh" error string when
+ // either count is zero, so we just trust the inputs here.
+ onv := int(r.num_vertices)
+ onf := int(r.num_faces)
+
+ outVerts := make([][3]float32, onv)
+ rv := unsafe.Slice((*C.float)(unsafe.Pointer(r.vertices)), onv*3)
+ for i := 0; i < onv; i++ {
+ outVerts[i] = [3]float32{float32(rv[i*3]), float32(rv[i*3+1]), float32(rv[i*3+2])}
+ }
+ outFaces := make([][3]uint32, onf)
+ rf := unsafe.Slice((*C.int)(unsafe.Pointer(r.faces)), onf*3)
+ for i := 0; i < onf; i++ {
+ outFaces[i] = [3]uint32{uint32(rf[i*3]), uint32(rf[i*3+1]), uint32(rf[i*3+2])}
+ }
+ return outVerts, outFaces, nil
+}
diff --git a/internal/cgalclip/cgalclip/clip.cpp b/internal/cgalclip/cgalclip/clip.cpp
new file mode 100644
index 0000000..b4ea9a3
--- /dev/null
+++ b/internal/cgalclip/cgalclip/clip.cpp
@@ -0,0 +1,126 @@
+// Clip a triangle mesh against a half-space using CGAL's
+// Polygon_mesh_processing::clip(). The kept half is the side where
+// normal·p <= d. The clipped output is a closed, oriented triangle
+// mesh (the cap is added by CGAL during clipping).
+
+#include "clip.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+typedef CGAL::Exact_predicates_inexact_constructions_kernel K;
+typedef K::Point_3 Point_3;
+typedef K::Plane_3 Plane_3;
+typedef CGAL::Surface_mesh Mesh;
+namespace PMP = CGAL::Polygon_mesh_processing;
+
+extern "C" {
+
+struct CResult cc_clip(
+ const float *vertices, int num_vertices,
+ const int *faces, int num_faces,
+ double nx, double ny, double nz, double d)
+{
+ struct CResult r = {};
+ try {
+ // Build a polygon soup, then orient it into a mesh. Same path
+ // as alpha-wrap's input prep — handles non-manifold inputs
+ // by orienting the soup before mesh construction.
+ std::vector pts;
+ pts.reserve(num_vertices);
+ for (int i = 0; i < num_vertices; i++) {
+ pts.emplace_back(vertices[i*3], vertices[i*3+1], vertices[i*3+2]);
+ }
+ std::vector> tris;
+ tris.reserve(num_faces);
+ for (int i = 0; i < num_faces; i++) {
+ tris.push_back({(std::size_t)faces[i*3],
+ (std::size_t)faces[i*3+1],
+ (std::size_t)faces[i*3+2]});
+ }
+
+ Mesh mesh;
+ if (!PMP::orient_polygon_soup(pts, tris)) {
+ r.error = strdup("input mesh is non-orientable");
+ return r;
+ }
+ PMP::polygon_soup_to_polygon_mesh(pts, tris, mesh);
+
+ // Plane orientation: clip() keeps the negative side of the
+ // CGAL plane, where the plane is normal·p + d_cgal = 0.
+ // Our convention is normal·p <= d, so d_cgal = -d.
+ Plane_3 plane(nx, ny, nz, -d);
+
+ // clip_volume=true asks PMP::clip to seal the resulting cut
+ // surface so the output is a closed solid (the cap is added
+ // automatically). The throw_on_self_intersection flag is on
+ // so we surface bad inputs rather than producing garbage.
+ PMP::clip(mesh, plane,
+ CGAL::parameters::clip_volume(true)
+ .throw_on_self_intersection(true));
+
+ if (mesh.number_of_faces() == 0) {
+ r.error = strdup("clip produced empty mesh (plane misses input or input degenerate)");
+ return r;
+ }
+
+ // Surface_mesh indices may have gaps after edits; remap to
+ // contiguous output indices.
+ std::vector vmap(mesh.num_vertices() +
+ mesh.number_of_removed_vertices(), -1);
+ r.num_vertices = (int)mesh.number_of_vertices();
+ r.num_faces = (int)mesh.number_of_faces();
+ r.vertices = (float*)malloc(r.num_vertices * 3 * sizeof(float));
+ r.faces = (int*)malloc(r.num_faces * 3 * sizeof(int));
+ if (!r.vertices || !r.faces) {
+ free(r.vertices); free(r.faces);
+ r.vertices = NULL; r.faces = NULL;
+ r.num_vertices = 0; r.num_faces = 0;
+ r.error = strdup("out of memory");
+ return r;
+ }
+
+ int vi = 0;
+ for (auto v : mesh.vertices()) {
+ auto p = mesh.point(v);
+ r.vertices[vi*3] = (float)p.x();
+ r.vertices[vi*3+1] = (float)p.y();
+ r.vertices[vi*3+2] = (float)p.z();
+ vmap[(std::size_t)v] = vi;
+ vi++;
+ }
+ int fi = 0;
+ for (auto f : mesh.faces()) {
+ auto h = mesh.halfedge(f);
+ auto h1 = mesh.next(h);
+ auto h2 = mesh.next(h1);
+ r.faces[fi*3] = vmap[(std::size_t)mesh.target(h)];
+ r.faces[fi*3+1] = vmap[(std::size_t)mesh.target(h1)];
+ r.faces[fi*3+2] = vmap[(std::size_t)mesh.target(h2)];
+ fi++;
+ }
+ } catch (const std::exception &e) {
+ free(r.vertices); free(r.faces);
+ r.vertices = NULL; r.faces = NULL;
+ r.num_vertices = 0; r.num_faces = 0;
+ r.error = strdup(e.what());
+ } catch (...) {
+ r.error = strdup("unknown C++ exception in clip");
+ }
+ return r;
+}
+
+void cc_free(struct CResult r) {
+ free(r.vertices);
+ free(r.faces);
+ free(r.error);
+}
+
+}
diff --git a/internal/cgalclip/cgalclip/clip.h b/internal/cgalclip/cgalclip/clip.h
new file mode 100644
index 0000000..155ad3b
--- /dev/null
+++ b/internal/cgalclip/cgalclip/clip.h
@@ -0,0 +1,34 @@
+#ifndef CGALCLIP_CLIP_H
+#define CGALCLIP_CLIP_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* CResult mirrors AWResult in alphawrap: caller-owned C buffers with
+ * vertices flat as 3 floats each, faces flat as 3 ints each, and
+ * an error string (NULL on success). Free with cc_free. */
+struct CResult {
+ float *vertices; /* 3 floats per vertex */
+ int num_vertices;
+ int *faces; /* 3 ints per face */
+ int num_faces;
+ char *error; /* NULL on success */
+};
+
+/* Clip the input mesh against an axis-aligned half-space and return the
+ * remaining (closed, watertight) half. The plane is described by
+ * normal · p == d
+ * and the kept half is the one where normal · p <= d (the "negative"
+ * side). To get the other half, flip both `normal` and `d`. */
+struct CResult cc_clip(
+ const float *vertices, int num_vertices,
+ const int *faces, int num_faces,
+ double nx, double ny, double nz, double d);
+
+void cc_free(struct CResult result);
+
+#ifdef __cplusplus
+}
+#endif
+#endif
diff --git a/internal/diskcache/diskcache.go b/internal/diskcache/diskcache.go
index e8afd22..f950c25 100644
--- a/internal/diskcache/diskcache.go
+++ b/internal/diskcache/diskcache.go
@@ -11,20 +11,18 @@ package diskcache
import (
"crypto/sha256"
- "encoding/gob"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"io/fs"
- "math"
"os"
"path/filepath"
- "sort"
"strings"
"time"
- "github.com/klauspost/compress/zstd"
+ "github.com/rtwfroody/ditherforge/internal/cacheblob"
+ "github.com/rtwfroody/ditherforge/internal/cachepolicy"
)
// dataExt and metaExt are the file extensions for cache data files and
@@ -39,10 +37,13 @@ const (
// EntryMetadata is the JSON shape of a sidecar .meta.json file.
type EntryMetadata struct {
// CostMs is how long the data file took to generate, in
- // milliseconds. Used by Sweep to make cost-aware eviction
- // decisions: entries with high cost-per-byte are kept, low-cost
- // or huge entries evict first.
+ // milliseconds. Used by Sweep to score entries for eviction:
+ // expensive-to-regenerate entries are kept longer.
CostMs int64 `json:"costMs"`
+ // Description is a short human-readable summary of what the
+ // entry contains (e.g. "Load: foo.glb (alpha-wrap)"). Printed
+ // during sweep so the operator can see what's being evicted.
+ Description string `json:"description,omitempty"`
}
@@ -60,6 +61,15 @@ type Cache struct {
// construction; reassignment after the cache is in use is racy because
// Set may invoke it from a goroutine.
OnError func(stage, op, key string, err error)
+ // OnEvict, if non-nil, is called for each entry Sweep removes,
+ // before the files are deleted. reason is "age" (past maxAge) or
+ // "size" (cost-aware eviction to fit the budget). Description is
+ // the meta-recorded human-readable summary, or "" if absent. Key is
+ // the cache key (the basename of the data/meta files, excluding
+ // extension), so repeated evicts of the same blob are visible.
+ // Mtime is the entry's newest file mtime so callers can derive
+ // the age at the moment of eviction.
+ OnEvict func(stage, key, description, reason string, sizeBytes, costMs int64, mtime time.Time)
}
func (c *Cache) reportError(stage, op, key string, err error) {
@@ -68,6 +78,12 @@ func (c *Cache) reportError(stage, op, key string, err error) {
}
}
+func (c *Cache) reportEvict(stage, key, description, reason string, sizeBytes, costMs int64, mtime time.Time) {
+ if c.OnEvict != nil {
+ c.OnEvict(stage, key, description, reason, sizeBytes, costMs, mtime)
+ }
+}
+
// Open creates the cache directory if needed and returns a Cache handle.
func Open(dir string) (*Cache, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
@@ -114,45 +130,27 @@ func (c *Cache) pathFor(stage, key string) string {
return filepath.Join(c.Dir, stage, key+dataExt)
}
-// Get reads, zstd-decompresses, and gob-decodes the entry into out (a
-// pointer). Returns false on miss; on any decode error the file is removed
-// silently and false is returned. On success, the file's mtime is bumped so
-// the LRU sweep treats it as a recent access.
-func (c *Cache) Get(stage, key string, out any) bool {
+// GetBlob reads the raw cacheblob bytes for (stage, key). Returns nil
+// on miss. On success the file's mtime is bumped so the sweep treats
+// this as a recent access. Decode errors are not detected here — the
+// caller decides whether to decode the blob.
+func (c *Cache) GetBlob(stage, key string) []byte {
p := c.pathFor(stage, key)
- f, err := os.Open(p)
+ data, err := os.ReadFile(p)
if err != nil {
if !os.IsNotExist(err) {
c.reportError(stage, "open", key, err)
}
- return false
- }
- zr, err := zstd.NewReader(f)
- if err != nil {
- f.Close()
- os.Remove(p)
- c.reportError(stage, "decode", key, err)
- return false
- }
- if err := gob.NewDecoder(zr).Decode(out); err != nil {
- zr.Close()
- f.Close()
- os.Remove(p)
- c.reportError(stage, "decode", key, err)
- return false
+ return nil
}
- zr.Close()
- f.Close()
now := time.Now()
_ = os.Chtimes(p, now, now)
- return true
+ return data
}
-// Set gob-encodes val, zstd-compresses, and writes the result atomically
-// (temp file + rename). All errors are silently swallowed: the cache is
-// best-effort and a failed write must not break the pipeline. Errors are
-// reported via OnError if set.
-func (c *Cache) Set(stage, key string, val any) {
+// SetBlob writes a pre-encoded cacheblob to disk atomically (temp file
+// + rename). Errors are silently swallowed and routed through OnError.
+func (c *Cache) SetBlob(stage, key string, blob []byte) {
dir := filepath.Join(c.Dir, stage)
if err := os.MkdirAll(dir, 0o755); err != nil {
c.reportError(stage, "mkdir", key, err)
@@ -165,24 +163,10 @@ func (c *Cache) Set(stage, key string, val any) {
return
}
tmpName := tmp.Name()
- zw, err := zstd.NewWriter(tmp, zstd.WithEncoderLevel(zstd.SpeedDefault))
- if err != nil {
- tmp.Close()
- os.Remove(tmpName)
- c.reportError(stage, "encode", key, err)
- return
- }
- if err := gob.NewEncoder(zw).Encode(val); err != nil {
- zw.Close()
- tmp.Close()
- os.Remove(tmpName)
- c.reportError(stage, "encode", key, err)
- return
- }
- if err := zw.Close(); err != nil {
+ if _, err := tmp.Write(blob); err != nil {
tmp.Close()
os.Remove(tmpName)
- c.reportError(stage, "encode", key, err)
+ c.reportError(stage, "write", key, err)
return
}
if err := tmp.Close(); err != nil {
@@ -196,12 +180,55 @@ func (c *Cache) Set(stage, key string, val any) {
}
}
+// Remove deletes the data file (and meta sidecar, if any) for
+// (stage, key). Errors are routed through OnError. Used by callers
+// that decoded the blob themselves and discovered it was corrupt;
+// removing the bad file means the next access misses cleanly and
+// recomputes instead of silently failing forever.
+func (c *Cache) Remove(stage, key string) {
+ if err := os.Remove(c.pathFor(stage, key)); err != nil && !os.IsNotExist(err) {
+ c.reportError(stage, "remove", key, err)
+ }
+ metaPath := filepath.Join(c.Dir, stage, key+metaExt)
+ if err := os.Remove(metaPath); err != nil && !os.IsNotExist(err) {
+ c.reportError(stage, "remove", key, err)
+ }
+}
+
+// Get reads, zstd-decompresses, and gob-decodes the entry into out (a
+// pointer). Returns false on miss; on any decode error the file is
+// removed silently and false is returned.
+func (c *Cache) Get(stage, key string, out any) bool {
+ blob := c.GetBlob(stage, key)
+ if blob == nil {
+ return false
+ }
+ if err := cacheblob.Decode(blob, out); err != nil {
+ os.Remove(c.pathFor(stage, key))
+ c.reportError(stage, "decode", key, err)
+ return false
+ }
+ return true
+}
+
+// Set encodes val with cacheblob and writes the result atomically.
+// Errors are silently swallowed and routed through OnError.
+func (c *Cache) Set(stage, key string, val any) {
+ blob, err := cacheblob.Encode(val)
+ if err != nil {
+ c.reportError(stage, "encode", key, err)
+ return
+ }
+ c.SetBlob(stage, key, blob)
+}
+
// RecordCost writes a sidecar JSON file recording how long the data file
-// at (stage, key) took to generate. Sweep uses this to make cost-aware
-// eviction decisions: entries with high cost-per-byte are kept, low-cost
-// or huge entries evict first. Best-effort like Set; errors go through
+// at (stage, key) took to generate, and a short human-readable description
+// of what the entry contains. Sweep uses cost to score entries for
+// eviction; description is shown in the sweep printout so the operator
+// can see what's being removed. Best-effort like Set; errors go through
// OnError but never fail the caller.
-func (c *Cache) RecordCost(stage, key string, cost time.Duration) {
+func (c *Cache) RecordCost(stage, key, description string, cost time.Duration) {
dir := filepath.Join(c.Dir, stage)
if err := os.MkdirAll(dir, 0o755); err != nil {
c.reportError(stage, "mkdir", key, err)
@@ -214,7 +241,7 @@ func (c *Cache) RecordCost(stage, key string, cost time.Duration) {
return
}
tmpName := tmp.Name()
- md := EntryMetadata{CostMs: cost.Milliseconds()}
+ md := EntryMetadata{CostMs: cost.Milliseconds(), Description: description}
if err := json.NewEncoder(tmp).Encode(md); err != nil {
tmp.Close()
os.Remove(tmpName)
@@ -246,42 +273,24 @@ type SweepStats struct {
// (stale .tmp- leftovers, files from older formats) are also tracked as
// single-file entries with no cost.
type cacheEntry struct {
+ stage string
+ key string
paths []string
totalSize int64
newestMtime time.Time
costMs int64
-}
-
-// recencyHalfLife is how fast an entry's "value" decays as time since
-// last access grows. With one day, a freshly-touched entry counts at
-// full weight, a day-old entry at 50%, a week-old entry at ~0.8%
-// (which the maxAge cutoff handles separately). Tied to time-since-
-// access (mtime), which Get bumps on every cache hit.
-const recencyHalfLife = 24 * time.Hour
-
-// recencyFactor returns the multiplier in (0, 1] that age contributes to
-// an entry's eviction score. age <= 0 (clock skew) yields 1.0.
-func recencyFactor(age time.Duration) float64 {
- if age <= 0 {
- return 1.0
- }
- return math.Pow(0.5, age.Seconds()/recencyHalfLife.Seconds())
+ description string
}
// Sweep walks the cache directory and removes entries by two rules:
//
// 1. Age: any entry whose newest file is older than maxAge is deleted.
// 2. Value-aware size eviction: among the remaining entries, those
-// with the lowest score are deleted first until total size fits
-// within maxBytes. The score combines three factors:
-//
-// score = (costMs / sizeBytes) * 2^(-age/halflife)
-//
-// Higher cost = more valuable (proportional). Larger size = less
-// valuable per byte (proportional). Older = less valuable (decays
-// exponentially with halflife = 24h). Ties fall back to oldest-
-// mtime-first, which preserves LRU semantics for legacy entries
-// with no recorded cost.
+// with the lowest cachepolicy.Score are deleted first until total
+// size fits within maxBytes. The score balances generation cost
+// (more valuable), size (less valuable per byte, sqrt-shaped so
+// huge expensive entries still beat tiny cheap ones), and recency
+// (decays exponentially over cachepolicy.HalfLife).
//
// Errors on individual files are ignored so a single unreadable file
// doesn't abort the sweep.
@@ -335,7 +344,20 @@ func (c *Cache) Sweep(maxAge time.Duration, maxBytes int64) (SweepStats, error)
}
e, ok := entries[groupID]
if !ok {
- e = &cacheEntry{}
+ // Derive the cache key from the basename without
+ // extension. For the meta/data sibling pair this gives
+ // the same key; for stray "other" files it's the full
+ // basename.
+ var key string
+ switch {
+ case strings.HasSuffix(base, dataExt):
+ key = strings.TrimSuffix(base, dataExt)
+ case strings.HasSuffix(base, metaExt):
+ key = strings.TrimSuffix(base, metaExt)
+ default:
+ key = base
+ }
+ e = &cacheEntry{stage: filepath.Base(dir), key: key}
entries[groupID] = e
}
e.paths = append(e.paths, path)
@@ -358,6 +380,7 @@ func (c *Cache) Sweep(maxAge time.Duration, maxBytes int64) (SweepStats, error)
c.reportError(stage, "decode", key, err)
} else {
e.costMs = md.CostMs
+ e.description = md.Description
}
}
}
@@ -381,6 +404,7 @@ func (c *Cache) Sweep(maxAge time.Duration, maxBytes int64) (SweepStats, error)
survivors := make([]*cacheEntry, 0, len(entries))
for _, e := range entries {
if e.newestMtime.Before(cutoff) {
+ c.reportEvict(e.stage, e.key, e.description, "age", e.totalSize, e.costMs, e.newestMtime)
for _, p := range e.paths {
os.Remove(p)
}
@@ -391,41 +415,26 @@ func (c *Cache) Sweep(maxAge time.Duration, maxBytes int64) (SweepStats, error)
survivors = append(survivors, e)
}
- // Phase 2: cost-aware size eviction.
- var total int64
- for _, e := range survivors {
- total += e.totalSize
- }
- if total <= maxBytes {
- return stats, nil
- }
- now := time.Now()
- score := func(e *cacheEntry) float64 {
- if e.totalSize <= 0 {
- return 0
+ // Phase 2: cost-aware size eviction. Delegate ranking to
+ // cachepolicy; the returned indices line up with survivors.
+ policyEntries := make([]cachepolicy.Entry, len(survivors))
+ for i, e := range survivors {
+ policyEntries[i] = cachepolicy.Entry{
+ Stage: e.stage,
+ Description: e.description,
+ SizeBytes: e.totalSize,
+ CostMs: e.costMs,
+ Mtime: e.newestMtime,
}
- base := float64(e.costMs) / float64(e.totalSize)
- return base * recencyFactor(now.Sub(e.newestMtime))
}
- sort.Slice(survivors, func(i, j int) bool {
- si, sj := score(survivors[i]), score(survivors[j])
- if si != sj {
- return si < sj
- }
- // Tie-break: older first. Only matters when scores are
- // exactly equal (typically zero-cost legacy entries).
- return survivors[i].newestMtime.Before(survivors[j].newestMtime)
- })
- for _, e := range survivors {
- if total <= maxBytes {
- break
- }
+ for _, idx := range cachepolicy.FitToBudget(policyEntries, maxBytes, time.Now()) {
+ e := survivors[idx]
+ c.reportEvict(e.stage, e.key, e.description, "size", e.totalSize, e.costMs, e.newestMtime)
for _, p := range e.paths {
os.Remove(p)
}
stats.SizeEvicted++
stats.BytesFreed += e.totalSize
- total -= e.totalSize
}
return stats, nil
}
diff --git a/internal/diskcache/diskcache_test.go b/internal/diskcache/diskcache_test.go
index 2e9d1a9..efcdc7a 100644
--- a/internal/diskcache/diskcache_test.go
+++ b/internal/diskcache/diskcache_test.go
@@ -273,7 +273,7 @@ func TestRecordCostRoundTrip(t *testing.T) {
dir := t.TempDir()
c, _ := Open(dir)
c.Set("test", "k", payload{Name: "x"})
- c.RecordCost("test", "k", 1234*time.Millisecond)
+ c.RecordCost("test", "k", "round-trip", 1234*time.Millisecond)
metaPath := filepath.Join(dir, "test", "k.meta.json")
data, err := os.ReadFile(metaPath)
if err != nil {
@@ -304,9 +304,9 @@ func TestSweepCostAwareEviction(t *testing.T) {
// Roughly equal sizes (same payload shape). Costs differ by 1000x:
// the cheap one took 1 ms, the middle took 100 ms, the expensive
// one took 10000 ms.
- c.RecordCost("test", "cheap", 1*time.Millisecond)
- c.RecordCost("test", "midwa", 100*time.Millisecond)
- c.RecordCost("test", "spend", 10000*time.Millisecond)
+ c.RecordCost("test", "cheap", "cheap entry", 1*time.Millisecond)
+ c.RecordCost("test", "midwa", "midway entry", 100*time.Millisecond)
+ c.RecordCost("test", "spend", "expensive entry", 10000*time.Millisecond)
// Make 'cheap' the freshest by mtime so LRU alone would *keep* it
// over 'spend'. Cost-awareness must override.
@@ -392,8 +392,8 @@ func TestSweepRecencyDominatesEvictionAtEqualCost(t *testing.T) {
}
c.Set("test", "fresh", payload{Name: "fresh", Data: mkData(1)})
c.Set("test", "stale", payload{Name: "stale", Data: mkData(2)})
- c.RecordCost("test", "fresh", 500*time.Millisecond)
- c.RecordCost("test", "stale", 500*time.Millisecond)
+ c.RecordCost("test", "fresh", "fresh", 500*time.Millisecond)
+ c.RecordCost("test", "stale", "stale", 500*time.Millisecond)
now := time.Now()
// Make 'stale' a day old so recency factor halves it; 'fresh'
// stays roughly current.
@@ -419,6 +419,110 @@ func TestSweepRecencyDominatesEvictionAtEqualCost(t *testing.T) {
}
}
+// TestSweepLargeExpensiveBeatsSmallCheap: the user's stated rule —
+// 1KB that took 1s should NOT be kept over 1000KB that took 1000s.
+// With cost-per-byte alone the two would tie. The score formula's
+// sub-linear size penalty (sqrt) breaks the tie in favor of the
+// entry whose absolute cost is higher.
+func TestSweepLargeExpensiveBeatsSmallCheap(t *testing.T) {
+ dir := t.TempDir()
+ c, _ := Open(dir)
+ // Non-compressible data so on-disk size scales with logical size:
+ // hash chains produce essentially random bytes that zstd can't shrink.
+ mkRandomish := func(n int) []int {
+ d := make([]int, n)
+ x := uint64(n)*2654435761 + 0x9E3779B97F4A7C15
+ for i := range d {
+ x ^= x << 13
+ x ^= x >> 7
+ x ^= x << 17
+ d[i] = int(x)
+ }
+ return d
+ }
+ // Small entry: small payload, modest cost.
+ c.Set("test", "small", payload{Name: "small", Data: mkRandomish(64)})
+ // Large entry: ~1000× larger payload, ~1000× more cost.
+ c.Set("test", "large", payload{Name: "large", Data: mkRandomish(64000)})
+ c.RecordCost("test", "small", "small/cheap", 1*time.Second)
+ c.RecordCost("test", "large", "large/expensive", 1000*time.Second)
+
+ // Both entries equally fresh, so recency doesn't pick the winner.
+ now := time.Now().Add(-1 * time.Minute)
+ for _, k := range []string{"small", "large"} {
+ os.Chtimes(c.pathFor("test", k), now, now)
+ os.Chtimes(filepath.Join(dir, "test", k+".meta.json"), now, now)
+ }
+
+ // Budget that fits the large entry (with its meta) but not also
+ // the small one. Setting cap = large total + 1 forces sweep to
+ // drop a single entry — the small one if scoring is correct.
+ largeTotal := func() int64 {
+ di, _ := os.Stat(c.pathFor("test", "large"))
+ mi, _ := os.Stat(filepath.Join(dir, "test", "large.meta.json"))
+ return di.Size() + mi.Size()
+ }()
+ cap := largeTotal + 1
+
+ if _, err := c.Sweep(7*24*time.Hour, cap); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(c.pathFor("test", "small")); !os.IsNotExist(err) {
+ t.Error("small/cheap entry should have been evicted: a 1000× cheaper entry must lose to a 1000× more expensive one even when it's smaller")
+ }
+ if _, err := os.Stat(c.pathFor("test", "large")); err != nil {
+ t.Error("large/expensive entry should have survived")
+ }
+}
+
+// TestSweepHugeExpensiveBeatsTinyCheap: a fresh 5KB / 0.5s Parse
+// entry must NOT outrank a fresh 500KB / 60s Load entry. Without the
+// size floor, the sqrt denominator's penalty against the large entry
+// would invert the ranking even though the Load is 120× more
+// expensive in absolute terms.
+func TestSweepHugeExpensiveBeatsTinyCheap(t *testing.T) {
+ dir := t.TempDir()
+ c, _ := Open(dir)
+ mkRandomish := func(n int) []int {
+ d := make([]int, n)
+ x := uint64(n)*2654435761 + 0x9E3779B97F4A7C15
+ for i := range d {
+ x ^= x << 13
+ x ^= x >> 7
+ x ^= x << 17
+ d[i] = int(x)
+ }
+ return d
+ }
+ c.Set("test", "tiny", payload{Name: "tiny", Data: mkRandomish(64)})
+ c.Set("test", "huge", payload{Name: "huge", Data: mkRandomish(64000)})
+ c.RecordCost("test", "tiny", "tiny/cheap", 500*time.Millisecond)
+ c.RecordCost("test", "huge", "huge/expensive", 60*time.Second)
+
+ now := time.Now().Add(-1 * time.Minute)
+ for _, k := range []string{"tiny", "huge"} {
+ os.Chtimes(c.pathFor("test", k), now, now)
+ os.Chtimes(filepath.Join(dir, "test", k+".meta.json"), now, now)
+ }
+
+ hugeTotal := func() int64 {
+ di, _ := os.Stat(c.pathFor("test", "huge"))
+ mi, _ := os.Stat(filepath.Join(dir, "test", "huge.meta.json"))
+ return di.Size() + mi.Size()
+ }()
+ cap := hugeTotal + 1
+
+ if _, err := c.Sweep(7*24*time.Hour, cap); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(c.pathFor("test", "tiny")); !os.IsNotExist(err) {
+ t.Error("tiny/cheap should have been evicted: a 120× cheaper entry must lose to a 120× more expensive one even though it's smaller")
+ }
+ if _, err := os.Stat(c.pathFor("test", "huge")); err != nil {
+ t.Error("huge/expensive should have survived")
+ }
+}
+
// TestSweepFallbackToLRUWhenNoCosts: when no entries have recorded
// costs (legacy / pre-cost-tracking entries), eviction falls back to
// oldest-mtime-first, matching the previous LRU behavior.
diff --git a/internal/export3mf/bambu.go b/internal/export3mf/bambu.go
index c62208c..6fef400 100644
--- a/internal/export3mf/bambu.go
+++ b/internal/export3mf/bambu.go
@@ -92,7 +92,10 @@ func exportBambu(
creationDate := time.Now().UTC().Format("2006-01-02")
mainModel := buildBambuMainModel(applicationTag, creationDate, objID, buildID, componentID, buildItemID, buildTransform)
- objectModel := buildObjectModel(model, assignments, subObjID)
+ // Bambu is single-object today (no Split support yet); inner
+ // object id stays 1, matching what buildBambuMainModel's outer
+ // component references (objectid="1").
+ objectModel := buildObjectModel(model, assignments, subObjID, 1)
modelSettings := buildBambuModelSettings(model)
projectSettings, err := buildBambuProjectSettings(printer, nozzle, machineProfile, filamentProfile, paletteRGB, opts.LayerHeight, variants)
if err != nil {
diff --git a/internal/export3mf/export3mf.go b/internal/export3mf/export3mf.go
index 66c60eb..76be62e 100644
--- a/internal/export3mf/export3mf.go
+++ b/internal/export3mf/export3mf.go
@@ -11,6 +11,7 @@ import (
"strings"
"github.com/rtwfroody/ditherforge/internal/loader"
+ "github.com/rtwfroody/ditherforge/internal/plog"
)
// MaxFilaments is the maximum number of palette colors supported by 3MF export.
@@ -91,17 +92,59 @@ func Export(model *loader.LoadedModel, assignments []int32, outputPath string, p
return fmt.Errorf("%s: %w", printer.ID, err)
}
- objUUID := newUUID()
- instUUID := newUUID()
- buildUUID := newUUID()
-
+ // Single global tx/ty/tz centres the laid-out model on the bed.
+ // In the Split case, both halves are already laid out side-by-side
+ // in bed coords; one global translation centers the whole assembly.
minX, maxX, minY, maxY, minZ := meshExtents(model)
tx := plateX - float64(minX+maxX)/2
ty := plateY - float64(minY+maxY)/2
tz := -float64(minZ)
transform := fmt.Sprintf("1 0 0 0 1 0 0 0 1 %.4f %.4f %.4f", tx, ty, tz)
- objectRels := ` `
+ // Build a uniform list of parts. Single-mesh exports have one
+ // part (the whole model); Split exports have one part per
+ // FaceMeshIdx group. Each part becomes a top-level
+ // at id 2+i with a build-item placement.
+ var parts []*part
+ if mp := splitModelByMesh(model, assignments); mp != nil {
+ for i, p := range mp {
+ plog.Printf(" Export 3MF: part %d — %d verts, %d faces", i, len(p.Vertices), len(p.Faces))
+ parts = append(parts, &part{
+ objUUID: newUUID(),
+ instUUID: newUUID(),
+ compUUID: newUUID(),
+ objectID: 2 + i,
+ innerPath: fmt.Sprintf("/3D/Objects/object_%d.model", i+1),
+ innerRel: fmt.Sprintf("rel-%d", i+1),
+ verts: p.Vertices,
+ faces: p.Faces,
+ assigns: p.Assignments,
+ })
+ }
+ } else {
+ parts = []*part{{
+ objUUID: newUUID(),
+ instUUID: newUUID(),
+ compUUID: newUUID(),
+ objectID: 2,
+ innerPath: "/3D/Objects/object_1.model",
+ innerRel: "rel-1",
+ verts: model.Vertices,
+ faces: model.Faces,
+ assigns: assignments,
+ }}
+ }
+
+ buildUUID := newUUID()
+
+ // objectRels lists every inner .model file as a relationship.
+ var orelsB strings.Builder
+ orelsB.WriteString(``)
+ for _, p := range parts {
+ fmt.Fprintf(&orelsB, ` `, p.innerPath, p.innerRel)
+ }
+ orelsB.WriteString(` `)
+ objectRels := orelsB.String()
// Attribute ditherforge via standard 3MF metadata. We intentionally do NOT
// prefix Application with "BambuStudio-" / "OrcaSlicer-": doing so sets
@@ -113,18 +156,36 @@ func Export(model *loader.LoadedModel, assignments []int32, outputPath string, p
}
applicationTag := fmt.Sprintf("ditherforge-%s", appVersion)
- mainModel := fmt.Sprintf(``+
- ``+
- `%s `+
- `ditherforge `+
- `ditherforge output `+
- ``+
- ` `+
- ` `+
- ` `+
- ` `, applicationTag, objUUID, newUUID(), transform, buildUUID, instUUID)
+ var mb strings.Builder
+ mb.WriteString(``)
+ mb.WriteString(``)
+ fmt.Fprintf(&mb, `%s `, applicationTag)
+ mb.WriteString(`ditherforge `)
+ mb.WriteString(`ditherforge output `)
+ mb.WriteString(``)
+ for _, p := range parts {
+ // The component references the inner file's object by its id.
+ // Each inner .model file declares
+ // (see buildObjectModel call below) — using the same number
+ // for the inner object as the outer keeps every object id in
+ // the 3MF unique, which is what Bambu Studio's importer keys
+ // deduplication on. Reusing inner objectid="1" across multiple
+ // inner files (the previous behavior) caused the importer to
+ // collapse all parts into a single visual object even though
+ // each inner file held distinct geometry.
+ fmt.Fprintf(&mb, ``, p.objectID, p.objUUID)
+ fmt.Fprintf(&mb, ` `, p.innerPath, p.objectID, p.compUUID, transform)
+ mb.WriteString(` `)
+ }
+ mb.WriteString(` `)
+ fmt.Fprintf(&mb, ``, buildUUID)
+ for _, p := range parts {
+ fmt.Fprintf(&mb, ` `, p.objectID, p.instUUID)
+ }
+ mb.WriteString(` `)
+ mainModel := mb.String()
- modelSettings := buildModelSettings(model)
+ modelSettings := buildModelSettingsParts(parts, len(model.Faces))
f, err := os.Create(outputPath)
if err != nil {
@@ -170,8 +231,17 @@ func Export(model *loader.LoadedModel, assignments []int32, outputPath string, p
if err := writeEntry("3D/_rels/3dmodel.model.rels", objectRels); err != nil {
return err
}
- if err := writeEntry("3D/Objects/object_1.model", buildObjectModel(model, assignments, newUUID())); err != nil {
- return err
+ for _, p := range parts {
+ // Strip the leading "/" so the zip entry path matches the
+ // 3MF convention.
+ entryName := p.innerPath
+ if len(entryName) > 0 && entryName[0] == '/' {
+ entryName = entryName[1:]
+ }
+ partModel := &loader.LoadedModel{Vertices: p.verts, Faces: p.faces}
+ if err := writeEntry(entryName, buildObjectModel(partModel, p.assigns, newUUID(), p.objectID)); err != nil {
+ return err
+ }
}
if err := writeEntry("Metadata/model_settings.config", modelSettings); err != nil {
return err
@@ -198,10 +268,87 @@ func Export(model *loader.LoadedModel, assignments []int32, outputPath string, p
return nil
}
-// buildObjectModel writes the inner /3D/Objects/object_1.model with vertices,
+// part is one top-level in the 3MF output. Single-mesh
+// exports have one part; Split-aware multi-part exports have one per
+// FaceMeshIdx group. The fields capture the UUID + ID + path
+// scaffolding plus the geometry/assignments slices.
+type part struct {
+ objUUID string
+ instUUID string
+ compUUID string
+ objectID int
+ innerPath string
+ innerRel string
+ verts [][3]float32
+ faces [][3]uint32
+ assigns []int32
+}
+
+// splitPart is one mesh extracted from a multi-mesh LoadedModel via
+// FaceMeshIdx. Each part has a self-contained vertex table (only
+// vertices referenced by the part's faces) with remapped face
+// indices. Used by the Split-aware export path to emit one
+// `` entry per FaceMeshIdx group.
+type splitPart struct {
+ Vertices [][3]float32
+ Faces [][3]uint32
+ Assignments []int32
+}
+
+// splitModelByMesh partitions a LoadedModel into per-FaceMeshIdx
+// parts, with each part's vertex table compacted to only the
+// vertices its faces reference. Returns nil for single-mesh models
+// (NumMeshes <= 1) so the caller can take the unchanged
+// single-object export path.
+func splitModelByMesh(model *loader.LoadedModel, assignments []int32) []*splitPart {
+ if model.NumMeshes <= 1 || len(model.FaceMeshIdx) != len(model.Faces) {
+ return nil
+ }
+ parts := make([]*splitPart, model.NumMeshes)
+ for i := range parts {
+ parts[i] = &splitPart{}
+ }
+ // Per-part: source-vertex-index → part-local index.
+ vertMap := make([]map[uint32]uint32, model.NumMeshes)
+ for i := range vertMap {
+ vertMap[i] = make(map[uint32]uint32)
+ }
+ for fi, f := range model.Faces {
+ m := int(model.FaceMeshIdx[fi])
+ if m < 0 || m >= model.NumMeshes {
+ continue
+ }
+ var newF [3]uint32
+ for k, vi := range f {
+ localIdx, ok := vertMap[m][vi]
+ if !ok {
+ localIdx = uint32(len(parts[m].Vertices))
+ parts[m].Vertices = append(parts[m].Vertices, model.Vertices[vi])
+ vertMap[m][vi] = localIdx
+ }
+ newF[k] = localIdx
+ }
+ parts[m].Faces = append(parts[m].Faces, newF)
+ if assignments != nil && fi < len(assignments) {
+ parts[m].Assignments = append(parts[m].Assignments, assignments[fi])
+ }
+ }
+ return parts
+}
+
+// buildObjectModel writes the inner /3D/Objects/object_N.model with vertices,
// triangles, and paint_color assignments. Shared by the generic and Bambu
// export paths; they differ only in how objUUID is sourced.
-func buildObjectModel(model *loader.LoadedModel, assignments []int32, objUUID string) string {
+//
+// objectID is the the inner mesh declares. Multi-mesh
+// exports (Split) need each inner file's object id to be unique
+// across the whole 3MF — Bambu Studio's importer keys deduplication
+// on inner object id, so two inner files both using id=1 collapse
+// into a single visual object even though they live at different
+// paths and contain different meshes. Single-mesh exports can pass
+// any positive integer; the outer build references it by the same
+// number.
+func buildObjectModel(model *loader.LoadedModel, assignments []int32, objUUID string, objectID int) string {
var sb strings.Builder
sb.WriteString(``)
@@ -212,7 +359,7 @@ func buildObjectModel(model *loader.LoadedModel, assignments []int32, objUUID st
sb.WriteString(` requiredextensions="p">`)
sb.WriteString(`1 `)
sb.WriteString(``)
- fmt.Fprintf(&sb, ``, objUUID)
+ fmt.Fprintf(&sb, ``, objectID, objUUID)
sb.WriteString(``)
for _, v := range model.Vertices {
@@ -317,16 +464,27 @@ func buildProjectSettings(printer *Printer, nozzle *Nozzle, machineProfile map[s
return string(b), nil
}
-func buildModelSettings(model *loader.LoadedModel) string {
+func buildModelSettingsParts(parts []*part, totalFaces int) string {
var sb strings.Builder
sb.WriteString(``)
- sb.WriteString(``)
- sb.WriteString(` `)
- sb.WriteString(` `)
- fmt.Fprintf(&sb, ` `, len(model.Faces))
- sb.WriteString(``)
- sb.WriteString(` `)
- sb.WriteString(` `)
- sb.WriteString(` `)
+ sb.WriteString(``)
+ for i, p := range parts {
+ fmt.Fprintf(&sb, ``, p.objectID)
+ // Name distinguishes halves so the slicer's UI shows them
+ // separately. Single-mesh exports stay "ditherforge_output".
+ name := "ditherforge_output"
+ if len(parts) > 1 {
+ name = fmt.Sprintf("ditherforge_output_part%d", i+1)
+ }
+ fmt.Fprintf(&sb, ` `, name)
+ sb.WriteString(` `)
+ fmt.Fprintf(&sb, ` `, len(p.faces))
+ sb.WriteString(``)
+ sb.WriteString(` `)
+ sb.WriteString(` `)
+ sb.WriteString(` `)
+ }
+ sb.WriteString(` `)
+ _ = totalFaces
return sb.String()
}
diff --git a/internal/export3mf/split_test.go b/internal/export3mf/split_test.go
new file mode 100644
index 0000000..a4dc29a
--- /dev/null
+++ b/internal/export3mf/split_test.go
@@ -0,0 +1,106 @@
+package export3mf
+
+import (
+ "testing"
+
+ "github.com/rtwfroody/ditherforge/internal/loader"
+)
+
+// TestSplitModelByMesh_SingleMeshReturnsNil — when NumMeshes is 0
+// or 1 (the unsplit path), splitModelByMesh returns nil so the
+// caller takes the unchanged single-object export path.
+func TestSplitModelByMesh_SingleMeshReturnsNil(t *testing.T) {
+ model := &loader.LoadedModel{
+ Vertices: [][3]float32{{0, 0, 0}, {1, 0, 0}, {0, 1, 0}},
+ Faces: [][3]uint32{{0, 1, 2}},
+ NumMeshes: 1,
+ }
+ if got := splitModelByMesh(model, []int32{0}); got != nil {
+ t.Errorf("got %d parts for single-mesh model, want nil", len(got))
+ }
+
+ model.NumMeshes = 0
+ if got := splitModelByMesh(model, []int32{0}); got != nil {
+ t.Errorf("got %d parts for NumMeshes=0, want nil", len(got))
+ }
+}
+
+// TestSplitModelByMesh_PartitionsAndCompactsVertices — two meshes
+// referenced by FaceMeshIdx produce two parts, each with a compacted
+// vertex table and remapped face indices. Verifies the load-bearing
+// "vertex table is per-part" contract.
+func TestSplitModelByMesh_PartitionsAndCompactsVertices(t *testing.T) {
+ // 6 vertices: 0-2 used by mesh 0, 3-5 used by mesh 1.
+ // 2 faces: face 0 in mesh 0, face 1 in mesh 1.
+ model := &loader.LoadedModel{
+ Vertices: [][3]float32{
+ {0, 0, 0}, {1, 0, 0}, {0, 1, 0}, // mesh 0 verts
+ {10, 0, 0}, {11, 0, 0}, {10, 1, 0}, // mesh 1 verts
+ },
+ Faces: [][3]uint32{{0, 1, 2}, {3, 4, 5}},
+ FaceMeshIdx: []int32{0, 1},
+ NumMeshes: 2,
+ }
+ assignments := []int32{7, 9}
+ parts := splitModelByMesh(model, assignments)
+ if len(parts) != 2 {
+ t.Fatalf("got %d parts, want 2", len(parts))
+ }
+ // Each part has 3 vertices and 1 face.
+ for i, p := range parts {
+ if len(p.Vertices) != 3 {
+ t.Errorf("part %d: %d vertices, want 3", i, len(p.Vertices))
+ }
+ if len(p.Faces) != 1 {
+ t.Errorf("part %d: %d faces, want 1", i, len(p.Faces))
+ }
+ if len(p.Assignments) != 1 {
+ t.Errorf("part %d: %d assignments, want 1", i, len(p.Assignments))
+ }
+ // Face indices remapped to part-local: 0, 1, 2.
+ f := p.Faces[0]
+ if f[0] != 0 || f[1] != 1 || f[2] != 2 {
+ t.Errorf("part %d face %v: indices not compacted to {0,1,2}", i, f)
+ }
+ }
+ // Mesh-0 vertices match the first 3 of the source.
+ if parts[0].Vertices[0] != model.Vertices[0] {
+ t.Errorf("part 0 first vertex %v, want %v", parts[0].Vertices[0], model.Vertices[0])
+ }
+ // Mesh-1 vertices match indices 3-5.
+ if parts[1].Vertices[0] != model.Vertices[3] {
+ t.Errorf("part 1 first vertex %v, want %v", parts[1].Vertices[0], model.Vertices[3])
+ }
+ // Assignments preserved per-face.
+ if parts[0].Assignments[0] != 7 || parts[1].Assignments[0] != 9 {
+ t.Errorf("assignments not preserved: parts[0]=%v parts[1]=%v", parts[0].Assignments, parts[1].Assignments)
+ }
+}
+
+// TestSplitModelByMesh_SharedVerticesAreDuplicated — when a vertex
+// is referenced by faces from different meshes, each part gets its
+// own copy. This is the contract that makes per-part ``
+// emission self-contained.
+func TestSplitModelByMesh_SharedVerticesAreDuplicated(t *testing.T) {
+ model := &loader.LoadedModel{
+ Vertices: [][3]float32{
+ {0, 0, 0}, {1, 0, 0}, {0, 1, 0}, {1, 1, 0},
+ },
+ // Two faces sharing vertex 1; one in mesh 0, one in mesh 1.
+ Faces: [][3]uint32{{0, 1, 2}, {1, 3, 2}},
+ FaceMeshIdx: []int32{0, 1},
+ NumMeshes: 2,
+ }
+ parts := splitModelByMesh(model, nil)
+ if len(parts) != 2 {
+ t.Fatalf("got %d parts, want 2", len(parts))
+ }
+ // Vertex 1 (1,0,0) and vertex 2 (0,1,0) are referenced by both
+ // meshes; each part gets its own copy.
+ if len(parts[0].Vertices) != 3 {
+ t.Errorf("part 0: %d vertices, want 3 (verts 0,1,2 from mesh 0)", len(parts[0].Vertices))
+ }
+ if len(parts[1].Vertices) != 3 {
+ t.Errorf("part 1: %d vertices, want 3 (verts 1,3,2 from mesh 1)", len(parts[1].Vertices))
+ }
+}
diff --git a/internal/loader/persist.go b/internal/loader/persist.go
index c08b616..5f53880 100644
--- a/internal/loader/persist.go
+++ b/internal/loader/persist.go
@@ -25,8 +25,18 @@ type modelOnDisk struct {
NumMeshes int
}
-// GobEncode lets gob serialize a LoadedModel.
+// GobEncode lets gob serialize a LoadedModel. nil receivers encode
+// as an empty model so a nil *LoadedModel inside an array (e.g. an
+// uninitialised splitOutput.Halves slot in the disabled-passthrough
+// path) round-trips without panicking.
func (m *LoadedModel) GobEncode() ([]byte, error) {
+ if m == nil {
+ var out bytes.Buffer
+ if err := gob.NewEncoder(&out).Encode(modelOnDisk{}); err != nil {
+ return nil, err
+ }
+ return out.Bytes(), nil
+ }
od := modelOnDisk{
Vertices: m.Vertices,
Faces: m.Faces,
diff --git a/internal/pipeline/PIPELINE.md b/internal/pipeline/PIPELINE.md
index f436e04..d5fb045 100644
--- a/internal/pipeline/PIPELINE.md
+++ b/internal/pipeline/PIPELINE.md
@@ -212,5 +212,5 @@ Active `.tmp-*` files from in-flight write goroutines are skipped within the age
## Lifecycle
-- `runStageCached` (`stepcache.go`) is the canonical wrapper every stage uses. On hit it emits a UI marker and a console log line (`"Loading: cache hit (disk, 312ms)"`). On miss it times the body via `time.Since` and async-writes the meta sidecar via `RecordCost`.
+- `runStage` (`run.go`) is the generic helper every per-run stage method uses. It memoizes the body's output into `pipelineRun` and threads the value through `runStageCached` (`stepcache.go`), which on a hit emits a UI marker and a console log line (`"Loading: cache hit (disk, 312ms)"`) and on a miss times the body and async-writes the meta sidecar via `RecordCost`.
- All disk writes are tracked in a `sync.WaitGroup` on `StageCache`. `App.shutdown` calls `WaitForDiskWrites` (with a 30-second timeout) so big payloads aren't killed mid-rename when the user closes the window.
diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go
index 7a4f3a5..711ac4b 100644
--- a/internal/pipeline/pipeline.go
+++ b/internal/pipeline/pipeline.go
@@ -19,6 +19,7 @@ import (
"github.com/rtwfroody/ditherforge/internal/export3mf"
"github.com/rtwfroody/ditherforge/internal/loader"
"github.com/rtwfroody/ditherforge/internal/palette"
+ "github.com/rtwfroody/ditherforge/internal/plog"
"github.com/rtwfroody/ditherforge/internal/progress"
"github.com/rtwfroody/ditherforge/internal/voxel"
)
@@ -57,6 +58,24 @@ type Options struct {
AlphaWrap bool // enable CGAL Alpha_wrap_3 post-load mesh cleanup
AlphaWrapAlpha float32 // mm; 0 = auto (5 × NozzleDiameter)
AlphaWrapOffset float32 // mm; 0 = auto (alpha / 30)
+ Split SplitSettings `json:"Split,omitempty"`
+}
+
+// SplitSettings controls the optional Split stage that cuts a model
+// into two halves with peg/pocket connectors and lays them out
+// side-by-side on the bed. The zero value disables the stage; the
+// pipeline runs bit-identically to the pre-Split path. See
+// docs/SPLIT.md for the architecture.
+type SplitSettings struct {
+ Enabled bool
+ Axis int // 0=X, 1=Y, 2=Z
+ Offset float64 // model-space, along Axis
+ ConnectorStyle string // "none", "pegs", "dowels"
+ ConnectorCount int // 0 = auto, 1..3 explicit
+ ConnectorDiamMM float64
+ ConnectorDepthMM float64
+ ClearanceMM float64
+ GapMM float64
}
// Sticker defines a PNG image to apply onto the voxelized mesh surface.
@@ -80,7 +99,14 @@ type WarpPin struct {
// Callbacks groups optional callbacks for a pipeline run.
type Callbacks struct {
- OnInputMesh func(*MeshData, float32, float32) // mesh, preview scale, native extent mm
+ // OnInputMesh receives:
+ // mesh — the preview-format mesh data
+ // previewScale — multiply by this to convert pipeline coords back to preview coords
+ // nativeExtentMM — native max bounding-box extent in mm
+ // bboxMin, bboxMax — original-mesh-coord bbox (in mm, post-scale, post-normalizeZ).
+ // Used by the Split Settings panel to size the
+ // offset slider per axis.
+ OnInputMesh func(mesh *MeshData, previewScale, nativeExtentMM float32, bboxMin, bboxMax [3]float32)
// OnStickerOverlay is fired when stickers are placed on a mesh
// distinct from the input mesh — i.e. the alpha-wrap surface. The
// overlay should be rendered on top of the input mesh, biased
@@ -100,6 +126,7 @@ type Callbacks struct {
var stageNames = map[StageID]string{
StageParse: "Parsing",
StageLoad: "Loading",
+ StageSplit: "Splitting",
StageVoxelize: "Voxelizing",
StageSticker: "Applying stickers",
StageDecimate: "Decimating",
@@ -165,7 +192,7 @@ func RunCached(ctx context.Context, cache *StageCache, opts Options, cb *Callbac
}
// Extract callbacks, using safe defaults for nil.
- var onInputMesh func(*MeshData, float32, float32)
+ var onInputMesh func(*MeshData, float32, float32, [3]float32, [3]float32)
var onStickerOverlay func(*MeshData, float32)
var onPalette func([][3]uint8, []string)
var onWarning func(string)
@@ -238,7 +265,25 @@ func RunCached(ctx context.Context, cache *StageCache, opts Options, cb *Callbac
mesh = attachStickerOverlay(mesh, bakedDecals)
}
mesh = scalePreviewMesh(mesh, lo.PreviewScale)
- onInputMesh(mesh, lo.PreviewScale, lo.ExtentMM)
+ // Compute the original-mesh-coord bbox (in mm, post-scale,
+ // post-normalizeZ). Used by the Split UI to size the offset
+ // slider per axis.
+ var bboxMin, bboxMax [3]float32
+ if len(lo.ColorModel.Vertices) > 0 {
+ bboxMin = lo.ColorModel.Vertices[0]
+ bboxMax = lo.ColorModel.Vertices[0]
+ for _, v := range lo.ColorModel.Vertices[1:] {
+ for i := 0; i < 3; i++ {
+ if v[i] < bboxMin[i] {
+ bboxMin[i] = v[i]
+ }
+ if v[i] > bboxMax[i] {
+ bboxMax[i] = v[i]
+ }
+ }
+ }
+ }
+ onInputMesh(mesh, lo.PreviewScale, lo.ExtentMM, bboxMin, bboxMax)
if onStickerOverlay != nil {
var overlay *MeshData
@@ -341,6 +386,13 @@ func Run(ctx context.Context, opts Options) (*PrepareResult, *Result, error) {
// call so the cache lookups hit.
// Returns the number of faces in the output.
func ExportFile(cache *StageCache, opts Options, outputPath string, exportOpts export3mf.Options) (int, error) {
+ // Stage outputs are written to disk asynchronously by runStage, and
+ // ExportFile reads them back from disk. After a fresh RunCached the
+ // writes may still be in flight (a 1M-face merge encode takes
+ // seconds). Block on them so the lookups below see the just-written
+ // blobs instead of reporting "pipeline has not been run yet".
+ cache.WaitForDiskWrites()
+
lo := cache.getLoad(opts)
po := cache.getPalette(opts)
mo := cache.getMerge(opts)
@@ -350,18 +402,27 @@ func ExportFile(cache *StageCache, opts Options, outputPath string, exportOpts e
outModel := buildOutputModel(lo.ColorModel, mo)
- fmt.Printf("Exporting %s...", outputPath)
+ plog.Printf("Exporting %s...", outputPath)
tExport := time.Now()
if err := export3mf.Export(outModel, mo.ShellAssignments, outputPath, po.Palette, exportOpts); err != nil {
return 0, fmt.Errorf("exporting 3MF: %w", err)
}
- fmt.Printf(" done in %.1fs\n", time.Since(tExport).Seconds())
+ plog.Printf("Exported in %.1fs", time.Since(tExport).Seconds())
return len(outModel.Faces), nil
}
// buildOutputModel constructs a LoadedModel from merge output, suitable for
// export or preview mesh building.
+//
+// When the merge output carries a per-face HalfIdx (Split was
+// enabled), the result's FaceMeshIdx is populated from it and
+// NumMeshes is set to 2. NO CURRENT CONSUMER READS THESE FIELDS —
+// the wiring is preparatory for the Phase 7 follow-up in
+// internal/export3mf, which will iterate per FaceMeshIdx group to
+// emit two `` entries. Until that lands, the export path
+// emits a single `` containing both halves with the
+// bed-layout gap between them.
func buildOutputModel(srcModel *loader.LoadedModel, mo *mergeOutput) *loader.LoadedModel {
placeholder := image.NewNRGBA(image.Rect(0, 0, 1, 1))
placeholder.SetNRGBA(0, 0, color.NRGBA{128, 128, 128, 255})
@@ -372,13 +433,22 @@ func buildOutputModel(srcModel *loader.LoadedModel, mo *mergeOutput) *loader.Loa
textures = []image.Image{placeholder}
}
- return &loader.LoadedModel{
+ out := &loader.LoadedModel{
Vertices: mo.ShellVerts,
Faces: mo.ShellFaces,
UVs: make([][2]float32, len(mo.ShellVerts)),
Textures: textures,
FaceTextureIdx: make([]int32, len(mo.ShellFaces)),
}
+ if mo.ShellHalfIdx != nil {
+ faceMeshIdx := make([]int32, len(mo.ShellHalfIdx))
+ for i, h := range mo.ShellHalfIdx {
+ faceMeshIdx[i] = int32(h)
+ }
+ out.FaceMeshIdx = faceMeshIdx
+ out.NumMeshes = 2
+ }
+ return out
}
// applyBaseColorOverride sets the base color for all untextured faces to the
@@ -452,44 +522,65 @@ func applyBaseColor(cache *StageCache, lo *loadOutput, opts Options) {
lo.appliedBaseColor = opts.BaseColor
}
-// floodFillTwoGrids runs flood fill separately for each grid and merges results.
+// floodFillTwoGrids runs flood fill separately for each (Grid,
+// HalfIdx) partition and merges results. Partitioning by HalfIdx is
+// load-bearing for the Split path: FloodFillPatches operates on
+// CellKey index-arithmetic adjacency, not spatial adjacency, so two
+// halves whose CellKey columns happen to be adjacent in index space
+// (which can happen when GapMM < cellSize) would otherwise have
+// patches bridging across the bed-layout gap. With this partition,
+// patches are guaranteed to live in exactly one (Grid, HalfIdx) pair.
func floodFillTwoGrids(ctx context.Context, cells []voxel.ActiveCell, assignments []int32, tracker progress.Tracker) (map[voxel.CellKey]int, int, error) {
- // Partition cells by grid.
- var cells0, cells1 []voxel.ActiveCell
- var assign0, assign1 []int32
- idx0 := make([]int, 0, len(cells))
- idx1 := make([]int, 0, len(cells))
+ // Up to 4 partitions: (Grid 0/1) × (HalfIdx 0/1). Empty groups are
+ // skipped; the unsplit path produces only HalfIdx=0 entries.
+ type partKey struct {
+ grid uint8
+ halfIdx uint8
+ }
+ parts := make(map[partKey]*struct {
+ cells []voxel.ActiveCell
+ assigns []int32
+ })
for i, c := range cells {
- if c.Grid == 0 {
- cells0 = append(cells0, c)
- assign0 = append(assign0, assignments[i])
- idx0 = append(idx0, i)
- } else {
- cells1 = append(cells1, c)
- assign1 = append(assign1, assignments[i])
- idx1 = append(idx1, i)
+ k := partKey{grid: c.Grid, halfIdx: c.HalfIdx}
+ p, ok := parts[k]
+ if !ok {
+ p = &struct {
+ cells []voxel.ActiveCell
+ assigns []int32
+ }{}
+ parts[k] = p
}
+ p.cells = append(p.cells, c)
+ p.assigns = append(p.assigns, assignments[i])
}
var counter atomic.Int64
- pm0, n0, err := voxel.FloodFillPatches(ctx, cells0, assign0, tracker, &counter)
- if err != nil {
- return nil, 0, err
- }
- pm1, n1, err := voxel.FloodFillPatches(ctx, cells1, assign1, tracker, &counter)
- if err != nil {
- return nil, 0, err
- }
-
- // Merge: offset grid-1 patch IDs by n0.
merged := make(map[voxel.CellKey]int, len(cells))
- for k, v := range pm0 {
- merged[k] = v
- }
- for k, v := range pm1 {
- merged[k] = v + n0
+ totalPatches := 0
+ // Iterate parts in a deterministic order so patch IDs are stable
+ // across runs (matters for cache stability on downstream stages).
+ order := []partKey{
+ {grid: 0, halfIdx: 0},
+ {grid: 0, halfIdx: 1},
+ {grid: 1, halfIdx: 0},
+ {grid: 1, halfIdx: 1},
+ }
+ for _, k := range order {
+ p, ok := parts[k]
+ if !ok {
+ continue
+ }
+ pm, n, err := voxel.FloodFillPatches(ctx, p.cells, p.assigns, tracker, &counter)
+ if err != nil {
+ return nil, 0, err
+ }
+ for ck, v := range pm {
+ merged[ck] = v + totalPatches
+ }
+ totalPatches += n
}
- return merged, n0 + n1, nil
+ return merged, totalPatches, nil
}
@@ -586,7 +677,7 @@ func normalizeZ(model *loader.LoadedModel) {
}
func printStats(assignments []int32, paletteRGB [][3]uint8) {
- fmt.Println(" Face counts per material:")
+ plog.Println(" Face counts per material:")
for i, p := range paletteRGB {
hexColor := fmt.Sprintf("#%02X%02X%02X", p[0], p[1], p[2])
count := 0
@@ -595,6 +686,6 @@ func printStats(assignments []int32, paletteRGB [][3]uint8) {
count++
}
}
- fmt.Printf(" [%d] %s: %d faces\n", i, hexColor, count)
+ plog.Printf(" [%d] %s: %d faces", i, hexColor, count)
}
}
diff --git a/internal/pipeline/run.go b/internal/pipeline/run.go
index a0ba167..348bd1c 100644
--- a/internal/pipeline/run.go
+++ b/internal/pipeline/run.go
@@ -14,7 +14,9 @@ import (
"github.com/rtwfroody/ditherforge/internal/export3mf"
"github.com/rtwfroody/ditherforge/internal/loader"
"github.com/rtwfroody/ditherforge/internal/palette"
+ "github.com/rtwfroody/ditherforge/internal/plog"
"github.com/rtwfroody/ditherforge/internal/progress"
+ "github.com/rtwfroody/ditherforge/internal/split"
"github.com/rtwfroody/ditherforge/internal/squarevoxel"
"github.com/rtwfroody/ditherforge/internal/voxel"
)
@@ -44,6 +46,7 @@ type pipelineRun struct {
// consumers within the same Run skip the cache lookup.
parse *loader.LoadedModel
load *loadOutput
+ split *splitOutput
decimate *decimateOutput
sticker *stickerOutput
voxelize *voxelizeOutput
@@ -62,45 +65,92 @@ func (r *pipelineRun) checkCancel() error {
return nil
}
+// runStage is the shared scaffold for every per-run stage method. The
+// per-method boilerplate (memoization slot, body invocation, cache
+// set, cache-hit fallback) is identical across stages and varies only
+// in the output type, the slot pointer, the StageID, and the body —
+// which this helper takes as parameters.
+//
+// Behavior:
+//
+// 1. If the slot already holds a value (this Run already produced or
+// decoded it), return immediately.
+// 2. Run the cache-aware wrapper. On a cache hit the body is skipped
+// and the slot stays nil; on a miss, the body produces the value,
+// stores it in the slot, and async-writes the encoded blob to the
+// disk cache.
+// 3. If the slot is still nil after the wrapper, the cache-hit path
+// ran — decode from the cache to populate the slot.
+//
+// The slot-then-cache-set ordering is load-bearing: a downstream call
+// to the typed getter (e.g. cache.getX) cannot return a value the
+// disk-write goroutine hasn't yet flushed. Memoizing into the slot
+// before kicking the async write ensures the same Run's downstream
+// consumers see the live pointer immediately.
+func runStage[T any](
+ r *pipelineRun,
+ stage StageID,
+ slot **T,
+ body func() (*T, error),
+) (*T, error) {
+ if *slot != nil {
+ return *slot, nil
+ }
+ err := runStageCached(r.cache, stage, r.opts, r.tracker, func() error {
+ out, err := body()
+ if err != nil {
+ return err
+ }
+ // Order is load-bearing: write the slot before kicking
+ // the async cache.set. Within-run consumers read the
+ // slot via pipelineRun memoization and would race the
+ // disk-write goroutine if we set the cache first.
+ *slot = out
+ r.cache.set(stage, r.opts, out)
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ if *slot == nil {
+ if v := r.cache.get(stage, r.opts); v != nil {
+ *slot = v.(*T)
+ }
+ }
+ return *slot, nil
+}
+
// ----- Stage methods -----
func (r *pipelineRun) Parse() (*loader.LoadedModel, error) {
- if r.parse != nil {
- return r.parse, nil
- }
- err := runStageCached(r.cache, StageParse, r.opts, r.tracker, func() error {
+ return runStage(r, StageParse, &r.parse, func() (*loader.LoadedModel, error) {
stage := progress.BeginStage(r.tracker, stageNames[StageParse], false, 0)
defer stage.Done()
- fmt.Printf("Parsing %s...", r.opts.Input)
+ plog.Printf("Parsing %s...", r.opts.Input)
t := time.Now()
loaded, err := loadModel(r.opts.Input, r.opts.ObjectIndex)
if err != nil {
- return fmt.Errorf("parsing %s: %w", filepath.Ext(r.opts.Input), err)
+ return nil, fmt.Errorf("parsing %s: %w", filepath.Ext(r.opts.Input), err)
}
- fmt.Printf(" %d vertices, %d faces in %.1fs\n",
+ plog.Printf(" Parsed: %d vertices, %d faces in %.1fs",
len(loaded.Vertices), len(loaded.Faces), time.Since(t).Seconds())
- r.cache.setParse(r.opts, loaded)
- return nil
+ return loaded, nil
})
- if err != nil {
- return nil, err
- }
- r.parse = r.cache.getParse(r.opts)
- return r.parse, nil
}
func (r *pipelineRun) Load() (*loadOutput, error) {
- if r.load != nil {
- return r.load, nil
- }
- err := runStageCached(r.cache, StageLoad, r.opts, r.tracker, func() error {
- stage := progress.BeginStage(r.tracker, stageNames[StageLoad], false, 0)
- defer stage.Done()
-
+ lo, err := runStage(r, StageLoad, &r.load, func() (*loadOutput, error) {
raw, err := r.Parse()
if err != nil {
- return err
+ return nil, err
}
+ label := stageNames[StageLoad]
+ if r.opts.AlphaWrap {
+ label += " (including alpha-wrap)"
+ }
+ stage := progress.BeginStage(r.tracker, label, false, 0)
+ defer stage.Done()
+
inputExt := strings.ToLower(filepath.Ext(r.opts.Input))
unitScale := unitScaleForExt(inputExt)
scale := unitScale * r.opts.Scale
@@ -119,10 +169,10 @@ func (r *pipelineRun) Load() (*loadOutput, error) {
normalizeZ(model)
ex := modelExtents(model)
- fmt.Printf(" Extent: %.1f x %.1f x %.1f mm\n", ex[0], ex[1], ex[2])
+ plog.Printf(" Extent: %.1f x %.1f x %.1f mm", ex[0], ex[1], ex[2])
if err := r.checkCancel(); err != nil {
- return err
+ return nil, err
}
nativeExtentMM := modelMaxExtent(model) * unitScale / totalScale
@@ -136,13 +186,13 @@ func (r *pipelineRun) Load() (*loadOutput, error) {
if offset <= 0 {
offset = alpha / 30
}
- fmt.Printf(" Alpha-wrap: alpha=%.3f mm, offset=%.3f mm...", alpha, offset)
+ plog.Printf(" Alpha-wrap: alpha=%.3f mm, offset=%.3f mm starting", alpha, offset)
tWrap := time.Now()
wrapped, werr := alphawrap.Wrap(model, alpha, offset)
if werr != nil {
- return fmt.Errorf("alpha-wrap: %w", werr)
+ return nil, fmt.Errorf("alpha-wrap: %w", werr)
}
- fmt.Printf(" %d vertices, %d faces in %.1fs\n",
+ plog.Printf(" Alpha-wrap: %d vertices, %d faces in %.1fs",
len(wrapped.Vertices), len(wrapped.Faces), time.Since(tWrap).Seconds())
geomModel = wrapped
}
@@ -153,81 +203,154 @@ func (r *pipelineRun) Load() (*loadOutput, error) {
geomExt := modelMaxExtent(geomModel)
inflateOffset := (geomExt - origExt) / 2
if inflateOffset > 1e-4 {
- fmt.Printf(" Inflating color-sample mesh by %.3f mm\n", inflateOffset)
+ plog.Printf(" Inflating color-sample mesh by %.3f mm", inflateOffset)
sampleModel = loader.InflateAlongNormals(model, inflateOffset)
}
}
- r.cache.setLoad(r.opts, &loadOutput{
+ return &loadOutput{
Model: geomModel,
ColorModel: model,
SampleModel: sampleModel,
InputMesh: buildInputMeshData(model),
PreviewScale: unitScale / totalScale,
ExtentMM: nativeExtentMM,
- })
- return nil
+ }, nil
})
if err != nil {
return nil, err
}
- r.load = r.cache.getLoad(r.opts)
// Apply base-color override on top of the (possibly cached)
// load output. Cheap and idempotent. On a fresh disk hit
// (lo.appliedBaseColor=="") this skips the parse cache lookup.
- applyBaseColor(r.cache, r.load, r.opts)
- return r.load, nil
+ applyBaseColor(r.cache, lo, r.opts)
+ return lo, nil
}
-func (r *pipelineRun) Decimate() (*decimateOutput, error) {
- if r.decimate != nil {
- return r.decimate, nil
+func (r *pipelineRun) Split() (*splitOutput, error) {
+ return runStage(r, StageSplit, &r.split, func() (*splitOutput, error) {
+ lo, err := r.Load()
+ if err != nil {
+ return nil, err
+ }
+ stage := progress.BeginStage(r.tracker, stageNames[StageSplit], false, 0)
+ defer stage.Done()
+
+ // Disabled-passthrough: emit the stage event so the UI shows
+ // "Splitting" ticking by, then return a marker output that
+ // downstream stages treat as "no split."
+ if !r.opts.Split.Enabled {
+ return &splitOutput{Enabled: false}, nil
+ }
+
+ // Split requires a watertight input; the design doc says the
+ // frontend forces AlphaWrap=true when Split is enabled.
+ // Surface the precondition violation here so the user sees a
+ // clear error rather than a downstream "non-manifold cut
+ // polygon" message from split.Cut.
+ if !r.opts.AlphaWrap {
+ return nil, fmt.Errorf("split: requires AlphaWrap=true (split.Cut needs a watertight input mesh; see docs/SPLIT.md)")
+ }
+
+ tSplit := time.Now()
+
+ // Translate Options.Split into split.Cut + split.Layout calls.
+ plane := split.AxisPlane(r.opts.Split.Axis, r.opts.Split.Offset)
+ conn := split.ConnectorSettings{
+ Style: parseConnectorStyle(r.opts.Split.ConnectorStyle),
+ Count: r.opts.Split.ConnectorCount,
+ DiamMM: r.opts.Split.ConnectorDiamMM,
+ DepthMM: r.opts.Split.ConnectorDepthMM,
+ ClearanceMM: r.opts.Split.ClearanceMM,
+ }
+ // Cut runs on lo.Model. The frontend forces AlphaWrap=true
+ // when Split is enabled (see docs/SPLIT.md "Watertight
+ // requirement"), so lo.Model is watertight under correct
+ // frontend wiring. If a caller bypasses that guard,
+ // split.Cut surfaces a clear error.
+ res, err := split.Cut(lo.Model, plane, conn)
+ if err != nil {
+ return nil, fmt.Errorf("split.Cut: %w", err)
+ }
+ xforms := split.Layout(res, r.opts.Split.GapMM)
+
+ plog.Printf(" Split: cut and laid out two halves in %.1fs (half 0: %d verts, %d faces; half 1: %d verts, %d faces)",
+ time.Since(tSplit).Seconds(),
+ len(res.Halves[0].Vertices), len(res.Halves[0].Faces),
+ len(res.Halves[1].Vertices), len(res.Halves[1].Faces))
+ return &splitOutput{
+ Enabled: true,
+ Halves: res.Halves,
+ Xform: xforms,
+ CutNormal: plane.Normal,
+ CutPlaneD: plane.D,
+ }, nil
+ })
+}
+
+// parseConnectorStyle converts the Options string into the typed
+// split.ConnectorStyle. Unknown values fall back to NoConnectors;
+// we trust the frontend to send valid strings.
+func parseConnectorStyle(s string) split.ConnectorStyle {
+ switch s {
+ case "pegs":
+ return split.Pegs
+ case "dowels":
+ return split.Dowels
+ default:
+ return split.NoConnectors
}
- err := runStageCached(r.cache, StageDecimate, r.opts, r.tracker, func() error {
+}
+
+func (r *pipelineRun) Decimate() (*decimateOutput, error) {
+ return runStage(r, StageDecimate, &r.decimate, func() (*decimateOutput, error) {
lo, err := r.Load()
if err != nil {
- return err
+ return nil, err
+ }
+ so, err := r.Split()
+ if err != nil {
+ return nil, err
}
- fmt.Println("Decimating...")
cellSize := r.opts.NozzleDiameter * squarevoxel.UpperCellScale
+
+ if so.Enabled {
+ // Use CountSurfaceCells on the unsplit lo.Model as the
+ // total target. Layout is rotation+translation, so the
+ // volume / surface area is preserved across halves;
+ // proportional per-half splitting lives inside
+ // DecimateHalves.
+ combinedTarget := squarevoxel.CountSurfaceCells(r.ctx, lo.Model, r.opts.NozzleDiameter, r.opts.LayerHeight)
+ halves, derr := squarevoxel.DecimateHalves(r.ctx, so.Halves, combinedTarget, cellSize, r.opts.NoSimplify, r.tracker)
+ if derr != nil {
+ return nil, fmt.Errorf("decimate (split): %w", derr)
+ }
+ return &decimateOutput{Halves: halves}, nil
+ }
+
targetCells := squarevoxel.CountSurfaceCells(r.ctx, lo.Model, r.opts.NozzleDiameter, r.opts.LayerHeight)
decimModel, derr := squarevoxel.DecimateMesh(r.ctx, lo.Model, targetCells, cellSize, r.opts.NoSimplify, r.tracker)
if derr != nil {
- return fmt.Errorf("decimate: %w", derr)
+ return nil, fmt.Errorf("decimate: %w", derr)
}
- r.cache.setDecimate(r.opts, &decimateOutput{DecimModel: decimModel})
- return nil
+ return &decimateOutput{DecimModel: decimModel}, nil
})
- if err != nil {
- return nil, err
- }
- r.decimate = r.cache.getDecimate(r.opts)
- return r.decimate, nil
}
func (r *pipelineRun) Sticker() (*stickerOutput, error) {
- if r.sticker != nil {
- return r.sticker, nil
- }
- err := runStageCached(r.cache, StageSticker, r.opts, r.tracker, func() error {
+ return runStage(r, StageSticker, &r.sticker, func() (*stickerOutput, error) {
lo, err := r.Load()
if err != nil {
- return err
+ return nil, err
}
return r.computeSticker(lo)
})
- if err != nil {
- return nil, err
- }
- r.sticker = r.cache.getSticker(r.opts)
- return r.sticker, nil
}
-func (r *pipelineRun) computeSticker(lo *loadOutput) error {
+func (r *pipelineRun) computeSticker(lo *loadOutput) (*stickerOutput, error) {
if len(r.opts.Stickers) == 0 {
progress.BeginStage(r.tracker, stageNames[StageSticker], false, 0).Done()
- r.cache.setSticker(r.opts, &stickerOutput{})
- return nil
+ return &stickerOutput{}, nil
}
var sourceModel *loader.LoadedModel
if r.opts.AlphaWrap {
@@ -261,17 +384,17 @@ func (r *pipelineRun) computeSticker(lo *loadOutput) error {
f, err := os.Open(s.ImagePath)
if err != nil {
- return fmt.Errorf("sticker %s: %w", s.ImagePath, err)
+ return nil, fmt.Errorf("sticker %s: %w", s.ImagePath, err)
}
img, _, err := image.Decode(f)
f.Close()
if err != nil {
- return fmt.Errorf("sticker %s: %w", s.ImagePath, err)
+ return nil, fmt.Errorf("sticker %s: %w", s.ImagePath, err)
}
bounds := img.Bounds()
if bounds.Dx() == 0 || bounds.Dy() == 0 {
- fmt.Printf(" Sticker %s: 0x0 image, skipping\n", s.ImagePath)
+ plog.Printf(" Sticker %s: 0x0 image, skipping", s.ImagePath)
stage.Progress(base + stickerUnits)
continue
}
@@ -281,7 +404,7 @@ func (r *pipelineRun) computeSticker(lo *loadOutput) error {
case "unfold":
seedTri := voxel.FindSeedTriangle(s.Center, model, si)
if seedTri < 0 {
- fmt.Printf(" Sticker %s: no triangle found near center, skipping\n", s.ImagePath)
+ plog.Printf(" Sticker %s: no triangle found near center, skipping", s.ImagePath)
stage.Progress(base + stickerUnits)
continue
}
@@ -289,23 +412,23 @@ func (r *pipelineRun) computeSticker(lo *loadOutput) error {
seedTri, s.Center, s.Normal, s.Up, s.Scale, s.Rotation, s.MaxAngle,
onProgress)
if err != nil {
- return err
+ return nil, err
}
case "projection":
decal, err = voxel.BuildStickerDecalProjection(r.ctx, model, img,
s.Center, s.Normal, s.Up, s.Scale, s.Rotation, onProgress)
if err != nil {
- return err
+ return nil, err
}
if len(decal.TriUVs) == 0 {
- fmt.Printf(" Sticker %s: no front-facing geometry within projection rect, skipping\n", s.ImagePath)
+ plog.Printf(" Sticker %s: no front-facing geometry within projection rect, skipping", s.ImagePath)
stage.Progress(base + stickerUnits)
continue
}
default:
- return fmt.Errorf("sticker %s: unknown mode %q", s.ImagePath, s.Mode)
+ return nil, fmt.Errorf("sticker %s: unknown mode %q", s.ImagePath, s.Mode)
}
- fmt.Printf(" Sticker %s: %d triangles covered\n", s.ImagePath, len(decal.TriUVs))
+ plog.Printf(" Sticker %s: %d triangles covered", s.ImagePath, len(decal.TriUVs))
if decal.LSCMResidual > 1e-5 && r.onWarning != nil {
r.onWarning(fmt.Sprintf(
"Sticker %q didn't unfold cleanly (residual %.1e). The mesh in this region has very-poor-quality triangles; the sticker may look distorted. Try alpha-wrap or a different placement.",
@@ -321,22 +444,22 @@ func (r *pipelineRun) computeSticker(lo *loadOutput) error {
FromAlphaWrap: r.opts.AlphaWrap,
}
so.si = si
- r.cache.setSticker(r.opts, so)
- return nil
+ return so, nil
}
func (r *pipelineRun) Voxelize() (*voxelizeOutput, error) {
- if r.voxelize != nil {
- return r.voxelize, nil
- }
- err := runStageCached(r.cache, StageVoxelize, r.opts, r.tracker, func() error {
+ return runStage(r, StageVoxelize, &r.voxelize, func() (*voxelizeOutput, error) {
lo, err := r.Load()
if err != nil {
- return err
+ return nil, err
}
so, err := r.Sticker()
if err != nil {
- return err
+ return nil, err
+ }
+ spo, err := r.Split()
+ if err != nil {
+ return nil, err
}
layer0Size := r.opts.NozzleDiameter * squarevoxel.Layer0CellScale
upperSize := r.opts.NozzleDiameter * squarevoxel.UpperCellScale
@@ -354,41 +477,39 @@ func (r *pipelineRun) Voxelize() (*voxelizeOutput, error) {
}
}
- fmt.Println("Voxelizing...")
+ var splitInfo *squarevoxel.SplitInfo
+ if spo.Enabled {
+ splitInfo = &squarevoxel.SplitInfo{
+ Halves: spo.Halves,
+ Xform: spo.Xform,
+ }
+ }
+
result, verr := squarevoxel.VoxelizeTwoGrids(r.ctx, lo.Model, sampleModel,
stickerModel, stickerSI,
- layer0Size, upperSize, layerH, r.tracker, so.Decals)
+ layer0Size, upperSize, layerH, r.tracker, so.Decals, splitInfo)
if verr != nil {
- return fmt.Errorf("voxelize: %w", verr)
+ return nil, fmt.Errorf("voxelize: %w", verr)
}
- r.cache.setVoxelize(r.opts, &voxelizeOutput{
+ return &voxelizeOutput{
Cells: result.Cells,
CellAssignMap: result.CellAssignMap,
MinV: result.MinV,
Layer0Size: layer0Size,
UpperSize: upperSize,
LayerH: layerH,
- })
- return nil
+ }, nil
})
- if err != nil {
- return nil, err
- }
- r.voxelize = r.cache.getVoxelize(r.opts)
- return r.voxelize, nil
}
func (r *pipelineRun) ColorAdjust() (*colorAdjustOutput, error) {
- if r.colorAdjust != nil {
- return r.colorAdjust, nil
- }
- err := runStageCached(r.cache, StageColorAdjust, r.opts, r.tracker, func() error {
- stage := progress.BeginStage(r.tracker, stageNames[StageColorAdjust], false, 0)
- defer stage.Done()
+ return runStage(r, StageColorAdjust, &r.colorAdjust, func() (*colorAdjustOutput, error) {
vo, err := r.Voxelize()
if err != nil {
- return err
+ return nil, err
}
+ stage := progress.BeginStage(r.tracker, stageNames[StageColorAdjust], false, 0)
+ defer stage.Done()
adj := voxel.ColorAdjustment{
Brightness: r.opts.Brightness,
Contrast: r.opts.Contrast,
@@ -397,109 +518,90 @@ func (r *pipelineRun) ColorAdjust() (*colorAdjustOutput, error) {
tAdj := time.Now()
cells, cerr := voxel.AdjustCellColors(r.ctx, vo.Cells, adj)
if cerr != nil {
- return cerr
+ return nil, cerr
}
if !adj.IsIdentity() {
- fmt.Printf(" Adjusted colors (B:%+.0f C:%+.0f S:%+.0f) in %.1fs\n",
+ plog.Printf(" Adjusted colors (B:%+.0f C:%+.0f S:%+.0f) in %.1fs",
r.opts.Brightness, r.opts.Contrast, r.opts.Saturation, time.Since(tAdj).Seconds())
}
- r.cache.setColorAdjust(r.opts, &colorAdjustOutput{Cells: cells})
- return nil
+ return &colorAdjustOutput{Cells: cells}, nil
})
- if err != nil {
- return nil, err
- }
- r.colorAdjust = r.cache.getColorAdjust(r.opts)
- return r.colorAdjust, nil
}
func (r *pipelineRun) ColorWarp() (*colorWarpOutput, error) {
- if r.colorWarp != nil {
- return r.colorWarp, nil
- }
- err := runStageCached(r.cache, StageColorWarp, r.opts, r.tracker, func() error {
- stage := progress.BeginStage(r.tracker, stageNames[StageColorWarp], false, 0)
- defer stage.Done()
+ return runStage(r, StageColorWarp, &r.colorWarp, func() (*colorWarpOutput, error) {
cao, err := r.ColorAdjust()
if err != nil {
- return err
+ return nil, err
}
+ stage := progress.BeginStage(r.tracker, stageNames[StageColorWarp], false, 0)
+ defer stage.Done()
if len(r.opts.WarpPins) == 0 {
- out := make([]voxel.ActiveCell, len(cao.Cells))
- copy(out, cao.Cells)
- r.cache.setColorWarp(r.opts, &colorWarpOutput{Cells: out})
- return nil
+ cells := make([]voxel.ActiveCell, len(cao.Cells))
+ copy(cells, cao.Cells)
+ return &colorWarpOutput{Cells: cells}, nil
}
pins := make([]voxel.ColorWarpPin, len(r.opts.WarpPins))
for i, p := range r.opts.WarpPins {
src, perr := palette.ParsePalette([]string{p.SourceHex})
if perr != nil {
- return fmt.Errorf("warp pin %d source: %w", i, perr)
+ return nil, fmt.Errorf("warp pin %d source: %w", i, perr)
}
tgt, perr := palette.ParsePalette([]string{p.TargetHex})
if perr != nil {
- return fmt.Errorf("warp pin %d target: %w", i, perr)
+ return nil, fmt.Errorf("warp pin %d target: %w", i, perr)
}
pins[i] = voxel.ColorWarpPin{Source: src[0], Target: tgt[0], Sigma: p.Sigma}
}
tWarp := time.Now()
cells, werr := voxel.WarpCellColors(r.ctx, cao.Cells, pins)
if werr != nil {
- return werr
+ return nil, werr
}
- fmt.Printf(" Warped colors (%d pins) in %.1fs\n", len(pins), time.Since(tWarp).Seconds())
- r.cache.setColorWarp(r.opts, &colorWarpOutput{Cells: cells})
- return nil
+ plog.Printf(" Warped colors (%d pins) in %.1fs", len(pins), time.Since(tWarp).Seconds())
+ return &colorWarpOutput{Cells: cells}, nil
})
- if err != nil {
- return nil, err
- }
- r.colorWarp = r.cache.getColorWarp(r.opts)
- return r.colorWarp, nil
}
func (r *pipelineRun) Palette() (*paletteOutput, error) {
- if r.palette != nil {
- return r.palette, nil
- }
- err := runStageCached(r.cache, StagePalette, r.opts, r.tracker, func() error {
- stage := progress.BeginStage(r.tracker, stageNames[StagePalette], false, 0)
- defer stage.Done()
-
+ return runStage(r, StagePalette, &r.palette, func() (*paletteOutput, error) {
cwo, err := r.ColorWarp()
if err != nil {
- return err
+ return nil, err
}
+ stage := progress.BeginStage(r.tracker, stageNames[StagePalette], false, 0)
+ defer stage.Done()
+
pcfg, perr := buildPaletteConfig(r.opts)
if perr != nil {
- return perr
+ return nil, perr
}
if pcfg.NumColors > export3mf.MaxFilaments {
- return fmt.Errorf("palette has %d colors but max supported is %d", pcfg.NumColors, export3mf.MaxFilaments)
+ return nil, fmt.Errorf("palette has %d colors but max supported is %d", pcfg.NumColors, export3mf.MaxFilaments)
}
cells := make([]voxel.ActiveCell, len(cwo.Cells))
copy(cells, cwo.Cells)
ditherMode := r.opts.Dither
pal, palLabels, palDisplay, perr := voxel.ResolvePalette(r.ctx, cells, pcfg, ditherMode != "none", r.tracker)
if perr != nil {
- return perr
+ return nil, perr
}
if palDisplay != "" {
- fmt.Printf("%s\n", palDisplay)
+ plog.Printf("%s", palDisplay)
}
if len(pal) == 0 {
- return fmt.Errorf("no palette colors")
+ return nil, fmt.Errorf("no palette colors")
}
if r.opts.ColorSnap > 0 {
if serr := voxel.SnapColors(r.ctx, cells, pal, r.opts.ColorSnap); serr != nil {
- return serr
+ return nil, serr
}
- fmt.Printf(" Snapped cell colors toward palette by delta E %.1f\n", r.opts.ColorSnap)
+ plog.Printf(" Snapped cell colors toward palette by delta E %.1f", r.opts.ColorSnap)
}
if len(pcfg.Locked) == 0 && len(pal) > 1 {
assigns, aerr := voxel.AssignColors(r.ctx, cells, pal)
if aerr != nil {
- return aerr
+ return nil, aerr
}
counts := make([]int, len(pal))
for _, a := range assigns {
@@ -516,32 +618,23 @@ func (r *pipelineRun) Palette() (*paletteOutput, error) {
palLabels[0], palLabels[best] = palLabels[best], palLabels[0]
}
}
- r.cache.setPalette(r.opts, &paletteOutput{
+ return &paletteOutput{
Palette: pal,
PaletteLabels: palLabels,
Cells: cells,
- })
- return nil
+ }, nil
})
- if err != nil {
- return nil, err
- }
- r.palette = r.cache.getPalette(r.opts)
- return r.palette, nil
}
func (r *pipelineRun) Dither() (*ditherOutput, error) {
- if r.dither != nil {
- return r.dither, nil
- }
- err := runStageCached(r.cache, StageDither, r.opts, r.tracker, func() error {
+ return runStage(r, StageDither, &r.dither, func() (*ditherOutput, error) {
po, err := r.Palette()
if err != nil {
- return err
+ return nil, err
}
vo, err := r.Voxelize()
if err != nil {
- return err
+ return nil, err
}
stage := progress.BeginStage(r.tracker, stageNames[StageDither], true, 2*len(po.Cells))
defer stage.Done()
@@ -559,9 +652,9 @@ func (r *pipelineRun) Dither() (*ditherOutput, error) {
assignments, derr = voxel.AssignColors(r.ctx, cells, pal)
}
if derr != nil {
- return derr
+ return nil, derr
}
- fmt.Printf(" Dithered (%s) %d cells in %.1fs\n", ditherMode, len(cells), time.Since(tDither).Seconds())
+ plog.Printf(" Dithered (%s) %d cells in %.1fs", ditherMode, len(cells), time.Since(tDither).Seconds())
counts := make([]int, len(pal))
for _, a := range assignments {
counts[a]++
@@ -574,51 +667,46 @@ func (r *pipelineRun) Dither() (*ditherOutput, error) {
sort.Slice(order, func(a, b int) bool { return counts[order[a]] > counts[order[b]] })
for _, i := range order {
c := pal[i]
- fmt.Printf(" #%02X%02X%02X: %d cells (%.1f%%)\n", c[0], c[1], c[2], counts[i], 100*float64(counts[i])/float64(total))
+ plog.Printf(" #%02X%02X%02X: %d cells (%.1f%%)", c[0], c[1], c[2], counts[i], 100*float64(counts[i])/float64(total))
}
tFlood := time.Now()
patchMap, numPatches, ferr := floodFillTwoGrids(r.ctx, cells, assignments, r.tracker)
if ferr != nil {
- return ferr
+ return nil, ferr
}
- fmt.Printf(" Flood fill: %d patches in %.1fs\n", numPatches, time.Since(tFlood).Seconds())
+ plog.Printf(" Flood fill: %d patches in %.1fs", numPatches, time.Since(tFlood).Seconds())
patchAssignment := make([]int32, numPatches)
for i, c := range cells {
k := voxel.CellKey{Grid: c.Grid, Col: c.Col, Row: c.Row, Layer: c.Layer}
pid := patchMap[k]
patchAssignment[pid] = assignments[i]
}
- r.cache.setDither(r.opts, &ditherOutput{
+ return &ditherOutput{
Assignments: assignments,
PatchMap: patchMap,
NumPatches: numPatches,
PatchAssignment: patchAssignment,
- })
- return nil
+ }, nil
})
- if err != nil {
- return nil, err
- }
- r.dither = r.cache.getDither(r.opts)
- return r.dither, nil
}
func (r *pipelineRun) Clip() (*clipOutput, error) {
- if r.clip != nil {
- return r.clip, nil
- }
- err := runStageCached(r.cache, StageClip, r.opts, r.tracker, func() error {
+ return runStage(r, StageClip, &r.clip, func() (*clipOutput, error) {
do, err := r.Dither()
if err != nil {
- return err
+ return nil, err
}
deco, err := r.Decimate()
if err != nil {
- return err
+ return nil, err
}
vo, err := r.Voxelize()
if err != nil {
- return err
+ return nil, err
+ }
+ spo, err := r.Split()
+ if err != nil {
+ return nil, err
}
tClip := time.Now()
cfg := voxel.TwoGridConfig{
@@ -628,63 +716,180 @@ func (r *pipelineRun) Clip() (*clipOutput, error) {
LayerH: vo.LayerH,
SeamZ: vo.MinV[2] + 0.5*vo.LayerH,
}
+
+ if spo.Enabled {
+ out, err := r.clipSplit(do, deco, vo, cfg)
+ if err != nil {
+ return nil, err
+ }
+ plog.Printf(" Clipped (split): %d faces in %.1fs", len(out.ShellFaces), time.Since(tClip).Seconds())
+ return out, nil
+ }
+
shellVerts, shellFaces, shellAssignments, cerr := voxel.ClipMeshByPatchesTwoGrid(
r.ctx, deco.DecimModel, do.PatchMap, do.PatchAssignment, cfg, r.tracker)
if cerr != nil {
- return fmt.Errorf("clip: %w", cerr)
+ return nil, fmt.Errorf("clip: %w", cerr)
}
- fmt.Printf(" Clipped mesh: %d faces in %.1fs\n", len(shellFaces), time.Since(tClip).Seconds())
- fmt.Printf(" After clip: %s\n", voxel.CheckWatertight(shellFaces))
- r.cache.setClip(r.opts, &clipOutput{
+ plog.Printf(" Clipped mesh: %d faces in %.1fs", len(shellFaces), time.Since(tClip).Seconds())
+ plog.Printf(" After clip: %s", voxel.CheckWatertight(shellFaces))
+ return &clipOutput{
ShellVerts: shellVerts,
ShellFaces: shellFaces,
ShellAssignments: shellAssignments,
- })
- return nil
+ }, nil
})
- if err != nil {
- return nil, err
+}
+
+// clipSplit runs ClipMeshByPatchesTwoGrid once per half, with each
+// half's PatchMap subset, and concatenates the per-half outputs into
+// a single clipOutput with ShellHalfIdx tagging each face.
+//
+// Patches are connected components of cells with the same color
+// assignment. Cells in different halves are spatially separated by
+// the bed-layout gap and never share neighbors, so flood-fill never
+// joins them: every patch belongs to exactly one half. We rely on
+// that to filter PatchMap by cell.HalfIdx without losing
+// connectivity.
+func (r *pipelineRun) clipSplit(do *ditherOutput, deco *decimateOutput, vo *voxelizeOutput, cfg voxel.TwoGridConfig) (*clipOutput, error) {
+ var halfPatchMaps [2]map[voxel.CellKey]int
+ for h := 0; h < 2; h++ {
+ halfPatchMaps[h] = make(map[voxel.CellKey]int)
+ }
+ for ck, patchIdx := range do.PatchMap {
+ cellIdx, ok := vo.CellAssignMap[ck]
+ if !ok {
+ continue
+ }
+ h := vo.Cells[cellIdx].HalfIdx
+ halfPatchMaps[h][ck] = patchIdx
}
- r.clip = r.cache.getClip(r.opts)
- return r.clip, nil
+
+ var combinedVerts [][3]float32
+ var combinedFaces [][3]uint32
+ var combinedAssign []int32
+ var combinedHalfIdx []byte
+ for h := 0; h < 2; h++ {
+ // Empty-half short-circuit: with no cells/patches in this
+ // half, ClipMeshByPatchesTwoGrid would still iterate the
+ // half's mesh and clip it against the SeamZ plane only,
+ // producing geometry tagged with a default assignment that
+ // no caller validated. Skip the call.
+ if deco.Halves[h] == nil || len(deco.Halves[h].Faces) == 0 || len(halfPatchMaps[h]) == 0 {
+ continue
+ }
+ verts, faces, assigns, err := voxel.ClipMeshByPatchesTwoGrid(
+ r.ctx, deco.Halves[h], halfPatchMaps[h], do.PatchAssignment, cfg, r.tracker)
+ if err != nil {
+ return nil, fmt.Errorf("clip half %d: %w", h, err)
+ }
+ offset := uint32(len(combinedVerts))
+ combinedVerts = append(combinedVerts, verts...)
+ for _, f := range faces {
+ combinedFaces = append(combinedFaces, [3]uint32{f[0] + offset, f[1] + offset, f[2] + offset})
+ combinedHalfIdx = append(combinedHalfIdx, byte(h))
+ }
+ combinedAssign = append(combinedAssign, assigns...)
+ }
+ return &clipOutput{
+ ShellVerts: combinedVerts,
+ ShellFaces: combinedFaces,
+ ShellAssignments: combinedAssign,
+ ShellHalfIdx: combinedHalfIdx,
+ }, nil
}
func (r *pipelineRun) Merge() (*mergeOutput, error) {
- if r.merge != nil {
- return r.merge, nil
- }
- err := runStageCached(r.cache, StageMerge, r.opts, r.tracker, func() error {
+ return runStage(r, StageMerge, &r.merge, func() (*mergeOutput, error) {
co, err := r.Clip()
if err != nil {
- return err
+ return nil, err
}
shellVerts := co.ShellVerts
shellFaces := co.ShellFaces
shellAssignments := co.ShellAssignments
+ shellHalfIdx := co.ShellHalfIdx
if !r.opts.NoMerge {
tMerge := time.Now()
before := len(shellFaces)
var merr error
- shellFaces, shellAssignments, merr = voxel.MergeCoplanarTriangles(r.ctx, shellVerts, shellFaces, shellAssignments, r.tracker)
+ if shellHalfIdx != nil {
+ // Per-half merge: halves don't share vertices (clipSplit
+ // offsets each half's vertex indices), so
+ // MergeCoplanarTriangles run on the full mesh would not
+ // merge across halves anyway, but the per-face HalfIdx
+ // parallel array needs to track the merged face count.
+ // Simplest: extract per-half slices, merge each, then
+ // concatenate. Faces in clipSplit's output are already
+ // grouped by half (h=0 then h=1), so the slice ranges
+ // are contiguous.
+ shellFaces, shellAssignments, shellHalfIdx, merr =
+ mergeSplitFaces(r.ctx, shellVerts, shellFaces, shellAssignments, shellHalfIdx, r.tracker)
+ } else {
+ shellFaces, shellAssignments, merr = voxel.MergeCoplanarTriangles(r.ctx, shellVerts, shellFaces, shellAssignments, r.tracker)
+ }
if merr != nil {
- return fmt.Errorf("merge: %w", merr)
+ return nil, fmt.Errorf("merge: %w", merr)
}
- fmt.Printf(" Merged shell: %d -> %d faces in %.1fs\n", before, len(shellFaces), time.Since(tMerge).Seconds())
+ plog.Printf(" Merged shell: %d -> %d faces in %.1fs", before, len(shellFaces), time.Since(tMerge).Seconds())
} else {
progress.BeginStage(r.tracker, stageNames[StageMerge], false, 0).Done()
}
- fmt.Printf(" Output mesh: %s\n", voxel.CheckWatertight(shellFaces))
- r.cache.setMerge(r.opts, &mergeOutput{
+ plog.Printf(" Output mesh: %s", voxel.CheckWatertight(shellFaces))
+ return &mergeOutput{
ShellVerts: shellVerts,
ShellFaces: shellFaces,
ShellAssignments: shellAssignments,
- })
- return nil
+ ShellHalfIdx: shellHalfIdx,
+ }, nil
})
+}
+
+// mergeSplitFaces runs MergeCoplanarTriangles independently on each
+// half's contiguous face slice (clipSplit groups faces by half), then
+// concatenates results and rebuilds the per-face HalfIdx array.
+// Vertices are shared across halves by index space (clipSplit emits a
+// unified vertex table with offsets), but faces never reference
+// across halves, so per-half merge is correct.
+func mergeSplitFaces(
+ ctx context.Context,
+ verts [][3]float32,
+ faces [][3]uint32,
+ assignments []int32,
+ halfIdx []byte,
+ tracker progress.Tracker,
+) ([][3]uint32, []int32, []byte, error) {
+ // Find the boundary between half 0 and half 1.
+ boundary := len(faces)
+ for i, h := range halfIdx {
+ if h == 1 {
+ boundary = i
+ break
+ }
+ }
+ h0Faces := faces[:boundary]
+ h1Faces := faces[boundary:]
+ h0Assign := assignments[:boundary]
+ h1Assign := assignments[boundary:]
+
+ mergedH0Faces, mergedH0Assign, err := voxel.MergeCoplanarTriangles(ctx, verts, h0Faces, h0Assign, tracker)
if err != nil {
- return nil, err
+ return nil, nil, nil, fmt.Errorf("merge half 0: %w", err)
+ }
+ mergedH1Faces, mergedH1Assign, err := voxel.MergeCoplanarTriangles(ctx, verts, h1Faces, h1Assign, tracker)
+ if err != nil {
+ return nil, nil, nil, fmt.Errorf("merge half 1: %w", err)
+ }
+
+ combinedFaces := append(mergedH0Faces, mergedH1Faces...)
+ combinedAssign := append(mergedH0Assign, mergedH1Assign...)
+ combinedHalfIdx := make([]byte, 0, len(combinedFaces))
+ for range mergedH0Faces {
+ combinedHalfIdx = append(combinedHalfIdx, 0)
+ }
+ for range mergedH1Faces {
+ combinedHalfIdx = append(combinedHalfIdx, 1)
}
- r.merge = r.cache.getMerge(r.opts)
- return r.merge, nil
+ return combinedFaces, combinedAssign, combinedHalfIdx, nil
}
diff --git a/internal/pipeline/split_test.go b/internal/pipeline/split_test.go
new file mode 100644
index 0000000..08a5ed5
--- /dev/null
+++ b/internal/pipeline/split_test.go
@@ -0,0 +1,283 @@
+package pipeline
+
+import (
+ "context"
+ "testing"
+
+ "github.com/rtwfroody/ditherforge/internal/progress"
+ "github.com/rtwfroody/ditherforge/internal/voxel"
+)
+
+// TestSplitDisabled_NoCacheKeyChange — when Split.Enabled is false,
+// changing other Split fields should not affect any stage's cache
+// key. This preserves cache-hit equivalence with the pre-Split path
+// — anyone toggling Split sliders while Split is off must not
+// invalidate downstream caches.
+func TestSplitDisabled_NoCacheKeyChange(t *testing.T) {
+ c := NewStageCache()
+ path := makeFakeInput(t)
+ base := Options{Input: path, Scale: 1, NozzleDiameter: 0.4, LayerHeight: 0.2, Dither: "none"}
+ // Split off. Toggling other fields should be invisible.
+ tweaked := base
+ tweaked.Split.Axis = 1
+ tweaked.Split.Offset = 5.0
+ tweaked.Split.ConnectorStyle = "pegs"
+ tweaked.Split.ConnectorCount = 2
+ tweaked.Split.ConnectorDiamMM = 5
+ tweaked.Split.ConnectorDepthMM = 6
+ tweaked.Split.ClearanceMM = 0.15
+ tweaked.Split.GapMM = 5
+ for s := StageLoad; s < numStages; s++ {
+ if c.stageKey(s, base) != c.stageKey(s, tweaked) {
+ t.Errorf("stage %d key changed when Split is off but other Split fields changed", s)
+ }
+ }
+}
+
+// TestSplitEnabled_CacheKeyCascade — flipping Split.Enabled changes
+// StageSplit's key and every downstream stage's key, but not
+// StageLoad or StageParse (Split is downstream of Load).
+func TestSplitEnabled_CacheKeyCascade(t *testing.T) {
+ c := NewStageCache()
+ path := makeFakeInput(t)
+ off := Options{Input: path, Scale: 1, NozzleDiameter: 0.4, LayerHeight: 0.2, Dither: "none"}
+ on := off
+ on.Split.Enabled = true
+ on.Split.Axis = 2
+ on.Split.Offset = 5
+ on.Split.ConnectorStyle = "dowels"
+ on.Split.ConnectorDiamMM = 4
+ on.Split.ConnectorDepthMM = 5
+ on.Split.ClearanceMM = 0.15
+ on.Split.GapMM = 5
+
+ // Parse and Load should NOT change.
+ if c.stageKey(StageParse, off) != c.stageKey(StageParse, on) {
+ t.Error("StageParse key changed when Split toggled — cascade leaked upward")
+ }
+ if c.stageKey(StageLoad, off) != c.stageKey(StageLoad, on) {
+ t.Error("StageLoad key changed when Split toggled — cascade leaked upward")
+ }
+ // Split through Merge SHOULD change.
+ for s := StageSplit; s < numStages; s++ {
+ if c.stageKey(s, off) == c.stageKey(s, on) {
+ t.Errorf("stage %d key did not change when Split toggled (cascade broken)", s)
+ }
+ }
+}
+
+// TestSplitEnabled_FieldCascade — when Split is enabled, changing
+// each Split field individually changes downstream cache keys. Maps
+// to "any settings change rebuilds the appropriate caches."
+func TestSplitEnabled_FieldCascade(t *testing.T) {
+ c := NewStageCache()
+ path := makeFakeInput(t)
+ base := Options{Input: path, Scale: 1, NozzleDiameter: 0.4, LayerHeight: 0.2, Dither: "none"}
+ base.Split.Enabled = true
+ base.Split.Axis = 2
+ base.Split.Offset = 5
+ base.Split.ConnectorStyle = "dowels"
+ base.Split.GapMM = 5
+ cases := []struct {
+ name string
+ mut func(o *Options)
+ }{
+ {"Axis", func(o *Options) { o.Split.Axis = 0 }},
+ {"Offset", func(o *Options) { o.Split.Offset = 6 }},
+ {"ConnectorStyle", func(o *Options) { o.Split.ConnectorStyle = "pegs" }},
+ {"ConnectorCount", func(o *Options) { o.Split.ConnectorCount = 2 }},
+ {"ConnectorDiamMM", func(o *Options) { o.Split.ConnectorDiamMM = 5 }},
+ {"ConnectorDepthMM", func(o *Options) { o.Split.ConnectorDepthMM = 6 }},
+ {"ClearanceMM", func(o *Options) { o.Split.ClearanceMM = 0.2 }},
+ {"GapMM", func(o *Options) { o.Split.GapMM = 8 }},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ alt := base
+ tc.mut(&alt)
+ if c.stageKey(StageSplit, base) == c.stageKey(StageSplit, alt) {
+ t.Errorf("StageSplit key did not change when %s changed", tc.name)
+ }
+ })
+ }
+}
+
+// TestMergeSplitFaces_PerHalfMergeAndConcat — mergeSplitFaces should
+// run MergeCoplanarTriangles once per half (faces are grouped by
+// halfIdx in clipSplit's output) and concatenate, preserving the
+// per-face HalfIdx parallel array on the result. Constructs a tiny
+// shell with two coplanar quads on each half (4 triangles per half,
+// expecting merge to reduce to 2 triangles per half).
+func TestMergeSplitFaces_PerHalfMergeAndConcat(t *testing.T) {
+ // Half 0: a quad in the z=0 plane at x=[0,1], y=[0,2], split into
+ // 2 triangles, with a coplanar adjacent quad at y=[2,4]. Result:
+ // 4 triangles that merge into 2 (since coplanar same-color groups
+ // re-triangulate to a quad = 2 tris).
+ verts := [][3]float32{
+ // half 0 (8 verts)
+ {0, 0, 0}, {1, 0, 0}, {1, 2, 0}, {0, 2, 0},
+ {0, 4, 0}, {1, 4, 0}, // extends y to 4
+ {0, 0, 0}, {0, 0, 0}, // padding to keep counts simple
+ // half 1 (8 verts shifted in x)
+ {10, 0, 0}, {11, 0, 0}, {11, 2, 0}, {10, 2, 0},
+ {10, 4, 0}, {11, 4, 0},
+ {0, 0, 0}, {0, 0, 0},
+ }
+ // 4 tris per half (2 quads each = 4 tris).
+ faces := [][3]uint32{
+ // Half 0 quads (z=0 plane)
+ {0, 1, 2}, {0, 2, 3}, // first quad
+ {3, 2, 5}, {3, 5, 4}, // second quad sharing edge 2-3 (now indices 3-2 reversed) -> using 3 and 5 for share
+ // Half 1
+ {8, 9, 10}, {8, 10, 11},
+ {11, 10, 13}, {11, 13, 12},
+ }
+ assignments := []int32{0, 0, 0, 0, 1, 1, 1, 1}
+ halfIdx := []byte{0, 0, 0, 0, 1, 1, 1, 1}
+ outFaces, outAssign, outHalf, err := mergeSplitFaces(
+ context.Background(), verts, faces, assignments, halfIdx, progress.NullTracker{},
+ )
+ if err != nil {
+ t.Fatalf("mergeSplitFaces: %v", err)
+ }
+ if len(outFaces) != len(outAssign) || len(outFaces) != len(outHalf) {
+ t.Errorf("output array lengths differ: faces=%d assign=%d half=%d", len(outFaces), len(outAssign), len(outHalf))
+ }
+ // Count faces per half. Should be > 0 and grouped (all 0s come
+ // before all 1s after concat).
+ var n0, n1 int
+ transitionSeen := false
+ for i, h := range outHalf {
+ if h == 0 {
+ if transitionSeen {
+ t.Errorf("face %d has HalfIdx=0 but a HalfIdx=1 came earlier — concat order broken", i)
+ }
+ n0++
+ } else if h == 1 {
+ transitionSeen = true
+ n1++
+ } else {
+ t.Errorf("face %d has unexpected HalfIdx=%d", i, h)
+ }
+ }
+ if n0 == 0 || n1 == 0 {
+ t.Errorf("expected both halves represented; got n0=%d n1=%d", n0, n1)
+ }
+}
+
+// TestClipSplit_FiltersPatchMapByHalf — verifies that clipSplit's
+// patch-map filtering routes each cell's patch into the correct
+// per-half map. Doesn't run the full clip; it's a unit test of the
+// filter logic, which is the load-bearing correctness step.
+func TestClipSplit_FiltersPatchMapByHalf(t *testing.T) {
+ // Two cells: one in half 0, one in half 1.
+ cells := []voxel.ActiveCell{
+ {Grid: 0, Col: 0, Row: 0, Layer: 0, HalfIdx: 0},
+ {Grid: 0, Col: 5, Row: 0, Layer: 0, HalfIdx: 1},
+ }
+ cellAssignMap := map[voxel.CellKey]int{
+ {Grid: 0, Col: 0, Row: 0, Layer: 0}: 0,
+ {Grid: 0, Col: 5, Row: 0, Layer: 0}: 1,
+ }
+ patchMap := map[voxel.CellKey]int{
+ {Grid: 0, Col: 0, Row: 0, Layer: 0}: 0,
+ {Grid: 0, Col: 5, Row: 0, Layer: 0}: 1,
+ }
+
+ var halfPatchMaps [2]map[voxel.CellKey]int
+ for h := 0; h < 2; h++ {
+ halfPatchMaps[h] = make(map[voxel.CellKey]int)
+ }
+ for ck, patchIdx := range patchMap {
+ cellIdx, ok := cellAssignMap[ck]
+ if !ok {
+ continue
+ }
+ h := cells[cellIdx].HalfIdx
+ halfPatchMaps[h][ck] = patchIdx
+ }
+ if len(halfPatchMaps[0]) != 1 || len(halfPatchMaps[1]) != 1 {
+ t.Errorf("expected 1 cell per half map, got h0=%d h1=%d", len(halfPatchMaps[0]), len(halfPatchMaps[1]))
+ }
+ if _, ok := halfPatchMaps[0][voxel.CellKey{Grid: 0, Col: 0, Row: 0, Layer: 0}]; !ok {
+ t.Errorf("half 0 map missing the col=0 cell")
+ }
+ if _, ok := halfPatchMaps[1][voxel.CellKey{Grid: 0, Col: 5, Row: 0, Layer: 0}]; !ok {
+ t.Errorf("half 1 map missing the col=5 cell")
+ }
+}
+
+// TestFloodFillTwoGrids_PartitionsByHalfIdx — the load-bearing
+// safety check from the phase-7 review: flood fill must NOT bridge
+// two halves whose CellKey columns happen to be index-adjacent.
+// floodFillTwoGrids partitions by (Grid, HalfIdx); cells in
+// different halves can never end up in the same patch even if their
+// column indices are 1 apart and they share a color assignment.
+func TestFloodFillTwoGrids_PartitionsByHalfIdx(t *testing.T) {
+ cells := []voxel.ActiveCell{
+ // Two halves with column-adjacent cells, both assigned color 0.
+ {Grid: 0, Col: 0, Row: 0, Layer: 0, HalfIdx: 0},
+ {Grid: 0, Col: 1, Row: 0, Layer: 0, HalfIdx: 1},
+ }
+ assignments := []int32{0, 0}
+ patchMap, numPatches, err := floodFillTwoGrids(context.Background(), cells, assignments, progress.NullTracker{})
+ if err != nil {
+ t.Fatalf("floodFillTwoGrids: %v", err)
+ }
+ if numPatches != 2 {
+ t.Errorf("got %d patches, want 2 (one per half — adjacent columns must NOT bridge)", numPatches)
+ }
+ p0 := patchMap[voxel.CellKey{Grid: 0, Col: 0, Row: 0, Layer: 0}]
+ p1 := patchMap[voxel.CellKey{Grid: 0, Col: 1, Row: 0, Layer: 0}]
+ if p0 == p1 {
+ t.Errorf("cells in different halves got the same patch ID %d (would silently merge in Clip)", p0)
+ }
+}
+
+// TestFloodFillTwoGrids_PartitionsByGridAndHalf — broader smoke
+// test: each (Grid, HalfIdx) combo gets its own patch space.
+func TestFloodFillTwoGrids_PartitionsByGridAndHalf(t *testing.T) {
+ cells := []voxel.ActiveCell{
+ {Grid: 0, Col: 0, Row: 0, Layer: 0, HalfIdx: 0},
+ {Grid: 0, Col: 0, Row: 0, Layer: 0, HalfIdx: 1},
+ {Grid: 1, Col: 0, Row: 0, Layer: 1, HalfIdx: 0},
+ {Grid: 1, Col: 0, Row: 0, Layer: 1, HalfIdx: 1},
+ }
+ assignments := []int32{0, 0, 0, 0}
+ _, numPatches, err := floodFillTwoGrids(context.Background(), cells, assignments, progress.NullTracker{})
+ if err != nil {
+ t.Fatalf("floodFillTwoGrids: %v", err)
+ }
+ if numPatches != 4 {
+ t.Errorf("got %d patches, want 4 (one per (Grid, HalfIdx) combo)", numPatches)
+ }
+}
+
+// TestStageSplitDescription — the eviction-log description includes
+// the connector style and offset so operators can identify entries.
+func TestStageSplitDescription(t *testing.T) {
+ off := Options{Input: "/tmp/foo.glb"}
+ if got := stageDescription(StageSplit, off); got != "Split: foo.glb (off)" {
+ t.Errorf("disabled description = %q, want 'Split: foo.glb (off)'", got)
+ }
+ on := off
+ on.Split.Enabled = true
+ on.Split.Axis = 2
+ on.Split.Offset = 5
+ on.Split.ConnectorStyle = "pegs"
+ on.Split.ConnectorCount = 2
+ got := stageDescription(StageSplit, on)
+ want := "Split: foo.glb (Z@5.0mm, pegs ×2)"
+ if got != want {
+ t.Errorf("enabled description = %q, want %q", got, want)
+ }
+ // Auto-count (ConnectorCount=0) renders as "×auto" so a zero
+ // in the log isn't mistaken for "no connectors."
+ auto := on
+ auto.Split.ConnectorCount = 0
+ got = stageDescription(StageSplit, auto)
+ want = "Split: foo.glb (Z@5.0mm, pegs ×auto)"
+ if got != want {
+ t.Errorf("auto-count description = %q, want %q", got, want)
+ }
+}
diff --git a/internal/pipeline/splitpreview.go b/internal/pipeline/splitpreview.go
new file mode 100644
index 0000000..a278ac5
--- /dev/null
+++ b/internal/pipeline/splitpreview.go
@@ -0,0 +1,147 @@
+package pipeline
+
+import (
+ "fmt"
+)
+
+// SplitPreviewResult describes the cut plane and the model's
+// projected silhouette in plane-local coordinates so the frontend
+// can draw a translucent rectangle through the model. All vector
+// fields are in original-mesh world coordinates (the same frame as
+// the input mesh emitted via OnInputMesh) — NOT in bed coordinates.
+type SplitPreviewResult struct {
+ // Origin is the centre of the model's silhouette projected onto
+ // the cut plane. Lies on the plane (Normal·Origin == Offset)
+ // but is offset within the plane to the projected centroid so
+ // the rendered quad is symmetric over the model.
+ Origin [3]float32 `json:"origin"`
+ // Normal is the plane's unit normal, in original-mesh coords.
+ Normal [3]float32 `json:"normal"`
+ // U and V are the orthonormal basis vectors that span the plane,
+ // chosen with U × V = Normal so the frontend can build a
+ // right-handed orientation for the quad.
+ U [3]float32 `json:"u"`
+ V [3]float32 `json:"v"`
+ // HalfExtentU and HalfExtentV are half-side lengths of the
+ // plane-local bounding rectangle that contains the model's
+ // projection onto (U, V). The quad rendered by the frontend has
+ // world-space corners
+ // Origin ± HalfExtentU·U ± HalfExtentV·V.
+ HalfExtentU float32 `json:"halfExtentU"`
+ HalfExtentV float32 `json:"halfExtentV"`
+}
+
+// ComputeSplitPreview returns the cut-plane geometry for the model
+// cached under `opts`. Reads the StageLoad output from the cache;
+// returns an error if it's not present (e.g., the user hasn't run
+// the pipeline since startup).
+//
+// Goroutine-safe: only reads from the cache (which itself reads from
+// disk via atomic rename) and Vertices (immutable after StageLoad
+// completes). Safe to call from any goroutine, including
+// concurrently with a pipeline run.
+func ComputeSplitPreview(cache *StageCache, opts Options, s SplitSettings) (*SplitPreviewResult, error) {
+ lo := cache.getLoad(opts)
+ if lo == nil || lo.Model == nil {
+ return nil, fmt.Errorf("split preview: model load output not in cache (run the pipeline first)")
+ }
+ return computeSplitPreviewFromVertices(lo.Model.Vertices, s)
+}
+
+// computeSplitPreviewFromVertices is the pure, cache-independent
+// core of ComputeSplitPreview. Tests inject vertices directly here
+// rather than go through the cache, which would require disk-backed
+// scaffolding for round-tripping a synthetic loadOutput.
+//
+// The result is centered on the model's projected bbox along (U, V)
+// so the quad is symmetric over the model — convenient for
+// frontend rendering. The plane's actual world position is at
+// `Offset` along the chosen `Axis`; the centering only translates
+// the quad within the plane (U·Normal = V·Normal = 0), not the
+// plane equation Normal·p = Offset.
+//
+// Mirrored client-side in frontend/src/App.svelte's
+// `cutPlanePreview` $derived to avoid an RPC per slider tick. Keep
+// the two implementations in sync — especially the (U, V) basis
+// table and the centering math.
+func computeSplitPreviewFromVertices(verts [][3]float32, s SplitSettings) (*SplitPreviewResult, error) {
+ if len(verts) == 0 {
+ return nil, fmt.Errorf("split preview: model has no vertices")
+ }
+
+ axis := s.Axis
+ if axis < 0 || axis > 2 {
+ axis = 2
+ }
+ var normal [3]float32
+ normal[axis] = 1
+
+ // Origin starts at offset along the chosen axis, from world
+ // origin. This matches split.AxisPlane(axis, offset) which says
+ // "Normal·p == D" with D = offset.
+ origin := [3]float32{0, 0, 0}
+ origin[axis] = float32(s.Offset)
+
+ // Orthonormal (U, V) basis on the plane. Fixed convention per
+ // axis so the basis is stable as the user toggles axes.
+ // All three are right-handed: U × V = Normal.
+ var u, v [3]float32
+ switch axis {
+ case 0: // normal = +X → U=+Y, V=+Z
+ u = [3]float32{0, 1, 0}
+ v = [3]float32{0, 0, 1}
+ case 1: // normal = +Y → U=+Z, V=+X
+ u = [3]float32{0, 0, 1}
+ v = [3]float32{1, 0, 0}
+ default: // axis == 2, normal = +Z → U=+X, V=+Y
+ u = [3]float32{1, 0, 0}
+ v = [3]float32{0, 1, 0}
+ }
+
+ // Project the model's silhouette onto (U, V); find the bbox.
+ // Note: this is the projected silhouette of all vertices, not
+ // the cross-section at the cut. The frontend renders this as a
+ // translucent overlay, so a slightly oversized rectangle is
+ // preferable to one that shrinks/grows as the cut moves through
+ // the model.
+ minU, maxU := projectAxis(verts[0], u), projectAxis(verts[0], u)
+ minV, maxV := projectAxis(verts[0], v), projectAxis(verts[0], v)
+ for _, p := range verts[1:] {
+ du := projectAxis(p, u)
+ dv := projectAxis(p, v)
+ if du < minU {
+ minU = du
+ }
+ if du > maxU {
+ maxU = du
+ }
+ if dv < minV {
+ minV = dv
+ }
+ if dv > maxV {
+ maxV = dv
+ }
+ }
+ halfU := (maxU - minU) / 2
+ halfV := (maxV - minV) / 2
+ originU := (minU + maxU) / 2
+ originV := (minV + maxV) / 2
+ for i := 0; i < 3; i++ {
+ origin[i] += originU*u[i] + originV*v[i]
+ }
+
+ return &SplitPreviewResult{
+ Origin: origin,
+ Normal: normal,
+ U: u,
+ V: v,
+ HalfExtentU: halfU,
+ HalfExtentV: halfV,
+ }, nil
+}
+
+// projectAxis returns the dot product of point p and unit-vector
+// axis a — the scalar coordinate of p along a.
+func projectAxis(p, a [3]float32) float32 {
+ return p[0]*a[0] + p[1]*a[1] + p[2]*a[2]
+}
diff --git a/internal/pipeline/splitpreview_test.go b/internal/pipeline/splitpreview_test.go
new file mode 100644
index 0000000..a428ac5
--- /dev/null
+++ b/internal/pipeline/splitpreview_test.go
@@ -0,0 +1,186 @@
+package pipeline
+
+import (
+ "math"
+ "sync"
+ "testing"
+)
+
+// unitCubeVerts returns the 8 corners of a 1×1×1 cube at origin.
+func unitCubeVerts() [][3]float32 {
+ return [][3]float32{
+ {0, 0, 0}, {1, 0, 0}, {1, 1, 0}, {0, 1, 0},
+ {0, 0, 1}, {1, 0, 1}, {1, 1, 1}, {0, 1, 1},
+ }
+}
+
+// TestComputeSplitPreview_NoCachedLoad — without a cached load
+// output, ComputeSplitPreview returns a clear error.
+func TestComputeSplitPreview_NoCachedLoad(t *testing.T) {
+ c := NewStageCache()
+ _, err := ComputeSplitPreview(c, Options{}, SplitSettings{})
+ if err == nil {
+ t.Fatal("expected error when no load output is cached")
+ }
+}
+
+// TestSplitPreview_EmptyVertices — degenerate input is handled with
+// a clear error rather than a divide-by-zero or nil panic.
+func TestSplitPreview_EmptyVertices(t *testing.T) {
+ _, err := computeSplitPreviewFromVertices(nil, SplitSettings{Axis: 2})
+ if err == nil {
+ t.Fatal("expected error on empty vertices")
+ }
+}
+
+// TestSplitPreview_PlaneEquation — Normal·Origin == Offset for all
+// three axes and a range of offsets. This is the load-bearing
+// invariant that lets the frontend render the cut plane correctly.
+func TestSplitPreview_PlaneEquation(t *testing.T) {
+ verts := unitCubeVerts()
+ for axis := 0; axis < 3; axis++ {
+ for _, offset := range []float64{-5, 0, 0.5, 3.7, 100} {
+ res, err := computeSplitPreviewFromVertices(verts, SplitSettings{Axis: axis, Offset: offset})
+ if err != nil {
+ t.Fatalf("axis=%d offset=%g: %v", axis, offset, err)
+ }
+ dot := float64(res.Origin[0])*float64(res.Normal[0]) +
+ float64(res.Origin[1])*float64(res.Normal[1]) +
+ float64(res.Origin[2])*float64(res.Normal[2])
+ if math.Abs(dot-offset) > 1e-5 {
+ t.Errorf("axis=%d offset=%g: Normal·Origin = %g, want %g", axis, offset, dot, offset)
+ }
+ }
+ }
+}
+
+// TestSplitPreview_BasisOrthonormal — for each axis, U × V == Normal.
+// Right-handed orientation lets the frontend render the quad with
+// consistent face culling.
+func TestSplitPreview_BasisOrthonormal(t *testing.T) {
+ verts := unitCubeVerts()
+ for axis := 0; axis < 3; axis++ {
+ res, err := computeSplitPreviewFromVertices(verts, SplitSettings{Axis: axis})
+ if err != nil {
+ t.Fatalf("axis=%d: %v", axis, err)
+ }
+ // U·Normal == 0 and V·Normal == 0 (basis vectors are in-plane).
+ if dot := dot3(res.U, res.Normal); math.Abs(float64(dot)) > 1e-5 {
+ t.Errorf("axis=%d: U·Normal = %g, want 0", axis, dot)
+ }
+ if dot := dot3(res.V, res.Normal); math.Abs(float64(dot)) > 1e-5 {
+ t.Errorf("axis=%d: V·Normal = %g, want 0", axis, dot)
+ }
+ // U × V == Normal.
+ cx := res.U[1]*res.V[2] - res.U[2]*res.V[1]
+ cy := res.U[2]*res.V[0] - res.U[0]*res.V[2]
+ cz := res.U[0]*res.V[1] - res.U[1]*res.V[0]
+ if math.Abs(float64(cx-res.Normal[0])) > 1e-5 ||
+ math.Abs(float64(cy-res.Normal[1])) > 1e-5 ||
+ math.Abs(float64(cz-res.Normal[2])) > 1e-5 {
+ t.Errorf("axis=%d: U × V = (%g, %g, %g), want %v", axis, cx, cy, cz, res.Normal)
+ }
+ }
+}
+
+func dot3(a, b [3]float32) float32 {
+ return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]
+}
+
+// TestSplitPreview_HalfExtents — for a unit cube, half-extent on
+// each in-plane axis = 0.5.
+func TestSplitPreview_HalfExtents(t *testing.T) {
+ verts := unitCubeVerts()
+ for axis := 0; axis < 3; axis++ {
+ res, err := computeSplitPreviewFromVertices(verts, SplitSettings{Axis: axis, Offset: 0.5})
+ if err != nil {
+ t.Fatalf("axis=%d: %v", axis, err)
+ }
+ if math.Abs(float64(res.HalfExtentU)-0.5) > 1e-5 || math.Abs(float64(res.HalfExtentV)-0.5) > 1e-5 {
+ t.Errorf("axis=%d: half-extents = (%g, %g), want (0.5, 0.5)", axis, res.HalfExtentU, res.HalfExtentV)
+ }
+ }
+}
+
+// TestSplitPreview_AsymmetricBbox — when the model is asymmetric
+// across the in-plane axes, the returned Origin shifts off the
+// world-axis-projected point but still satisfies Normal·Origin =
+// Offset (the centering only translates within the plane).
+func TestSplitPreview_AsymmetricBbox(t *testing.T) {
+ // Model offset to (10..12, 20..23, 0..1) — asymmetric in X and Y.
+ verts := [][3]float32{
+ {10, 20, 0}, {12, 20, 0}, {12, 23, 0}, {10, 23, 0},
+ {10, 20, 1}, {12, 20, 1}, {12, 23, 1}, {10, 23, 1},
+ }
+ res, err := computeSplitPreviewFromVertices(verts, SplitSettings{Axis: 2, Offset: 0.5})
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Cut at z=0.5; basis is (U=+X, V=+Y). Centre of projected bbox:
+ // X=(10+12)/2=11, Y=(20+23)/2=21.5. Origin should be (11, 21.5, 0.5).
+ if math.Abs(float64(res.Origin[0])-11) > 1e-5 || math.Abs(float64(res.Origin[1])-21.5) > 1e-5 {
+ t.Errorf("Origin XY = (%g, %g), want (11, 21.5)", res.Origin[0], res.Origin[1])
+ }
+ // Plane equation still holds: Normal·Origin = Offset.
+ if math.Abs(float64(res.Origin[2])-0.5) > 1e-5 {
+ t.Errorf("Origin Z = %g, want 0.5 (= offset)", res.Origin[2])
+ }
+ if math.Abs(float64(res.HalfExtentU)-1) > 1e-5 || math.Abs(float64(res.HalfExtentV)-1.5) > 1e-5 {
+ t.Errorf("half-extents = (%g, %g), want (1, 1.5)", res.HalfExtentU, res.HalfExtentV)
+ }
+}
+
+// TestSplitPreview_InvalidAxisFallsBackToZ — out-of-range axis
+// values (-1, 3, 99) silently fall back to Z. This matches the
+// AxisPlane convention in internal/split.
+func TestSplitPreview_InvalidAxisFallsBackToZ(t *testing.T) {
+ verts := unitCubeVerts()
+ wantZ := [3]float32{0, 0, 1}
+ for _, axis := range []int{-1, 3, 99} {
+ res, err := computeSplitPreviewFromVertices(verts, SplitSettings{Axis: axis})
+ if err != nil {
+ t.Fatalf("axis=%d: %v", axis, err)
+ }
+ if res.Normal != wantZ {
+ t.Errorf("axis=%d: normal=%v, want Z fallback %v", axis, res.Normal, wantZ)
+ }
+ }
+}
+
+// TestSplitPreview_ConcurrentSafety — fires many goroutines at the
+// pure helper to make sure there's no shared-state hazard. The
+// helper is stateless by construction; this test exists so a
+// future change can't introduce hidden state without breaking it.
+func TestSplitPreview_ConcurrentSafety(t *testing.T) {
+ verts := unitCubeVerts()
+ const N = 64
+ var wg sync.WaitGroup
+ for i := 0; i < N; i++ {
+ wg.Add(1)
+ go func(axis int) {
+ defer wg.Done()
+ for j := 0; j < 100; j++ {
+ _, err := computeSplitPreviewFromVertices(verts, SplitSettings{Axis: axis % 3, Offset: float64(j)})
+ if err != nil {
+ t.Errorf("axis=%d j=%d: %v", axis, j, err)
+ return
+ }
+ }
+ }(i)
+ }
+ wg.Wait()
+}
+
+// TestProjectAxis_DotProduct — sanity check the helper.
+func TestProjectAxis_DotProduct(t *testing.T) {
+ p := [3]float32{3, 4, 5}
+ if got := projectAxis(p, [3]float32{1, 0, 0}); got != 3 {
+ t.Errorf("projectAxis on +X: got %g, want 3", got)
+ }
+ if got := projectAxis(p, [3]float32{0, 1, 0}); got != 4 {
+ t.Errorf("projectAxis on +Y: got %g, want 4", got)
+ }
+ if got := projectAxis(p, [3]float32{0, 0, 1}); got != 5 {
+ t.Errorf("projectAxis on +Z: got %g, want 5", got)
+ }
+}
diff --git a/internal/pipeline/stepcache.go b/internal/pipeline/stepcache.go
index 534416e..d0d15d2 100644
--- a/internal/pipeline/stepcache.go
+++ b/internal/pipeline/stepcache.go
@@ -7,13 +7,17 @@ import (
"hash/fnv"
"math"
"os"
+ "path/filepath"
"strings"
"sync"
"time"
+ "github.com/rtwfroody/ditherforge/internal/cacheblob"
"github.com/rtwfroody/ditherforge/internal/diskcache"
"github.com/rtwfroody/ditherforge/internal/loader"
+ "github.com/rtwfroody/ditherforge/internal/plog"
"github.com/rtwfroody/ditherforge/internal/progress"
+ "github.com/rtwfroody/ditherforge/internal/split"
"github.com/rtwfroody/ditherforge/internal/voxel"
)
@@ -23,7 +27,7 @@ type StageID int
const (
// StageParse parses the input file into a pristine *LoadedModel in
// file units, with no transformations applied. Output is small and
- // only depends on (Input, ObjectIndex, ReloadSeq). Replaced what used
+ // only depends on (Input, ObjectIndex). Replaced what used
// to be a separate "raw cache" living outside the stages array.
StageParse StageID = iota
// StageLoad transforms the parsed model into a usable loadOutput:
@@ -32,6 +36,12 @@ const (
// stage's body, not a separate stage — so the on-disk cache for
// StageLoad subsumes what used to be a separate alpha-wrap cache.
StageLoad
+ // StageSplit cuts the watertight loaded mesh in two and lays the
+ // halves out side-by-side on the bed (see docs/SPLIT.md). The
+ // Decimate, Voxelize, and downstream stages consume the split
+ // output when Options.Split.Enabled is true. When disabled, the
+ // stage is a passthrough.
+ StageSplit
StageDecimate
StageSticker // builds decals from mesh, before voxelization
StageVoxelize
@@ -52,6 +62,8 @@ func stageSubdir(s StageID) string {
return "parse"
case StageLoad:
return "load"
+ case StageSplit:
+ return "split"
case StageDecimate:
return "decimate"
case StageSticker:
@@ -74,53 +86,69 @@ func stageSubdir(s StageID) string {
return "unknown"
}
-// stageMemoryCap is the per-stage in-memory entry cap. Two slots is enough
-// for the canonical "toggle between A and B" workflow (e.g. LayerHeight
-// 0.2 ↔ 0.12). Cycling through three or more settings still hits disk on
-// the second pass, which is fast for these payloads. Eviction is FIFO by
-// insertion order.
-const stageMemoryCap = 2
-
-// stageMap is a per-stage in-memory cache holding up to cap entries keyed by
-// the unified cache key. Eviction is insertion-order FIFO — we don't promote
-// on read because the goal is "keep the last N computed", not "keep the last
-// N read".
-type stageMap struct {
- cap int
- entries map[string]any
- order []string // insertion order; index 0 is oldest
-}
-
-func newStageMap(cap int) *stageMap {
- return &stageMap{cap: cap, entries: make(map[string]any, cap)}
-}
-
-func (m *stageMap) get(key string) any {
- return m.entries[key]
-}
-
-func (m *stageMap) put(key string, output any) {
- if _, ok := m.entries[key]; ok {
- m.entries[key] = output
- return
- }
- if len(m.entries) >= m.cap {
- oldest := m.order[0]
- m.order = m.order[1:]
- delete(m.entries, oldest)
+// stageDescription returns a short human-readable summary of what an
+// entry for (stage, opts) contains. Stored in the disk-cache meta
+// sidecar and printed during sweeps so the operator can see what's
+// being evicted ("Load: foo.glb (alpha-wrap)" beats an opaque hash).
+func stageDescription(stage StageID, opts Options) string {
+ base := filepath.Base(opts.Input)
+ switch stage {
+ case StageParse:
+ return fmt.Sprintf("Parse: %s", base)
+ case StageLoad:
+ s := fmt.Sprintf("Load: %s", base)
+ if opts.AlphaWrap {
+ s += " (alpha-wrap)"
+ }
+ return s
+ case StageSplit:
+ if !opts.Split.Enabled {
+ return fmt.Sprintf("Split: %s (off)", base)
+ }
+ axisName := []string{"X", "Y", "Z"}[opts.Split.Axis]
+ countStr := fmt.Sprintf("×%d", opts.Split.ConnectorCount)
+ if opts.Split.ConnectorCount == 0 {
+ countStr = "×auto"
+ }
+ return fmt.Sprintf("Split: %s (%s@%.1fmm, %s %s)",
+ base, axisName, opts.Split.Offset, opts.Split.ConnectorStyle, countStr)
+ case StageDecimate:
+ return fmt.Sprintf("Decimate: %s @ %.2fmm", base, opts.NozzleDiameter)
+ case StageSticker:
+ return fmt.Sprintf("Stickers: %s (%d)", base, len(opts.Stickers))
+ case StageVoxelize:
+ return fmt.Sprintf("Voxelize: %s @ %.2f/%.2fmm", base, opts.NozzleDiameter, opts.LayerHeight)
+ case StageColorAdjust:
+ return fmt.Sprintf("Color adjust: %s (B%+.0f C%+.0f S%+.0f)",
+ base, opts.Brightness, opts.Contrast, opts.Saturation)
+ case StageColorWarp:
+ return fmt.Sprintf("Color warp: %s (%d pins)", base, len(opts.WarpPins))
+ case StagePalette:
+ return fmt.Sprintf("Palette: %s (%d colors)", base, opts.NumColors)
+ case StageDither:
+ mode := opts.Dither
+ if mode == "" {
+ mode = "default"
+ }
+ return fmt.Sprintf("Dither: %s (%s)", base, mode)
+ case StageClip:
+ return fmt.Sprintf("Clip: %s", base)
+ case StageMerge:
+ return fmt.Sprintf("Merge: %s", base)
}
- m.entries[key] = output
- m.order = append(m.order, key)
+ return base
}
-// StageCache holds per-stage cached outputs. Each stage has a multi-slot
-// in-memory cache keyed by a unified string key; the same key looks up the
-// stage's gob-encoded representation in the disk cache.
+// StageCache holds per-stage cached outputs as compressed cacheblob
+// bytes on disk. There is no separate in-memory tier of compressed
+// blobs: the OS page cache keeps recent reads resident and decode
+// (zstd + gob) dominates hit latency anyway, so a process-local copy
+// of the same compressed bytes earns very little. Within a single
+// pipeline invocation, pipelineRun (run.go) memoizes the live decoded
+// struct so a stage is decoded at most once per run.
type StageCache struct {
- stages [numStages]*stageMap
-
- // disk persists the gob-encoded outputs of expensive stages across app
- // restarts. nil = persistence disabled.
+ // disk persists cacheblobs across app restarts. nil = caching
+ // disabled (everything recomputes; tests use this).
disk *diskcache.Cache
// diskWrites tracks async disk-write goroutines so the app can wait
@@ -147,13 +175,10 @@ type StageCache struct {
invContents string
}
-// NewStageCache returns an empty stage cache.
+// NewStageCache returns an empty stage cache with no disk persistence.
+// Use SetDisk to attach a disk tier.
func NewStageCache() *StageCache {
- c := &StageCache{}
- for i := range c.stages {
- c.stages[i] = newStageMap(stageMemoryCap)
- }
- return c
+ return &StageCache{}
}
// SetDisk attaches a disk cache. Call this once after NewStageCache; passing
@@ -169,21 +194,17 @@ func (c *StageCache) SetDisk(d *diskcache.Cache) {
// - on a miss, times the body, lets body emit its own progress markers
// (some stages are spinners, some have determinate progress bars from
// inner functions like DecimateMesh / VoxelizeTwoGrids), and on
-// success records the wall-clock duration as a sidecar metadata file
-// so Sweep can make cost-aware eviction decisions.
+// success calls stampCost to back-fill the disk meta sidecar with
+// the wall-clock generation time.
//
-// body is responsible for storing its result via cache.set… before
-// returning. This keeps the helper a pure cross-cut concern (cache check
-// + UI marker + cost recording) without coupling it to each stage's
-// typed output.
+// body is responsible only for producing and persisting the stage's
+// result. In normal use, callers reach this helper via runStage (in
+// run.go), which wraps body to memoize the live pointer into
+// pipelineRun and queue the async cache.set. After body returns
+// successfully, runStageCached calls stampCost to back-fill the
+// disk-side meta sidecar with description and wall-clock duration.
//
-// Pattern:
-//
-// return runStageCached(cache, StageDecimate, opts, tracker, func() error {
-// ...
-// cache.setDecimate(opts, &decimateOutput{...})
-// return nil
-// })
+// Direct callers are rare; prefer runStage.
func runStageCached(
cache *StageCache,
stage StageID,
@@ -191,55 +212,57 @@ func runStageCached(
tracker progress.Tracker,
body func() error,
) error {
+ name := stageNames[stage]
+ key := cache.stageKey(stage, opts)
getStart := time.Now()
v, src := cache.getWithSource(stage, opts)
if v != nil {
- fmt.Printf("%s: cache hit (%s, %s)\n", stageNames[stage],
- hitSourceLabel(src), time.Since(getStart).Round(time.Microsecond))
- progress.BeginStage(tracker, stageNames[stage], false, 0).Done()
+ plog.Printf("%s: cache hit (%s, %s) key=%s", name,
+ hitSourceLabel(src), time.Since(getStart).Round(time.Microsecond),
+ shortKey(key))
+ progress.BeginStage(tracker, name, false, 0).Done()
return nil
}
+ plog.Printf("%s: starting (cache miss key=%s)", name, shortKey(key))
start := time.Now()
if err := body(); err != nil {
// Errored runs don't record cost. The body may not have
- // written the data file (or wrote a partial), so a meta
+ // written its result (or wrote a partial), so a meta
// pointing at it would be misleading.
+ plog.Printf("%s: failed after %s — %v", name,
+ time.Since(start).Round(time.Millisecond), err)
return err
}
- cache.recordCost(stage, opts, time.Since(start))
+ plog.Printf("%s: done in %s", name,
+ time.Since(start).Round(time.Millisecond))
+ // Body wrote the blob via the typed setter. Stamp the disk
+ // meta sidecar with description and wall-clock cost so the
+ // next sweep can rank this entry correctly.
+ cache.stampCost(stage, opts, time.Since(start))
return nil
}
+// shortKey returns the first 12 hex chars of a stage cache key — enough
+// to disambiguate runs in console logs without dumping the full SHA. An
+// empty key (input file unhashable) renders as "?".
+func shortKey(key string) string {
+ if key == "" {
+ return "?"
+ }
+ if len(key) > 12 {
+ return key[:12]
+ }
+ return key
+}
+
// hitSourceLabel returns a short label for console messages.
func hitSourceLabel(s hitSource) string {
- switch s {
- case hitMemory:
- return "memory"
- case hitDisk:
+ if s == hitDisk {
return "disk"
}
return "miss"
}
-// recordCost writes the sidecar metadata file capturing how long the
-// stage took to run. Async like Set, tracked by the same WaitGroup so
-// shutdown can wait for it. Failures go to OnError, never returned.
-// No-op when disk persistence is disabled.
-func (c *StageCache) recordCost(stage StageID, opts Options, cost time.Duration) {
- if c.disk == nil {
- return
- }
- key := c.stageKey(stage, opts)
- if key == "" {
- return
- }
- c.diskWrites.Add(1)
- go func() {
- defer c.diskWrites.Done()
- c.disk.RecordCost(stageSubdir(stage), key, cost)
- }()
-}
-
// WaitForDiskWrites blocks until all in-flight async disk writes have
// completed. Call from shutdown so a 400 MB compressed load entry
// doesn't get its goroutine killed mid-flight by process exit.
@@ -441,7 +464,35 @@ type colorWarpOutput struct {
}
type decimateOutput struct {
+ // DecimModel is populated for the unsplit path. nil when split is
+ // enabled.
DecimModel *loader.LoadedModel
+ // Halves is populated for the split path: per-half decimated
+ // laid-out meshes. Both indices nil when split is disabled.
+ Halves [2]*loader.LoadedModel
+}
+
+// splitOutput is the result of cutting a watertight model in two and
+// laying the halves out side-by-side on the bed. Halves are in bed
+// coordinates (post-Layout); Xform[i] is the forward transform from
+// original-mesh coords to bed coords for half i (Voxelize calls
+// ApplyInverse to map cell centroids back to original coords for
+// color sampling).
+//
+// When Options.Split.Enabled is false, splitOutput.Enabled is false
+// and downstream stages take their non-split path.
+//
+// CONSUMERS MUST GATE ON `Enabled`, NEVER ON `Halves[i] == nil`.
+// loader.LoadedModel.GobEncode handles nil receivers by encoding
+// an empty model, which decodes as a non-nil zero LoadedModel. So
+// after a disk-cache round-trip, Halves[0]/Halves[1] are non-nil
+// even when Enabled is false. Only the Enabled bit is reliable.
+type splitOutput struct {
+ Enabled bool
+ Halves [2]*loader.LoadedModel
+ Xform [2]split.Transform
+ CutNormal [3]float64 // outward normal from half 0 toward half 1
+ CutPlaneD float64
}
type paletteOutput struct {
@@ -461,6 +512,12 @@ type clipOutput struct {
ShellVerts [][3]float32
ShellFaces [][3]uint32
ShellAssignments []int32
+ // ShellHalfIdx is parallel to ShellFaces; non-nil only when Split
+ // is enabled, in which case each face is tagged with the half it
+ // came from. Downstream Merge keeps it parallel through the
+ // per-half merge pass; Export uses it (eventually) to emit one
+ // 3MF entry per half.
+ ShellHalfIdx []byte
}
// mergeOutput has the same structure as clipOutput. When NoMerge is true,
@@ -470,6 +527,7 @@ type mergeOutput struct {
ShellVerts [][3]float32
ShellFaces [][3]uint32
ShellAssignments []int32
+ ShellHalfIdx []byte // parallel to ShellFaces; nil when Split disabled
}
// --- Per-stage settings structs for cache key computation ---
@@ -483,16 +541,22 @@ type mergeOutput struct {
// parseSettings is what affects the parsed-from-file *LoadedModel.
// File-content invariants live elsewhere (the stageKey cascade adds the
// sha256 of the file's bytes, so identical bytes hit the same cache).
+//
+// ReloadSeq deliberately is NOT here. It's a frontend-only mechanism
+// for re-triggering reactive $effects when the user re-selects the
+// same input path; including it in the cache key would make cache
+// hits depend on which UI gesture loaded the file (direct .glb open
+// bumps reloadSeq; loading a .json settings file does not), even
+// when the actual file content and pipeline settings are identical.
type parseSettings struct {
Input string
- ReloadSeq int64
ObjectIndex int
}
// loadSettings is what affects the post-parse loadOutput: scale,
// normalize, alpha-wrap. The cumulative cascade key for StageLoad
-// includes parseSettings via stageFnv(StageParse), so changing Input or
-// ReloadSeq also invalidates StageLoad.
+// includes parseSettings via stageFnv(StageParse), so changing Input
+// also invalidates StageLoad.
type loadSettings struct {
Scale float32
HasSize bool
@@ -542,6 +606,23 @@ type decimateSettings struct {
LayerHeight float32
}
+// splitSettings is what affects StageSplit's output. When Enabled is
+// false, only the Enabled bit is hashed so a disabled-Split run
+// produces the same downstream cache keys it would have produced
+// before the Split feature shipped. Toggling other fields while
+// Enabled=false does not invalidate the cache.
+type splitSettings struct {
+ Enabled bool
+ Axis int
+ Offset float64
+ ConnectorStyle string
+ ConnectorCount int
+ ConnectorDiamMM float64
+ ConnectorDepthMM float64
+ ClearanceMM float64
+ GapMM float64
+}
+
type paletteSettings struct {
NumColors int
LockedColors string // joined for hashing
@@ -593,7 +674,6 @@ func (c *StageCache) settingsForStage(stage StageID, opts Options) any {
case StageParse:
return parseSettings{
Input: opts.Input,
- ReloadSeq: opts.ReloadSeq,
ObjectIndex: opts.ObjectIndex,
}
case StageLoad:
@@ -620,6 +700,23 @@ func (c *StageCache) settingsForStage(stage StageID, opts Options) any {
return colorAdjustSettings{Brightness: opts.Brightness, Contrast: opts.Contrast, Saturation: opts.Saturation}
case StageColorWarp:
return colorWarpSettings{WarpPins: opts.WarpPins}
+ case StageSplit:
+ // When disabled, only the Enabled bit affects the key; this
+ // preserves cache-hit equivalence with the pre-Split path.
+ if !opts.Split.Enabled {
+ return splitSettings{Enabled: false}
+ }
+ return splitSettings{
+ Enabled: true,
+ Axis: opts.Split.Axis,
+ Offset: opts.Split.Offset,
+ ConnectorStyle: opts.Split.ConnectorStyle,
+ ConnectorCount: opts.Split.ConnectorCount,
+ ConnectorDiamMM: opts.Split.ConnectorDiamMM,
+ ConnectorDepthMM: opts.Split.ConnectorDepthMM,
+ ClearanceMM: opts.Split.ClearanceMM,
+ GapMM: opts.Split.GapMM,
+ }
case StageDecimate:
return decimateSettings{NoSimplify: opts.NoSimplify, NozzleDiameter: opts.NozzleDiameter, LayerHeight: opts.LayerHeight}
case StagePalette:
@@ -650,7 +747,6 @@ func (c *StageCache) stageFnv(stage StageID, opts Options) uint64 {
switch v := s.(type) {
case parseSettings:
writeString(h, v.Input)
- binary.Write(h, binary.LittleEndian, v.ReloadSeq)
writeInt(h, v.ObjectIndex)
case loadSettings:
writeFloat32(h, v.Scale)
@@ -698,6 +794,18 @@ func (c *StageCache) stageFnv(stage StageID, opts Options) uint64 {
writeString(h, p.TargetHex)
writeFloat64(h, p.Sigma)
}
+ case splitSettings:
+ writeBool(h, v.Enabled)
+ if v.Enabled {
+ writeInt(h, v.Axis)
+ writeFloat64(h, v.Offset)
+ writeString(h, v.ConnectorStyle)
+ writeInt(h, v.ConnectorCount)
+ writeFloat64(h, v.ConnectorDiamMM)
+ writeFloat64(h, v.ConnectorDepthMM)
+ writeFloat64(h, v.ClearanceMM)
+ writeFloat64(h, v.GapMM)
+ }
case decimateSettings:
writeBool(h, v.NoSimplify)
writeFloat32(h, v.NozzleDiameter)
@@ -737,6 +845,8 @@ func allocOutput(stage StageID) any {
return &loader.LoadedModel{}
case StageLoad:
return &loadOutput{}
+ case StageSplit:
+ return &splitOutput{}
case StageDecimate:
return &decimateOutput{}
case StageSticker:
@@ -759,74 +869,113 @@ func allocOutput(stage StageID) any {
return nil
}
-// hitSource indicates where a cache hit came from. Used to drive the
-// console message in runStageCached so the user can see whether disk
-// caching is paying off (disk hits) or just same-session repetition
-// (memory hits).
+// hitSource indicates where a cache hit came from. Currently only the
+// disk tier produces hits (in-process compressed-byte caching was
+// removed because the OS page cache + pipelineRun memoization already
+// cover what it would have provided).
type hitSource int
const (
hitMiss hitSource = iota
- hitMemory
hitDisk
)
// get returns the cached output for the given stage and opts, or nil on
-// miss. Tries memory first; on miss, tries disk and warms memory on a hit.
-// Every stage is treated identically — there are no stages with special
-// caching rules.
+// miss. Every stage is treated identically — there are no stages with
+// special caching rules.
func (c *StageCache) get(stage StageID, opts Options) any {
v, _ := c.getWithSource(stage, opts)
return v
}
// getWithSource is get plus an indicator of where the hit came from.
-// Used by runStageCached for the console "cache hit" message.
+// On a hit, decodes the blob into a freshly allocated output struct.
+// A blob that fails to decode (corrupted file, format change) is
+// deleted so the next access misses cleanly and recomputes.
func (c *StageCache) getWithSource(stage StageID, opts Options) (any, hitSource) {
key := c.stageKey(stage, opts)
- if key == "" {
+ if key == "" || c.disk == nil {
return nil, hitMiss
}
- if v := c.stages[stage].get(key); v != nil {
- return v, hitMemory
- }
- if c.disk == nil {
+ subdir := stageSubdir(stage)
+ blob := c.disk.GetBlob(subdir, key)
+ if blob == nil {
return nil, hitMiss
}
out := allocOutput(stage)
if out == nil {
return nil, hitMiss
}
- if !c.disk.Get(stageSubdir(stage), key, out) {
+ if err := cacheblob.Decode(blob, out); err != nil {
+ c.disk.Remove(subdir, key)
return nil, hitMiss
}
- c.stages[stage].put(key, out)
return out, hitDisk
}
-// set stores output for the given stage and opts in memory and async-writes
-// it to disk.
+// set spawns a goroutine that encodes output and writes the resulting
+// blob to disk. Description and cost are filled in by stampCost,
+// which runStageCached calls after the body returns and the
+// wall-clock duration is known.
+//
+// Encoding happens off the calling goroutine deliberately: encoding a
+// multi-hundred-MB stage output allocates aggressively, and doing
+// that synchronously on the pipeline worker thread piled on memory
+// pressure right before CGO calls into native libraries (alpha-wrap,
+// renderer). That timing reliably tripped a SIGSEGV in a C++ runtime
+// signal handler that wasn't SA_ONSTACK-clean. Async encoding spreads
+// the allocation pressure over time and keeps the calling goroutine
+// thin.
//
-// Concurrency contract: after calling set, callers must treat output as
-// read-only. The disk-write goroutine reads it concurrently with downstream
-// stages; concurrent reads of immutable data are race-free.
+// Lifetime: after set returns, the caller's local pointer is the
+// only live decoded copy. The encoder goroutine reads it
+// concurrently with downstream stages; concurrent reads of immutable
+// data are race-free, but the caller must not mutate.
func (c *StageCache) set(stage StageID, opts Options, output any) {
key := c.stageKey(stage, opts)
- if key == "" {
+ if key == "" || c.disk == nil {
return
}
- c.stages[stage].put(key, output)
- if c.disk == nil {
+ subdir := stageSubdir(stage)
+ c.diskWrites.Add(1)
+ go func() {
+ defer c.diskWrites.Done()
+ blob, err := cacheblob.Encode(output)
+ if err != nil {
+ return
+ }
+ c.disk.SetBlob(subdir, key, blob)
+ }()
+}
+
+// stampCost back-fills the disk-side meta sidecar with description and
+// wall-clock cost for the entry the most recent typed setter wrote.
+// Async; tracked by diskWrites so shutdown waits for it.
+//
+// Best-effort under same-key contention: if two pipeline runs produce
+// the same key in quick succession, their stampCost goroutines may
+// land out of order, leaving the meta with the wrong cost. The blob
+// is still correct (last writer wins on the data file too) and an
+// off-by-one cost only mildly skews future eviction scoring; not
+// worth a per-key serializer.
+func (c *StageCache) stampCost(stage StageID, opts Options, cost time.Duration) {
+ key := c.stageKey(stage, opts)
+ if key == "" || c.disk == nil {
return
}
+ subdir := stageSubdir(stage)
+ description := stageDescription(stage, opts)
c.diskWrites.Add(1)
go func() {
defer c.diskWrites.Done()
- c.disk.Set(stageSubdir(stage), key, output)
+ c.disk.RecordCost(subdir, key, description, cost)
}()
}
-// Typed wrappers — return the concrete output type for each stage.
+// Typed getters — return the concrete output type for each stage.
+// Used by callers outside the pipeline-run flow (e.g. pipeline.go's
+// post-run consumers, applyBaseColor). The per-stage Run methods use
+// runStage's generic c.get instead.
func (c *StageCache) getParse(opts Options) *loader.LoadedModel {
v := c.get(StageParse, opts)
@@ -836,10 +985,6 @@ func (c *StageCache) getParse(opts Options) *loader.LoadedModel {
return v.(*loader.LoadedModel)
}
-func (c *StageCache) setParse(opts Options, m *loader.LoadedModel) {
- c.set(StageParse, opts, m)
-}
-
func (c *StageCache) getLoad(opts Options) *loadOutput {
v := c.get(StageLoad, opts)
if v == nil {
@@ -848,70 +993,6 @@ func (c *StageCache) getLoad(opts Options) *loadOutput {
return v.(*loadOutput)
}
-func (c *StageCache) setLoad(opts Options, lo *loadOutput) {
- c.set(StageLoad, opts, lo)
-}
-
-func (c *StageCache) getDecimate(opts Options) *decimateOutput {
- v := c.get(StageDecimate, opts)
- if v == nil {
- return nil
- }
- return v.(*decimateOutput)
-}
-
-func (c *StageCache) setDecimate(opts Options, do *decimateOutput) {
- c.set(StageDecimate, opts, do)
-}
-
-func (c *StageCache) getSticker(opts Options) *stickerOutput {
- v := c.get(StageSticker, opts)
- if v == nil {
- return nil
- }
- return v.(*stickerOutput)
-}
-
-func (c *StageCache) setSticker(opts Options, so *stickerOutput) {
- c.set(StageSticker, opts, so)
-}
-
-func (c *StageCache) getVoxelize(opts Options) *voxelizeOutput {
- v := c.get(StageVoxelize, opts)
- if v == nil {
- return nil
- }
- return v.(*voxelizeOutput)
-}
-
-func (c *StageCache) setVoxelize(opts Options, vo *voxelizeOutput) {
- c.set(StageVoxelize, opts, vo)
-}
-
-func (c *StageCache) getColorAdjust(opts Options) *colorAdjustOutput {
- v := c.get(StageColorAdjust, opts)
- if v == nil {
- return nil
- }
- return v.(*colorAdjustOutput)
-}
-
-func (c *StageCache) setColorAdjust(opts Options, cao *colorAdjustOutput) {
- c.set(StageColorAdjust, opts, cao)
-}
-
-func (c *StageCache) getColorWarp(opts Options) *colorWarpOutput {
- v := c.get(StageColorWarp, opts)
- if v == nil {
- return nil
- }
- return v.(*colorWarpOutput)
-}
-
-func (c *StageCache) setColorWarp(opts Options, cwo *colorWarpOutput) {
- c.set(StageColorWarp, opts, cwo)
-}
-
func (c *StageCache) getPalette(opts Options) *paletteOutput {
v := c.get(StagePalette, opts)
if v == nil {
@@ -920,34 +1001,6 @@ func (c *StageCache) getPalette(opts Options) *paletteOutput {
return v.(*paletteOutput)
}
-func (c *StageCache) setPalette(opts Options, po *paletteOutput) {
- c.set(StagePalette, opts, po)
-}
-
-func (c *StageCache) getDither(opts Options) *ditherOutput {
- v := c.get(StageDither, opts)
- if v == nil {
- return nil
- }
- return v.(*ditherOutput)
-}
-
-func (c *StageCache) setDither(opts Options, do *ditherOutput) {
- c.set(StageDither, opts, do)
-}
-
-func (c *StageCache) getClip(opts Options) *clipOutput {
- v := c.get(StageClip, opts)
- if v == nil {
- return nil
- }
- return v.(*clipOutput)
-}
-
-func (c *StageCache) setClip(opts Options, co *clipOutput) {
- c.set(StageClip, opts, co)
-}
-
func (c *StageCache) getMerge(opts Options) *mergeOutput {
v := c.get(StageMerge, opts)
if v == nil {
@@ -956,7 +1009,3 @@ func (c *StageCache) getMerge(opts Options) *mergeOutput {
return v.(*mergeOutput)
}
-func (c *StageCache) setMerge(opts Options, mo *mergeOutput) {
- c.set(StageMerge, opts, mo)
-}
-
diff --git a/internal/pipeline/unified_cache_test.go b/internal/pipeline/unified_cache_test.go
index 740e419..e9eabd8 100644
--- a/internal/pipeline/unified_cache_test.go
+++ b/internal/pipeline/unified_cache_test.go
@@ -4,6 +4,8 @@ import (
"os"
"path/filepath"
"testing"
+
+ "github.com/rtwfroody/ditherforge/internal/diskcache"
)
// makeFakeInput writes a tiny placeholder to a temp dir so stageKey's
@@ -18,57 +20,6 @@ func makeFakeInput(t *testing.T) string {
return path
}
-// TestStageMapFIFO: a stageMap evicts the oldest entry once cap is exceeded.
-func TestStageMapFIFO(t *testing.T) {
- m := newStageMap(3)
- m.put("a", 1)
- m.put("b", 2)
- m.put("c", 3)
- m.put("d", 4) // pushes out 'a'
- if m.get("a") != nil {
- t.Error("oldest entry 'a' was not evicted")
- }
- if m.get("d") == nil {
- t.Error("newest entry 'd' is missing")
- }
-}
-
-// TestStageMapCapTwoToggleAB: at the production cap of 2, A↔B↔A↔B keeps
-// both entries resident — the toggle case the unified cache is designed
-// around.
-func TestStageMapCapTwoToggleAB(t *testing.T) {
- m := newStageMap(2)
- m.put("A", "vA")
- m.put("B", "vB")
- if m.get("A") != "vA" || m.get("B") != "vB" {
- t.Fatal("setup: both entries should be present")
- }
- // Re-touching A and B (no new keys introduced) must not evict either.
- m.put("A", "vA2")
- m.put("B", "vB2")
- if m.get("A") != "vA2" {
- t.Errorf("A evicted by re-put cycle, got %v", m.get("A"))
- }
- if m.get("B") != "vB2" {
- t.Errorf("B evicted by re-put cycle, got %v", m.get("B"))
- }
-}
-
-// TestStageMapUpdate: putting the same key twice replaces the value but
-// does not consume an extra slot.
-func TestStageMapUpdate(t *testing.T) {
- m := newStageMap(2)
- m.put("a", 1)
- m.put("a", 99)
- m.put("b", 2)
- if m.get("a") != 99 {
- t.Errorf("a = %v, want 99 (update)", m.get("a"))
- }
- if m.get("b") != 2 {
- t.Errorf("b should still be present after update of a")
- }
-}
-
// TestStageKeyCascade: changing a downstream stage's settings does not
// affect an upstream stage's key. Changing an upstream stage's settings
// changes every downstream stage's key (cascade).
@@ -118,45 +69,54 @@ func TestStageKeyDownstreamCascade(t *testing.T) {
}
}
-// TestCacheAToggleBToggleAHitsMemory: the A↔B↔A scenario the user actually
+// TestCacheAToggleBToggleAHitsDisk: the A↔B↔A scenario the user actually
// cares about. After computing for A, then B, then A again, the second
-// "A" lookup must come from in-memory cache (no recompute).
-func TestCacheAToggleBToggleAHitsMemory(t *testing.T) {
+// "A" lookup must hit the disk cache (no recompute). Identity
+// comparison doesn't apply because the cache stores blobs and decodes
+// a fresh struct on every hit.
+func TestCacheAToggleBToggleAHitsDisk(t *testing.T) {
c := NewStageCache()
+ d, err := diskcache.Open(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ c.SetDisk(d)
+ // Cleanup safety only — the explicit WaitForDiskWrites before the
+ // reads below is what the assertions depend on.
+ defer c.WaitForDiskWrites()
+
path := makeFakeInput(t)
- // Two opts that differ only in LayerHeight.
optsA := Options{Input: path, Scale: 1, NozzleDiameter: 0.4, LayerHeight: 0.2, Dither: "none"}
optsB := optsA
optsB.LayerHeight = 0.12
- // Pretend we just computed each stage's output for A.
- doA := &decimateOutput{}
- c.set(StageDecimate, optsA, doA)
- voA := &voxelizeOutput{}
- c.set(StageVoxelize, optsA, voA)
-
- // Compute for B.
- doB := &decimateOutput{}
- c.set(StageDecimate, optsB, doB)
- voB := &voxelizeOutput{}
- c.set(StageVoxelize, optsB, voB)
+ c.set(StageDecimate, optsA, &decimateOutput{})
+ c.set(StageVoxelize, optsA, &voxelizeOutput{})
+ c.set(StageDecimate, optsB, &decimateOutput{})
+ c.set(StageVoxelize, optsB, &voxelizeOutput{})
+ // Wait for async writes to land before reading.
+ c.WaitForDiskWrites()
- // Toggle back to A — must return the original instances.
- if got := c.get(StageDecimate, optsA); got != doA {
- t.Errorf("Decimate A→B→A: got different instance, expected memory hit on original")
+ if _, src := c.getWithSource(StageDecimate, optsA); src != hitDisk {
+ t.Errorf("Decimate A→B→A: hit source %v, want hitDisk", src)
}
- if got := c.get(StageVoxelize, optsA); got != voA {
- t.Errorf("Voxelize A→B→A: got different instance, expected memory hit on original")
+ if _, src := c.getWithSource(StageVoxelize, optsA); src != hitDisk {
+ t.Errorf("Voxelize A→B→A: hit source %v, want hitDisk", src)
}
- // And B's entries are still there too.
- if got := c.get(StageDecimate, optsB); got != doB {
- t.Errorf("Decimate B is missing from memory after toggle")
+ if _, src := c.getWithSource(StageDecimate, optsB); src != hitDisk {
+ t.Errorf("Decimate B: hit source %v, want hitDisk", src)
}
}
// TestParseStageKeyDependsOnInputOnly: StageParse's key changes when
-// Input/ObjectIndex/ReloadSeq change but is invariant under everything
-// else (Scale, Size, alpha-wrap, base color, etc.).
+// Input/ObjectIndex change but is invariant under everything else
+// (Scale, Size, alpha-wrap, base color, ReloadSeq, etc.).
+//
+// ReloadSeq is intentionally NOT in the parse cache key — it's a
+// frontend-only counter for re-triggering reactive effects on
+// same-path re-selects. Including it would cause spurious cache misses
+// when the same underlying file is loaded via different UI paths
+// (direct .glb open bumps reloadSeq; settings-JSON load doesn't).
func TestParseStageKeyDependsOnInputOnly(t *testing.T) {
c := NewStageCache()
pathA := makeFakeInput(t)
@@ -183,11 +143,12 @@ func TestParseStageKeyDependsOnInputOnly(t *testing.T) {
t.Error("StageParse key did not change when ObjectIndex changed")
}
- // ReloadSeq bump SHOULD change StageParse's key (force re-parse).
+ // ReloadSeq must NOT change StageParse's key — it's a frontend
+ // reactive-trigger counter, not a real cache invariant.
reloaded := base
reloaded.ReloadSeq = 1
- if c.stageKey(StageParse, base) == c.stageKey(StageParse, reloaded) {
- t.Error("StageParse key did not change when ReloadSeq bumped")
+ if c.stageKey(StageParse, base) != c.stageKey(StageParse, reloaded) {
+ t.Error("StageParse key changed when ReloadSeq bumped; cache must survive same-path re-selects")
}
}
diff --git a/internal/pipeline/version.go b/internal/pipeline/version.go
index 35c3004..1bf20c3 100644
--- a/internal/pipeline/version.go
+++ b/internal/pipeline/version.go
@@ -2,7 +2,7 @@ package pipeline
// VersionSemver is the bare semver portion of the application version.
// Keep this in sync with Version below; the release workflow greps Version.
-const VersionSemver = "0.6.11"
+const VersionSemver = "0.7.0"
// Version is the application version string shown in UIs and CLI --version.
const Version = "ditherforge " + VersionSemver
diff --git a/internal/plog/plog.go b/internal/plog/plog.go
new file mode 100644
index 0000000..6c6bce5
--- /dev/null
+++ b/internal/plog/plog.go
@@ -0,0 +1,48 @@
+// Package plog is a tiny timestamped logger for pipeline stages.
+//
+// All pipeline-stage console output flows through plog so the user
+// can see wall-clock costs and spot duplicate work (cache misses)
+// by comparing timestamps across runs. Lines look like:
+//
+// [18:35:12.345] Parsing /path/to/model.glb...
+// [18:35:13.987] Alpha-wrap: alpha=0.400 mm, offset=0.013 mm starting
+// [18:40:25.612] Alpha-wrap: 1761586 vertices, 3524832 faces in 311.6s
+//
+// plog deliberately does not depend on the standard log package: the
+// pipeline already writes to stdout (Wails captures stdout for the
+// dev terminal), and we want the timestamp prefix only — no file/line
+// or other log.Lflags noise.
+package plog
+
+import (
+ "fmt"
+ "os"
+ "sync"
+ "time"
+)
+
+var mu sync.Mutex
+
+// Printf writes a single timestamped line. The format string must not
+// include a trailing newline — Printf always appends one. Multiple
+// goroutines may call Printf concurrently; output is line-atomic.
+func Printf(format string, args ...any) {
+ msg := fmt.Sprintf(format, args...)
+ mu.Lock()
+ defer mu.Unlock()
+ fmt.Fprintf(os.Stdout, "[%s] %s\n",
+ time.Now().Format("15:04:05.000"), msg)
+}
+
+// Println writes a single timestamped line containing the given args
+// joined by spaces (matches fmt.Println's behavior). Always appends a
+// newline.
+func Println(args ...any) {
+ msg := fmt.Sprintln(args...)
+ // fmt.Sprintln appends a newline; strip it because Printf adds one.
+ msg = msg[:len(msg)-1]
+ mu.Lock()
+ defer mu.Unlock()
+ fmt.Fprintf(os.Stdout, "[%s] %s\n",
+ time.Now().Format("15:04:05.000"), msg)
+}
diff --git a/internal/split/cap_polygon.go b/internal/split/cap_polygon.go
new file mode 100644
index 0000000..a96e2ae
--- /dev/null
+++ b/internal/split/cap_polygon.go
@@ -0,0 +1,337 @@
+package split
+
+import (
+ "fmt"
+ "math"
+
+ "github.com/rtwfroody/ditherforge/internal/loader"
+)
+
+// capPolygon represents one component of a half's cap (the flat
+// surface added by CGAL's clip on the cut plane). outer is the CCW
+// outer boundary loop in 2D plane-basis coordinates; holes are the
+// CW inner boundary loops (cavities). Both loops are closed but the
+// closing edge is implicit (loop[0] connects to loop[len-1]).
+type capPolygon struct {
+ outer [][2]float64
+ holes [][][2]float64
+}
+
+// recoverCapPolygons walks the half's faces, finds those lying on
+// the cut plane (cap faces), traces their boundary, and returns one
+// capPolygon per connected component.
+//
+// The plane's normal must point in the cap's outward direction —
+// after cgalclip.Clip(model, plane.Normal, plane.D), half 0's cap
+// outward normal equals +plane.Normal, and half 1's cap outward
+// normal equals -plane.Normal. Callers pass the normal that matches
+// the half being analyzed.
+func recoverCapPolygons(half *loader.LoadedModel, capNormal [3]float64, planeD float64) ([]capPolygon, error) {
+ if half == nil || len(half.Faces) == 0 {
+ return nil, fmt.Errorf("recoverCapPolygons: empty half")
+ }
+
+ bbox := bboxDiag(half.Vertices)
+ planeEps := math.Max(1e-6, 1e-6*bbox)
+
+ // 1. Identify cap faces.
+ capFaces := make([]int, 0)
+ for fi, f := range half.Faces {
+ v0 := vec3(half.Vertices[f[0]])
+ v1 := vec3(half.Vertices[f[1]])
+ v2 := vec3(half.Vertices[f[2]])
+ // Centroid on plane?
+ cz := (dot3(v0, capNormal) + dot3(v1, capNormal) + dot3(v2, capNormal)) / 3
+ if math.Abs(cz-planeD) > planeEps {
+ continue
+ }
+ // Outward normal aligned with capNormal? Use cross of edges,
+ // don't bother normalizing — sign-of-dot is enough.
+ e1 := sub3(v1, v0)
+ e2 := sub3(v2, v0)
+ n := cross3(e1, e2)
+ // Allow a small slack — CGAL kernel rounds vertex positions,
+ // so cap faces' computed normals can wobble a few ULPs off
+ // from capNormal. Require cos > 0.99 (≈8°) to be safe.
+ nl := math.Sqrt(n[0]*n[0] + n[1]*n[1] + n[2]*n[2])
+ if nl == 0 {
+ continue
+ }
+ cos := dot3(n, capNormal) / nl
+ if cos < 0.99 {
+ continue
+ }
+ capFaces = append(capFaces, fi)
+ }
+ if len(capFaces) == 0 {
+ return nil, fmt.Errorf("recoverCapPolygons: no cap faces found")
+ }
+
+ // 2. Build edge map: undirected edge -> incident cap-face count.
+ type edgeKey struct{ a, b uint32 }
+ mkEdge := func(a, b uint32) edgeKey {
+ if a < b {
+ return edgeKey{a, b}
+ }
+ return edgeKey{b, a}
+ }
+ edgeCount := make(map[edgeKey]int)
+ for _, fi := range capFaces {
+ f := half.Faces[fi]
+ edgeCount[mkEdge(f[0], f[1])]++
+ edgeCount[mkEdge(f[1], f[2])]++
+ edgeCount[mkEdge(f[2], f[0])]++
+ }
+
+ // 3. Boundary edges: count == 1. Build adjacency so we can walk
+ // loops by following the unique successor at each endpoint.
+ type adjEntry struct {
+ other uint32
+ }
+ adj := make(map[uint32][]adjEntry)
+ for ek, n := range edgeCount {
+ if n != 1 {
+ continue
+ }
+ adj[ek.a] = append(adj[ek.a], adjEntry{ek.b})
+ adj[ek.b] = append(adj[ek.b], adjEntry{ek.a})
+ }
+ if len(adj) == 0 {
+ return nil, fmt.Errorf("recoverCapPolygons: no boundary edges")
+ }
+
+ // 4. Walk loops. With a manifold cap, every boundary vertex has
+ // exactly 2 boundary neighbors. T-junctions or non-manifold caps
+ // would show degree > 2; we pick the unvisited neighbor at each
+ // step and warn on degree > 2.
+ visitedEdge := make(map[edgeKey]bool)
+ var loops [][][2]float64
+
+ // 2D basis for projecting plane points.
+ uBasis, vBasis := perpBasis(capNormal)
+
+ project2D := func(p [3]float64) [2]float64 {
+ return [2]float64{dot3(p, uBasis), dot3(p, vBasis)}
+ }
+
+ for startVert, neigh := range adj {
+ if len(neigh) == 0 {
+ continue
+ }
+ // Find an unvisited edge starting here.
+ var firstNext uint32
+ found := false
+ for _, e := range neigh {
+ if !visitedEdge[mkEdge(startVert, e.other)] {
+ firstNext = e.other
+ found = true
+ break
+ }
+ }
+ if !found {
+ continue
+ }
+
+ // Walk the loop.
+ loop := make([]uint32, 0)
+ loop = append(loop, startVert)
+ prev := startVert
+ cur := firstNext
+ visitedEdge[mkEdge(prev, cur)] = true
+ for cur != startVert {
+ loop = append(loop, cur)
+ // Find next neighbor of cur that isn't prev (and edge unvisited).
+ next := uint32(math.MaxUint32)
+ for _, e := range adj[cur] {
+ if e.other == prev {
+ continue
+ }
+ if visitedEdge[mkEdge(cur, e.other)] {
+ continue
+ }
+ next = e.other
+ break
+ }
+ if next == math.MaxUint32 {
+ return nil, fmt.Errorf("recoverCapPolygons: loop did not close (open chain at vertex %d)", cur)
+ }
+ visitedEdge[mkEdge(cur, next)] = true
+ prev = cur
+ cur = next
+ }
+
+ // Project to 2D.
+ pts := make([][2]float64, len(loop))
+ for i, vi := range loop {
+ pts[i] = project2D(vec3(half.Vertices[vi]))
+ }
+ loops = append(loops, pts)
+ }
+
+ if len(loops) == 0 {
+ return nil, fmt.Errorf("recoverCapPolygons: no closed loops recovered")
+ }
+
+ // 5. Classify loops by enclosure depth. A loop is outer if no
+ // other loop encloses it; a hole if enclosed by exactly one outer
+ // loop. (The boundary walk doesn't preserve cap-face winding
+ // direction, so loop signed-area sign is unreliable for outer/hole
+ // classification — use point-in-polygon depth instead.) Loops
+ // with degenerate (near-zero) area are skipped.
+ type loopInfo struct {
+ pts [][2]float64
+ absArea float64
+ depth int
+ parent int // index of nearest enclosing loop, -1 if outer
+ }
+ infos := make([]loopInfo, 0, len(loops))
+ for _, lp := range loops {
+ a := math.Abs(signedArea2D(lp))
+ if a < 1e-12 {
+ continue
+ }
+ infos = append(infos, loopInfo{pts: lp, absArea: a, parent: -1})
+ }
+ if len(infos) == 0 {
+ return nil, fmt.Errorf("recoverCapPolygons: all loops degenerate")
+ }
+
+ // For each loop i, count how many other loops enclose it. The
+ // nearest enclosing loop (smallest area among enclosers) is its
+ // parent. Even depth = outer loop; odd depth = hole.
+ for i := range infos {
+ probe := infos[i].pts[0]
+ bestArea := math.Inf(1)
+ bestParent := -1
+ for j := range infos {
+ if i == j {
+ continue
+ }
+ if !pointInPolygon2D(probe, infos[j].pts) {
+ continue
+ }
+ infos[i].depth++
+ if infos[j].absArea < bestArea {
+ bestArea = infos[j].absArea
+ bestParent = j
+ }
+ }
+ infos[i].parent = bestParent
+ }
+
+ // Normalize windings: outer = CCW (positive signed area), hole =
+ // CW (negative signed area). Reverse if needed.
+ for i := range infos {
+ want := 1.0
+ if infos[i].depth%2 == 1 {
+ want = -1.0
+ }
+ if math.Copysign(1, signedArea2D(infos[i].pts)) != want {
+ infos[i].pts = reverseLoop(infos[i].pts)
+ }
+ }
+
+ // Assemble polygons: one capPolygon per outer (depth 0) loop;
+ // holes attach to their nearest-enclosing outer parent.
+ outerIdxOf := make(map[int]int)
+ var polygons []capPolygon
+ for i, info := range infos {
+ if info.depth%2 == 0 {
+ outerIdxOf[i] = len(polygons)
+ polygons = append(polygons, capPolygon{outer: info.pts})
+ }
+ }
+ if len(polygons) == 0 {
+ return nil, fmt.Errorf("recoverCapPolygons: no outer loops found")
+ }
+ for i, info := range infos {
+ if info.depth%2 == 1 {
+ parent := info.parent
+ // Walk up to the nearest depth-0 (outer) ancestor.
+ for parent >= 0 && infos[parent].depth%2 == 1 {
+ parent = infos[parent].parent
+ }
+ if parent < 0 {
+ // Shouldn't happen; an odd-depth loop must be enclosed.
+ continue
+ }
+ polygons[outerIdxOf[parent]].holes = append(polygons[outerIdxOf[parent]].holes, info.pts)
+ _ = i
+ }
+ }
+
+ return polygons, nil
+}
+
+func vec3(v [3]float32) [3]float64 {
+ return [3]float64{float64(v[0]), float64(v[1]), float64(v[2])}
+}
+
+func dot3(a, b [3]float64) float64 {
+ return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]
+}
+
+func sub3(a, b [3]float64) [3]float64 {
+ return [3]float64{a[0] - b[0], a[1] - b[1], a[2] - b[2]}
+}
+
+func bboxDiag(verts [][3]float32) float64 {
+ if len(verts) == 0 {
+ return 0
+ }
+ mn := verts[0]
+ mx := verts[0]
+ for _, v := range verts[1:] {
+ for i := 0; i < 3; i++ {
+ if v[i] < mn[i] {
+ mn[i] = v[i]
+ }
+ if v[i] > mx[i] {
+ mx[i] = v[i]
+ }
+ }
+ }
+ d := [3]float64{
+ float64(mx[0] - mn[0]),
+ float64(mx[1] - mn[1]),
+ float64(mx[2] - mn[2]),
+ }
+ return math.Sqrt(d[0]*d[0] + d[1]*d[1] + d[2]*d[2])
+}
+
+func signedArea2D(loop [][2]float64) float64 {
+ if len(loop) < 3 {
+ return 0
+ }
+ var a float64
+ for i := range loop {
+ j := (i + 1) % len(loop)
+ a += loop[i][0]*loop[j][1] - loop[j][0]*loop[i][1]
+ }
+ return 0.5 * a
+}
+
+func pointInPolygon2D(p [2]float64, loop [][2]float64) bool {
+ inside := false
+ n := len(loop)
+ for i, j := 0, n-1; i < n; j, i = i, i+1 {
+ xi, yi := loop[i][0], loop[i][1]
+ xj, yj := loop[j][0], loop[j][1]
+ if (yi > p[1]) != (yj > p[1]) {
+ t := (p[1] - yi) / (yj - yi)
+ x := xi + t*(xj-xi)
+ if p[0] < x {
+ inside = !inside
+ }
+ }
+ }
+ return inside
+}
+
+func reverseLoop(loop [][2]float64) [][2]float64 {
+ r := make([][2]float64, len(loop))
+ for i, p := range loop {
+ r[len(loop)-1-i] = p
+ }
+ return r
+}
diff --git a/internal/split/cap_polygon_test.go b/internal/split/cap_polygon_test.go
new file mode 100644
index 0000000..6af0c37
--- /dev/null
+++ b/internal/split/cap_polygon_test.go
@@ -0,0 +1,48 @@
+package split
+
+import (
+ "math"
+ "testing"
+
+ "github.com/rtwfroody/ditherforge/internal/loader"
+)
+
+// TestRecoverCapPolygons_Cube — a cube cut at z=25 yields two halves.
+// Half 0 (z<=25) has its cap on z=25 with outward normal +Z. The
+// recovered cap polygon should be a single 4-vertex outer loop with
+// area 50×50 = 2500.
+func TestRecoverCapPolygons_Cube(t *testing.T) {
+ verts := [][3]float32{
+ {0, 0, 0}, {50, 0, 0}, {50, 50, 0}, {0, 50, 0},
+ {0, 0, 50}, {50, 0, 50}, {50, 50, 50}, {0, 50, 50},
+ }
+ faces := [][3]uint32{
+ {0, 2, 1}, {0, 3, 2}, {4, 5, 6}, {4, 6, 7},
+ {0, 1, 5}, {0, 5, 4}, {2, 3, 7}, {2, 7, 6},
+ {0, 4, 7}, {0, 7, 3}, {1, 2, 6}, {1, 6, 5},
+ }
+ cube := &loader.LoadedModel{Vertices: verts, Faces: faces}
+ res, err := Cut(cube, AxisPlane(2, 25), ConnectorSettings{})
+ if err != nil {
+ t.Fatalf("Cut: %v", err)
+ }
+
+ polys, err := recoverCapPolygons(res.Halves[0], [3]float64{0, 0, 1}, 25)
+ if err != nil {
+ t.Fatalf("recoverCapPolygons: %v", err)
+ }
+ if len(polys) != 1 {
+ t.Fatalf("got %d cap polygons, want 1", len(polys))
+ }
+ if len(polys[0].outer) < 4 {
+ t.Fatalf("outer loop has %d vertices, want >= 4 (cube cap is a square; CGAL may add midpoints)", len(polys[0].outer))
+ }
+ if len(polys[0].holes) != 0 {
+ t.Errorf("got %d holes on cube cap, want 0", len(polys[0].holes))
+ }
+ area := math.Abs(signedArea2D(polys[0].outer))
+ want := 50.0 * 50.0
+ if math.Abs(area-want) > 0.5 {
+ t.Errorf("cap area = %g, want %g", area, want)
+ }
+}
diff --git a/internal/split/connectors.go b/internal/split/connectors.go
new file mode 100644
index 0000000..08ffa58
--- /dev/null
+++ b/internal/split/connectors.go
@@ -0,0 +1,159 @@
+package split
+
+import (
+ "github.com/rtwfroody/ditherforge/internal/cgalbool"
+ "github.com/rtwfroody/ditherforge/internal/loader"
+ "github.com/rtwfroody/ditherforge/internal/plog"
+)
+
+// applyConnectors adds peg or dowel features to the cap surfaces of
+// the two halves and returns possibly-mutated halves. On any failure
+// for an individual connector, applyConnectors logs a warning and
+// continues — failures isolate per-connector. If every connector
+// fails, both halves come back unchanged (flat caps).
+//
+// Convention: cgalclip.Clip leaves half 0's cap with outward normal
+// equal to +plane.Normal and half 1's cap with outward normal equal
+// to -plane.Normal. Pegs (Style==Pegs) protrude from half 0 along
+// +plane.Normal and matching pockets carve into half 1 from the
+// -plane.Normal side. Dowels punch matching pockets in both halves.
+func applyConnectors(halves [2]*loader.LoadedModel, plane Plane, settings ConnectorSettings) [2]*loader.LoadedModel {
+ if settings.Style == NoConnectors {
+ return halves
+ }
+ if settings.DiamMM <= 0 || settings.DepthMM <= 0 {
+ plog.Printf(" Split: connectors requested but dimensions are zero (diam=%.3f, depth=%.3f); using flat caps", settings.DiamMM, settings.DepthMM)
+ return halves
+ }
+ // Count == 0 means "auto"; pick a sensible default. count < 0 is
+ // treated the same.
+ count := settings.Count
+ if count <= 0 {
+ count = 2
+ }
+
+ // Recover cap polygons from half 0 (cap normal = +plane.Normal).
+ // We use half 0 to plan placement, then mirror the same XY
+ // positions onto half 1 — guarantees pegs and pockets line up.
+ polys, err := recoverCapPolygons(halves[0], plane.Normal, plane.D)
+ if err != nil {
+ plog.Printf(" Split: cap polygon recovery failed (%v); using flat caps", err)
+ return halves
+ }
+
+ // Spacing heuristic: at least 2.5× the connector diameter so pegs
+ // don't touch each other. Best-effort — placePegs may yield fewer
+ // pegs than requested if the polygon is small.
+ //
+ // Boundary clearance: the peg center must be at least one peg
+ // diameter from the cap polygon boundary so a circle of 2× the
+ // peg diameter fits fully inside, leaving peg-radius worth of
+ // wall around every peg. Without this guard, the greedy
+ // farthest-point placement parks pegs at corners, which then
+ // punch through the side wall.
+ minSpacing := 2.5 * settings.DiamMM
+ boundaryClearance := settings.DiamMM
+ centers2D, err := placePegsInPolygons(polys, count, minSpacing, boundaryClearance)
+ if err != nil {
+ plog.Printf(" Split: peg placement failed (%v); using flat caps", err)
+ return halves
+ }
+
+ // Lift placement points back to 3D on the cut plane.
+ uBasis, vBasis := perpBasis(plane.Normal)
+ centers3D := make([][3]float64, len(centers2D))
+ for i, c2 := range centers2D {
+ centers3D[i] = [3]float64{
+ c2[0]*uBasis[0] + c2[1]*vBasis[0] + plane.D*plane.Normal[0],
+ c2[0]*uBasis[1] + c2[1]*vBasis[1] + plane.D*plane.Normal[1],
+ c2[0]*uBasis[2] + c2[1]*vBasis[2] + plane.D*plane.Normal[2],
+ }
+ }
+
+ // Determine cylinder dimensions per half.
+ // Pegs: half 0 gets a male cylinder (radius = DiamMM/2);
+ // half 1 gets a female cylinder (radius = DiamMM/2 + Clearance).
+ // Dowels: both halves get female cylinders (radius = DiamMM/2 + Clearance).
+ // Cylinder height: 2*DepthMM total (so the cylinder straddles the
+ // plane; the plane sits at the midpoint and each half intersects
+ // it for DepthMM along the inward direction).
+ maleR := settings.DiamMM / 2
+ femaleR := settings.DiamMM/2 + settings.ClearanceMM
+ halfHeight := settings.DepthMM
+
+ type opKind int
+ const (
+ opUnion opKind = iota
+ opDifference
+ )
+ type pendingOp struct {
+ idx int // which connector
+ halfIdx int // 0 or 1
+ radius float64
+ op opKind // Union for peg into half 0, Difference for pockets
+ }
+ var ops []pendingOp
+ switch settings.Style {
+ case Pegs:
+ for i := range centers3D {
+ ops = append(ops, pendingOp{i, 0, maleR, opUnion})
+ ops = append(ops, pendingOp{i, 1, femaleR, opDifference})
+ }
+ case Dowels:
+ for i := range centers3D {
+ ops = append(ops, pendingOp{i, 0, femaleR, opDifference})
+ ops = append(ops, pendingOp{i, 1, femaleR, opDifference})
+ }
+ }
+
+ const segments = 32
+
+ // Apply ops sequentially. Each op rebuilds one half. Failure of a
+ // single op skips that op only — if pegs is selected and the half-1
+ // difference fails for connector i but the half-0 union succeeded,
+ // half 0 ends up with a peg that has no pocket. Acceptable as a
+ // degraded fallback (the user sees the warning and decides).
+ plog.Printf(" Split: applying %d %s connector(s) at %d location(s)", len(ops), connectorStyleName(settings.Style), len(centers3D))
+
+ out := halves
+ for _, op := range ops {
+ cyl, err := buildCylinder(plane.Normal, op.radius, halfHeight, segments)
+ if err != nil {
+ plog.Printf(" Split: cylinder build for connector %d failed (%v); skipping", op.idx, err)
+ continue
+ }
+ cyl = translateMesh(cyl, centers3D[op.idx])
+ var result *loader.LoadedModel
+ var berr error
+ switch op.op {
+ case opUnion:
+ result, berr = cgalbool.Union(out[op.halfIdx], cyl)
+ case opDifference:
+ result, berr = cgalbool.Difference(out[op.halfIdx], cyl)
+ }
+ if berr != nil {
+ styleName := connectorStyleName(settings.Style)
+ plog.Printf(" Split: %s connector %d on half %d failed (%v); using flat cap for this connector", styleName, op.idx, op.halfIdx, berr)
+ continue
+ }
+ // Booleans drop UVs/colors/textures — re-attach the half's
+ // pre-existing parallel arrays would be wrong because vertex
+ // indexing has changed. Halves coming out of cgalclip.Clip are
+ // already geometry-only (UVs/colors not populated), so this is
+ // fine: the boolean output stays geometry-only.
+ out[op.halfIdx] = result
+ }
+
+ return out
+}
+
+func connectorStyleName(s ConnectorStyle) string {
+ switch s {
+ case Pegs:
+ return "peg"
+ case Dowels:
+ return "dowel"
+ default:
+ return "connector"
+ }
+}
diff --git a/internal/split/connectors_test.go b/internal/split/connectors_test.go
new file mode 100644
index 0000000..9015f33
--- /dev/null
+++ b/internal/split/connectors_test.go
@@ -0,0 +1,114 @@
+package split
+
+import (
+ "math"
+ "testing"
+
+ "github.com/rtwfroody/ditherforge/internal/loader"
+)
+
+// makeCube builds a 50mm cube triangle mesh aligned at the origin.
+func makeCube() *loader.LoadedModel {
+ verts := [][3]float32{
+ {0, 0, 0}, {50, 0, 0}, {50, 50, 0}, {0, 50, 0},
+ {0, 0, 50}, {50, 0, 50}, {50, 50, 50}, {0, 50, 50},
+ }
+ faces := [][3]uint32{
+ {0, 2, 1}, {0, 3, 2}, {4, 5, 6}, {4, 6, 7},
+ {0, 1, 5}, {0, 5, 4}, {2, 3, 7}, {2, 7, 6},
+ {0, 4, 7}, {0, 7, 3}, {1, 2, 6}, {1, 6, 5},
+ }
+ return &loader.LoadedModel{Vertices: verts, Faces: faces}
+}
+
+func volume(m *loader.LoadedModel) float64 {
+ v := 0.0
+ for _, f := range m.Faces {
+ a := m.Vertices[f[0]]
+ b := m.Vertices[f[1]]
+ c := m.Vertices[f[2]]
+ v += float64(a[0])*(float64(b[1])*float64(c[2])-float64(c[1])*float64(b[2])) -
+ float64(a[1])*(float64(b[0])*float64(c[2])-float64(c[0])*float64(b[2])) +
+ float64(a[2])*(float64(b[0])*float64(c[1])-float64(c[0])*float64(b[1]))
+ }
+ return math.Abs(v / 6)
+}
+
+// TestCut_PegsAddsAndSubtractsVolume — cut a cube at z=25 with a peg
+// connector and verify half 0's volume increased by the peg cylinder
+// volume and half 1's volume decreased by the female pocket cylinder
+// volume (within tolerance).
+func TestCut_PegsAddsAndSubtractsVolume(t *testing.T) {
+ cube := makeCube()
+ flatRes, err := Cut(cube, AxisPlane(2, 25), ConnectorSettings{})
+ if err != nil {
+ t.Fatalf("flat Cut: %v", err)
+ }
+ flatV0 := volume(flatRes.Halves[0])
+ flatV1 := volume(flatRes.Halves[1])
+
+ settings := ConnectorSettings{
+ Style: Pegs,
+ Count: 1,
+ DiamMM: 4,
+ DepthMM: 5,
+ ClearanceMM: 0.15,
+ }
+ res, err := Cut(cube, AxisPlane(2, 25), settings)
+ if err != nil {
+ t.Fatalf("Pegs Cut: %v", err)
+ }
+ v0 := volume(res.Halves[0])
+ v1 := volume(res.Halves[1])
+
+ // Peg cylinder: radius 2, halfHeight 5 → full cylinder volume π·r²·2h = π·4·10 ≈ 125.66.
+ // Half lives above z=25, so only the +Z half (volume ≈ 62.83) adds to half 0.
+ // Wait — the cylinder is centered on z=25 with halfHeight=5, so it
+ // straddles [20, 30]. Half 0 (z<=25) gains the lower half of the
+ // cylinder (z=25 down to z=20), volume π·r²·5 = π·4·5 ≈ 62.83.
+ // Half 1 (z>=25) loses the upper half of the cylinder with female
+ // radius (2.15), volume π·2.15²·5 ≈ 72.6.
+ expectedAdded := math.Pi * 2 * 2 * 5
+ expectedRemoved := math.Pi * 2.15 * 2.15 * 5
+ delta0 := v0 - flatV0
+ delta1 := flatV1 - v1
+ tol := 5.0 // tolerance for cylinder discretization (32 segments)
+ if math.Abs(delta0-expectedAdded) > tol {
+ t.Errorf("half 0 volume added = %g, want ≈ %g (peg cylinder lower half)", delta0, expectedAdded)
+ }
+ if math.Abs(delta1-expectedRemoved) > tol {
+ t.Errorf("half 1 volume removed = %g, want ≈ %g (pocket cylinder upper half)", delta1, expectedRemoved)
+ }
+}
+
+// TestCut_DowelsRemovesFromBoth — Dowels punches matching pockets in
+// both halves; both halves' volumes should decrease.
+func TestCut_DowelsRemovesFromBoth(t *testing.T) {
+ cube := makeCube()
+ flatRes, err := Cut(cube, AxisPlane(2, 25), ConnectorSettings{})
+ if err != nil {
+ t.Fatalf("flat Cut: %v", err)
+ }
+ flatV0 := volume(flatRes.Halves[0])
+ flatV1 := volume(flatRes.Halves[1])
+
+ settings := ConnectorSettings{
+ Style: Dowels,
+ Count: 1,
+ DiamMM: 4,
+ DepthMM: 5,
+ ClearanceMM: 0.15,
+ }
+ res, err := Cut(cube, AxisPlane(2, 25), settings)
+ if err != nil {
+ t.Fatalf("Dowels Cut: %v", err)
+ }
+ v0 := volume(res.Halves[0])
+ v1 := volume(res.Halves[1])
+ if v0 >= flatV0 {
+ t.Errorf("Dowels: half 0 volume = %g, want < flat %g (pocket should remove material)", v0, flatV0)
+ }
+ if v1 >= flatV1 {
+ t.Errorf("Dowels: half 1 volume = %g, want < flat %g (pocket should remove material)", v1, flatV1)
+ }
+}
diff --git a/internal/split/cylinder.go b/internal/split/cylinder.go
new file mode 100644
index 0000000..2a0c847
--- /dev/null
+++ b/internal/split/cylinder.go
@@ -0,0 +1,139 @@
+package split
+
+import (
+ "fmt"
+ "math"
+
+ "github.com/rtwfroody/ditherforge/internal/loader"
+)
+
+// buildCylinder returns a closed triangle-mesh cylinder centered at
+// the origin, with axis pointing along `axis` (must be unit-length),
+// the given radius, total height 2*halfHeight, and `segments`
+// circumferential subdivisions. The cylinder is geometry-only — the
+// returned LoadedModel populates only Vertices and Faces.
+//
+// Tessellation: 2*segments triangles for the side wall, segments-2
+// for each cap (fan from the first ring vertex), so 4*segments-4
+// triangles total. Faces wind so that outward normals point away
+// from the cylinder axis (and from the caps).
+func buildCylinder(axis [3]float64, radius, halfHeight float64, segments int) (*loader.LoadedModel, error) {
+ if segments < 3 {
+ return nil, fmt.Errorf("buildCylinder: segments must be ≥ 3, got %d", segments)
+ }
+ if radius <= 0 || halfHeight <= 0 {
+ return nil, fmt.Errorf("buildCylinder: radius and halfHeight must be positive (got %v, %v)", radius, halfHeight)
+ }
+
+ u, v := perpBasis(axis)
+
+ // Vertices: top ring (at +halfHeight) followed by bottom ring (at
+ // -halfHeight). 2*segments vertices total.
+ verts := make([][3]float32, 0, 2*segments)
+ for s := 0; s < 2; s++ {
+ h := halfHeight
+ if s == 1 {
+ h = -halfHeight
+ }
+ for i := 0; i < segments; i++ {
+ theta := 2 * math.Pi * float64(i) / float64(segments)
+ c := math.Cos(theta)
+ si := math.Sin(theta)
+ p := [3]float64{
+ radius*(c*u[0]+si*v[0]) + h*axis[0],
+ radius*(c*u[1]+si*v[1]) + h*axis[1],
+ radius*(c*u[2]+si*v[2]) + h*axis[2],
+ }
+ verts = append(verts, [3]float32{float32(p[0]), float32(p[1]), float32(p[2])})
+ }
+ }
+
+ faces := make([][3]uint32, 0, 4*segments-4)
+
+ // Side walls: each segment i contributes two triangles.
+ // top[i], bot[i], bot[(i+1)%segments] and top[i], bot[(i+1)%segments], top[(i+1)%segments]
+ // Wind outward (right-hand rule with outward normal away from axis).
+ for i := 0; i < segments; i++ {
+ t0 := uint32(i)
+ t1 := uint32((i + 1) % segments)
+ b0 := uint32(segments + i)
+ b1 := uint32(segments + (i+1)%segments)
+ faces = append(faces,
+ [3]uint32{t0, b0, b1},
+ [3]uint32{t0, b1, t1},
+ )
+ }
+
+ // Top cap: fan from vertex 0, normal == +axis. Triangle (0, i, i+1)
+ // where i runs from 1 to segments-2.
+ for i := uint32(1); i < uint32(segments-1); i++ {
+ faces = append(faces, [3]uint32{0, i, i + 1})
+ }
+
+ // Bottom cap: fan from segments (first bottom vertex), normal == -axis.
+ // Wind in reverse order so the outward normal points opposite to axis.
+ base := uint32(segments)
+ for i := uint32(1); i < uint32(segments-1); i++ {
+ faces = append(faces, [3]uint32{base, base + i + 1, base + i})
+ }
+
+ return &loader.LoadedModel{
+ Vertices: verts,
+ Faces: faces,
+ }, nil
+}
+
+// translateMesh returns a new LoadedModel whose vertices are shifted
+// by `offset`. Geometry-only.
+func translateMesh(m *loader.LoadedModel, offset [3]float64) *loader.LoadedModel {
+ out := &loader.LoadedModel{
+ Vertices: make([][3]float32, len(m.Vertices)),
+ Faces: make([][3]uint32, len(m.Faces)),
+ }
+ copy(out.Faces, m.Faces)
+ for i, v := range m.Vertices {
+ out.Vertices[i] = [3]float32{
+ v[0] + float32(offset[0]),
+ v[1] + float32(offset[1]),
+ v[2] + float32(offset[2]),
+ }
+ }
+ return out
+}
+
+// perpBasis returns two unit vectors u, v that together with `n`
+// form a right-handed orthonormal basis (u × v == n). `n` must be
+// unit-length.
+func perpBasis(n [3]float64) (u, v [3]float64) {
+ // Pick the basis vector least aligned with n to avoid degenerate
+ // cross products.
+ var ref [3]float64
+ if math.Abs(n[0]) <= math.Abs(n[1]) && math.Abs(n[0]) <= math.Abs(n[2]) {
+ ref = [3]float64{1, 0, 0}
+ } else if math.Abs(n[1]) <= math.Abs(n[2]) {
+ ref = [3]float64{0, 1, 0}
+ } else {
+ ref = [3]float64{0, 0, 1}
+ }
+ u = cross3(ref, n)
+ u = normalize3(u)
+ v = cross3(n, u)
+ v = normalize3(v)
+ return u, v
+}
+
+func cross3(a, b [3]float64) [3]float64 {
+ return [3]float64{
+ a[1]*b[2] - a[2]*b[1],
+ a[2]*b[0] - a[0]*b[2],
+ a[0]*b[1] - a[1]*b[0],
+ }
+}
+
+func normalize3(a [3]float64) [3]float64 {
+ l := math.Sqrt(a[0]*a[0] + a[1]*a[1] + a[2]*a[2])
+ if l == 0 {
+ return a
+ }
+ return [3]float64{a[0] / l, a[1] / l, a[2] / l}
+}
diff --git a/internal/split/cylinder_test.go b/internal/split/cylinder_test.go
new file mode 100644
index 0000000..3d6bb21
--- /dev/null
+++ b/internal/split/cylinder_test.go
@@ -0,0 +1,89 @@
+package split
+
+import (
+ "math"
+ "testing"
+)
+
+// TestBuildCylinder_Watertight — every triangle edge must have
+// exactly two incident faces (a closed mesh has no boundary).
+func TestBuildCylinder_Watertight(t *testing.T) {
+ cyl, err := buildCylinder([3]float64{0, 0, 1}, 1.0, 2.0, 16)
+ if err != nil {
+ t.Fatalf("buildCylinder: %v", err)
+ }
+
+ // 16 segments → 16 side quads (32 tris) + 14 cap tris × 2 = 60 tris.
+ if got := len(cyl.Faces); got != 60 {
+ t.Errorf("face count = %d, want 60 (32 side + 28 caps)", got)
+ }
+ if got := len(cyl.Vertices); got != 32 {
+ t.Errorf("vertex count = %d, want 32 (16 per ring × 2 rings)", got)
+ }
+
+ type ek struct{ a, b uint32 }
+ mk := func(a, b uint32) ek {
+ if a < b {
+ return ek{a, b}
+ }
+ return ek{b, a}
+ }
+ count := make(map[ek]int)
+ for _, f := range cyl.Faces {
+ count[mk(f[0], f[1])]++
+ count[mk(f[1], f[2])]++
+ count[mk(f[2], f[0])]++
+ }
+ for e, n := range count {
+ if n != 2 {
+ t.Errorf("edge %v has %d incident faces, want 2", e, n)
+ break
+ }
+ }
+}
+
+// TestBuildCylinder_Bbox — extents along axis are ±halfHeight, and
+// extents perpendicular are ≈ ±radius.
+func TestBuildCylinder_Bbox(t *testing.T) {
+ cyl, err := buildCylinder([3]float64{0, 0, 1}, 2.5, 4.0, 32)
+ if err != nil {
+ t.Fatalf("buildCylinder: %v", err)
+ }
+ mn := [3]float32{math.MaxFloat32, math.MaxFloat32, math.MaxFloat32}
+ mx := [3]float32{-math.MaxFloat32, -math.MaxFloat32, -math.MaxFloat32}
+ for _, v := range cyl.Vertices {
+ for i := 0; i < 3; i++ {
+ if v[i] < mn[i] {
+ mn[i] = v[i]
+ }
+ if v[i] > mx[i] {
+ mx[i] = v[i]
+ }
+ }
+ }
+ // Z extents: ±4.0
+ if math.Abs(float64(mn[2])+4) > 1e-5 || math.Abs(float64(mx[2])-4) > 1e-5 {
+ t.Errorf("z extent = [%g, %g], want [-4, 4]", mn[2], mx[2])
+ }
+ // XY extents: should be inscribed in radius circle, exactly at 2.5
+ // only at vertices that lie on the +X axis (segment 0).
+ for _, v := range cyl.Vertices {
+ r := math.Sqrt(float64(v[0]*v[0] + v[1]*v[1]))
+ if math.Abs(r-2.5) > 1e-5 {
+ t.Errorf("vertex radius = %g, want 2.5", r)
+ }
+ }
+}
+
+// TestBuildCylinder_RejectsBadInputs guards against silent zero-size.
+func TestBuildCylinder_RejectsBadInputs(t *testing.T) {
+ if _, err := buildCylinder([3]float64{0, 0, 1}, 1, 1, 2); err == nil {
+ t.Error("segments=2 should error")
+ }
+ if _, err := buildCylinder([3]float64{0, 0, 1}, 0, 1, 8); err == nil {
+ t.Error("radius=0 should error")
+ }
+ if _, err := buildCylinder([3]float64{0, 0, 1}, 1, 0, 8); err == nil {
+ t.Error("halfHeight=0 should error")
+ }
+}
diff --git a/internal/split/layout.go b/internal/split/layout.go
new file mode 100644
index 0000000..c519870
--- /dev/null
+++ b/internal/split/layout.go
@@ -0,0 +1,230 @@
+package split
+
+import (
+ "math"
+)
+
+// Transform maps original-mesh coordinates to bed coordinates:
+//
+// bed_pos = Rotation · orig_pos + Translation
+//
+// where Rotation is a 3×3 rotation matrix stored row-major. The inverse
+// (used by Voxelize for color sampling on the unmoved ColorModel /
+// SampleModel / sticker meshes) is the transpose of Rotation:
+//
+// orig_pos = Rotationᵀ · (bed_pos − Translation)
+type Transform struct {
+ Rotation [9]float64 // 3×3, row-major
+ Translation [3]float64
+}
+
+// IdentityTransform is the trivial (no-op) transform.
+var IdentityTransform = Transform{
+ Rotation: [9]float64{1, 0, 0, 0, 1, 0, 0, 0, 1},
+}
+
+// Apply maps p from original-mesh coords to bed coords.
+func (t Transform) Apply(p [3]float32) [3]float32 {
+ px, py, pz := float64(p[0]), float64(p[1]), float64(p[2])
+ return [3]float32{
+ float32(t.Rotation[0]*px + t.Rotation[1]*py + t.Rotation[2]*pz + t.Translation[0]),
+ float32(t.Rotation[3]*px + t.Rotation[4]*py + t.Rotation[5]*pz + t.Translation[1]),
+ float32(t.Rotation[6]*px + t.Rotation[7]*py + t.Rotation[8]*pz + t.Translation[2]),
+ }
+}
+
+// ApplyInverse maps p from bed coords back to original-mesh coords.
+// Phase-6 voxelize uses this for color sampling: the cell centroid
+// arrives in bed coords, this returns the corresponding original-mesh
+// coord where ColorModel / SampleModel / sticker decals live.
+func (t Transform) ApplyInverse(p [3]float32) [3]float32 {
+ px := float64(p[0]) - t.Translation[0]
+ py := float64(p[1]) - t.Translation[1]
+ pz := float64(p[2]) - t.Translation[2]
+ return [3]float32{
+ float32(t.Rotation[0]*px + t.Rotation[3]*py + t.Rotation[6]*pz),
+ float32(t.Rotation[1]*px + t.Rotation[4]*py + t.Rotation[7]*pz),
+ float32(t.Rotation[2]*px + t.Rotation[5]*py + t.Rotation[8]*pz),
+ }
+}
+
+// Layout rotates each half so its outward cut-face normal points to
+// −Z (cut face flat on the build plate), then places the two halves
+// side by side along +X with `gapMM` between them, centred on Y = 0
+// and resting on Z = 0. Vertex positions in result.Halves are
+// rewritten in place to bed coordinates. Returns the per-half
+// Transform that took original-mesh coords to those bed coords.
+//
+// Half 0's outward cap normal is +result.Plane.Normal; half 1's is
+// −result.Plane.Normal. Half 0 ends up to the −X side, half 1 to the
+// +X side.
+//
+// When result.CapUp[h] is set, half h is oriented cap-up instead
+// (outward cap normal points to +Z). This is used for the male-peg
+// half so the peg tips print pointing upward rather than hanging
+// off the build plate. The bbox-min-z=0 shift still applies, so the
+// half rests with its lowest non-cap point on the bed.
+func Layout(result *CutResult, gapMM float64) [2]Transform {
+ plane := result.Plane
+ var xforms [2]Transform
+
+ // Step 1: cap-to-bed (or cap-up) rotation per half.
+ capNormals := [2][3]float64{
+ plane.Normal,
+ {-plane.Normal[0], -plane.Normal[1], -plane.Normal[2]},
+ }
+ for h := 0; h < 2; h++ {
+ var R [9]float64
+ if result.CapUp[h] {
+ R = rotationToPosZ(capNormals[h])
+ } else {
+ R = rotationToNegZ(capNormals[h])
+ }
+ for i, v := range result.Halves[h].Vertices {
+ result.Halves[h].Vertices[i] = applyRotation(R, v)
+ }
+ xforms[h].Rotation = R
+ }
+
+ // Step 2: compute post-rotation bboxes; we need them for both the
+ // z-zero shift and the side-by-side xy placement.
+ bboxes := make([]struct {
+ minX, maxX float64
+ minY, maxY float64
+ minZ float64
+ }, 2)
+ for h := 0; h < 2; h++ {
+ bboxes[h].minX = math.Inf(1)
+ bboxes[h].maxX = math.Inf(-1)
+ bboxes[h].minY = math.Inf(1)
+ bboxes[h].maxY = math.Inf(-1)
+ bboxes[h].minZ = math.Inf(1)
+ for _, v := range result.Halves[h].Vertices {
+ x, y, z := float64(v[0]), float64(v[1]), float64(v[2])
+ if x < bboxes[h].minX {
+ bboxes[h].minX = x
+ }
+ if x > bboxes[h].maxX {
+ bboxes[h].maxX = x
+ }
+ if y < bboxes[h].minY {
+ bboxes[h].minY = y
+ }
+ if y > bboxes[h].maxY {
+ bboxes[h].maxY = y
+ }
+ if z < bboxes[h].minZ {
+ bboxes[h].minZ = z
+ }
+ }
+ }
+
+ // Step 3: composed translation per half.
+ // - z: shift so bbox.min.z = 0.
+ // - y: shift so y-centroid = 0.
+ // - x: half 0 has min.x = 0; half 1 has min.x = halfA.x_extent + gap.
+ halfAExtentX := bboxes[0].maxX - bboxes[0].minX
+ translations := [2][3]float64{
+ {
+ -bboxes[0].minX,
+ -(bboxes[0].minY + bboxes[0].maxY) / 2,
+ -bboxes[0].minZ,
+ },
+ {
+ -bboxes[1].minX + halfAExtentX + gapMM,
+ -(bboxes[1].minY + bboxes[1].maxY) / 2,
+ -bboxes[1].minZ,
+ },
+ }
+
+ for h := 0; h < 2; h++ {
+ for i, v := range result.Halves[h].Vertices {
+ result.Halves[h].Vertices[i] = [3]float32{
+ v[0] + float32(translations[h][0]),
+ v[1] + float32(translations[h][1]),
+ v[2] + float32(translations[h][2]),
+ }
+ }
+ xforms[h].Translation = translations[h]
+ }
+
+ return xforms
+}
+
+// rotationToNegZ returns the row-major 3×3 rotation that maps the unit
+// vector a to (0, 0, −1). Special-cased for the antipodal cases (a =
+// ±(0, 0, 1)) where the cross product would be zero.
+func rotationToNegZ(a [3]float64) [9]float64 {
+ target := [3]float64{0, 0, -1}
+ dot := a[0]*target[0] + a[1]*target[1] + a[2]*target[2]
+ const aligned = 1 - 1e-9
+ if dot > aligned {
+ return [9]float64{1, 0, 0, 0, 1, 0, 0, 0, 1}
+ }
+ if dot < -aligned {
+ // a is +Z; rotate 180° around X. Any axis perpendicular to Z
+ // would work; X chosen arbitrarily and consistently for
+ // reproducibility.
+ return [9]float64{1, 0, 0, 0, -1, 0, 0, 0, -1}
+ }
+ // Rodrigues' formula: axis = a × target (normalised), angle =
+ // acos(a · target).
+ ax := a[1]*target[2] - a[2]*target[1]
+ ay := a[2]*target[0] - a[0]*target[2]
+ az := a[0]*target[1] - a[1]*target[0]
+ axisLen := math.Sqrt(ax*ax + ay*ay + az*az)
+ ax /= axisLen
+ ay /= axisLen
+ az /= axisLen
+ angle := math.Acos(dot)
+ c := math.Cos(angle)
+ s := math.Sin(angle)
+ omc := 1 - c
+ return [9]float64{
+ c + ax*ax*omc, ax*ay*omc - az*s, ax*az*omc + ay*s,
+ ay*ax*omc + az*s, c + ay*ay*omc, ay*az*omc - ax*s,
+ az*ax*omc - ay*s, az*ay*omc + ax*s, c + az*az*omc,
+ }
+}
+
+// rotationToPosZ returns the row-major 3×3 rotation that maps the unit
+// vector a to (0, 0, +1). Used for cap-up layout.
+func rotationToPosZ(a [3]float64) [9]float64 {
+ target := [3]float64{0, 0, 1}
+ dot := a[0]*target[0] + a[1]*target[1] + a[2]*target[2]
+ const aligned = 1 - 1e-9
+ if dot > aligned {
+ return [9]float64{1, 0, 0, 0, 1, 0, 0, 0, 1}
+ }
+ if dot < -aligned {
+ // a is −Z; rotate 180° around X (mirrors rotationToNegZ's
+ // arbitrary-axis choice).
+ return [9]float64{1, 0, 0, 0, -1, 0, 0, 0, -1}
+ }
+ ax := a[1]*target[2] - a[2]*target[1]
+ ay := a[2]*target[0] - a[0]*target[2]
+ az := a[0]*target[1] - a[1]*target[0]
+ axisLen := math.Sqrt(ax*ax + ay*ay + az*az)
+ ax /= axisLen
+ ay /= axisLen
+ az /= axisLen
+ angle := math.Acos(dot)
+ c := math.Cos(angle)
+ s := math.Sin(angle)
+ omc := 1 - c
+ return [9]float64{
+ c + ax*ax*omc, ax*ay*omc - az*s, ax*az*omc + ay*s,
+ ay*ax*omc + az*s, c + ay*ay*omc, ay*az*omc - ax*s,
+ az*ax*omc - ay*s, az*ay*omc + ax*s, c + az*az*omc,
+ }
+}
+
+// applyRotation returns R · v for a row-major 3×3 rotation matrix R.
+func applyRotation(R [9]float64, v [3]float32) [3]float32 {
+ px, py, pz := float64(v[0]), float64(v[1]), float64(v[2])
+ return [3]float32{
+ float32(R[0]*px + R[1]*py + R[2]*pz),
+ float32(R[3]*px + R[4]*py + R[5]*pz),
+ float32(R[6]*px + R[7]*py + R[8]*pz),
+ }
+}
diff --git a/internal/split/layout_test.go b/internal/split/layout_test.go
new file mode 100644
index 0000000..909c347
--- /dev/null
+++ b/internal/split/layout_test.go
@@ -0,0 +1,340 @@
+package split
+
+import (
+ "math"
+ "testing"
+
+ "github.com/rtwfroody/ditherforge/internal/loader"
+)
+
+// TestLayout_UnitCubeAtMidplane — cube cut at z=0.5, no connectors.
+// Both halves should sit on z=0 with their cap faces flat on the bed,
+// disjoint along X with the requested gap, and centred on y=0.
+func TestLayout_UnitCubeAtMidplane(t *testing.T) {
+ cube := makeUnitCube()
+ res, err := Cut(cube, AxisPlane(2, 0.5), ConnectorSettings{})
+ if err != nil {
+ t.Fatalf("Cut: %v", err)
+ }
+ const gap = 0.2
+ xforms := Layout(res, gap)
+
+ // 1. Both halves rest on z=0.
+ for h := 0; h < 2; h++ {
+ minZ := math.Inf(1)
+ for _, v := range res.Halves[h].Vertices {
+ if float64(v[2]) < minZ {
+ minZ = float64(v[2])
+ }
+ }
+ if math.Abs(minZ) > 1e-5 {
+ t.Errorf("half %d: bbox min.z = %g, want 0", h, minZ)
+ }
+ }
+
+ // 2. Halves are disjoint along X with the requested gap.
+ bbox := func(h int) (minX, maxX float64) {
+ minX = math.Inf(1)
+ maxX = math.Inf(-1)
+ for _, v := range res.Halves[h].Vertices {
+ if float64(v[0]) < minX {
+ minX = float64(v[0])
+ }
+ if float64(v[0]) > maxX {
+ maxX = float64(v[0])
+ }
+ }
+ return
+ }
+ min0, max0 := bbox(0)
+ min1, max1 := bbox(1)
+ if math.Abs(min0) > 1e-5 {
+ t.Errorf("half 0 min.x = %g, want 0", min0)
+ }
+ if max0+gap > min1+1e-5 {
+ t.Errorf("halves overlap in x: half0.max=%g + gap=%g >= half1.min=%g", max0, gap, min1)
+ }
+ if math.Abs(min1-(max0+gap)) > 1e-5 {
+ t.Errorf("gap between halves: half1.min=%g, want %g (= half0.max %g + gap %g)", min1, max0+gap, max0, gap)
+ }
+ _ = max1
+
+ // 4. Both halves centred on y=0.
+ for h := 0; h < 2; h++ {
+ minY := math.Inf(1)
+ maxY := math.Inf(-1)
+ for _, v := range res.Halves[h].Vertices {
+ if float64(v[1]) < minY {
+ minY = float64(v[1])
+ }
+ if float64(v[1]) > maxY {
+ maxY = float64(v[1])
+ }
+ }
+ if math.Abs(minY+maxY) > 1e-5 {
+ t.Errorf("half %d not centred on y=0: minY=%g maxY=%g", h, minY, maxY)
+ }
+ }
+
+ // 5. Both halves remain watertight after layout.
+ for h := 0; h < 2; h++ {
+ assertWatertight(t, res.Halves[h], "laid-out half "+string(rune('0'+h)))
+ }
+
+ // 6. Inverse round-trip on cube vertices that are still inside
+ // the half's pre-layout extent (i.e., guaranteed to be in the
+ // half's vertex list under some coordinate).
+ for h := 0; h < 2; h++ {
+ var p [3]float32
+ if h == 0 {
+ p = [3]float32{0.5, 0.5, 0}
+ } else {
+ p = [3]float32{0.5, 0.5, 1}
+ }
+ pBed := xforms[h].Apply(p)
+ pBack := xforms[h].ApplyInverse(pBed)
+ dx := math.Abs(float64(pBack[0] - p[0]))
+ dy := math.Abs(float64(pBack[1] - p[1]))
+ dz := math.Abs(float64(pBack[2] - p[2]))
+ if dx > 1e-5 || dy > 1e-5 || dz > 1e-5 {
+ t.Errorf("half %d inverse round-trip: %v → %v → %v (Δ %g,%g,%g)", h, p, pBed, pBack, dx, dy, dz)
+ }
+ }
+}
+
+// TestLayout_PreservesVolume — total volume after layout = total
+// volume before layout. Rotations and translations are isometries, so
+// the per-half volume should be invariant.
+func TestLayout_PreservesVolume(t *testing.T) {
+ cube := makeUnitCube()
+ res, err := Cut(cube, AxisPlane(2, 0.5), ConnectorSettings{})
+ if err != nil {
+ t.Fatalf("Cut: %v", err)
+ }
+ beforeV0 := math.Abs(closedMeshVolume(res.Halves[0]))
+ beforeV1 := math.Abs(closedMeshVolume(res.Halves[1]))
+ Layout(res, 0.2)
+ afterV0 := math.Abs(closedMeshVolume(res.Halves[0]))
+ afterV1 := math.Abs(closedMeshVolume(res.Halves[1]))
+ if math.Abs(beforeV0-afterV0) > 1e-5 || math.Abs(beforeV1-afterV1) > 1e-5 {
+ t.Errorf("volumes changed across layout: half 0 %g→%g, half 1 %g→%g",
+ beforeV0, afterV0, beforeV1, afterV1)
+ }
+}
+
+// TestLayout_TransformMatchesMutation — the per-vertex equality test:
+// for every vertex in the laid-out result, xforms[h].Apply(orig)
+// should equal the post-Layout position. This is the test that
+// catches a row/column-major mixup or a sign flip in Apply.
+func TestLayout_TransformMatchesMutation(t *testing.T) {
+ cube := makeUnitCube()
+ res, err := Cut(cube, AxisPlane(2, 0.5), ConnectorSettings{})
+ if err != nil {
+ t.Fatalf("Cut: %v", err)
+ }
+ // Snapshot pre-Layout vertex arrays for both halves.
+ origVerts := [2][][3]float32{
+ append([][3]float32(nil), res.Halves[0].Vertices...),
+ append([][3]float32(nil), res.Halves[1].Vertices...),
+ }
+ xforms := Layout(res, 0.2)
+ for h := 0; h < 2; h++ {
+ for i, orig := range origVerts[h] {
+ want := res.Halves[h].Vertices[i]
+ got := xforms[h].Apply(orig)
+ dx := math.Abs(float64(got[0] - want[0]))
+ dy := math.Abs(float64(got[1] - want[1]))
+ dz := math.Abs(float64(got[2] - want[2]))
+ if dx > 1e-5 || dy > 1e-5 || dz > 1e-5 {
+ t.Errorf("half %d vertex %d: Apply(orig=%v) = %v, want %v (mutated value)", h, i, orig, got, want)
+ if i > 5 {
+ break // only report a few
+ }
+ }
+ }
+ }
+}
+
+// TestLayout_RoundTripCloud — round-trip through Apply + ApplyInverse
+// on every laid-out vertex returns the corresponding original vertex.
+func TestLayout_RoundTripCloud(t *testing.T) {
+ cube := makeUnitCube()
+ res, err := Cut(cube, AxisPlane(2, 0.5), ConnectorSettings{})
+ if err != nil {
+ t.Fatalf("Cut: %v", err)
+ }
+ origVerts := [2][][3]float32{
+ append([][3]float32(nil), res.Halves[0].Vertices...),
+ append([][3]float32(nil), res.Halves[1].Vertices...),
+ }
+ xforms := Layout(res, 0.2)
+ for h := 0; h < 2; h++ {
+ for i, orig := range origVerts[h] {
+ bed := res.Halves[h].Vertices[i]
+ back := xforms[h].ApplyInverse(bed)
+ d := math.Abs(float64(back[0]-orig[0])) +
+ math.Abs(float64(back[1]-orig[1])) +
+ math.Abs(float64(back[2]-orig[2]))
+ if d > 1e-4 {
+ t.Errorf("half %d vertex %d: bed=%v → orig %v, want %v (Δ=%g)", h, i, bed, back, orig, d)
+ if i > 5 {
+ break
+ }
+ }
+ }
+ }
+}
+
+// TestLayout_NonZAxisCut — exercise the Rodrigues body of
+// rotationToNegZ (not just the antipodal special cases) by cutting
+// along the X and Y axes.
+func TestLayout_NonZAxisCut(t *testing.T) {
+ for axis := 0; axis < 2; axis++ {
+ cube := makeUnitCube()
+ res, err := Cut(cube, AxisPlane(axis, 0.5), ConnectorSettings{})
+ if err != nil {
+ t.Fatalf("axis %d: Cut: %v", axis, err)
+ }
+ Layout(res, 0.2)
+
+ // Both halves should rest on z=0 and have their cap faces on
+ // the bed.
+ for h := 0; h < 2; h++ {
+ minZ := math.Inf(1)
+ for _, v := range res.Halves[h].Vertices {
+ if float64(v[2]) < minZ {
+ minZ = float64(v[2])
+ }
+ }
+ if math.Abs(minZ) > 1e-5 {
+ t.Errorf("axis %d half %d: bbox min.z=%g, want 0", axis, h, minZ)
+ }
+ assertWatertight(t, res.Halves[h], "non-z half "+string(rune('0'+h)))
+ }
+ }
+}
+
+// TestLayout_PegUp — Layout combined with a peg connector orients
+// the male half cap-up so the pegs print pointing upward.
+//
+// For a cube cut at z=25 with Pegs(depth=5):
+// - Half 0 in original coords spans z ∈ [0, 25] (body) plus
+// z ∈ [25, 30] (peg). Outward cap normal is +Z; CapUp[0]=true
+// so the layout rotation is identity (already +Z).
+// - bbox-min-z=0 leaves z extent [0, 30]: the body's z=0 face is
+// on the bed, the cap is at bed z=25, and the peg tips reach
+// bed z=30 (highest, pointing up).
+//
+// Verifies (a) the body face is on the bed (min.z=0), (b) the peg
+// tip is the highest point at bed z≈30, and (c) inverse round-trip
+// recovers the peg tip's original coords at z=30.
+func TestLayout_PegUp(t *testing.T) {
+ verts := [][3]float32{
+ {0, 0, 0}, {50, 0, 0}, {50, 50, 0}, {0, 50, 0},
+ {0, 0, 50}, {50, 0, 50}, {50, 50, 50}, {0, 50, 50},
+ }
+ faces := [][3]uint32{
+ {0, 2, 1}, {0, 3, 2}, {4, 5, 6}, {4, 6, 7},
+ {0, 1, 5}, {0, 5, 4}, {2, 3, 7}, {2, 7, 6},
+ {0, 4, 7}, {0, 7, 3}, {1, 2, 6}, {1, 6, 5},
+ }
+ cube := &loader.LoadedModel{Vertices: verts, Faces: faces}
+ settings := ConnectorSettings{
+ Style: Pegs, Count: 1, DiamMM: 4, DepthMM: 5, ClearanceMM: 0.15,
+ }
+ res, err := Cut(cube, AxisPlane(2, 25), settings)
+ if err != nil {
+ t.Fatalf("Cut: %v", err)
+ }
+ if !res.CapUp[0] || res.CapUp[1] {
+ t.Errorf("CapUp = %v, want [true, false] (peg-side half is half 0)", res.CapUp)
+ }
+ xforms := Layout(res, 5)
+
+ half0 := res.Halves[0]
+ minZ := math.Inf(1)
+ maxZ := math.Inf(-1)
+ for _, v := range half0.Vertices {
+ z := float64(v[2])
+ if z < minZ {
+ minZ = z
+ }
+ if z > maxZ {
+ maxZ = z
+ }
+ }
+ if math.Abs(minZ) > 1e-5 {
+ t.Errorf("half 0 min.z = %g, want 0 (body face on bed)", minZ)
+ }
+ if math.Abs(maxZ-30) > 0.5 {
+ t.Errorf("half 0 max.z = %g, want ≈ 30 (peg tip points up)", maxZ)
+ }
+
+ // Inverse round-trip on the highest-z vertex (peg tip) should
+ // recover original coords at z = 30 (cap depth + peg depth).
+ var tipBed [3]float32
+ for _, v := range half0.Vertices {
+ if float64(v[2]) > maxZ-0.01 {
+ tipBed = v
+ break
+ }
+ }
+ tipOrig := xforms[0].ApplyInverse(tipBed)
+ if math.Abs(float64(tipOrig[2])-30) > 0.1 {
+ t.Errorf("peg tip orig z = %g, want 30 (cap z=25 + peg depth=5)", tipOrig[2])
+ }
+}
+
+// TestLayout_TransformOnPlanePoints — plane vertices in original
+// coords should map to z=0 in bed coords via Transform.Apply.
+func TestLayout_TransformOnPlanePoints(t *testing.T) {
+ cube := makeUnitCube()
+ res, err := Cut(cube, AxisPlane(2, 0.5), ConnectorSettings{})
+ if err != nil {
+ t.Fatalf("Cut: %v", err)
+ }
+ origPoints := []struct {
+ half int
+ point [3]float32
+ }{
+ {0, [3]float32{0, 0, 0.5}},
+ {0, [3]float32{1, 1, 0.5}},
+ {1, [3]float32{0.5, 0.5, 0.5}},
+ }
+ xforms := Layout(res, 0.2)
+ for _, op := range origPoints {
+ pBed := xforms[op.half].Apply(op.point)
+ if math.Abs(float64(pBed[2])) > 1e-5 {
+ t.Errorf("plane point %v in half %d → bed %v: z != 0", op.point, op.half, pBed)
+ }
+ }
+}
+
+// TestRotationToNegZ_AlignsCorrectly — sanity check the rotation
+// utility: applying the rotation to the input cap normal should
+// produce (0, 0, −1) within float precision, for several axis
+// choices.
+func TestRotationToNegZ_AlignsCorrectly(t *testing.T) {
+ cases := []struct {
+ name string
+ a [3]float64
+ }{
+ {"+Z", [3]float64{0, 0, 1}},
+ {"-Z", [3]float64{0, 0, -1}},
+ {"+X", [3]float64{1, 0, 0}},
+ {"-X", [3]float64{-1, 0, 0}},
+ {"+Y", [3]float64{0, 1, 0}},
+ {"-Y", [3]float64{0, -1, 0}},
+ }
+ for _, c := range cases {
+ R := rotationToNegZ(c.a)
+ got := applyRotation(R, [3]float32{float32(c.a[0]), float32(c.a[1]), float32(c.a[2])})
+ want := [3]float32{0, 0, -1}
+ dx := math.Abs(float64(got[0] - want[0]))
+ dy := math.Abs(float64(got[1] - want[1]))
+ dz := math.Abs(float64(got[2] - want[2]))
+ if dx > 1e-5 || dy > 1e-5 || dz > 1e-5 {
+ t.Errorf("%s: rotation maps to %v, want %v", c.name, got, want)
+ }
+ }
+}
diff --git a/internal/split/placement.go b/internal/split/placement.go
new file mode 100644
index 0000000..13a31e3
--- /dev/null
+++ b/internal/split/placement.go
@@ -0,0 +1,378 @@
+package split
+
+import (
+ "fmt"
+ "math"
+ "sort"
+)
+
+// placePegs picks N peg-center positions inside the given polygon
+// (with holes), spaced reasonably far apart. The polygon is in 2D
+// plane-basis coordinates (the same basis recoverCapPolygons emits).
+//
+// boundaryClearance is the minimum distance every peg center must
+// keep from the polygon boundary (outer loop and any hole). The
+// caller passes the peg diameter so a circle of 2× the peg diameter
+// can fit fully inside the polygon around each peg center, leaving
+// peg-radius worth of wall around every peg. Pixels closer than
+// boundaryClearance to the boundary are excluded from candidacy.
+//
+// Algorithm: rasterize the polygon into a binary mask at fixed
+// resolution, run a multi-source BFS distance transform from the
+// outside-mask to find each inside pixel's distance to the boundary,
+// then place pegs greedily — first at the inside pixel closest to
+// the polygon centroid, and each subsequent peg at the inside pixel
+// maximally far from all previously placed pegs (subject to
+// boundary-clearance).
+//
+// Returns peg centers in polygon coordinates. Returns an error if no
+// inside pixels survive the clearance erosion (polygon too small for
+// the requested clearance, or polygon too thin to fit a peg).
+//
+// For polygons-with-multiple-components callers should call this
+// once per polygon, dividing N proportionally to area.
+func placePegs(poly capPolygon, count int, minSpacing, boundaryClearance float64) ([][2]float64, error) {
+ if count <= 0 {
+ return nil, fmt.Errorf("placePegs: count must be positive, got %d", count)
+ }
+ if len(poly.outer) < 3 {
+ return nil, fmt.Errorf("placePegs: outer loop has < 3 vertices")
+ }
+
+ // Bbox.
+ minX, minY := math.Inf(1), math.Inf(1)
+ maxX, maxY := math.Inf(-1), math.Inf(-1)
+ for _, p := range poly.outer {
+ if p[0] < minX {
+ minX = p[0]
+ }
+ if p[0] > maxX {
+ maxX = p[0]
+ }
+ if p[1] < minY {
+ minY = p[1]
+ }
+ if p[1] > maxY {
+ maxY = p[1]
+ }
+ }
+ dx := maxX - minX
+ dy := maxY - minY
+ if dx <= 0 || dy <= 0 {
+ return nil, fmt.Errorf("placePegs: degenerate bounding box")
+ }
+
+ // Resolution: 200 pixels along the longer axis. The grid is padded
+ // by one pixel of guaranteed-outside on every side so the
+ // chamfer distance transform always has seed pixels — without
+ // that pad, a polygon that fills its bbox (e.g. an axis-aligned
+ // rectangle) starts the scan with zero outside cells, the
+ // propagation never reaches the inside pixels, and every inside
+ // pixel ends up with a fictitious "very large" distance.
+ const targetRes = 200
+ step := math.Max(dx, dy) / targetRes
+ innerW := int(math.Ceil(dx/step)) + 1
+ innerH := int(math.Ceil(dy/step)) + 1
+ W := innerW + 2
+ H := innerH + 2
+
+ // Rasterize: pixel (i, j) of the padded grid corresponds to world
+ // (minX + (i-1)*step, minY + (j-1)*step). The 1-pixel border
+ // (i==0, i==W-1, j==0, j==H-1) is always mask=false.
+ mask := make([]bool, W*H)
+ for j := 1; j < H-1; j++ {
+ y := minY + float64(j-1)*step
+ for i := 1; i < W-1; i++ {
+ x := minX + float64(i-1)*step
+ p := [2]float64{x, y}
+ if !pointInPolygon2D(p, poly.outer) {
+ continue
+ }
+ inHole := false
+ for _, h := range poly.holes {
+ if pointInPolygon2D(p, h) {
+ inHole = true
+ break
+ }
+ }
+ if inHole {
+ continue
+ }
+ mask[j*W+i] = true
+ }
+ }
+
+ // Distance transform: dist[idx] = euclidean distance from pixel
+ // idx to the nearest non-mask (outside or hole) pixel. Multi-
+ // source BFS using chamfer 3-4 distances (a cheap approximation
+ // of the L2 distance, exact to a few percent).
+ dist := computeDistanceTransform(mask, W, H, step)
+
+ // Build the eligible-set: inside pixels whose distance-to-
+ // boundary >= boundaryClearance. If the clearance erodes
+ // everything, fall back to the unrestricted inside-set so we
+ // don't silently produce zero pegs on a small polygon.
+ insideIdx := make([]int, 0, W*H/2)
+ for idx, ok := range mask {
+ if !ok {
+ continue
+ }
+ if dist[idx] < boundaryClearance {
+ continue
+ }
+ insideIdx = append(insideIdx, idx)
+ }
+ if len(insideIdx) == 0 {
+ // Polygon too small for the requested clearance — fall back
+ // to "any inside pixel" so the user gets at least one peg
+ // placed in the most-interior location, rather than a
+ // silent no-op.
+ for idx, ok := range mask {
+ if !ok {
+ continue
+ }
+ insideIdx = append(insideIdx, idx)
+ }
+ }
+ if len(insideIdx) == 0 {
+ return nil, fmt.Errorf("placePegs: polygon mask is empty (polygon too small for resolution %d)", targetRes)
+ }
+
+ pixelToWorld := func(idx int) [2]float64 {
+ i := idx % W
+ j := idx / W
+ // Padded grid: pixel (i, j) ↔ world (minX + (i-1)*step, minY + (j-1)*step).
+ return [2]float64{minX + float64(i-1)*step, minY + float64(j-1)*step}
+ }
+
+ // Centroid of the polygon (use mask centroid for robustness with holes).
+ var cx, cy float64
+ for _, idx := range insideIdx {
+ p := pixelToWorld(idx)
+ cx += p[0]
+ cy += p[1]
+ }
+ cx /= float64(len(insideIdx))
+ cy /= float64(len(insideIdx))
+
+ // First peg: inside pixel closest to centroid.
+ first := insideIdx[0]
+ bestDist2 := math.Inf(1)
+ for _, idx := range insideIdx {
+ p := pixelToWorld(idx)
+ d2 := (p[0]-cx)*(p[0]-cx) + (p[1]-cy)*(p[1]-cy)
+ if d2 < bestDist2 {
+ bestDist2 = d2
+ first = idx
+ }
+ }
+ placed := []int{first}
+ pegs := [][2]float64{pixelToWorld(first)}
+
+ // Subsequent pegs: greedy farthest-point.
+ for k := 1; k < count; k++ {
+ bestIdx := -1
+ bestMinD2 := -1.0
+ for _, idx := range insideIdx {
+ p := pixelToWorld(idx)
+ minD2 := math.Inf(1)
+ for _, pidx := range placed {
+ q := pixelToWorld(pidx)
+ d2 := (p[0]-q[0])*(p[0]-q[0]) + (p[1]-q[1])*(p[1]-q[1])
+ if d2 < minD2 {
+ minD2 = d2
+ }
+ }
+ if minD2 > bestMinD2 {
+ bestMinD2 = minD2
+ bestIdx = idx
+ }
+ }
+ // Reject if minimum spacing would be violated (only for
+ // minSpacing > 0; placement is best-effort otherwise).
+ if minSpacing > 0 && bestMinD2 < minSpacing*minSpacing {
+ break
+ }
+ if bestIdx < 0 {
+ break
+ }
+ placed = append(placed, bestIdx)
+ pegs = append(pegs, pixelToWorld(bestIdx))
+ }
+
+ return pegs, nil
+}
+
+// placePegsInPolygons distributes count pegs across multiple polygon
+// components, allocating count proportionally to area. Each component
+// gets at least 1 if count >= number of components; otherwise the
+// largest components get a peg first.
+func placePegsInPolygons(polys []capPolygon, count int, minSpacing, boundaryClearance float64) ([][2]float64, error) {
+ if len(polys) == 0 {
+ return nil, fmt.Errorf("placePegsInPolygons: no polygons")
+ }
+ if count <= 0 {
+ return nil, fmt.Errorf("placePegsInPolygons: count must be positive")
+ }
+ if len(polys) == 1 {
+ return placePegs(polys[0], count, minSpacing, boundaryClearance)
+ }
+
+ // Score each polygon by net area (outer minus holes).
+ type polyArea struct {
+ idx int
+ area float64
+ }
+ areas := make([]polyArea, len(polys))
+ totalArea := 0.0
+ for i, p := range polys {
+ a := math.Abs(signedArea2D(p.outer))
+ for _, h := range p.holes {
+ a -= math.Abs(signedArea2D(h))
+ }
+ if a < 0 {
+ a = 0
+ }
+ areas[i] = polyArea{i, a}
+ totalArea += a
+ }
+ if totalArea <= 0 {
+ return nil, fmt.Errorf("placePegsInPolygons: all polygons have zero area")
+ }
+
+ // Allocate counts by largest-remainder method.
+ allocs := make([]int, len(polys))
+ type remainder struct {
+ idx int
+ frac float64
+ }
+ rems := make([]remainder, 0, len(polys))
+ used := 0
+ for i, pa := range areas {
+ exact := float64(count) * pa.area / totalArea
+ whole := int(math.Floor(exact))
+ allocs[i] = whole
+ used += whole
+ rems = append(rems, remainder{i, exact - float64(whole)})
+ }
+ sort.Slice(rems, func(i, j int) bool { return rems[i].frac > rems[j].frac })
+ for k := 0; used < count && k < len(rems); k++ {
+ allocs[rems[k].idx]++
+ used++
+ }
+
+ var out [][2]float64
+ for i, n := range allocs {
+ if n == 0 {
+ continue
+ }
+ pegs, err := placePegs(polys[i], n, minSpacing, boundaryClearance)
+ if err != nil {
+ // Skip this polygon; others still contribute.
+ continue
+ }
+ out = append(out, pegs...)
+ }
+ if len(out) == 0 {
+ return nil, fmt.Errorf("placePegsInPolygons: no pegs placed")
+ }
+ return out, nil
+}
+
+// computeDistanceTransform returns, for each pixel in the W×H grid, its
+// Euclidean distance to the nearest non-mask (false) pixel. mask[idx] ==
+// true means "inside the polygon"; the returned distance is in world
+// units (multiplied by `step`).
+//
+// Implementation: chamfer 3-4 distance transform — two raster passes
+// (forward, then backward) using neighbor offsets {3, 4} for 4- and
+// 8-connected neighbors respectively, scaled by step/3. This is exact
+// enough for placement (a few percent off true L2) and runs in O(W·H).
+func computeDistanceTransform(mask []bool, W, H int, step float64) []float64 {
+ const inf = math.MaxFloat64
+ dist := make([]float64, W*H)
+ // Initialize: outside pixels = 0, inside pixels = +inf.
+ for i, ok := range mask {
+ if ok {
+ dist[i] = inf
+ } else {
+ dist[i] = 0
+ }
+ }
+ // Chamfer offsets in pixel-distance units; scale by step/3 to
+ // recover world-distance.
+ const a = 3.0 // 4-connected
+ const b = 4.0 // 8-connected (diagonal)
+ scale := step / 3.0
+
+ // Forward pass: top-left to bottom-right, neighbors above/left.
+ for j := 0; j < H; j++ {
+ for i := 0; i < W; i++ {
+ idx := j*W + i
+ if dist[idx] == 0 {
+ continue
+ }
+ best := dist[idx]
+ if j > 0 {
+ if v := dist[(j-1)*W+i] + a*scale; v < best {
+ best = v
+ }
+ if i > 0 {
+ if v := dist[(j-1)*W+(i-1)] + b*scale; v < best {
+ best = v
+ }
+ }
+ if i < W-1 {
+ if v := dist[(j-1)*W+(i+1)] + b*scale; v < best {
+ best = v
+ }
+ }
+ }
+ if i > 0 {
+ if v := dist[j*W+(i-1)] + a*scale; v < best {
+ best = v
+ }
+ }
+ dist[idx] = best
+ }
+ }
+ // Backward pass: bottom-right to top-left, neighbors below/right.
+ for j := H - 1; j >= 0; j-- {
+ for i := W - 1; i >= 0; i-- {
+ idx := j*W + i
+ if dist[idx] == 0 {
+ continue
+ }
+ best := dist[idx]
+ if j < H-1 {
+ if v := dist[(j+1)*W+i] + a*scale; v < best {
+ best = v
+ }
+ if i > 0 {
+ if v := dist[(j+1)*W+(i-1)] + b*scale; v < best {
+ best = v
+ }
+ }
+ if i < W-1 {
+ if v := dist[(j+1)*W+(i+1)] + b*scale; v < best {
+ best = v
+ }
+ }
+ }
+ if i < W-1 {
+ if v := dist[j*W+(i+1)] + a*scale; v < best {
+ best = v
+ }
+ }
+ dist[idx] = best
+ }
+ }
+ // Clamp residual +inf (entirely-inside polygon, no nearby outside)
+ // to a large finite value so callers don't see NaN.
+ for i := range dist {
+ if dist[i] == inf {
+ dist[i] = math.Max(float64(W), float64(H)) * step
+ }
+ }
+ return dist
+}
diff --git a/internal/split/placement_test.go b/internal/split/placement_test.go
new file mode 100644
index 0000000..97b3795
--- /dev/null
+++ b/internal/split/placement_test.go
@@ -0,0 +1,141 @@
+package split
+
+import (
+ "math"
+ "testing"
+)
+
+// TestPlacePegs_UnitSquareSpread verifies that 4 pegs in a unit
+// square aren't clustered — pairwise distances should be reasonably
+// spread.
+func TestPlacePegs_UnitSquareSpread(t *testing.T) {
+ square := capPolygon{
+ outer: [][2]float64{{0, 0}, {1, 0}, {1, 1}, {0, 1}},
+ }
+ pegs, err := placePegs(square, 4, 0, 0)
+ if err != nil {
+ t.Fatalf("placePegs: %v", err)
+ }
+ if len(pegs) != 4 {
+ t.Fatalf("got %d pegs, want 4", len(pegs))
+ }
+ // All inside the square.
+ for i, p := range pegs {
+ if p[0] < 0 || p[0] > 1 || p[1] < 0 || p[1] > 1 {
+ t.Errorf("peg %d at %v outside unit square", i, p)
+ }
+ }
+ // Min pairwise distance >= 0.4. A truly clustered placement (all
+ // near centroid) would yield distances ~0.05; this threshold
+ // flags clustering without demanding optimal packing.
+ minD := math.Inf(1)
+ for i := 0; i < len(pegs); i++ {
+ for j := i + 1; j < len(pegs); j++ {
+ dx := pegs[i][0] - pegs[j][0]
+ dy := pegs[i][1] - pegs[j][1]
+ d := math.Sqrt(dx*dx + dy*dy)
+ if d < minD {
+ minD = d
+ }
+ }
+ }
+ if minD < 0.4 {
+ t.Errorf("min pairwise distance = %g, want >= 0.4 (pegs are clustered)", minD)
+ }
+}
+
+// TestPlacePegs_LShapeSpread — for an L-shaped polygon (a non-convex
+// shape) and N=2 pegs, verify the pegs are reasonably spread. The
+// L-shape has a longest internal distance ≈ 2.83 (corner-to-corner),
+// so a sane greedy placement should yield pegs at least 1.0 apart.
+func TestPlacePegs_LShapeSpread(t *testing.T) {
+ // L-shape (CCW): (0,0) -> (2,0) -> (2,1) -> (1,1) -> (1,2) -> (0,2) -> (0,0)
+ lshape := capPolygon{
+ outer: [][2]float64{
+ {0, 0}, {2, 0}, {2, 1}, {1, 1}, {1, 2}, {0, 2},
+ },
+ }
+ pegs, err := placePegs(lshape, 2, 0, 0)
+ if err != nil {
+ t.Fatalf("placePegs: %v", err)
+ }
+ if len(pegs) != 2 {
+ t.Fatalf("got %d pegs, want 2", len(pegs))
+ }
+ dx := pegs[0][0] - pegs[1][0]
+ dy := pegs[0][1] - pegs[1][1]
+ d := math.Sqrt(dx*dx + dy*dy)
+ if d < 1.0 {
+ t.Errorf("L-shape pegs at %v %v, distance %g; want >= 1.0", pegs[0], pegs[1], d)
+ }
+}
+
+// TestPlacePegs_HoleAvoided checks that a peg isn't placed inside a
+// polygon hole.
+func TestPlacePegs_HoleAvoided(t *testing.T) {
+ // Square outer 4×4, hole 1.5×1.5 in the center.
+ poly := capPolygon{
+ outer: [][2]float64{{0, 0}, {4, 0}, {4, 4}, {0, 4}},
+ holes: [][][2]float64{
+ {{1.25, 2.75}, {2.75, 2.75}, {2.75, 1.25}, {1.25, 1.25}}, // CW
+ },
+ }
+ pegs, err := placePegs(poly, 1, 0, 0)
+ if err != nil {
+ t.Fatalf("placePegs: %v", err)
+ }
+ if len(pegs) != 1 {
+ t.Fatalf("got %d pegs, want 1", len(pegs))
+ }
+ p := pegs[0]
+ // Peg shouldn't be in the hole.
+ if p[0] >= 1.25 && p[0] <= 2.75 && p[1] >= 1.25 && p[1] <= 2.75 {
+ t.Errorf("peg at %v lies inside the hole", p)
+ }
+ // Peg should be inside the outer square.
+ if p[0] < 0 || p[0] > 4 || p[1] < 0 || p[1] > 4 {
+ t.Errorf("peg at %v outside outer square", p)
+ }
+}
+
+// TestPlacePegs_BoundaryClearance verifies pegs sit at least
+// boundaryClearance from every edge of the polygon. With a 10×10
+// square and clearance 2, every peg must lie within [2, 8] × [2, 8].
+func TestPlacePegs_BoundaryClearance(t *testing.T) {
+ square := capPolygon{
+ outer: [][2]float64{{0, 0}, {10, 0}, {10, 10}, {0, 10}},
+ }
+ pegs, err := placePegs(square, 4, 0, 2.0)
+ if err != nil {
+ t.Fatalf("placePegs: %v", err)
+ }
+ if len(pegs) != 4 {
+ t.Fatalf("got %d pegs, want 4", len(pegs))
+ }
+ // Allow one-pixel slack for rasterization (10mm / 200px = 0.05mm).
+ const slack = 0.1
+ for i, p := range pegs {
+ if p[0] < 2.0-slack || p[0] > 8.0+slack || p[1] < 2.0-slack || p[1] > 8.0+slack {
+ t.Errorf("peg %d at %v violates boundary clearance 2.0 (must be in [2,8]×[2,8])", i, p)
+ }
+ }
+}
+
+// TestPlacePegs_SinglePeg with count=1 places near centroid.
+func TestPlacePegs_SinglePeg(t *testing.T) {
+ square := capPolygon{
+ outer: [][2]float64{{0, 0}, {2, 0}, {2, 2}, {0, 2}},
+ }
+ pegs, err := placePegs(square, 1, 0, 0)
+ if err != nil {
+ t.Fatalf("placePegs: %v", err)
+ }
+ if len(pegs) != 1 {
+ t.Fatalf("got %d pegs, want 1", len(pegs))
+ }
+ p := pegs[0]
+ // Centroid is (1, 1). Single-peg placement should be near it.
+ if math.Abs(p[0]-1) > 0.2 || math.Abs(p[1]-1) > 0.2 {
+ t.Errorf("single peg at %v, want near (1, 1)", p)
+ }
+}
diff --git a/internal/split/split.go b/internal/split/split.go
new file mode 100644
index 0000000..9534348
--- /dev/null
+++ b/internal/split/split.go
@@ -0,0 +1,159 @@
+// Package split cuts a watertight mesh by a plane, producing two
+// closed-watertight halves. Cutting is delegated to CGAL's
+// Polygon_mesh_processing::clip via internal/cgalclip; the cap
+// surface is added by CGAL during the clip, so this package no
+// longer hand-rolls per-triangle classification, cut-polygon
+// recovery, or cap triangulation. Connectors and bed layout still
+// live here (connectors.go, layout.go).
+package split
+
+import (
+ "fmt"
+ "math"
+ "sync"
+
+ "github.com/rtwfroody/ditherforge/internal/cgalclip"
+ "github.com/rtwfroody/ditherforge/internal/loader"
+)
+
+// Plane is a 3D plane in original-mesh coordinates. A point p lies on
+// the plane iff Normal·p == D. Normal must be unit-length.
+type Plane struct {
+ Normal [3]float64
+ D float64
+}
+
+// ConnectorStyle selects what alignment features Cut bakes into the
+// cut faces.
+type ConnectorStyle int
+
+const (
+ // NoConnectors leaves both caps as flat planar surfaces.
+ NoConnectors ConnectorStyle = iota
+ // Pegs places a solid cylindrical peg on half 0's cap and a
+ // matching cylindrical pocket on half 1's cap. Female radius =
+ // peg radius + clearance.
+ Pegs
+ // Dowels punches matching cylindrical holes in both caps. Both
+ // holes are oversized by clearance. The user prints separate
+ // dowels (or uses hardware-store steel pins).
+ Dowels
+)
+
+// ConnectorSettings controls connector placement and dimensions. The
+// zero value (Style=NoConnectors) leaves caps flat.
+type ConnectorSettings struct {
+ Style ConnectorStyle
+ Count int // 0 = auto; 1..3 explicit
+ DiamMM float64 // peg/dowel diameter in mm
+ DepthMM float64 // peg/pocket depth (per side for Dowels)
+ ClearanceMM float64 // per-side radial clearance applied to female features
+}
+
+// AxisPlane builds a Plane perpendicular to one of the principal
+// axes (axis: 0=X, 1=Y, 2=Z) at the given offset along that axis.
+// Normal points in +axis direction. Invalid axis values fall back to
+// Z; callers that can't tolerate that should validate before calling.
+func AxisPlane(axis int, offset float64) Plane {
+ if axis < 0 || axis > 2 {
+ axis = 2
+ }
+ var n [3]float64
+ n[axis] = 1
+ return Plane{Normal: n, D: offset}
+}
+
+// CutResult is the output of Cut. Halves[0] and Halves[1] are
+// independent closed-watertight meshes corresponding to the negative
+// and positive sides of the plane respectively. Plane is the cut plane
+// that produced this result, stored so phase-3 Layout can find the
+// cap normal without the caller needing to keep track separately.
+//
+// CapUp[h] requests that Layout orient half h with its cap normal
+// pointing to +Z (cap-side up) rather than the default −Z (cap-side
+// down on the build plate). Set true on the half that carries male
+// pegs so the peg tips print upward instead of being printed
+// hanging-in-air. Default-false matches the original cap-down
+// behaviour for NoConnectors and Dowels.
+//
+// Cap faces aren't tracked separately — they're just part of each
+// half's face list. Callers that need to identify the cap should
+// match face normals against the plane normal.
+type CutResult struct {
+ Halves [2]*loader.LoadedModel
+ Plane Plane
+ CapUp [2]bool
+}
+
+// Cut splits a watertight model by a plane, producing two closed
+// halves. CGAL's clip handles all the geometry — vertex
+// classification, cut-polygon recovery, cap triangulation, and
+// multi-component / nested-cavity cases — robustly via exact
+// predicates.
+//
+// When connectors.Style is Pegs or Dowels, applyConnectors recovers
+// the cap polygon, places connector centers, builds peg/pocket
+// cylinders, and applies CGAL boolean operations to bake them into
+// the halves. Per-connector failures isolate: any one failure logs a
+// warning and the rest of the pipeline continues. Total connector
+// failure leaves the halves with flat caps.
+func Cut(model *loader.LoadedModel, plane Plane, connectors ConnectorSettings) (*CutResult, error) {
+ if model == nil || len(model.Vertices) == 0 || len(model.Faces) == 0 {
+ return nil, fmt.Errorf("split.Cut: empty model")
+ }
+ if !isUnitNormal(plane.Normal) {
+ return nil, fmt.Errorf("split.Cut: plane normal is not unit-length: %v", plane.Normal)
+ }
+
+ // Clip both halves concurrently. Each call pays the full CGAL
+ // setup cost (mesh build + clip), but they're independent and
+ // CPU-bound, so wall time roughly halves on multi-core machines.
+ var (
+ halves [2]*loader.LoadedModel
+ errs [2]error
+ wg sync.WaitGroup
+ )
+ wg.Add(2)
+ // Half 0 (negative side): keep where Normal·p <= D.
+ go func() {
+ defer wg.Done()
+ halves[0], errs[0] = cgalclip.Clip(model, plane.Normal, plane.D)
+ }()
+ // Half 1 (positive side): pass the flipped plane, so CGAL keeps
+ // where -Normal·p <= -D (equivalently Normal·p >= D).
+ go func() {
+ defer wg.Done()
+ negNormal := [3]float64{-plane.Normal[0], -plane.Normal[1], -plane.Normal[2]}
+ halves[1], errs[1] = cgalclip.Clip(model, negNormal, -plane.D)
+ }()
+ wg.Wait()
+
+ for i := range errs {
+ if errs[i] != nil {
+ return nil, fmt.Errorf("split.Cut: half %d: %w", i, errs[i])
+ }
+ }
+
+ halves = applyConnectors(halves, plane, connectors)
+
+ // For Pegs, half 0 carries the male peg geometry. Flip its layout
+ // so the peg side prints up — saves the user from a peg printed
+ // hanging upside-down with no support. Dowels stay cap-down on
+ // both halves (pockets are interior to the half, no overhang).
+ var capUp [2]bool
+ if connectors.Style == Pegs {
+ capUp[0] = true
+ }
+
+ return &CutResult{
+ Halves: halves,
+ Plane: plane,
+ CapUp: capUp,
+ }, nil
+}
+
+// isUnitNormal reports whether n has length within 1e-6 of 1.
+func isUnitNormal(n [3]float64) bool {
+ l2 := n[0]*n[0] + n[1]*n[1] + n[2]*n[2]
+ return math.Abs(l2-1) < 1e-6
+}
diff --git a/internal/split/split_test.go b/internal/split/split_test.go
new file mode 100644
index 0000000..4089b3c
--- /dev/null
+++ b/internal/split/split_test.go
@@ -0,0 +1,410 @@
+package split
+
+import (
+ "math"
+ "testing"
+
+ "github.com/rtwfroody/ditherforge/internal/loader"
+)
+
+// makeUnitCube builds a closed watertight unit cube spanning [0,1]^3
+// with 12 triangles (2 per face). All faces are CCW from the outside.
+func makeUnitCube() *loader.LoadedModel {
+ v := [][3]float32{
+ {0, 0, 0}, // 0
+ {1, 0, 0}, // 1
+ {1, 1, 0}, // 2
+ {0, 1, 0}, // 3
+ {0, 0, 1}, // 4
+ {1, 0, 1}, // 5
+ {1, 1, 1}, // 6
+ {0, 1, 1}, // 7
+ }
+ f := [][3]uint32{
+ // bottom (z=0), normal -z
+ {0, 2, 1}, {0, 3, 2},
+ // top (z=1), normal +z
+ {4, 5, 6}, {4, 6, 7},
+ // y=0, normal -y
+ {0, 1, 5}, {0, 5, 4},
+ // y=1, normal +y
+ {2, 3, 7}, {2, 7, 6},
+ // x=0, normal -x
+ {0, 4, 7}, {0, 7, 3},
+ // x=1, normal +x
+ {1, 2, 6}, {1, 6, 5},
+ }
+ return &loader.LoadedModel{
+ Vertices: v,
+ Faces: f,
+ }
+}
+
+// makeIcosphere returns a unit-radius icosphere centred at the origin
+// with `subdiv` levels of subdivision (subdiv=0 is the base
+// icosahedron, ≈20 faces; subdiv=2 is ≈320 faces). Always closed and
+// watertight.
+func makeIcosphere(subdiv int) *loader.LoadedModel {
+ t := float32((1 + math.Sqrt(5)) / 2)
+ verts := [][3]float32{
+ {-1, t, 0}, {1, t, 0}, {-1, -t, 0}, {1, -t, 0},
+ {0, -1, t}, {0, 1, t}, {0, -1, -t}, {0, 1, -t},
+ {t, 0, -1}, {t, 0, 1}, {-t, 0, -1}, {-t, 0, 1},
+ }
+ for i := range verts {
+ x, y, z := float64(verts[i][0]), float64(verts[i][1]), float64(verts[i][2])
+ l := math.Sqrt(x*x + y*y + z*z)
+ verts[i] = [3]float32{float32(x / l), float32(y / l), float32(z / l)}
+ }
+ faces := [][3]uint32{
+ {0, 11, 5}, {0, 5, 1}, {0, 1, 7}, {0, 7, 10}, {0, 10, 11},
+ {1, 5, 9}, {5, 11, 4}, {11, 10, 2}, {10, 7, 6}, {7, 1, 8},
+ {3, 9, 4}, {3, 4, 2}, {3, 2, 6}, {3, 6, 8}, {3, 8, 9},
+ {4, 9, 5}, {2, 4, 11}, {6, 2, 10}, {8, 6, 7}, {9, 8, 1},
+ }
+ for s := 0; s < subdiv; s++ {
+ mid := make(map[uint64]uint32)
+ midpoint := func(a, b uint32) uint32 {
+ lo, hi := a, b
+ if lo > hi {
+ lo, hi = hi, lo
+ }
+ key := uint64(lo)<<32 | uint64(hi)
+ if idx, ok := mid[key]; ok {
+ return idx
+ }
+ va, vb := verts[a], verts[b]
+ m := [3]float32{
+ (va[0] + vb[0]) / 2,
+ (va[1] + vb[1]) / 2,
+ (va[2] + vb[2]) / 2,
+ }
+ x, y, z := float64(m[0]), float64(m[1]), float64(m[2])
+ l := math.Sqrt(x*x + y*y + z*z)
+ m = [3]float32{float32(x / l), float32(y / l), float32(z / l)}
+ idx := uint32(len(verts))
+ verts = append(verts, m)
+ mid[key] = idx
+ return idx
+ }
+ var newFaces [][3]uint32
+ for _, f := range faces {
+ a := midpoint(f[0], f[1])
+ b := midpoint(f[1], f[2])
+ c := midpoint(f[2], f[0])
+ newFaces = append(newFaces,
+ [3]uint32{f[0], a, c},
+ [3]uint32{f[1], b, a},
+ [3]uint32{f[2], c, b},
+ [3]uint32{a, b, c},
+ )
+ }
+ faces = newFaces
+ }
+ return &loader.LoadedModel{Vertices: verts, Faces: faces}
+}
+
+// edgeKey32 is a small undirected edge key used by the watertight check.
+type edgeKey32 struct{ a, b uint32 }
+
+func edgeOf(a, b uint32) edgeKey32 {
+ if a < b {
+ return edgeKey32{a, b}
+ }
+ return edgeKey32{b, a}
+}
+
+// assertWatertight verifies every edge of model.Faces has exactly two
+// incident faces. Returns the count of non-2 edges (0 = watertight).
+func assertWatertight(t *testing.T, model *loader.LoadedModel, name string) {
+ t.Helper()
+ counts := make(map[edgeKey32]int)
+ for _, f := range model.Faces {
+ counts[edgeOf(f[0], f[1])]++
+ counts[edgeOf(f[1], f[2])]++
+ counts[edgeOf(f[2], f[0])]++
+ }
+ bad := 0
+ for k, c := range counts {
+ if c != 2 {
+ if bad < 5 {
+ t.Errorf("%s: edge %v has %d incident faces, want 2", name, k, c)
+ }
+ bad++
+ }
+ }
+ if bad > 0 {
+ t.Fatalf("%s: %d edges are non-manifold", name, bad)
+ }
+}
+
+// closedMeshVolume returns the signed volume enclosed by a closed
+// triangle mesh, using the divergence theorem (sum of tetrahedron
+// volumes from origin). Positive when the mesh winds CCW from outside.
+func closedMeshVolume(m *loader.LoadedModel) float64 {
+ var v float64
+ for _, f := range m.Faces {
+ a := m.Vertices[f[0]]
+ b := m.Vertices[f[1]]
+ c := m.Vertices[f[2]]
+ v += float64(a[0])*(float64(b[1])*float64(c[2])-float64(b[2])*float64(c[1])) -
+ float64(a[1])*(float64(b[0])*float64(c[2])-float64(b[2])*float64(c[0])) +
+ float64(a[2])*(float64(b[0])*float64(c[1])-float64(b[1])*float64(c[0]))
+ }
+ return v / 6
+}
+
+// surfaceArea returns the total surface area of a triangle mesh.
+func surfaceArea(m *loader.LoadedModel) float64 {
+ var a float64
+ for _, f := range m.Faces {
+ p := m.Vertices[f[0]]
+ q := m.Vertices[f[1]]
+ r := m.Vertices[f[2]]
+ ux := float64(q[0] - p[0])
+ uy := float64(q[1] - p[1])
+ uz := float64(q[2] - p[2])
+ vx := float64(r[0] - p[0])
+ vy := float64(r[1] - p[1])
+ vz := float64(r[2] - p[2])
+ nx := uy*vz - uz*vy
+ ny := uz*vx - ux*vz
+ nz := ux*vy - uy*vx
+ a += 0.5 * math.Sqrt(nx*nx+ny*ny+nz*nz)
+ }
+ return a
+}
+
+func TestCut_UnitCubeAtMidplane(t *testing.T) {
+ cube := makeUnitCube()
+ res, err := Cut(cube, AxisPlane(2, 0.5), ConnectorSettings{})
+ if err != nil {
+ t.Fatalf("Cut: %v", err)
+ }
+ for h := 0; h < 2; h++ {
+ assertWatertight(t, res.Halves[h], "half "+string(rune('0'+h)))
+ }
+ for h := 0; h < 2; h++ {
+ v := closedMeshVolume(res.Halves[h])
+ if math.Abs(math.Abs(v)-0.5) > 1e-5 {
+ t.Errorf("half %d: |volume|=%g, want 0.5", h, math.Abs(v))
+ }
+ }
+}
+
+func TestCut_SphereAtEquator(t *testing.T) {
+ sphere := makeIcosphere(2)
+ areaBefore := surfaceArea(sphere)
+ // Cut slightly off the equator: subdividing the icosahedron lands
+ // many vertices exactly on z=0, and Cut requires no on-plane
+ // vertices.
+ res, err := Cut(sphere, AxisPlane(2, 0.01), ConnectorSettings{})
+ if err != nil {
+ t.Fatalf("Cut: %v", err)
+ }
+ for h := 0; h < 2; h++ {
+ assertWatertight(t, res.Halves[h], "hemisphere "+string(rune('0'+h)))
+ }
+ areaAfter := surfaceArea(res.Halves[0]) + surfaceArea(res.Halves[1])
+ // areaAfter is original sphere area + 2× cap area (both halves
+ // have the same cap polygon). The cap's area is roughly π for a
+ // unit sphere cut at the equator.
+ expected := areaBefore + 2*math.Pi
+ if math.Abs(areaAfter-expected)/expected > 0.05 {
+ t.Errorf("sphere area after cut = %g, want ≈ %g (5%% tol)", areaAfter, expected)
+ }
+}
+
+// TestCut_TangentPlaneFails verifies that a plane lying exactly on a
+// boundary face produces an empty-half error from CGAL. Previous
+// hand-rolled code had an on-plane vertex snap that nudged the cut
+// just inside the face, producing a thin sliver; CGAL is strict and
+// reports the empty half cleanly.
+func TestCut_TangentPlaneFails(t *testing.T) {
+ cube := makeUnitCube()
+ _, err := Cut(cube, AxisPlane(2, 1), ConnectorSettings{})
+ if err == nil {
+ t.Fatal("Cut: expected error for tangent plane")
+ }
+}
+
+func TestCut_MissingMeshFails(t *testing.T) {
+ cube := makeUnitCube()
+ _, err := Cut(cube, AxisPlane(2, 10), ConnectorSettings{})
+ if err == nil {
+ t.Fatal("Cut: expected error for plane that misses the mesh")
+ }
+}
+
+func TestCut_NonUnitNormalFails(t *testing.T) {
+ cube := makeUnitCube()
+ _, err := Cut(cube, Plane{Normal: [3]float64{2, 0, 0}, D: 0.5}, ConnectorSettings{})
+ if err == nil {
+ t.Fatal("Cut: expected error for non-unit normal")
+ }
+}
+
+// makeHollowCube returns a cube of side 2 (centred at origin) with an
+// internal cube cavity of side 1 (also centred). The inner cube's
+// faces are wound INVERTED so the combined mesh remains watertight
+// with a closed cavity inside.
+func makeHollowCube() *loader.LoadedModel {
+ outer := func(s float32) ([][3]float32, [][3]uint32) {
+ v := [][3]float32{
+ {-s, -s, -s}, {s, -s, -s}, {s, s, -s}, {-s, s, -s},
+ {-s, -s, s}, {s, -s, s}, {s, s, s}, {-s, s, s},
+ }
+ f := [][3]uint32{
+ {0, 2, 1}, {0, 3, 2}, // -z
+ {4, 5, 6}, {4, 6, 7}, // +z
+ {0, 1, 5}, {0, 5, 4}, // -y
+ {2, 3, 7}, {2, 7, 6}, // +y
+ {0, 4, 7}, {0, 7, 3}, // -x
+ {1, 2, 6}, {1, 6, 5}, // +x
+ }
+ return v, f
+ }
+ innerFlipped := func(s float32) ([][3]float32, [][3]uint32) {
+ v, f := outer(s)
+ // Flip winding so the inner surface's normal points inward
+ // (creating an enclosed void).
+ for i := range f {
+ f[i][1], f[i][2] = f[i][2], f[i][1]
+ }
+ return v, f
+ }
+ ov, of := outer(1)
+ iv, ifaces := innerFlipped(0.25)
+ offset := uint32(len(ov))
+ for i := range ifaces {
+ ifaces[i][0] += offset
+ ifaces[i][1] += offset
+ ifaces[i][2] += offset
+ }
+ return &loader.LoadedModel{
+ Vertices: append(ov, iv...),
+ Faces: append(of, ifaces...),
+ }
+}
+
+// makeStackedCubes returns a 1×1×2 watertight mesh formed by stacking
+// two unit cubes along Z. The four "middle" vertices share z=0
+// exactly — cutting at z=0 exercises the on-plane snap path with
+// genuinely-interior vertices (geometry on both sides of the cut).
+func makeStackedCubes() *loader.LoadedModel {
+ v := [][3]float32{
+ {0, 0, -1}, {1, 0, -1}, {1, 1, -1}, {0, 1, -1}, // 0..3 bottom
+ {0, 0, 0}, {1, 0, 0}, {1, 1, 0}, {0, 1, 0}, // 4..7 middle (on z=0)
+ {0, 0, 1}, {1, 0, 1}, {1, 1, 1}, {0, 1, 1}, // 8..11 top
+ }
+ f := [][3]uint32{
+ // bottom face
+ {0, 2, 1}, {0, 3, 2},
+ // bottom-cube side walls (linking 0..3 to 4..7)
+ {0, 1, 5}, {0, 5, 4},
+ {1, 2, 6}, {1, 6, 5},
+ {2, 3, 7}, {2, 7, 6},
+ {3, 0, 4}, {3, 4, 7},
+ // top-cube side walls (linking 4..7 to 8..11)
+ {4, 5, 9}, {4, 9, 8},
+ {5, 6, 10}, {5, 10, 9},
+ {6, 7, 11}, {6, 11, 10},
+ {7, 4, 8}, {7, 8, 11},
+ // top face
+ {8, 9, 10}, {8, 10, 11},
+ }
+ return &loader.LoadedModel{Vertices: v, Faces: f}
+}
+
+// TestCut_OnPlaneVertexSnapsOff verifies that interior vertices lying
+// exactly on the cut plane are silently snapped off it (along the
+// plane normal, by sub-micron amount) so the cut succeeds. Uses a
+// stacked-cubes mesh whose middle quad has all four vertices at z=0;
+// without snap, the cap-polygon walker would see a degree-4 junction
+// at each of those vertices and break.
+func TestCut_OnPlaneVertexSnapsOff(t *testing.T) {
+ mesh := makeStackedCubes()
+ originalVerts := append([][3]float32(nil), mesh.Vertices...)
+ res, err := Cut(mesh, AxisPlane(2, 0), ConnectorSettings{})
+ if err != nil {
+ t.Fatalf("expected snap-off to recover, got error: %v", err)
+ }
+ if res == nil || res.Halves[0] == nil || res.Halves[1] == nil {
+ t.Fatal("expected both halves to be populated after snap-off")
+ }
+ // Caller's mesh must be unmodified (snap is supposed to happen on
+ // a shallow clone).
+ for i, v := range mesh.Vertices {
+ if v != originalVerts[i] {
+ t.Errorf("Cut mutated input vertex %d: got %v, want %v", i, v, originalVerts[i])
+ }
+ }
+ // Both halves should be well-formed and have material on their side
+ // of the plane.
+ assertWatertight(t, res.Halves[0], "half 0")
+ assertWatertight(t, res.Halves[1], "half 1")
+}
+
+// TestCut_MultiComponentSupported covers the non-nested two-component
+// case (a barbell-like cross-section where one cut plane catches two
+// disjoint cube lobes). Each component triangulates as its own cap
+// region so both halves still close watertight.
+func TestCut_MultiComponentSupported(t *testing.T) {
+ // Two unit cubes side by side at x=[0,1] and x=[3,4]. Cutting at
+ // z=0.5 catches both, producing two disjoint cap polygons per
+ // half — neither nested inside the other.
+ cube1 := makeUnitCube()
+ cube2v := make([][3]float32, len(cube1.Vertices))
+ for i, p := range cube1.Vertices {
+ cube2v[i] = [3]float32{p[0] + 3, p[1], p[2]}
+ }
+ cube2f := make([][3]uint32, len(cube1.Faces))
+ off := uint32(len(cube1.Vertices))
+ for i, f := range cube1.Faces {
+ cube2f[i] = [3]uint32{f[0] + off, f[1] + off, f[2] + off}
+ }
+ pair := &loader.LoadedModel{
+ Vertices: append(cube1.Vertices, cube2v...),
+ Faces: append(cube1.Faces, cube2f...),
+ }
+ res, err := Cut(pair, AxisPlane(2, 0.5), ConnectorSettings{})
+ if err != nil {
+ t.Fatalf("expected multi-component cut to succeed, got %v", err)
+ }
+ if res == nil || res.Halves[0] == nil || res.Halves[1] == nil {
+ t.Fatal("expected both halves to be populated")
+ }
+ // Each half should be watertight even though its cap has two
+ // disjoint cross-section regions.
+ for h := 0; h < 2; h++ {
+ assertWatertight(t, res.Halves[h], "multi-comp half "+string(rune('0'+h)))
+ }
+}
+
+func TestCut_PolygonWithHoles(t *testing.T) {
+ hollow := makeHollowCube()
+ // Cut at z=0.1 (off-axis to avoid degenerate alignment with face
+ // boundaries of the inner cube).
+ res, err := Cut(hollow, AxisPlane(2, 0.1), ConnectorSettings{})
+ if err != nil {
+ t.Fatalf("Cut: %v", err)
+ }
+ for h := 0; h < 2; h++ {
+ assertWatertight(t, res.Halves[h], "hollow half "+string(rune('0'+h)))
+ }
+ // Each half's volume = (2×2×2 outer half - 0.5×0.5×0.5 inner half).
+ // Outer cube volume of side-2 cut at z=0.1 yields halves of
+ // volumes 2×2×1.1 = 4.4 and 2×2×0.9 = 3.6. Inner cube volume of
+ // side-0.5 cut at z=0.1 yields halves of 0.5×0.5×0.35 = 0.0875
+ // and 0.5×0.5×0.15 = 0.0375.
+ // So expected enclosed volumes:
+ // half 0 (z<0.1): 4.4 - 0.0875 = 4.3125
+ // half 1 (z>0.1): 3.6 - 0.0375 = 3.5625
+ expectedVol := []float64{4.3125, 3.5625}
+ for h := 0; h < 2; h++ {
+ v := math.Abs(closedMeshVolume(res.Halves[h]))
+ if math.Abs(v-expectedVol[h]) > 0.01 {
+ t.Errorf("hollow half %d: volume = %g, want ≈ %g", h, v, expectedVol[h])
+ }
+ }
+}
diff --git a/internal/squarevoxel/decimate_split_test.go b/internal/squarevoxel/decimate_split_test.go
new file mode 100644
index 0000000..8e8ee5e
--- /dev/null
+++ b/internal/squarevoxel/decimate_split_test.go
@@ -0,0 +1,180 @@
+package squarevoxel
+
+import (
+ "context"
+ "math"
+ "testing"
+
+ "github.com/rtwfroody/ditherforge/internal/loader"
+ "github.com/rtwfroody/ditherforge/internal/progress"
+ "github.com/rtwfroody/ditherforge/internal/split"
+)
+
+// makeIcosphere returns a unit-radius icosphere centred at the
+// origin with `subdiv` subdivision passes. subdiv=2 → 320 triangles,
+// enough for QEM to have meaningful work to do during decimation.
+// Always closed and watertight, with shared vertices between adjacent
+// triangles (so split.Cut can walk the cut polygon without dead ends).
+func makeIcosphere(subdiv int) *loader.LoadedModel {
+ t := float32((1 + math.Sqrt(5)) / 2)
+ verts := [][3]float32{
+ {-1, t, 0}, {1, t, 0}, {-1, -t, 0}, {1, -t, 0},
+ {0, -1, t}, {0, 1, t}, {0, -1, -t}, {0, 1, -t},
+ {t, 0, -1}, {t, 0, 1}, {-t, 0, -1}, {-t, 0, 1},
+ }
+ for i := range verts {
+ x, y, z := float64(verts[i][0]), float64(verts[i][1]), float64(verts[i][2])
+ l := math.Sqrt(x*x + y*y + z*z)
+ verts[i] = [3]float32{float32(x / l), float32(y / l), float32(z / l)}
+ }
+ faces := [][3]uint32{
+ {0, 11, 5}, {0, 5, 1}, {0, 1, 7}, {0, 7, 10}, {0, 10, 11},
+ {1, 5, 9}, {5, 11, 4}, {11, 10, 2}, {10, 7, 6}, {7, 1, 8},
+ {3, 9, 4}, {3, 4, 2}, {3, 2, 6}, {3, 6, 8}, {3, 8, 9},
+ {4, 9, 5}, {2, 4, 11}, {6, 2, 10}, {8, 6, 7}, {9, 8, 1},
+ }
+ for s := 0; s < subdiv; s++ {
+ mid := make(map[uint64]uint32)
+ midpoint := func(a, b uint32) uint32 {
+ lo, hi := a, b
+ if lo > hi {
+ lo, hi = hi, lo
+ }
+ key := uint64(lo)<<32 | uint64(hi)
+ if idx, ok := mid[key]; ok {
+ return idx
+ }
+ va, vb := verts[a], verts[b]
+ m := [3]float32{(va[0] + vb[0]) / 2, (va[1] + vb[1]) / 2, (va[2] + vb[2]) / 2}
+ x, y, z := float64(m[0]), float64(m[1]), float64(m[2])
+ l := math.Sqrt(x*x + y*y + z*z)
+ m = [3]float32{float32(x / l), float32(y / l), float32(z / l)}
+ idx := uint32(len(verts))
+ verts = append(verts, m)
+ mid[key] = idx
+ return idx
+ }
+ var newFaces [][3]uint32
+ for _, f := range faces {
+ a := midpoint(f[0], f[1])
+ b := midpoint(f[1], f[2])
+ c := midpoint(f[2], f[0])
+ newFaces = append(newFaces,
+ [3]uint32{f[0], a, c},
+ [3]uint32{f[1], b, a},
+ [3]uint32{f[2], c, b},
+ [3]uint32{a, b, c},
+ )
+ }
+ faces = newFaces
+ }
+ return &loader.LoadedModel{Vertices: verts, Faces: faces}
+}
+
+// TestDecimate_HalfPreservesCapPlanarity is the load-bearing
+// validation for phase 5: when a Split-produced half is decimated,
+// cap-perimeter vertices stay near the cap plane within a tolerance
+// scaled by cellSize. This validates the design's no-extension
+// assumption — that QEM's planar-affinity bias keeps cap-region
+// vertices on (or very near) the cut plane without needing an
+// explicit pinned-vertex extension to voxel.Decimate.
+//
+// Uses a subdivision-2 icosphere (~320 tris) so the simplifier has
+// meaningful work: decimating to 50% means ~80 collapses per half,
+// enough for cap-perimeter edges to genuinely compete in the heap
+// against body edges.
+//
+// The threshold is `0.1 × cellSize` — a real fixture run shows
+// observed drift up to ~3% of cellSize (1.5 μm at cellSize=50 μm),
+// well below printer resolution but non-zero. A regression that
+// disabled QEM's planar bias would produce drift on the order of
+// cellSize itself (10x more), so this threshold catches that.
+func TestDecimate_HalfPreservesCapPlanarity(t *testing.T) {
+ const cutZ = 0.1
+ const cellSize = 0.05
+ sphere := makeIcosphere(2)
+ res, err := split.Cut(sphere, split.AxisPlane(2, cutZ), split.ConnectorSettings{})
+ if err != nil {
+ t.Fatalf("split.Cut: %v", err)
+ }
+
+ for h := 0; h < 2; h++ {
+ half := res.Halves[h]
+ origFaces := len(half.Faces)
+ target := origFaces * 50 / 100
+ dec, err := DecimateMesh(context.Background(), half, target, cellSize, false, progress.NullTracker{})
+ if err != nil {
+ t.Fatalf("half %d: DecimateMesh: %v", h, err)
+ }
+ if len(dec.Faces) >= origFaces {
+ t.Errorf("half %d: decimation didn't reduce face count: %d → %d (target %d)", h, origFaces, len(dec.Faces), target)
+ }
+
+ // Any vertex that ended up within 1.0 × cellSize of the cap
+ // plane is in the cap region (vs. the far surface of the
+ // half). Within that region, no vertex should be more than
+ // 0.1 × cellSize off the plane. A real regression in the
+ // planar-affinity bias would drag cap-region vertices by
+ // roughly cellSize, well outside this band.
+ nearRegion := float64(cellSize)
+ maxDrift := 0.1 * float64(cellSize)
+ capRegionVerts := 0
+ for _, v := range dec.Vertices {
+ off := math.Abs(float64(v[2]) - cutZ)
+ if off < nearRegion {
+ capRegionVerts++
+ if off > maxDrift {
+ t.Errorf("half %d: cap-region vertex z=%g drift %g > maxDrift %g (cellSize=%g)", h, v[2], off, maxDrift, cellSize)
+ }
+ }
+ }
+ if capRegionVerts < 4 {
+ t.Errorf("half %d: only %d cap-region vertices survived; cap may have collapsed entirely", h, capRegionVerts)
+ }
+ }
+}
+
+// TestDecimateHalves_ProportionalTargets — the wrapper splits the
+// total target between halves proportionally to face count and
+// returns a decimated mesh per half.
+func TestDecimateHalves_ProportionalTargets(t *testing.T) {
+ sphere := makeIcosphere(2)
+ res, err := split.Cut(sphere, split.AxisPlane(2, 0.1), split.ConnectorSettings{})
+ if err != nil {
+ t.Fatalf("split.Cut: %v", err)
+ }
+ totalFaces := len(res.Halves[0].Faces) + len(res.Halves[1].Faces)
+ target := totalFaces * 50 / 100
+ out, err := DecimateHalves(context.Background(), res.Halves, target, 0.05, false, progress.NullTracker{})
+ if err != nil {
+ t.Fatalf("DecimateHalves: %v", err)
+ }
+ for i := 0; i < 2; i++ {
+ if out[i] == nil {
+ t.Errorf("half %d: nil output", i)
+ continue
+ }
+ if len(out[i].Faces) >= len(res.Halves[i].Faces) {
+ t.Errorf("half %d: decimation didn't reduce face count: %d → %d", i, len(res.Halves[i].Faces), len(out[i].Faces))
+ }
+ }
+}
+
+// TestDecimateHalves_NoSimplifyPassthrough — when noSimplify=true the
+// helper returns each half unmodified (identity equality).
+func TestDecimateHalves_NoSimplifyPassthrough(t *testing.T) {
+ sphere := makeIcosphere(1)
+ res, err := split.Cut(sphere, split.AxisPlane(2, 0.1), split.ConnectorSettings{})
+ if err != nil {
+ t.Fatalf("split.Cut: %v", err)
+ }
+ out, err := DecimateHalves(context.Background(), res.Halves, 1, 0.1, true, progress.NullTracker{})
+ if err != nil {
+ t.Fatalf("DecimateHalves: %v", err)
+ }
+ for i := 0; i < 2; i++ {
+ if out[i] != res.Halves[i] {
+ t.Errorf("half %d: noSimplify didn't return the input unchanged", i)
+ }
+ }
+}
diff --git a/internal/squarevoxel/split_test.go b/internal/squarevoxel/split_test.go
new file mode 100644
index 0000000..16212ed
--- /dev/null
+++ b/internal/squarevoxel/split_test.go
@@ -0,0 +1,331 @@
+package squarevoxel
+
+import (
+ "context"
+ "math"
+ "testing"
+
+ "github.com/rtwfroody/ditherforge/internal/loader"
+ "github.com/rtwfroody/ditherforge/internal/progress"
+ "github.com/rtwfroody/ditherforge/internal/split"
+)
+
+// makeColorCubeModel returns a `side`-mm cube with the given uniform
+// per-face base color, parallel-array conformant.
+func makeColorCubeModel(side float32, baseColor [4]uint8) *loader.LoadedModel {
+ verts := [][3]float32{
+ {0, 0, 0}, {side, 0, 0}, {side, side, 0}, {0, side, 0},
+ {0, 0, side}, {side, 0, side}, {side, side, side}, {0, side, side},
+ }
+ faces := [][3]uint32{
+ {0, 2, 1}, {0, 3, 2},
+ {4, 5, 6}, {4, 6, 7},
+ {0, 1, 5}, {0, 5, 4},
+ {2, 3, 7}, {2, 7, 6},
+ {0, 4, 7}, {0, 7, 3},
+ {1, 2, 6}, {1, 6, 5},
+ }
+ noTexture := make([]bool, len(faces))
+ for i := range noTexture {
+ noTexture[i] = true
+ }
+ baseColors := make([][4]uint8, len(faces))
+ for i := range baseColors {
+ baseColors[i] = baseColor
+ }
+ faceTexIdx := make([]int32, len(faces))
+ faceAlpha := make([]float32, len(faces))
+ for i := range faceAlpha {
+ faceAlpha[i] = 1
+ }
+ return &loader.LoadedModel{
+ Vertices: verts,
+ Faces: faces,
+ FaceTextureIdx: faceTexIdx,
+ FaceAlpha: faceAlpha,
+ FaceBaseColor: baseColors,
+ NoTextureMask: noTexture,
+ }
+}
+
+// translatedModel returns a deep-copy of m with all vertices shifted by
+// (dx, dy, dz). Parallel arrays are reused (read-only after construction).
+func translatedModel(m *loader.LoadedModel, dx, dy, dz float32) *loader.LoadedModel {
+ out := *m
+ out.Vertices = make([][3]float32, len(m.Vertices))
+ for i, v := range m.Vertices {
+ out.Vertices[i] = [3]float32{v[0] + dx, v[1] + dy, v[2] + dz}
+ }
+ return &out
+}
+
+// TestVoxelize_SplitInfoNilUnchanged — passing splitInfo=nil should
+// produce the legacy single-mesh result. HalfIdx is 0 on every cell.
+func TestVoxelize_SplitInfoNilUnchanged(t *testing.T) {
+ cube := makeColorCubeModel(20, [4]uint8{200, 100, 50, 255})
+ res, err := VoxelizeTwoGrids(
+ context.Background(),
+ cube, cube,
+ nil, nil,
+ 2, 2, 0.4,
+ progress.NullTracker{},
+ nil,
+ nil,
+ )
+ if err != nil {
+ t.Fatalf("VoxelizeTwoGrids: %v", err)
+ }
+ if len(res.Cells) == 0 {
+ t.Fatal("no active cells")
+ }
+ for _, c := range res.Cells {
+ if c.HalfIdx != 0 {
+ t.Errorf("unsplit cell has HalfIdx=%d, want 0", c.HalfIdx)
+ break
+ }
+ }
+}
+
+// TestVoxelize_SplitInfoTagsHalves — two spatially-separated halves
+// each with its own translated geometry mesh. Verifies HalfIdx
+// tagging by location and that cells from each half land in their
+// expected x-range.
+func TestVoxelize_SplitInfoTagsHalves(t *testing.T) {
+ half0 := makeColorCubeModel(20, [4]uint8{255, 0, 0, 255})
+ half1 := translatedModel(makeColorCubeModel(20, [4]uint8{0, 255, 0, 255}), 25, 0, 0)
+ colorModel := &loader.LoadedModel{
+ Vertices: append(append([][3]float32(nil), half0.Vertices...), half1.Vertices...),
+ FaceTextureIdx: append(append([]int32(nil), half0.FaceTextureIdx...), half1.FaceTextureIdx...),
+ FaceAlpha: append(append([]float32(nil), half0.FaceAlpha...), half1.FaceAlpha...),
+ FaceBaseColor: append(append([][4]uint8(nil), half0.FaceBaseColor...), half1.FaceBaseColor...),
+ NoTextureMask: append(append([]bool(nil), half0.NoTextureMask...), half1.NoTextureMask...),
+ }
+ colorModel.Faces = append([][3]uint32(nil), half0.Faces...)
+ off := uint32(len(half0.Vertices))
+ for _, f := range half1.Faces {
+ colorModel.Faces = append(colorModel.Faces, [3]uint32{f[0] + off, f[1] + off, f[2] + off})
+ }
+ splitInfo := &SplitInfo{
+ Halves: [2]*loader.LoadedModel{half0, half1},
+ Xform: [2]split.Transform{split.IdentityTransform, split.IdentityTransform},
+ }
+ res, err := VoxelizeTwoGrids(
+ context.Background(),
+ nil,
+ colorModel,
+ nil, nil,
+ 2, 2, 0.4,
+ progress.NullTracker{},
+ nil,
+ splitInfo,
+ )
+ if err != nil {
+ t.Fatalf("VoxelizeTwoGrids: %v", err)
+ }
+ var nHalf0, nHalf1 int
+ for _, c := range res.Cells {
+ switch c.HalfIdx {
+ case 0:
+ nHalf0++
+ if c.Cx > 25 {
+ t.Errorf("half-0 cell at x=%g, expected x<25", c.Cx)
+ }
+ case 1:
+ nHalf1++
+ if c.Cx < 20 {
+ t.Errorf("half-1 cell at x=%g, expected x>20", c.Cx)
+ }
+ default:
+ t.Errorf("unexpected HalfIdx %d on cell at x=%g", c.HalfIdx, c.Cx)
+ }
+ }
+ if nHalf0 == 0 || nHalf1 == 0 {
+ t.Errorf("got %d half-0 cells and %d half-1 cells, want both > 0", nHalf0, nHalf1)
+ }
+}
+
+// TestVoxelize_SplitInfoInverseTransformDistinctHalves — the
+// production scenario: half 0 in one bed location, half 1 in
+// another, each with its own Xform, single colorModel in original
+// coords. Voxelize must apply the right inverse transform per cell.
+func TestVoxelize_SplitInfoInverseTransformDistinctHalves(t *testing.T) {
+ // Original cube at x=[0, 20], coloured red.
+ colorModel := makeColorCubeModel(20, [4]uint8{255, 0, 0, 255})
+
+ // Two halves: half 0 translated +100 in x (bed-coord position),
+ // half 1 translated +200 in x. In real Layout output the two
+ // halves would have different geometry (one half each); for this
+ // test we use the same shape translated to two bed-coord places.
+ geom0 := translatedModel(colorModel, 100, 0, 0)
+ geom1 := translatedModel(colorModel, 200, 0, 0)
+ xform0 := split.Transform{
+ Rotation: [9]float64{1, 0, 0, 0, 1, 0, 0, 0, 1},
+ Translation: [3]float64{100, 0, 0},
+ }
+ xform1 := split.Transform{
+ Rotation: [9]float64{1, 0, 0, 0, 1, 0, 0, 0, 1},
+ Translation: [3]float64{200, 0, 0},
+ }
+ splitInfo := &SplitInfo{
+ Halves: [2]*loader.LoadedModel{geom0, geom1},
+ Xform: [2]split.Transform{xform0, xform1},
+ }
+ res, err := VoxelizeTwoGrids(
+ context.Background(),
+ nil,
+ colorModel,
+ nil, nil,
+ 2, 2, 0.4,
+ progress.NullTracker{},
+ nil,
+ splitInfo,
+ )
+ if err != nil {
+ t.Fatalf("VoxelizeTwoGrids: %v", err)
+ }
+ var redInHalf0, redInHalf1 int
+ for _, c := range res.Cells {
+ isRed := c.Color[0] > 200 && c.Color[1] < 50 && c.Color[2] < 50
+ switch c.HalfIdx {
+ case 0:
+ if isRed {
+ redInHalf0++
+ }
+ if c.Cx < 90 || c.Cx > 130 {
+ t.Errorf("half-0 cell at x=%g, expected near 100..120", c.Cx)
+ }
+ case 1:
+ if isRed {
+ redInHalf1++
+ }
+ if c.Cx < 190 || c.Cx > 230 {
+ t.Errorf("half-1 cell at x=%g, expected near 200..220", c.Cx)
+ }
+ }
+ }
+ if redInHalf0 == 0 {
+ t.Error("half 0 sampled no red cells; per-half inverse transform may be wrong")
+ }
+ if redInHalf1 == 0 {
+ t.Error("half 1 sampled no red cells; per-half inverse transform may be wrong")
+ }
+}
+
+// TestVoxelize_SplitInfoNonIdentityRotation — exercises the
+// non-translation part of the inverse transform. A 90° rotation
+// about Y maps the cube to a rotated bed-coord cube; voxelize's
+// inverse-transform should still recover red colors from the
+// original colorModel.
+func TestVoxelize_SplitInfoNonIdentityRotation(t *testing.T) {
+ colorModel := makeColorCubeModel(20, [4]uint8{255, 0, 0, 255})
+
+ // Forward transform: rotate 90° about Y (x → z, z → -x), then
+ // translate so the rotated cube lands in positive bed coords.
+ // 90° about Y rotation matrix (row-major):
+ // ( 0, 0, 1)
+ // ( 0, 1, 0)
+ // (-1, 0, 0)
+ // Original cube spans (0..20, 0..20, 0..20). After rotation:
+ // x' = z (range 0..20)
+ // y' = y (range 0..20)
+ // z' = -x (range -20..0)
+ // Translate by (50, 0, 50) to put the cube at bed coords
+ // (50..70, 0..20, 30..50).
+ xform := split.Transform{
+ Rotation: [9]float64{0, 0, 1, 0, 1, 0, -1, 0, 0},
+ Translation: [3]float64{50, 0, 50},
+ }
+ geom := &loader.LoadedModel{
+ Faces: append([][3]uint32(nil), colorModel.Faces...),
+ FaceTextureIdx: colorModel.FaceTextureIdx,
+ FaceAlpha: colorModel.FaceAlpha,
+ FaceBaseColor: colorModel.FaceBaseColor,
+ NoTextureMask: colorModel.NoTextureMask,
+ }
+ geom.Vertices = make([][3]float32, len(colorModel.Vertices))
+ for i, v := range colorModel.Vertices {
+ geom.Vertices[i] = xform.Apply(v)
+ }
+ splitInfo := &SplitInfo{
+ Halves: [2]*loader.LoadedModel{geom, geom},
+ Xform: [2]split.Transform{xform, xform},
+ }
+ res, err := VoxelizeTwoGrids(
+ context.Background(),
+ nil,
+ colorModel,
+ nil, nil,
+ 2, 2, 0.4,
+ progress.NullTracker{},
+ nil,
+ splitInfo,
+ )
+ if err != nil {
+ t.Fatalf("VoxelizeTwoGrids: %v", err)
+ }
+ red := 0
+ for _, c := range res.Cells {
+ if c.Color[0] > 200 && c.Color[1] < 50 && c.Color[2] < 50 {
+ red++
+ }
+ }
+ if red < len(res.Cells)*8/10 {
+ t.Errorf("only %d/%d cells sampled red — non-identity inverse transform may be wrong", red, len(res.Cells))
+ }
+ // Sanity: a sample bed-coord cell, when run through ApplyInverse,
+ // should land somewhere inside the original cube (0..20)^3.
+ if len(res.Cells) > 0 {
+ c := res.Cells[0]
+ orig := xform.ApplyInverse([3]float32{c.Cx, c.Cy, c.Cz})
+ for i, x := range orig {
+ if x < -1 || x > 21 {
+ t.Errorf("bed cell %d: ApplyInverse → %v, axis %d out of expected (-1, 21) range", 0, orig, i)
+ }
+ _ = math.IsNaN(float64(x))
+ }
+ }
+}
+
+// TestVoxelize_SplitInfoRequiresColorModel — passing splitInfo
+// without an explicit colorModel should error.
+func TestVoxelize_SplitInfoRequiresColorModel(t *testing.T) {
+ half := makeColorCubeModel(20, [4]uint8{0, 0, 0, 255})
+ splitInfo := &SplitInfo{
+ Halves: [2]*loader.LoadedModel{half, half},
+ Xform: [2]split.Transform{split.IdentityTransform, split.IdentityTransform},
+ }
+ _, err := VoxelizeTwoGrids(
+ context.Background(),
+ nil, nil,
+ nil, nil,
+ 2, 2, 0.4,
+ progress.NullTracker{},
+ nil,
+ splitInfo,
+ )
+ if err == nil {
+ t.Fatal("expected error when split path runs without colorModel")
+ }
+}
+
+// TestVoxelize_SplitInfoEmptyHalfRejected — an empty/degenerate half
+// should be rejected with a clear error.
+func TestVoxelize_SplitInfoEmptyHalfRejected(t *testing.T) {
+ half := makeColorCubeModel(20, [4]uint8{0, 0, 0, 255})
+ splitInfo := &SplitInfo{
+ Halves: [2]*loader.LoadedModel{half, {}},
+ Xform: [2]split.Transform{split.IdentityTransform, split.IdentityTransform},
+ }
+ _, err := VoxelizeTwoGrids(
+ context.Background(),
+ nil, half,
+ nil, nil,
+ 2, 2, 0.4,
+ progress.NullTracker{},
+ nil,
+ splitInfo,
+ )
+ if err == nil {
+ t.Fatal("expected error when split half is empty")
+ }
+}
diff --git a/internal/squarevoxel/squarevoxel.go b/internal/squarevoxel/squarevoxel.go
index 28bec58..747b9f9 100644
--- a/internal/squarevoxel/squarevoxel.go
+++ b/internal/squarevoxel/squarevoxel.go
@@ -13,10 +13,30 @@ import (
"time"
"github.com/rtwfroody/ditherforge/internal/loader"
+ "github.com/rtwfroody/ditherforge/internal/plog"
"github.com/rtwfroody/ditherforge/internal/progress"
+ "github.com/rtwfroody/ditherforge/internal/split"
"github.com/rtwfroody/ditherforge/internal/voxel"
)
+// SplitInfo carries per-half geometry plus the forward transforms
+// that produced the laid-out halves. VoxelizeTwoGrids calls
+// Xform[i].ApplyInverse on each cell centroid to map bed coords
+// back into original-mesh coords, where colorModel, stickerModel,
+// and the sticker spatial index live unmoved.
+//
+// Xform is the FORWARD transform (orig → bed), not the inverse.
+// The "inverse" lives in voxelize's call to ApplyInverse, not in
+// the field. This matches splitOutput.Xform in docs/SPLIT.md.
+//
+// When SplitInfo is nil, VoxelizeTwoGrids voxelizes the single
+// `model` argument with no transform (bit-identical to the
+// pre-split path).
+type SplitInfo struct {
+ Halves [2]*loader.LoadedModel
+ Xform [2]split.Transform
+}
+
// Cell size multipliers relative to nozzle diameter.
const (
Layer0CellScale = 1.275 // wider cells for the first layer
@@ -104,6 +124,11 @@ func voxelizeRegion(
//
// stickerModel/stickerSI may be nil; when non-nil and distinct from
// colorModel, decal lookups go against that mesh (alpha-wrap mode).
+//
+// halfIdx is recorded on every emitted cell. invXform maps the cell
+// centroid (which is in bed coords) back to original-mesh coords for
+// color sampling on the unmoved colorModel/stickerModel; pass
+// split.IdentityTransform for the unsplit path.
func colorCells(
ctx context.Context,
colorModel *loader.LoadedModel,
@@ -115,6 +140,8 @@ func colorCells(
tracker progress.Tracker,
counter *atomic.Int64,
decals []*voxel.StickerDecal,
+ halfIdx uint8,
+ invXform split.Transform,
) ([]voxel.ActiveCell, error) {
colorRadius := p.CellSize * 3
cellKeys := make([]voxel.CellKey, 0, len(cellSet))
@@ -159,18 +186,23 @@ func colorCells(
}
cur := counter.Add(1)
tracker.StageProgress("Coloring cells", int(cur))
+ // (cx, cy, cz) is in bed coords (the grid lives on the
+ // bed). For color sampling, project back into
+ // original-mesh coords via the per-half inverse
+ // transform — colorModel/stickerModel are unmoved.
cx := p.MinV[0] + float32(k.Col)*p.CellSize
cy := p.MinV[1] + float32(k.Row)*p.CellSize
cz := p.MinV[2] + float32(k.Layer)*p.LayerH
+ samplePos := invXform.ApplyInverse([3]float32{cx, cy, cz})
var rgba [4]uint8
if separateSticker {
rgba = voxel.SampleNearestColorWithSticker(
- [3]float32{cx, cy, cz},
+ samplePos,
colorModel, si, colorRadius, buf, decals,
stickerModel, stickerSI, stickerBuf)
} else {
rgba = voxel.SampleNearestColor(
- [3]float32{cx, cy, cz},
+ samplePos,
colorModel, si, colorRadius, buf, decals)
}
if rgba[3] < 128 {
@@ -179,7 +211,8 @@ func colorCells(
local = append(local, voxel.ActiveCell{
Grid: k.Grid, Col: k.Col, Row: k.Row, Layer: k.Layer,
Cx: cx, Cy: cy, Cz: cz,
- Color: [3]uint8{rgba[0], rgba[1], rgba[2]},
+ Color: [3]uint8{rgba[0], rgba[1], rgba[2]},
+ HalfIdx: halfIdx,
})
}
workerCells[workerIdx] = local
@@ -217,6 +250,11 @@ type TwoGridResult struct {
// mesh than the color sampler — typically the alpha-wrap mesh while
// colorModel is the original textured mesh. Pass nil for both to use
// colorModel for sticker lookups (which also covers the no-sticker case).
+//
+// When splitInfo is non-nil, the `model` parameter is ignored; geometry
+// comes from splitInfo.Halves and each cell records its halfIdx. The
+// `colorModel` parameter is required (no fallback) because the geometry
+// meshes are in bed coords while colorModel must be in original coords.
func VoxelizeTwoGrids(
ctx context.Context,
model, colorModel *loader.LoadedModel,
@@ -224,17 +262,66 @@ func VoxelizeTwoGrids(
layer0Size, upperSize, layerH float32,
tracker progress.Tracker,
decals []*voxel.StickerDecal,
+ splitInfo *SplitInfo,
) (*TwoGridResult, error) {
- if len(model.Vertices) == 0 || len(model.Faces) == 0 {
- return nil, fmt.Errorf("empty model")
+ // Decide the geometry meshes and per-mesh inverse transforms.
+ // Unsplit path (splitInfo == nil) takes the single `model`
+ // argument with identity transform; split path iterates the
+ // two halves with their respective inverse transforms.
+ type geomEntry struct {
+ mesh *loader.LoadedModel
+ invXform split.Transform
+ halfIdx uint8
+ }
+ var entries []geomEntry
+ if splitInfo == nil {
+ if model == nil || len(model.Vertices) == 0 || len(model.Faces) == 0 {
+ return nil, fmt.Errorf("empty model")
+ }
+ entries = []geomEntry{{mesh: model, invXform: split.IdentityTransform, halfIdx: 0}}
+ } else {
+ for h := 0; h < 2; h++ {
+ m := splitInfo.Halves[h]
+ if m == nil || len(m.Vertices) == 0 || len(m.Faces) == 0 {
+ return nil, fmt.Errorf("empty split half %d", h)
+ }
+ entries = append(entries, geomEntry{
+ mesh: m,
+ invXform: splitInfo.Xform[h],
+ halfIdx: uint8(h),
+ })
+ }
}
+
if colorModel == nil {
+ // In the unsplit path colorModel can fall back to the
+ // geometry mesh; in the split path the caller must supply
+ // colorModel explicitly (it lives in original coords,
+ // distinct from the laid-out half meshes).
+ if splitInfo != nil {
+ return nil, fmt.Errorf("split voxelize requires explicit colorModel (lives in original coords, distinct from laid-out halves)")
+ }
colorModel = model
}
- fmt.Printf(" Input mesh: %s\n", voxel.CheckWatertight(model.Faces))
+ for _, e := range entries {
+ if len(entries) > 1 {
+ plog.Printf(" Input mesh (half %d): %s", e.halfIdx, voxel.CheckWatertight(e.mesh.Faces))
+ } else {
+ plog.Printf(" Input mesh: %s", voxel.CheckWatertight(e.mesh.Faces))
+ }
+ }
- minV, maxV := voxel.ComputeBounds(model.Vertices)
+ // Bbox is the union over all geometry meshes (in bed coords for
+ // the split path).
+ minV, maxV := voxel.ComputeBounds(entries[0].mesh.Vertices)
+ for _, e := range entries[1:] {
+ mn, mx := voxel.ComputeBounds(e.mesh.Vertices)
+ for i := 0; i < 3; i++ {
+ minV[i] = min(minV[i], mn[i])
+ maxV[i] = max(maxV[i], mx[i])
+ }
+ }
maxCellSize := max(layer0Size, upperSize)
xyPad := maxCellSize * 2
zPad := layerH * 2
@@ -260,12 +347,15 @@ func VoxelizeTwoGrids(
if nLayers > 1 {
regions = 2
}
- tracker.StageStart("Voxelizing", true, len(model.Faces)*regions)
+ totalFaces := 0
+ for _, e := range entries {
+ totalFaces += len(e.mesh.Faces)
+ }
+ tracker.StageStart("Voxelizing", true, totalFaces*regions)
var voxCounter atomic.Int64
tVoxelize := time.Now()
- // First layer: grid 0 (wide voxels)
nCols0 := int(math.Ceil(float64(maxV[0]-minV[0])/float64(layer0Size))) + 1
nRows0 := int(math.Ceil(float64(maxV[1]-minV[1])/float64(layer0Size))) + 1
p0 := regionParams{
@@ -273,48 +363,64 @@ func VoxelizeTwoGrids(
MinV: minV, NCols: nCols0, NRows: nRows0,
LayerLo: 0, LayerHi: 0,
}
- cellSet0 := voxelizeRegion(ctx, model, p0, tracker, &voxCounter)
-
- // Remaining layers: grid 1 (narrow voxels)
- var cellSet1 map[voxel.CellKey]struct{}
nCols1 := int(math.Ceil(float64(maxV[0]-minV[0])/float64(upperSize))) + 1
nRows1 := int(math.Ceil(float64(maxV[1]-minV[1])/float64(upperSize))) + 1
- if nLayers > 1 {
- p1 := regionParams{
- Grid: 1, CellSize: upperSize, LayerH: layerH,
- MinV: minV, NCols: nCols1, NRows: nRows1,
- LayerLo: 1, LayerHi: nLayers - 1,
+ p1 := regionParams{
+ Grid: 1, CellSize: upperSize, LayerH: layerH,
+ MinV: minV, NCols: nCols1, NRows: nRows1,
+ LayerLo: 1, LayerHi: nLayers - 1,
+ }
+
+ // Voxelize each geometry mesh into per-mesh region cell sets.
+ type meshCells struct {
+ layer0 map[voxel.CellKey]struct{}
+ upper map[voxel.CellKey]struct{}
+ }
+ perMesh := make([]meshCells, len(entries))
+ totalCells := 0
+ for i, e := range entries {
+ perMesh[i].layer0 = voxelizeRegion(ctx, e.mesh, p0, tracker, &voxCounter)
+ totalCells += len(perMesh[i].layer0)
+ if nLayers > 1 {
+ perMesh[i].upper = voxelizeRegion(ctx, e.mesh, p1, tracker, &voxCounter)
+ totalCells += len(perMesh[i].upper)
}
- cellSet1 = voxelizeRegion(ctx, model, p1, tracker, &voxCounter)
}
- totalCells := len(cellSet0) + len(cellSet1)
- fmt.Printf(" Voxelized: %d cells (layer0: %d, upper: %d) in %.1fs\n",
- totalCells, len(cellSet0), len(cellSet1), time.Since(tVoxelize).Seconds())
+ plog.Printf(" Voxelized: %d cells across %d mesh(es) in %.1fs",
+ totalCells, len(entries), time.Since(tVoxelize).Seconds())
tracker.StageDone("Voxelizing")
- // Color cells
+ // Color cells per mesh, threading the per-mesh inverse transform
+ // so color sampling on colorModel/stickerModel happens in
+ // original-mesh coordinates.
tColor := time.Now()
tracker.StageStart("Coloring cells", true, totalCells)
var counter atomic.Int64
- cells0, err := colorCells(ctx, colorModel, si, stickerModel, stickerSI, cellSet0, p0, tracker, &counter, decals)
- if err != nil {
- return nil, err
- }
- cells1, err := colorCells(ctx, colorModel, si, stickerModel, stickerSI, cellSet1, regionParams{
- Grid: 1, CellSize: upperSize, LayerH: layerH,
- MinV: minV, NCols: nCols1, NRows: nRows1,
- LayerLo: 1, LayerHi: nLayers - 1,
- }, tracker, &counter, decals)
- if err != nil {
- return nil, err
+ var cells []voxel.ActiveCell
+ for i, e := range entries {
+ cells0, err := colorCells(ctx, colorModel, si, stickerModel, stickerSI,
+ perMesh[i].layer0, p0, tracker, &counter, decals,
+ e.halfIdx, e.invXform)
+ if err != nil {
+ return nil, err
+ }
+ cells = append(cells, cells0...)
+ if nLayers > 1 {
+ cells1, err := colorCells(ctx, colorModel, si, stickerModel, stickerSI,
+ perMesh[i].upper, p1, tracker, &counter, decals,
+ e.halfIdx, e.invXform)
+ if err != nil {
+ return nil, err
+ }
+ cells = append(cells, cells1...)
+ }
}
- cells := append(cells0, cells1...)
tracker.StageDone("Coloring cells")
- fmt.Printf(" Colored cells: %d cells in %.1fs\n", len(cells), time.Since(tColor).Seconds())
+ plog.Printf(" Colored cells: %d cells in %.1fs", len(cells), time.Since(tColor).Seconds())
if len(cells) == 0 {
return nil, fmt.Errorf("no active cells found")
}
@@ -346,7 +452,7 @@ func Voxelize(ctx context.Context, model, colorModel *loader.LoadedModel, cellSi
colorModel = model
}
- fmt.Printf(" Input mesh: %s\n", voxel.CheckWatertight(model.Faces))
+ plog.Printf(" Input mesh: %s", voxel.CheckWatertight(model.Faces))
minV, maxV := voxel.ComputeBounds(model.Vertices)
xyPad := cellSize * 2
@@ -375,19 +481,19 @@ func Voxelize(ctx context.Context, model, colorModel *loader.LoadedModel, cellSi
LayerLo: 0, LayerHi: nLayers - 1,
}
cellSet := voxelizeRegion(ctx, model, p, tracker, &voxCounter)
- fmt.Printf(" Voxelized: %d cells in %.1fs\n", len(cellSet), time.Since(tVoxelize).Seconds())
+ plog.Printf(" Voxelized: %d cells in %.1fs", len(cellSet), time.Since(tVoxelize).Seconds())
tracker.StageDone("Voxelizing")
tColor := time.Now()
tracker.StageStart("Coloring cells", true, len(cellSet))
var counter atomic.Int64
- cells, err := colorCells(ctx, model, si, nil, nil, cellSet, p, tracker, &counter, decals)
+ cells, err := colorCells(ctx, model, si, nil, nil, cellSet, p, tracker, &counter, decals, 0, split.IdentityTransform)
if err != nil {
return nil, nil, [3]float32{}, err
}
tracker.StageDone("Coloring cells")
- fmt.Printf(" Colored cells: %d cells in %.1fs\n", len(cells), time.Since(tColor).Seconds())
+ plog.Printf(" Colored cells: %d cells in %.1fs", len(cells), time.Since(tColor).Seconds())
if len(cells) == 0 {
return nil, nil, [3]float32{}, fmt.Errorf("no active cells found")
}
@@ -460,7 +566,7 @@ func DecimateMesh(ctx context.Context, model *loader.LoadedModel, targetCells in
return nil, err
}
wr := voxel.CheckWatertight(decFaces)
- fmt.Printf(" Decimated mesh: %s\n", wr)
+ plog.Printf(" Decimated mesh: %s", wr)
return &loader.LoadedModel{
Vertices: decVerts,
Faces: decFaces,
@@ -470,3 +576,35 @@ func DecimateMesh(ctx context.Context, model *loader.LoadedModel, targetCells in
tracker.StageDone("Decimating")
return model, nil
}
+
+// DecimateHalves runs DecimateMesh once per Split half, splitting the
+// total target cell count between halves proportional to each half's
+// face count. Used by the StageSplit-aware pipeline path; the
+// unsplit path keeps using DecimateMesh directly.
+//
+// Each half is closed-watertight in its own right (post-Layout), so
+// the underlying voxel.Decimate runs unmodified. Cap planarity is
+// preserved by QEM's planar-affinity bias: collapsing a
+// cap-perimeter vertex moves it off the cap plane, which is high
+// quadric error and is disfavored by the heap. (Verified by
+// TestDecimate_HalfPreservesCapPlanarity.)
+func DecimateHalves(ctx context.Context, halves [2]*loader.LoadedModel, totalTargetCells int, cellSize float32, noSimplify bool, tracker progress.Tracker) ([2]*loader.LoadedModel, error) {
+ // split.Cut's contract guarantees both halves are non-nil; we rely
+ // on that here rather than guarding for nil.
+ totalFaces := len(halves[0].Faces) + len(halves[1].Faces)
+ var out [2]*loader.LoadedModel
+ for i, h := range halves {
+ // Proportional split with a floor of 1 (avoid degenerate
+ // "decimate to 0 faces" requests).
+ perHalfTarget := totalTargetCells * len(h.Faces) / totalFaces
+ if perHalfTarget < 1 {
+ perHalfTarget = 1
+ }
+ decimated, err := DecimateMesh(ctx, h, perHalfTarget, cellSize, noSimplify, tracker)
+ if err != nil {
+ return out, fmt.Errorf("decimate half %d: %w", i, err)
+ }
+ out[i] = decimated
+ }
+ return out, nil
+}
diff --git a/internal/voxel/decimate.go b/internal/voxel/decimate.go
index e7708c6..8492cb3 100644
--- a/internal/voxel/decimate.go
+++ b/internal/voxel/decimate.go
@@ -3,10 +3,10 @@ package voxel
import (
"container/heap"
"context"
- "fmt"
"math"
"time"
+ "github.com/rtwfroody/ditherforge/internal/plog"
"github.com/rtwfroody/ditherforge/internal/progress"
)
@@ -246,7 +246,7 @@ func Decimate(ctx context.Context, verts [][3]float32, faces [][3]uint32, target
}
outVerts, outFaces := d.compact()
- fmt.Printf(" Decimated %d -> %d faces in %.1fs\n",
+ plog.Printf(" Decimated %d -> %d faces in %.1fs",
len(faces), len(outFaces), time.Since(tStart).Seconds())
return outVerts, outFaces, nil
}
diff --git a/internal/voxel/types.go b/internal/voxel/types.go
index e848e0b..99e6b74 100644
--- a/internal/voxel/types.go
+++ b/internal/voxel/types.go
@@ -22,11 +22,18 @@ type Config struct {
}
// ActiveCell represents one voxel cell to generate.
+//
+// HalfIdx identifies which Split half produced the cell when the
+// model has been split into two halves. 0 in the unsplit path; 0 or
+// 1 in the split path. Downstream stages (Merge, export3mf) use this
+// to partition cells per half so the 3MF output emits one
+// `` entry per half.
type ActiveCell struct {
Grid uint8
Col, Row, Layer int
Cx, Cy, Cz float32
Color [3]uint8
+ HalfIdx uint8
}
// CellKey is a canonical grid cell identifier.