diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2b103a..d8e7c14 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,17 +17,14 @@ jobs: - os: ubuntu-latest platform: linux-amd64 wails_flags: "-tags webkit2_41,draco,cgal" - cli_targets: "linux/amd64 linux/arm64" cgal_cli_targets: "linux/amd64" - os: windows-latest platform: windows-amd64 wails_flags: "-tags draco,cgal" - cli_targets: "windows/amd64" cgal_cli_targets: "windows/amd64" - os: macos-latest - platform: macos-universal + platform: macos-arm64 wails_flags: "" - cli_targets: "darwin/amd64 darwin/arm64" cgal_cli_targets: "darwin/arm64" runs-on: ${{ matrix.os }} @@ -111,23 +108,15 @@ jobs: shell: bash run: wails build ${{ matrix.wails_flags }} - - name: Build GUI (macOS universal with CGAL on arm64) + - name: Build GUI (macOS arm64 with CGAL) if: runner.os == 'macOS' shell: bash run: | - # Build arm64 with CGAL and Draco linked in. + # arm64-only build. Intel Macs are not supported by the release + # pipeline since CGAL is required at build time and no + # cross-compiled GMP/MPFR is available on the macOS arm64 runner. # customenv tells draco-go to use our system libdraco_c instead of its bundled one. CGO_LDFLAGS="-L/usr/local/lib -ldraco_c -lstdc++ -lm" wails build -platform darwin/arm64 -tags customenv,draco,cgal - cp build/bin/ditherforge.app/Contents/MacOS/ditherforge ditherforge-gui-arm64 - - # Build amd64 without CGAL (no cross-compiled GMP/MPFR available). - wails build -platform darwin/amd64 - cp build/bin/ditherforge.app/Contents/MacOS/ditherforge ditherforge-gui-amd64 - - # Combine into universal binary inside the .app bundle. - lipo -create -output build/bin/ditherforge.app/Contents/MacOS/ditherforge \ - ditherforge-gui-arm64 ditherforge-gui-amd64 - rm ditherforge-gui-arm64 ditherforge-gui-amd64 # Verify no Homebrew dylib dependencies leaked into the binary. if otool -L build/bin/ditherforge.app/Contents/MacOS/ditherforge | grep -q /opt/homebrew; then @@ -158,31 +147,12 @@ jobs: go build -tags "$TAGS" -o "ditherforge-cli-${GOOS}-${GOARCH}${EXT}" ./cmd/ditherforge done - - name: Build CLI (without CGAL, cross-compile) - shell: bash - run: | - for target in ${{ matrix.cli_targets }}; do - GOOS="${target%/*}" - GOARCH="${target#*/}" - EXT="" - if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi - OUT="ditherforge-cli-${GOOS}-${GOARCH}${EXT}" - if [ -f "$OUT" ]; then - echo "Skipping ${GOOS}/${GOARCH} (already built with CGAL)" - continue - fi - echo "Building CLI for ${GOOS}/${GOARCH} (no CGAL)..." - CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" \ - go build -o "$OUT" ./cmd/ditherforge - done - # --- Smoke test (Linux only) --- - name: Smoke test if: runner.os == 'Linux' run: | ./ditherforge-cli-linux-amd64 --version - go test -tags cgal -timeout 2m ./internal/alphawrap/... go test -timeout 2m ./internal/... # --- Package --- @@ -199,14 +169,12 @@ jobs: tar czf "ditherforge-${VERSION}-linux-amd64.tar.gz" -C pkg . rm -rf pkg - # CLI: tar.gz for each arch - for arch in amd64 arm64; do - mkdir -p pkg - cp "ditherforge-cli-linux-${arch}" pkg/ditherforge-cli - cp README.md LICENSE pkg/ - tar czf "ditherforge-cli-${VERSION}-linux-${arch}.tar.gz" -C pkg . - rm -rf pkg - done + # CLI: tar.gz + mkdir -p pkg + cp ditherforge-cli-linux-amd64 pkg/ditherforge-cli + cp README.md LICENSE pkg/ + tar czf "ditherforge-cli-${VERSION}-linux-amd64.tar.gz" -C pkg . + rm -rf pkg - name: Package (Windows) if: runner.os == 'Windows' @@ -240,17 +208,14 @@ jobs: hdiutil create -volname "DitherForge" \ -srcfolder dmg-staging \ -ov -format UDZO \ - "ditherforge-${VERSION}-macos-universal.dmg" + "ditherforge-${VERSION}-macos-arm64.dmg" rm -rf dmg-staging - # CLI: universal binary via lipo, sign, then tar.gz - lipo -create -output ditherforge-cli \ - ditherforge-cli-darwin-amd64 \ - ditherforge-cli-darwin-arm64 + # CLI: arm64 only mkdir -p pkg - cp ditherforge-cli pkg/ + cp ditherforge-cli-darwin-arm64 pkg/ditherforge-cli cp README.md LICENSE pkg/ - tar czf "ditherforge-cli-${VERSION}-macos-universal.tar.gz" -C pkg . + tar czf "ditherforge-cli-${VERSION}-macos-arm64.tar.gz" -C pkg . rm -rf pkg - uses: actions/upload-artifact@v4 @@ -326,7 +291,7 @@ jobs: - Linux: \`ditherforge-${VERSION}-linux-amd64.tar.gz\` - Windows: \`ditherforge-${VERSION}-windows-amd64.zip\` - - macOS: \`ditherforge-${VERSION}-macos-universal.dmg\` + - macOS (Apple Silicon): \`ditherforge-${VERSION}-macos-arm64.dmg\` For the command-line build, see the **Latest CLI build** release. EOF @@ -346,9 +311,8 @@ jobs: ## Downloads - Linux amd64: \`ditherforge-cli-${VERSION}-linux-amd64.tar.gz\` - - Linux arm64: \`ditherforge-cli-${VERSION}-linux-arm64.tar.gz\` - Windows: \`ditherforge-cli-${VERSION}-windows-amd64.zip\` - - macOS universal: \`ditherforge-cli-${VERSION}-macos-universal.tar.gz\` + - macOS (Apple Silicon): \`ditherforge-cli-${VERSION}-macos-arm64.tar.gz\` For the desktop app, see the **Latest GUI build** release. EOF diff --git a/README.md b/README.md index 671a5cf..cd16c80 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,12 @@ Pre-built binaries for Linux, Windows, and macOS are available on the 3. Set **Nozzle diameter** and **Layer height** to match your slicer 4. Set **Size (mm)** to your target print size 5. Optionally, open the **Stickers** panel to apply PNG or JPEG images onto the model surface -6. Adjust the palette and color settings — the output preview updates automatically -7. Use **File > Export 3MF** to save the result (defaults to `.3mf`) -8. Open the exported 3MF in OrcaSlicer or BambuStudio and print +6. Optionally, open the **Split** panel to cut the model in two halves that print side-by-side and assemble with pegs +7. Adjust the palette and color settings — the output preview updates automatically +8. Use **File > Export 3MF** to save the result (defaults to `.3mf`) +9. Open the exported 3MF in OrcaSlicer or BambuStudio and print + +All sidebar sections are collapsible — click a section header to fold or expand it. **File > Open Recent** lists both recently opened models and recently used JSON settings files. @@ -191,6 +194,44 @@ regions that are nearly a single solid color. Set the value with the **Color snap (delta E)** slider (0 to 50, default 5). Set to 0 to disable. +## How to Split a Model into Two Halves + +The **Split** panel cuts the model along an axis-aligned plane into two halves +that print separately and assemble back into the original. Both halves are +laid out side by side on the build plate, sitting flat on the cut face. Use +this when the model is taller than your build volume, when supports for an +overhang would otherwise be hard to remove, or when you want to paint each +half before assembly. + +To split a model: + +1. Open the **Split** panel and check **Split into two parts**. Alpha-wrap is + forced on automatically — a clean cut needs a watertight input mesh. +2. Choose the **Cut plane** (XY, XZ, or YZ) and the **Offset** along that + axis. The 3D viewer overlays a translucent quad showing the live cut + position. +3. Pick a **Connector style**: + - **Pegs** — a solid peg on one half mates with a matching pocket on the + other. Best for FDM where dowel hardware isn't on hand. + - **Dowel holes** — matching pockets on both halves; print or buy + separate dowel pins to glue in. + - **None** — flat cut, glue-only assembly. +4. Adjust **Count** (number of connectors along the cut; **Auto** picks 1, 2, + or 3 based on the cut polygon's inscribed-circle radius), **Diameter**, + **Depth**, **Clearance** (per-side radial gap on the female feature so + the peg slides in), and **Bed gap** (space between the two halves on the + plate) as needed. +5. Export the result with **File > Export 3MF** as usual. The exported file + contains two build items, one per half, that the slicer treats as + independent objects. + +Stickers, color pins, and base color are applied to the original (unsplit) +mesh, so they survive the cut and appear on whichever half they land on. +Split panel state is saved and restored with the JSON settings file. + +If you turn off **Alpha-wrap** while Split is enabled, Split is automatically +disabled as well. A toast explains the dependency. + ## How to Save and Load Settings Use **File > Save JSON** to save all current settings — palette, color pins, @@ -227,29 +268,45 @@ compatible with OrcaSlicer and BambuStudio. frontmost surface; "Unfold" mode flood-fills from the placement point across mesh adjacency. Sticker colors are alpha-composited over the base texture. -4. **Voxelize** — maps the model onto a grid of cells matching the nozzle and +4. **Split** (optional) — cuts the geometry mesh along the configured plane + using CGAL's `Polygon_mesh_processing::clip`, bakes peg or dowel + connectors into the cut faces via boolean ops, and lays the two halves + side by side on the build plate. Color sampling stays in the original + mesh's coordinate frame, so stickers, color pins, and base color + survive the cut unchanged. +5. **Voxelize** — maps the model onto a grid of cells matching the nozzle and layer settings. Each cell gets the color sampled from the original texture (including any stickers). First-layer cells are wider (`nozzle × 1.275`); upper cells are narrower (`nozzle × 1.05`). -5. **Color adjust** — applies brightness, contrast, and saturation. -6. **Color warp** — applies color pin remappings using Gaussian RBF +6. **Color adjust** — applies brightness, contrast, and saturation. +7. **Color warp** — applies color pin remappings using Gaussian RBF interpolation in CIELAB color space. -7. **Palette** — resolves locked colors, then selects auto colors from the +8. **Palette** — resolves locked colors, then selects auto colors from the active collection. Applies color snap to shift cell colors toward the palette. -8. **Dither** — assigns a palette color to each cell to approximate the original +9. **Dither** — assigns a palette color to each cell to approximate the original texture. The default `dizzy` mode uses random traversal with error diffusion to spatial neighbors, producing blue-noise-like patterns. `none` assigns the nearest palette color with no dithering. -9. **Clip** — cuts the decimated mesh along voxel color boundaries and assigns - each fragment a palette color. -10. **Merge** — merges coplanar triangles to reduce face count. -11. **Export** — writes a 3MF file with per-face material assignments. +10. **Clip** — cuts the decimated mesh along voxel color boundaries and assigns + each fragment a palette color. +11. **Merge** — merges coplanar triangles to reduce face count. +12. **Export** — writes a 3MF file with per-face material assignments. When + Split is enabled, two `` entries are emitted (one per half) so + slicers see them as independent build items. If **Alpha-wrap** is enabled (Advanced section), it runs between Load and -Decimate to produce a watertight shell of the input mesh. +Decimate to produce a watertight shell of the input mesh. Split also forces +alpha-wrap on, since the cut needs a watertight input. Each stage is cached by its settings hash. Changing a downstream parameter -(e.g., dithering mode) skips all upstream stages on the next run. +(e.g., dithering mode) skips all upstream stages on the next run. The Load, +Decimate, and Alpha-wrap stage caches persist across app restarts on disk +(zstd-compressed), so re-opening a recent model is much faster than the +first time. + +While the pipeline runs, the output stage list shows live progress for each +stage along with cache hit/miss status. If a stage fails, the error message +appears as a final line in the list. --- @@ -265,8 +322,9 @@ ditherforge-cli model.glb --size 100 This loads `model.glb`, scales it to 100 mm, selects 4 colors from the default palette, and writes `model.3mf` alongside the input. -Note: the CLI does not currently support stickers, color pins, or multi-object -selection. Use the GUI to configure those and save a JSON settings file. +Note: the CLI does not currently support stickers, color pins, splitting, or +multi-object selection. Use the GUI to configure those and save a JSON +settings file. ### Options @@ -401,6 +459,27 @@ contrast, and saturation adjustments as the rest of the model. Stickers are saved as part of the JSON settings file. +### Split + +Cuts the model along an axis-aligned plane into two halves laid out side by +side on the build plate. Optional connectors register the halves during +glue-up. + +| Field | Default | Description | +|-------|---------|-------------| +| Split into two parts | off | Master toggle. When off, the rest of the section is hidden and the pipeline behaves as if Split didn't exist. Forces Alpha-wrap on; turning Alpha-wrap off auto-disables Split. | +| Cut plane | XY | Axis-aligned plane: XY (cut along Z), XZ (cut along Y), or YZ (cut along X). | +| Offset (mm) | bbox mid | Position of the cut plane along the chosen axis, measured from the model's local origin. Adjustable via number field or slider. | +| Connector style | Pegs | `Pegs` (built-in male/female), `Dowel holes` (matching pockets, separate dowel pins), or `None` (flat cut). | +| Count | Auto | Number of connectors. `Auto` picks 1, 2, or 3 based on the cut polygon's inscribed-circle radius. | +| Diameter (mm) | 5.0 | Connector diameter. Hidden when style is None. | +| Depth (mm) | 6.0 | Connector depth (per side for dowels). Hidden when style is None. | +| Clearance (mm) | 0.15 | Per-side radial clearance applied to the female feature so the peg slides in. | +| Bed gap (mm) | 5.0 | Space between the two halves on the build plate. | + +While the Split panel is open, a translucent overlay in the 3D viewer shows +the live cut plane through the input model. + ### Color Pins (Warp Pins) Each pin maps a source color to a target filament color using Gaussian RBF @@ -452,7 +531,7 @@ solid-color regions. Saved settings include: input file path, size/scale, nozzle diameter, layer height, palette (locked colors and collection), color adjustments, color pins, -stickers, dither mode, color snap, and advanced flags. +stickers, dither mode, color snap, split configuration, and advanced flags. ### Advanced Options (GUI) diff --git a/app.go b/app.go index 7a6bf17..883e25b 100644 --- a/app.go +++ b/app.go @@ -24,6 +24,7 @@ import ( "github.com/rtwfroody/ditherforge/internal/loader" "github.com/rtwfroody/ditherforge/internal/palette" "github.com/rtwfroody/ditherforge/internal/pipeline" + "github.com/rtwfroody/ditherforge/internal/plog" "github.com/rtwfroody/ditherforge/internal/progress" wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" ) @@ -31,11 +32,15 @@ import ( // App is the Wails application backend. type App struct { ctx context.Context - mu sync.Mutex // protects cache and lastOpts; held during pipeline execution and Export3MF + mu sync.Mutex // protects cache and the last* mesh-handler IDs; held during pipeline execution and Export3MF cancelMu sync.Mutex // protects cancel func; separate from mu so ProcessPipeline can cancel without blocking cancel context.CancelFunc // cancels in-flight pipeline work cache *pipeline.StageCache // per-stage cache across runs - lastOpts pipeline.Options // last successfully processed options + // lastOpts is the last successfully processed Options. Uses + // atomic.Pointer so SplitPreview (and other read-only Wails + // methods) can snapshot it without blocking on `mu`, which the + // pipeline worker holds for the entire duration of a run. + lastOpts atomic.Pointer[pipeline.Options] pipeGen atomic.Int64 // generation counter for pipeline requests meshes *meshHandler // serves binary mesh data over HTTP lastInputID string // mesh handler ID for last input mesh (protected by mu) @@ -67,6 +72,13 @@ type meshEvent struct { URL string `json:"url"` PreviewScale float32 `json:"previewScale,omitempty"` ExtentMM float32 `json:"extentMM,omitempty"` // native max extent in mm, for input-mesh + // Per-axis bbox of the loaded model in original-mesh coords (mm, + // post-scale, post-normalizeZ). Populated only on input-mesh + // events. Used by the Split Settings panel to size the offset + // slider's range and pick a sensible default offset (the bbox + // midpoint along the chosen axis). + BBoxMin [3]float32 `json:"bboxMin,omitempty"` + BBoxMax [3]float32 `json:"bboxMax,omitempty"` } // NewApp creates a new App instance. @@ -80,7 +92,16 @@ func NewApp() *App { if dir, err := diskcache.DefaultDir(); err == nil { if d, err := diskcache.Open(dir); err == nil { d.OnError = func(stage, op, key string, err error) { - fmt.Fprintf(os.Stderr, "disk cache %s %s [%s]: %v\n", stage, op, key, err) + plog.Printf("disk cache %s %s key=%s: %v", stage, op, shortDiskKey(key), err) + } + d.OnEvict = func(stage, key, description, reason string, sizeBytes, costMs int64, mtime time.Time) { + what := description + if what == "" { + what = stage + } + plog.Printf("disk cache evict (%s): %s key=%s — %s, %.1fs to generate, %s old", + reason, what, shortDiskKey(key), humanSize(sizeBytes), + float64(costMs)/1000, humanAge(time.Since(mtime))) } cache.SetDisk(d) } else { @@ -97,9 +118,57 @@ func NewApp() *App { const ( diskCacheMaxAge = 7 * 24 * time.Hour - diskCacheMaxBytes = 1 << 30 // 1 GiB + diskCacheMaxBytes = 1 << 31 // 2 GiB ) +// humanSize formats a byte count using KB / MB / GB units (1024-based). +// shortDiskKey returns the first 12 hex chars of a disk-cache key — the +// same prefix length plog uses for stage cache keys, so eviction logs +// and stage cache hit/miss logs can be correlated by key. +func shortDiskKey(key string) string { + if key == "" { + return "?" + } + if len(key) > 12 { + return key[:12] + } + return key +} + +// humanAge returns a short human-readable rendering of a Duration. +// Used by the disk-cache eviction log so the user can see at a glance +// whether a freshly-generated entry is being thrown away or an +// ancient one. +func humanAge(d time.Duration) string { + if d < 0 { + d = 0 + } + switch { + case d < time.Minute: + return fmt.Sprintf("%.0fs", d.Seconds()) + case d < time.Hour: + return fmt.Sprintf("%.1fm", d.Minutes()) + case d < 24*time.Hour: + return fmt.Sprintf("%.1fh", d.Hours()) + default: + return fmt.Sprintf("%.1fd", d.Hours()/24) + } +} + +func humanSize(n int64) string { + const k = 1024.0 + f := float64(n) + switch { + case f >= k*k*k: + return fmt.Sprintf("%.1f GB", f/(k*k*k)) + case f >= k*k: + return fmt.Sprintf("%.1f MB", f/(k*k)) + case f >= k: + return fmt.Sprintf("%.1f KB", f/k) + } + return fmt.Sprintf("%d B", n) +} + func (a *App) startup(ctx context.Context) { a.ctx = ctx go a.pipelineWorker() @@ -212,11 +281,12 @@ func (a *App) Export3MF() (string, error) { a.mu.Lock() defer a.mu.Unlock() + last := a.lastOpts.Load() defaultName := "output.3mf" defaultDir := "" - if a.lastOpts.Input != "" { - defaultDir = filepath.Dir(a.lastOpts.Input) - base := filepath.Base(a.lastOpts.Input) + if last != nil && last.Input != "" { + defaultDir = filepath.Dir(last.Input) + base := filepath.Base(last.Input) ext := filepath.Ext(base) stem := strings.TrimSuffix(base, ext) if strings.EqualFold(ext, ".3mf") { @@ -225,6 +295,9 @@ func (a *App) Export3MF() (string, error) { defaultName = stem + ".3mf" } } + if last == nil { + return "", fmt.Errorf("no model has been processed yet") + } path, err := wailsRuntime.SaveFileDialog(a.ctx, wailsRuntime.SaveDialogOptions{ Title: "Save Output", @@ -241,10 +314,10 @@ func (a *App) Export3MF() (string, error) { return "", nil } - _, err = pipeline.ExportFile(a.cache, a.lastOpts, path, export3mf.Options{ - PrinterID: a.lastOpts.Printer, - NozzleDiameter: a.lastOpts.NozzleDiameter, - LayerHeight: a.lastOpts.LayerHeight, + _, err = pipeline.ExportFile(a.cache, *last, path, export3mf.Options{ + PrinterID: last.Printer, + NozzleDiameter: last.NozzleDiameter, + LayerHeight: last.LayerHeight, }) if err != nil { return "", err @@ -357,20 +430,30 @@ func (a *App) processOne(req pipelineRequest) { a.mu.Lock() defer a.mu.Unlock() + plog.Printf("Pipeline gen %d starting: %s (reloadSeq=%d)", + req.gen, req.opts.Input, req.opts.ReloadSeq) + ctx, cancel := context.WithCancel(a.ctx) a.cancelMu.Lock() a.cancel = cancel a.cancelMu.Unlock() result, err := pipeline.RunCached(ctx, a.cache, req.opts, &pipeline.Callbacks{ - OnInputMesh: func(mesh *pipeline.MeshData, pvScale float32, extentMM float32) { + OnInputMesh: func(mesh *pipeline.MeshData, pvScale float32, extentMM float32, bboxMin, bboxMax [3]float32) { // Input mesh available — emit immediately so the preview appears // before later pipeline stages finish. if a.lastInputID != "" { a.meshes.Remove(a.lastInputID) } a.lastInputID = a.meshes.Store(mesh) - wailsRuntime.EventsEmit(a.ctx, "input-mesh", meshEvent{Gen: req.gen, URL: "/mesh/" + a.lastInputID, PreviewScale: pvScale, ExtentMM: extentMM}) + wailsRuntime.EventsEmit(a.ctx, "input-mesh", meshEvent{ + Gen: req.gen, + URL: "/mesh/" + a.lastInputID, + PreviewScale: pvScale, + ExtentMM: extentMM, + BBoxMin: bboxMin, + BBoxMax: bboxMax, + }) }, OnStickerOverlay: func(mesh *pipeline.MeshData, pvScale float32) { // Alpha-wrap mode: stickers are carried by a separate mesh @@ -417,7 +500,7 @@ func (a *App) processOne(req pipelineRequest) { }) if err != nil { if ctx.Err() != nil { - fmt.Printf("Pipeline gen %d cancelled\n", req.gen) + plog.Printf("Pipeline gen %d cancelled", req.gen) wailsRuntime.EventsEmit(a.ctx, "pipeline-cancelled", pipelineEvent{Gen: req.gen}) return } @@ -427,7 +510,8 @@ func (a *App) processOne(req pipelineRequest) { }) return } - a.lastOpts = req.opts + optsCopy := req.opts + a.lastOpts.Store(&optsCopy) if result.OutputMesh != nil { if a.lastOutputID != "" { @@ -455,7 +539,7 @@ func (a *App) processOne(req pipelineRequest) { // LogMessage prints a message from the frontend to stdout. func (a *App) LogMessage(level, msg string) { - fmt.Printf("[JS %s] %s\n", level, msg) + plog.Printf("[JS %s] %s", level, msg) } // Version returns the application version string. @@ -463,6 +547,28 @@ func (a *App) Version() string { return pipeline.Version } +// SplitPreview returns the cut-plane geometry for the model that's +// currently in the cache, computed from the supplied SplitSettings +// (typically the live values from the Settings panel as the user +// drags the offset slider). The mesh used is the most recently +// loaded model — this method does NOT trigger a full pipeline run. +// +// Returns an error when the model isn't loaded yet (the user hasn't +// run the pipeline since startup). +// +// Does NOT take a.mu — the worker holds that for the entire +// duration of a pipeline run, and the slider drag rate (~60Hz) +// can't tolerate that. lastOpts is read via atomic.Pointer; the +// cache read is goroutine-safe (disk-backed, no shared in-memory +// pointer with the worker). +func (a *App) SplitPreview(s pipeline.SplitSettings) (*pipeline.SplitPreviewResult, error) { + last := a.lastOpts.Load() + if last == nil { + return nil, fmt.Errorf("no model loaded yet") + } + return pipeline.ComputeSplitPreview(a.cache, *last, s) +} + // PrinterOption describes one printer + its nozzle/layer-height options for // the frontend printer selector. Layer heights are in mm. type PrinterOption struct { @@ -707,6 +813,22 @@ type Settings struct { AlphaWrap bool `json:"alphaWrap"` AlphaWrapAlpha string `json:"alphaWrapAlpha"` AlphaWrapOffset string `json:"alphaWrapOffset"` + // Split panel state. Plain values matching the rest of the struct. + // Files saved before these fields existed lack the keys, which + // decode as zero in Go and serialise back as the explicit zero + // values on next save. On load, the frontend's TS Settings class + // reads missing JSON keys as `undefined`, and applySettings's + // `!== undefined` guards preserve in-memory state in that case — + // so older files keep round-tripping cleanly. + SplitEnabled bool `json:"splitEnabled"` + SplitAxis int `json:"splitAxis"` + SplitOffset float64 `json:"splitOffset"` + SplitConnectorStyle string `json:"splitConnectorStyle"` + SplitConnectorCount int `json:"splitConnectorCount"` + SplitConnectorDiamMM float64 `json:"splitConnectorDiamMM"` + SplitConnectorDepthMM float64 `json:"splitConnectorDepthMM"` + SplitClearanceMM float64 `json:"splitClearanceMM"` + SplitGapMM float64 `json:"splitGapMM"` } // SaveSettings writes settings to the given path. @@ -787,6 +909,7 @@ func (a *App) EnumerateObjects(path string) ([]loader.ObjectInfo, error) { // LoadSettingsFile reads settings from the given path. func (a *App) LoadSettingsFile(path string) (*LoadSettingsResult, error) { + plog.Printf("Opening settings file: %s", path) data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read settings: %w", err) diff --git a/docs/SPLIT.md b/docs/SPLIT.md new file mode 100644 index 0000000..84d686b --- /dev/null +++ b/docs/SPLIT.md @@ -0,0 +1,699 @@ +# Split feature — design doc + +Status: implemented (CGAL clip backend, connectors stubbed pending re-impl). +Owner: tim +Last updated: 2026-04-30 + +> **Implementation note (2026-04-30).** This doc originally described a +> hand-rolled per-triangle classification + cap-polygon recovery + +> ear-clip cap triangulation. That code has been replaced wholesale by a +> single call to CGAL's `Polygon_mesh_processing::clip` per half, run +> concurrently — see `internal/cgalclip/`. The CGAL clip handles all +> the cases the hand-rolled code stacked hacks on top of (on-plane +> vertices, multi-component caps, internal cavities, dense +> alpha-wrapped tessellation) robustly via exact predicates. Watertight +> input is still required; alpha-wrap remains the way to get there. +> +> Connectors (Pegs / Dowels) are a no-op stub at present: the old +> connector code threaded through cap-polygon internals that no longer +> exist. They'll come back as small boolean ops on the clipped halves +> (cylinder ∪ half[0]; cylinder ∩ half[1] with clearance offset). The +> connector design notes below still describe the intended behavior. + +## Goal + +Let the user split a model into two halves that print separately and assemble +into the original. Both halves print on the same build plate, sitting flat on +the cut face, side by side. Alignment features on the cut face make the two +halves register cleanly. + +Out of scope for v1: arbitrary-orientation cut planes, dovetail / snap-fit +joints, splitting into more than two pieces, automatic suggestion of where to +cut. + +## Key idea + +Stickers, warp pins, base color, and color sampling all live in the original +mesh's coordinate system. **Split does not touch them.** It cuts only the +geometry mesh (`lo.Model`), bakes connectors into the cut faces, lays the two +halves out side by side on the bed, and records the per-half transform that +took original-mesh coords to bed coords. + +Specifically, **only `lo.Model` is cut**. `lo.ColorModel`, `lo.SampleModel`, +and the sticker stage's cloned mesh `so.Model` all remain at their original +coordinates. The sticker spatial index `so.si` and the decal `TriUVs` (keyed +by triangle index into `so.Model`) stay valid because their host mesh is +untouched. + +Voxelization and decimation receive the two laid-out half meshes as +*separate* `*loader.LoadedModel`s, plus the two transforms. Each downstream +stage that consumes geometry just loops over `len==2` (or `len==1` when +Split is disabled). HalfIdx is **implicit** — it is the index of the +source mesh, not a per-face attribute. Voxel cells inherit `HalfIdx` from +whichever input mesh produced them at voxelize time. + +For every voxel cell at bed-space position `pBed`, voxelize knows the +cell's `halfIdx` (= which input mesh produced it), then samples color at +`xform[halfIdx].Inverse() · pBed` in original-mesh space — where the unmoved +`ColorModel`, `SampleModel`, and sticker decals still live. + +This avoids splitting decals, splitting `ColorModel`, splitting +`SampleModel`, and re-projecting any user-placed UI features. The cost is a +small dither mismatch at the seam, which we accept for v1 (see "Known +limitations"). + +## User-facing design + +A new collapsible **Split** section appears in the settings panel, off by +default. When the master toggle is off, no other split controls are visible +and the pipeline behaves exactly as today. + +### Controls + +| Control | Type | Default | Notes | +| ---------------------- | -------------- | --------- | --------------------------------------------------------------------- | +| Split into two parts | checkbox | off | Master toggle. When off, the rest of the section is hidden. | +| Cut axis | radio X / Y / Z| Z | Cut plane is perpendicular to this axis (model-space). | +| Cut offset | slider, mm | bbox mid | Position along the cut axis. Range = model bbox along that axis. | +| Connector style | dropdown | Pegs | `None`, `Pegs` (built-in male/female), `Dowel holes` (separate dowel).| +| Connector count | auto / 1 / 2 / 3| auto | Auto = 1 for short cut polygons, scales up to 3 for long ones. | +| Connector diameter | mm | 5.0 | Hidden when style = None. | +| Connector depth | mm | 6.0 | Hidden when style = None. For Dowel mode this is per-side. | +| Fit clearance | mm | 0.15 | Per-side radial clearance applied to the female feature only. | +| Side-by-side gap | mm | 5.0 | Gap between the two halves on the build plate. | + +### Live preview + +While the Split section is open, the 3D viewer overlays a translucent quad at +the current cut plane through the model. The two halves are shaded with a +small hue shift so the user can see which fragment is which before +committing. + +### Watertight requirement + +A clean cut needs a watertight input. **The frontend forces `AlphaWrap=true` +when Split is enabled** (and shows a tooltip explaining why). The pipeline +itself does not silently override settings — what `Options` says is what +runs. If the user manually disables alpha-wrap while Split is on, the Split +checkbox auto-disables and a toast explains the dependency. + +### Tooltip + +Following the existing `` pattern: "Cut the model in +two so each half fits in your build volume or so you can paint each half +before assembly. Alignment pegs help the halves register when glued." + +## Architecture + +### Pipeline placement + +```mermaid +flowchart LR + Parse --> Load + Load --> Sticker["Sticker
(consumes lo.ColorModel,
untouched by split)"] + Load --> Split + Split --> Decimate["Decimate
(one call per half)"] + Split --> Voxelize + Sticker --> Voxelize + Decimate --> Clip + Voxelize --> ColorAdjust --> ColorWarp --> Palette --> Dither --> Clip --> Merge +``` + +Split sits **directly after Load**, parallel to Sticker. Both **Decimate and +Voxelize consume Split's output**, so they share a coordinate frame +(laid-out bed coords). This was the constraint behind the placement: Clip +later combines decimated meshes with the voxelized cells, and that join is +only meaningful if both come from the same coordinate frame. + +**Decimate runs once per half**, each call going into the existing +`voxel.Decimate` with that half's vertex/face arrays. Each half is a closed +watertight mesh (surface + cap), so the simplifier sees no boundary +vertices and runs unmodified. The cap perimeter (where cut surface meets +cap fan) is preserved by QEM's planar-affinity bias — collapsing a +cap-perimeter vertex onto a surface vertex moves it off the cap plane, +incurring high quadric error. A unit test verifies cap planarity is +preserved on a representative split mesh; if real models reveal cap +deformation in practice, the simplifier can be extended with a small +optional `pinnedVertices` parameter (see phase 5). + +Sticker is unaffected by split because decals reference triangles in the +sticker stage's mesh (`so.Model`), which is *not* cut. Voxels from either +half land in original-mesh coordinates after inverse transform and pick up +decal colors transparently. + +### Stage ordering + +`StageSplit` is a new `StageID` inserted **between `StageLoad` and +`StageDecimate`**. Stage IDs cascade in the existing `stageKey` mechanism, +so any change to `SplitSettings` invalidates Split, Decimate, Voxelize, and +everything downstream — but never invalidates Sticker, Load, or Parse. + +### Options additions + +```go +type SplitSettings struct { + Enabled bool + Axis byte // 'X', 'Y', or 'Z' + Offset float64 // model-space, along Axis + ConnectorStyle string // "none", "pegs", "dowel" + ConnectorCount int // 0 = auto, otherwise 1..3 + ConnectorDiamMM float64 + ConnectorDepthMM float64 + ClearanceMM float64 + GapMM float64 +} +``` + +`Options.Split SplitSettings` (with `json:",omitempty"` semantics so old +settings files round-trip) is hashed into `stageKey` for `StageSplit` and +all downstream. When `Split.Enabled == false`, `splitSettings` hashes to a +fixed empty value so toggling Split off matches the pre-feature cache state. + +### `splitOutput` + +```go +type splitOutput struct { + Enabled bool + + // When Enabled is false, the rest of the struct is unused; downstream + // stages treat the run as if Split didn't exist. + + // Halves[i] is the laid-out closed mesh for half i (cap and connectors + // baked in). Halves[0] and Halves[1] are independent *LoadedModel + // values — no shared aliasing. + Halves [2]*loader.LoadedModel + + // Xform[i] maps original-mesh coords to bed coords for half i. + // Voxelize uses Xform[i].Inverse() to map cell centroids back to + // original-mesh coords for color sampling. + Xform [2]Transform + + CutNormal [3]float64 // outward normal from half 0 toward half 1, in original coords + CutPlaneD float64 // signed distance from origin +} + +type Transform struct { + Rotation [9]float64 // 3x3, row-major + Translation [3]float64 +} +``` + +When disabled, `splitOutput.Enabled = false`, `Halves` is zero, and +downstream consumers fall back to the unsplit `lo.Model` path. + +The two-mesh shape lets Decimate run as two independent calls into the +existing `voxel.Decimate` (each half is closed and watertight in its own +right) without touching the simplifier. Voxelize loops over the slice of +input meshes; HalfIdx is implicit in the loop index. + +**Cache size note.** For a 150 mm alpha-wrapped Apollo, the two halves +together may be 600+ MB encoded. With the existing 2 GiB disk-cache budget +and the `costMs / sqrt(size)` scoring, Split entries are expensive enough +to keep but heavy enough that they may evict cheaper neighbors — which is +the correct behavior, just worth knowing. + +### Geometry algorithm + +Implemented in a new `internal/split/` package, no CGAL. + +**1. Plane cut** (`split.Cut`): +- Classify each vertex by signed distance to plane, with `epsilon = 1e-6 × + bbox_diag`. +- For each triangle: emit unchanged, or split into 1 + 2 triangles via + linear-interp midpoint vertices. Position, normal, color, UV all + interpolated. +- Collect midpoint vertices into the **cut polygon** — generally one or + more closed loops in the plane, recovered by walking shared edges. + +**2. Cap triangulation**: +- Project the cut polygon to 2D using an orthonormal basis on the plane. +- Triangulate (with holes if there are interior loops) using ear-clipping + with hole bridging. Pure Go, ~250 LOC. No external dependency. +- Cap normals: `+n` for half-A's cap (where `n` points from A toward B), + `-n` for half-B's cap. +- Cap vertices conform to the existing `LoadedModel` parallel-array + invariants: zero UVs, and cap faces use `FaceTextureIdx = len(Textures)` + (the loader's "no-texture" sentinel — see `internal/loader/loader.go:33`) + so they fall through to the per-face base-color path. Cap voxels then + sample color via the inverse-transform path (the inverse-transformed + centroid lands inside the original mesh, where `ColorModel` produces + the right color naturally). The cap mesh itself never carries authored + color. + +**Failure policy.** `Cut` returns a clear error and produces no output +when: + +- Any model vertex lies exactly on the cut plane. On-plane vertices + break the loop walker (the cut polygon becomes non-manifold around + them). The frontend should nudge the cut offset by `epsilon × bbox_diag` + on collision. +- The cut polygon is empty (the plane misses the model entirely or the + model lies on a single side). +- Cap area is below `epsilon × bbox_diag²` (the cut plane is tangent to + the surface, producing a slit instead of a polygon). +- The cut produces multiple disconnected components in the cap (Phase 1 + doesn't support this; the frontend should choose a cut through one + connected piece). + +**No silent fallback** — an unwatertight half cannot be voxelized +correctly downstream. + +**3. Connectors** (when `style != "none"`): +- Choose `n` connector positions on the cap using max-distance-to-boundary + via the polylabel algorithm (priority-queue search for the polygon's pole + of inaccessibility, iterated to place multiple points with min separation + = `4 × diameter`). Implemented inline in + `internal/split/polylabel.go` (~150 LOC); no external dependency. +- When the cap is a polygon-with-holes (alpha-wrap can produce sealed + interior cavities, and a cut through one yields multiple loops), + connector placement targets only the **outer-boundary loop**'s + polylabel. Interior holes do not attract connectors; the triangulator + still treats them as holes for cap geometry. +- Auto-count heuristic uses **the polylabel max-inscribed-circle radius `R`** + as the size metric (not the cap bbox, which over-estimates for concave + polygons): 1 connector if `R < 4 × diameter`, 3 if `R > 12 × diameter`, + else 2. +- Reject any position closer than `2 × diameter` to the polygon boundary. + If all rejected, emit zero connectors and surface a warning. +- For each position: + - Style `pegs`: subtract a circle of radius `r + clearance` from half-B's + cap polygon (creating a hole), and emit a short capped cylinder of + radius `r` and length `depth` welded to half-A's cap surface as + additive geometry. The female pocket is closed off at depth by adding + a parallel cap surface inset by `depth` along the cap normal, plus a + cylindrical wall. + - Style `dowel`: subtract circles of radius `r + clearance` from both + caps (depth `depth` per side). No solid pegs. The user prints separate + dowels or buys hardware-store pins. + +Because the cap is planar, all booleans are 2D polygon ops — circles and +polygon-with-holes — not 3D mesh CSG. + +**4. Layout** (`split.Layout`): +- For each half, compute the rotation that takes its outward cut normal + to `-Z` (cut face on the build plate). +- Translate so the half's bbox `min.z = 0`. +- Place side by side along `+X`: half A at `x_min = 0`, half B at `x_min = + halfA.bbox_max.x + GapMM`. Center both at `y = 0`. +- Record the composite transform per half. The transform is what the user + sees on the build plate; its inverse maps back to original-mesh + coordinates. + +`Load` already runs `normalizeZ(model)` so its output has `bbox.min.z = 0`. +Split's per-half layout produces the equivalent invariant for each half +independently; **`normalizeZ` is not re-run after split**. + +**Build volume overflow.** If the laid-out bbox exceeds the printer's +build volume along X (i.e. +`halfA.x_extent + GapMM + halfB.x_extent > buildVolume.x`), `OnWarning` +fires with a clear message ("split halves don't fit on the build plate; +reduce gap, choose a different cut, or scale the model down"). The +pipeline does not abort — printing one half at a time is still useful. + +### Voxelization changes + +`StageVoxelize` is the stage that learns about per-half transforms. Its +signature gains an optional `*splitOutput`: + +- When `splitOutput == nil` or `splitOutput.Enabled == false`: behaves + exactly as today, takes `lo.Model` as the single geometry mesh. +- When enabled: voxelize iterates over `splitOutput.Halves[0]` and + `splitOutput.Halves[1]` independently, marking each cell with its + `HalfIdx` (= source mesh index). The color meshes — `lo.ColorModel`, + `lo.SampleModel`, and `so.Model` — and the sticker spatial index + `so.si` and decal `TriUVs` all stay at original coords, untouched and + unrebuilt. + +For each voxel cell, voxelize: +1. Has the cell's `HalfIdx` from its source loop (no per-face attribute + needed). +2. Inverse-transforms the cell centroid into original-mesh space: + `pOrig = splitOutput.Xform[halfIdx].Inverse() · cellCentroid`. +3. Samples `ColorModel` / `SampleModel` / sticker decals at `pOrig` using + the existing spatial-index code paths — they all live in original + coords, unmoved. + +The change to `squarevoxel.VoxelizeTwoGrids` is: accept an optional +`splitInfo` parameter — a slice of `(geomMesh, inverseTransform)` pairs — +and loop over the slice. With one entry and identity transform, behavior +is bit-identical to today. With two entries, the geometry traversal happens +twice (once per half) and each cell records its `HalfIdx`. + +`voxel.ActiveCell` gains a `HalfIdx byte` field. When `splitInfo == nil` it +stays zero. + +### `halfIdx` propagation through downstream stages + +The `halfIdx` recorded at voxelize threads through: + +- ColorAdjust / ColorWarp / Palette: pass through, untouched. +- Dither: operates on the laid-out cell grid. Error diffusion is + per-connected-component in cell-grid space; the seam's separation gap + produces no error flow across it. (See "Known limitations".) +- Clip: per-half clipping. Each half is independently clipped against its + own footprint on the bed. +- Merge / Export: emits **two `` entries** in the 3MF output, one + per `halfIdx`. This is what slicers expect for multi-part prints. + +`mergeOutput` today is a flat triangle soup +(`{ShellVerts, ShellFaces, ShellAssignments}`, see `stepcache.go:450`). It +gains a parallel per-face halfIdx array: + +```go +type mergeOutput struct { + ShellVerts [][3]float32 + ShellFaces [][3]uint32 + ShellAssignments []int32 + ShellHalfIdx []byte // parallel to ShellFaces; nil when Split disabled +} +``` + +`buildOutputModel` is called from two sites in `pipeline.go` (lines 278 +and 351 — both export and preview flows). Both must learn to produce +either one `LoadedModel` (when `ShellHalfIdx == nil`) or two (one per +halfIdx value). The export3mf layer then writes the latter as sibling +`` entries in the same file. + +### Caching impact + +- `stageKey(StageSplit, opts)`: + - Cascades from Load (so any upstream change invalidates). + - When disabled, hashes only the `Enabled` bit. Toggling Split off after + using it should re-hit prior cached entries for downstream stages. + - When enabled, hashes the full `SplitSettings`. +- `stageDescription(StageSplit, opts)`: + - Disabled: `"Split: off"` + - Enabled: `"Split: Z@5.0mm, 2× 5mm pegs"` (offset value spelled out so + the eviction log is self-describing as the user toggles values). +- Decimate's and Voxelize's stage keys already cascade from Split. + +## Frontend + +`frontend/src/App.svelte` adds a `` between Model and +Stickers. A new `frontend/src/lib/components/SplitControls.svelte` houses +the actual controls and binds to `Options.Split`. + +### Viewer state + +The 3D viewer's **input preview always shows the unsplit `lo.InputMesh`**. +The user authored stickers and warp pins against that frame, so we don't +re-base the editing view mid-flow. The translucent cut-plane overlay is +drawn through the unsplit mesh while the Split section is open. + +The **result preview** (post-pipeline, after Voxelize/Merge) shows the +laid-out, two-object output. That's the natural place to surface the +side-by-side layout because by then the user is reviewing what will be +sent to the printer, not editing. + +The backend exposes `SplitPreview() (*splitPreview, error)` returning plane +origin, normal, and the model bbox in plane-local coords so the frontend +sizes the cut-plane quad correctly. + +### Split / AlphaWrap coupling + +Coupling lives entirely in the frontend (the backend trusts `Options` to +say what should run). The policy is one-way enforcement: + +- **Toggling Split on** sets `AlphaWrap = true` in the same settings + update, before the values are sent to the backend. +- **Toggling AlphaWrap off** while Split is on cascades to setting + `Split.Enabled = false` in the same update, with a toast explaining + why. + +Because both edits happen in one settings update, there's no cycle and no +race. The Loading-stage progress label is +`"Loading (including alpha-wrap)"` (the existing label) regardless of +whether the user or the Split coupling enabled alpha-wrap — the user +shouldn't care about which one set it. + +## Implementation phases + +Independently shippable chunks, each ending with `go test ./...` green. + +1. **`internal/split/plane.go`** — `Cut(mesh, plane) → (geom *Mesh, + halfIdx []byte, capPolys [][]Polygon)`. Watertight-preserving cut + cap + triangulation, single-mesh output. No connectors, no layout. + ~500 LOC + tests. +2. **`internal/split/polylabel.go` + `connectors.go`** — pole-of- + inaccessibility placement, 2D circle subtraction on cap polygons, peg + cylinder generation as additive triangles. ~400 LOC + tests. +3. **`internal/split/layout.go`** — rotate and translate halves in-place + on `geom`, build `Transform[2]`. ~150 LOC + tests. +4. **Voxelize signature extension as no-op.** Extend + `squarevoxel.VoxelizeTwoGrids` to accept an optional `splitInfo` + parameter (per-face halfIdx + inverse transforms) and add `HalfIdx + byte` to `voxel.ActiveCell`. With `splitInfo == nil`, behavior is + bit-identical to today. **Lands first**, before StageSplit, so phase 5 + has a working Voxelize to plug into. ~250 LOC + tests. +5. **Per-half decimation glue.** Extend `pipelineRun.Decimate` to call + `squarevoxel.DecimateMesh` (which wraps the existing `voxel.Decimate`) + once per half when Split is enabled. Each half is closed and watertight + in its own right, so the simplifier runs unmodified — no per-face + attribute carry-through, no cross-half collapse policy, no constraint + set. `decimateOutput` carries `[2]*loader.LoadedModel` instead of one. + `targetCells` is split between halves proportional to face count. + + The cap perimeter is preserved by QEM's planar-affinity bias (a + cap-perimeter collapse moves the vertex off the cap plane, which is + high quadric error). A unit test on a representative split mesh + verifies cap planarity is maintained. If real-world testing reveals + cap deformation, add an optional `pinnedVertices map[uint32]bool` + parameter to `voxel.Decimate` that ORs into the existing + `vertBoundary` set — a ~10-line change, deferred unless needed. + + ~50 LOC pipeline glue + ~80 LOC tests. +6. **Pipeline wiring** — `StageSplit` in `internal/pipeline/run.go` using + `runStage[T]`, `splitOutput` type, `splitSettings` cache key, stage + description. Disabled-passthrough first. Voxelize and Decimate consume + `splitOutput` when enabled. ~200 LOC. +7. **`halfIdx` plumbing through Merge / Export** — add `ShellHalfIdx + []byte` to `mergeOutput`, partition cells by halfIdx, update **both** + `buildOutputModel` call sites in `pipeline.go` (lines 278 and 351) + to emit one or two `LoadedModel`s as appropriate, and emit two + `` entries in 3MF. The merge/export refactor is the largest + piece of plumbing; estimate 400–600 LOC + tests. +8. **Backend Wails methods** — `SplitPreview` and frontend hooks. ~50 LOC. +9. **Frontend** — `SplitControls.svelte`, plane overlay, App.svelte + wiring, Split↔AlphaWrap coupling. ~300 LOC. + +Phase 4 (Voxelize signature, no-op) is the gate that lets phase 6 +(StageSplit) land cleanly. Phase 5 (per-half decimation) is small now that +the simplifier needs no extension. + +## Testing strategy + +`internal/split/`: + +1. **Unit cube cut at z=0.5** — `Halves[0]` and `Halves[1]` each have + 12 surface + 2 cap triangles, volume 0.5, and are independently + watertight. +2. **Sphere cut at the equator** — two hemispheres. Caps are circular + polygons. Surface area per half ≈ ½ original (tolerance-based). +3. **Watertightness preservation** — for each half, every edge has + exactly two incident triangles. +4. **Cap triangulation failure modes** — plane tangent to surface, or + cutting through a single coplanar facet, returns a clear error. No + silent fallback. +5. **Connector subtraction Euler-characteristic** — cap with `n` connector + holes has `χ = 1 - n`. +6. **Bbox layout non-overlap** — half 0 and half 1 bboxes are disjoint + along X with at least `GapMM` between them; both `bbox_min.z ≈ 0`. +7. **Polylabel correctness on adversarial polygons** — concave shapes (U, + torus cross-section, mug-handle slice) place connectors strictly + inside the polygon, not at the centroid. + +`internal/squarevoxel/`: + +8. **Per-half decimation preserves cap planarity** — synthetic split mesh + decimated per half; assert cap-perimeter vertices lie within + `epsilon × bbox_diag` of the cut plane after simplification. If this + test fails on representative inputs, add the `pinnedVertices` + extension to `voxel.Decimate`. + +`internal/pipeline/`: + +9. **Cache key cascade** — changing any `SplitSettings` field invalidates + StageSplit + Decimate + Voxelize + downstream; doesn't change StageLoad + or StageSticker. +10. **Disabled-toggle stability** — toggling `Split.Enabled` on then off + yields identical downstream stage keys to never having toggled (forces + the empty-when-disabled hashing). +11. **Inverse-transform color sampling** — synthetic textured cube, cut + and laid out; assert each cell's sampled color matches what the + cell's inverse-transformed centroid would sample on the unsplit mesh. +12. **Voxelize signature no-op** — running Voxelize with `splitInfo == + nil` produces bit-identical output to the pre-feature path. + +Integration: `tests/objects/cube.3mf` through full pipeline with Split on, +producing a 3MF with two distinct `` entries that, when assembled, +reconstitute the original cube. + +## Known limitations (v1) + +- **Dither seam mismatch.** Floyd–Steinberg propagates error within + connected components; the laid-out gap stops error flow at the seam, so + dithered colors won't quite match across the cut. Mitigations exist + (anchor both halves' voxel grids to a shared origin in original-mesh + coords; propagate FS error across the seam manually) but are deferred. +- **Connectors require physical separation before voxelize.** When + connectors are present, halves must be laid out before voxelize because + pegs and pockets at original positions interpenetrate. With `style=none` + there's no fundamental obstacle to voxelizing in original coords (and + thus dithering across the seam), but for v1 we use the laid-out path + uniformly to keep one code path. +- **Single planar cut, axis-aligned.** No arbitrary orientation, no + multi-cut, no curved cuts. +- **Pegs only, no dovetails / snap-fits.** Those need 3D mesh CSG. +- **Cap colors for the geometry mesh are interior-only** — the inverse + transform always lands inside the original mesh, so cap voxels sample + correctly. The cap mesh itself carries zero UVs and `FaceTextureIdx = + len(Textures)` (no-texture sentinel) for parallel-array conformance, + but the values are never read. +- **More than two connected components per side.** If the cut produces + multiple components (e.g. a cut through a U-shape splits each leg), + the largest connected component per side is kept and the rest is + reported as a warning. + +## Phase 7 follow-up: 3MF two-object emission + +The non-bambu export path (the default for non-Bambu printers like +the Snapmaker U1) now emits one top-level `` per Split half +with one `` per object in ``. Each half is a +self-contained component .model file with its own compacted vertex +table; slicers see two independent build items and can apply +per-object settings (different filaments, orientations, etc.). + +The Bambu-Studio export path (`bambu.go`) still emits a single +`` containing both halves. The Bambu path has additional +plate metadata, multiple thumbnails, and Bambu-specific +project_settings structure that needs per-object replication. +Tracked as a follow-up; until it ships, Bambu users see both +halves as one build object containing two visually-disjoint mesh +components. + +Implementation notes: +- `splitModelByMesh` (export3mf.go) partitions a multi-mesh + `LoadedModel` (with `FaceMeshIdx` + `NumMeshes>1`) into per-mesh + parts with compacted vertex tables. Returns nil for single-mesh + models so the unsplit path takes the unchanged code path. +- `Export` builds a uniform `[]*part` slice (1 in single-mesh, N in + multi-mesh) and loops over it for the main-model `` + entries, the inner `.model` file writes, and the model_settings + per-object metadata. The single-mesh path produces output + bit-identical to pre-feature for non-bambu exports. +- `buildModelSettingsParts` emits one `` block per part; + multi-part exports name parts `ditherforge_output_partN`. + +## Phase 5 measurements (informational) + +The phase-5 cap-planarity validation (`TestDecimate_HalfPreservesCapPlanarity`) +found that QEM's planar-affinity bias preserves the cap plane +*loosely*, not *exactly*. On an icosphere cut at z=0.1 and decimated +to 50% face count, surviving cap-perimeter vertices drift up to +~3% of `cellSize` off the plane (~1.5 μm at cellSize=50 μm). This +is well below FDM printer resolution and is acceptable for v1. + +A regression that disabled the planar-affinity bias entirely would +produce drift on the order of `cellSize` itself (a 30× increase), +which the test threshold (`0.1 × cellSize`) would catch. + +If real-world prints reveal cap mismatch issues at the half +boundary, the design doc's deferred fix is to add an optional +`pinnedVertices` parameter to `voxel.Decimate` and pass the +cap-perimeter vertex set when decimating Split halves. + +## Phase 3 follow-ups (not yet addressed) + +- **Peg orientation when cap is on bed.** `Layout` rotates each half + so its outward cap normal points to −Z (cap face down on the bed). + This works cleanly for NoConnectors and Dowels: the cap rests on + the bed and the half's body extends upward. For Pegs, the male + peg extends past the cap in `+cap_normal` direction (original + coords), which becomes `−Z` in bed coords, so the peg tip rests on + the bed and the cap is elevated by the peg depth. The half is + still printable, but the user must flip-and-glue (or use a + different print orientation) to assemble. Two future-work paths: + (a) cap-up layout for the male side specifically; (b) leave it as + a documented quirk and make the result preview obvious in the + frontend. (a) is the cleaner UX but adds layout-side branching on + connector style. + +## Phase 2 follow-ups (not yet addressed) + +Phase 2 ships connector placement (polylabel) plus peg/pocket/dowel +geometry, but with one significant limitation: + +- **Multi-connector triangulation.** The auto-count heuristic and the + user-supplied `Count` are temporarily clamped to 1 inside + `placeConnectors`. When two connectors land at near-equal + Y-coordinates (which polylabel-with-exclusion frequently produces on + symmetric caps), the second hole's bridge crosses through the first + hole's bridge spike, and `earClip` fails to find an ear. The fix is + one of: + 1. Port a more robust earcut (Mapbox's earcut.js handles bridge + spikes correctly via the visibility/angle scan over reflex + vertices including bridged spike endpoints). + 2. Perturb connector placements so they don't share Y-values; emit a + warning when perturbation pushes the connector off-center. + 3. Process all hole bridges into a single combined merged polygon in + one pass (Mapbox's approach), rather than incremental per-hole + bridges. + + Until this lands, the auto heuristic always emits 1 connector. The + inscribed-circle radius `R` and the resulting auto-count (1/2/3 + before clamping) are computed and would be the correct count under + (1)–(3), so removing the cap is mostly a matter of fixing the + earcut path. + +## Phase 1 follow-ups (not yet addressed) + +These came out of the phase-1 code review but were intentionally +deferred to keep the initial commit small. Worth picking up before +phase 2 or alongside it. + +### Code structure +- **Extract `splitFace` subcases into named helpers.** The function is + ~150 lines with three subcase branches; each branch could be a + named method on `cutBuilder` with a 3-arm dispatch in the body. +- **`processFaces` boolean classification.** Replace + `s0+s1+s2 > 0` / `< 0` with `hasPos`/`hasNeg` booleans for + readability. +- **`appendFace` cap-default consolidation.** The six near-duplicate + `if half.X != nil { if srcFace >= 0 ... else ... }` blocks could + be a `capFaceDefaults` struct with one path and one source of + truth for cap-face attribute values. +- **`reversePoly` parallel-mutation hint.** The function mutates + both the points and the parallel index slice; rename or wrap as + a `polyPair` type so callers can't drift. +- **Cap-vertex base color.** Currently hardcoded + `[128,128,128,255]` (50% gray). Either comment that the value is + intentionally unused (the inverse-transform path overrides it) or + move it to a named constant. +- **`split.go` epsilon floor.** The `eps < 1e-9` clamp is + undocumented; add a comment explaining the floor is below float32 + position noise. + +### Test gaps +- **Bridge-hole adversarial polygon.** L-shaped outer with a hole in + the concave corner; exercises the visibility-check path that's now + in `bridgeHole`. Currently no test forces a non-trivial visibility + candidate. +- **Float-precision cut near vertex.** Cube cut at `z = ε × bbox_diag` + (just above z=0). Verifies the eps floor doesn't produce zero-area + cap triangles or false multi-component errors on legitimate cuts. +- **UV seam wrap-around at midpoints.** When a source UV pair + straddles a seam (e.g. 0.95 → 0.05), `midpointVertex` linearly + interpolates to 0.5 instead of wrapping. Phase 6 (color sampling) + needs to know what it gets; lock in the current behavior with a + test now. + +## Future work + +- Voxelize-in-original-coords path for `style=none` so the seam dither + matches. +- Cross-seam FS error propagation when connectors are present. +- Arbitrary-orientation cut planes (free rotation gizmo in the viewer). +- Dovetail / snap-fit connectors via 3D mesh CSG (CGAL). +- Auto-cut: pick a plane that minimizes cut area, or fits the user's + declared build volume. +- Splitting into N > 2 pieces. diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 2f3aa99..45795e4 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -11,6 +11,8 @@ import { Slider } from '$lib/components/ui/slider'; import * as Tooltip from '$lib/components/ui/tooltip'; import HelpTip from '$lib/components/HelpTip.svelte'; + import SettingsSection from '$lib/components/SettingsSection.svelte'; + import SplitControls from '$lib/components/SplitControls.svelte'; import { LockIcon, LockOpenIcon, LoaderCircleIcon, SunIcon, MoonIcon } from '@lucide/svelte'; import * as Menubar from '$lib/components/ui/menubar'; import ModelViewer from '$lib/components/ModelViewer.svelte'; @@ -23,6 +25,7 @@ import type { StickerUI } from '$lib/components/StickerPanel.svelte'; import { SharedCamera } from '$lib/components/SharedCamera.svelte'; import { contrastColor } from '$lib/utils'; + import type { CutPlanePreview } from '$lib/types'; import { ProcessPipeline, Export3MF, SaveSettings, SaveSettingsDialog, OpenFileDialog, LoadSettingsFile, DefaultSettingsPath, Version, LogMessage, GetCollectionColors, ImportCollection, CreateCollection, DeleteCollection, OpenStickerImage, ReadStickerThumbnail, EnumerateObjects, ListPrinters, Quit } from '../wailsjs/go/main/App'; import type { main } from '../wailsjs/go/models'; import { collectionStore } from '$lib/stores/collections.svelte'; @@ -146,6 +149,106 @@ let alphaWrap = $state(false); let alphaWrapAlpha = $state(''); // mm; '' = auto (5 × nozzle diameter) let alphaWrapOffset = $state(''); // mm; '' = auto (alpha / 30) + // Split (cut model into two halves with peg/pocket connectors). + // See docs/SPLIT.md. Defaults match the design doc's "what most + // users want" baseline. + let splitEnabled = $state(false); + let splitAxis = $state(2); // 0=X, 1=Y, 2=Z + let splitOffset = $state(0); + let splitConnectorStyle = $state('pegs'); + let splitConnectorCount = $state(0); // 0 = auto + let splitConnectorDiamMM = $state(5); + let splitConnectorDepthMM = $state(6); + let splitClearanceMM = $state(0.15); + let splitGapMM = $state(5); + // The loaded model's bbox in original-mesh coords (mm, post-scale, + // post-normalizeZ). Populated from the input-mesh event; null until + // the first event arrives so the Split UI can distinguish "no model + // loaded yet" from "model with bbox at the origin." + let modelBBoxMin = $state<[number, number, number] | null>(null); + let modelBBoxMax = $state<[number, number, number] | null>(null); + // Min/max for the Split offset slider — derived from the bbox along + // the chosen axis. Updates automatically when the user toggles axes + // or loads a new model. Falls back to a 0..100 placeholder while + // bbox is unknown (the slider is rarely visible in that state since + // Split.Enabled requires AlphaWrap, which requires a loaded model). + const splitOffsetMin = $derived(modelBBoxMin?.[splitAxis] ?? 0); + const splitOffsetMax = $derived(modelBBoxMax?.[splitAxis] ?? 100); + + // When the user changes the cut axis, recentre the offset on the + // new axis's bbox midpoint. Without this, an offset that was sane + // for axis Z would commonly fall outside the X or Y range. + $effect(() => { + const axis = splitAxis; + if (!modelBBoxMin || !modelBBoxMax) return; + const lo = modelBBoxMin[axis]; + const hi = modelBBoxMax[axis]; + if (splitOffset < lo || splitOffset > hi) { + splitOffset = (lo + hi) / 2; + } + }); + + // Cut-plane preview overlay for the input viewer. Mirrors the + // backend's pipeline.computeSplitPreviewFromVertices in + // internal/pipeline/splitpreview.go — keep the two in sync. The + // (U, V) basis is right-handed with U × V = Normal, and the quad + // is centred on the model's bbox so it sits symmetrically over the + // mesh. Computed client-side from the bbox so it tracks the slider + // without RPC churn. + // + // Assumes (U, V) are axis-aligned (one of {±X, ±Y, ±Z}). That lets + // us compute min/max of the projected silhouette from the two + // bbox-corner endpoints alone instead of every vertex — equivalent + // because the projection of a bbox onto an axis-aligned vector is + // determined entirely by its corners. If splitpreview.go ever + // generalizes to arbitrary plane normals, this mirror must too. + const cutPlanePreview = $derived.by((): CutPlanePreview | null => { + if (!splitEnabled || !modelBBoxMin || !modelBBoxMax) return null; + const axis = splitAxis; + const normal: [number, number, number] = [0, 0, 0]; + normal[axis] = 1; + let u: [number, number, number]; + let v: [number, number, number]; + switch (axis) { + case 0: u = [0, 1, 0]; v = [0, 0, 1]; break; + case 1: u = [0, 0, 1]; v = [1, 0, 0]; break; + default: u = [1, 0, 0]; v = [0, 1, 0]; break; + } + const proj = (p: [number, number, number], a: [number, number, number]) => + p[0] * a[0] + p[1] * a[1] + p[2] * a[2]; + const minU = Math.min(proj(modelBBoxMin, u), proj(modelBBoxMax, u)); + const maxU = Math.max(proj(modelBBoxMin, u), proj(modelBBoxMax, u)); + const minV = Math.min(proj(modelBBoxMin, v), proj(modelBBoxMax, v)); + const maxV = Math.max(proj(modelBBoxMin, v), proj(modelBBoxMax, v)); + const originU = (minU + maxU) / 2; + const originV = (minV + maxV) / 2; + const origin: [number, number, number] = [0, 0, 0]; + origin[axis] = splitOffset; + for (let i = 0; i < 3; i++) origin[i] += originU * u[i] + originV * v[i]; + // The bbox is in original-mesh mm but the input viewer renders the + // mesh at previewScale (vertices multiplied by previewScale in + // scalePreviewMesh). Scale origin and half-extents to match the + // rendered frame; (u, v, normal) directions are scale-invariant. + const ps = calibratedPreviewScale ?? 1; + return { + origin: [origin[0] * ps, origin[1] * ps, origin[2] * ps], + normal, + u, + v, + halfExtentU: ((maxU - minU) / 2) * ps, + halfExtentV: ((maxV - minV) / 2) * ps, + }; + }); + + // Cascade: turning AlphaWrap off while Split is on auto-disables + // Split (the cut needs a watertight input). The reverse cascade + // (turning Split on auto-enables AlphaWrap) lives in + // SplitControls.svelte's onAlphaWrapForced callback. + $effect(() => { + if (!alphaWrap && splitEnabled) { + splitEnabled = false; + } + }); let stickers = $state([]); let placingStickerIndex = $state(-1); const placingSticker = $derived(placingStickerIndex >= 0 ? stickers[placingStickerIndex] ?? null : null); @@ -241,6 +344,11 @@ let inputOverlayMeshUrl: string | undefined = $state(undefined); let outputMeshUrl: string | undefined = $state(undefined); let inputError = $state(''); + // Pipeline error from a backend stage failure. Rendered as a final + // red line below the stage list in the output viewer so the user can + // see *which* stage failed by the green checkmarks above it. Cleared + // at the start of each pipeline run. + let pipelineError = $state(''); // Resolved unlocked colors from the backend (the non-locked portion of the palette). let resolvedUnlockedColors = $state([]); @@ -300,7 +408,7 @@ Version().then(v => version = v); // Listen for binary mesh URLs from the backend. - EventsOn('input-mesh', (event: { gen: number; url: string; previewScale?: number; extentMM?: number }) => { + EventsOn('input-mesh', (event: { gen: number; url: string; previewScale?: number; extentMM?: number; bboxMin?: [number, number, number]; bboxMax?: [number, number, number] }) => { if (event.gen < latestGen) return; inputMeshUrl = event.url; // The overlay is set/cleared deterministically by the @@ -312,6 +420,22 @@ if (event.previewScale !== undefined) { applyPreviewScale(event.previewScale); } + // Update the model bbox for the Split offset slider. The bbox + // is in original-mesh coords (mm, post-scale, post-normalizeZ). + if (event.bboxMin && event.bboxMax) { + const newMin = event.bboxMin; + const newMax = event.bboxMax; + modelBBoxMin = newMin; + modelBBoxMax = newMax; + // If Split was enabled with the previous model's bbox in mind + // (offset clamped to a range that's now invalid), recentre the + // offset on the new model's bbox along the chosen axis. + const lo = newMin[splitAxis]; + const hi = newMax[splitAxis]; + if (splitOffset < lo || splitOffset > hi) { + splitOffset = (lo + hi) / 2; + } + } }); EventsOn('input-overlay-mesh', (event: { gen: number; url: string }) => { if (event.gen < latestGen) return; @@ -339,6 +463,7 @@ if (event.gen < latestGen) return; running = false; inputError = event.message; + pipelineError = event.message; statusMessage = `Error: ${event.message}`; statusType = 'error'; }); @@ -438,7 +563,12 @@ JSON.stringify(warpPins), JSON.stringify(stickers), dither, committedColorSnap, noMerge, noSimplify, stats, - alphaWrap, alphaWrapAlpha, alphaWrapOffset, reloadSeq]; + alphaWrap, alphaWrapAlpha, alphaWrapOffset, + splitEnabled, splitAxis, splitOffset, + splitConnectorStyle, splitConnectorCount, + splitConnectorDiamMM, splitConnectorDepthMM, + splitClearanceMM, splitGapMM, + reloadSeq]; if (!initialized) { initialized = true; return; @@ -619,6 +749,10 @@ inputMeshUrl = undefined; inputOverlayMeshUrl = undefined; outputMeshUrl = undefined; + // Bbox is per-model; clear so the Split UI doesn't briefly show + // the prior model's range while the new mesh is loading. + modelBBoxMin = null; + modelBBoxMax = null; inputFile = path; // Stickers are tied to the previous model's geometry; clear them. // Warp pins reference colors sampled from the previous model; clear too. @@ -683,6 +817,15 @@ alphaWrap, alphaWrapAlpha: String(alphaWrapAlpha), alphaWrapOffset: String(alphaWrapOffset), + splitEnabled, + splitAxis, + splitOffset, + splitConnectorStyle, + splitConnectorCount, + splitConnectorDiamMM, + splitConnectorDepthMM, + splitClearanceMM, + splitGapMM, }; } @@ -748,6 +891,15 @@ if (s.alphaWrap !== undefined) alphaWrap = s.alphaWrap; if (s.alphaWrapAlpha !== undefined) alphaWrapAlpha = s.alphaWrapAlpha; if (s.alphaWrapOffset !== undefined) alphaWrapOffset = s.alphaWrapOffset; + if (s.splitEnabled !== undefined) splitEnabled = s.splitEnabled; + if (s.splitAxis !== undefined) splitAxis = s.splitAxis; + if (s.splitOffset !== undefined) splitOffset = s.splitOffset; + if (s.splitConnectorStyle !== undefined) splitConnectorStyle = s.splitConnectorStyle; + if (s.splitConnectorCount !== undefined) splitConnectorCount = s.splitConnectorCount; + if (s.splitConnectorDiamMM !== undefined) splitConnectorDiamMM = s.splitConnectorDiamMM; + if (s.splitConnectorDepthMM !== undefined) splitConnectorDepthMM = s.splitConnectorDepthMM; + if (s.splitClearanceMM !== undefined) splitClearanceMM = s.splitClearanceMM; + if (s.splitGapMM !== undefined) splitGapMM = s.splitGapMM; } async function handleSave() { @@ -921,6 +1073,17 @@ AlphaWrap: alphaWrap, AlphaWrapAlpha: parseFloat(alphaWrapAlpha) || 0, AlphaWrapOffset: parseFloat(alphaWrapOffset) || 0, + Split: { + Enabled: splitEnabled, + Axis: splitAxis, + Offset: splitOffset, + ConnectorStyle: splitConnectorStyle, + ConnectorCount: splitConnectorCount, + ConnectorDiamMM: splitConnectorDiamMM, + ConnectorDepthMM: splitConnectorDepthMM, + ClearanceMM: splitClearanceMM, + GapMM: splitGapMM, + }, Force: force, ReloadSeq: reloadSeq, ObjectIndex: objectIndex, @@ -956,6 +1119,7 @@ } running = true; inputError = ''; + pipelineError = ''; statusMessage = 'Processing...'; statusType = 'idle'; outputMeshUrl = undefined; @@ -1061,336 +1225,371 @@ - -
- Printer -
-
-
-
- Printer + + {#snippet tip()} - Target printer for the exported 3MF. Nozzle and layer height - options adapt to what the selected printer supports. + Target hardware. Sets the smallest detail the output can reproduce. -
- - -
- Nozzle (mm) - - Nozzle diameter variant for the selected printer. Also sets the - finest horizontal detail the output can represent. - -
-
- Layer height (mm) - - Vertical resolution of the print. Must match the layer height - used when slicing. - -
- - reconcilePrinterSelection()} + > + {#each printers as p (p.id)} + {/each} - {:else} - - {/if} - -
- - -
- Model -
-
-
-
- - - - Size sets the longest dimension of the output in millimeters. Scale multiplies the model's native size. - -
-
- Base color - - Color used for faces that aren't covered by the model's texture. Pick one to override the model's default. - -
- {#if sizeMode === 'size'} - - {:else} - - {/if} - {#if baseColor} -
- - + {#if printers.length === 0} + + {/if} + + +
+ Nozzle (mm) + + Nozzle diameter variant for the selected printer. Also sets the + finest horizontal detail the output can represent. +
- {:else} - - {/if} - {#if baseColorPickerOpen} -
- { baseColor = { hex, label, collection }; baseColorPickerOpen = false; }} - onclose={() => { baseColorPickerOpen = false; }} - /> +
+ Layer height (mm) + + Vertical resolution of the print. Must match the layer height + used when slicing. +
- {/if} -
- - -
-
+ + + + {#snippet tip()} - Wrap the model with a watertight shell to fix self-intersections, thin walls, and other geometry that slicers choke on. Runs after the output is generated and can be slow on large models. + Size the model, set a fallback color, and optionally alpha-wrap to clean up bad geometry. - - {#if alphaWrap} -
- - -
- {/if} -
- -
- Stickers -
-
- - - -
- Color -
-
- -
-
-
-
- + {/snippet} +
+
+
+ + - Shift the input texture lighter or darker before dithering. + Size sets the longest dimension of the output in millimeters. Scale multiplies the model's native size.
- {brightness} -
- brightness = v} onValueCommit={(v: number) => committedBrightness = v} /> -
-
-
- + Base color - Stretch or compress the tonal range of the input texture before dithering. + Color used for faces that aren't covered by the model's texture. Pick one to override the model's default.
- {contrast} + {#if sizeMode === 'size'} + + {:else} + + {/if} + {#if baseColor} +
+ + +
+ {:else} + + {/if} + {#if baseColorPickerOpen} +
+ { baseColor = { hex, label, collection }; baseColorPickerOpen = false; }} + onclose={() => { baseColorPickerOpen = false; }} + /> +
+ {/if}
- contrast = v} onValueCommit={(v: number) => committedContrast = v} /> -
-
-
-
- + + +
+
- {saturation} + + {#if alphaWrap} +
+ + +
+ {/if}
- saturation = v} onValueCommit={(v: number) => committedSaturation = v} />
-
- - pickingPinIndex = pickingPinIndex === i ? -1 : i} - /> - - -
-
-
- - - CIELAB distance below which pixels snap to the nearest palette color instead of being dithered. Lower values preserve more color detail; higher values reduce dithering artifacts. - + + + + {#snippet tip()} + + Cut the model into two halves that print side by side + and assemble back together with peg/pocket alignment. + Useful for build-volume limits, or to expose supports + that would otherwise be hard to remove. + + {/snippet} + { alphaWrap = true; }} + /> + + + + {#snippet tip()} + + Stamp logos, labels, or artwork onto the model surface. + + {/snippet} + + + + + {#snippet tip()} + + Adjust the input texture and tune color mapping before dithering. + + {/snippet} +
+
+
+
+
+ + + Shift the input texture lighter or darker before dithering. + +
+ {brightness} +
+ brightness = v} onValueCommit={(v: number) => committedBrightness = v} /> +
+
+
+
+ + + Stretch or compress the tonal range of the input texture before dithering. + +
+ {contrast} +
+ contrast = v} onValueCommit={(v: number) => committedContrast = v} /> +
+
+
+
+ + + Make colors more vivid or closer to gray before dithering. + +
+ {saturation} +
+ saturation = v} onValueCommit={(v: number) => committedSaturation = v} /> +
+
+ + pickingPinIndex = pickingPinIndex === i ? -1 : i} + /> + + +
+
+
+ + + CIELAB distance below which pixels snap to the nearest palette color instead of being dithered. Lower values preserve more color detail; higher values reduce dithering artifacts. + +
+ {colorSnap} +
+ colorSnap = v} onValueCommit={(v: number) => committedColorSnap = v} />
- {colorSnap}
- colorSnap = v} onValueCommit={(v: number) => committedColorSnap = v} /> -
- -
- Filament - - Filament slots used in the output. Click a slot to lock it to a specific color; unlocked slots are filled automatically from the chosen collection. Use + to add more slots. - -
-
- - -
- -
-
- {#each colorSlots as slot, i} - {@const resolved = resolvedBySlot[i]} -
- - - {#if slot || resolved} + + + + {#snippet tip()} + + Filament slots used in the output. Click a slot to lock it to a specific color; unlocked slots are filled automatically from the chosen collection. Use + to add more slots. + + {/snippet} +
+ +
+
+ {#each colorSlots as slot, i} + {@const resolved = resolvedBySlot[i]} +
- {/if} - {#if colorSlots.length > 1} - - {/if} -
- {/each} - {#if colorSlots.length < 16} - + + {#if slot || resolved} + + {/if} + {#if colorSlots.length > 1} + + {/if} +
+ {/each} + {#if colorSlots.length < 16} + + {/if} +
+ {#if pickerIndex !== null} + {/if}
- {#if pickerIndex !== null} - - {/if} -
- -
-
- - - Filament collection the auto-picker draws from for unlocked palette slots. Manage collections from the Filaments menu. - + +
+
+ + + Filament collection the auto-picker draws from for unlocked palette slots. Manage collections from the Filaments menu. + +
+
-
-
- - -
- - Advanced -
-
-
+ + + + {#snippet tip()} + + Dither algorithm and diagnostic toggles. Most users can ignore these. + + {/snippet} +
@@ -1433,7 +1632,7 @@
-
+ @@ -1451,10 +1650,10 @@
- = 0} stickerPlaceMode={placingStickerIndex >= 0} stickerImage={placingSticker?.thumbnail ?? ''} stickerSize={(placingSticker?.scale ?? 0) * (calibratedPreviewScale ?? 1)} stickerRotation={placingSticker?.rotation ?? 0} onColorPick={handleColorPick} onStickerPlace={handleStickerPlace} warpPins={pickingPinIndex >= 0 ? [] : warpPins} loading={inputFile ? inputFile.split('/').pop() ?? '' : ''} errorMessage={inputError} /> + = 0} stickerPlaceMode={placingStickerIndex >= 0} stickerImage={placingSticker?.thumbnail ?? ''} stickerSize={(placingSticker?.scale ?? 0) * (calibratedPreviewScale ?? 1)} stickerRotation={placingSticker?.rotation ?? 0} onColorPick={handleColorPick} onStickerPlace={handleStickerPlace} warpPins={pickingPinIndex >= 0 ? [] : warpPins} loading={inputFile ? inputFile.split('/').pop() ?? '' : ''} errorMessage={inputError} cutPlane={cutPlanePreview} />
- +
diff --git a/frontend/src/lib/components/HelpTip.svelte b/frontend/src/lib/components/HelpTip.svelte index ceb99ba..21dfd9d 100644 --- a/frontend/src/lib/components/HelpTip.svelte +++ b/frontend/src/lib/components/HelpTip.svelte @@ -18,6 +18,8 @@ tabindex={-1} class="inline-flex items-center justify-center text-muted-foreground hover:text-foreground cursor-help align-middle" aria-label="Help" + onclick={(e: MouseEvent) => e.stopPropagation()} + onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') e.stopPropagation(); }} > diff --git a/frontend/src/lib/components/ModelViewer.svelte b/frontend/src/lib/components/ModelViewer.svelte index 32f3ed2..cf7d1c7 100644 --- a/frontend/src/lib/components/ModelViewer.svelte +++ b/frontend/src/lib/components/ModelViewer.svelte @@ -10,6 +10,7 @@ import * as THREE from 'three'; import { LogMessage } from '../../../wailsjs/go/main/App'; import { LoaderCircleIcon, CheckIcon } from '@lucide/svelte'; + import type { CutPlanePreview } from '$lib/types'; function log(msg: string) { @@ -58,6 +59,8 @@ loading = '', stages = [], stageTick = 0, + cutPlane = null, + pipelineError = '', }: { meshUrl?: string; overlayMeshUrl?: string; @@ -79,6 +82,8 @@ loading?: string; stages?: StageInfo[]; stageTick?: number; + cutPlane?: CutPlanePreview | null; + pipelineError?: string; } = $props(); // Compute live elapsed time for a running stage. The _tick parameter @@ -901,6 +906,72 @@ }; }); + // Cut-plane preview quad. Built reactively from the cutPlane prop so + // it tracks the Split slider in real time. Geometry corners are + // origin ± hU·u ± hV·v; material is translucent and double-sided + // with depthWrite off so the underlying mesh remains visible. No + // normals are computed because MeshBasicMaterial is unlit. + // + // cutPlaneRev increments on every (re)build so the Invalidator can + // trigger a Threlte render via a cheap monotonic key instead of + // stringifying the prop on every parent render. Update it via + // untrack() because `cutPlaneRev = cutPlaneRev + 1` would otherwise + // self-track inside this $effect — Svelte 5 sees the read on the + // RHS and tracks it as a dep, the write retriggers the effect, and + // the effect trips `effect_update_depth_exceeded`. The thrown error + // aborts mount partway, which manifests as the File menu not wiring + // up and `ListPrinters()` never resolving. + let cutPlaneGeo = $state(null); + let cutPlaneMat = $state(null); + let cutPlaneRev = $state(0); + function bumpCutPlaneRev() { + cutPlaneRev = untrack(() => cutPlaneRev) + 1; + } + $effect(() => { + const cp = cutPlane; + if (!cp) { + cutPlaneGeo = null; + cutPlaneMat = null; + bumpCutPlaneRev(); + return; + } + // Pad slightly so the plane visibly extends past the model. + const hU = cp.halfExtentU * 1.1; + const hV = cp.halfExtentV * 1.1; + const o = cp.origin; + const u = cp.u; + const v = cp.v; + const corner = (su: number, sv: number) => [ + o[0] + su * hU * u[0] + sv * hV * v[0], + o[1] + su * hU * u[1] + sv * hV * v[1], + o[2] + su * hU * u[2] + sv * hV * v[2], + ]; + const c00 = corner(-1, -1); + const c10 = corner(+1, -1); + const c11 = corner(+1, +1); + const c01 = corner(-1, +1); + const positions = new Float32Array([ + ...c00, ...c10, ...c11, + ...c00, ...c11, ...c01, + ]); + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + const mat = new THREE.MeshBasicMaterial({ + color: 0x4080ff, + transparent: true, + opacity: 0.25, + side: THREE.DoubleSide, + depthWrite: false, + }); + cutPlaneGeo = geo; + cutPlaneMat = mat; + bumpCutPlaneRev(); + return () => { + geo.dispose(); + mat.dispose(); + }; + }); + // Camera-direction bias for the overlay group: nudge it slightly toward // the camera so its surface always wins the depth test against the base // mesh, regardless of view angle. Bias size is scaled to scene extent @@ -1039,7 +1110,11 @@ {/if} - + {#if cutPlaneGeo && cutPlaneMat} + + {/if} + + @@ -1048,7 +1123,7 @@
{errorMessage}
- {:else if stages.length > 0} + {:else if stages.length > 0 || pipelineError}
{#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 @@ + + +
+ + + {#if enabled} +
+ + + + + + + + + {#if connectorStyle !== 'none'} + + + + + + + + {/if} + + +
+ {/if} +
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.