diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index fb869f0..21dbfe2 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -55,12 +55,12 @@ ) from app.services.image_processor import ImageProcessor from app.services.ai_tracer import AITracer -from app.services.polygon_scaler import PolygonScaler, ScaledPolygon, ScaledFingerHole +from app.services.polygon_scaler import PolygonScaler, ScaledPolygon, ScaledFingerHole, ScaledLevelPart from app.services.stl_generator_manifold import ManifoldSTLGenerator from app.services.session_store import SessionStore from app.services.tool_store import ToolStore from app.services.bin_store import BinStore -from app.services.bin_service import sync_placed_tools, resolve_clearance +from app.services.bin_service import sync_placed_tools, resolve_clearance, placed_levels from app.services.image_service import generate_tool_thumbnail from app.services import shape_compiler router = APIRouter() @@ -752,6 +752,8 @@ async def list_tools(request: Request, user_id: str = Depends(get_user_id)): smooth_level=tool.smooth_level, thumbnail_url=thumb_url, parametric=tool.shapes is not None, + clearance_override=tool.clearance_override, + spacing_override=tool.spacing_override, )) summaries.sort(key=lambda t: t.created_at or "", reverse=True) return ToolListResponse(tools=summaries) @@ -775,7 +777,7 @@ async def create_tool(request: Request, req: CreateToolRequest, user_id: str = D ToolShape(id=str(uuid.uuid4()), type="rectangle", mode="add", width=40.0, height=40.0) ] try: - points, interior_rings, offset = shape_compiler.compile_shapes(shapes) + points, interior_rings, offset, levels = shape_compiler.compile_shapes(shapes) except ValueError as e: raise HTTPException(status_code=422, detail=str(e)) @@ -786,6 +788,7 @@ async def create_tool(request: Request, req: CreateToolRequest, user_id: str = D points=points, interior_rings=interior_rings, shapes=shape_compiler.recentre_shapes(shapes, offset), + levels=levels, created_at=datetime.utcnow().isoformat(), ) user_tools.set(tool_id, tool) @@ -806,15 +809,17 @@ async def update_tool(request: Request, tool_id: str, req: ToolUpdateRequest, us if len(req.shapes) == 0: raise HTTPException(status_code=422, detail="design needs at least one shape") try: - points, interior_rings, offset = shape_compiler.compile_shapes(req.shapes) + points, interior_rings, offset, levels = shape_compiler.compile_shapes(req.shapes) except ValueError as e: raise HTTPException(status_code=422, detail=str(e)) tool.shapes = shape_compiler.recentre_shapes(req.shapes, offset) tool.points = points tool.interior_rings = interior_rings + tool.levels = levels else: # explicit null detaches to a plain polygon, keeping materialized points tool.shapes = None + tool.levels = None elif tool.shapes is not None and (req.points is not None or req.interior_rings is not None): raise HTTPException( status_code=422, @@ -836,6 +841,8 @@ async def update_tool(request: Request, tool_id: str, req: ToolUpdateRequest, us tool.smooth_level = req.smooth_level if "clearance_override" in provided: tool.clearance_override = req.clearance_override + if "spacing_override" in provided: + tool.spacing_override = req.spacing_override user_tools.set(tool_id, tool) return tool @@ -1106,10 +1113,17 @@ def generate_bin_stl(request: Request, bin_id: str, user_id: str = Depends(get_u if not bin_data.placed_tools: raise HTTPException(status_code=400, detail="bin has no tools placed") + # re-derive placed geometry from the library tools so footprints and the + # levels derived below can never disagree (covers bins generated without + # an intervening GET, e.g. raw API or drawer flows) + if sync_placed_tools(bin_data, user_tools): + user_bins.set(bin_id, bin_data) + bc = bin_data.bin_config - # include source tool smoothed/parametric/clearance state in hash so - # toggling any of them invalidates the cache + # include source tool smoothed/parametric/clearance/levels state in hash + # so toggling any of them invalidates the cache (a depth-only edit leaves + # the placed footprint unchanged, so levels must hash explicitly) smoothed_flags = {} for pt in bin_data.placed_tools: src = user_tools.get(pt.tool_id) @@ -1118,6 +1132,7 @@ def generate_bin_stl(request: Request, bin_id: str, user_id: str = Depends(get_u "smooth_level": src.smooth_level if src else 0.5, "parametric": src.shapes is not None if src else False, "clearance_override": src.clearance_override if src else None, + "levels": [l.model_dump() for l in (src.levels or [])] if src else [], } input_data = { "bin_config": bc.model_dump(), @@ -1143,8 +1158,20 @@ def generate_bin_stl(request: Request, bin_id: str, user_id: str = Depends(get_u [(p.x, p.y) for p in ring] for ring in pt.interior_rings ] - sp = ScaledPolygon(pt.id, points_mm, pt.name, fholes, interior_rings_mm, depth_override=pt.depth_override) source_tool = user_tools.get(pt.tool_id) + level_parts = None + bin_levels = placed_levels(source_tool, pt) + if bin_levels: + level_parts = [ + ScaledLevelPart( + level.depth, + [(p.x, p.y) for p in part.points], + [[(p.x, p.y) for p in ring] for ring in part.interior_rings], + ) + for level in bin_levels + for part in level.parts + ] + sp = ScaledPolygon(pt.id, points_mm, pt.name, fholes, interior_rings_mm, depth_override=pt.depth_override, levels=level_parts) sp = polygon_scaler.add_clearance(sp, resolve_clearance(source_tool, bc.cutout_clearance)) if source_tool and source_tool.shapes: # parametric outlines are exact; only strip collinear points diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index 878eaa9..0a2f4b2 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -89,6 +89,7 @@ class BinParams(BaseModel): insert_enabled: bool = False insert_height: float = 1.0 cutout_chamfer: float = 0.0 + tool_spacing: float = 0.0 # mm; keep-out air gap beyond each cutout when arranging @field_validator("grid_x", "grid_y") @classmethod @@ -125,6 +126,13 @@ def validate_clearance(cls, v: float) -> float: raise ValueError("clearance must be between 0 and 10mm") return v + @field_validator("tool_spacing") + @classmethod + def validate_tool_spacing(cls, v: float) -> float: + if v < 0 or v > 20: + raise ValueError("tool spacing must be between 0 and 20mm") + return v + @field_validator("insert_height") @classmethod def validate_insert_height(cls, v: float) -> float: @@ -228,6 +236,29 @@ class ToolShape(BaseModel): corner_radius: float = 0.0 # rectangle rx: float | None = None # ellipse semi-axes (circle when rx == ry) ry: float | None = None + # pocket depth in mm from the bin top; only meaningful for mode="add". + # None = the bin/placement default depth (single-level behaviour) + depth: float | None = None + + @field_validator("depth") + @classmethod + def validate_shape_depth(cls, v: float | None) -> float | None: + if v is not None and (v < 1 or v > 200): + raise ValueError("shape depth must be between 1 and 200mm") + return v + + +class ToolLevelPart(BaseModel): + """one connected component of a depth level's cross-section, tool space mm""" + points: list[Point] + interior_rings: list[list[Point]] = [] + + +class ToolLevel(BaseModel): + """materialized cross-section for one pocket depth. the pocket is the + union of each level extruded from the bin top down to its own depth.""" + depth: float | None = None # None = the default-depth group + parts: list[ToolLevelPart] class Tool(BaseModel): @@ -244,6 +275,9 @@ class Tool(BaseModel): # parametric shape source; when set, points/interior_rings are materialized from it shapes: list[ToolShape] | None = None clearance_override: float | None = None # mm; None = bin's cutout_clearance + spacing_override: float | None = None # mm; None = bin's tool_spacing + # materialized per-depth cross-sections; None unless a shape has a depth + levels: list[ToolLevel] | None = None class ToolSummary(BaseModel): @@ -257,6 +291,8 @@ class ToolSummary(BaseModel): smooth_level: float = 0.5 thumbnail_url: str | None = None parametric: bool = False + clearance_override: float | None = None + spacing_override: float | None = None class ToolUpdateRequest(BaseModel): @@ -269,6 +305,7 @@ class ToolUpdateRequest(BaseModel): # explicit null detaches a parametric tool to a plain polygon shapes: list[ToolShape] | None = None clearance_override: float | None = None + spacing_override: float | None = None class CreateToolRequest(BaseModel): diff --git a/backend/app/services/bin_service.py b/backend/app/services/bin_service.py index 48309d4..3fbca8c 100644 --- a/backend/app/services/bin_service.py +++ b/backend/app/services/bin_service.py @@ -1,5 +1,5 @@ import math -from app.models.schemas import Point, FingerHole +from app.models.schemas import Point, FingerHole, ToolLevel, ToolLevelPart def resolve_clearance(source_tool, bin_clearance: float) -> float: @@ -9,6 +9,65 @@ def resolve_clearance(source_tool, bin_clearance: float) -> float: return bin_clearance +def resolve_spacing(source_tool, bin_spacing: float) -> float: + """per-tool spacing override wins over the bin's global tool_spacing. + + spacing is a keep-out air gap beyond the cutout outline used when + arranging tools; it never changes pocket geometry. + """ + if source_tool is not None and source_tool.spacing_override is not None: + return source_tool.spacing_override + return bin_spacing + + +def _placement_transform(tool, pt): + """map library tool space into bin space using the placement's centroid + + rotation. returns a point mapper fn. the vertex-mean centroid is + rotation-invariant, so this reproduces the placed transform exactly.""" + n_placed = len(pt.points) + placed_cx = sum(p.x for p in pt.points) / n_placed + placed_cy = sum(p.y for p in pt.points) / n_placed + + n_lib = len(tool.points) + lib_cx = sum(p.x for p in tool.points) / n_lib + lib_cy = sum(p.y for p in tool.points) / n_lib + + rot = math.radians(pt.rotation) + cos_r, sin_r = math.cos(rot), math.sin(rot) + + def map_xy(x: float, y: float) -> tuple[float, float]: + rx = (x - lib_cx) * cos_r - (y - lib_cy) * sin_r + ry = (x - lib_cx) * sin_r + (y - lib_cy) * cos_r + return placed_cx + rx, placed_cy + ry + + return map_xy + + +def placed_levels(source_tool, pt) -> list[ToolLevel] | None: + """transform source_tool.levels into bin space with the same centroid + + rotation math sync_placed_tools uses for the footprint points""" + if source_tool is None or not source_tool.levels or not source_tool.points or not pt.points: + return None + map_xy = _placement_transform(source_tool, pt) + + def map_points(points): + return [Point(x=mx, y=my) for mx, my in (map_xy(p.x, p.y) for p in points)] + + return [ + ToolLevel( + depth=level.depth, + parts=[ + ToolLevelPart( + points=map_points(part.points), + interior_rings=[map_points(ring) for ring in part.interior_rings], + ) + for part in level.parts + ], + ) + for level in source_tool.levels + ] + + def sync_placed_tools(bin_data, user_tools) -> bool: """sync placed tools with their library versions. returns True if any changed.""" changed = False @@ -19,22 +78,12 @@ def sync_placed_tools(bin_data, user_tools) -> bool: if not tool or not tool.points: continue - n_placed = len(pt.points) - placed_cx = sum(p.x for p in pt.points) / n_placed - placed_cy = sum(p.y for p in pt.points) / n_placed - - n_lib = len(tool.points) - lib_cx = sum(p.x for p in tool.points) / n_lib - lib_cy = sum(p.y for p in tool.points) / n_lib - - rot = math.radians(pt.rotation) - cos_r, sin_r = math.cos(rot), math.sin(rot) + map_xy = _placement_transform(tool, pt) new_points = [] for p in tool.points: - rx = (p.x - lib_cx) * cos_r - (p.y - lib_cy) * sin_r - ry = (p.x - lib_cx) * sin_r + (p.y - lib_cy) * cos_r - new_points.append(Point(x=placed_cx + rx, y=placed_cy + ry)) + mx, my = map_xy(p.x, p.y) + new_points.append(Point(x=mx, y=my)) # preserve per-placement state (depth_override, etc.) by matching # source-tool holes to existing placed holes by id. without this, @@ -42,10 +91,9 @@ def sync_placed_tools(bin_data, user_tools) -> bool: existing_overrides = {fh.id: fh.depth_override for fh in pt.finger_holes} new_fh = [] for fh in tool.finger_holes: - rx = (fh.x - lib_cx) * cos_r - (fh.y - lib_cy) * sin_r - ry = (fh.x - lib_cx) * sin_r + (fh.y - lib_cy) * cos_r + mx, my = map_xy(fh.x, fh.y) new_fh.append(FingerHole( - id=fh.id, x=placed_cx + rx, y=placed_cy + ry, + id=fh.id, x=mx, y=my, radius=fh.radius, width=fh.width, height=fh.height, rotation=fh.rotation, shape=fh.shape, depth_override=existing_overrides.get(fh.id), @@ -55,9 +103,8 @@ def sync_placed_tools(bin_data, user_tools) -> bool: for ring in (tool.interior_rings or []): new_ring = [] for p in ring: - rx = (p.x - lib_cx) * cos_r - (p.y - lib_cy) * sin_r - ry = (p.x - lib_cx) * sin_r + (p.y - lib_cy) * cos_r - new_ring.append(Point(x=placed_cx + rx, y=placed_cy + ry)) + mx, my = map_xy(p.x, p.y) + new_ring.append(Point(x=mx, y=my)) new_rings.append(new_ring) if new_points != pt.points or new_fh != pt.finger_holes or new_rings != pt.interior_rings: diff --git a/backend/app/services/polygon_scaler.py b/backend/app/services/polygon_scaler.py index 9112f82..bf714a2 100644 --- a/backend/app/services/polygon_scaler.py +++ b/backend/app/services/polygon_scaler.py @@ -46,14 +46,30 @@ def __init__( self.depth_override = depth_override +class ScaledLevelPart: + """one connected component of a multi-level pocket cross-section, mm. + depth=None means the default-depth group (placement/bin depth applies).""" + def __init__( + self, + depth: float | None, + points_mm: list[tuple[float, float]], + interior_rings_mm: list[list[tuple[float, float]]] = None, + ): + self.depth = depth + self.points_mm = points_mm + self.interior_rings_mm = interior_rings_mm or [] + + class ScaledPolygon: - def __init__(self, id: str, points_mm: list[tuple[float, float]], label: str, finger_holes: list[ScaledFingerHole] = None, interior_rings_mm: list[list[tuple[float, float]]] = None, depth_override: float | None = None): + def __init__(self, id: str, points_mm: list[tuple[float, float]], label: str, finger_holes: list[ScaledFingerHole] = None, interior_rings_mm: list[list[tuple[float, float]]] = None, depth_override: float | None = None, levels: list[ScaledLevelPart] | None = None): self.id = id self.points_mm = points_mm self.label = label self.finger_holes = finger_holes or [] self.interior_rings_mm = interior_rings_mm or [] self.depth_override = depth_override + # when set, the pocket is cut per level part instead of the footprint + self.levels = levels class PolygonScaler: @@ -122,46 +138,82 @@ def scale_and_centre( return centered, finger_holes, interior_rings + def _buffer_rings( + self, + points_mm: list[tuple[float, float]], + interior_rings_mm: list[list[tuple[float, float]]], + clearance_mm: float, + ) -> tuple[list[tuple[float, float]], list[list[tuple[float, float]]]]: + """buffer one exterior+holes ring set outward; falls back to the input""" + shape = ShapelyPolygon(points_mm, holes=interior_rings_mm or []) + if not shape.is_valid: + shape = make_valid(shape) + + buffered = shape.buffer(clearance_mm, join_style=2) + + if buffered.geom_type == "Polygon": + coords = list(buffered.exterior.coords)[:-1] + holes = [list(interior.coords)[:-1] for interior in buffered.interiors] + return coords, holes + return points_mm, interior_rings_mm + def add_clearance(self, polygon: ScaledPolygon, clearance_mm: float) -> ScaledPolygon: - """expand polygon outward by clearance amount""" + """expand polygon outward by clearance amount. level parts get the + same clearance per side; buffered adjacent levels overlapping slightly + is harmless -- the deeper prism wins in the union of cutters.""" if clearance_mm <= 0: return polygon try: - shape = ShapelyPolygon(polygon.points_mm, holes=polygon.interior_rings_mm or []) - if not shape.is_valid: - shape = make_valid(shape) - - buffered = shape.buffer(clearance_mm, join_style=2) + coords, holes = self._buffer_rings(polygon.points_mm, polygon.interior_rings_mm, clearance_mm) - if buffered.geom_type == "Polygon": - coords = list(buffered.exterior.coords)[:-1] - holes = [list(interior.coords)[:-1] for interior in buffered.interiors] - else: - coords = polygon.points_mm - holes = polygon.interior_rings_mm + levels = None + if polygon.levels: + levels = [] + for part in polygon.levels: + p_coords, p_holes = self._buffer_rings(part.points_mm, part.interior_rings_mm, clearance_mm) + levels.append(ScaledLevelPart(part.depth, p_coords, p_holes)) - return ScaledPolygon(polygon.id, coords, polygon.label, polygon.finger_holes, holes, depth_override=polygon.depth_override) + return ScaledPolygon(polygon.id, coords, polygon.label, polygon.finger_holes, holes, depth_override=polygon.depth_override, levels=levels) except Exception: return polygon + def _simplify_rings( + self, + points_mm: list[tuple[float, float]], + interior_rings_mm: list[list[tuple[float, float]]], + tolerance_mm: float, + ) -> tuple[list[tuple[float, float]], list[list[tuple[float, float]]]]: + """Douglas-Peucker one exterior+holes ring set; falls back to the input""" + shape = ShapelyPolygon(points_mm, holes=interior_rings_mm or []) + if not shape.is_valid: + shape = make_valid(shape) + + simplified = shape.simplify(tolerance_mm, preserve_topology=True) + + if simplified.geom_type == "Polygon" and len(simplified.exterior.coords) >= 4: + coords = list(simplified.exterior.coords)[:-1] + holes = [list(interior.coords)[:-1] for interior in simplified.interiors] + return coords, holes + return points_mm, interior_rings_mm + def simplify(self, polygon: ScaledPolygon, tolerance_mm: float = 0.3) -> ScaledPolygon: """reduce vertex count via Douglas-Peucker. big speedup for CSG.""" - if len(polygon.points_mm) <= 8 and not polygon.interior_rings_mm: + if len(polygon.points_mm) <= 8 and not polygon.interior_rings_mm and not polygon.levels: return polygon try: - shape = ShapelyPolygon(polygon.points_mm, holes=polygon.interior_rings_mm or []) - if not shape.is_valid: - shape = make_valid(shape) + coords, holes = self._simplify_rings(polygon.points_mm, polygon.interior_rings_mm, tolerance_mm) - simplified = shape.simplify(tolerance_mm, preserve_topology=True) + levels = None + if polygon.levels: + levels = [] + for part in polygon.levels: + p_coords, p_holes = self._simplify_rings(part.points_mm, part.interior_rings_mm, tolerance_mm) + levels.append(ScaledLevelPart(part.depth, p_coords, p_holes)) - if simplified.geom_type == "Polygon" and len(simplified.exterior.coords) >= 4: - coords = list(simplified.exterior.coords)[:-1] - holes = [list(interior.coords)[:-1] for interior in simplified.interiors] - return ScaledPolygon(polygon.id, coords, polygon.label, polygon.finger_holes, holes, depth_override=polygon.depth_override) + return ScaledPolygon(polygon.id, coords, polygon.label, polygon.finger_holes, holes, depth_override=polygon.depth_override, levels=levels) except Exception: pass diff --git a/backend/app/services/shape_compiler.py b/backend/app/services/shape_compiler.py index e372582..a6104e9 100644 --- a/backend/app/services/shape_compiler.py +++ b/backend/app/services/shape_compiler.py @@ -12,7 +12,7 @@ from shapely.ops import unary_union from shapely.validation import make_valid -from app.models.schemas import Point, ToolShape +from app.models.schemas import Point, ToolLevel, ToolLevelPart, ToolShape # max chord deviation when approximating curves with segments. 0.05mm keeps a # 33mm circle visually round and survives the 0.05mm collinear-cleanup simplify. @@ -59,15 +59,34 @@ def _ring_points(coords) -> list[Point]: return [Point(x=c[0], y=c[1]) for c in list(coords)[:-1]] +def _level_parts(geom) -> list[ToolLevelPart]: + """split a (Multi)Polygon into ToolLevelParts, one per connected component""" + polys = list(geom.geoms) if geom.geom_type == "MultiPolygon" else [geom] + parts = [] + for p in polys: + if p.is_empty or p.area < 1e-6: + continue + parts.append(ToolLevelPart( + points=_ring_points(p.exterior.coords), + interior_rings=[_ring_points(i.coords) for i in p.interiors], + )) + return parts + + def compile_shapes( shapes: list[ToolShape], -) -> tuple[list[Point], list[list[Point]], tuple[float, float]]: +) -> tuple[list[Point], list[list[Point]], tuple[float, float], list[ToolLevel] | None]: """union all additive shapes, subtract all subtractive ones, recentre on the bounding-box midpoint (the convention every Tool consumer assumes). - Returns (points, interior_rings, (cx, cy)) where (cx, cy) is the offset that - was subtracted -- callers must shift stored shape positions by the same - amount so shapes and materialized points stay congruent. + Returns (points, interior_rings, (cx, cy), levels) where (cx, cy) is the + offset that was subtracted -- callers must shift stored shape positions by + the same amount so shapes and materialized points stay congruent. + + levels is None unless at least one add-shape has a depth: then adds are + grouped by depth (None = default group), every group has ALL subtracts + carved out, and union(levels) == the footprint exactly. The pocket becomes + the union of each level extruded to its own depth. Raises ValueError with a user-facing message on invalid input. """ @@ -75,15 +94,17 @@ def compile_shapes( if s.type == "line" and s.mode != "guide": raise ValueError("lines can only be guides") - adds = [_shape_geometry(s) for s in shapes if s.mode == "add"] + add_shapes = [s for s in shapes if s.mode == "add"] + adds = [_shape_geometry(s) for s in add_shapes] subs = [_shape_geometry(s) for s in shapes if s.mode == "subtract"] if not adds: raise ValueError("design needs at least one solid (additive) shape") + sub_union = unary_union(subs) if subs else None result = unary_union(adds) - if subs: - result = result.difference(unary_union(subs)) + if sub_union is not None: + result = result.difference(sub_union) if not result.is_valid: result = make_valid(result) @@ -104,7 +125,29 @@ def compile_shapes( points = _ring_points(result.exterior.coords) interior_rings = [_ring_points(interior.coords) for interior in result.interiors] - return points, interior_rings, (cx, cy) + + levels = None + if any(s.depth is not None for s in add_shapes): + groups: dict[float | None, list] = {} + for s, geom in zip(add_shapes, adds): + groups.setdefault(s.depth, []).append(geom) + levels = [] + for depth, geoms in groups.items(): + level_geom = unary_union(geoms) + if sub_union is not None: + level_geom = level_geom.difference(sub_union) + if not level_geom.is_valid: + level_geom = make_valid(level_geom) + if level_geom.is_empty or level_geom.area < 1e-6: + continue + level_geom = affinity.translate(level_geom, -cx, -cy) + parts = _level_parts(level_geom) + if parts: + levels.append(ToolLevel(depth=depth, parts=parts)) + if not levels: + levels = None + + return points, interior_rings, (cx, cy), levels def recentre_shapes(shapes: list[ToolShape], offset: tuple[float, float]) -> list[ToolShape]: diff --git a/backend/app/services/stl_generator_manifold.py b/backend/app/services/stl_generator_manifold.py index d159bc7..65655a6 100644 --- a/backend/app/services/stl_generator_manifold.py +++ b/backend/app/services/stl_generator_manifold.py @@ -190,6 +190,20 @@ def _resolve_pocket_depth(override: float | None, config, max_depth: float) -> f return max(5, min(base, max_depth)) +def _resolve_level_depth( + part_depth: float | None, + placement_override: float | None, + config, + max_depth: float, +) -> float: + """Per-level pocket depth: an explicit level depth is absolute (the + placement override does not scale or offset it); the default-depth group + falls back to the placement override, then the bin's cutout_depth. + insert_height and the [5, max_depth] clamp apply uniformly.""" + override = part_depth if part_depth is not None else placement_override + return _resolve_pocket_depth(override, config, max_depth) + + def _magnet_inset(config) -> float | None: """Per-side magnet inset from cell centre. Clamps to fit the smaller of the X/Y cells with ~1mm clearance to the cell edge. Returns None when the @@ -299,6 +313,38 @@ def _shapely_to_cross_sections(shifted_pts: list[tuple], interior_rings: list[li return rings +def _shift_rings(points, holes, offset_x: float, offset_y: float): + """apply the bin-centre shift and the Y-axis flip (SVG Y-down → manifold + Y-up) to one exterior + holes ring set""" + shifted = [(p[0] + offset_x, -(p[1] + offset_y)) for p in points] + shifted_holes = [] + for hole in (holes or []): + sh = [(p[0] + offset_x, -(p[1] + offset_y)) for p in hole] + if len(sh) >= 3: + shifted_holes.append(sh) + return shifted, shifted_holes + + +def _build_cross_section(shifted, shifted_holes): + """repair via Shapely and build a CrossSection; None when degenerate""" + import manifold3d as mf + + rings = _shapely_to_cross_sections(shifted, shifted_holes) + if not rings: + return None + has_holes = len(rings) > 1 + if has_holes: + # use EvenOdd to handle holes — same pattern as text labels + cs = mf.CrossSection(rings, mf.FillRule.EvenOdd) + else: + cs = mf.CrossSection(rings) + if cs.area() <= 0: + cs = mf.CrossSection([r[::-1] for r in rings], mf.FillRule.EvenOdd if has_holes else mf.FillRule.Positive) + if cs.area() <= 0: + return None + return cs + + def _make_polygon_cutouts( polygons: list[ScaledPolygon], config: GenerateRequest, @@ -307,44 +353,45 @@ def _make_polygon_cutouts( offset_x: float, offset_y: float, ): - """Batch union of all polygon cutout extrusions.""" + """Batch union of all polygon cutout extrusions. + + Multi-level polygons cut one prism per level part instead of the + footprint; every prism opens at the bin top, so the union forms a + stepped pocket with no overhangs by construction.""" import manifold3d as mf cutters = [] for poly in polygons: - shifted = [ - (p[0] + offset_x, -(p[1] + offset_y)) - for p in poly.points_mm - ] + if poly.levels: + for part in poly.levels: + shifted, shifted_holes = _shift_rings(part.points_mm, part.interior_rings_mm, offset_x, offset_y) + if len(shifted) < 3: + continue + try: + cs = _build_cross_section(shifted, shifted_holes) + if cs is None: + continue + pocket_depth = _resolve_level_depth(part.depth, poly.depth_override, config, max_depth) + cutter = mf.Manifold.extrude(cs, pocket_depth + 0.01).translate( + (0.0, 0.0, wall_top_z - pocket_depth) + ) + cutters.append(cutter) + except Exception as e: + logger.warning("level cutout failed: %s", e) + continue + + shifted, shifted_holes = _shift_rings(poly.points_mm, poly.interior_rings_mm, offset_x, offset_y) if len(shifted) < 3: continue - # shift interior rings the same way - shifted_holes = [] - for hole in (poly.interior_rings_mm or []): - shifted_hole = [ - (p[0] + offset_x, -(p[1] + offset_y)) - for p in hole - ] - if len(shifted_hole) >= 3: - shifted_holes.append(shifted_hole) try: - rings = _shapely_to_cross_sections(shifted, shifted_holes) - if not rings: + cs = _build_cross_section(shifted, shifted_holes) + if cs is None: continue - has_holes = len(rings) > 1 - if has_holes: - # use EvenOdd to handle holes — same pattern as text labels - cs = mf.CrossSection(rings, mf.FillRule.EvenOdd) - else: - cs = mf.CrossSection(rings) - if cs.area() <= 0: - cs = mf.CrossSection([r[::-1] for r in rings], mf.FillRule.EvenOdd if has_holes else mf.FillRule.Positive) - if cs.area() > 0: - pocket_depth = _resolve_pocket_depth(poly.depth_override, config, max_depth) - cutter = mf.Manifold.extrude(cs, pocket_depth + 0.01).translate( - (0.0, 0.0, wall_top_z - pocket_depth) - ) - cutters.append(cutter) + pocket_depth = _resolve_pocket_depth(poly.depth_override, config, max_depth) + cutter = mf.Manifold.extrude(cs, pocket_depth + 0.01).translate( + (0.0, 0.0, wall_top_z - pocket_depth) + ) + cutters.append(cutter) except Exception as e: logger.warning("polygon cutout failed: %s", e) @@ -387,7 +434,16 @@ def _make_chamfer_cutouts( if len(sh) >= 3: shifted_holes.append(sh) - pocket_depth = _resolve_pocket_depth(poly.depth_override, config, max_depth) + # the chamfer cuts on the footprint top edge; clamp to the shallowest + # level so it cannot punch through the floor of a shallow level whose + # edge lies on the outline + if poly.levels: + pocket_depth = min( + _resolve_level_depth(part.depth, poly.depth_override, config, max_depth) + for part in poly.levels + ) + else: + pocket_depth = _resolve_pocket_depth(poly.depth_override, config, max_depth) eff_chamfer = min(chamfer_size, max(0.0, pocket_depth - 1)) if eff_chamfer <= 0: continue diff --git a/backend/tests/test_shape_compiler.py b/backend/tests/test_shape_compiler.py index c09c967..38d7e14 100644 --- a/backend/tests/test_shape_compiler.py +++ b/backend/tests/test_shape_compiler.py @@ -1,4 +1,4 @@ -"""Tests for parametric shape materialization in shape_compiler.""" +"""Tests for parametric shape materialization in shape_compiler.""" import math import pytest @@ -27,7 +27,7 @@ def bbox(points): class TestPrimitives: def test_circle_resolution(self): # a 33mm-diameter circle must stay genuinely round - points, rings, _ = compile_shapes([circle(r=16.5)]) + points, rings, _, _ = compile_shapes([circle(r=16.5)]) assert len(points) >= 40 assert rings == [] # every vertex on the radius within chord tolerance @@ -35,18 +35,18 @@ def test_circle_resolution(self): assert math.hypot(p.x, p.y) == pytest.approx(16.5, abs=0.06) def test_rectangle_exact(self): - points, _, _ = compile_shapes([rect(w=80, h=30)]) + points, _, _, _ = compile_shapes([rect(w=80, h=30)]) minx, miny, maxx, maxy = bbox(points) assert (maxx - minx) == pytest.approx(80) assert (maxy - miny) == pytest.approx(30) def test_rotated_rounded_rect_valid(self): - points, rings, _ = compile_shapes([rect(w=50, h=20, rotation=30, corner_radius=5)]) + points, rings, _, _ = compile_shapes([rect(w=50, h=20, rotation=30, corner_radius=5)]) assert len(points) >= 4 assert rings == [] def test_result_centered_at_origin(self): - points, _, offset = compile_shapes([rect(x=100, y=-50, w=40, h=20)]) + points, _, offset, _ = compile_shapes([rect(x=100, y=-50, w=40, h=20)]) minx, miny, maxx, maxy = bbox(points) assert (minx + maxx) / 2 == pytest.approx(0, abs=1e-6) assert (miny + maxy) / 2 == pytest.approx(0, abs=1e-6) @@ -55,7 +55,7 @@ def test_result_centered_at_origin(self): class TestBooleans: def test_overlapping_adds_union_to_single_ring(self): - points, rings, _ = compile_shapes([ + points, rings, _, _ = compile_shapes([ rect(id="a", w=40, h=40), rect(id="b", x=30, w=40, h=40), ]) @@ -64,7 +64,7 @@ def test_overlapping_adds_union_to_single_ring(self): assert rings == [] def test_subtract_inside_makes_interior_ring(self): - points, rings, _ = compile_shapes([ + points, rings, _, _ = compile_shapes([ rect(w=80, h=30), circle(mode="subtract", r=5), ]) @@ -96,7 +96,7 @@ def test_no_additive_shapes_rejected(self): class TestGuides: def test_guides_excluded_from_outline(self): - points, rings, _ = compile_shapes([ + points, rings, _, _ = compile_shapes([ rect(w=40, h=40), circle(id="g", mode="guide", x=20, y=20, r=30), ]) @@ -107,7 +107,7 @@ def test_guides_excluded_from_outline(self): def test_guide_line_allowed(self): line = ToolShape(id="l", type="line", mode="guide", width=100) - points, _, _ = compile_shapes([rect(), line]) + points, _, _, _ = compile_shapes([rect(), line]) assert len(points) >= 4 def test_solid_line_rejected(self): @@ -119,15 +119,15 @@ def test_solid_line_rejected(self): class TestRecentre: def test_shapes_shifted_by_offset(self): shapes = [rect(x=100, y=-50)] - _, _, offset = compile_shapes(shapes) + _, _, offset, _ = compile_shapes(shapes) shifted = recentre_shapes(shapes, offset) assert shifted[0].x == pytest.approx(0) assert shifted[0].y == pytest.approx(0) def test_recompiling_recentred_shapes_is_stable(self): shapes = [rect(x=12.5, w=40, h=20), circle(x=30, r=10)] - points1, _, offset = compile_shapes(shapes) + points1, _, offset, _ = compile_shapes(shapes) shifted = recentre_shapes(shapes, offset) - points2, _, offset2 = compile_shapes(shifted) + points2, _, offset2, _ = compile_shapes(shifted) assert offset2 == pytest.approx((0, 0), abs=1e-9) assert len(points1) == len(points2) diff --git a/backend/tests/test_shape_depth_levels.py b/backend/tests/test_shape_depth_levels.py new file mode 100644 index 0000000..3cefc3e --- /dev/null +++ b/backend/tests/test_shape_depth_levels.py @@ -0,0 +1,277 @@ +"""Tests for per-shape depth: compile-time level grouping, placement +transform, depth resolution and the multi-level pocket cutters.""" +import math + +import pytest + +from app.models.schemas import ( + BinConfig, + BinModel, + BinParams, + PlacedTool, + Point, + Tool, + ToolLevel, + ToolLevelPart, + ToolShape, +) +from app.services.bin_service import placed_levels +from app.services.polygon_scaler import PolygonScaler, ScaledLevelPart, ScaledPolygon +from app.services.shape_compiler import compile_shapes +from app.services.stl_generator_manifold import ( + _make_polygon_cutouts, + _resolve_level_depth, +) + + +def rect(id="r1", mode="add", x=0.0, y=0.0, w=40.0, h=40.0, depth=None): + return ToolShape(id=id, type="rectangle", mode=mode, x=x, y=y, width=w, height=h, depth=depth) + + +def circle(id="c1", mode="add", x=0.0, y=0.0, r=16.5, depth=None): + return ToolShape(id=id, type="ellipse", mode=mode, x=x, y=y, rx=r, ry=r, depth=depth) + + +def ring_area(points): + """shoelace area of a Point ring""" + n = len(points) + s = 0.0 + for i in range(n): + a, b = points[i], points[(i + 1) % n] + s += a.x * b.y - b.x * a.y + return abs(s) / 2 + + +def part_area(part): + return ring_area(part.points) - sum(ring_area(r) for r in part.interior_rings) + + +class TestCompileLevels: + def test_concentric_circles_form_two_levels(self): + # the C7 bulb: wide shallow recess over a narrow deep hole + points, rings, _, levels = compile_shapes([ + circle(id="wide", r=11, depth=10), + circle(id="narrow", r=7.5, depth=30), + ]) + # footprint = the wide circle + assert rings == [] + assert max(math.hypot(p.x, p.y) for p in points) == pytest.approx(11, abs=0.06) + + assert levels is not None + by_depth = {lv.depth: lv for lv in levels} + assert set(by_depth) == {10, 30} + assert len(by_depth[10].parts) == 1 + assert len(by_depth[30].parts) == 1 + assert part_area(by_depth[10].parts[0]) == pytest.approx(math.pi * 11**2, rel=0.01) + assert part_area(by_depth[30].parts[0]) == pytest.approx(math.pi * 7.5**2, rel=0.01) + + def test_no_depths_means_no_levels(self): + _, _, _, levels = compile_shapes([circle(r=10), rect(x=5, w=20, h=20)]) + assert levels is None + + def test_mixed_depth_and_default_groups(self): + _, _, _, levels = compile_shapes([ + rect(id="base", w=80, h=30), + rect(id="deep", w=20, h=10, depth=25), + ]) + assert levels is not None + assert {lv.depth for lv in levels} == {None, 25} + + def test_subtract_carves_every_level(self): + # subtract sits inside the deep rect and spans its full height + _, _, _, levels = compile_shapes([ + rect(id="base", w=80, h=30), + rect(id="deep", w=60, h=10, depth=20), + rect(id="cut", mode="subtract", w=5, h=12), + ]) + deep = next(lv for lv in levels if lv.depth == 20) + total = sum(part_area(p) for p in deep.parts) + assert total == pytest.approx(60 * 10 - 5 * 10, rel=0.01) + + def test_subtract_splitting_one_level_yields_parts(self): + # the cut splits the deep rect in two but the footprint stays connected + points, _, _, levels = compile_shapes([ + rect(id="base", w=80, h=30), + rect(id="deep", w=60, h=10, depth=20), + rect(id="cut", mode="subtract", w=5, h=12), + ]) + deep = next(lv for lv in levels if lv.depth == 20) + assert len(deep.parts) == 2 + + def test_levels_recentred_with_footprint(self): + _, _, offset, levels = compile_shapes([ + circle(id="wide", x=100, y=-50, r=11, depth=10), + circle(id="narrow", x=100, y=-50, r=7.5, depth=30), + ]) + assert offset == pytest.approx((100, -50)) + for lv in levels: + for part in lv.parts: + xs = [p.x for p in part.points] + ys = [p.y for p in part.points] + assert (min(xs) + max(xs)) / 2 == pytest.approx(0, abs=0.1) + assert (min(ys) + max(ys)) / 2 == pytest.approx(0, abs=0.1) + + def test_depth_validator_bounds(self): + with pytest.raises(ValueError): + ToolShape(id="bad", type="ellipse", rx=5, ry=5, depth=0.5) + with pytest.raises(ValueError): + ToolShape(id="bad", type="ellipse", rx=5, ry=5, depth=201) + + +class TestResolveLevelDepth: + def test_level_depth_is_absolute(self): + bp = BinParams(cutout_depth=20) + assert _resolve_level_depth(10, 25, bp, max_depth=100) == 10.0 + + def test_default_group_falls_back_to_placement_override(self): + bp = BinParams(cutout_depth=20) + assert _resolve_level_depth(None, 25, bp, max_depth=100) == 25.0 + + def test_default_group_falls_back_to_global(self): + bp = BinParams(cutout_depth=20) + assert _resolve_level_depth(None, None, bp, max_depth=100) == 20.0 + + def test_insert_height_added(self): + bp = BinParams(cutout_depth=20, insert_enabled=True, insert_height=2.5) + assert _resolve_level_depth(10, None, bp, max_depth=100) == 12.5 + + def test_clamped_to_min_and_max(self): + bp = BinParams(cutout_depth=20) + assert _resolve_level_depth(2, None, bp, max_depth=100) == 5.0 + assert _resolve_level_depth(150, None, bp, max_depth=100) == 100.0 + + +class _Store: + def __init__(self, items): + self._d = {item.id: item for item in items} + + def get(self, key): + return self._d.get(key) + + +def _square(x0, y0, size): + return [Point(x=x0, y=y0), Point(x=x0 + size, y=y0), Point(x=x0 + size, y=y0 + size), Point(x=x0, y=y0 + size)] + + +class TestPlacedLevels: + def _tool_with_level(self): + return Tool( + id="tool1", + name="bulb", + points=_square(0, 0, 10), + levels=[ToolLevel(depth=12, parts=[ToolLevelPart(points=_square(0, 0, 10))])], + ) + + def test_rotation_matches_sync_transform(self): + tool = self._tool_with_level() + # marker level point so the rotation is observable + tool.levels[0].parts[0].points = [Point(x=10, y=5), Point(x=10, y=0), Point(x=0, y=0), Point(x=0, y=5)] + placed = PlacedTool( + id="p1", tool_id="tool1", name="bulb", + points=_square(20, 20, 10), rotation=90, + ) + levels = placed_levels(tool, placed) + # lib centroid (5,5), placed centroid (25,25); (10,5) -> 90deg -> + # rx = -(y-cy) = 0, ry = (x-cx) = 5 -> (25, 30) + p = levels[0].parts[0].points[0] + assert (p.x, p.y) == (pytest.approx(25), pytest.approx(30)) + assert levels[0].depth == 12 + + def test_no_levels_returns_none(self): + tool = Tool(id="t", name="flat", points=_square(0, 0, 10)) + placed = PlacedTool(id="p1", tool_id="t", name="flat", points=_square(20, 20, 10), rotation=0) + assert placed_levels(tool, placed) is None + + def test_translation_only(self): + tool = self._tool_with_level() + placed = PlacedTool(id="p1", tool_id="tool1", name="bulb", points=_square(30, 40, 10), rotation=0) + levels = placed_levels(tool, placed) + xs = [p.x for p in levels[0].parts[0].points] + ys = [p.y for p in levels[0].parts[0].points] + assert min(xs) == pytest.approx(30) + assert min(ys) == pytest.approx(40) + + +class TestLevelCutters: + def test_volume_matches_sum_of_level_prisms(self): + bp = BinParams(cutout_depth=20) + poly = ScaledPolygon( + "p1", + [(0.0, 0.0), (30.0, 0.0), (30.0, 30.0), (0.0, 30.0)], + "test", + levels=[ + ScaledLevelPart(8, [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]), + ScaledLevelPart(15, [(20.0, 0.0), (25.0, 0.0), (25.0, 5.0), (20.0, 5.0)]), + ], + ) + cutter = _make_polygon_cutouts([poly], bp, wall_top_z=30, max_depth=100, offset_x=0, offset_y=0) + assert cutter is not None + # the footprint (30x30) must NOT be cut -- only the two level prisms + assert cutter.volume() == pytest.approx(100 * 8 + 25 * 15, rel=0.02) + + def test_default_level_uses_placement_override(self): + bp = BinParams(cutout_depth=20) + poly = ScaledPolygon( + "p1", + [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)], + "test", + depth_override=25, + levels=[ScaledLevelPart(None, [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)])], + ) + cutter = _make_polygon_cutouts([poly], bp, wall_top_z=30, max_depth=100, offset_x=0, offset_y=0) + assert cutter.volume() == pytest.approx(100 * 25, rel=0.02) + + +class TestScalerCarriesLevels: + def _poly(self): + return ScaledPolygon( + "p1", + [(0.0, 0.0), (20.0, 0.0), (20.0, 20.0), (0.0, 20.0)], + "test", + levels=[ScaledLevelPart(10, [(5.0, 5.0), (15.0, 5.0), (15.0, 15.0), (5.0, 15.0)])], + ) + + def test_add_clearance_buffers_each_level(self): + scaler = PolygonScaler() + out = scaler.add_clearance(self._poly(), 1.0) + xs = [p[0] for p in out.levels[0].points_mm] + assert max(xs) - min(xs) == pytest.approx(12.0) + assert out.levels[0].depth == 10 + + def test_simplify_keeps_levels(self): + scaler = PolygonScaler() + out = scaler.simplify(self._poly(), tolerance_mm=0.05) + assert out.levels is not None + assert out.levels[0].depth == 10 + + def test_zero_clearance_passthrough(self): + scaler = PolygonScaler() + out = scaler.add_clearance(self._poly(), 0.0) + assert out.levels is not None + + +class TestLevelsRoundTrip: + def test_tool_with_levels_survives_dump_and_validate(self): + tool = Tool( + id="t", + name="bulb", + points=_square(0, 0, 10), + levels=[ToolLevel(depth=12, parts=[ToolLevelPart(points=_square(0, 0, 10))])], + ) + loaded = Tool.model_validate(tool.model_dump()) + assert loaded.levels[0].depth == 12 + assert len(loaded.levels[0].parts) == 1 + + def test_missing_levels_key_loads_as_none(self): + data = Tool(id="t", name="flat", points=_square(0, 0, 10)).model_dump() + del data["levels"] + assert Tool.model_validate(data).levels is None + + def test_bin_with_legacy_placed_tools_loads(self): + bin_data = BinModel( + id="b1", + bin_config=BinConfig(), + placed_tools=[PlacedTool(id="p1", tool_id="t", name="flat", points=_square(0, 0, 10))], + ) + loaded = BinModel.model_validate(bin_data.model_dump()) + assert loaded.placed_tools[0].points == bin_data.placed_tools[0].points diff --git a/backend/tests/test_tool_spacing.py b/backend/tests/test_tool_spacing.py new file mode 100644 index 0000000..78a99cb --- /dev/null +++ b/backend/tests/test_tool_spacing.py @@ -0,0 +1,64 @@ +"""Tests for per-tool spacing override resolution and the bin tool_spacing field.""" +import pytest +from pydantic import ValidationError + +from app.models.schemas import BinParams, Tool, Point +from app.services.bin_service import resolve_spacing + + +def make_tool(spacing_override=None): + return Tool( + id="t1", + name="test", + points=[Point(x=0, y=0), Point(x=10, y=0), Point(x=10, y=10), Point(x=0, y=10)], + spacing_override=spacing_override, + ) + + +class TestResolveSpacing: + def test_no_tool_uses_bin_default(self): + assert resolve_spacing(None, 2.0) == 2.0 + + def test_no_override_uses_bin_default(self): + assert resolve_spacing(make_tool(), 2.0) == 2.0 + + def test_override_takes_precedence(self): + assert resolve_spacing(make_tool(spacing_override=3.25), 0.0) == 3.25 + + def test_zero_override_means_no_extra_keepout(self): + assert resolve_spacing(make_tool(spacing_override=0.0), 2.0) == 0.0 + + +class TestToolSpacingValidator: + def test_defaults_to_zero(self): + assert BinParams().tool_spacing == 0.0 + + def test_accepts_bounds(self): + assert BinParams(tool_spacing=0.0).tool_spacing == 0.0 + assert BinParams(tool_spacing=20.0).tool_spacing == 20.0 + + def test_rejects_negative(self): + with pytest.raises(ValidationError): + BinParams(tool_spacing=-1.0) + + def test_rejects_too_large(self): + with pytest.raises(ValidationError): + BinParams(tool_spacing=21.0) + + +class TestSpacingRoundTrip: + def test_override_survives_dump_and_validate(self): + tool = make_tool(spacing_override=3.25) + loaded = Tool.model_validate(tool.model_dump()) + assert loaded.spacing_override == 3.25 + + def test_missing_key_defaults_to_none(self): + """tools.json written before the field existed must load unchanged""" + data = make_tool().model_dump() + del data["spacing_override"] + assert Tool.model_validate(data).spacing_override is None + + def test_bin_params_missing_key_defaults_to_zero(self): + data = BinParams().model_dump() + del data["tool_spacing"] + assert BinParams.model_validate(data).tool_spacing == 0.0 diff --git a/docs/api.md b/docs/api.md index 732b784..cee2d3f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -19,13 +19,18 @@ - `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. + polygon. `clearance_override` (mm) beats the bin's `cutout_clearance` during generation; + `spacing_override` (mm) beats the bin's `tool_spacing` when the frontend auto-arranges + (keep-out air gap only — never changes pocket geometry). - `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`. +result on the bounding-box midpoint. Add-shapes may carry `depth` (mm from the bin top); +compiling then also materializes `Tool.levels`, which the generator cuts as one prism per +level for stepped pockets (see docs/stl-generation.md). See +`backend/app/services/shape_compiler.py`. ## Bins - `GET /api/bins` - list bins diff --git a/docs/architecture.md b/docs/architecture.md index 7c70439..dd21fd3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,8 +86,8 @@ tracefinity/ ## Data Model -- **Tool**: a single traced polygon + finger holes, stored in mm, centred at origin. Lives in a persistent library (`tools.json`). -- **PlacedTool**: a positioned copy of a tool in a bin. Points/holes in bin-space mm. Has `tool_id` linking back to source. +- **Tool**: a single traced polygon + finger holes, stored in mm, centred at origin. Lives in a persistent library (`tools.json`). Parametric tools also store `shapes` (and `levels` when any add-shape has a depth); `clearance_override`/`spacing_override` beat the bin's `cutout_clearance`/`tool_spacing`. +- **PlacedTool**: a positioned copy of a tool in a bin. Points/holes in bin-space mm. Has `tool_id` linking back to source. Never stores levels — those are re-derived from the library tool at generate time (`bin_service.placed_levels()`). - **Bin**: bin config + placed tools + text labels. Used for STL generation (`bins.json`). - **Session**: ephemeral, used only for upload/trace workflow. Output is tools saved to library via `save-tools`. diff --git a/docs/stl-generation.md b/docs/stl-generation.md index 848caeb..3952cc1 100644 --- a/docs/stl-generation.md +++ b/docs/stl-generation.md @@ -4,6 +4,16 @@ STL generation uses manifold3d (mesh booleans, 10-100x faster than OCCT B-rep). The gridfinity shell is constructed from first principles using `CrossSection` extrusions and `batch_boolean` operations. Polygon cutouts, finger holes, magnet holes and text labels are subtracted from the bin body in one pass. +## Multi-level pockets (per-shape depth) + +A parametric tool whose add-shapes carry a `depth` materializes `Tool.levels`: adds grouped by depth value (`None` = the default group), every subtract carved out of every level, so `union(levels) == footprint`. At generate time the levels are transformed into bin space with the same centroid+rotation math `sync_placed_tools` uses (placements never store levels), and the generator cuts **one straight prism per level part**, each opening at the bin top — the union forms a stepped pocket with no overhangs by construction. + +- An explicit level depth is **absolute**; the placement `depth_override` only applies to the default-depth group. `insert_height` and the `[5, max_depth]` clamp apply uniformly. +- Clearance buffers each level part the same as the footprint; overlapping buffered levels are harmless (the deeper prism wins in the union). +- The chamfer stays on the footprint top edge, clamped to `shallowest_level_depth - 1`. +- Known limitations: the contrast insert and text labels still use the flat footprint/default depth, not per-level floors. +- `generate` re-syncs placements first and hashes `Tool.levels`, so depth-only edits invalidate the STL cache. + ## Z-Axis Reference Heights - **Base top**: 4.75mm (three tapered layers: 2.15 + 1.8 + 0.8). Infill starts here. diff --git a/frontend/src/app/bins/[id]/page.tsx b/frontend/src/app/bins/[id]/page.tsx index e5dbd56..9d4d5d1 100644 --- a/frontend/src/app/bins/[id]/page.tsx +++ b/frontend/src/app/bins/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect, useRef, useCallback } from 'react' +import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useRouter, useParams } from 'next/navigation' import { BinEditor } from '@/components/BinEditor' import { BinConfigurator, calcMaxCutoutDepth } from '@/components/BinConfigurator' @@ -10,7 +10,7 @@ import { getBin, updateBin, generateBinStl, getBinStlUrl, getBinZipUrl, getBinTh import { getSettings, saveSettings } from '@/lib/settings' import type { BinConfig, BinData, PlacedTool, TextLabel } from '@/types' import { Download, Loader2, Package, ChevronDown, Check, LayoutGrid, RotateCw, Sparkles } from 'lucide-react' -import { arrangeTools } from '@/lib/packing' +import { arrangeTools, type ToolPadInfo } from '@/lib/packing' import { Breadcrumb } from '@/components/Breadcrumb' import { Alert } from '@/components/Alert' import { useDebouncedSave } from '@/hooks/useDebouncedSave' @@ -41,6 +41,7 @@ function defaultConfig(): BinConfig { cutout_depth: 20, cutout_clearance: 1.0, cutout_chamfer: 0, + tool_spacing: 0, insert_enabled: false, insert_height: 1.0, text_labels: [], @@ -85,6 +86,7 @@ export default function BinPage() { const doGenerateRef = useRef<() => void>(() => {}) const [smoothedToolIds, setSmoothedToolIds] = useState>(new Set()) const [smoothLevels, setSmoothLevels] = useState>(new Map()) + const [toolInfo, setToolInfo] = useState>(new Map()) const smoothLevelTimerRef = useRef(null) const [autoSize, setAutoSize] = useState(true) @@ -159,6 +161,10 @@ export default function BinPage() { setConfig(withGridDefaults(data.bin_config)) setSmoothedToolIds(new Set(tools.filter(t => t.smoothed).map(t => t.id))) setSmoothLevels(new Map(tools.map(t => [t.id, t.smooth_level]))) + setToolInfo(new Map(tools.map(t => [t.id, { + clearance: t.clearance_override ?? null, + spacing: t.spacing_override ?? null, + }]))) } catch { setError('Bin not found') } finally { @@ -265,7 +271,15 @@ export default function BinPage() { maxY = Math.max(maxY, p.y) } } - const halfMargin = config.wall_thickness + config.cutout_clearance + 0.25 + // the widest per-tool clearance + spacing governs the grid fit + let maxPad = 0 + for (const tool of placedTools) { + const info = toolInfo.get(tool.tool_id) + const clr = info?.clearance ?? config.cutout_clearance + const sp = info?.spacing ?? (config.tool_spacing ?? 0) + maxPad = Math.max(maxPad, clr + sp) + } + const halfMargin = config.wall_thickness + maxPad + 0.25 const toolW = maxX - minX const toolH = maxY - minY const gux = config.grid_unit_x_mm @@ -295,7 +309,7 @@ export default function BinPage() { ), }))) } - }, [autoSize, isDragging, placedTools, config.grid_x, config.grid_y, config.grid_unit_x_mm, config.grid_unit_y_mm, config.wall_thickness, config.cutout_clearance]) + }, [autoSize, isDragging, placedTools, toolInfo, config.grid_x, config.grid_y, config.grid_unit_x_mm, config.grid_unit_y_mm, config.wall_thickness, config.cutout_clearance, config.tool_spacing]) const handleToggleSmoothed = useCallback(async (toolId: string, smoothed: boolean) => { try { @@ -319,7 +333,7 @@ export default function BinPage() { // pack all placed tools into the smallest grid footprint const runArrange = useCallback((tools: PlacedTool[]) => { - const result = arrangeTools(tools, config, arrangeRotation) + const result = arrangeTools(tools, config, arrangeRotation, toolInfo) if (!result) return false setPlacedTools(result.tools) setConfig(prev => (prev.grid_x === result.gridX && prev.grid_y === result.gridY @@ -329,7 +343,7 @@ export default function BinPage() { ? `${result.unplacedIds.length} tool${result.unplacedIds.length !== 1 ? 's' : ''} did not fit even at ${result.gridX}x${result.gridY} and kept ${result.unplacedIds.length !== 1 ? 'their' : 'its'} position` : null) return true - }, [config, arrangeRotation]) + }, [config, arrangeRotation, toolInfo]) const handleAddTool = useCallback((tool: PlacedTool) => { if (autoArrange && runArrange([...placedTools, tool])) return @@ -344,7 +358,10 @@ export default function BinPage() { const toolW = maxX - minX const toolH = maxY - minY - const margin = 2 * config.wall_thickness + 2 * config.cutout_clearance + 0.5 + const info = toolInfo.get(tool.tool_id) + const clr = info?.clearance ?? config.cutout_clearance + const sp = info?.spacing ?? (config.tool_spacing ?? 0) + const margin = 2 * config.wall_thickness + 2 * (clr + sp) + 0.5 const gux = config.grid_unit_x_mm const guy = config.grid_unit_y_mm const needX = Math.max(config.grid_x, Math.ceil((toolW + margin) / gux)) @@ -371,7 +388,21 @@ export default function BinPage() { } setPlacedTools(prev => [...prev, placed]) - }, [autoArrange, runArrange, placedTools, config.grid_x, config.grid_y, config.grid_unit_x_mm, config.grid_unit_y_mm, config.wall_thickness, config.cutout_clearance]) + }, [autoArrange, runArrange, placedTools, toolInfo, config.grid_x, config.grid_y, config.grid_unit_x_mm, config.grid_unit_y_mm, config.wall_thickness, config.cutout_clearance, config.tool_spacing]) + + // dashed keep-out halo per placement: clearance + spacing beyond the + // outline bbox, shown only for tools with a non-zero resolved spacing + const keepOutByPlacementId = useMemo(() => { + const m = new Map() + for (const pt of placedTools) { + const info = toolInfo.get(pt.tool_id) + const sp = info?.spacing ?? (config.tool_spacing ?? 0) + if (sp <= 0) continue + const clr = info?.clearance ?? config.cutout_clearance + m.set(pt.id, clr + sp) + } + return m + }, [placedTools, toolInfo, config.cutout_clearance, config.tool_spacing]) function handleDownload() { window.open(getBinStlUrl(binId), '_blank') @@ -583,6 +614,7 @@ export default function BinPage() { smoothLevels={smoothLevels} onSmoothLevelChange={handleSmoothLevelChange} onDraggingChange={setIsDragging} + keepOutByPlacementId={keepOutByPlacementId} /> diff --git a/frontend/src/app/tools/[id]/page.tsx b/frontend/src/app/tools/[id]/page.tsx index 0e25263..5b3129d 100644 --- a/frontend/src/app/tools/[id]/page.tsx +++ b/frontend/src/app/tools/[id]/page.tsx @@ -46,6 +46,7 @@ export default function ToolPage() { name, shapes: sent, clearance_override: tool.clearance_override ?? null, + spacing_override: tool.spacing_override ?? null, }) setMaterializeError(null) // apply the authoritative materialized outline; only adopt the @@ -114,6 +115,10 @@ export default function ToolPage() { setTool(prev => prev ? { ...prev, clearance_override } : null) }, []) + const handleSpacingChange = useCallback((spacing_override: number | null) => { + setTool(prev => prev ? { ...prev, spacing_override } : null) + }, []) + const handleConvertToPolygon = useCallback(async () => { if (!window.confirm('Convert to a freeform polygon? The shape parameters are discarded and this cannot be undone.')) return try { @@ -174,9 +179,11 @@ export default function ToolPage() { outlinePoints={tool.points} outlineRings={tool.interior_rings} clearanceOverride={tool.clearance_override ?? null} + spacingOverride={tool.spacing_override ?? null} materializeError={materializeError} onShapesChange={handleShapesChange} onClearanceChange={handleClearanceChange} + onSpacingChange={handleSpacingChange} onConvertToPolygon={handleConvertToPolygon} /> ) : ( diff --git a/frontend/src/components/BinConfigurator.tsx b/frontend/src/components/BinConfigurator.tsx index b88a919..977c47d 100644 --- a/frontend/src/components/BinConfigurator.tsx +++ b/frontend/src/components/BinConfigurator.tsx @@ -306,6 +306,17 @@ export function BinConfigurator({ config, onChange, autoSize, onAutoSizeChange, onChange={(v) => update({ cutout_clearance: v })} /> + update({ tool_spacing: v })} + /> + onSmoothLevelChange?: (toolId: string, level: number) => void onDraggingChange?: (dragging: boolean) => void + keepOutByPlacementId?: Map } type Tool = 'select' | 'text' type Selection = - | { type: 'tool'; toolId: string } + | { type: 'tool'; toolIds: string[] } | { type: 'hole'; toolId: string; holeId: string } | { type: 'label'; labelId: string } | null +interface ToolDragOrig { + id: string + points: { x: number; y: number }[] + holes: { id: string; x: number; y: number }[] + rings: { x: number; y: number }[][] +} + type DragState = - | { type: 'tool'; toolId: string; startX: number; startY: number; origPoints: { x: number; y: number }[]; origHoles: { id: string; x: number; y: number }[]; origInteriorRings: { x: number; y: number }[][] } + | { type: 'tool'; anchorId: string; startX: number; startY: number; orig: ToolDragOrig[] } | { type: 'rotate'; toolId: string; centerX: number; centerY: number; startAngle: number; origRotation: number; origPoints: { x: number; y: number }[]; origHoles: { id: string; x: number; y: number }[]; origInteriorRings: { x: number; y: number }[][] } | { type: 'label'; labelId: string; startX: number; startY: number; origX: number; origY: number } | { type: 'rotate-label'; labelId: string; centerX: number; centerY: number; startAngle: number; origRotation: number } @@ -62,11 +71,15 @@ export function BinEditor({ smoothLevels, onSmoothLevelChange, onDraggingChange, + keepOutByPlacementId, }: Props) { const svgRef = useRef(null) const [selection, setSelection] = useState(null) const [activeTool, setActiveTool] = useState('select') const [dragging, setDragging] = useState(null) + const [marquee, setMarquee] = useState<{ x0: number; y0: number; x1: number; y1: number } | null>(null) + const marqueeRef = useRef<{ x0: number; y0: number; additive: boolean; baseIds: string[] } | null>(null) + const suppressClickRef = useRef(false) const [snapEnabled, setSnapEnabled] = useState(true) const [pendingLabel, setPendingLabel] = useState<{ x: number; y: number } | null>(null) const [pendingText, setPendingText] = useState('') @@ -158,16 +171,33 @@ export function BinEditor({ const tool = placedTools.find(t => t.id === toolId) if (!tool) return - setSelection({ type: 'tool', toolId }) + const current = selection?.type === 'tool' ? selection.toolIds : [] + + // shift/ctrl-click toggles membership without starting a drag + if (e.shiftKey || e.ctrlKey || e.metaKey) { + const ids = current.includes(toolId) + ? current.filter(id => id !== toolId) + : [...current, toolId] + setSelection(ids.length > 0 ? { type: 'tool', toolIds: ids } : null) + return + } + + // dragging a member of a multi-selection moves the whole group; + // grabbing an unselected tool replaces the selection + const ids = current.includes(toolId) ? current : [toolId] + setSelection({ type: 'tool', toolIds: ids }) const pos = screenToMm(e.clientX, e.clientY) setDragging({ type: 'tool', - toolId, + anchorId: toolId, startX: pos.x, startY: pos.y, - origPoints: tool.points.map(p => ({ x: p.x, y: p.y })), - origHoles: tool.finger_holes.map(fh => ({ id: fh.id, x: fh.x, y: fh.y })), - origInteriorRings: (tool.interior_rings ?? []).map(ring => ring.map(p => ({ x: p.x, y: p.y }))), + orig: placedTools.filter(t => ids.includes(t.id)).map(t => ({ + id: t.id, + points: t.points.map(p => ({ x: p.x, y: p.y })), + holes: t.finger_holes.map(fh => ({ id: fh.id, x: fh.x, y: fh.y })), + rings: (t.interior_rings ?? []).map(ring => ring.map(p => ({ x: p.x, y: p.y }))), + })), }) } @@ -293,8 +323,11 @@ export function BinEditor({ const onLabelsChange = onTextLabelsChangeRef.current 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 + // snap the grabbed tool's centre; every selected tool follows by the + // same offset so the group's relative layout is preserved + const anchor = dragging.orig.find(o => o.id === dragging.anchorId) ?? dragging.orig[0] + const origCenterX = anchor.points.reduce((sum, p) => sum + p.x, 0) / anchor.points.length + const origCenterY = anchor.points.reduce((sum, p) => sum + p.y, 0) / anchor.points.length let rawDx = pos.x - dragging.startX let rawDy = pos.y - dragging.startY if (shiftKey) ({ dx: rawDx, dy: rawDy } = axisLock(rawDx, rawDy)) @@ -303,17 +336,19 @@ export function BinEditor({ const newCenterY = shiftKey && rawDy === 0 ? origCenterY : snapToGrid(origCenterY + rawDy) const dx = newCenterX - origCenterX const dy = newCenterY - origCenterY + const origById = new Map(dragging.orig.map(o => [o.id, o])) const updated = currentTools.map(tool => { - if (tool.id !== dragging.toolId) return tool + const orig = origById.get(tool.id) + if (!orig) return tool return { ...tool, - points: dragging.origPoints.map(p => ({ x: p.x + dx, y: p.y + dy })), + points: orig.points.map(p => ({ x: p.x + dx, y: p.y + dy })), finger_holes: tool.finger_holes.map(fh => { - const orig = dragging.origHoles.find(h => h.id === fh.id) - if (!orig) return fh - return { ...fh, x: orig.x + dx, y: orig.y + dy } + const oh = orig.holes.find(h => h.id === fh.id) + if (!oh) return fh + return { ...fh, x: oh.x + dx, y: oh.y + dy } }), - interior_rings: dragging.origInteriorRings.map(ring => + interior_rings: orig.rings.map(ring => ring.map(p => ({ x: p.x + dx, y: p.y + dy })) ), } @@ -400,9 +435,66 @@ export function BinEditor({ } }, [dragging, handleMouseMove, handleMouseUp]) + // rubber-band selection: drag on empty canvas to select every tool whose + // bounds intersect the marquee. shift/ctrl adds to the current selection. + const handleCanvasMouseDown = (e: React.MouseEvent) => { + if (activeTool !== 'select' || e.button !== 0) return + if (editingLabelId || pendingLabel) return + suppressClickRef.current = false + const pos = screenToMm(e.clientX, e.clientY) + marqueeRef.current = { + x0: pos.x, + y0: pos.y, + additive: e.shiftKey || e.ctrlKey || e.metaKey, + baseIds: selection?.type === 'tool' ? selection.toolIds : [], + } + setMarquee({ x0: pos.x, y0: pos.y, x1: pos.x, y1: pos.y }) + } + + const handleMarqueeMove = useCallback((e: MouseEvent) => { + const start = marqueeRef.current + if (!start) return + const pos = screenToMm(e.clientX, e.clientY) + setMarquee({ x0: start.x0, y0: start.y0, x1: pos.x, y1: pos.y }) + }, [screenToMm]) + + const handleMarqueeUp = useCallback((e: MouseEvent) => { + const start = marqueeRef.current + if (!start) return + marqueeRef.current = null + setMarquee(null) + const pos = screenToMm(e.clientX, e.clientY) + const minX = Math.min(start.x0, pos.x) + const maxX = Math.max(start.x0, pos.x) + const minY = Math.min(start.y0, pos.y) + const maxY = Math.max(start.y0, pos.y) + // a near-zero marquee is a plain click -- let the click handler clear + if (maxX - minX < 0.5 && maxY - minY < 0.5) return + suppressClickRef.current = true + const ids = toolsRef.current + .filter(t => { + const b = toolBounds(t) + return b.minX <= maxX && b.maxX >= minX && b.minY <= maxY && b.maxY >= minY + }) + .map(t => t.id) + const merged = start.additive ? [...new Set([...start.baseIds, ...ids])] : ids + setSelection(merged.length > 0 ? { type: 'tool', toolIds: merged } : null) + }, [screenToMm]) + + const marqueeActive = marquee !== null + useEffect(() => { + if (!marqueeActive) return + window.addEventListener('mousemove', handleMarqueeMove) + window.addEventListener('mouseup', handleMarqueeUp) + return () => { + window.removeEventListener('mousemove', handleMarqueeMove) + window.removeEventListener('mouseup', handleMarqueeUp) + } + }, [marqueeActive, handleMarqueeMove, handleMarqueeUp]) + const handleDeleteTool = () => { if (selection?.type !== 'tool') return - onPlacedToolsChange(placedTools.filter(t => t.id !== selection.toolId)) + onPlacedToolsChange(placedTools.filter(t => !selection.toolIds.includes(t.id))) setSelection(null) } @@ -435,6 +527,12 @@ export function BinEditor({ }, [pendingLabel, pendingText, textLabels, onTextLabelsChange]) const handleBackgroundClick = (e: React.MouseEvent) => { + // a finished marquee fires a click on the svg -- don't clear the + // selection it just made + if (suppressClickRef.current) { + suppressClickRef.current = false + return + } if (activeTool === 'text') { if (pendingLabel) { commitPendingLabel() @@ -461,8 +559,10 @@ export function BinEditor({ ? textLabels.find(l => l.id === selection.labelId) : null - const selectedTool = selection?.type === 'tool' - ? placedTools.find(t => t.id === selection.toolId) + const selectedToolCount = selection?.type === 'tool' ? selection.toolIds.length : 0 + + const selectedTool = selection?.type === 'tool' && selection.toolIds.length === 1 + ? placedTools.find(t => t.id === selection.toolIds[0]) : null const selectedHole = selection?.type === 'hole' @@ -523,6 +623,7 @@ export function BinEditor({ setSnapEnabled={setSnapEnabled} handleRecenter={handleRecenter} selectedTool={selectedTool ?? null} + selectedToolCount={selectedToolCount} selectedLabel={selectedLabel ?? null} selectedHole={selectedHole ?? null} selectedHoleToolId={selection?.type === 'hole' ? selection.toolId : null} @@ -559,6 +660,7 @@ export function BinEditor({ pendingLabelText={pendingText} smoothedToolIds={smoothedToolIds} smoothLevels={smoothLevels} + keepOutByPlacementId={keepOutByPlacementId} activeTool={activeTool} binWidthMm={binWidthMm} binHeightMm={binHeightMm} @@ -567,6 +669,8 @@ export function BinEditor({ handleOffset={handleOffset} pendingInputRef={pendingInputRef} editInputRef={editInputRef} + marquee={marquee} + handleCanvasMouseDown={handleCanvasMouseDown} handleToolMouseDown={handleToolMouseDown} handleRotateMouseDown={handleRotateMouseDown} handleLabelMouseDown={handleLabelMouseDown} diff --git a/frontend/src/components/BinEditorCanvas.tsx b/frontend/src/components/BinEditorCanvas.tsx index 223a1cb..7748416 100644 --- a/frontend/src/components/BinEditorCanvas.tsx +++ b/frontend/src/components/BinEditorCanvas.tsx @@ -5,11 +5,12 @@ import type { PlacedTool, TextLabel } from '@/types' import { polygonPathData, smoothPathData, simplifyPolygon, smoothEpsilon } from '@/lib/svg' import { DEFAULT_GRID_UNIT, DISPLAY_SCALE } from '@/lib/constants' import { CutoutOverlay } from '@/components/CutoutOverlay' +import { toolBounds } from '@/lib/packing' type Tool = 'select' | 'text' type Selection = - | { type: 'tool'; toolId: string } + | { type: 'tool'; toolIds: string[] } | { type: 'hole'; toolId: string; holeId: string } | { type: 'label'; labelId: string } | null @@ -32,6 +33,10 @@ interface Props { pendingLabelText: string smoothedToolIds?: Set smoothLevels?: Map + // mm beyond each placement's bbox to draw as a dashed keep-out halo + keepOutByPlacementId?: Map + // rubber-band selection rect, mm + marquee?: { x0: number; y0: number; x1: number; y1: number } | null activeTool: Tool binWidthMm: number binHeightMm: number @@ -43,6 +48,7 @@ interface Props { pendingInputRef: RefObject editInputRef: RefObject // event handlers + handleCanvasMouseDown: (e: React.MouseEvent) => void handleToolMouseDown: (toolId: string) => (e: React.MouseEvent) => void handleRotateMouseDown: (toolId: string) => (e: React.MouseEvent) => void handleLabelMouseDown: (labelId: string) => (e: React.MouseEvent) => void @@ -78,6 +84,8 @@ export function BinEditorCanvas({ pendingLabelText, smoothedToolIds, smoothLevels, + keepOutByPlacementId, + marquee, activeTool, binWidthMm, binHeightMm, @@ -86,6 +94,7 @@ export function BinEditorCanvas({ handleOffset, pendingInputRef, editInputRef, + handleCanvasMouseDown, handleToolMouseDown, handleRotateMouseDown, handleLabelMouseDown, @@ -114,6 +123,7 @@ export function BinEditorCanvas({ className={`rounded max-w-full max-h-full ${activeTool === 'select' ? 'cursor-default' : 'cursor-crosshair'}`} style={{ overflow: 'visible' }} onClick={handleBackgroundClick} + onMouseDown={handleCanvasMouseDown} > {Array.from({ length: gridX + 1 }).map((_, i) => ( @@ -156,10 +166,32 @@ export function BinEditorCanvas({ } else { pathData = polygonPathData(tool.points, tool.interior_rings, DISPLAY_SCALE) } - const isSelected = selection?.type === 'tool' && selection.toolId === tool.id + const isSelected = selection?.type === 'tool' && selection.toolIds.includes(tool.id) + + const keepOut = keepOutByPlacementId?.get(tool.id) return ( + {keepOut != null && keepOut > 0 && (() => { + // keep-out halo at the bbox the arranger packs (outline + + // finger holes), expanded by clearance + spacing + const b = toolBounds(tool) + const off = keepOut * DISPLAY_SCALE + return ( + + ) + })()} { - const tool = placedTools.find(t => t.id === selection.toolId) + {/* multi-selection: dashed bbox per selected tool, no rotate handles */} + {selection?.type === 'tool' && selection.toolIds.length > 1 && selection.toolIds.map(id => { + const tool = placedTools.find(t => t.id === id) + if (!tool) return null + const pad = handleR * 0.4 + let bMinX = Infinity, bMinY = Infinity, bMaxX = -Infinity, bMaxY = -Infinity + for (const p of tool.points) { + bMinX = Math.min(bMinX, p.x); bMinY = Math.min(bMinY, p.y) + bMaxX = Math.max(bMaxX, p.x); bMaxY = Math.max(bMaxY, p.y) + } + return ( + + ) + })} + + {/* selection handles: single tool */} + {selection?.type === 'tool' && selection.toolIds.length === 1 && (() => { + const tool = placedTools.find(t => t.id === selection.toolIds[0]) if (!tool) return null const pad = handleR * 0.4 let bMinX = Infinity, bMinY = Infinity, bMaxX = -Infinity, bMaxY = -Infinity @@ -333,6 +388,25 @@ export function BinEditorCanvas({ ) })()} + {/* rubber-band selection rect */} + {marquee && (() => { + const x = Math.min(marquee.x0, marquee.x1) * DISPLAY_SCALE + const y = Math.min(marquee.y0, marquee.y1) * DISPLAY_SCALE + const w = Math.abs(marquee.x1 - marquee.x0) * DISPLAY_SCALE + const h = Math.abs(marquee.y1 - marquee.y0) * DISPLAY_SCALE + if (w < 1 && h < 1) return null + return ( + + ) + })()} + {editingLabelId && (() => { const label = textLabels.find(l => l.id === editingLabelId) if (!label) return null diff --git a/frontend/src/components/BinEditorToolbar.tsx b/frontend/src/components/BinEditorToolbar.tsx index 5892a92..2404aaa 100644 --- a/frontend/src/components/BinEditorToolbar.tsx +++ b/frontend/src/components/BinEditorToolbar.tsx @@ -81,6 +81,8 @@ interface Props { setSnapEnabled: (enabled: boolean) => void handleRecenter: () => void selectedTool: PlacedTool | null + // total tools in the selection; per-tool controls only show when it is 1 + selectedToolCount: number selectedLabel: TextLabel | null selectedHole: FingerHole | null selectedHoleToolId: string | null @@ -109,6 +111,7 @@ export function BinEditorToolbar({ setSnapEnabled, handleRecenter, selectedTool, + selectedToolCount, selectedLabel, selectedHole, selectedHoleToolId, @@ -163,6 +166,23 @@ export function BinEditorToolbar({ Recenter + {selectedToolCount > 1 && ( + <> +
+ + {selectedToolCount} tools + + + + )} + {selectedTool && ( <>
diff --git a/frontend/src/components/ShapeDesigner.tsx b/frontend/src/components/ShapeDesigner.tsx index 4d912e9..03787cb 100644 --- a/frontend/src/components/ShapeDesigner.tsx +++ b/frontend/src/components/ShapeDesigner.tsx @@ -25,9 +25,11 @@ interface Props { outlinePoints: Point[] outlineRings: Point[][] clearanceOverride: number | null + spacingOverride: number | null materializeError: string | null onShapesChange: (shapes: ToolShape[]) => void onClearanceChange: (v: number | null) => void + onSpacingChange: (v: number | null) => void onConvertToPolygon: () => void } @@ -43,9 +45,11 @@ export function ShapeDesigner({ outlinePoints, outlineRings, clearanceOverride, + spacingOverride, materializeError, onShapesChange, onClearanceChange, + onSpacingChange, onConvertToPolygon, }: Props) { const svgRef = useRef(null) @@ -395,6 +399,8 @@ export function ShapeDesigner({ onShapesChange={commitShapes} clearanceOverride={clearanceOverride} onClearanceChange={onClearanceChange} + spacingOverride={spacingOverride} + onSpacingChange={onSpacingChange} materializeError={materializeError} onConvertToPolygon={onConvertToPolygon} /> diff --git a/frontend/src/components/ShapeDesignerCanvas.tsx b/frontend/src/components/ShapeDesignerCanvas.tsx index a546cc6..ed49a99 100644 --- a/frontend/src/components/ShapeDesignerCanvas.tsx +++ b/frontend/src/components/ShapeDesignerCanvas.tsx @@ -74,16 +74,16 @@ function ShapeElement({ shape, zoom, selected }: { shape: ToolShape; zoom: numbe ) } -function MaskShape({ shape }: { shape: ToolShape }) { - const fill = shape.mode === 'add' ? 'white' : 'black' +function MaskShape({ shape, fill }: { shape: ToolShape; fill?: string }) { + const f = fill ?? (shape.mode === 'add' ? 'white' : 'black') const transform = `translate(${shape.x * S},${shape.y * S})${shape.rotation ? ` rotate(${shape.rotation})` : ''}` if (shape.type === 'rectangle') { const w = (shape.width ?? 0) * S const h = (shape.height ?? 0) * S const r = (shape.corner_radius ?? 0) * S - return + return } - return + return } export function ShapeDesignerCanvas({ @@ -152,6 +152,38 @@ export function ShapeDesignerCanvas({ className="pointer-events-none" /> + {/* per-shape depth: darker = deeper, masked so holes stay unpainted */} + {(() => { + const depthShapes = shapes.filter((sh) => sh.mode === 'add' && sh.depth != null) + if (depthShapes.length === 0) return null + const maxD = Math.max(30, ...depthShapes.map((sh) => sh.depth!)) + return ( + + + {depthShapes.map((sh) => ( + + ))} + + {depthShapes.map((sh) => ( + + {sh.depth}mm + + ))} + + ) + })()} + {/* authoritative outline from the last server materialization */} {outlinePoints.length >= 3 && ( void clearanceOverride: number | null onClearanceChange: (v: number | null) => void + spacingOverride: number | null + onSpacingChange: (v: number | null) => void materializeError: string | null onConvertToPolygon: () => void } @@ -47,6 +49,8 @@ export function ShapeListPanel({ onShapesChange, clearanceOverride, onClearanceChange, + spacingOverride, + onSpacingChange, materializeError, onConvertToPolygon, }: Props) { @@ -67,7 +71,9 @@ export function ShapeListPanel({ 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] }) + const next = order[(order.indexOf(s.mode) + 1) % order.length] + // depth only means something on solids + update(s.id, { mode: next, ...(next !== 'add' ? { depth: null } : {}) }) } return ( @@ -116,7 +122,12 @@ export function ShapeListPanel({ ) : ( )} - {shapeDisplayName(s)} + + {shapeDisplayName(s)} + {s.mode === 'add' && s.depth != null && ( + · {s.depth}mm + )} +
)}
@@ -214,6 +240,22 @@ export function ShapeListPanel({

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

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

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

Holes are carved after all solids are merged.

diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index cb1c065..327b1b3 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -229,6 +229,7 @@ export async function updateTool( smooth_level?: number shapes?: import('@/types').ToolShape[] | null clearance_override?: number | null + spacing_override?: number | null } ): Promise { return fetchApi(`/api/tools/${toolId}`, { diff --git a/frontend/src/lib/packing.test.ts b/frontend/src/lib/packing.test.ts new file mode 100644 index 0000000..5f6f302 --- /dev/null +++ b/frontend/src/lib/packing.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' +import { arrangeTools, type ToolPadInfo } from './packing' +import type { BinConfig, PlacedTool } from '@/types' + +function makeConfig(overrides: Partial = {}): BinConfig { + return { + grid_x: 2, + grid_y: 2, + grid_unit_x_mm: 42, + grid_unit_y_mm: 42, + grid_unit_locked: true, + height_units: 4, + magnets: true, + magnet_diameter: 6, + magnet_depth: 2.4, + magnet_corners_only: false, + stacking_lip: true, + wall_thickness: 1.6, + cutout_depth: 20, + cutout_clearance: 1.0, + cutout_chamfer: 0, + tool_spacing: 0, + insert_enabled: false, + insert_height: 1.0, + text_labels: [], + bed_size: 256, + ...overrides, + } +} + +function makeSquare(id: string, toolId: string, size: number): PlacedTool { + return { + id, + tool_id: toolId, + name: id, + points: [ + { x: 0, y: 0 }, + { x: size, y: 0 }, + { x: size, y: size }, + { x: 0, y: size }, + ], + finger_holes: [], + interior_rings: [], + rotation: 0, + } +} + +function bounds(pt: PlacedTool) { + const xs = pt.points.map((p) => p.x) + const ys = pt.points.map((p) => p.y) + return { + minX: Math.min(...xs), + maxX: Math.max(...xs), + minY: Math.min(...ys), + maxY: Math.max(...ys), + } +} + +/** outline-to-outline gap between two tools along whichever axis separates them */ +function outlineGap(a: PlacedTool, b: PlacedTool): number { + const ba = bounds(a) + const bb = bounds(b) + const dx = Math.max(bb.minX - ba.maxX, ba.minX - bb.maxX) + const dy = Math.max(bb.minY - ba.maxY, ba.minY - bb.maxY) + return Math.max(dx, dy) +} + +const WEB = 1.6 // max(1.2, wall_thickness 1.6) + +describe('arrangeTools spacing', () => { + it('legacy gap without spacing matches 2*clearance + web', () => { + const config = makeConfig() + const result = arrangeTools( + [makeSquare('a', 't1', 30), makeSquare('b', 't2', 30)], + config, + false, + )! + expect(result.unplacedIds).toEqual([]) + const [a, b] = result.tools + expect(outlineGap(a, b)).toBeCloseTo(2 * 1.0 + WEB, 5) + }) + + it('per-tool spacing widens the gap by both tools\' spacing', () => { + const config = makeConfig() + const toolInfo = new Map([ + ['t1', { spacing: 3.25 }], + ['t2', { spacing: 3.25 }], + ]) + const result = arrangeTools( + [makeSquare('a', 't1', 20), makeSquare('b', 't2', 20)], + config, + false, + toolInfo, + )! + expect(result.unplacedIds).toEqual([]) + const [a, b] = result.tools + expect(outlineGap(a, b)).toBeCloseTo(2 * 1.0 + 2 * 3.25 + WEB, 5) + }) + + it('bin tool_spacing applies to tools without an override', () => { + const config = makeConfig({ tool_spacing: 2 }) + const toolInfo = new Map([ + ['t1', { spacing: 3.25 }], + ['t2', {}], + ]) + const result = arrangeTools( + [makeSquare('a', 't1', 20), makeSquare('b', 't2', 20)], + config, + false, + toolInfo, + )! + const [a, b] = result.tools + // clr_a + sp_a + clr_b + sp_b + web + expect(outlineGap(a, b)).toBeCloseTo(1.0 + 3.25 + 1.0 + 2 + WEB, 5) + }) + + it('keeps the outline clear of the bin wall by edge + pad', () => { + const config = makeConfig() + const toolInfo = new Map([['t1', { spacing: 3.25 }]]) + const result = arrangeTools([makeSquare('a', 't1', 20)], config, false, toolInfo)! + const b = bounds(result.tools[0]) + const edge = config.wall_thickness + 0.25 + const pad = config.cutout_clearance + 3.25 + WEB / 2 + expect(b.minX).toBeCloseTo(edge + pad, 5) + expect(b.minY).toBeCloseTo(edge + pad, 5) + }) + + it('clearance override composes with spacing in the pad', () => { + const config = makeConfig() + const toolInfo = new Map([ + ['t1', { clearance: 0, spacing: 5 }], + ['t2', { clearance: 0, spacing: 5 }], + ]) + const result = arrangeTools( + [makeSquare('a', 't1', 20), makeSquare('b', 't2', 20)], + config, + false, + toolInfo, + )! + const [a, b] = result.tools + expect(outlineGap(a, b)).toBeCloseTo(0 + 5 + 0 + 5 + WEB, 5) + }) +}) + diff --git a/frontend/src/lib/packing.ts b/frontend/src/lib/packing.ts index 829d7dd..cf02e82 100644 --- a/frontend/src/lib/packing.ts +++ b/frontend/src/lib/packing.ts @@ -29,7 +29,7 @@ interface Bounds { maxY: number } -function toolBounds(pt: PlacedTool): Bounds { +export function toolBounds(pt: PlacedTool): Bounds { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity for (const p of pt.points) { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y) @@ -106,23 +106,41 @@ export interface ArrangeResult { unplacedIds: string[] } +// per-tool padding inputs, keyed by PlacedTool.tool_id; null/undefined +// fields fall back to the bin's cutout_clearance / tool_spacing +export interface ToolPadInfo { + clearance?: number | null + spacing?: number | null +} + export function arrangeTools( placedTools: PlacedTool[], config: BinConfig, allowRotation: boolean, + toolInfo?: Map, ): ArrangeResult | null { if (placedTools.length === 0) return null // distance from bin edge to the bbox must cover wall + clearance (matches // the auto-size margin); between two pockets we need both clearances plus - // a printable web, so each padded box carries clearance + web/2 per side + // a printable web, so each padded box carries clearance + spacing + web/2 + // per side. spacing is a keep-out air gap for tools that overhang their + // cutout; clearance is later baked into the pocket at STL time, so the + // finished-pocket gap is spacing_a + spacing_b + web. const web = Math.max(1.2, config.wall_thickness) - const pad = config.cutout_clearance + web / 2 const edge = config.wall_thickness + 0.25 + const padById = new Map(placedTools.map((pt) => { + const info = toolInfo?.get(pt.tool_id) + const clr = info?.clearance ?? config.cutout_clearance + const sp = info?.spacing ?? (config.tool_spacing ?? 0) + return [pt.id, clr + sp + web / 2] + })) + const boundsById = new Map(placedTools.map((pt) => [pt.id, toolBounds(pt)])) const items: PackItem[] = placedTools.map((pt) => { const b = boundsById.get(pt.id)! + const pad = padById.get(pt.id)! return { id: pt.id, w: b.maxX - b.minX + 2 * pad, h: b.maxY - b.minY + 2 * pad } }) @@ -187,6 +205,7 @@ export function arrangeTools( bb = { minX: cx - halfH, maxX: cx + halfH, minY: cy - halfW, maxY: cy + halfW } } + const pad = padById.get(pt.id)! const dx = edge + placement.x + pad - bb.minX const dy = edge + placement.y + pad - bb.minY return { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6cc8516..29bcfb4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -110,6 +110,7 @@ export interface BinConfig { cutout_depth: number cutout_clearance: number cutout_chamfer: number + tool_spacing: number insert_enabled: boolean insert_height: number text_labels: TextLabel[] @@ -133,6 +134,8 @@ export interface ToolShape { corner_radius?: number rx?: number | null // ellipse semi-axes (circle when rx == ry) ry?: number | null + // pocket depth in mm from the bin top; add-shapes only, null = bin default + depth?: number | null } export interface Tool { @@ -147,6 +150,7 @@ export interface Tool { created_at: string | null shapes?: ToolShape[] | null clearance_override?: number | null + spacing_override?: number | null } export interface ToolSummary { @@ -160,6 +164,8 @@ export interface ToolSummary { smooth_level: number thumbnail_url: string | null parametric: boolean + clearance_override?: number | null + spacing_override?: number | null } // --- bins ---