197 lines
9.1 KiB
Python
197 lines
9.1 KiB
Python
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 "",
|
|
)
|