From 003eb969a0068e6ea58bea69b3b5a484be8a1038 Mon Sep 17 00:00:00 2001 From: SkyeRangerDelta Date: Fri, 12 Jun 2026 15:45:10 -0400 Subject: [PATCH 1/6] feat: Per-tool spacing override and bin tool_spacing (backend) Adds a keep-out air gap concept for tools that overhang their cutout (e.g. C7 bulbs with a 15mm base and 21.5mm flare). Spacing is resolved like clearance (tool override wins over the bin default) but never changes pocket geometry -- the frontend arranger consumes it. ToolSummary now exposes clearance_override and spacing_override so the bin page can resolve per-tool padding. Co-Authored-By: Claude Fable 5 --- backend/app/api/routes.py | 4 ++ backend/app/models/schemas.py | 12 ++++++ backend/app/services/bin_service.py | 11 +++++ backend/tests/test_tool_spacing.py | 64 +++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+) create mode 100644 backend/tests/test_tool_spacing.py diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index fb869f0..2deb04a 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -752,6 +752,8 @@ async def list_tools(request: Request, user_id: str = Depends(get_user_id)): smooth_level=tool.smooth_level, thumbnail_url=thumb_url, parametric=tool.shapes is not None, + clearance_override=tool.clearance_override, + spacing_override=tool.spacing_override, )) summaries.sort(key=lambda t: t.created_at or "", reverse=True) return ToolListResponse(tools=summaries) @@ -836,6 +838,8 @@ async def update_tool(request: Request, tool_id: str, req: ToolUpdateRequest, us tool.smooth_level = req.smooth_level if "clearance_override" in provided: tool.clearance_override = req.clearance_override + if "spacing_override" in provided: + tool.spacing_override = req.spacing_override user_tools.set(tool_id, tool) return tool diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index 878eaa9..31deb22 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -89,6 +89,7 @@ class BinParams(BaseModel): insert_enabled: bool = False insert_height: float = 1.0 cutout_chamfer: float = 0.0 + tool_spacing: float = 0.0 # mm; keep-out air gap beyond each cutout when arranging @field_validator("grid_x", "grid_y") @classmethod @@ -125,6 +126,13 @@ def validate_clearance(cls, v: float) -> float: raise ValueError("clearance must be between 0 and 10mm") return v + @field_validator("tool_spacing") + @classmethod + def validate_tool_spacing(cls, v: float) -> float: + if v < 0 or v > 20: + raise ValueError("tool spacing must be between 0 and 20mm") + return v + @field_validator("insert_height") @classmethod def validate_insert_height(cls, v: float) -> float: @@ -244,6 +252,7 @@ class Tool(BaseModel): # parametric shape source; when set, points/interior_rings are materialized from it shapes: list[ToolShape] | None = None clearance_override: float | None = None # mm; None = bin's cutout_clearance + spacing_override: float | None = None # mm; None = bin's tool_spacing class ToolSummary(BaseModel): @@ -257,6 +266,8 @@ class ToolSummary(BaseModel): smooth_level: float = 0.5 thumbnail_url: str | None = None parametric: bool = False + clearance_override: float | None = None + spacing_override: float | None = None class ToolUpdateRequest(BaseModel): @@ -269,6 +280,7 @@ class ToolUpdateRequest(BaseModel): # explicit null detaches a parametric tool to a plain polygon shapes: list[ToolShape] | None = None clearance_override: float | None = None + spacing_override: float | None = None class CreateToolRequest(BaseModel): diff --git a/backend/app/services/bin_service.py b/backend/app/services/bin_service.py index 48309d4..4ab6e81 100644 --- a/backend/app/services/bin_service.py +++ b/backend/app/services/bin_service.py @@ -9,6 +9,17 @@ def resolve_clearance(source_tool, bin_clearance: float) -> float: return bin_clearance +def resolve_spacing(source_tool, bin_spacing: float) -> float: + """per-tool spacing override wins over the bin's global tool_spacing. + + spacing is a keep-out air gap beyond the cutout outline used when + arranging tools; it never changes pocket geometry. + """ + if source_tool is not None and source_tool.spacing_override is not None: + return source_tool.spacing_override + return bin_spacing + + def sync_placed_tools(bin_data, user_tools) -> bool: """sync placed tools with their library versions. returns True if any changed.""" changed = False diff --git a/backend/tests/test_tool_spacing.py b/backend/tests/test_tool_spacing.py new file mode 100644 index 0000000..78a99cb --- /dev/null +++ b/backend/tests/test_tool_spacing.py @@ -0,0 +1,64 @@ +"""Tests for per-tool spacing override resolution and the bin tool_spacing field.""" +import pytest +from pydantic import ValidationError + +from app.models.schemas import BinParams, Tool, Point +from app.services.bin_service import resolve_spacing + + +def make_tool(spacing_override=None): + return Tool( + id="t1", + name="test", + points=[Point(x=0, y=0), Point(x=10, y=0), Point(x=10, y=10), Point(x=0, y=10)], + spacing_override=spacing_override, + ) + + +class TestResolveSpacing: + def test_no_tool_uses_bin_default(self): + assert resolve_spacing(None, 2.0) == 2.0 + + def test_no_override_uses_bin_default(self): + assert resolve_spacing(make_tool(), 2.0) == 2.0 + + def test_override_takes_precedence(self): + assert resolve_spacing(make_tool(spacing_override=3.25), 0.0) == 3.25 + + def test_zero_override_means_no_extra_keepout(self): + assert resolve_spacing(make_tool(spacing_override=0.0), 2.0) == 0.0 + + +class TestToolSpacingValidator: + def test_defaults_to_zero(self): + assert BinParams().tool_spacing == 0.0 + + def test_accepts_bounds(self): + assert BinParams(tool_spacing=0.0).tool_spacing == 0.0 + assert BinParams(tool_spacing=20.0).tool_spacing == 20.0 + + def test_rejects_negative(self): + with pytest.raises(ValidationError): + BinParams(tool_spacing=-1.0) + + def test_rejects_too_large(self): + with pytest.raises(ValidationError): + BinParams(tool_spacing=21.0) + + +class TestSpacingRoundTrip: + def test_override_survives_dump_and_validate(self): + tool = make_tool(spacing_override=3.25) + loaded = Tool.model_validate(tool.model_dump()) + assert loaded.spacing_override == 3.25 + + def test_missing_key_defaults_to_none(self): + """tools.json written before the field existed must load unchanged""" + data = make_tool().model_dump() + del data["spacing_override"] + assert Tool.model_validate(data).spacing_override is None + + def test_bin_params_missing_key_defaults_to_zero(self): + data = BinParams().model_dump() + del data["tool_spacing"] + assert BinParams.model_validate(data).tool_spacing == 0.0 From adae51203de58f0878e7fba4b476af36d1079761 Mon Sep 17 00:00:00 2001 From: SkyeRangerDelta Date: Fri, 12 Jun 2026 15:53:14 -0400 Subject: [PATCH 2/6] feat: Tool spacing in auto-arrange, halo, and per-tool override UI The packer now pads each tool individually with clearance + spacing + web/2 (per side), so tools that overhang their cutout get room. The bin canvas draws a dashed keep-out halo around placements whose resolved spacing is non-zero, and the bin configurator gains a Tool Spacing slider. The shape designer exposes the per-tool spacing override next to the clearance override. Adds the first packing.ts test suite. Co-Authored-By: Claude Fable 5 --- frontend/src/app/bins/[id]/page.tsx | 48 +++++-- frontend/src/app/tools/[id]/page.tsx | 7 + frontend/src/components/BinConfigurator.tsx | 11 ++ frontend/src/components/BinEditor.tsx | 3 + frontend/src/components/BinEditorCanvas.tsx | 26 ++++ frontend/src/components/ShapeDesigner.tsx | 6 + frontend/src/components/ShapeListPanel.tsx | 20 +++ frontend/src/lib/api.ts | 1 + frontend/src/lib/packing.test.ts | 144 ++++++++++++++++++++ frontend/src/lib/packing.ts | 25 +++- frontend/src/types/index.ts | 4 + 11 files changed, 284 insertions(+), 11 deletions(-) create mode 100644 frontend/src/lib/packing.test.ts diff --git a/frontend/src/app/bins/[id]/page.tsx b/frontend/src/app/bins/[id]/page.tsx index e5dbd56..9d4d5d1 100644 --- a/frontend/src/app/bins/[id]/page.tsx +++ b/frontend/src/app/bins/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect, useRef, useCallback } from 'react' +import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useRouter, useParams } from 'next/navigation' import { BinEditor } from '@/components/BinEditor' import { BinConfigurator, calcMaxCutoutDepth } from '@/components/BinConfigurator' @@ -10,7 +10,7 @@ import { getBin, updateBin, generateBinStl, getBinStlUrl, getBinZipUrl, getBinTh import { getSettings, saveSettings } from '@/lib/settings' import type { BinConfig, BinData, PlacedTool, TextLabel } from '@/types' import { Download, Loader2, Package, ChevronDown, Check, LayoutGrid, RotateCw, Sparkles } from 'lucide-react' -import { arrangeTools } from '@/lib/packing' +import { arrangeTools, type ToolPadInfo } from '@/lib/packing' import { Breadcrumb } from '@/components/Breadcrumb' import { Alert } from '@/components/Alert' import { useDebouncedSave } from '@/hooks/useDebouncedSave' @@ -41,6 +41,7 @@ function defaultConfig(): BinConfig { cutout_depth: 20, cutout_clearance: 1.0, cutout_chamfer: 0, + tool_spacing: 0, insert_enabled: false, insert_height: 1.0, text_labels: [], @@ -85,6 +86,7 @@ export default function BinPage() { const doGenerateRef = useRef<() => void>(() => {}) const [smoothedToolIds, setSmoothedToolIds] = useState>(new Set()) const [smoothLevels, setSmoothLevels] = useState>(new Map()) + const [toolInfo, setToolInfo] = useState>(new Map()) const smoothLevelTimerRef = useRef(null) const [autoSize, setAutoSize] = useState(true) @@ -159,6 +161,10 @@ export default function BinPage() { setConfig(withGridDefaults(data.bin_config)) setSmoothedToolIds(new Set(tools.filter(t => t.smoothed).map(t => t.id))) setSmoothLevels(new Map(tools.map(t => [t.id, t.smooth_level]))) + setToolInfo(new Map(tools.map(t => [t.id, { + clearance: t.clearance_override ?? null, + spacing: t.spacing_override ?? null, + }]))) } catch { setError('Bin not found') } finally { @@ -265,7 +271,15 @@ export default function BinPage() { maxY = Math.max(maxY, p.y) } } - const halfMargin = config.wall_thickness + config.cutout_clearance + 0.25 + // the widest per-tool clearance + spacing governs the grid fit + let maxPad = 0 + for (const tool of placedTools) { + const info = toolInfo.get(tool.tool_id) + const clr = info?.clearance ?? config.cutout_clearance + const sp = info?.spacing ?? (config.tool_spacing ?? 0) + maxPad = Math.max(maxPad, clr + sp) + } + const halfMargin = config.wall_thickness + maxPad + 0.25 const toolW = maxX - minX const toolH = maxY - minY const gux = config.grid_unit_x_mm @@ -295,7 +309,7 @@ export default function BinPage() { ), }))) } - }, [autoSize, isDragging, placedTools, config.grid_x, config.grid_y, config.grid_unit_x_mm, config.grid_unit_y_mm, config.wall_thickness, config.cutout_clearance]) + }, [autoSize, isDragging, placedTools, toolInfo, config.grid_x, config.grid_y, config.grid_unit_x_mm, config.grid_unit_y_mm, config.wall_thickness, config.cutout_clearance, config.tool_spacing]) const handleToggleSmoothed = useCallback(async (toolId: string, smoothed: boolean) => { try { @@ -319,7 +333,7 @@ export default function BinPage() { // pack all placed tools into the smallest grid footprint const runArrange = useCallback((tools: PlacedTool[]) => { - const result = arrangeTools(tools, config, arrangeRotation) + const result = arrangeTools(tools, config, arrangeRotation, toolInfo) if (!result) return false setPlacedTools(result.tools) setConfig(prev => (prev.grid_x === result.gridX && prev.grid_y === result.gridY @@ -329,7 +343,7 @@ export default function BinPage() { ? `${result.unplacedIds.length} tool${result.unplacedIds.length !== 1 ? 's' : ''} did not fit even at ${result.gridX}x${result.gridY} and kept ${result.unplacedIds.length !== 1 ? 'their' : 'its'} position` : null) return true - }, [config, arrangeRotation]) + }, [config, arrangeRotation, toolInfo]) const handleAddTool = useCallback((tool: PlacedTool) => { if (autoArrange && runArrange([...placedTools, tool])) return @@ -344,7 +358,10 @@ export default function BinPage() { const toolW = maxX - minX const toolH = maxY - minY - const margin = 2 * config.wall_thickness + 2 * config.cutout_clearance + 0.5 + const info = toolInfo.get(tool.tool_id) + const clr = info?.clearance ?? config.cutout_clearance + const sp = info?.spacing ?? (config.tool_spacing ?? 0) + const margin = 2 * config.wall_thickness + 2 * (clr + sp) + 0.5 const gux = config.grid_unit_x_mm const guy = config.grid_unit_y_mm const needX = Math.max(config.grid_x, Math.ceil((toolW + margin) / gux)) @@ -371,7 +388,21 @@ export default function BinPage() { } setPlacedTools(prev => [...prev, placed]) - }, [autoArrange, runArrange, placedTools, config.grid_x, config.grid_y, config.grid_unit_x_mm, config.grid_unit_y_mm, config.wall_thickness, config.cutout_clearance]) + }, [autoArrange, runArrange, placedTools, toolInfo, config.grid_x, config.grid_y, config.grid_unit_x_mm, config.grid_unit_y_mm, config.wall_thickness, config.cutout_clearance, config.tool_spacing]) + + // dashed keep-out halo per placement: clearance + spacing beyond the + // outline bbox, shown only for tools with a non-zero resolved spacing + const keepOutByPlacementId = useMemo(() => { + const m = new Map() + for (const pt of placedTools) { + const info = toolInfo.get(pt.tool_id) + const sp = info?.spacing ?? (config.tool_spacing ?? 0) + if (sp <= 0) continue + const clr = info?.clearance ?? config.cutout_clearance + m.set(pt.id, clr + sp) + } + return m + }, [placedTools, toolInfo, config.cutout_clearance, config.tool_spacing]) function handleDownload() { window.open(getBinStlUrl(binId), '_blank') @@ -583,6 +614,7 @@ export default function BinPage() { smoothLevels={smoothLevels} onSmoothLevelChange={handleSmoothLevelChange} onDraggingChange={setIsDragging} + keepOutByPlacementId={keepOutByPlacementId} /> diff --git a/frontend/src/app/tools/[id]/page.tsx b/frontend/src/app/tools/[id]/page.tsx index 0e25263..5b3129d 100644 --- a/frontend/src/app/tools/[id]/page.tsx +++ b/frontend/src/app/tools/[id]/page.tsx @@ -46,6 +46,7 @@ export default function ToolPage() { name, shapes: sent, clearance_override: tool.clearance_override ?? null, + spacing_override: tool.spacing_override ?? null, }) setMaterializeError(null) // apply the authoritative materialized outline; only adopt the @@ -114,6 +115,10 @@ export default function ToolPage() { setTool(prev => prev ? { ...prev, clearance_override } : null) }, []) + const handleSpacingChange = useCallback((spacing_override: number | null) => { + setTool(prev => prev ? { ...prev, spacing_override } : null) + }, []) + const handleConvertToPolygon = useCallback(async () => { if (!window.confirm('Convert to a freeform polygon? The shape parameters are discarded and this cannot be undone.')) return try { @@ -174,9 +179,11 @@ export default function ToolPage() { outlinePoints={tool.points} outlineRings={tool.interior_rings} clearanceOverride={tool.clearance_override ?? null} + spacingOverride={tool.spacing_override ?? null} materializeError={materializeError} onShapesChange={handleShapesChange} onClearanceChange={handleClearanceChange} + onSpacingChange={handleSpacingChange} onConvertToPolygon={handleConvertToPolygon} /> ) : ( diff --git a/frontend/src/components/BinConfigurator.tsx b/frontend/src/components/BinConfigurator.tsx index b88a919..977c47d 100644 --- a/frontend/src/components/BinConfigurator.tsx +++ b/frontend/src/components/BinConfigurator.tsx @@ -306,6 +306,17 @@ export function BinConfigurator({ config, onChange, autoSize, onAutoSizeChange, onChange={(v) => update({ cutout_clearance: v })} /> + update({ tool_spacing: v })} + /> + onSmoothLevelChange?: (toolId: string, level: number) => void onDraggingChange?: (dragging: boolean) => void + keepOutByPlacementId?: Map } type Tool = 'select' | 'text' @@ -62,6 +63,7 @@ export function BinEditor({ smoothLevels, onSmoothLevelChange, onDraggingChange, + keepOutByPlacementId, }: Props) { const svgRef = useRef(null) const [selection, setSelection] = useState(null) @@ -559,6 +561,7 @@ export function BinEditor({ pendingLabelText={pendingText} smoothedToolIds={smoothedToolIds} smoothLevels={smoothLevels} + keepOutByPlacementId={keepOutByPlacementId} activeTool={activeTool} binWidthMm={binWidthMm} binHeightMm={binHeightMm} diff --git a/frontend/src/components/BinEditorCanvas.tsx b/frontend/src/components/BinEditorCanvas.tsx index 223a1cb..6179b84 100644 --- a/frontend/src/components/BinEditorCanvas.tsx +++ b/frontend/src/components/BinEditorCanvas.tsx @@ -5,6 +5,7 @@ import type { PlacedTool, TextLabel } from '@/types' import { polygonPathData, smoothPathData, simplifyPolygon, smoothEpsilon } from '@/lib/svg' import { DEFAULT_GRID_UNIT, DISPLAY_SCALE } from '@/lib/constants' import { CutoutOverlay } from '@/components/CutoutOverlay' +import { toolBounds } from '@/lib/packing' type Tool = 'select' | 'text' @@ -32,6 +33,8 @@ interface Props { pendingLabelText: string smoothedToolIds?: Set smoothLevels?: Map + // mm beyond each placement's bbox to draw as a dashed keep-out halo + keepOutByPlacementId?: Map activeTool: Tool binWidthMm: number binHeightMm: number @@ -78,6 +81,7 @@ export function BinEditorCanvas({ pendingLabelText, smoothedToolIds, smoothLevels, + keepOutByPlacementId, activeTool, binWidthMm, binHeightMm, @@ -158,8 +162,30 @@ export function BinEditorCanvas({ } const isSelected = selection?.type === 'tool' && selection.toolId === tool.id + const keepOut = keepOutByPlacementId?.get(tool.id) + return ( + {keepOut != null && keepOut > 0 && (() => { + // keep-out halo at the bbox the arranger packs (outline + + // finger holes), expanded by clearance + spacing + const b = toolBounds(tool) + const off = keepOut * DISPLAY_SCALE + return ( + + ) + })()} void onClearanceChange: (v: number | null) => void + onSpacingChange: (v: number | null) => void onConvertToPolygon: () => void } @@ -43,9 +45,11 @@ export function ShapeDesigner({ outlinePoints, outlineRings, clearanceOverride, + spacingOverride, materializeError, onShapesChange, onClearanceChange, + onSpacingChange, onConvertToPolygon, }: Props) { const svgRef = useRef(null) @@ -395,6 +399,8 @@ export function ShapeDesigner({ onShapesChange={commitShapes} clearanceOverride={clearanceOverride} onClearanceChange={onClearanceChange} + spacingOverride={spacingOverride} + onSpacingChange={onSpacingChange} materializeError={materializeError} onConvertToPolygon={onConvertToPolygon} /> diff --git a/frontend/src/components/ShapeListPanel.tsx b/frontend/src/components/ShapeListPanel.tsx index 93b0371..df664bb 100644 --- a/frontend/src/components/ShapeListPanel.tsx +++ b/frontend/src/components/ShapeListPanel.tsx @@ -12,6 +12,8 @@ interface Props { onShapesChange: (shapes: ToolShape[]) => void clearanceOverride: number | null onClearanceChange: (v: number | null) => void + spacingOverride: number | null + onSpacingChange: (v: number | null) => void materializeError: string | null onConvertToPolygon: () => void } @@ -47,6 +49,8 @@ export function ShapeListPanel({ onShapesChange, clearanceOverride, onClearanceChange, + spacingOverride, + onSpacingChange, materializeError, onConvertToPolygon, }: Props) { @@ -214,6 +218,22 @@ export function ShapeListPanel({

Cutouts grow by this much per side in bins. Leave blank to use each bin's clearance; set 0 for an exact fit.

+ + onSpacingChange(v)} + onCommitNull={() => onSpacingChange(null)} + className={FIELD_CLASS} + /> + +

+ Extra keep-out air gap when arranging tools in a bin (the cutout itself is unchanged). Use for tools that overhang their cutout. +

Holes are carved after all solids are merged.

diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index cb1c065..327b1b3 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -229,6 +229,7 @@ export async function updateTool( smooth_level?: number shapes?: import('@/types').ToolShape[] | null clearance_override?: number | null + spacing_override?: number | null } ): Promise { return fetchApi(`/api/tools/${toolId}`, { diff --git a/frontend/src/lib/packing.test.ts b/frontend/src/lib/packing.test.ts new file mode 100644 index 0000000..5f6f302 --- /dev/null +++ b/frontend/src/lib/packing.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' +import { arrangeTools, type ToolPadInfo } from './packing' +import type { BinConfig, PlacedTool } from '@/types' + +function makeConfig(overrides: Partial = {}): BinConfig { + return { + grid_x: 2, + grid_y: 2, + grid_unit_x_mm: 42, + grid_unit_y_mm: 42, + grid_unit_locked: true, + height_units: 4, + magnets: true, + magnet_diameter: 6, + magnet_depth: 2.4, + magnet_corners_only: false, + stacking_lip: true, + wall_thickness: 1.6, + cutout_depth: 20, + cutout_clearance: 1.0, + cutout_chamfer: 0, + tool_spacing: 0, + insert_enabled: false, + insert_height: 1.0, + text_labels: [], + bed_size: 256, + ...overrides, + } +} + +function makeSquare(id: string, toolId: string, size: number): PlacedTool { + return { + id, + tool_id: toolId, + name: id, + points: [ + { x: 0, y: 0 }, + { x: size, y: 0 }, + { x: size, y: size }, + { x: 0, y: size }, + ], + finger_holes: [], + interior_rings: [], + rotation: 0, + } +} + +function bounds(pt: PlacedTool) { + const xs = pt.points.map((p) => p.x) + const ys = pt.points.map((p) => p.y) + return { + minX: Math.min(...xs), + maxX: Math.max(...xs), + minY: Math.min(...ys), + maxY: Math.max(...ys), + } +} + +/** outline-to-outline gap between two tools along whichever axis separates them */ +function outlineGap(a: PlacedTool, b: PlacedTool): number { + const ba = bounds(a) + const bb = bounds(b) + const dx = Math.max(bb.minX - ba.maxX, ba.minX - bb.maxX) + const dy = Math.max(bb.minY - ba.maxY, ba.minY - bb.maxY) + return Math.max(dx, dy) +} + +const WEB = 1.6 // max(1.2, wall_thickness 1.6) + +describe('arrangeTools spacing', () => { + it('legacy gap without spacing matches 2*clearance + web', () => { + const config = makeConfig() + const result = arrangeTools( + [makeSquare('a', 't1', 30), makeSquare('b', 't2', 30)], + config, + false, + )! + expect(result.unplacedIds).toEqual([]) + const [a, b] = result.tools + expect(outlineGap(a, b)).toBeCloseTo(2 * 1.0 + WEB, 5) + }) + + it('per-tool spacing widens the gap by both tools\' spacing', () => { + const config = makeConfig() + const toolInfo = new Map([ + ['t1', { spacing: 3.25 }], + ['t2', { spacing: 3.25 }], + ]) + const result = arrangeTools( + [makeSquare('a', 't1', 20), makeSquare('b', 't2', 20)], + config, + false, + toolInfo, + )! + expect(result.unplacedIds).toEqual([]) + const [a, b] = result.tools + expect(outlineGap(a, b)).toBeCloseTo(2 * 1.0 + 2 * 3.25 + WEB, 5) + }) + + it('bin tool_spacing applies to tools without an override', () => { + const config = makeConfig({ tool_spacing: 2 }) + const toolInfo = new Map([ + ['t1', { spacing: 3.25 }], + ['t2', {}], + ]) + const result = arrangeTools( + [makeSquare('a', 't1', 20), makeSquare('b', 't2', 20)], + config, + false, + toolInfo, + )! + const [a, b] = result.tools + // clr_a + sp_a + clr_b + sp_b + web + expect(outlineGap(a, b)).toBeCloseTo(1.0 + 3.25 + 1.0 + 2 + WEB, 5) + }) + + it('keeps the outline clear of the bin wall by edge + pad', () => { + const config = makeConfig() + const toolInfo = new Map([['t1', { spacing: 3.25 }]]) + const result = arrangeTools([makeSquare('a', 't1', 20)], config, false, toolInfo)! + const b = bounds(result.tools[0]) + const edge = config.wall_thickness + 0.25 + const pad = config.cutout_clearance + 3.25 + WEB / 2 + expect(b.minX).toBeCloseTo(edge + pad, 5) + expect(b.minY).toBeCloseTo(edge + pad, 5) + }) + + it('clearance override composes with spacing in the pad', () => { + const config = makeConfig() + const toolInfo = new Map([ + ['t1', { clearance: 0, spacing: 5 }], + ['t2', { clearance: 0, spacing: 5 }], + ]) + const result = arrangeTools( + [makeSquare('a', 't1', 20), makeSquare('b', 't2', 20)], + config, + false, + toolInfo, + )! + const [a, b] = result.tools + expect(outlineGap(a, b)).toBeCloseTo(0 + 5 + 0 + 5 + WEB, 5) + }) +}) + diff --git a/frontend/src/lib/packing.ts b/frontend/src/lib/packing.ts index 829d7dd..cf02e82 100644 --- a/frontend/src/lib/packing.ts +++ b/frontend/src/lib/packing.ts @@ -29,7 +29,7 @@ interface Bounds { maxY: number } -function toolBounds(pt: PlacedTool): Bounds { +export function toolBounds(pt: PlacedTool): Bounds { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity for (const p of pt.points) { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y) @@ -106,23 +106,41 @@ export interface ArrangeResult { unplacedIds: string[] } +// per-tool padding inputs, keyed by PlacedTool.tool_id; null/undefined +// fields fall back to the bin's cutout_clearance / tool_spacing +export interface ToolPadInfo { + clearance?: number | null + spacing?: number | null +} + export function arrangeTools( placedTools: PlacedTool[], config: BinConfig, allowRotation: boolean, + toolInfo?: Map, ): ArrangeResult | null { if (placedTools.length === 0) return null // distance from bin edge to the bbox must cover wall + clearance (matches // the auto-size margin); between two pockets we need both clearances plus - // a printable web, so each padded box carries clearance + web/2 per side + // a printable web, so each padded box carries clearance + spacing + web/2 + // per side. spacing is a keep-out air gap for tools that overhang their + // cutout; clearance is later baked into the pocket at STL time, so the + // finished-pocket gap is spacing_a + spacing_b + web. const web = Math.max(1.2, config.wall_thickness) - const pad = config.cutout_clearance + web / 2 const edge = config.wall_thickness + 0.25 + const padById = new Map(placedTools.map((pt) => { + const info = toolInfo?.get(pt.tool_id) + const clr = info?.clearance ?? config.cutout_clearance + const sp = info?.spacing ?? (config.tool_spacing ?? 0) + return [pt.id, clr + sp + web / 2] + })) + const boundsById = new Map(placedTools.map((pt) => [pt.id, toolBounds(pt)])) const items: PackItem[] = placedTools.map((pt) => { const b = boundsById.get(pt.id)! + const pad = padById.get(pt.id)! return { id: pt.id, w: b.maxX - b.minX + 2 * pad, h: b.maxY - b.minY + 2 * pad } }) @@ -187,6 +205,7 @@ export function arrangeTools( bb = { minX: cx - halfH, maxX: cx + halfH, minY: cy - halfW, maxY: cy + halfW } } + const pad = padById.get(pt.id)! const dx = edge + placement.x + pad - bb.minX const dy = edge + placement.y + pad - bb.minY return { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6cc8516..71e3dc7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -110,6 +110,7 @@ export interface BinConfig { cutout_depth: number cutout_clearance: number cutout_chamfer: number + tool_spacing: number insert_enabled: boolean insert_height: number text_labels: TextLabel[] @@ -147,6 +148,7 @@ export interface Tool { created_at: string | null shapes?: ToolShape[] | null clearance_override?: number | null + spacing_override?: number | null } export interface ToolSummary { @@ -160,6 +162,8 @@ export interface ToolSummary { smooth_level: number thumbnail_url: string | null parametric: boolean + clearance_override?: number | null + spacing_override?: number | null } // --- bins --- From 3a5669cb015abae374805bb42e052145dcf1f044 Mon Sep 17 00:00:00 2001 From: SkyeRangerDelta Date: Fri, 12 Jun 2026 16:00:01 -0400 Subject: [PATCH 3/6] feat: Multi-level pockets from per-shape depth (backend) Each add-shape in a parametric tool may carry a depth (mm from the bin top). compile_shapes groups adds by depth, carves every subtract out of every level, and materializes Tool.levels alongside the unchanged footprint. At generate time the levels are transformed into bin space with the same centroid+rotation math sync_placed_tools uses (placements stay level-free), clearance/simplify apply per level part, and the generator cuts one top-opening prism per part -- a stepped pocket with no overhangs by construction. An explicit level depth is absolute; the default-depth group still honours the placement depth override. The chamfer clamp now uses the shallowest resolved level, generate re-syncs placements before cutting, and the STL cache hash includes tool levels so depth-only edits invalidate it. Co-Authored-By: Claude Fable 5 --- backend/app/api/routes.py | 37 ++- backend/app/models/schemas.py | 25 ++ backend/app/services/bin_service.py | 76 +++-- backend/app/services/polygon_scaler.py | 98 +++++-- backend/app/services/shape_compiler.py | 61 +++- .../app/services/stl_generator_manifold.py | 118 ++++++-- backend/tests/test_shape_compiler.py | 24 +- backend/tests/test_shape_depth_levels.py | 277 ++++++++++++++++++ 8 files changed, 614 insertions(+), 102 deletions(-) create mode 100644 backend/tests/test_shape_depth_levels.py diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 2deb04a..21dbfe2 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -55,12 +55,12 @@ ) from app.services.image_processor import ImageProcessor from app.services.ai_tracer import AITracer -from app.services.polygon_scaler import PolygonScaler, ScaledPolygon, ScaledFingerHole +from app.services.polygon_scaler import PolygonScaler, ScaledPolygon, ScaledFingerHole, ScaledLevelPart from app.services.stl_generator_manifold import ManifoldSTLGenerator from app.services.session_store import SessionStore from app.services.tool_store import ToolStore from app.services.bin_store import BinStore -from app.services.bin_service import sync_placed_tools, resolve_clearance +from app.services.bin_service import sync_placed_tools, resolve_clearance, placed_levels from app.services.image_service import generate_tool_thumbnail from app.services import shape_compiler router = APIRouter() @@ -777,7 +777,7 @@ async def create_tool(request: Request, req: CreateToolRequest, user_id: str = D ToolShape(id=str(uuid.uuid4()), type="rectangle", mode="add", width=40.0, height=40.0) ] try: - points, interior_rings, offset = shape_compiler.compile_shapes(shapes) + points, interior_rings, offset, levels = shape_compiler.compile_shapes(shapes) except ValueError as e: raise HTTPException(status_code=422, detail=str(e)) @@ -788,6 +788,7 @@ async def create_tool(request: Request, req: CreateToolRequest, user_id: str = D points=points, interior_rings=interior_rings, shapes=shape_compiler.recentre_shapes(shapes, offset), + levels=levels, created_at=datetime.utcnow().isoformat(), ) user_tools.set(tool_id, tool) @@ -808,15 +809,17 @@ async def update_tool(request: Request, tool_id: str, req: ToolUpdateRequest, us if len(req.shapes) == 0: raise HTTPException(status_code=422, detail="design needs at least one shape") try: - points, interior_rings, offset = shape_compiler.compile_shapes(req.shapes) + points, interior_rings, offset, levels = shape_compiler.compile_shapes(req.shapes) except ValueError as e: raise HTTPException(status_code=422, detail=str(e)) tool.shapes = shape_compiler.recentre_shapes(req.shapes, offset) tool.points = points tool.interior_rings = interior_rings + tool.levels = levels else: # explicit null detaches to a plain polygon, keeping materialized points tool.shapes = None + tool.levels = None elif tool.shapes is not None and (req.points is not None or req.interior_rings is not None): raise HTTPException( status_code=422, @@ -1110,10 +1113,17 @@ def generate_bin_stl(request: Request, bin_id: str, user_id: str = Depends(get_u if not bin_data.placed_tools: raise HTTPException(status_code=400, detail="bin has no tools placed") + # re-derive placed geometry from the library tools so footprints and the + # levels derived below can never disagree (covers bins generated without + # an intervening GET, e.g. raw API or drawer flows) + if sync_placed_tools(bin_data, user_tools): + user_bins.set(bin_id, bin_data) + bc = bin_data.bin_config - # include source tool smoothed/parametric/clearance state in hash so - # toggling any of them invalidates the cache + # include source tool smoothed/parametric/clearance/levels state in hash + # so toggling any of them invalidates the cache (a depth-only edit leaves + # the placed footprint unchanged, so levels must hash explicitly) smoothed_flags = {} for pt in bin_data.placed_tools: src = user_tools.get(pt.tool_id) @@ -1122,6 +1132,7 @@ def generate_bin_stl(request: Request, bin_id: str, user_id: str = Depends(get_u "smooth_level": src.smooth_level if src else 0.5, "parametric": src.shapes is not None if src else False, "clearance_override": src.clearance_override if src else None, + "levels": [l.model_dump() for l in (src.levels or [])] if src else [], } input_data = { "bin_config": bc.model_dump(), @@ -1147,8 +1158,20 @@ def generate_bin_stl(request: Request, bin_id: str, user_id: str = Depends(get_u [(p.x, p.y) for p in ring] for ring in pt.interior_rings ] - sp = ScaledPolygon(pt.id, points_mm, pt.name, fholes, interior_rings_mm, depth_override=pt.depth_override) source_tool = user_tools.get(pt.tool_id) + level_parts = None + bin_levels = placed_levels(source_tool, pt) + if bin_levels: + level_parts = [ + ScaledLevelPart( + level.depth, + [(p.x, p.y) for p in part.points], + [[(p.x, p.y) for p in ring] for ring in part.interior_rings], + ) + for level in bin_levels + for part in level.parts + ] + sp = ScaledPolygon(pt.id, points_mm, pt.name, fholes, interior_rings_mm, depth_override=pt.depth_override, levels=level_parts) sp = polygon_scaler.add_clearance(sp, resolve_clearance(source_tool, bc.cutout_clearance)) if source_tool and source_tool.shapes: # parametric outlines are exact; only strip collinear points diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index 31deb22..0a2f4b2 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -236,6 +236,29 @@ class ToolShape(BaseModel): corner_radius: float = 0.0 # rectangle rx: float | None = None # ellipse semi-axes (circle when rx == ry) ry: float | None = None + # pocket depth in mm from the bin top; only meaningful for mode="add". + # None = the bin/placement default depth (single-level behaviour) + depth: float | None = None + + @field_validator("depth") + @classmethod + def validate_shape_depth(cls, v: float | None) -> float | None: + if v is not None and (v < 1 or v > 200): + raise ValueError("shape depth must be between 1 and 200mm") + return v + + +class ToolLevelPart(BaseModel): + """one connected component of a depth level's cross-section, tool space mm""" + points: list[Point] + interior_rings: list[list[Point]] = [] + + +class ToolLevel(BaseModel): + """materialized cross-section for one pocket depth. the pocket is the + union of each level extruded from the bin top down to its own depth.""" + depth: float | None = None # None = the default-depth group + parts: list[ToolLevelPart] class Tool(BaseModel): @@ -253,6 +276,8 @@ class Tool(BaseModel): shapes: list[ToolShape] | None = None clearance_override: float | None = None # mm; None = bin's cutout_clearance spacing_override: float | None = None # mm; None = bin's tool_spacing + # materialized per-depth cross-sections; None unless a shape has a depth + levels: list[ToolLevel] | None = None class ToolSummary(BaseModel): diff --git a/backend/app/services/bin_service.py b/backend/app/services/bin_service.py index 4ab6e81..3fbca8c 100644 --- a/backend/app/services/bin_service.py +++ b/backend/app/services/bin_service.py @@ -1,5 +1,5 @@ import math -from app.models.schemas import Point, FingerHole +from app.models.schemas import Point, FingerHole, ToolLevel, ToolLevelPart def resolve_clearance(source_tool, bin_clearance: float) -> float: @@ -20,6 +20,54 @@ def resolve_spacing(source_tool, bin_spacing: float) -> float: return bin_spacing +def _placement_transform(tool, pt): + """map library tool space into bin space using the placement's centroid + + rotation. returns a point mapper fn. the vertex-mean centroid is + rotation-invariant, so this reproduces the placed transform exactly.""" + n_placed = len(pt.points) + placed_cx = sum(p.x for p in pt.points) / n_placed + placed_cy = sum(p.y for p in pt.points) / n_placed + + n_lib = len(tool.points) + lib_cx = sum(p.x for p in tool.points) / n_lib + lib_cy = sum(p.y for p in tool.points) / n_lib + + rot = math.radians(pt.rotation) + cos_r, sin_r = math.cos(rot), math.sin(rot) + + def map_xy(x: float, y: float) -> tuple[float, float]: + rx = (x - lib_cx) * cos_r - (y - lib_cy) * sin_r + ry = (x - lib_cx) * sin_r + (y - lib_cy) * cos_r + return placed_cx + rx, placed_cy + ry + + return map_xy + + +def placed_levels(source_tool, pt) -> list[ToolLevel] | None: + """transform source_tool.levels into bin space with the same centroid + + rotation math sync_placed_tools uses for the footprint points""" + if source_tool is None or not source_tool.levels or not source_tool.points or not pt.points: + return None + map_xy = _placement_transform(source_tool, pt) + + def map_points(points): + return [Point(x=mx, y=my) for mx, my in (map_xy(p.x, p.y) for p in points)] + + return [ + ToolLevel( + depth=level.depth, + parts=[ + ToolLevelPart( + points=map_points(part.points), + interior_rings=[map_points(ring) for ring in part.interior_rings], + ) + for part in level.parts + ], + ) + for level in source_tool.levels + ] + + def sync_placed_tools(bin_data, user_tools) -> bool: """sync placed tools with their library versions. returns True if any changed.""" changed = False @@ -30,22 +78,12 @@ def sync_placed_tools(bin_data, user_tools) -> bool: if not tool or not tool.points: continue - n_placed = len(pt.points) - placed_cx = sum(p.x for p in pt.points) / n_placed - placed_cy = sum(p.y for p in pt.points) / n_placed - - n_lib = len(tool.points) - lib_cx = sum(p.x for p in tool.points) / n_lib - lib_cy = sum(p.y for p in tool.points) / n_lib - - rot = math.radians(pt.rotation) - cos_r, sin_r = math.cos(rot), math.sin(rot) + map_xy = _placement_transform(tool, pt) new_points = [] for p in tool.points: - rx = (p.x - lib_cx) * cos_r - (p.y - lib_cy) * sin_r - ry = (p.x - lib_cx) * sin_r + (p.y - lib_cy) * cos_r - new_points.append(Point(x=placed_cx + rx, y=placed_cy + ry)) + mx, my = map_xy(p.x, p.y) + new_points.append(Point(x=mx, y=my)) # preserve per-placement state (depth_override, etc.) by matching # source-tool holes to existing placed holes by id. without this, @@ -53,10 +91,9 @@ def sync_placed_tools(bin_data, user_tools) -> bool: existing_overrides = {fh.id: fh.depth_override for fh in pt.finger_holes} new_fh = [] for fh in tool.finger_holes: - rx = (fh.x - lib_cx) * cos_r - (fh.y - lib_cy) * sin_r - ry = (fh.x - lib_cx) * sin_r + (fh.y - lib_cy) * cos_r + mx, my = map_xy(fh.x, fh.y) new_fh.append(FingerHole( - id=fh.id, x=placed_cx + rx, y=placed_cy + ry, + id=fh.id, x=mx, y=my, radius=fh.radius, width=fh.width, height=fh.height, rotation=fh.rotation, shape=fh.shape, depth_override=existing_overrides.get(fh.id), @@ -66,9 +103,8 @@ def sync_placed_tools(bin_data, user_tools) -> bool: for ring in (tool.interior_rings or []): new_ring = [] for p in ring: - rx = (p.x - lib_cx) * cos_r - (p.y - lib_cy) * sin_r - ry = (p.x - lib_cx) * sin_r + (p.y - lib_cy) * cos_r - new_ring.append(Point(x=placed_cx + rx, y=placed_cy + ry)) + mx, my = map_xy(p.x, p.y) + new_ring.append(Point(x=mx, y=my)) new_rings.append(new_ring) if new_points != pt.points or new_fh != pt.finger_holes or new_rings != pt.interior_rings: diff --git a/backend/app/services/polygon_scaler.py b/backend/app/services/polygon_scaler.py index 9112f82..bf714a2 100644 --- a/backend/app/services/polygon_scaler.py +++ b/backend/app/services/polygon_scaler.py @@ -46,14 +46,30 @@ def __init__( self.depth_override = depth_override +class ScaledLevelPart: + """one connected component of a multi-level pocket cross-section, mm. + depth=None means the default-depth group (placement/bin depth applies).""" + def __init__( + self, + depth: float | None, + points_mm: list[tuple[float, float]], + interior_rings_mm: list[list[tuple[float, float]]] = None, + ): + self.depth = depth + self.points_mm = points_mm + self.interior_rings_mm = interior_rings_mm or [] + + class ScaledPolygon: - def __init__(self, id: str, points_mm: list[tuple[float, float]], label: str, finger_holes: list[ScaledFingerHole] = None, interior_rings_mm: list[list[tuple[float, float]]] = None, depth_override: float | None = None): + def __init__(self, id: str, points_mm: list[tuple[float, float]], label: str, finger_holes: list[ScaledFingerHole] = None, interior_rings_mm: list[list[tuple[float, float]]] = None, depth_override: float | None = None, levels: list[ScaledLevelPart] | None = None): self.id = id self.points_mm = points_mm self.label = label self.finger_holes = finger_holes or [] self.interior_rings_mm = interior_rings_mm or [] self.depth_override = depth_override + # when set, the pocket is cut per level part instead of the footprint + self.levels = levels class PolygonScaler: @@ -122,46 +138,82 @@ def scale_and_centre( return centered, finger_holes, interior_rings + def _buffer_rings( + self, + points_mm: list[tuple[float, float]], + interior_rings_mm: list[list[tuple[float, float]]], + clearance_mm: float, + ) -> tuple[list[tuple[float, float]], list[list[tuple[float, float]]]]: + """buffer one exterior+holes ring set outward; falls back to the input""" + shape = ShapelyPolygon(points_mm, holes=interior_rings_mm or []) + if not shape.is_valid: + shape = make_valid(shape) + + buffered = shape.buffer(clearance_mm, join_style=2) + + if buffered.geom_type == "Polygon": + coords = list(buffered.exterior.coords)[:-1] + holes = [list(interior.coords)[:-1] for interior in buffered.interiors] + return coords, holes + return points_mm, interior_rings_mm + def add_clearance(self, polygon: ScaledPolygon, clearance_mm: float) -> ScaledPolygon: - """expand polygon outward by clearance amount""" + """expand polygon outward by clearance amount. level parts get the + same clearance per side; buffered adjacent levels overlapping slightly + is harmless -- the deeper prism wins in the union of cutters.""" if clearance_mm <= 0: return polygon try: - shape = ShapelyPolygon(polygon.points_mm, holes=polygon.interior_rings_mm or []) - if not shape.is_valid: - shape = make_valid(shape) - - buffered = shape.buffer(clearance_mm, join_style=2) + coords, holes = self._buffer_rings(polygon.points_mm, polygon.interior_rings_mm, clearance_mm) - if buffered.geom_type == "Polygon": - coords = list(buffered.exterior.coords)[:-1] - holes = [list(interior.coords)[:-1] for interior in buffered.interiors] - else: - coords = polygon.points_mm - holes = polygon.interior_rings_mm + levels = None + if polygon.levels: + levels = [] + for part in polygon.levels: + p_coords, p_holes = self._buffer_rings(part.points_mm, part.interior_rings_mm, clearance_mm) + levels.append(ScaledLevelPart(part.depth, p_coords, p_holes)) - return ScaledPolygon(polygon.id, coords, polygon.label, polygon.finger_holes, holes, depth_override=polygon.depth_override) + return ScaledPolygon(polygon.id, coords, polygon.label, polygon.finger_holes, holes, depth_override=polygon.depth_override, levels=levels) except Exception: return polygon + def _simplify_rings( + self, + points_mm: list[tuple[float, float]], + interior_rings_mm: list[list[tuple[float, float]]], + tolerance_mm: float, + ) -> tuple[list[tuple[float, float]], list[list[tuple[float, float]]]]: + """Douglas-Peucker one exterior+holes ring set; falls back to the input""" + shape = ShapelyPolygon(points_mm, holes=interior_rings_mm or []) + if not shape.is_valid: + shape = make_valid(shape) + + simplified = shape.simplify(tolerance_mm, preserve_topology=True) + + if simplified.geom_type == "Polygon" and len(simplified.exterior.coords) >= 4: + coords = list(simplified.exterior.coords)[:-1] + holes = [list(interior.coords)[:-1] for interior in simplified.interiors] + return coords, holes + return points_mm, interior_rings_mm + def simplify(self, polygon: ScaledPolygon, tolerance_mm: float = 0.3) -> ScaledPolygon: """reduce vertex count via Douglas-Peucker. big speedup for CSG.""" - if len(polygon.points_mm) <= 8 and not polygon.interior_rings_mm: + if len(polygon.points_mm) <= 8 and not polygon.interior_rings_mm and not polygon.levels: return polygon try: - shape = ShapelyPolygon(polygon.points_mm, holes=polygon.interior_rings_mm or []) - if not shape.is_valid: - shape = make_valid(shape) + coords, holes = self._simplify_rings(polygon.points_mm, polygon.interior_rings_mm, tolerance_mm) - simplified = shape.simplify(tolerance_mm, preserve_topology=True) + levels = None + if polygon.levels: + levels = [] + for part in polygon.levels: + p_coords, p_holes = self._simplify_rings(part.points_mm, part.interior_rings_mm, tolerance_mm) + levels.append(ScaledLevelPart(part.depth, p_coords, p_holes)) - if simplified.geom_type == "Polygon" and len(simplified.exterior.coords) >= 4: - coords = list(simplified.exterior.coords)[:-1] - holes = [list(interior.coords)[:-1] for interior in simplified.interiors] - return ScaledPolygon(polygon.id, coords, polygon.label, polygon.finger_holes, holes, depth_override=polygon.depth_override) + return ScaledPolygon(polygon.id, coords, polygon.label, polygon.finger_holes, holes, depth_override=polygon.depth_override, levels=levels) except Exception: pass diff --git a/backend/app/services/shape_compiler.py b/backend/app/services/shape_compiler.py index e372582..a6104e9 100644 --- a/backend/app/services/shape_compiler.py +++ b/backend/app/services/shape_compiler.py @@ -12,7 +12,7 @@ from shapely.ops import unary_union from shapely.validation import make_valid -from app.models.schemas import Point, ToolShape +from app.models.schemas import Point, ToolLevel, ToolLevelPart, ToolShape # max chord deviation when approximating curves with segments. 0.05mm keeps a # 33mm circle visually round and survives the 0.05mm collinear-cleanup simplify. @@ -59,15 +59,34 @@ def _ring_points(coords) -> list[Point]: return [Point(x=c[0], y=c[1]) for c in list(coords)[:-1]] +def _level_parts(geom) -> list[ToolLevelPart]: + """split a (Multi)Polygon into ToolLevelParts, one per connected component""" + polys = list(geom.geoms) if geom.geom_type == "MultiPolygon" else [geom] + parts = [] + for p in polys: + if p.is_empty or p.area < 1e-6: + continue + parts.append(ToolLevelPart( + points=_ring_points(p.exterior.coords), + interior_rings=[_ring_points(i.coords) for i in p.interiors], + )) + return parts + + def compile_shapes( shapes: list[ToolShape], -) -> tuple[list[Point], list[list[Point]], tuple[float, float]]: +) -> tuple[list[Point], list[list[Point]], tuple[float, float], list[ToolLevel] | None]: """union all additive shapes, subtract all subtractive ones, recentre on the bounding-box midpoint (the convention every Tool consumer assumes). - Returns (points, interior_rings, (cx, cy)) where (cx, cy) is the offset that - was subtracted -- callers must shift stored shape positions by the same - amount so shapes and materialized points stay congruent. + Returns (points, interior_rings, (cx, cy), levels) where (cx, cy) is the + offset that was subtracted -- callers must shift stored shape positions by + the same amount so shapes and materialized points stay congruent. + + levels is None unless at least one add-shape has a depth: then adds are + grouped by depth (None = default group), every group has ALL subtracts + carved out, and union(levels) == the footprint exactly. The pocket becomes + the union of each level extruded to its own depth. Raises ValueError with a user-facing message on invalid input. """ @@ -75,15 +94,17 @@ def compile_shapes( if s.type == "line" and s.mode != "guide": raise ValueError("lines can only be guides") - adds = [_shape_geometry(s) for s in shapes if s.mode == "add"] + add_shapes = [s for s in shapes if s.mode == "add"] + adds = [_shape_geometry(s) for s in add_shapes] subs = [_shape_geometry(s) for s in shapes if s.mode == "subtract"] if not adds: raise ValueError("design needs at least one solid (additive) shape") + sub_union = unary_union(subs) if subs else None result = unary_union(adds) - if subs: - result = result.difference(unary_union(subs)) + if sub_union is not None: + result = result.difference(sub_union) if not result.is_valid: result = make_valid(result) @@ -104,7 +125,29 @@ def compile_shapes( points = _ring_points(result.exterior.coords) interior_rings = [_ring_points(interior.coords) for interior in result.interiors] - return points, interior_rings, (cx, cy) + + levels = None + if any(s.depth is not None for s in add_shapes): + groups: dict[float | None, list] = {} + for s, geom in zip(add_shapes, adds): + groups.setdefault(s.depth, []).append(geom) + levels = [] + for depth, geoms in groups.items(): + level_geom = unary_union(geoms) + if sub_union is not None: + level_geom = level_geom.difference(sub_union) + if not level_geom.is_valid: + level_geom = make_valid(level_geom) + if level_geom.is_empty or level_geom.area < 1e-6: + continue + level_geom = affinity.translate(level_geom, -cx, -cy) + parts = _level_parts(level_geom) + if parts: + levels.append(ToolLevel(depth=depth, parts=parts)) + if not levels: + levels = None + + return points, interior_rings, (cx, cy), levels def recentre_shapes(shapes: list[ToolShape], offset: tuple[float, float]) -> list[ToolShape]: diff --git a/backend/app/services/stl_generator_manifold.py b/backend/app/services/stl_generator_manifold.py index d159bc7..65655a6 100644 --- a/backend/app/services/stl_generator_manifold.py +++ b/backend/app/services/stl_generator_manifold.py @@ -190,6 +190,20 @@ def _resolve_pocket_depth(override: float | None, config, max_depth: float) -> f return max(5, min(base, max_depth)) +def _resolve_level_depth( + part_depth: float | None, + placement_override: float | None, + config, + max_depth: float, +) -> float: + """Per-level pocket depth: an explicit level depth is absolute (the + placement override does not scale or offset it); the default-depth group + falls back to the placement override, then the bin's cutout_depth. + insert_height and the [5, max_depth] clamp apply uniformly.""" + override = part_depth if part_depth is not None else placement_override + return _resolve_pocket_depth(override, config, max_depth) + + def _magnet_inset(config) -> float | None: """Per-side magnet inset from cell centre. Clamps to fit the smaller of the X/Y cells with ~1mm clearance to the cell edge. Returns None when the @@ -299,6 +313,38 @@ def _shapely_to_cross_sections(shifted_pts: list[tuple], interior_rings: list[li return rings +def _shift_rings(points, holes, offset_x: float, offset_y: float): + """apply the bin-centre shift and the Y-axis flip (SVG Y-down → manifold + Y-up) to one exterior + holes ring set""" + shifted = [(p[0] + offset_x, -(p[1] + offset_y)) for p in points] + shifted_holes = [] + for hole in (holes or []): + sh = [(p[0] + offset_x, -(p[1] + offset_y)) for p in hole] + if len(sh) >= 3: + shifted_holes.append(sh) + return shifted, shifted_holes + + +def _build_cross_section(shifted, shifted_holes): + """repair via Shapely and build a CrossSection; None when degenerate""" + import manifold3d as mf + + rings = _shapely_to_cross_sections(shifted, shifted_holes) + if not rings: + return None + has_holes = len(rings) > 1 + if has_holes: + # use EvenOdd to handle holes — same pattern as text labels + cs = mf.CrossSection(rings, mf.FillRule.EvenOdd) + else: + cs = mf.CrossSection(rings) + if cs.area() <= 0: + cs = mf.CrossSection([r[::-1] for r in rings], mf.FillRule.EvenOdd if has_holes else mf.FillRule.Positive) + if cs.area() <= 0: + return None + return cs + + def _make_polygon_cutouts( polygons: list[ScaledPolygon], config: GenerateRequest, @@ -307,44 +353,45 @@ def _make_polygon_cutouts( offset_x: float, offset_y: float, ): - """Batch union of all polygon cutout extrusions.""" + """Batch union of all polygon cutout extrusions. + + Multi-level polygons cut one prism per level part instead of the + footprint; every prism opens at the bin top, so the union forms a + stepped pocket with no overhangs by construction.""" import manifold3d as mf cutters = [] for poly in polygons: - shifted = [ - (p[0] + offset_x, -(p[1] + offset_y)) - for p in poly.points_mm - ] + if poly.levels: + for part in poly.levels: + shifted, shifted_holes = _shift_rings(part.points_mm, part.interior_rings_mm, offset_x, offset_y) + if len(shifted) < 3: + continue + try: + cs = _build_cross_section(shifted, shifted_holes) + if cs is None: + continue + pocket_depth = _resolve_level_depth(part.depth, poly.depth_override, config, max_depth) + cutter = mf.Manifold.extrude(cs, pocket_depth + 0.01).translate( + (0.0, 0.0, wall_top_z - pocket_depth) + ) + cutters.append(cutter) + except Exception as e: + logger.warning("level cutout failed: %s", e) + continue + + shifted, shifted_holes = _shift_rings(poly.points_mm, poly.interior_rings_mm, offset_x, offset_y) if len(shifted) < 3: continue - # shift interior rings the same way - shifted_holes = [] - for hole in (poly.interior_rings_mm or []): - shifted_hole = [ - (p[0] + offset_x, -(p[1] + offset_y)) - for p in hole - ] - if len(shifted_hole) >= 3: - shifted_holes.append(shifted_hole) try: - rings = _shapely_to_cross_sections(shifted, shifted_holes) - if not rings: + cs = _build_cross_section(shifted, shifted_holes) + if cs is None: continue - has_holes = len(rings) > 1 - if has_holes: - # use EvenOdd to handle holes — same pattern as text labels - cs = mf.CrossSection(rings, mf.FillRule.EvenOdd) - else: - cs = mf.CrossSection(rings) - if cs.area() <= 0: - cs = mf.CrossSection([r[::-1] for r in rings], mf.FillRule.EvenOdd if has_holes else mf.FillRule.Positive) - if cs.area() > 0: - pocket_depth = _resolve_pocket_depth(poly.depth_override, config, max_depth) - cutter = mf.Manifold.extrude(cs, pocket_depth + 0.01).translate( - (0.0, 0.0, wall_top_z - pocket_depth) - ) - cutters.append(cutter) + pocket_depth = _resolve_pocket_depth(poly.depth_override, config, max_depth) + cutter = mf.Manifold.extrude(cs, pocket_depth + 0.01).translate( + (0.0, 0.0, wall_top_z - pocket_depth) + ) + cutters.append(cutter) except Exception as e: logger.warning("polygon cutout failed: %s", e) @@ -387,7 +434,16 @@ def _make_chamfer_cutouts( if len(sh) >= 3: shifted_holes.append(sh) - pocket_depth = _resolve_pocket_depth(poly.depth_override, config, max_depth) + # the chamfer cuts on the footprint top edge; clamp to the shallowest + # level so it cannot punch through the floor of a shallow level whose + # edge lies on the outline + if poly.levels: + pocket_depth = min( + _resolve_level_depth(part.depth, poly.depth_override, config, max_depth) + for part in poly.levels + ) + else: + pocket_depth = _resolve_pocket_depth(poly.depth_override, config, max_depth) eff_chamfer = min(chamfer_size, max(0.0, pocket_depth - 1)) if eff_chamfer <= 0: continue diff --git a/backend/tests/test_shape_compiler.py b/backend/tests/test_shape_compiler.py index c09c967..38d7e14 100644 --- a/backend/tests/test_shape_compiler.py +++ b/backend/tests/test_shape_compiler.py @@ -1,4 +1,4 @@ -"""Tests for parametric shape materialization in shape_compiler.""" +"""Tests for parametric shape materialization in shape_compiler.""" import math import pytest @@ -27,7 +27,7 @@ def bbox(points): class TestPrimitives: def test_circle_resolution(self): # a 33mm-diameter circle must stay genuinely round - points, rings, _ = compile_shapes([circle(r=16.5)]) + points, rings, _, _ = compile_shapes([circle(r=16.5)]) assert len(points) >= 40 assert rings == [] # every vertex on the radius within chord tolerance @@ -35,18 +35,18 @@ def test_circle_resolution(self): assert math.hypot(p.x, p.y) == pytest.approx(16.5, abs=0.06) def test_rectangle_exact(self): - points, _, _ = compile_shapes([rect(w=80, h=30)]) + points, _, _, _ = compile_shapes([rect(w=80, h=30)]) minx, miny, maxx, maxy = bbox(points) assert (maxx - minx) == pytest.approx(80) assert (maxy - miny) == pytest.approx(30) def test_rotated_rounded_rect_valid(self): - points, rings, _ = compile_shapes([rect(w=50, h=20, rotation=30, corner_radius=5)]) + points, rings, _, _ = compile_shapes([rect(w=50, h=20, rotation=30, corner_radius=5)]) assert len(points) >= 4 assert rings == [] def test_result_centered_at_origin(self): - points, _, offset = compile_shapes([rect(x=100, y=-50, w=40, h=20)]) + points, _, offset, _ = compile_shapes([rect(x=100, y=-50, w=40, h=20)]) minx, miny, maxx, maxy = bbox(points) assert (minx + maxx) / 2 == pytest.approx(0, abs=1e-6) assert (miny + maxy) / 2 == pytest.approx(0, abs=1e-6) @@ -55,7 +55,7 @@ def test_result_centered_at_origin(self): class TestBooleans: def test_overlapping_adds_union_to_single_ring(self): - points, rings, _ = compile_shapes([ + points, rings, _, _ = compile_shapes([ rect(id="a", w=40, h=40), rect(id="b", x=30, w=40, h=40), ]) @@ -64,7 +64,7 @@ def test_overlapping_adds_union_to_single_ring(self): assert rings == [] def test_subtract_inside_makes_interior_ring(self): - points, rings, _ = compile_shapes([ + points, rings, _, _ = compile_shapes([ rect(w=80, h=30), circle(mode="subtract", r=5), ]) @@ -96,7 +96,7 @@ def test_no_additive_shapes_rejected(self): class TestGuides: def test_guides_excluded_from_outline(self): - points, rings, _ = compile_shapes([ + points, rings, _, _ = compile_shapes([ rect(w=40, h=40), circle(id="g", mode="guide", x=20, y=20, r=30), ]) @@ -107,7 +107,7 @@ def test_guides_excluded_from_outline(self): def test_guide_line_allowed(self): line = ToolShape(id="l", type="line", mode="guide", width=100) - points, _, _ = compile_shapes([rect(), line]) + points, _, _, _ = compile_shapes([rect(), line]) assert len(points) >= 4 def test_solid_line_rejected(self): @@ -119,15 +119,15 @@ def test_solid_line_rejected(self): class TestRecentre: def test_shapes_shifted_by_offset(self): shapes = [rect(x=100, y=-50)] - _, _, offset = compile_shapes(shapes) + _, _, offset, _ = compile_shapes(shapes) shifted = recentre_shapes(shapes, offset) assert shifted[0].x == pytest.approx(0) assert shifted[0].y == pytest.approx(0) def test_recompiling_recentred_shapes_is_stable(self): shapes = [rect(x=12.5, w=40, h=20), circle(x=30, r=10)] - points1, _, offset = compile_shapes(shapes) + points1, _, offset, _ = compile_shapes(shapes) shifted = recentre_shapes(shapes, offset) - points2, _, offset2 = compile_shapes(shifted) + points2, _, offset2, _ = compile_shapes(shifted) assert offset2 == pytest.approx((0, 0), abs=1e-9) assert len(points1) == len(points2) diff --git a/backend/tests/test_shape_depth_levels.py b/backend/tests/test_shape_depth_levels.py new file mode 100644 index 0000000..3cefc3e --- /dev/null +++ b/backend/tests/test_shape_depth_levels.py @@ -0,0 +1,277 @@ +"""Tests for per-shape depth: compile-time level grouping, placement +transform, depth resolution and the multi-level pocket cutters.""" +import math + +import pytest + +from app.models.schemas import ( + BinConfig, + BinModel, + BinParams, + PlacedTool, + Point, + Tool, + ToolLevel, + ToolLevelPart, + ToolShape, +) +from app.services.bin_service import placed_levels +from app.services.polygon_scaler import PolygonScaler, ScaledLevelPart, ScaledPolygon +from app.services.shape_compiler import compile_shapes +from app.services.stl_generator_manifold import ( + _make_polygon_cutouts, + _resolve_level_depth, +) + + +def rect(id="r1", mode="add", x=0.0, y=0.0, w=40.0, h=40.0, depth=None): + return ToolShape(id=id, type="rectangle", mode=mode, x=x, y=y, width=w, height=h, depth=depth) + + +def circle(id="c1", mode="add", x=0.0, y=0.0, r=16.5, depth=None): + return ToolShape(id=id, type="ellipse", mode=mode, x=x, y=y, rx=r, ry=r, depth=depth) + + +def ring_area(points): + """shoelace area of a Point ring""" + n = len(points) + s = 0.0 + for i in range(n): + a, b = points[i], points[(i + 1) % n] + s += a.x * b.y - b.x * a.y + return abs(s) / 2 + + +def part_area(part): + return ring_area(part.points) - sum(ring_area(r) for r in part.interior_rings) + + +class TestCompileLevels: + def test_concentric_circles_form_two_levels(self): + # the C7 bulb: wide shallow recess over a narrow deep hole + points, rings, _, levels = compile_shapes([ + circle(id="wide", r=11, depth=10), + circle(id="narrow", r=7.5, depth=30), + ]) + # footprint = the wide circle + assert rings == [] + assert max(math.hypot(p.x, p.y) for p in points) == pytest.approx(11, abs=0.06) + + assert levels is not None + by_depth = {lv.depth: lv for lv in levels} + assert set(by_depth) == {10, 30} + assert len(by_depth[10].parts) == 1 + assert len(by_depth[30].parts) == 1 + assert part_area(by_depth[10].parts[0]) == pytest.approx(math.pi * 11**2, rel=0.01) + assert part_area(by_depth[30].parts[0]) == pytest.approx(math.pi * 7.5**2, rel=0.01) + + def test_no_depths_means_no_levels(self): + _, _, _, levels = compile_shapes([circle(r=10), rect(x=5, w=20, h=20)]) + assert levels is None + + def test_mixed_depth_and_default_groups(self): + _, _, _, levels = compile_shapes([ + rect(id="base", w=80, h=30), + rect(id="deep", w=20, h=10, depth=25), + ]) + assert levels is not None + assert {lv.depth for lv in levels} == {None, 25} + + def test_subtract_carves_every_level(self): + # subtract sits inside the deep rect and spans its full height + _, _, _, levels = compile_shapes([ + rect(id="base", w=80, h=30), + rect(id="deep", w=60, h=10, depth=20), + rect(id="cut", mode="subtract", w=5, h=12), + ]) + deep = next(lv for lv in levels if lv.depth == 20) + total = sum(part_area(p) for p in deep.parts) + assert total == pytest.approx(60 * 10 - 5 * 10, rel=0.01) + + def test_subtract_splitting_one_level_yields_parts(self): + # the cut splits the deep rect in two but the footprint stays connected + points, _, _, levels = compile_shapes([ + rect(id="base", w=80, h=30), + rect(id="deep", w=60, h=10, depth=20), + rect(id="cut", mode="subtract", w=5, h=12), + ]) + deep = next(lv for lv in levels if lv.depth == 20) + assert len(deep.parts) == 2 + + def test_levels_recentred_with_footprint(self): + _, _, offset, levels = compile_shapes([ + circle(id="wide", x=100, y=-50, r=11, depth=10), + circle(id="narrow", x=100, y=-50, r=7.5, depth=30), + ]) + assert offset == pytest.approx((100, -50)) + for lv in levels: + for part in lv.parts: + xs = [p.x for p in part.points] + ys = [p.y for p in part.points] + assert (min(xs) + max(xs)) / 2 == pytest.approx(0, abs=0.1) + assert (min(ys) + max(ys)) / 2 == pytest.approx(0, abs=0.1) + + def test_depth_validator_bounds(self): + with pytest.raises(ValueError): + ToolShape(id="bad", type="ellipse", rx=5, ry=5, depth=0.5) + with pytest.raises(ValueError): + ToolShape(id="bad", type="ellipse", rx=5, ry=5, depth=201) + + +class TestResolveLevelDepth: + def test_level_depth_is_absolute(self): + bp = BinParams(cutout_depth=20) + assert _resolve_level_depth(10, 25, bp, max_depth=100) == 10.0 + + def test_default_group_falls_back_to_placement_override(self): + bp = BinParams(cutout_depth=20) + assert _resolve_level_depth(None, 25, bp, max_depth=100) == 25.0 + + def test_default_group_falls_back_to_global(self): + bp = BinParams(cutout_depth=20) + assert _resolve_level_depth(None, None, bp, max_depth=100) == 20.0 + + def test_insert_height_added(self): + bp = BinParams(cutout_depth=20, insert_enabled=True, insert_height=2.5) + assert _resolve_level_depth(10, None, bp, max_depth=100) == 12.5 + + def test_clamped_to_min_and_max(self): + bp = BinParams(cutout_depth=20) + assert _resolve_level_depth(2, None, bp, max_depth=100) == 5.0 + assert _resolve_level_depth(150, None, bp, max_depth=100) == 100.0 + + +class _Store: + def __init__(self, items): + self._d = {item.id: item for item in items} + + def get(self, key): + return self._d.get(key) + + +def _square(x0, y0, size): + return [Point(x=x0, y=y0), Point(x=x0 + size, y=y0), Point(x=x0 + size, y=y0 + size), Point(x=x0, y=y0 + size)] + + +class TestPlacedLevels: + def _tool_with_level(self): + return Tool( + id="tool1", + name="bulb", + points=_square(0, 0, 10), + levels=[ToolLevel(depth=12, parts=[ToolLevelPart(points=_square(0, 0, 10))])], + ) + + def test_rotation_matches_sync_transform(self): + tool = self._tool_with_level() + # marker level point so the rotation is observable + tool.levels[0].parts[0].points = [Point(x=10, y=5), Point(x=10, y=0), Point(x=0, y=0), Point(x=0, y=5)] + placed = PlacedTool( + id="p1", tool_id="tool1", name="bulb", + points=_square(20, 20, 10), rotation=90, + ) + levels = placed_levels(tool, placed) + # lib centroid (5,5), placed centroid (25,25); (10,5) -> 90deg -> + # rx = -(y-cy) = 0, ry = (x-cx) = 5 -> (25, 30) + p = levels[0].parts[0].points[0] + assert (p.x, p.y) == (pytest.approx(25), pytest.approx(30)) + assert levels[0].depth == 12 + + def test_no_levels_returns_none(self): + tool = Tool(id="t", name="flat", points=_square(0, 0, 10)) + placed = PlacedTool(id="p1", tool_id="t", name="flat", points=_square(20, 20, 10), rotation=0) + assert placed_levels(tool, placed) is None + + def test_translation_only(self): + tool = self._tool_with_level() + placed = PlacedTool(id="p1", tool_id="tool1", name="bulb", points=_square(30, 40, 10), rotation=0) + levels = placed_levels(tool, placed) + xs = [p.x for p in levels[0].parts[0].points] + ys = [p.y for p in levels[0].parts[0].points] + assert min(xs) == pytest.approx(30) + assert min(ys) == pytest.approx(40) + + +class TestLevelCutters: + def test_volume_matches_sum_of_level_prisms(self): + bp = BinParams(cutout_depth=20) + poly = ScaledPolygon( + "p1", + [(0.0, 0.0), (30.0, 0.0), (30.0, 30.0), (0.0, 30.0)], + "test", + levels=[ + ScaledLevelPart(8, [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]), + ScaledLevelPart(15, [(20.0, 0.0), (25.0, 0.0), (25.0, 5.0), (20.0, 5.0)]), + ], + ) + cutter = _make_polygon_cutouts([poly], bp, wall_top_z=30, max_depth=100, offset_x=0, offset_y=0) + assert cutter is not None + # the footprint (30x30) must NOT be cut -- only the two level prisms + assert cutter.volume() == pytest.approx(100 * 8 + 25 * 15, rel=0.02) + + def test_default_level_uses_placement_override(self): + bp = BinParams(cutout_depth=20) + poly = ScaledPolygon( + "p1", + [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)], + "test", + depth_override=25, + levels=[ScaledLevelPart(None, [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)])], + ) + cutter = _make_polygon_cutouts([poly], bp, wall_top_z=30, max_depth=100, offset_x=0, offset_y=0) + assert cutter.volume() == pytest.approx(100 * 25, rel=0.02) + + +class TestScalerCarriesLevels: + def _poly(self): + return ScaledPolygon( + "p1", + [(0.0, 0.0), (20.0, 0.0), (20.0, 20.0), (0.0, 20.0)], + "test", + levels=[ScaledLevelPart(10, [(5.0, 5.0), (15.0, 5.0), (15.0, 15.0), (5.0, 15.0)])], + ) + + def test_add_clearance_buffers_each_level(self): + scaler = PolygonScaler() + out = scaler.add_clearance(self._poly(), 1.0) + xs = [p[0] for p in out.levels[0].points_mm] + assert max(xs) - min(xs) == pytest.approx(12.0) + assert out.levels[0].depth == 10 + + def test_simplify_keeps_levels(self): + scaler = PolygonScaler() + out = scaler.simplify(self._poly(), tolerance_mm=0.05) + assert out.levels is not None + assert out.levels[0].depth == 10 + + def test_zero_clearance_passthrough(self): + scaler = PolygonScaler() + out = scaler.add_clearance(self._poly(), 0.0) + assert out.levels is not None + + +class TestLevelsRoundTrip: + def test_tool_with_levels_survives_dump_and_validate(self): + tool = Tool( + id="t", + name="bulb", + points=_square(0, 0, 10), + levels=[ToolLevel(depth=12, parts=[ToolLevelPart(points=_square(0, 0, 10))])], + ) + loaded = Tool.model_validate(tool.model_dump()) + assert loaded.levels[0].depth == 12 + assert len(loaded.levels[0].parts) == 1 + + def test_missing_levels_key_loads_as_none(self): + data = Tool(id="t", name="flat", points=_square(0, 0, 10)).model_dump() + del data["levels"] + assert Tool.model_validate(data).levels is None + + def test_bin_with_legacy_placed_tools_loads(self): + bin_data = BinModel( + id="b1", + bin_config=BinConfig(), + placed_tools=[PlacedTool(id="p1", tool_id="t", name="flat", points=_square(0, 0, 10))], + ) + loaded = BinModel.model_validate(bin_data.model_dump()) + assert loaded.placed_tools[0].points == bin_data.placed_tools[0].points From 0efbadec5be0c963b1b11c1c90a9c226567d6183 Mon Sep 17 00:00:00 2001 From: SkyeRangerDelta Date: Fri, 12 Jun 2026 16:01:29 -0400 Subject: [PATCH 4/6] feat: Per-shape depth field and depth visualization in the designer Add-shapes gain a nullable Depth input (placeholder = bin default) and a depth badge in the shape list; depth is cleared when a shape stops being a solid. The canvas overlays each depth shape with a darkness proportional to its depth (masked by the boolean preview so holes stay unpainted) plus an Nmm label. Co-Authored-By: Claude Fable 5 --- .../src/components/ShapeDesignerCanvas.tsx | 40 +++++++++++++++++-- frontend/src/components/ShapeListPanel.tsx | 26 +++++++++++- frontend/src/types/index.ts | 2 + 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/ShapeDesignerCanvas.tsx b/frontend/src/components/ShapeDesignerCanvas.tsx index a546cc6..ed49a99 100644 --- a/frontend/src/components/ShapeDesignerCanvas.tsx +++ b/frontend/src/components/ShapeDesignerCanvas.tsx @@ -74,16 +74,16 @@ function ShapeElement({ shape, zoom, selected }: { shape: ToolShape; zoom: numbe ) } -function MaskShape({ shape }: { shape: ToolShape }) { - const fill = shape.mode === 'add' ? 'white' : 'black' +function MaskShape({ shape, fill }: { shape: ToolShape; fill?: string }) { + const f = fill ?? (shape.mode === 'add' ? 'white' : 'black') const transform = `translate(${shape.x * S},${shape.y * S})${shape.rotation ? ` rotate(${shape.rotation})` : ''}` if (shape.type === 'rectangle') { const w = (shape.width ?? 0) * S const h = (shape.height ?? 0) * S const r = (shape.corner_radius ?? 0) * S - return + return } - return + return } export function ShapeDesignerCanvas({ @@ -152,6 +152,38 @@ export function ShapeDesignerCanvas({ className="pointer-events-none" /> + {/* per-shape depth: darker = deeper, masked so holes stay unpainted */} + {(() => { + const depthShapes = shapes.filter((sh) => sh.mode === 'add' && sh.depth != null) + if (depthShapes.length === 0) return null + const maxD = Math.max(30, ...depthShapes.map((sh) => sh.depth!)) + return ( + + + {depthShapes.map((sh) => ( + + ))} + + {depthShapes.map((sh) => ( + + {sh.depth}mm + + ))} + + ) + })()} + {/* authoritative outline from the last server materialization */} {outlinePoints.length >= 3 && ( { if (s.type === 'line') return // lines are always guides const order: ToolShapeMode[] = ['add', 'subtract', 'guide'] - update(s.id, { mode: order[(order.indexOf(s.mode) + 1) % order.length] }) + const next = order[(order.indexOf(s.mode) + 1) % order.length] + // depth only means something on solids + update(s.id, { mode: next, ...(next !== 'add' ? { depth: null } : {}) }) } return ( @@ -120,7 +122,12 @@ export function ShapeListPanel({ ) : ( )} - {shapeDisplayName(s)} + + {shapeDisplayName(s)} + {s.mode === 'add' && s.depth != null && ( + · {s.depth}mm + )} + + {selectedToolCount > 1 && ( + <> +
+ + {selectedToolCount} tools + + + + )} + {selectedTool && ( <>