Files
cdsl-cad/backend/app/services/editing.py
T
2026-08-19 19:34:30 +08:00

188 lines
8.7 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")
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},
)