Files
cdsl-cad/backend/app/services/editing.py
T
2026-08-26 14:13:11 +08:00

224 lines
10 KiB
Python

from __future__ import annotations
import copy
import json
import math
from typing import Any
from app.services.engine_service import build_revision, load_engine
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))
length = max(length, width)
radius = width / 2
left, right = -length / 2 + radius, length / 2 - radius
return {"type": "analytic_contours", "contours": [{"role": "outer", "closed": True, "segments": [
{"type": "line", "start": [right, radius], "end": [left, radius]},
{"type": "arc", "start": [left, radius], "end": [left, -radius], "center": [left, 0.0], "radius_mm": radius},
{"type": "line", "start": [left, -radius], "end": [right, -radius]},
{"type": "arc", "start": [right, -radius], "end": [right, radius], "center": [right, 0.0], "radius_mm": radius},
]}]}, depth
if operation == "add_pocket":
width = _number(values, "width", 20.0)
height = _number(values, "height", 12.0)
return {"type": "polygon", "vertices": [
[-width / 2, -height / 2], [width / 2, -height / 2],
[width / 2, height / 2], [-width / 2, height / 2],
]}, 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)))
pitch_x = _number(values, "pitchX", 12.0)
pitch_y = _number(values, "pitchY", 12.0)
return {"type": "analytic_contours", "contours": [
{"role": "outer", "closed": True, "segments": [{"type": "circle", "center": [
(column - (columns - 1) / 2) * pitch_x,
(row - (rows - 1) / 2) * pitch_y,
], "radius_mm": diameter / 2}]}
for row in range(rows) for column in range(columns)
]}, 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],
*,
node_id: str = "",
branch_id: str = "main",
visibility: str = "final",
) -> 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")))
# Historical revisions can contain importer-only profile macros. An edit
# creates a new CDSL-only revision, so lower those profiles explicitly
# before adding the new generic feature.
load_engine(settings)
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
lowered_cdsl = lower_legacy_profiles(cdsl)
legacy_profiles_lowered = lowered_cdsl != cdsl
cdsl = lowered_cdsl
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,
"legacy_profiles_lowered": legacy_profiles_lowered,
},
part_skills=None,
generation_assumptions=["Legacy profile macros were lowered to direct analytic contours before this edit."] if legacy_profiles_lowered else [],
node_id=node_id,
branch_id=branch_id,
visibility=visibility,
)