from __future__ import annotations import copy import json import math from typing import Any from app.services.engine_service import build_revision from app.services.storage import WorkspaceStore from app.settings import Settings SUPPORTED_OPERATIONS = { "add_hole", "add_counterbore", "add_countersink", "add_slot", "add_pocket", "add_circular_pocket", "add_hole_pattern", } def _number(values: dict[str, Any], name: str, fallback: float, minimum: float = 0.01) -> float: value = float(values.get(name, fallback)) if not math.isfinite(value) or value < minimum: raise ValueError(f"{name} must be a finite number >= {minimum}") return value def _selection_frame(selection: dict[str, Any]) -> dict[str, list[float]]: pick = selection.get("pick") if isinstance(selection.get("pick"), dict) else selection surface = pick.get("surface") if isinstance(pick.get("surface"), dict) else {} surface_type = str(surface.get("type") or surface.get("surfaceType") or "").lower() if surface_type and "plane" not in surface_type: raise ValueError("Direct CDSL edits currently require a planar face") frame = pick.get("frame") if isinstance(pick.get("frame"), dict) else {} origin = frame.get("origin_mm") or pick.get("center") or pick.get("point") normal = frame.get("normal") or pick.get("normal") x_dir = frame.get("x_dir") or frame.get("xDir") or [1.0, 0.0, 0.0] y_dir = frame.get("y_dir") or frame.get("yDir") or [0.0, 1.0, 0.0] if not all(isinstance(value, list) and len(value) >= 3 for value in (origin, normal, x_dir, y_dir)): raise ValueError("Select a planar face before applying a direct CDSL edit") return { "origin_mm": [float(item) for item in origin[:3]], "normal": [float(item) for item in normal[:3]], "x_dir": [float(item) for item in x_dir[:3]], "y_dir": [float(item) for item in y_dir[:3]], } def _next_id(prefix: str, existing: set[str]) -> str: index = 1 while f"{prefix}_{index:03d}" in existing: index += 1 return f"{prefix}_{index:03d}" def _profile_for(operation: str, values: dict[str, Any]) -> tuple[dict[str, Any], float]: depth = _through_depth(values) if operation in {"add_hole", "add_counterbore", "add_countersink", "add_circular_pocket"}: diameter = _number(values, "holeDiameter", values.get("diameter", 10.0)) return {"type": "circle", "center": [0.0, 0.0], "radius_mm": diameter / 2}, depth if operation == "add_slot": width = _number(values, "slotWidth", values.get("width", 8.0)) length = _number(values, "slotLength", values.get("length", width * 3)) return {"type": "obround", "center": [0.0, 0.0], "length_mm": max(length, width), "width_mm": width}, depth if operation == "add_pocket": width = _number(values, "width", 20.0) height = _number(values, "height", 12.0) return {"type": "rectangle", "center": [0.0, 0.0], "width_mm": width, "height_mm": height}, depth if operation == "add_hole_pattern": diameter = _number(values, "holeDiameter", values.get("diameter", 6.0)) rows = max(1, int(_number(values, "rows", 2, 1))) columns = max(1, int(_number(values, "columns", 2, 1))) return { "type": "circle_grid", "radius_mm": diameter / 2, "count_x": columns, "count_y": rows, "spacing_x_mm": _number(values, "pitchX", 12.0), "spacing_y_mm": _number(values, "pitchY", 12.0), "center_mm": [0.0, 0.0], }, depth raise ValueError(f"Unsupported direct CDSL edit: {operation}") def _through_depth(values: dict[str, Any]) -> float: # A through cut deliberately exceeds the model bounds. build123d clips the # cutter against the solid, so this remains deterministic for any part size. return 10000.0 if str(values.get("depth") or "").lower() == "through" else _number(values, "depth", 10.0) def _hole_feature(operation: str, frame: dict[str, list[float]], values: dict[str, Any]) -> dict[str, Any]: diameter = _number(values, "holeDiameter", values.get("diameter", 10.0)) params: dict[str, Any] = { "diameter_mm": diameter, "depth_mm": _through_depth(values), "positions": [{"mm": [0.0, 0.0, 0.0]}], "host_face": {"frame": frame}, } atomic = "hole_blind" if operation == "add_counterbore": counterbore_diameter = _number(values, "counterboreDiameter", diameter * 2) if counterbore_diameter <= diameter: raise ValueError("counterboreDiameter must be larger than holeDiameter") params["counterbore_diameter_mm"] = counterbore_diameter params["counterbore_depth_mm"] = _number(values, "counterboreDepth", min(diameter, 2.0)) atomic = "hole_counterbore" elif operation == "add_countersink": countersink_diameter = _number(values, "countersinkDiameter", diameter * 2) if countersink_diameter <= diameter: raise ValueError("countersinkDiameter must be larger than holeDiameter") params["countersink_diameter_mm"] = countersink_diameter params["countersink_angle_rad"] = math.radians(_number(values, "countersinkAngleDeg", 90.0, 1.0)) atomic = "hole_countersink" return {"atomic": atomic, "params": params} def _slot_frame(frame: dict[str, list[float]], picks: list[dict[str, Any]]) -> tuple[dict[str, list[float]], float]: if len(picks) < 2: raise ValueError("Select the two endpoints for the slot") first = _selection_frame({"pick": picks[0]}) second = _selection_frame({"pick": picks[1]}) vector = [second["origin_mm"][index] - first["origin_mm"][index] for index in range(3)] length = math.sqrt(sum(value * value for value in vector)) if length < 0.01: raise ValueError("Slot endpoints must be distinct") x_dir = [value / length for value in vector] normal = first["normal"] y_dir = [ normal[1] * x_dir[2] - normal[2] * x_dir[1], normal[2] * x_dir[0] - normal[0] * x_dir[2], normal[0] * x_dir[1] - normal[1] * x_dir[0], ] midpoint = [(first["origin_mm"][index] + second["origin_mm"][index]) / 2 for index in range(3)] return {"origin_mm": midpoint, "normal": normal, "x_dir": x_dir, "y_dir": y_dir}, length def apply_direct_edit( settings: Settings, store: WorkspaceStore, task_id: str, operation: str, selection: dict[str, Any], parameters: dict[str, Any], ) -> dict[str, Any]: if operation in {"add_chamfer", "add_fillet"}: raise ValueError("Chamfer and fillet require a stable CDSL edge anchor and are not available for this model yet") if operation not in SUPPORTED_OPERATIONS: raise ValueError(f"Unsupported direct CDSL edit: {operation}") source = store.current_cdsl_path(task_id) task = store.read_task(task_id) revision_id = str((task or {}).get("current_revision") or "") if source is None or not revision_id: raise ValueError("Task has no successful CDSL revision") current_revision = next( (item for item in (task or {}).get("revisions", []) if item.get("revision_id") == revision_id), {}, ) frame = _selection_frame(selection) cdsl = copy.deepcopy(json.loads(source.read_text(encoding="utf-8"))) features = cdsl.setdefault("features", []) sketches = cdsl.setdefault("geometry", {}).setdefault("sketches", []) picks = selection.get("picks") if isinstance(selection.get("picks"), list) else [] if operation == "add_slot": frame, slot_length = _slot_frame(frame, [pick for pick in picks if isinstance(pick, dict)]) parameters = {**parameters, "slotLength": slot_length} profile, depth = _profile_for(operation, parameters) feature_id = _next_id("edit", {str(item.get("id")) for item in features}) sketch_id = _next_id("edit_sketch", {str(item.get("id")) for item in sketches}) dependency = str(features[-1].get("id")) if features else "" sketches.append({"id": sketch_id, "name": operation, "workplane": frame, "profile": profile}) feature: dict[str, Any] = { "id": feature_id, "depends_on": [dependency] if dependency else [], "name": operation, "sketch_id": sketch_id, } if operation in {"add_hole", "add_counterbore", "add_countersink"}: hole = _hole_feature(operation, frame, parameters) feature["atomic_id"] = hole["atomic"] feature["params"] = hole["params"] else: feature["atomic_id"] = "extrude_cut_blind" feature["params"] = {"distance_mm": depth} features.append(feature) return build_revision( settings=settings, store=store, task_id=task_id, request=f"Direct CDSL edit: {operation}", cdsl=cdsl, reference_ids=[], summary=f"Applied {operation}", parent_revision_id=revision_id, operation={"type": operation, "selection": selection, "parameters": parameters}, part_skills=None, generation_assumptions=[], design_intent_id=str(current_revision.get("design_intent_id") or ""), design_intent_path=str(current_revision.get("design_intent_path") or ""), design_intent_status="accepted" if current_revision.get("design_intent_id") else "", )