038d38ed98
- 新增 loft、双向切除、through-all/up-to-next 等 CADFS lowering 与 engine 支持 - 支持多种 reference plane、B-spline profile 和 circular pattern replay - 保留 transform 历史,并烘焙安全的单源平移/旋转变换 - 改进 selector 绑定、拓扑快照和 pattern 变换处理 - 建立 17 个代表样本的转换、重建与比较回归工具链 - 补充 schema、author guidance、运行时和几何回归测试
406 lines
18 KiB
Python
406 lines
18 KiB
Python
"""Core CDSL sketch resolver.
|
|
|
|
The runtime accepts only direct geometric descriptions: circles, straight-edge
|
|
polygons, and closed analytic line/arc/circle/B-spline 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)
|
|
elif edge["type"] == "bspline":
|
|
output["points_mm"] = [
|
|
_to_3d(workplane, point[0], point[1])
|
|
for point in edge["points_mm"]
|
|
]
|
|
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"])
|
|
if output.get("type") == "bspline":
|
|
output["points_mm"] = list(reversed(output["points_mm"]))
|
|
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":
|
|
points = segment.get("points") or []
|
|
if len(points) < 3:
|
|
raise ValueError("analytic_contours: bspline needs at least 3 interpolation points")
|
|
converted = [_point(point) for point in points]
|
|
return [{
|
|
"type": "bspline",
|
|
"start_mm": converted[0],
|
|
"end_mm": converted[-1],
|
|
"points_mm": converted,
|
|
}]
|
|
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") == "bspline":
|
|
points.extend((float(point[0]), float(point[1])) for point in edge["points_mm"][1:-1])
|
|
continue
|
|
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
|