Files
cdsl-cad/backend/engine/cdsl_engine/sketch_solver.py
T

419 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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_raw = workplane.get("y_dir")
y_dir = _default_y_dir(x_dir, normal)
if y_raw:
magnitude = math.sqrt(sum(component * component for component in y_raw))
if magnitude > 1e-12:
y_unit = [component / magnitude for component in y_raw]
# 与 PlaneSpec.from_mapping 同策略:只有与 x_dir / normal 正交的
# y_dir 才尊重(SolidWorks 导出的 y_dir==x_dir 占位数据与 X 平行,
# 直接使用会让轮廓塌缩成一条线,必须回退到 normal×x_dir)。
if abs(_dot(y_unit, x_dir)) <= 1e-6 and abs(_dot(y_unit, normal)) <= 1e-6:
y_dir = y_unit
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 _dot(left: Iterable[float], right: Iterable[float]) -> float:
return sum(a * b for a, b in zip(left, right))
def _default_y_dir(x_dir: Iterable[float], normal: Iterable[float]) -> list[float]:
x, n = list(x_dir), list(normal)
return [
n[1] * x[2] - n[2] * x[1],
n[2] * x[0] - n[0] * x[2],
n[0] * x[1] - n[1] * x[0],
]
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], *, allow_open: bool = False) -> list[_Ctx]:
# 将边排序成一条连通链。allow_open=False(默认)要求首尾相接成闭合环;
# allow_open=True 时允许链首尾不接(供开放轮廓先拼链、后补闭合边)。
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 not allow_open and _distance(ordered[0]["start_mm"], ordered[-1]["end_mm"]) > _TOLERANCE_MM:
raise ValueError("analytic_contours: closed contour endpoints do not meet")
return ordered
def _close_open_contour(edges: list[_Ctx]) -> tuple[list[_Ctx], bool]:
# 开放链补闭合边:若首尾未相接,则沿两点连线补一条直线边形成闭合环。
# 返回 (edges, opened)opened=False 表示首尾已天然相接(无需补边)。
if len(edges) < 2:
raise ValueError("analytic_contours: open contour needs at least 2 connected segments")
if _distance(edges[0]["start_mm"], edges[-1]["end_mm"]) <= _TOLERANCE_MM:
return edges, False
closing = _contour_line(edges[-1]["end_mm"][:2], edges[0]["start_mm"][:2])
return [*edges, closing], True
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 []):
# 开放轮廓(closed=false / role=open):先按开放链拼装,再在首尾间补一条
# 闭合边成闭合 region —— V 槽/开放型腔切除的刀具截面本就靠这条"槽口边"
# 闭合,故 B-rep 层可完全复用闭合链路。
contour_open = not bool(contour.get("closed", False))
if contour_open and contour.get("role") == "inner":
raise ValueError(f"analytic_contours: inner contour {index} cannot be 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":
if contour_open:
raise ValueError(f"analytic_contours: open contour {index} cannot contain a full circle segment")
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, allow_open=contour_open)
contour_opened = False
if contour_open:
if _distance(edges[0]["start_mm"], edges[-1]["end_mm"]) <= _TOLERANCE_MM:
raise ValueError(f"analytic_contours: contour {index} is geometrically closed; use closed=true")
edges, contour_opened = _close_open_contour(edges)
meta["_has_open_contour"] = True
_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),
"open": contour_open and contour_opened})
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": [], "open": bool(outer.get("open"))} 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")
if any(outer.get("open") for outer in containing):
raise ValueError("analytic_contours: an open outer contour cannot contain nested holes")
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": [], "_has_open_contour": False}
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 []],
"open": bool(region.get("open"))}
for region in meta["_regions"]
]
if meta["_has_open_contour"]:
output["_open_contour"] = True
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