Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions backend/app/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
BinProjectToolsRequest,
BinProjectCreateBinRequest,
BinProjectBinsRequest,
BinDefaults,
ProjectHealthResponse,
PlacedTool,
BinModel,
Expand Down Expand Up @@ -350,7 +351,7 @@ def _build_bin_from_tools(
project_id: str | None,
tool_ids: list[str],
user_tools: ToolStore,
default_config: BinConfig | None = None,
default_config: BinDefaults | None = None,
) -> BinModel:
placed: list[PlacedTool] = []
all_points_mm: list[tuple[float, float]] = []
Expand All @@ -370,7 +371,7 @@ def _build_bin_from_tools(
interior_rings=list(tool.interior_rings),
))

bc = BinConfig.model_validate(default_config.model_dump()) if default_config else BinConfig()
bc = BinConfig(**default_config.model_dump(exclude={"text_labels"}), text_labels=[]) if default_config else BinConfig()
if all_points_mm:
all_xs = [p[0] for p in all_points_mm]
all_ys = [p[1] for p in all_points_mm]
Expand Down Expand Up @@ -1391,7 +1392,7 @@ async def create_bin_from_project(
project_id=project_id,
tool_ids=tool_ids,
user_tools=user_tools,
default_config=project.default_bin_config,
default_config=req.bin_config or project.default_bin_config,
)
user_bins.set(bin_id, bin_data)
add_bin_to_project(project_store, project_id, bin_id)
Expand Down Expand Up @@ -1448,6 +1449,7 @@ async def create_bin(request: Request, req: CreateBinRequest, user_id: str = Dep
project_id=req.project_id,
tool_ids=req.tool_ids,
user_tools=user_tools,
default_config=req.bin_config,
)
user_bins.set(bin_id, bin_data)
add_bin_to_project(project_store, req.project_id, bin_id)
Expand Down
18 changes: 11 additions & 7 deletions backend/app/models/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,13 @@ def validate_wall(cls, v: float) -> float:
return v


class GenerateRequest(BinParams):
class BinDefaults(BinParams):
bed_size: float = 256.0 # mm, 0 = no splitting


class GenerateRequest(BinDefaults):
polygons: list[Polygon] | None = None # optional: use these instead of session polygons
text_labels: list[TextLabel] = []
bed_size: float = 256.0 # mm, 0 = no splitting


class GenerateResponse(BaseModel):
Expand Down Expand Up @@ -303,7 +306,7 @@ class BinProject(BaseModel):
bin_ids: list[str] = []
target_grid_x: int | None = None
target_grid_y: int | None = None
default_bin_config: BinParams | None = None
default_bin_config: BinDefaults | None = None
notes: str | None = None
created_at: str | None = None
updated_at: str | None = None
Expand Down Expand Up @@ -338,7 +341,7 @@ class BinProjectCreateRequest(BaseModel):
status: ProjectStatus = "active"
target_grid_x: int | None = None
target_grid_y: int | None = None
default_bin_config: BinParams | None = None
default_bin_config: BinDefaults | None = None
notes: str | None = None
tool_ids: list[str] = []

Expand All @@ -349,7 +352,7 @@ class BinProjectUpdateRequest(BaseModel):
status: ProjectStatus | None = None
target_grid_x: int | None = None
target_grid_y: int | None = None
default_bin_config: BinParams | None = None
default_bin_config: BinDefaults | None = None
notes: str | None = None


Expand All @@ -360,6 +363,7 @@ class BinProjectToolsRequest(BaseModel):
class BinProjectCreateBinRequest(BaseModel):
name: str | None = None
tool_ids: list[str] | None = None
bin_config: BinDefaults | None = None


class BinProjectBinsRequest(BaseModel):
Expand Down Expand Up @@ -397,9 +401,8 @@ class PlacedTool(BaseModel):
depth_override: float | None = None # mm; None = use bin_config.cutout_depth


class BinConfig(BinParams):
class BinConfig(BinDefaults):
text_labels: list[TextLabel] = []
bed_size: float = 256.0


class BinModel(BaseModel):
Expand Down Expand Up @@ -446,3 +449,4 @@ class CreateBinRequest(BaseModel):
name: str | None = None
project_id: str | None = None
tool_ids: list[str] = [] # pre-place these tools
bin_config: BinDefaults | None = None
91 changes: 89 additions & 2 deletions backend/tests/test_bin_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,21 +66,28 @@ def test_project_requests_support_clearable_fields():
create_req = BinProjectCreateRequest(
name="Top drawer",
tool_ids=["tool-1"],
default_bin_config=BinConfig(grid_x=4, grid_y=3),
default_bin_config=BinConfig(grid_x=4, grid_y=3, magnet_diameter=6.2, bed_size=220),
)
update_req = BinProjectUpdateRequest(description=None, notes=None, target_grid_x=None)
tools_req = BinProjectToolsRequest(tool_ids=["tool-1", "tool-2"])
bins_req = BinProjectBinsRequest(bin_ids=["bin-1"], import_tools=True)
bin_req = BinProjectCreateBinRequest(name="Top drawer bin", tool_ids=["tool-1"])
bin_req = BinProjectCreateBinRequest(
name="Top drawer bin",
tool_ids=["tool-1"],
bin_config=BinConfig(magnet_diameter=6.4, bed_size=210),
)

assert create_req.tool_ids == ["tool-1"]
assert create_req.default_bin_config.grid_x == 4
assert create_req.default_bin_config.magnet_diameter == 6.2
assert create_req.default_bin_config.bed_size == 220
assert "description" in update_req.model_fields_set
assert "notes" in update_req.model_fields_set
assert "target_grid_x" in update_req.model_fields_set
assert tools_req.tool_ids == ["tool-1", "tool-2"]
assert bins_req.import_tools is True
assert bin_req.tool_ids == ["tool-1"]
assert bin_req.bin_config.magnet_diameter == 6.4


def test_project_store_round_trips(tmp_path):
Expand Down Expand Up @@ -192,6 +199,86 @@ def test_project_placed_status_updates_when_bin_contents_change(tmp_path, monkey
assert detail["unplaced_tool_ids"] == ["tool-1"]


def test_project_update_round_trips_default_bin_config(tmp_path, monkeypatch):
client = _api_client(tmp_path, monkeypatch)
project = client.post("/api/bin-projects", json={"name": "Top drawer"}).json()

update_resp = client.patch(f"/api/bin-projects/{project['id']}", json={
"default_bin_config": {
"magnet_diameter": 6.2,
"magnet_depth": 2.8,
"magnet_corners_only": True,
"bed_size": 220,
},
})

assert update_resp.status_code == 200
updated_config = update_resp.json()["default_bin_config"]
assert updated_config["magnet_diameter"] == 6.2
assert updated_config["magnet_depth"] == 2.8
assert updated_config["magnet_corners_only"] is True
assert updated_config["bed_size"] == 220
detail_config = client.get(f"/api/bin-projects/{project['id']}").json()["default_bin_config"]
assert detail_config == updated_config

clear_resp = client.patch(f"/api/bin-projects/{project['id']}", json={"default_bin_config": None})

assert clear_resp.status_code == 200
assert clear_resp.json()["default_bin_config"] is None


def test_create_bin_accepts_default_bin_config(tmp_path, monkeypatch):
client = _api_client(tmp_path, monkeypatch)

resp = client.post("/api/bins", json={
"name": "Magnet test",
"bin_config": {
"magnet_diameter": 6.2,
"magnet_depth": 2.8,
"magnet_corners_only": True,
"bed_size": 220,
},
})

assert resp.status_code == 200
bin_config = resp.json()["bin_config"]
assert bin_config["magnet_diameter"] == 6.2
assert bin_config["magnet_depth"] == 2.8
assert bin_config["magnet_corners_only"] is True
assert bin_config["bed_size"] == 220


def test_project_create_bin_uses_request_config_before_project_default(tmp_path, monkeypatch):
client = _api_client(tmp_path, monkeypatch)
_seed_tool("tool-1")

project = client.post("/api/bin-projects", json={
"name": "Top drawer",
"tool_ids": ["tool-1"],
"default_bin_config": {"magnet_diameter": 6.2, "bed_size": 220},
}).json()

project_default_resp = client.post(
f"/api/bin-projects/{project['id']}/create-bin",
json={"tool_ids": ["tool-1"]},
)
override_resp = client.post(
f"/api/bin-projects/{project['id']}/create-bin",
json={
"name": "Override bin",
"tool_ids": ["tool-1"],
"bin_config": {"magnet_diameter": 6.4, "bed_size": 210},
},
)

assert project_default_resp.status_code == 200
assert project_default_resp.json()["bin_config"]["magnet_diameter"] == 6.2
assert project_default_resp.json()["bin_config"]["bed_size"] == 220
assert override_resp.status_code == 200
assert override_resp.json()["bin_config"]["magnet_diameter"] == 6.4
assert override_resp.json()["bin_config"]["bed_size"] == 210


def test_project_health_reports_and_repairs_safe_link_mismatches(tmp_path):
project_store = ProjectStore(tmp_path)
tool_store = ToolStore(tmp_path)
Expand Down
4 changes: 2 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
## Bins
- `GET /api/bins` - list bins
- `GET /api/bins/{id}` - get bin (syncs placed tools with library versions)
- `POST /api/bins` - create bin (optionally with tool_ids for auto-sizing)
- `POST /api/bins` - create bin (optionally with tool_ids for auto-sizing and bin_config defaults)
- `PUT /api/bins/{id}` - update bin
- `DELETE /api/bins/{id}` - delete bin + output files
- `POST /api/bins/{id}/generate` - generate STL/3MF from bin
Expand All @@ -37,7 +37,7 @@
- `DELETE /api/bin-projects/{id}/tools/{tool_id}` - remove a tool from a project
- `POST /api/bin-projects/{id}/bins` - link existing bins to a project
- `DELETE /api/bin-projects/{id}/bins/{bin_id}` - detach a bin from a project
- `POST /api/bin-projects/{id}/create-bin` - create a new bin from selected project tools
- `POST /api/bin-projects/{id}/create-bin` - create a new bin from selected project tools, using project or request bin defaults
- `GET /api/bin-projects/{id}/health` - report project/tool/bin link mismatches
- `POST /api/bin-projects/{id}/repair` - repair safe project/tool/bin link mismatches

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ tracefinity/
- **Tool**: a single traced polygon + finger holes, stored in mm, centred at origin. Lives in a persistent library (`tools.json`).
- **PlacedTool**: a positioned copy of a tool in a bin. Points/holes in bin-space mm. Has `tool_id` linking back to source.
- **Bin**: bin config + placed tools + text labels. Used for STL generation (`bins.json`).
- **BinProject**: a planning group of tool ids and linked bin ids. Placement status is derived from linked bins (`projects.json`).
- **BinProject**: a planning group of tool ids and linked bin ids. Placement status is derived from linked bins (`projects.json`). Projects can carry default bin settings used when creating project bins.
- **Session**: ephemeral, used only for upload/trace workflow. Output is tools saved to library via `save-tools`.

PlacedTools sync with their library source on bin load (`GET /bins/{id}`) via `bin_service.sync_placed_tools()`. Edits to a tool's points, finger holes, or name propagate to all bins that use it. The position offset is preserved.
Expand Down
73 changes: 49 additions & 24 deletions frontend/src/app/bins/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { BinConfigurator, calcMaxCutoutDepth } from '@/components/BinConfigurato
import { BinPreview3D } from '@/components/BinPreview3D'
import { ToolBrowser } from '@/components/ToolBrowser'
import { getBin, updateBin, generateBinStl, getBinStlUrl, getBinZipUrl, getBinThreemfUrl, getBinInsertUrl, getImageUrl, listTools, updateTool } from '@/lib/api'
import { getSettings } from '@/lib/settings'
import { getDefaultBinConfig, resetDefaultBinConfig, saveDefaultBinConfig } from '@/lib/binDefaults'
import type { BinConfig, BinData, PlacedTool, TextLabel } from '@/types'
import { Download, Loader2, Package, ChevronDown, Check } from 'lucide-react'
import { Breadcrumb } from '@/components/Breadcrumb'
Expand All @@ -24,27 +24,6 @@ function InfoBanner({ children }: { children: React.ReactNode }) {
)
}

function defaultConfig(): BinConfig {
return {
grid_x: 2,
grid_y: 2,
height_units: 4,
magnets: true,
magnet_diameter: 6.0,
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,
insert_enabled: false,
insert_height: 1.0,
text_labels: [],
bed_size: getSettings().bedSize,
}
}

export default function BinPage() {
const router = useRouter()
const params = useParams()
Expand All @@ -54,7 +33,7 @@ export default function BinPage() {
const [binData, setBinData] = useState<BinData | null>(null)
const [placedTools, setPlacedTools] = useState<PlacedTool[]>([])
const [textLabels, setTextLabels] = useState<TextLabel[]>([])
const [config, setConfig] = useState<BinConfig>(defaultConfig)
const [config, setConfig] = useState<BinConfig>(() => getDefaultBinConfig())
const [name, setName] = useState('')
const [stlUrl, setStlUrl] = useState<string | null>(null)
const [stlUrls, setStlUrls] = useState<string[]>([])
Expand All @@ -79,6 +58,8 @@ export default function BinPage() {
const [autoSize, setAutoSize] = useState(true)
const [isDragging, setIsDragging] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
const [defaultsStatus, setDefaultsStatus] = useState<string | null>(null)
const defaultsStatusTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const exportRef = useRef<HTMLDivElement>(null)

useEffect(() => {
Expand Down Expand Up @@ -198,7 +179,10 @@ export default function BinPage() {
)

useEffect(() => {
return () => { abortRef.current?.abort() }
return () => {
abortRef.current?.abort()
if (defaultsStatusTimeoutRef.current) clearTimeout(defaultsStatusTimeoutRef.current)
}
}, [])

useEffect(() => {
Expand Down Expand Up @@ -332,6 +316,25 @@ export default function BinPage() {
window.open(getBinInsertUrl(binId), '_blank')
}

function showDefaultsStatus(message: string) {
if (defaultsStatusTimeoutRef.current) clearTimeout(defaultsStatusTimeoutRef.current)
setDefaultsStatus(message)
defaultsStatusTimeoutRef.current = setTimeout(() => {
setDefaultsStatus(null)
defaultsStatusTimeoutRef.current = null
}, 2500)
}

function handleSaveDefaults() {
saveDefaultBinConfig(config)
showDefaultsStatus('Defaults saved')
}

function handleResetDefaults() {
setConfig(resetDefaultBinConfig())
showDefaultsStatus('Defaults reset')
}

if (loading) {
return (
<div className="flex items-center justify-center py-12 gap-2 text-text-muted">
Expand Down Expand Up @@ -371,6 +374,28 @@ export default function BinPage() {
{saved && <Check className="w-3 h-3 text-green-400 flex-shrink-0" />}
</div>
<BinConfigurator config={config} onChange={setConfig} autoSize={autoSize} onAutoSizeChange={setAutoSize} />
<div className="mt-3 border-t border-border pt-3 space-y-1.5">
<div className="flex gap-1.5">
<button
type="button"
onClick={handleSaveDefaults}
className="btn-secondary flex-1 px-2 py-1.5 text-[11px]"
>
Save as default
</button>
<button
type="button"
onClick={handleResetDefaults}
className="btn-secondary px-2 py-1.5 text-[11px]"
title="Reset this bin and saved defaults"
>
Reset
</button>
</div>
{defaultsStatus && (
<p className="text-[10px] text-text-muted">{defaultsStatus}</p>
)}
</div>
</div>

<div className="glass rounded-[10px] px-3 py-3">
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Trash2, Package, Plus, Loader2, Grid3X3, Folder } from 'lucide-react'
import { Alert } from '@/components/Alert'
import { PhotoIllustration, CornersIllustration, TraceIllustration, OrganiseIllustration } from '@/components/OnboardingIllustrations'
import { GRID_UNIT } from '@/lib/constants'
import { getDefaultBinDefaults } from '@/lib/binDefaults'
import { useDeleteConfirmation } from '@/hooks/useDeleteConfirmation'
import { projectNameMap, projectStatusLabels, toolProjectLabel, toolProjectTitle } from '@/lib/projectSelectors'

Expand Down Expand Up @@ -367,7 +368,7 @@ export default function HomePage() {
if (sourceToolId) setCreatingBin(sourceToolId)
setNameModal(null)
try {
const bin = await createBin({ name, tool_ids: toolIds })
const bin = await createBin({ name, tool_ids: toolIds, bin_config: getDefaultBinDefaults() })
router.push(`/bins/${bin.id}`)
} catch (err) {
setError(err instanceof Error ? err.message : 'failed to create bin')
Expand Down
Loading
Loading