Files
cdsl-cad/backend/engine/cdsl_engine/sketch_solver.py
T
2026-08-25 17:41:24 +08:00

368 lines
17 KiB
Python

"""Core CDSL sketch resolver.
The runtime accepts only direct geometric descriptions: circles, straight-edge
polygons, and closed analytic line/arc/circle contours. Semantic shapes and
historical profile macros belong to the importer compatibility layer and must
be lowered before this module is invoked.
"""
from __future__ import annotations
import math
from copy import deepcopy
from typing import Any, Iterable
_Ctx = dict[str, Any]
_TOLERANCE_MM = 1e-5
def _circle(center: list[float], radius_mm: float, construction: bool = False) -> _Ctx:
return {"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius_mm), "construction": construction}
def _line(start: list[float], end: list[float], construction: bool = False) -> _Ctx:
return {"type": "line", "start": [float(start[0]), float(start[1])], "end": [float(end[0]), float(end[1])], "construction": construction}
def _point(point: list[float]) -> list[float]:
return [float(point[0]), float(point[1]), float(point[2]) if len(point) > 2 else 0.0]
def _contour_line(start: list[float], end: list[float]) -> _Ctx:
return {"type": "line", "start_mm": _point(start), "end_mm": _point(end)}
def _contour_arc(start: list[float], end: list[float], center: list[float], radius: float | None, clockwise: bool | None = None) -> _Ctx:
edge: _Ctx = {"type": "arc", "start_mm": _point(start), "end_mm": _point(end), "center_mm": _point(center), "radius_mm": float(radius) if radius is not None else None}
if clockwise is not None:
edge["clockwise"] = bool(clockwise)
return edge
def _to_3d(workplane: _Ctx, u: float, v: float) -> list[float]:
origin = workplane.get("origin_mm") or [0, 0, 0]
x_dir = workplane.get("x_dir") or [1, 0, 0]
normal = workplane.get("normal") or [0, 0, 1]
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],
]
return [origin[0] + u * x_dir[0] + v * y_dir[0], origin[1] + u * x_dir[1] + v * y_dir[1], origin[2] + u * x_dir[2] + v * y_dir[2]]
def _transform_contours(contours: list[_Ctx], workplane: _Ctx) -> list[_Ctx]:
transformed: list[_Ctx] = []
normal = workplane.get("normal") or [0, 0, 1]
for edge in contours:
output = deepcopy(edge)
output["start_mm"] = _to_3d(workplane, edge["start_mm"][0], edge["start_mm"][1])
output["end_mm"] = _to_3d(workplane, edge["end_mm"][0], edge["end_mm"][1])
if edge["type"] == "arc":
output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1])
output["normal"] = list(normal)
transformed.append(output)
return transformed
def _gen_circle(profile: _Ctx, _: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
center = profile.get("center") or [0.0, 0.0]
radius = float(profile.get("radius_mm") or 0.0)
if radius <= 0:
raise ValueError("circle radius must be > 0")
cx, cy = float(center[0]), float(center[1])
points = [[cx + radius, cy, 0.0], [cx, cy + radius, 0.0], [cx - radius, cy, 0.0], [cx, cy - radius, 0.0], [cx + radius, cy, 0.0]]
return [_circle([cx, cy], radius)], [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius) for index in range(4)]
def _gen_polygon(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
vertices = profile.get("vertices") or []
if len(vertices) < 3:
entities = meta.get("_entities") or []
if not entities:
raise ValueError("polygon needs at least 3 vertices")
return list(entities), [_contour_line(edge["start"], edge["end"]) for edge in entities if edge.get("type") == "line"]
points = [(float(vertex[0]), float(vertex[1])) for vertex in vertices]
return (
[_line(list(points[index]), list(points[(index + 1) % len(points)])) for index in range(len(points))],
[_contour_line([*points[index], 0.0], [*points[(index + 1) % len(points)], 0.0]) for index in range(len(points))],
)
def _distance(left: list[float], right: list[float]) -> float:
return math.hypot(float(left[0]) - float(right[0]), float(left[1]) - float(right[1]))
def _reverse(edge: _Ctx) -> _Ctx:
output = deepcopy(edge)
output["start_mm"], output["end_mm"] = output["end_mm"], output["start_mm"]
if output.get("type") == "arc" and "clockwise" in output:
output["clockwise"] = not bool(output["clockwise"])
return output
def _join(edges: list[_Ctx]) -> list[_Ctx]:
if not edges:
return []
remaining = [deepcopy(edge) for edge in edges]
ordered = [remaining.pop(0)]
while remaining:
tail = ordered[-1]["end_mm"]
for index, candidate in enumerate(remaining):
if _distance(tail, candidate["start_mm"]) <= _TOLERANCE_MM:
ordered.append(remaining.pop(index))
break
if _distance(tail, candidate["end_mm"]) <= _TOLERANCE_MM:
ordered.append(_reverse(remaining.pop(index)))
break
else:
raise ValueError("analytic_contours: segments do not form a connected contour")
if _distance(ordered[0]["start_mm"], ordered[-1]["end_mm"]) > _TOLERANCE_MM:
raise ValueError("analytic_contours: closed contour endpoints do not meet")
return ordered
def _circle_edges(segment: _Ctx) -> list[_Ctx]:
center = segment.get("center") or [0.0, 0.0]
radius = float(segment.get("radius_mm") or 0.0)
if radius <= 0:
raise ValueError("analytic_contours: circle radius_mm must be > 0")
cx, cy = float(center[0]), float(center[1])
clockwise = bool(segment.get("clockwise", False))
angles = [0.0, -90.0, -180.0, -270.0, -360.0] if clockwise else [0.0, 90.0, 180.0, 270.0, 360.0]
points = [[cx + radius * math.cos(math.radians(angle)), cy + radius * math.sin(math.radians(angle)), 0.0] for angle in angles]
return [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)]
def _segment_edges(segment: _Ctx) -> list[_Ctx]:
kind = segment.get("type")
if kind == "line":
return [_contour_line(segment["start"], segment["end"])]
if kind == "arc":
return [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))]
if kind == "circle":
return _circle_edges(segment)
if kind == "bspline":
raise ValueError("analytic_contours: bspline requires an explicit approximation capability")
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}")
def _sample_loop(edges: list[_Ctx]) -> list[tuple[float, float]]:
points: list[tuple[float, float]] = []
for edge in edges:
start = edge["start_mm"]
points.append((float(start[0]), float(start[1])))
if edge.get("type") != "arc":
continue
center, end = edge["center_mm"], edge["end_mm"]
start_angle = math.atan2(float(start[1]) - float(center[1]), float(start[0]) - float(center[0]))
end_angle = math.atan2(float(end[1]) - float(center[1]), float(end[0]) - float(center[0]))
delta = end_angle - start_angle
if edge.get("clockwise"):
if delta >= 0:
delta -= math.tau
elif delta <= 0:
delta += math.tau
radius = float(edge.get("radius_mm") or _distance(start, center))
for fraction in (0.25, 0.5, 0.75):
angle = start_angle + delta * fraction
points.append((float(center[0]) + radius * math.cos(angle), float(center[1]) + radius * math.sin(angle)))
return points
def _normalize_quarter_rounding_direction(edges: list[_Ctx]) -> None:
"""Repair inconsistent direction flags on a conventional rounded box.
The rule only applies to the unambiguous case of four equal 90-degree
corner arcs. It is geometry normalization, not a semantic shape macro.
"""
arcs = [edge for edge in edges if edge.get("type") == "arc"]
if len(arcs) != 4:
return
radii = [float(edge.get("radius_mm") or 0.0) for edge in arcs]
if min(radii) <= _TOLERANCE_MM or max(radii) - min(radii) > _TOLERANCE_MM:
return
for edge in arcs:
center = edge.get("center_mm")
if not isinstance(center, list):
return
start, end = edge["start_mm"], edge["end_mm"]
first = (float(start[0]) - float(center[0]), float(start[1]) - float(center[1]))
second = (float(end[0]) - float(center[0]), float(end[1]) - float(center[1]))
angle = abs(math.atan2(first[0] * second[1] - first[1] * second[0], first[0] * second[0] + first[1] * second[1]))
if abs(angle - math.pi / 2) > 1e-4:
return
points = [(float(edge["start_mm"][0]), float(edge["start_mm"][1])) for edge in edges]
clockwise = sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1] for index in range(len(points))) < 0.0
for edge in arcs:
edge["clockwise"] = clockwise
def _area(points: list[tuple[float, float]]) -> float:
return abs(sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1] for index in range(len(points))) / 2.0) if len(points) >= 3 else 0.0
def _contains(point: tuple[float, float], loop: list[tuple[float, float]]) -> bool:
inside = False
x, y = point
previous = loop[-1]
for current in loop:
if (current[1] > y) != (previous[1] > y):
crossing = (previous[0] - current[0]) * (y - current[1]) / (previous[1] - current[1]) + current[0]
if x < crossing:
inside = not inside
previous = current
return inside
def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
loops: list[_Ctx] = []
entities: list[_Ctx] = []
for index, contour in enumerate(profile.get("contours") or []):
if not contour.get("closed"):
raise ValueError(f"analytic_contours: contour {index} is open")
raw_edges: list[_Ctx] = []
for segment in contour.get("segments") or []:
if segment.get("type") == "line":
entities.append(_line(segment["start"], segment["end"]))
elif segment.get("type") == "circle":
entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0)))
raw_edges.extend(_segment_edges(segment))
if raw_edges:
edges = _join(raw_edges)
_normalize_quarter_rounding_direction(edges)
sample = _sample_loop(edges)
if _area(sample) <= _TOLERANCE_MM * _TOLERANCE_MM:
raise ValueError(f"analytic_contours: contour {index} is degenerate")
loops.append({"edges": edges, "points": sample, "area": _area(sample)})
for segment in profile.get("construction") or []:
if segment.get("type") == "line":
entities.append(_line(segment["start"], segment["end"], construction=True))
elif segment.get("type") == "circle":
entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0), construction=True))
if not loops:
return entities, []
for loop in loops:
loop["role"] = "inner" if sum(_contains(loop["points"][0], other["points"]) for other in loops if other is not loop) % 2 else "outer"
outers = [loop for loop in loops if loop["role"] == "outer"]
regions = [{"outer": outer["edges"], "holes": []} for outer in outers]
for inner in (loop for loop in loops if loop["role"] == "inner"):
containing = [outer for outer in outers if _contains(inner["points"][0], outer["points"])]
if not containing:
raise ValueError("analytic_contours: inner contour has no containing outer contour")
selected = min(containing, key=lambda outer: outer["area"])
regions[outers.index(selected)]["holes"].append(inner["edges"])
meta["_regions"] = regions
return entities, []
CORE_SHAPE_GENERATORS: dict[str, Any] = {"circle": _gen_circle, "polygon": _gen_polygon, "analytic_contours": _gen_analytic_contours}
SHAPE_GENERATORS = CORE_SHAPE_GENERATORS
SHAPE_CAPABILITIES: dict[str, _Ctx] = {
"circle": {"detectable": True, "arity": "circle", "description": "single circular contour"},
"polygon": {"detectable": True, "arity": "polygon", "description": "closed straight-edge contour"},
"analytic_contours": {"detectable": True, "arity": "analytic", "description": "closed line, arc, and circle contours"},
}
def register_shape(_: str, __: Any) -> None:
raise RuntimeError("Runtime profile types are fixed; lower custom profiles before CDSL execution")
def list_registered_shapes() -> list[str]:
return sorted(SHAPE_GENERATORS)
def resolve_profile(sketch: _Ctx) -> _Ctx:
profile = sketch.get("profile")
if not profile:
return sketch
generator = SHAPE_GENERATORS.get(profile.get("type"))
if generator is None:
raise ValueError(f"sketch {sketch.get('id')}: unsupported profile type {profile.get('type')!r}")
meta: _Ctx = {"id": sketch.get("id"), "_entities": sketch.get("entities"), "_regions": []}
entities, contour = generator(profile, meta)
output = deepcopy(sketch)
original_circles = [entity for entity in sketch.get("entities") or [] if entity.get("type") == "circle" and not entity.get("construction")]
output["entities"] = list(entities) + (original_circles if contour else [])
workplane = sketch.get("workplane")
if contour:
output["contour_edges_mm"] = _transform_contours(contour, workplane) if workplane else contour
if meta["_regions"]:
output["contour_regions_mm"] = [
{"outer": _transform_contours(region["outer"], workplane) if workplane else region["outer"], "holes": [_transform_contours(hole, workplane) if workplane else hole for hole in region.get("holes") or []]}
for region in meta["_regions"]
]
return output
def _shift_profile(sketch: _Ctx, source: _Ctx) -> _Ctx:
output = deepcopy(sketch)
output["profile"] = deepcopy(source["profile"])
output.pop("profile_from", None)
shift = sketch.get("profile_shift")
if shift and len(shift) == 2 and output["profile"].get("type") == "polygon":
for vertex in output["profile"]["vertices"]:
vertex[0], vertex[1] = round(float(vertex[0]) + float(shift[0]), 6), round(float(vertex[1]) + float(shift[1]), 6)
output.pop("profile_shift", None)
return output
def resolve_all_sketches(cdsl: _Ctx) -> _Ctx:
sketches = list((cdsl.get("geometry") or {}).get("sketches") or [])
resolved: dict[str, _Ctx] = {}
for sketch in sketches:
sketch_id = sketch.get("id")
if sketch_id is not None and "profile" in sketch:
resolved[str(sketch_id)] = resolve_profile(sketch)
for sketch in sketches:
sketch_id, source_id = sketch.get("id"), sketch.get("profile_from")
if sketch_id is not None and source_id:
source = resolved.get(str(source_id))
if source is None:
raise ValueError(f"sketch {sketch_id}: profile_from={source_id!r} not found or not yet resolved")
resolved[str(sketch_id)] = resolve_profile(_shift_profile(sketch, source))
output = deepcopy(cdsl)
output.setdefault("geometry", {})["sketches"] = [resolved.get(str(sketch.get("id")), deepcopy(sketch)) for sketch in sketches]
return output
def resolve_required_sketches(cdsl: _Ctx, sketch_ids: Iterable[str], *, errors: dict[str, str] | None = None) -> _Ctx:
sketches = list((cdsl.get("geometry") or {}).get("sketches") or [])
by_id = {str(sketch.get("id")): sketch for sketch in sketches if sketch.get("id") is not None}
resolved: dict[str, _Ctx] = {}
resolving: set[str] = set()
def resolve_one(sketch_id: str) -> _Ctx:
if sketch_id in resolved:
return resolved[sketch_id]
sketch = by_id.get(sketch_id)
if sketch is None:
raise ValueError(f"sketch {sketch_id!r} was not found")
if sketch_id in resolving:
raise ValueError(f"sketch {sketch_id}: profile_from contains a cycle")
resolving.add(sketch_id)
try:
if "profile" in sketch:
output = resolve_profile(sketch)
elif sketch.get("profile_from"):
output = resolve_profile(_shift_profile(sketch, resolve_one(str(sketch["profile_from"]))))
else:
output = deepcopy(sketch)
resolved[sketch_id] = output
return output
finally:
resolving.discard(sketch_id)
for sketch_id in {str(item) for item in sketch_ids}:
try:
resolve_one(sketch_id)
except ValueError as error:
if errors is None:
raise
errors[sketch_id] = str(error)
output = deepcopy(cdsl)
output.setdefault("geometry", {})["sketches"] = [resolved.get(str(sketch.get("id")), deepcopy(sketch)) for sketch in sketches]
return output