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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 34 additions & 7 deletions backend/app/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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))

Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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(),
Expand All @@ -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
Expand Down
37 changes: 37 additions & 0 deletions backend/app/models/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand Down
87 changes: 67 additions & 20 deletions backend/app/services/bin_service.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand All @@ -19,33 +78,22 @@ 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,
# GET /bins/{id} silently overwrites stored overrides on every load.
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),
Expand All @@ -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:
Expand Down
Loading
Loading