diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 27edd15..093d660 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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 + ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bca7a63 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 6e301cc..dcc61bb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index a933260..fb869f0 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -37,9 +37,11 @@ Polygon, FingerHole, Tool, + ToolShape, ToolSummary, ToolListResponse, ToolUpdateRequest, + CreateToolRequest, SaveToolsRequest, SaveToolsResponse, PlacedTool, @@ -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 @@ -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) @@ -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) @@ -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) @@ -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(), @@ -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) diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index 695289b..878eaa9 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -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 @@ -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): @@ -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): @@ -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): diff --git a/backend/app/services/bin_service.py b/backend/app/services/bin_service.py index 9022d1a..48309d4 100644 --- a/backend/app/services/bin_service.py +++ b/backend/app/services/bin_service.py @@ -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 diff --git a/backend/app/services/shape_compiler.py b/backend/app/services/shape_compiler.py new file mode 100644 index 0000000..e372582 --- /dev/null +++ b/backend/app/services/shape_compiler.py @@ -0,0 +1,115 @@ +"""Materialize parametric tool shapes into a polygon outline. + +Designer-made tools store a list of ToolShape primitives; everything +downstream (bin editor, sync, STL generation) consumes the materialized +points/interior_rings, so this is the only place boolean geometry happens. +""" + +import math + +from shapely import affinity +from shapely.geometry import Point as ShapelyPoint, Polygon as ShapelyPolygon, box +from shapely.ops import unary_union +from shapely.validation import make_valid + +from app.models.schemas import Point, 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. +CHORD_ERROR_MM = 0.05 + + +def _segments(radius: float) -> int: + """segment count so the sagitta r*(1-cos(pi/n)) stays under CHORD_ERROR_MM""" + if radius <= CHORD_ERROR_MM: + return 32 + n = math.pi / math.acos(max(-1.0, 1.0 - CHORD_ERROR_MM / radius)) + return min(256, max(32, math.ceil(n))) + + +def _shape_geometry(shape: ToolShape): + """build the shapely polygon for one primitive, positioned in tool space""" + if shape.type == "rectangle": + w = shape.width or 0.0 + h = shape.height or 0.0 + if w <= 0 or h <= 0: + raise ValueError(f"rectangle {shape.id} needs positive width and height") + r = min(shape.corner_radius, w / 2, h / 2) + if r > 0: + inner = box(-w / 2 + r, -h / 2 + r, w / 2 - r, h / 2 - r) + geom = inner.buffer(r, quad_segs=max(8, _segments(r) // 4)) + else: + geom = box(-w / 2, -h / 2, w / 2, h / 2) + elif shape.type == "ellipse": + rx = shape.rx or 0.0 + ry = shape.ry or 0.0 + if rx <= 0 or ry <= 0: + raise ValueError(f"ellipse {shape.id} needs positive radii") + circle = ShapelyPoint(0, 0).buffer(1.0, quad_segs=max(8, _segments(max(rx, ry)) // 4)) + geom = affinity.scale(circle, rx, ry, origin=(0, 0)) + else: + raise ValueError(f"shape type {shape.type} cannot form an outline") + + if shape.rotation: + geom = affinity.rotate(geom, shape.rotation, origin=(0, 0)) + return affinity.translate(geom, shape.x, shape.y) + + +def _ring_points(coords) -> list[Point]: + return [Point(x=c[0], y=c[1]) for c in list(coords)[:-1]] + + +def compile_shapes( + shapes: list[ToolShape], +) -> tuple[list[Point], list[list[Point]], tuple[float, float]]: + """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. + + Raises ValueError with a user-facing message on invalid input. + """ + for s in 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"] + 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") + + result = unary_union(adds) + if subs: + result = result.difference(unary_union(subs)) + if not result.is_valid: + result = make_valid(result) + + if result.is_empty or result.area < 1e-6: + raise ValueError("shapes produce an empty outline") + if result.geom_type == "MultiPolygon": + parts = len(result.geoms) + raise ValueError( + f"shapes must form a single connected outline ({parts} disconnected pieces)" + ) + if result.geom_type != "Polygon": + raise ValueError("shapes produce an invalid outline") + + minx, miny, maxx, maxy = result.bounds + cx = (minx + maxx) / 2 + cy = (miny + maxy) / 2 + result = affinity.translate(result, -cx, -cy) + + points = _ring_points(result.exterior.coords) + interior_rings = [_ring_points(interior.coords) for interior in result.interiors] + return points, interior_rings, (cx, cy) + + +def recentre_shapes(shapes: list[ToolShape], offset: tuple[float, float]) -> list[ToolShape]: + """shift stored shape positions by the recentring offset compile applied""" + cx, cy = offset + if cx == 0 and cy == 0: + return shapes + return [s.model_copy(update={"x": s.x - cx, "y": s.y - cy}) for s in shapes] diff --git a/backend/tests/test_clearance_override.py b/backend/tests/test_clearance_override.py new file mode 100644 index 0000000..5008915 --- /dev/null +++ b/backend/tests/test_clearance_override.py @@ -0,0 +1,44 @@ +"""Tests for per-tool clearance override resolution and application.""" +import pytest + +from app.models.schemas import Tool, Point +from app.services.bin_service import resolve_clearance +from app.services.polygon_scaler import PolygonScaler, ScaledPolygon + + +def make_tool(clearance_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)], + clearance_override=clearance_override, + ) + + +class TestResolveClearance: + def test_no_tool_uses_bin_default(self): + assert resolve_clearance(None, 1.0) == 1.0 + + def test_no_override_uses_bin_default(self): + assert resolve_clearance(make_tool(), 1.0) == 1.0 + + def test_override_takes_precedence(self): + assert resolve_clearance(make_tool(clearance_override=0.25), 1.0) == 0.25 + + def test_zero_override_means_exact_fit(self): + assert resolve_clearance(make_tool(clearance_override=0.0), 1.0) == 0.0 + + +class TestClearanceApplication: + def test_cutout_width_reflects_clearance(self): + scaler = PolygonScaler() + square = [(0.0, 0.0), (33.0, 0.0), (33.0, 33.0), (0.0, 33.0)] + sp = ScaledPolygon("p1", square, "square") + + expanded = scaler.add_clearance(sp, 0.25) + xs = [p[0] for p in expanded.points_mm] + assert max(xs) - min(xs) == pytest.approx(33.5) + + unchanged = scaler.add_clearance(sp, 0.0) + xs = [p[0] for p in unchanged.points_mm] + assert max(xs) - min(xs) == pytest.approx(33.0) diff --git a/backend/tests/test_shape_compiler.py b/backend/tests/test_shape_compiler.py new file mode 100644 index 0000000..c09c967 --- /dev/null +++ b/backend/tests/test_shape_compiler.py @@ -0,0 +1,133 @@ +"""Tests for parametric shape materialization in shape_compiler.""" +import math + +import pytest + +from app.models.schemas import ToolShape +from app.services.shape_compiler import compile_shapes, recentre_shapes + + +def rect(id="r1", mode="add", x=0.0, y=0.0, w=40.0, h=40.0, rotation=0.0, corner_radius=0.0): + return ToolShape( + id=id, type="rectangle", mode=mode, x=x, y=y, + width=w, height=h, rotation=rotation, corner_radius=corner_radius, + ) + + +def circle(id="c1", mode="add", x=0.0, y=0.0, r=16.5): + return ToolShape(id=id, type="ellipse", mode=mode, x=x, y=y, rx=r, ry=r) + + +def bbox(points): + xs = [p.x for p in points] + ys = [p.y for p in points] + return min(xs), min(ys), max(xs), max(ys) + + +class TestPrimitives: + def test_circle_resolution(self): + # a 33mm-diameter circle must stay genuinely round + points, rings, _ = compile_shapes([circle(r=16.5)]) + assert len(points) >= 40 + assert rings == [] + # every vertex on the radius within chord tolerance + for p in points: + 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)]) + 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)]) + 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)]) + 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) + assert offset == pytest.approx((100, -50)) + + +class TestBooleans: + def test_overlapping_adds_union_to_single_ring(self): + points, rings, _ = compile_shapes([ + rect(id="a", w=40, h=40), + rect(id="b", x=30, w=40, h=40), + ]) + minx, _, maxx, _ = bbox(points) + assert (maxx - minx) == pytest.approx(70) + assert rings == [] + + def test_subtract_inside_makes_interior_ring(self): + points, rings, _ = compile_shapes([ + rect(w=80, h=30), + circle(mode="subtract", r=5), + ]) + assert len(rings) == 1 + assert len(rings[0]) >= 20 + + def test_subtract_splitting_outline_rejected(self): + with pytest.raises(ValueError, match="single connected outline"): + compile_shapes([ + rect(w=80, h=30), + rect(id="cut", mode="subtract", w=10, h=40), + ]) + + def test_subtract_everything_rejected(self): + with pytest.raises(ValueError, match="empty outline"): + compile_shapes([ + rect(w=20, h=20), + rect(id="cut", mode="subtract", w=40, h=40), + ]) + + def test_disjoint_adds_rejected(self): + with pytest.raises(ValueError, match="single connected outline"): + compile_shapes([rect(id="a", w=10, h=10), rect(id="b", x=100, w=10, h=10)]) + + def test_no_additive_shapes_rejected(self): + with pytest.raises(ValueError, match="additive"): + compile_shapes([circle(mode="subtract")]) + + +class TestGuides: + def test_guides_excluded_from_outline(self): + points, rings, _ = compile_shapes([ + rect(w=40, h=40), + circle(id="g", mode="guide", x=20, y=20, r=30), + ]) + minx, miny, maxx, maxy = bbox(points) + assert (maxx - minx) == pytest.approx(40) + assert (maxy - miny) == pytest.approx(40) + assert rings == [] + + def test_guide_line_allowed(self): + line = ToolShape(id="l", type="line", mode="guide", width=100) + points, _, _ = compile_shapes([rect(), line]) + assert len(points) >= 4 + + def test_solid_line_rejected(self): + line = ToolShape(id="l", type="line", mode="add", width=100) + with pytest.raises(ValueError, match="guides"): + compile_shapes([rect(), line]) + + +class TestRecentre: + def test_shapes_shifted_by_offset(self): + shapes = [rect(x=100, y=-50)] + _, _, 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) + shifted = recentre_shapes(shapes, offset) + points2, _, offset2 = compile_shapes(shifted) + assert offset2 == pytest.approx((0, 0), abs=1e-9) + assert len(points1) == len(points2) diff --git a/docs/api.md b/docs/api.md index 24d6b6d..732b784 100644 --- a/docs/api.md +++ b/docs/api.md @@ -13,11 +13,20 @@ - `DELETE /api/sessions/{id}` - delete session ## Tools (library) -- `GET /api/tools` - list tools +- `GET /api/tools` - list tools (`parametric` flag marks shape-designed tools) - `GET /api/tools/{id}` - get tool -- `PUT /api/tools/{id}` - update tool (name, points, finger_holes) +- `POST /api/tools` - create a parametric tool from shape primitives (defaults to a 40x40 rect) +- `PUT /api/tools/{id}` - update tool, returns the full Tool. For parametric tools, send `shapes` + (compiled server-side into points/interior_rings; 422 if the result isn't a single connected + outline) -- direct `points` edits are rejected until `shapes: null` detaches it to a plain + polygon. `clearance_override` (mm) beats the bin's `cutout_clearance` during generation. - `DELETE /api/tools/{id}` - delete tool +Shape primitives (`ToolShape`): `rectangle` (width/height/corner_radius), `ellipse` (rx/ry), +`line` (guide only); `mode` is `add` | `subtract` (island) | `guide` (construction, excluded +from the outline). All dimensions mm, positions in tool space, materialization recentres the +result on the bounding-box midpoint. See `backend/app/services/shape_compiler.py`. + ## Bins - `GET /api/bins` - list bins - `GET /api/bins/{id}` - get bin (syncs placed tools with library versions) diff --git a/docs/architecture.md b/docs/architecture.md index cc5e29f..7c70439 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,6 +32,7 @@ tracefinity/ │ │ ├── ai_tracer.py # Gemini mask + contour tracing │ │ ├── image_processor.py # paper detection + perspective │ │ ├── polygon_scaler.py # px-to-mm, clearance, smoothing +│ │ ├── shape_compiler.py # parametric shapes -> outline (shapely booleans) │ │ ├── stl_generator_manifold.py # gridfinity STL + bin splitting │ │ ├── bin_service.py # placed-tool sync logic │ │ ├── image_service.py # tool thumbnail generation @@ -55,6 +56,11 @@ tracefinity/ │ │ │ ├── ToolEditor.tsx # tool editor orchestrator │ │ │ ├── ToolEditorToolbar.tsx # tool toolbar (mode, smooth, undo) │ │ │ ├── ToolEditorCanvas.tsx # tool SVG canvas +│ │ │ ├── ShapeDesigner.tsx # parametric designer (tools with `shapes`) +│ │ │ ├── ShapeDesignerCanvas.tsx # designer SVG canvas, mask boolean preview +│ │ │ ├── ShapeListPanel.tsx # shape rows with exact-mm inputs +│ │ │ ├── MeasurementOverlay.tsx # edge length / corner angle labels +│ │ │ ├── NumberField.tsx # commit-on-blur numeric input │ │ │ ├── ToolBrowser.tsx # sidebar tool picker for bins │ │ │ ├── PolygonEditor.tsx # trace-time polygon editor │ │ │ ├── CutoutOverlay.tsx # finger hole SVG rendering @@ -65,7 +71,10 @@ tracefinity/ │ │ └── lib/ │ │ ├── api.ts # API client │ │ ├── constants.ts # shared constants -│ │ └── svg.ts # polygon path, smoothing, snap +│ │ ├── shapes.ts # shape geometry (salient points, bounds, projection) +│ │ ├── shapeSnap.ts # designer snapping engine +│ │ ├── packing.ts # auto-arrange shelf packing for placed tools +│ │ └── svg.ts # polygon path, smoothing, snap, measurements │ └── package.json ├── .github/workflows/ │ ├── docker-dev.yml # build on push to main diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000..809ee63 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,16 @@ +import next from 'eslint-config-next' + +const config = [ + { ignores: ['.next/**', 'node_modules/**', 'next-env.d.ts'] }, + ...next, + { + rules: { + // Tracefinity renders dynamic, cache-busted, and canvas-derived image URLs + // (photos, masks, blob URLs) where next/image's optimizer is not applicable + // and would break cache-busting / forced remounts. is intentional here. + '@next/next/no-img-element': 'off', + }, + }, +] + +export default config diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index c4b7818..9edff1c 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/package.json b/frontend/package.json index c4690e1..d834441 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "dev": "next dev -p 4001", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "eslint .", + "test": "vitest run" }, "dependencies": { "@react-three/drei": "^10.7.7", diff --git a/frontend/src/app/bins/[id]/page.tsx b/frontend/src/app/bins/[id]/page.tsx index ce74f9c..e5dbd56 100644 --- a/frontend/src/app/bins/[id]/page.tsx +++ b/frontend/src/app/bins/[id]/page.tsx @@ -9,7 +9,8 @@ import { ToolBrowser } from '@/components/ToolBrowser' import { getBin, updateBin, generateBinStl, getBinStlUrl, getBinZipUrl, getBinThreemfUrl, getBinInsertUrl, getImageUrl, listTools, updateTool } from '@/lib/api' import { getSettings, saveSettings } from '@/lib/settings' import type { BinConfig, BinData, PlacedTool, TextLabel } from '@/types' -import { Download, Loader2, Package, ChevronDown, Check } from 'lucide-react' +import { Download, Loader2, Package, ChevronDown, Check, LayoutGrid, RotateCw, Sparkles } from 'lucide-react' +import { arrangeTools } from '@/lib/packing' import { Breadcrumb } from '@/components/Breadcrumb' import { Alert } from '@/components/Alert' import { useDebouncedSave } from '@/hooks/useDebouncedSave' @@ -90,10 +91,26 @@ export default function BinPage() { const [isDragging, setIsDragging] = useState(false) const [exportOpen, setExportOpen] = useState(false) const [snapMode, setSnapModeState] = useState('fixed-5') + const [autoArrange, setAutoArrangeState] = useState(false) + const [arrangeRotation, setArrangeRotationState] = useState(true) + const [layoutWarning, setLayoutWarning] = useState(null) const exportRef = useRef(null) useEffect(() => { - setSnapModeState(getSettings().snapMode) + const s = getSettings() + setSnapModeState(s.snapMode) + setAutoArrangeState(s.autoArrange) + setArrangeRotationState(s.arrangeRotation) + }, []) + + const handleAutoArrangeChange = useCallback((v: boolean) => { + setAutoArrangeState(v) + saveSettings({ autoArrange: v }) + }, []) + + const handleArrangeRotationChange = useCallback((v: boolean) => { + setArrangeRotationState(v) + saveSettings({ arrangeRotation: v }) }, []) const handleSnapModeChange = useCallback((m: SnapMode) => { @@ -300,7 +317,23 @@ export default function BinPage() { }, 300) }, []) + // pack all placed tools into the smallest grid footprint + const runArrange = useCallback((tools: PlacedTool[]) => { + const result = arrangeTools(tools, config, arrangeRotation) + if (!result) return false + setPlacedTools(result.tools) + setConfig(prev => (prev.grid_x === result.gridX && prev.grid_y === result.gridY + ? prev + : { ...prev, grid_x: result.gridX, grid_y: result.gridY })) + setLayoutWarning(result.unplacedIds.length > 0 + ? `${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]) + const handleAddTool = useCallback((tool: PlacedTool) => { + if (autoArrange && runArrange([...placedTools, tool])) return + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity for (const p of tool.points) { minX = Math.min(minX, p.x) @@ -338,7 +371,7 @@ export default function BinPage() { } setPlacedTools(prev => [...prev, placed]) - }, [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, config.grid_x, config.grid_y, config.grid_unit_x_mm, config.grid_unit_y_mm, config.wall_thickness, config.cutout_clearance]) function handleDownload() { window.open(getBinStlUrl(binId), '_blank') @@ -420,6 +453,9 @@ export default function BinPage() { {warning && ( {warning} )} + {layoutWarning && ( + {layoutWarning} + )} {splitCount > 1 && ( Split into {splitCount} pieces )} @@ -487,6 +523,39 @@ export default function BinPage() { binWidthMm={binW} binHeightMm={binH} layout="horizontal" + headerExtra={ +
+ + + +
+ } /> diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 9dcc0aa..29dfd4c 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -4,10 +4,10 @@ import { useState, useEffect, useRef, useMemo } from 'react' import { useRouter } from 'next/navigation' import { ImageUploader } from '@/components/ImageUploader' import { ConfirmModal } from '@/components/ConfirmModal' -import { uploadImage, listTools, listBins, deleteTool, deleteBin, createBin, getImageUrl } from '@/lib/api' +import { uploadImage, listTools, listBins, deleteTool, deleteBin, createBin, createTool, getImageUrl } from '@/lib/api' import type { ToolSummary, BinSummary, BinPreviewTool, Point } from '@/types' import { polygonPathData } from '@/lib/svg' -import { Trash2, Package, Plus, Loader2, Grid3X3, Search, ArrowUpDown } from 'lucide-react' +import { Trash2, Package, Plus, Loader2, Grid3X3, Search, ArrowUpDown, Shapes } from 'lucide-react' import { Alert } from '@/components/Alert' import { PhotoIllustration, CornersIllustration, TraceIllustration, OrganiseIllustration } from '@/components/OnboardingIllustrations' import { GRID_UNIT } from '@/lib/constants' @@ -90,11 +90,18 @@ function NameModal({ open, onConfirm, onCancel }: { const [value, setValue] = useState('') const inputRef = useRef(null) + // reset the field each time the modal opens -- adjust during render rather + // than in an effect to avoid a cascading re-render + const [wasOpen, setWasOpen] = useState(open) + if (open !== wasOpen) { + setWasOpen(open) + if (open) setValue('') + } + useEffect(() => { - if (open) { - setValue('') - setTimeout(() => inputRef.current?.focus(), 50) - } + if (!open) return + const t = setTimeout(() => inputRef.current?.focus(), 50) + return () => clearTimeout(t) }, [open]) useEffect(() => { @@ -218,6 +225,7 @@ export default function HomePage() { const [nameModal, setNameModal] = useState<{ toolIds?: string[] } | null>(null) const [toolSearch, setToolSearch] = useState('') const [toolSort, setToolSort] = useState('date') + const [creatingShape, setCreatingShape] = useState(false) const hasData = toolsList.length > 0 || binsList.length > 0 @@ -278,6 +286,19 @@ export default function HomePage() { setDeleteModal(null) } + async function handleNewShape() { + setCreatingShape(true) + setError(null) + try { + const tool = await createTool() + router.push(`/tools/${tool.id}`) + } catch (err) { + setError(err instanceof Error ? err.message : 'failed to create shape tool') + } finally { + setCreatingShape(false) + } + } + async function handleCreateBin(name: string, toolIds?: string[]) { const sourceToolId = toolIds?.[0] if (sourceToolId) setCreatingBin(sourceToolId) @@ -328,7 +349,17 @@ export default function HomePage() { title="Tools" count={toolsList.length} search={toolSearch} onSearchChange={setToolSearch} sortKey={toolSort} onSortChange={setToolSort} - /> + > + +
{filteredTools.map(tool => (
(null) const [name, setName] = useState('') + const [materializeError, setMaterializeError] = useState(null) + useEffect(() => { async function load() { try { @@ -37,10 +39,50 @@ export default function ToolPage() { const { saving, saved } = useDebouncedSave( async () => { if (!tool) return - await updateTool(toolId, { name, points: tool.points, finger_holes: tool.finger_holes, interior_rings: tool.interior_rings, smoothed: tool.smoothed, smooth_level: tool.smooth_level }) + if (tool.shapes != null) { + const sent = tool.shapes + try { + const ret = await updateTool(toolId, { + name, + shapes: sent, + clearance_override: tool.clearance_override ?? null, + }) + setMaterializeError(null) + // apply the authoritative materialized outline; only adopt the + // recentred shapes when no newer local edit is pending + setTool((prev) => { + if (!prev || prev.shapes == null) return prev + const sameShapes = JSON.stringify(prev.shapes) === JSON.stringify(sent) + const nextShapes = sameShapes ? (ret.shapes ?? prev.shapes) : prev.shapes + if ( + JSON.stringify(prev.points) === JSON.stringify(ret.points) && + JSON.stringify(prev.interior_rings) === JSON.stringify(ret.interior_rings) && + JSON.stringify(prev.shapes) === JSON.stringify(nextShapes) + ) { + return prev + } + return { ...prev, points: ret.points, interior_rings: ret.interior_rings, shapes: nextShapes } + }) + } catch (err) { + if (err instanceof ApiError && err.status === 422) { + setMaterializeError(err.message) + return + } + throw err + } + } else { + await updateTool(toolId, { + name, + points: tool.points, + finger_holes: tool.finger_holes, + interior_rings: tool.interior_rings, + smoothed: tool.smoothed, + smooth_level: tool.smooth_level, + }) + } }, [tool, name, toolId], - 150, + 300, { skipInitial: true } ) @@ -64,6 +106,25 @@ export default function ToolPage() { setTool(prev => prev ? { ...prev, interior_rings } : null) }, []) + const handleShapesChange = useCallback((shapes: ToolShape[]) => { + setTool(prev => prev ? { ...prev, shapes } : null) + }, []) + + const handleClearanceChange = useCallback((clearance_override: number | null) => { + setTool(prev => prev ? { ...prev, clearance_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 { + const ret = await updateTool(toolId, { shapes: null }) + setMaterializeError(null) + setTool(prev => prev ? { ...prev, shapes: null, points: ret.points, interior_rings: ret.interior_rings } : prev) + } catch { + // keep the designer open; the next autosave will surface errors + } + }, [toolId]) + if (loading) { return (
@@ -81,6 +142,8 @@ export default function ToolPage() { ) } + const isParametric = tool.shapes != null + return (
{/* floating breadcrumb panel */} @@ -105,18 +168,31 @@ export default function ToolPage() {
{/* editor fills the entire area */} - + {isParametric ? ( + + ) : ( + + )}
) } diff --git a/frontend/src/app/trace/[id]/page.tsx b/frontend/src/app/trace/[id]/page.tsx index d2c0513..869b5d0 100644 --- a/frontend/src/app/trace/[id]/page.tsx +++ b/frontend/src/app/trace/[id]/page.tsx @@ -141,6 +141,8 @@ export default function TracePage() { const result = await setCorners(sessionId, corners, paperSize) setCorrectedImageUrl(result.corrected_image_url) setImageVersion(Date.now()) + // keep scale_factor current so the measurement overlay works without a reload + setSession((s) => (s ? { ...s, scale_factor: result.scale_factor } : s)) if (singleTracer && tracers.length === 1) { // single tracer: trace immediately without changing step @@ -671,6 +673,7 @@ export default function TracePage() { onIncludedChange={step === 'edit' ? setIncludedPolygons : undefined} hovered={step === 'edit' ? hoveredPolygon : undefined} onHoveredChange={step === 'edit' ? setHoveredPolygon : undefined} + scaleFactor={session?.scale_factor} /> )}
diff --git a/frontend/src/components/BinConfigurator.tsx b/frontend/src/components/BinConfigurator.tsx index 2ac2bca..b88a919 100644 --- a/frontend/src/components/BinConfigurator.tsx +++ b/frontend/src/components/BinConfigurator.tsx @@ -3,6 +3,7 @@ import { Info, Link2, Link2Off } from 'lucide-react' import type { BinConfig } from '@/types' import { SNAP_FRACTIONS, type SnapMode } from '@/lib/constants' +import { NumberField } from '@/components/NumberField' const GF_HEIGHT_UNIT = 7.0 const GF_BASE_HEIGHT = 4.75 @@ -111,18 +112,13 @@ function SliderRow({ style={{ '--slider-pct': `${pct}%` } as React.CSSProperties} />
- { - const v = step >= 1 ? parseInt(e.target.value) : parseFloat(e.target.value) - if (!isNaN(v)) onChange(Math.min(max, Math.max(min, v))) - }} - className="w-14 h-7 bg-elevated text-right text-xs font-semibold text-text-primary rounded pr-2 focus:outline-none" + onCommit={onChange} /> {unit && {unit}}
diff --git a/frontend/src/components/BinEditor.tsx b/frontend/src/components/BinEditor.tsx index 0f07f81..633bfc9 100644 --- a/frontend/src/components/BinEditor.tsx +++ b/frontend/src/components/BinEditor.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useCallback, useEffect } from 'react' import type { PlacedTool, TextLabel } from '@/types' -import { snapToGrid as snapToGridUtil } from '@/lib/svg' +import { snapToGrid as snapToGridUtil, axisLock } from '@/lib/svg' import { DEFAULT_GRID_UNIT, DISPLAY_SCALE, SNAP_GRID, resolveSnap, type SnapMode } from '@/lib/constants' import { BinEditorToolbar } from '@/components/BinEditorToolbar' import { BinEditorCanvas } from '@/components/BinEditorCanvas' @@ -281,6 +281,7 @@ export function BinEditor({ if (!dragging) return const clientX = e.clientX const clientY = e.clientY + const shiftKey = e.shiftKey if (rafRef.current) cancelAnimationFrame(rafRef.current) rafRef.current = requestAnimationFrame(() => { @@ -294,10 +295,12 @@ export function BinEditor({ if (dragging.type === 'tool') { const origCenterX = dragging.origPoints.reduce((sum, p) => sum + p.x, 0) / dragging.origPoints.length const origCenterY = dragging.origPoints.reduce((sum, p) => sum + p.y, 0) / dragging.origPoints.length - const rawDx = pos.x - dragging.startX - const rawDy = pos.y - dragging.startY - const newCenterX = snapToGrid(origCenterX + rawDx) - const newCenterY = snapToGrid(origCenterY + rawDy) + let rawDx = pos.x - dragging.startX + let rawDy = pos.y - dragging.startY + if (shiftKey) ({ dx: rawDx, dy: rawDy } = axisLock(rawDx, rawDy)) + // the shift-locked axis stays exactly put, even off-grid + const newCenterX = shiftKey && rawDx === 0 ? origCenterX : snapToGrid(origCenterX + rawDx) + const newCenterY = shiftKey && rawDy === 0 ? origCenterY : snapToGrid(origCenterY + rawDy) const dx = newCenterX - origCenterX const dy = newCenterY - origCenterY const updated = currentTools.map(tool => { diff --git a/frontend/src/components/BinEditorToolbar.tsx b/frontend/src/components/BinEditorToolbar.tsx index fb8f902..5892a92 100644 --- a/frontend/src/components/BinEditorToolbar.tsx +++ b/frontend/src/components/BinEditorToolbar.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { MousePointer2, Trash2, Magnet, Type, Pencil, Maximize2 } from 'lucide-react' import type { FingerHole, PlacedTool, TextLabel } from '@/types' import { SNAP_GRID } from '@/lib/constants' @@ -16,10 +16,13 @@ interface DepthInputProps { function DepthInput({ value, defaultDepth, maxDepth, onCommit, resetKey }: DepthInputProps) { const [text, setText] = useState(value == null ? '' : String(value)) - // sync local text when the selected item changes (resetKey switches) - useEffect(() => { + // sync local text when the selected item changes (resetKey switches) or the + // committed value updates -- adjusting during render avoids a cascading effect + const [synced, setSynced] = useState({ resetKey, value }) + if (synced.resetKey !== resetKey || synced.value !== value) { + setSynced({ resetKey, value }) setText(value == null ? '' : String(value)) - }, [resetKey, value]) + } const commit = (raw: string) => { const trimmed = raw.trim() diff --git a/frontend/src/components/MeasurementOverlay.tsx b/frontend/src/components/MeasurementOverlay.tsx new file mode 100644 index 0000000..cfa9e75 --- /dev/null +++ b/frontend/src/components/MeasurementOverlay.tsx @@ -0,0 +1,120 @@ +'use client' + +import type { Point } from '@/types' +import { signedArea, edgeMidpointNormal, interiorAngleDeg, interiorBisector } from '@/lib/svg' + +interface Props { + points: Point[] + holes?: Point[][] + /** multiply SVG-unit distances by this to get mm */ + mmPerUnit: number + /** scales fonts/offsets so labels stay readable at any canvas size */ + uiScale: number +} + +const MAX_EDGE_LABELS = 120 + +function formatMm(mm: number): string { + return mm >= 10 ? mm.toFixed(1) : mm.toFixed(2) +} + +function RingMeasurements({ points, mmPerUnit, uiScale }: { points: Point[]; mmPerUnit: number; uiScale: number }) { + if (points.length < 3) return null + + const ccw = signedArea(points) > 0 + const n = points.length + const minEdgeLen = 24 * uiScale + + const edges = points.map((p, i) => { + const q = points[(i + 1) % n] + const len = Math.hypot(q.x - p.x, q.y - p.y) + return { i, p, q, len } + }) + + const shown = new Set( + edges + .filter((e) => e.len >= minEdgeLen) + .sort((a, b) => b.len - a.len) + .slice(0, MAX_EDGE_LABELS) + .map((e) => e.i) + ) + + const fontSize = 11 * uiScale + const halo: React.CSSProperties = { + paintOrder: 'stroke', + stroke: 'rgba(24, 24, 27, 0.85)', + strokeWidth: 3 * uiScale, + strokeLinejoin: 'round', + } + + return ( + + {edges.map((e) => { + if (!shown.has(e.i)) return null + const { mid, normal } = edgeMidpointNormal(e.p, e.q, ccw) + const x = mid.x + normal.x * 10 * uiScale + const y = mid.y + normal.y * 10 * uiScale + let deg = (Math.atan2(e.q.y - e.p.y, e.q.x - e.p.x) * 180) / Math.PI + if (deg > 90) deg -= 180 + if (deg < -90) deg += 180 + return ( + + {formatMm(e.len * mmPerUnit)} + + ) + })} + {points.map((v, i) => { + // angle label only where both adjacent edges are labeled, to limit clutter + const prevEdge = (i - 1 + n) % n + if (!shown.has(prevEdge) || !shown.has(i)) return null + const prev = points[prevEdge] + const next = points[(i + 1) % n] + const angle = interiorAngleDeg(prev, v, next, ccw) + if (angle > 178 && angle < 182) return null // collinear trace noise + const bis = interiorBisector(prev, v, next, ccw) + const x = v.x + bis.x * 14 * uiScale + const y = v.y + bis.y * 14 * uiScale + return ( + + {Math.round(angle)}° + + ) + })} + + ) +} + +/** + * SVG overlay showing edge lengths (mm) and interior vertex angles for a + * polygon ring and its holes. Coordinate-system agnostic: the host passes + * points in its own SVG units plus the unit->mm factor. + */ +export function MeasurementOverlay({ points, holes, mmPerUnit, uiScale }: Props) { + return ( + <> + + {(holes ?? []).map((hole, i) => ( + + ))} + + ) +} diff --git a/frontend/src/components/NumberField.tsx b/frontend/src/components/NumberField.tsx new file mode 100644 index 0000000..a31f7eb --- /dev/null +++ b/frontend/src/components/NumberField.tsx @@ -0,0 +1,105 @@ +'use client' + +import { useState } from 'react' + +interface NumberFieldProps { + value: number | null + min: number + max: number + step?: number + onCommit: (v: number) => void + onCommitNull?: () => void + nullable?: boolean + placeholder?: string + disabled?: boolean + className?: string +} + +function stepDecimals(step: number): number { + return step >= 1 ? 0 : Math.min(3, String(step).split('.')[1]?.length ?? 1) +} + +function format(value: number | null, step: number): string { + if (value == null) return '' + // avoid float noise like 0.30000000000000004 from step arithmetic + return String(Number(value.toFixed(stepDecimals(step)))) +} + +/** + * Numeric input that only parses/clamps on commit (blur or Enter), never per + * keystroke -- typing "37" into a min-30 field must not clamp the intermediate + * "3" to 30. Escape reverts to the last committed value. + */ +export function NumberField({ + value, + min, + max, + step = 1, + onCommit, + onCommitNull, + nullable, + placeholder, + disabled, + className, +}: NumberFieldProps) { + const [text, setText] = useState(() => format(value, step)) + const [focused, setFocused] = useState(false) + + // follow external changes (e.g. paired slider drags) while not being typed in. + // adjusting during render (vs an effect) is the recommended pattern for + // state derived from props -- avoids an extra render and a cascading update. + const [synced, setSynced] = useState({ value, step }) + if (!focused && (synced.value !== value || synced.step !== step)) { + setSynced({ value, step }) + setText(format(value, step)) + } + + const revert = () => setText(format(value, step)) + + const commit = (raw: string) => { + const trimmed = raw.trim() + if (trimmed === '' && nullable) { + onCommitNull?.() + return + } + const n = parseFloat(trimmed) + if (isNaN(n)) { + revert() + return + } + const clamped = Math.min(max, Math.max(min, n)) + const snapped = Math.round((clamped - min) / step) * step + min + const final = Number(Math.min(max, snapped).toFixed(stepDecimals(step))) + setText(format(final, step)) + onCommit(final) + } + + return ( + setFocused(true)} + onChange={(e) => setText(e.target.value)} + onBlur={(e) => { + setFocused(false) + commit(e.target.value) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') (e.currentTarget as HTMLInputElement).blur() + if (e.key === 'Escape') { + revert() + ;(e.currentTarget as HTMLInputElement).blur() + } + }} + className={ + className ?? + 'w-14 h-7 bg-elevated text-right text-xs font-semibold text-text-primary rounded pr-2 focus:outline-none' + } + /> + ) +} diff --git a/frontend/src/components/PaperCornerEditor.tsx b/frontend/src/components/PaperCornerEditor.tsx index 8f39d97..8738860 100644 --- a/frontend/src/components/PaperCornerEditor.tsx +++ b/frontend/src/components/PaperCornerEditor.tsx @@ -38,6 +38,9 @@ export function PaperCornerEditor({ imageUrl, corners, onCornersChange }: Props) } img.src = imageUrl return () => { cancelled = true } + // intentionally keyed off imageUrl only: corners/onCornersChange are read + // once at load to seed defaults; including them would reload on every drag + // eslint-disable-next-line react-hooks/exhaustive-deps }, [imageUrl]) // fit container to available space while preserving aspect ratio diff --git a/frontend/src/components/PolygonEditor.tsx b/frontend/src/components/PolygonEditor.tsx index 2813175..9cc0f1d 100644 --- a/frontend/src/components/PolygonEditor.tsx +++ b/frontend/src/components/PolygonEditor.tsx @@ -2,9 +2,10 @@ import { useState, useRef, useEffect, useCallback } from 'react' import type { Point, Polygon } from '@/types' -import { Undo2, Redo2, Trash2, Plus, Minus, Move } from 'lucide-react' -import { polygonPathData } from '@/lib/svg' +import { Undo2, Redo2, Trash2, Plus, Minus, Move, Ruler } from 'lucide-react' +import { polygonPathData, axisLock } from '@/lib/svg' import { useHistory } from '@/hooks/useHistory' +import { MeasurementOverlay } from '@/components/MeasurementOverlay' interface Props { imageUrl: string @@ -15,13 +16,15 @@ interface Props { onIncludedChange?: (ids: Set) => void hovered?: string | null onHoveredChange?: (id: string | null) => void + /** mm per image px (session.scale_factor); enables the measurement toggle */ + scaleFactor?: number | null } // base sizes for SVG UI elements, designed for ~800px viewBox width const BASE_VIEW_WIDTH = 800 type EditMode = 'select' | 'vertex' | 'add-vertex' | 'delete-vertex' type DragState = - | { type: 'vertex'; polyId: string; pointIdx: number } + | { type: 'vertex'; polyId: string; pointIdx: number; startPoint: Point } | null export function PolygonEditor({ @@ -33,6 +36,7 @@ export function PolygonEditor({ onIncludedChange, hovered, onHoveredChange, + scaleFactor, }: Props) { const wrapperRef = useRef(null) const containerRef = useRef(null) @@ -54,6 +58,7 @@ export function PolygonEditor({ const [editMode, setEditMode] = useState('select') const [dragging, setDragging] = useState(null) + const [showMeasurements, setShowMeasurements] = useState(false) const { set: pushHistory, undo: handleUndo, redo: handleRedo, canUndo, canRedo } = useHistory( polygons, @@ -176,10 +181,16 @@ export function PolygonEditor({ } } + const startVertexDrag = (polyId: string, pointIdx: number) => { + const poly = polygons.find(p => p.id === polyId) + const startPoint = poly?.points[pointIdx] ?? { x: 0, y: 0 } + setDragging({ type: 'vertex', polyId, pointIdx, startPoint }) + } + const handleVertexMouseDown = (polyId: string, pointIdx: number) => (e: React.MouseEvent) => { e.stopPropagation() if (editable && (editMode === 'vertex' || editMode === 'select')) { - setDragging({ type: 'vertex', polyId, pointIdx }) + startVertexDrag(polyId, pointIdx) } } @@ -187,7 +198,7 @@ export function PolygonEditor({ e.stopPropagation() e.preventDefault() if (editable && (editMode === 'vertex' || editMode === 'select')) { - setDragging({ type: 'vertex', polyId, pointIdx }) + startVertexDrag(polyId, pointIdx) } } @@ -195,7 +206,12 @@ export function PolygonEditor({ (e: MouseEvent) => { if (!dragging) return - const point = getScaledPoint(e.clientX, e.clientY) + let point = getScaledPoint(e.clientX, e.clientY) + if (e.shiftKey && dragging.type === 'vertex') { + // shift constrains movement to the dominant cardinal axis + const d = axisLock(point.x - dragging.startPoint.x, point.y - dragging.startPoint.y) + point = { x: dragging.startPoint.x + d.dx, y: dragging.startPoint.y + d.dy } + } if (dragging.type === 'vertex') { const updated = polygonsRef.current.map((poly) => { @@ -353,9 +369,26 @@ export function PolygonEditor({
+ {scaleFactor != null && ( + <> +
+ + + )} + {(editMode === 'select' || editMode === 'vertex') && !activeId && 'Click outlines to select tools'} - {(editMode === 'select' || editMode === 'vertex') && activeId && 'Drag vertices to adjust the outline'} + {(editMode === 'select' || editMode === 'vertex') && activeId && 'Drag vertices to adjust the outline · Shift locks to an axis'} {editMode === 'add-vertex' && 'Click on an edge to add a vertex'} {editMode === 'delete-vertex' && 'Click a vertex to remove it'} @@ -487,6 +520,15 @@ export function PolygonEditor({ ))} + {isActive && showMeasurements && scaleFactor != null && ( + + )} + ) })} diff --git a/frontend/src/components/ReadinessGate.tsx b/frontend/src/components/ReadinessGate.tsx index a393ada..27f1c01 100644 --- a/frontend/src/components/ReadinessGate.tsx +++ b/frontend/src/components/ReadinessGate.tsx @@ -61,30 +61,29 @@ export function ReadinessGate({ children }: { children: React.ReactNode }) { let nextDelay = POLL_INTERVAL_MS let success = false - // /boot.json is served by nginx and is always reachable once the - // container is up. /api/ready confirms uvicorn has bound the socket. + // /boot.json is served by nginx in the container to surface startup + // progress. It does not exist under `npm run dev` / from-source, so it's + // best-effort: we use it only to populate the progress display. try { const res = await fetchWithTimeout('/boot.json', FETCH_TIMEOUT_MS) - if (res.ok) { - const data = await res.json() as BootInfo - setBoot(data) - if (data.ready) { - // Confirm uvicorn is actually answering before we drop the splash. - try { - const ready = await fetchWithTimeout('/api/ready', FETCH_TIMEOUT_MS) - if (ready.ok) { - failsRef.current = 0 - setMode('ready') - nextDelay = HEARTBEAT_INTERVAL_MS - success = true - } - } catch { /* fall through to retry */ } - } - } else if (res.status === 503) { - // boot.json missing — backend hasn't written it yet, just retry. + if (res.ok) setBoot(await res.json() as BootInfo) + } catch { + // boot.json unreachable — fall back to /api/ready below. + } + + // /api/ready is the authoritative signal: it only answers 200 once + // uvicorn has bound, which (per the backend) means the tracer pre-load + // is complete. This works with or without nginx in front. + try { + const ready = await fetchWithTimeout('/api/ready', FETCH_TIMEOUT_MS) + if (ready.ok) { + failsRef.current = 0 + setMode('ready') + nextDelay = HEARTBEAT_INTERVAL_MS + success = true } } catch { - // network error reaching nginx — container is probably down. + // backend not answering yet — retry. } if (!success) { diff --git a/frontend/src/components/SettingsPopover.tsx b/frontend/src/components/SettingsPopover.tsx index 29f70d0..b02511e 100644 --- a/frontend/src/components/SettingsPopover.tsx +++ b/frontend/src/components/SettingsPopover.tsx @@ -4,16 +4,13 @@ import { useState, useEffect, useRef } from 'react' import { Settings } from 'lucide-react' import { getSettings, saveSettings } from '@/lib/settings' import { IconButton } from '@/components/IconButton' +import { NumberField } from '@/components/NumberField' export function SettingsPopover() { const [open, setOpen] = useState(false) - const [bedSize, setBedSize] = useState(256) + const [bedSize, setBedSize] = useState(() => getSettings().bedSize) const ref = useRef(null) - useEffect(() => { - setBedSize(getSettings().bedSize) - }, []) - useEffect(() => { if (!open) return function handleClick(e: MouseEvent) { @@ -58,17 +55,12 @@ export function SettingsPopover() { style={{ '--slider-pct': `${pct}%` } as React.CSSProperties} />
- { - const v = parseInt(e.target.value) - if (!isNaN(v)) handleBedSizeChange(Math.min(400, Math.max(150, v))) - }} - className="w-14 h-7 bg-elevated text-right text-xs font-semibold text-text-primary rounded pr-2 focus:outline-none" + onCommit={handleBedSizeChange} /> mm
diff --git a/frontend/src/components/ShapeDesigner.tsx b/frontend/src/components/ShapeDesigner.tsx new file mode 100644 index 0000000..4d912e9 --- /dev/null +++ b/frontend/src/components/ShapeDesigner.tsx @@ -0,0 +1,404 @@ +'use client' + +import { useState, useRef, useCallback, useEffect } from 'react' +import { Undo2, Redo2, Magnet } from 'lucide-react' +import type { Point, ToolShape } from '@/types' +import { DISPLAY_SCALE, ZOOM_FACTOR } from '@/lib/constants' +import { axisLock } from '@/lib/svg' +import { rotatePoint, shapeBounds } from '@/lib/shapes' +import { snapShapePosition, snapRotation, type SnapIndicator } from '@/lib/shapeSnap' +import { useHistory } from '@/hooks/useHistory' +import { ShapeDesignerCanvas } from '@/components/ShapeDesignerCanvas' +import { ShapeListPanel } from '@/components/ShapeListPanel' + +const PADDING_MM = 20 +const GRID_OPTIONS = [ + { label: 'Off', value: 0 }, + { label: '0.1', value: 0.1 }, + { label: '0.5', value: 0.5 }, + { label: '1', value: 1 }, + { label: '5', value: 5 }, +] + +interface Props { + shapes: ToolShape[] + outlinePoints: Point[] + outlineRings: Point[][] + clearanceOverride: number | null + materializeError: string | null + onShapesChange: (shapes: ToolShape[]) => void + onClearanceChange: (v: number | null) => void + onConvertToPolygon: () => void +} + +type DragState = + | { type: 'shape'; id: string; startMm: Point; orig: ToolShape; alt: boolean } + | { type: 'resize'; id: string; orig: ToolShape } + | { type: 'rotate'; id: string; orig: ToolShape; startAngle: number; alt: boolean } + | { type: 'pan'; startClientX: number; startClientY: number; origPanX: number; origPanY: number; svgScale: number } + | null + +export function ShapeDesigner({ + shapes, + outlinePoints, + outlineRings, + clearanceOverride, + materializeError, + onShapesChange, + onClearanceChange, + onConvertToPolygon, +}: Props) { + const svgRef = useRef(null) + const [selectedId, setSelectedId] = useState(null) + const [gridMm, setGridMm] = useState(1) + const [zoom, setZoom] = useState(1) + const [pan, setPan] = useState({ x: 0, y: 0 }) + const [dragging, setDragging] = useState(null) + const [dragShapes, setDragShapes] = useState(null) + const [snapIndicator, setSnapIndicator] = useState(null) + const spaceHeld = useRef(false) + const didPanRef = useRef(false) + + const displayShapes = dragShapes ?? shapes + + const { set: pushHistory, undo: handleUndo, redo: handleRedo, canUndo, canRedo } = useHistory( + shapes, + onShapesChange + ) + + const commitShapes = useCallback((updated: ToolShape[]) => { + pushHistory(updated) + onShapesChange(updated) + }, [pushHistory, onShapesChange]) + + // refs to avoid stale closures during window-level drag handlers + const shapesRef = useRef(shapes) + const dragShapesRef = useRef(dragShapes) + const zoomRef = useRef(zoom) + const panRef = useRef(pan) + const gridRef = useRef(gridMm) + useEffect(() => { shapesRef.current = shapes }, [shapes]) + useEffect(() => { dragShapesRef.current = dragShapes }, [dragShapes]) + useEffect(() => { zoomRef.current = zoom }, [zoom]) + useEffect(() => { panRef.current = pan }, [pan]) + useEffect(() => { gridRef.current = gridMm }, [gridMm]) + + // viewBox from committed shapes + outline so the frame stays stable during drags + const bounds = (() => { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (const s of shapes) { + const b = shapeBounds(s) + minX = Math.min(minX, b.minX); minY = Math.min(minY, b.minY) + maxX = Math.max(maxX, b.maxX); maxY = Math.max(maxY, b.maxY) + } + for (const p of outlinePoints) { + minX = Math.min(minX, p.x); minY = Math.min(minY, p.y) + maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y) + } + if (minX === Infinity) return { minX: -50, minY: -50, maxX: 50, maxY: 50 } + return { minX, minY, maxX, maxY } + })() + + const vbX = (bounds.minX - PADDING_MM) * DISPLAY_SCALE + const vbY = (bounds.minY - PADDING_MM) * DISPLAY_SCALE + const vbW = (bounds.maxX - bounds.minX + PADDING_MM * 2) * DISPLAY_SCALE + const vbH = (bounds.maxY - bounds.minY + PADDING_MM * 2) * DISPLAY_SCALE + const zvbW = vbW / zoom + const zvbH = vbH / zoom + const zvbX = vbX + (vbW - zvbW) / 2 + pan.x + const zvbY = vbY + (vbH - zvbH) / 2 + pan.y + + const gridStep = 10 + const gridMinX = Math.floor(zvbX / DISPLAY_SCALE / gridStep) * gridStep + const gridMaxX = Math.ceil((zvbX + zvbW) / DISPLAY_SCALE / gridStep) * gridStep + const gridMinY = Math.floor(zvbY / DISPLAY_SCALE / gridStep) * gridStep + const gridMaxY = Math.ceil((zvbY + zvbH) / DISPLAY_SCALE / gridStep) * gridStep + + const screenToMm = useCallback((clientX: number, clientY: number): Point => { + if (!svgRef.current) return { x: 0, y: 0 } + const rect = svgRef.current.getBoundingClientRect() + const scale = Math.max(zvbW / rect.width, zvbH / rect.height) + const offsetX = (rect.width * scale - zvbW) / 2 + const offsetY = (rect.height * scale - zvbH) / 2 + const svgX = (clientX - rect.left) * scale - offsetX + zvbX + const svgY = (clientY - rect.top) * scale - offsetY + zvbY + return { x: svgX / DISPLAY_SCALE, y: svgY / DISPLAY_SCALE } + }, [zvbW, zvbH, zvbX, zvbY]) + + const screenToMmRef = useRef(screenToMm) + useEffect(() => { screenToMmRef.current = screenToMm }, [screenToMm]) + + /** snap threshold in mm: ~8 screen px */ + const snapThresholdMm = useCallback((): number => { + if (!svgRef.current) return 1 + const rect = svgRef.current.getBoundingClientRect() + return (8 * Math.max(zvbW / rect.width, zvbH / rect.height)) / DISPLAY_SCALE + }, [zvbW, zvbH]) + const thresholdRef = useRef(snapThresholdMm) + useEffect(() => { thresholdRef.current = snapThresholdMm }, [snapThresholdMm]) + + // scroll-to-zoom toward the cursor + useEffect(() => { + const svg = svgRef.current + if (!svg) return + const handleWheel = (e: WheelEvent) => { + e.preventDefault() + const factor = e.deltaY < 0 ? ZOOM_FACTOR : 1 / ZOOM_FACTOR + const oldZoom = zoomRef.current + const newZoom = Math.min(20, Math.max(0.5, oldZoom * factor)) + if (newZoom === oldZoom) return + + const rect = svg.getBoundingClientRect() + const curPan = panRef.current + const curW = vbW / oldZoom + const curH = vbH / oldZoom + const curX = vbX + (vbW - curW) / 2 + curPan.x + const curY = vbY + (vbH - curH) / 2 + curPan.y + const svgScale = Math.min(rect.width / curW, rect.height / curH) + const padLeft = (rect.width - curW * svgScale) / 2 + const padTop = (rect.height - curH * svgScale) / 2 + const cursorX = curX + (e.clientX - rect.left - padLeft) / svgScale + const cursorY = curY + (e.clientY - rect.top - padTop) / svgScale + const newW = vbW / newZoom + const newH = vbH / newZoom + const newX = vbX + (vbW - newW) / 2 + curPan.x + const newY = vbY + (vbH - newH) / 2 + curPan.y + const newSvgScale = Math.min(rect.width / newW, rect.height / newH) + const newPadLeft = (rect.width - newW * newSvgScale) / 2 + const newPadTop = (rect.height - newH * newSvgScale) / 2 + const newCursorX = newX + (e.clientX - rect.left - newPadLeft) / newSvgScale + const newCursorY = newY + (e.clientY - rect.top - newPadTop) / newSvgScale + setPan({ x: curPan.x + (cursorX - newCursorX), y: curPan.y + (cursorY - newCursorY) }) + setZoom(newZoom) + } + svg.addEventListener('wheel', handleWheel, { passive: false }) + return () => svg.removeEventListener('wheel', handleWheel) + }, [vbW, vbH, vbX, vbY]) + + // space for pan, delete for removing the selected shape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.code === 'Space' && !e.repeat) spaceHeld.current = true + if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId) { + const tag = (document.activeElement?.tagName || '').toLowerCase() + if (tag === 'input' || tag === 'textarea') return + commitShapes(shapesRef.current.filter((s) => s.id !== selectedId)) + setSelectedId(null) + } + } + const handleKeyUp = (e: KeyboardEvent) => { + if (e.code === 'Space') spaceHeld.current = false + } + window.addEventListener('keydown', handleKeyDown) + window.addEventListener('keyup', handleKeyUp) + return () => { + window.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('keyup', handleKeyUp) + } + }, [selectedId, commitShapes]) + + const handleShapeMouseDown = (id: string) => (e: React.MouseEvent) => { + e.stopPropagation() + if (spaceHeld.current) return + const shape = shapes.find((s) => s.id === id) + if (!shape) return + setSelectedId(id) + setDragging({ type: 'shape', id, startMm: screenToMm(e.clientX, e.clientY), orig: shape, alt: e.altKey }) + } + + const handleResizeMouseDown = (id: string) => (e: React.MouseEvent) => { + e.stopPropagation() + const shape = shapes.find((s) => s.id === id) + if (!shape) return + setDragging({ type: 'resize', id, orig: shape }) + } + + const handleRotateMouseDown = (id: string) => (e: React.MouseEvent) => { + e.stopPropagation() + const shape = shapes.find((s) => s.id === id) + if (!shape) return + const mm = screenToMm(e.clientX, e.clientY) + const startAngle = (Math.atan2(mm.y - shape.y, mm.x - shape.x) * 180) / Math.PI + setDragging({ type: 'rotate', id, orig: shape, startAngle, alt: e.altKey }) + } + + const handleSvgMouseDown = (e: React.MouseEvent) => { + if (e.button !== 0 && e.button !== 1) return + if (e.button === 1 || spaceHeld.current || e.target === svgRef.current || (e.target as Element).tagName === 'rect') { + // pan on background / middle button / space + if (!svgRef.current) return + const rect = svgRef.current.getBoundingClientRect() + const svgScale = Math.min(rect.width / zvbW, rect.height / zvbH) + didPanRef.current = false + setDragging({ type: 'pan', startClientX: e.clientX, startClientY: e.clientY, origPanX: pan.x, origPanY: pan.y, svgScale }) + } + } + + const handleBackgroundClick = () => { + if (!didPanRef.current) setSelectedId(null) + } + + const handleMouseMove = useCallback((e: MouseEvent) => { + if (!dragging) return + const grid = gridRef.current + + if (dragging.type === 'pan') { + const dx = (e.clientX - dragging.startClientX) / dragging.svgScale + const dy = (e.clientY - dragging.startClientY) / dragging.svgScale + if (Math.abs(dx) + Math.abs(dy) > 2) didPanRef.current = true + setPan({ x: dragging.origPanX - dx, y: dragging.origPanY - dy }) + return + } + + const mm = screenToMmRef.current(e.clientX, e.clientY) + const base = shapesRef.current + + if (dragging.type === 'shape') { + let dx = mm.x - dragging.startMm.x + let dy = mm.y - dragging.startMm.y + let next = { x: dragging.orig.x + dx, y: dragging.orig.y + dy } + let indicator: SnapIndicator | null = null + if (e.shiftKey) { + // shift locks to the dominant cardinal axis; grid still snaps the + // free axis (unless Alt), the locked axis stays exactly put + ;({ dx, dy } = axisLock(dx, dy)) + const snapFree = (v: number) => (e.altKey || !grid ? v : Math.round(v / grid) * grid) + next = { + x: dx === 0 ? dragging.orig.x : snapFree(dragging.orig.x + dx), + y: dy === 0 ? dragging.orig.y : snapFree(dragging.orig.y + dy), + } + indicator = dx === 0 ? { axisX: dragging.orig.x } : { axisY: dragging.orig.y } + } else if (!e.altKey && !dragging.alt) { + const others = base.filter((s) => s.id !== dragging.id) + const snapped = snapShapePosition(dragging.orig, next.x, next.y, others, grid || null, thresholdRef.current()) + next = { x: snapped.x, y: snapped.y } + indicator = snapped.indicator + } + setSnapIndicator(indicator) + setDragShapes(base.map((s) => (s.id === dragging.id ? { ...s, x: next.x, y: next.y } : s))) + } else if (dragging.type === 'resize') { + const o = dragging.orig + const local = rotatePoint({ x: mm.x - o.x, y: mm.y - o.y }, -o.rotation) + const snapDim = (v: number) => { + const dim = Math.max(0.5, v) + return e.altKey || !grid ? dim : Math.max(0.5, Math.round(dim / grid) * grid) + } + let patch: Partial = {} + if (o.type === 'rectangle') { + patch = { width: snapDim(Math.abs(local.x) * 2), height: snapDim(Math.abs(local.y) * 2) } + } else if (o.type === 'ellipse') { + if (o.rx === o.ry) { + const r = snapDim(Math.hypot(local.x, local.y)) + patch = { rx: r, ry: r } + } else { + patch = { rx: snapDim(Math.abs(local.x)), ry: snapDim(Math.abs(local.y)) } + } + } + setDragShapes(base.map((s) => (s.id === dragging.id ? { ...s, ...patch } : s))) + } else if (dragging.type === 'rotate') { + const o = dragging.orig + const angle = (Math.atan2(mm.y - o.y, mm.x - o.x) * 180) / Math.PI + const next = snapRotation(o.rotation + angle - dragging.startAngle, !e.altKey && !dragging.alt) + setDragShapes(base.map((s) => (s.id === dragging.id ? { ...s, rotation: next } : s))) + } + }, [dragging]) + + const handleMouseUp = useCallback(() => { + if (dragging && dragging.type !== 'pan' && dragShapesRef.current) { + commitShapes(dragShapesRef.current) + } + setDragging(null) + setDragShapes(null) + setSnapIndicator(null) + }, [dragging, commitShapes]) + + useEffect(() => { + if (!dragging) return + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseup', handleMouseUp) + return () => { + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseup', handleMouseUp) + } + }, [dragging, handleMouseMove, handleMouseUp]) + + return ( +
+ + + {/* floating toolbar: top centre */} +
+
+
+ +
+ {GRID_OPTIONS.map((opt) => ( + + ))} +
+ mm +
+ +
+ + + + + + Shift = axis lock · Alt = no snap · Space/middle-drag = pan · Scroll = zoom + +
+
+ + {/* shape list panel: right edge */} +
+ +
+
+ ) +} diff --git a/frontend/src/components/ShapeDesignerCanvas.tsx b/frontend/src/components/ShapeDesignerCanvas.tsx new file mode 100644 index 0000000..a546cc6 --- /dev/null +++ b/frontend/src/components/ShapeDesignerCanvas.tsx @@ -0,0 +1,266 @@ +'use client' + +import { RefObject } from 'react' +import type { Point, ToolShape } from '@/types' +import { polygonPathData } from '@/lib/svg' +import { DISPLAY_SCALE } from '@/lib/constants' +import { shapeBounds } from '@/lib/shapes' +import type { SnapIndicator } from '@/lib/shapeSnap' + +const S = DISPLAY_SCALE + +interface Props { + svgRef: RefObject + zvbX: number + zvbY: number + zvbW: number + zvbH: number + zoom: number + gridMinX: number + gridMaxX: number + gridMinY: number + gridMaxY: number + gridStep: number + + shapes: ToolShape[] + selectedId: string | null + outlinePoints: Point[] + outlineRings: Point[][] + snapIndicator: SnapIndicator | null + + handleBackgroundClick: (e: React.MouseEvent) => void + handleSvgMouseDown: (e: React.MouseEvent) => void + handleShapeMouseDown: (id: string) => (e: React.MouseEvent) => void + handleResizeMouseDown: (id: string) => (e: React.MouseEvent) => void + handleRotateMouseDown: (id: string) => (e: React.MouseEvent) => void +} + +function ShapeElement({ shape, zoom, selected }: { shape: ToolShape; zoom: number; selected: boolean }) { + const sw = (selected ? 2.5 : 1.5) / zoom + const stroke = + shape.mode === 'guide' + ? 'rgb(96, 165, 250)' + : shape.mode === 'subtract' + ? 'rgb(248, 113, 113)' + : selected + ? 'rgb(90, 180, 222)' + : 'rgb(148, 163, 184)' + const dash = shape.mode === 'guide' ? `${8 / zoom},${5 / zoom}` : shape.mode === 'subtract' ? `${5 / zoom},${3 / zoom}` : undefined + 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 ( + + + + ) + } + if (shape.type === 'ellipse') { + return ( + + + + ) + } + // guide line + const hl = ((shape.width ?? 0) / 2) * S + return ( + + + + ) +} + +function MaskShape({ shape }: { shape: ToolShape }) { + const 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 +} + +export function ShapeDesignerCanvas({ + svgRef, zvbX, zvbY, zvbW, zvbH, zoom, + gridMinX, gridMaxX, gridMinY, gridMaxY, gridStep, + shapes, selectedId, outlinePoints, outlineRings, snapIndicator, + handleBackgroundClick, handleSvgMouseDown, + handleShapeMouseDown, handleResizeMouseDown, handleRotateMouseDown, +}: Props) { + const stopClick = (e: React.MouseEvent) => e.stopPropagation() + const s = zvbW / 800 + // solid shapes ordered so the mask builds add-then-subtract; guides never paint + const solidShapes = [...shapes.filter((sh) => sh.mode === 'add'), ...shapes.filter((sh) => sh.mode === 'subtract')] + const selected = shapes.find((sh) => sh.id === selectedId) + + return ( +
+ + + + {/* mm grid */} + {Array.from({ length: Math.ceil((gridMaxX - gridMinX) / gridStep) + 1 }).map((_, i) => { + const x = (gridMinX + i * gridStep) * S + const isOrigin = gridMinX + i * gridStep === 0 + return ( + + ) + })} + {Array.from({ length: Math.ceil((gridMaxY - gridMinY) / gridStep) + 1 }).map((_, i) => { + const y = (gridMinY + i * gridStep) * S + const isOrigin = gridMinY + i * gridStep === 0 + return ( + + ) + })} + + {/* live boolean preview: white = solid, black = hole */} + + + {solidShapes.map((sh) => ( + + ))} + + + + {/* authoritative outline from the last server materialization */} + {outlinePoints.length >= 3 && ( + + )} + + {/* per-shape strokes + hit areas */} + {shapes.map((sh) => ( + + + {/* hit target */} + {(() => { + const transform = `translate(${sh.x * S},${sh.y * S})${sh.rotation ? ` rotate(${sh.rotation})` : ''}` + const common = { + transform, + fill: sh.mode === 'guide' ? 'none' : 'transparent', + stroke: 'transparent', + strokeWidth: 14 / zoom, + className: 'cursor-move', + onMouseDown: handleShapeMouseDown(sh.id), + onClick: stopClick, + } + if (sh.type === 'rectangle') { + const w = (sh.width ?? 0) * S + const h = (sh.height ?? 0) * S + return + } + if (sh.type === 'ellipse') { + return + } + const hl = ((sh.width ?? 0) / 2) * S + return + })()} + + ))} + + {/* selection: bbox + resize + rotate handles */} + {selected && (() => { + const b = shapeBounds(selected) + const pad = 6 * s + const x1 = b.minX * S - pad + const y1 = b.minY * S - pad + const x2 = b.maxX * S + pad + const y2 = b.maxY * S + pad + const handleR = 9 * s + return ( + + + {selected.type !== 'line' && ( + + )} + + + + ) + })()} + + {/* snap indicators */} + {snapIndicator?.point && ( + + + + + + )} + {snapIndicator?.axisX !== undefined && ( + + )} + {snapIndicator?.axisY !== undefined && ( + + )} + +
+ ) +} diff --git a/frontend/src/components/ShapeListPanel.tsx b/frontend/src/components/ShapeListPanel.tsx new file mode 100644 index 0000000..93b0371 --- /dev/null +++ b/frontend/src/components/ShapeListPanel.tsx @@ -0,0 +1,230 @@ +'use client' + +import { Circle, Copy, Minus, Pencil, Plus, RectangleHorizontal, Trash2 } from 'lucide-react' +import type { ToolShape, ToolShapeMode } from '@/types' +import { NumberField } from '@/components/NumberField' +import { makeShape, duplicateShape, shapeDisplayName } from '@/lib/shapes' + +interface Props { + shapes: ToolShape[] + selectedId: string | null + onSelect: (id: string | null) => void + onShapesChange: (shapes: ToolShape[]) => void + clearanceOverride: number | null + onClearanceChange: (v: number | null) => void + materializeError: string | null + onConvertToPolygon: () => void +} + +const MODE_LABEL: Record = { + add: 'Solid', + subtract: 'Hole', + guide: 'Guide', +} + +const MODE_STYLE: Record = { + add: 'bg-accent-muted text-accent', + subtract: 'bg-red-900/40 text-red-400', + guide: 'bg-blue-900/40 text-blue-400', +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + ) +} + +const FIELD_CLASS = + 'w-16 h-6 bg-elevated border border-border-subtle rounded text-right text-[11px] text-text-primary pr-1.5 focus:outline-none focus:border-accent' + +export function ShapeListPanel({ + shapes, + selectedId, + onSelect, + onShapesChange, + clearanceOverride, + onClearanceChange, + materializeError, + onConvertToPolygon, +}: Props) { + const update = (id: string, patch: Partial) => { + onShapesChange(shapes.map((s) => (s.id === id ? { ...s, ...patch } : s))) + } + + const add = (shape: ToolShape) => { + onShapesChange([...shapes, shape]) + onSelect(shape.id) + } + + const remove = (id: string) => { + onShapesChange(shapes.filter((s) => s.id !== id)) + if (selectedId === id) onSelect(null) + } + + const cycleMode = (s: ToolShape) => { + 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] }) + } + + return ( +
+ {/* add buttons */} +
+ + + + Add shape +
+ + {/* shape rows */} + {shapes.map((s) => { + const selected = s.id === selectedId + const isCircle = s.type === 'ellipse' && s.rx === s.ry + return ( +
onSelect(s.id)} + className={`glass-toolbar px-2.5 py-2 cursor-pointer ${selected ? 'ring-1 ring-accent' : ''}`} + > +
+ {s.type === 'rectangle' ? ( + + ) : s.type === 'ellipse' ? ( + + ) : ( + + )} + {shapeDisplayName(s)} + + + +
+ + {selected && ( +
e.stopPropagation()}> + + update(s.id, { x: v })} className={FIELD_CLASS} /> + + + update(s.id, { y: v })} className={FIELD_CLASS} /> + + {s.type === 'rectangle' && ( + <> + + update(s.id, { width: v })} className={FIELD_CLASS} /> + + + update(s.id, { height: v })} className={FIELD_CLASS} /> + + + update(s.id, { corner_radius: v })} className={FIELD_CLASS} /> + + + )} + {s.type === 'ellipse' && isCircle && ( + + update(s.id, { rx: v / 2, ry: v / 2 })} className={FIELD_CLASS} /> + + )} + {s.type === 'ellipse' && ( + <> + + update(s.id, { rx: v / 2 })} className={FIELD_CLASS} /> + + + update(s.id, { ry: v / 2 })} className={FIELD_CLASS} /> + + + )} + {s.type === 'line' && ( + + update(s.id, { width: v })} className={FIELD_CLASS} /> + + )} + + update(s.id, { rotation: v })} className={FIELD_CLASS} /> + +
+ )} +
+ ) + })} + + {materializeError && ( +
+ {materializeError} — changes are not saved until the outline is valid again. +
+ )} + + {/* tool-level settings */} +
+ + onClearanceChange(v)} + onCommitNull={() => onClearanceChange(null)} + className={FIELD_CLASS} + /> + +

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

+

+ Holes are carved after all solids are merged. +

+ +
+
+ ) +} diff --git a/frontend/src/components/ThemeToggle.tsx b/frontend/src/components/ThemeToggle.tsx index df5b563..4c170f4 100644 --- a/frontend/src/components/ThemeToggle.tsx +++ b/frontend/src/components/ThemeToggle.tsx @@ -1,28 +1,54 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useSyncExternalStore } from 'react' import { Sun, Moon } from 'lucide-react' import { IconButton } from '@/components/IconButton' +type Theme = 'dark' | 'light' + +const listeners = new Set<() => void>() + +function resolveTheme(): Theme { + const stored = localStorage.getItem('theme') + if (stored === 'light' || stored === 'dark') return stored + return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark' +} + +function applyTheme(theme: Theme) { + document.documentElement.setAttribute('data-theme', theme) +} + +function subscribe(callback: () => void): () => void { + listeners.add(callback) + const mq = window.matchMedia('(prefers-color-scheme: light)') + mq.addEventListener('change', callback) + return () => { + listeners.delete(callback) + mq.removeEventListener('change', callback) + } +} + +// Server has no DOM/storage; render the default theme to match initial markup. +function getServerSnapshot(): Theme { + return 'dark' +} + +function setTheme(theme: Theme) { + localStorage.setItem('theme', theme) + applyTheme(theme) + listeners.forEach((l) => l()) +} + export function ThemeToggle() { - const [theme, setTheme] = useState<'dark' | 'light'>('dark') + const theme = useSyncExternalStore(subscribe, resolveTheme, getServerSnapshot) + // Reflect the resolved theme onto the DOM: initial load and OS-preference changes. useEffect(() => { - const stored = localStorage.getItem('theme') - if (stored === 'light' || stored === 'dark') { - setTheme(stored) - document.documentElement.setAttribute('data-theme', stored) - } else if (window.matchMedia('(prefers-color-scheme: light)').matches) { - setTheme('light') - document.documentElement.setAttribute('data-theme', 'light') - } - }, []) + applyTheme(theme) + }, [theme]) function toggle() { - const next = theme === 'dark' ? 'light' : 'dark' - setTheme(next) - document.documentElement.setAttribute('data-theme', next) - localStorage.setItem('theme', next) + setTheme(theme === 'dark' ? 'light' : 'dark') } return ( diff --git a/frontend/src/components/ToolBrowser.tsx b/frontend/src/components/ToolBrowser.tsx index 1eea95c..de680ba 100644 --- a/frontend/src/components/ToolBrowser.tsx +++ b/frontend/src/components/ToolBrowser.tsx @@ -12,6 +12,8 @@ interface Props { binWidthMm: number binHeightMm: number layout?: 'grid' | 'horizontal' + /** extra controls rendered in the horizontal-layout header row */ + headerExtra?: React.ReactNode } function ToolThumbnail({ points, interiorRings }: { points: Point[]; interiorRings?: Point[][] }) { @@ -48,7 +50,7 @@ function ToolThumbnail({ points, interiorRings }: { points: Point[]; interiorRin ) } -export function ToolBrowser({ onAddTool, binWidthMm, binHeightMm, layout = 'grid' }: Props) { +export function ToolBrowser({ onAddTool, binWidthMm, binHeightMm, layout = 'grid', headerExtra }: Props) { const [tools, setTools] = useState([]) const [loading, setLoading] = useState(true) const [adding, setAdding] = useState(null) @@ -127,6 +129,7 @@ export function ToolBrowser({ onAddTool, binWidthMm, binHeightMm, layout = 'grid

Library

{tools.length} + {headerExtra} {tools.length > 4 && (
diff --git a/frontend/src/components/ToolEditor.tsx b/frontend/src/components/ToolEditor.tsx index bab7593..f7ed434 100644 --- a/frontend/src/components/ToolEditor.tsx +++ b/frontend/src/components/ToolEditor.tsx @@ -3,7 +3,7 @@ import { useState, useRef, useCallback, useEffect, useMemo } from 'react' import { Plus, Circle, Disc, Square, RectangleHorizontal, Fingerprint } from 'lucide-react' import type { Point, FingerHole } from '@/types' -import { simplifyPolygon, smoothEpsilon, snapToGrid as snapToGridUtil } from '@/lib/svg' +import { simplifyPolygon, smoothEpsilon, snapToGrid as snapToGridUtil, axisLock } from '@/lib/svg' import { DISPLAY_SCALE, SNAP_GRID, ZOOM_FACTOR } from '@/lib/constants' import { useHistory } from '@/hooks/useHistory' import { ToolEditorToolbar } from '@/components/ToolEditorToolbar' @@ -26,7 +26,7 @@ interface Props { const PADDING_MM = 20 type DragState = - | { type: 'vertex'; pointIdx: number } + | { type: 'vertex'; pointIdx: number; startPoint: Point } | { type: 'hole'; holeId: string; startX: number; startY: number; origX: number; origY: number } | { type: 'resize'; holeId: string; startX: number; startY: number; origRadius: number; origWidth?: number; origHeight?: number; centerX: number; centerY: number; anchorX?: number; anchorY?: number; rotation?: number } | { type: 'rotate-hole'; holeId: string; centerX: number; centerY: number; startAngle: number; origRotation: number } @@ -46,6 +46,7 @@ export function ToolEditor({ points, fingerHoles, interiorRings, smoothed, smoot const [editMode, setEditMode] = useState('select') const [dragging, setDragging] = useState(null) const [snapEnabled, setSnapEnabled] = useState(true) + const [showMeasurements, setShowMeasurements] = useState(false) const [zoom, setZoom] = useState(1) const [pan, setPan] = useState({ x: 0, y: 0 }) const [cutoutOpen, setCutoutOpen] = useState(false) @@ -59,7 +60,7 @@ export function ToolEditor({ points, fingerHoles, interiorRings, smoothed, smoot onInteriorRingsChange?.(entry.interiorRings) }, [onPointsChange, onFingerHolesChange, onInteriorRingsChange]) - const currentRings = interiorRings ?? [] + const currentRings = useMemo(() => interiorRings ?? [], [interiorRings]) const { set: pushHistory, undo: handleUndo, redo: handleRedo, canUndo, canRedo } = useHistory( { points, fingerHoles, interiorRings: currentRings }, @@ -321,7 +322,7 @@ export function ToolEditor({ points, fingerHoles, interiorRings, smoothed, smoot return } setSelection({ type: 'vertex', pointIdx }) - setDragging({ type: 'vertex', pointIdx }) + setDragging({ type: 'vertex', pointIdx, startPoint: points[pointIdx] }) } const handleEdgeClick = (edgeIdx: number) => (e: React.MouseEvent) => { @@ -438,14 +439,30 @@ export function ToolEditor({ points, fingerHoles, interiorRings, smoothed, smoot if (dragging.type === 'vertex') { const updated = [...currentPoints] - updated[dragging.pointIdx] = { x: snap(pos.x), y: snap(pos.y) } + if (e.shiftKey) { + // shift constrains movement to the dominant cardinal axis; the + // locked axis stays exactly put, even off-grid + const d = axisLock(pos.x - dragging.startPoint.x, pos.y - dragging.startPoint.y) + updated[dragging.pointIdx] = { + x: d.dx === 0 ? dragging.startPoint.x : snap(dragging.startPoint.x + d.dx), + y: d.dy === 0 ? dragging.startPoint.y : snap(dragging.startPoint.y + d.dy), + } + } else { + updated[dragging.pointIdx] = { x: snap(pos.x), y: snap(pos.y) } + } setDragPoints(updated) } else if (dragging.type === 'hole') { - const dx = pos.x - dragging.startX - const dy = pos.y - dragging.startY + let dx = pos.x - dragging.startX + let dy = pos.y - dragging.startY + const locked = e.shiftKey + if (locked) ({ dx, dy } = axisLock(dx, dy)) const updated = currentHoles.map(fh => { if (fh.id !== dragging.holeId) return fh - return { ...fh, x: snap(dragging.origX + dx), y: snap(dragging.origY + dy) } + return { + ...fh, + x: locked && dx === 0 ? dragging.origX : snap(dragging.origX + dx), + y: locked && dy === 0 ? dragging.origY : snap(dragging.origY + dy), + } }) setDragHoles(updated) } else if (dragging.type === 'resize') { @@ -589,6 +606,7 @@ export function ToolEditor({ points, fingerHoles, interiorRings, smoothed, smoot displayPoints={displayPoints} smoothed={smoothed} interiorRings={interiorRings} + showMeasurements={showMeasurements} points={points} editMode={editMode} selection={selection} @@ -613,6 +631,8 @@ export function ToolEditor({ points, fingerHoles, interiorRings, smoothed, smoot onSmoothLevelChange={onSmoothLevelChange} snapEnabled={snapEnabled} setSnapEnabled={setSnapEnabled} + showMeasurements={showMeasurements} + setShowMeasurements={setShowMeasurements} canUndo={canUndo} canRedo={canRedo} handleUndo={handleUndo} diff --git a/frontend/src/components/ToolEditorCanvas.tsx b/frontend/src/components/ToolEditorCanvas.tsx index 7db43c1..d739e54 100644 --- a/frontend/src/components/ToolEditorCanvas.tsx +++ b/frontend/src/components/ToolEditorCanvas.tsx @@ -5,6 +5,7 @@ import type { Point, FingerHole } from '@/types' import { polygonPathData, smoothPathData } from '@/lib/svg' import { DISPLAY_SCALE } from '@/lib/constants' import { CutoutOverlay } from '@/components/CutoutOverlay' +import { MeasurementOverlay } from '@/components/MeasurementOverlay' import type { EditMode, Selection } from '@/components/ToolEditorToolbar' interface Props { @@ -29,6 +30,7 @@ interface Props { displayPoints: Point[] smoothed: boolean interiorRings?: Point[][] + showMeasurements?: boolean // edge/vertex interactions points: Point[] @@ -51,7 +53,7 @@ export function ToolEditorCanvas({ svgRef, zvbX, zvbY, zvbW, zvbH, isCutoutMode, handleBackgroundClick, handleSvgMouseDown, gridMinX, gridMaxX, gridMinY, gridMaxY, gridStep, zoom, - displayPoints, smoothed, interiorRings, + displayPoints, smoothed, interiorRings, showMeasurements, points, editMode, selection, handleEdgeClick, handleVertexMouseDown, displayHoles, handleHoleMouseDown, handleResizeMouseDown, handleHoleRotateMouseDown, @@ -113,6 +115,15 @@ export function ToolEditorCanvas({ strokeWidth={2 / zoom} /> + {showMeasurements && ( + ({ x: p.x * DISPLAY_SCALE, y: p.y * DISPLAY_SCALE }))} + holes={interiorRings?.map(ring => ring.map(p => ({ x: p.x * DISPLAY_SCALE, y: p.y * DISPLAY_SCALE })))} + mmPerUnit={1 / DISPLAY_SCALE} + uiScale={zvbW / 800} + /> + )} + {/* per-ring hit areas for fill-ring mode */} {editMode === 'fill-ring' && interiorRings?.map((ring, idx) => { const d = ring.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x * DISPLAY_SCALE} ${p.y * DISPLAY_SCALE}`).join(' ') + ' Z' diff --git a/frontend/src/components/ToolEditorToolbar.tsx b/frontend/src/components/ToolEditorToolbar.tsx index c24fa06..19972f1 100644 --- a/frontend/src/components/ToolEditorToolbar.tsx +++ b/frontend/src/components/ToolEditorToolbar.tsx @@ -1,7 +1,7 @@ 'use client' import { ReactNode } from 'react' -import { MousePointer2, Plus, Minus, Undo2, Redo2, Trash2, Circle, Disc, Square, RectangleHorizontal, Fingerprint, Magnet, RotateCw, RotateCcw, FlipHorizontal2, FlipVertical2, ChevronDown, PaintBucket } from 'lucide-react' +import { MousePointer2, Plus, Minus, Undo2, Redo2, Trash2, Circle, Disc, Square, RectangleHorizontal, Fingerprint, Magnet, RotateCw, RotateCcw, FlipHorizontal2, FlipVertical2, ChevronDown, PaintBucket, Ruler } from 'lucide-react' import type { FingerHole } from '@/types' import { SNAP_GRID } from '@/lib/constants' @@ -21,6 +21,8 @@ interface Props { onSmoothLevelChange: (level: number) => void snapEnabled: boolean setSnapEnabled: (enabled: boolean) => void + showMeasurements: boolean + setShowMeasurements: (show: boolean) => void canUndo: boolean canRedo: boolean handleUndo: () => void @@ -43,6 +45,7 @@ export function ToolEditorToolbar({ editMode, setEditMode, smoothed, smoothLevel, onSmoothedChange, onSmoothLevelChange, snapEnabled, setSnapEnabled, + showMeasurements, setShowMeasurements, canUndo, canRedo, handleUndo, handleRedo, cutoutOpen, setCutoutOpen, isCutoutMode, cutoutModeIcon, cutoutModeLabel, @@ -184,6 +187,16 @@ export function ToolEditorToolbar({ Snap +