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
12 changes: 10 additions & 2 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
{
"permissions": {
"allow": [
"Bash(npx tsc *)",
"Bash(npx vitest *)",
"Bash(sed -n '1,8p' src/components/BinConfigurator.test.ts)",
"Bash(npm run *)"
]
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"ssh"
],
"enableAllProjectMcpServers": true
]
}
59 changes: 59 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
backend:
name: Backend tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
cache-dependency-path: backend/requirements.txt

- name: Install dependencies
run: |
cd backend
pip install -r requirements.txt
pip install pytest

- name: Run pytest
run: cd backend && pytest

frontend:
name: Frontend lint, typecheck & unit tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Install dependencies
run: cd frontend && npm ci

- name: Typecheck
run: cd frontend && npx tsc --noEmit

- name: Unit tests
run: cd frontend && npm test

# Non-blocking: surfaces existing lint debt without failing the build.
# Flip to a gate once the pre-existing errors are cleared.
- name: Lint
run: cd frontend && npm run lint
continue-on-error: true
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ No API key and prefer not to use the local model? Upload a mask manually:

[Gridfinity](https://gridfinity.xyz/) is a modular storage system designed by [Zack Freedman](https://www.youtube.com/watch?v=ra_9zU-mnl8). Bins snap into baseplates on a 42mm grid, making it easy to organise tools, components, and supplies. The system is open source and hugely popular in the 3D printing community.

## AI Disclosure

Parts of this project were developed with the assistance of AI coding tools (Claude Code). AI was used to help design, implement, and document features such as the parametric shape tools, CAD-style shape designer, and auto-arrange packing. All AI-assisted contributions were reviewed by a human before being merged.

## Licence

MIT
86 changes: 75 additions & 11 deletions backend/app/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@
Polygon,
FingerHole,
Tool,
ToolShape,
ToolSummary,
ToolListResponse,
ToolUpdateRequest,
CreateToolRequest,
SaveToolsRequest,
SaveToolsResponse,
PlacedTool,
Expand All @@ -58,8 +60,9 @@
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
from app.services.bin_service import sync_placed_tools, resolve_clearance
from app.services.image_service import generate_tool_thumbnail
from app.services import shape_compiler
router = APIRouter()

# register heif/heic support with pillow
Expand Down Expand Up @@ -748,6 +751,7 @@ async def list_tools(request: Request, user_id: str = Depends(get_user_id)):
smoothed=tool.smoothed,
smooth_level=tool.smooth_level,
thumbnail_url=thumb_url,
parametric=tool.shapes is not None,
))
summaries.sort(key=lambda t: t.created_at or "", reverse=True)
return ToolListResponse(tools=summaries)
Expand All @@ -762,27 +766,78 @@ async def get_tool(request: Request, tool_id: str, user_id: str = Depends(get_us
return tool


@router.put("/tools/{tool_id}", response_model=StatusResponse)
@router.post("/tools", response_model=Tool)
async def create_tool(request: Request, req: CreateToolRequest, user_id: str = Depends(get_user_id)):
"""create a parametric (designer) tool from shape primitives"""
_, user_tools, _ = get_stores(user_id)

shapes = req.shapes or [
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)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))

tool_id = str(uuid.uuid4())
tool = Tool(
id=tool_id,
name=req.name,
points=points,
interior_rings=interior_rings,
shapes=shape_compiler.recentre_shapes(shapes, offset),
created_at=datetime.utcnow().isoformat(),
)
user_tools.set(tool_id, tool)
return tool


@router.put("/tools/{tool_id}", response_model=Tool)
async def update_tool(request: Request, tool_id: str, req: ToolUpdateRequest, user_id: str = Depends(get_user_id)):
_, user_tools, _ = get_stores(user_id)
tool = user_tools.get(tool_id)
if not tool:
raise HTTPException(status_code=404, detail="tool not found")

provided = req.model_fields_set

if "shapes" in provided:
if req.shapes is not None:
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)
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
else:
# explicit null detaches to a plain polygon, keeping materialized points
tool.shapes = 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,
detail="this tool is shape-based; edit its shapes or convert it to a polygon first",
)

if req.name is not None:
tool.name = req.name
if req.points is not None:
tool.points = req.points
if tool.shapes is None:
if req.points is not None:
tool.points = req.points
if req.interior_rings is not None:
tool.interior_rings = req.interior_rings
if req.finger_holes is not None:
tool.finger_holes = req.finger_holes
if req.interior_rings is not None:
tool.interior_rings = req.interior_rings
if req.smoothed is not None:
tool.smoothed = req.smoothed
if req.smooth_level is not None:
tool.smooth_level = req.smooth_level
if "clearance_override" in provided:
tool.clearance_override = req.clearance_override
user_tools.set(tool_id, tool)
return StatusResponse(status="ok")
return tool


@router.delete("/tools/{tool_id}", response_model=StatusResponse)
Expand Down Expand Up @@ -810,7 +865,10 @@ async def download_tool_svg(request: Request, tool_id: str, user_id: str = Depen
) for fh in tool.finger_holes]
sp = ScaledPolygon(tool.id, points_mm, tool.name, fholes, interior_rings_mm)

if tool.smoothed:
if tool.shapes:
# parametric outlines are exact; only strip collinear points
sp = polygon_scaler.simplify(sp, tolerance_mm=0.05)
elif tool.smoothed:
sp = polygon_scaler.smooth(sp, level=tool.smooth_level)
else:
sp = polygon_scaler.simplify(sp)
Expand Down Expand Up @@ -1050,13 +1108,16 @@ def generate_bin_stl(request: Request, bin_id: str, user_id: str = Depends(get_u

bc = bin_data.bin_config

# include source tool smoothed state in hash so toggling invalidates cache
# include source tool smoothed/parametric/clearance state in hash so
# toggling any of them invalidates the cache
smoothed_flags = {}
for pt in bin_data.placed_tools:
src = user_tools.get(pt.tool_id)
smoothed_flags[pt.tool_id] = {
"smoothed": src.smoothed if src else False,
"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,
}
input_data = {
"bin_config": bc.model_dump(),
Expand All @@ -1083,9 +1144,12 @@ def generate_bin_stl(request: Request, bin_id: str, user_id: str = Depends(get_u
for ring in pt.interior_rings
]
sp = ScaledPolygon(pt.id, points_mm, pt.name, fholes, interior_rings_mm, depth_override=pt.depth_override)
sp = polygon_scaler.add_clearance(sp, bc.cutout_clearance)
source_tool = user_tools.get(pt.tool_id)
if source_tool and source_tool.smoothed:
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
sp = polygon_scaler.simplify(sp, tolerance_mm=0.05)
elif source_tool and source_tool.smoothed:
sp = polygon_scaler.smooth(sp, level=source_tool.smooth_level)
else:
sp = polygon_scaler.simplify(sp)
Expand Down
28 changes: 28 additions & 0 deletions backend/app/models/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,22 @@ class StatusResponse(BaseModel):

# --- tool library ---

class ToolShape(BaseModel):
"""parametric 2D primitive for designer-made tools. all dimensions in mm,
positions in tool space (origin = tool centre)."""
id: str
type: Literal["rectangle", "ellipse", "line"] = "rectangle" # "line" only valid as a guide
mode: Literal["add", "subtract", "guide"] = "add" # guide = construction geometry, never part of the outline
x: float = 0.0 # shape centre
y: float = 0.0
rotation: float = 0.0 # degrees
width: float | None = None # rectangle; line length when type="line"
height: float | None = None # rectangle
corner_radius: float = 0.0 # rectangle
rx: float | None = None # ellipse semi-axes (circle when rx == ry)
ry: float | None = None


class Tool(BaseModel):
id: str
name: str
Expand All @@ -225,6 +241,9 @@ class Tool(BaseModel):
source_session_id: str | None = None
thumbnail_path: str | None = None
created_at: str | None = None
# 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


class ToolSummary(BaseModel):
Expand All @@ -237,6 +256,7 @@ class ToolSummary(BaseModel):
smoothed: bool = False
smooth_level: float = 0.5
thumbnail_url: str | None = None
parametric: bool = False


class ToolUpdateRequest(BaseModel):
Expand All @@ -246,6 +266,14 @@ class ToolUpdateRequest(BaseModel):
interior_rings: list[list[Point]] | None = None
smoothed: bool | None = None
smooth_level: float | None = None
# explicit null detaches a parametric tool to a plain polygon
shapes: list[ToolShape] | None = None
clearance_override: float | None = None


class CreateToolRequest(BaseModel):
name: str = "New shape"
shapes: list[ToolShape] | None = None # None => default 40x40 rectangle


class ToolListResponse(BaseModel):
Expand Down
7 changes: 7 additions & 0 deletions backend/app/services/bin_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
from app.models.schemas import Point, FingerHole


def resolve_clearance(source_tool, bin_clearance: float) -> float:
"""per-tool clearance override wins over the bin's global cutout_clearance"""
if source_tool is not None and source_tool.clearance_override is not None:
return source_tool.clearance_override
return bin_clearance


def sync_placed_tools(bin_data, user_tools) -> bool:
"""sync placed tools with their library versions. returns True if any changed."""
changed = False
Expand Down
Loading
Loading