Files
cdsl-cad/backend/engine/cdsl_engine/sketch_solver.py
T
likang 738934416e feat(cadfs): 扩展重建引擎能力并固化代表性模型回归
- 扩展 CDSL engine 的 shell、sweep、loft、reference plane、pattern 等运行时能力,
  支持新的实体结果模式、双向拉伸、曲线扫掠、镜像/圆周阵列及相关 selector 解析。
- 完善 Build123d 适配层的拓扑快照、Compound/ShapeList 兼容处理和旋转曲面识别,
  兼容 Python 3.12 / 当前 Build123d 缺少 axis_of_rotation 的合法曲面场景。
- 扩展 CDSL schema、profile schema、capability analysis、semantic validation 和
  sketch solver,使新增建模操作能够被校验、执行并保留可诊断的部分结果。
- 完善 CADFS FeatureScript lowering:
  支持 shell、sweep、surface/实体 loft、圆周阵列副本、镜像副本、删除阵列实例、
  新 body 操作、更多拉伸终止条件和 reference plane 变体。
- 补齐椭圆、B-spline、环形区域、imprint、SWEPT_FACE、CAP_FACE、OFFSET_FACE 等
  草图和拓扑引用的转换逻辑,改善后续特征的工作平面、轴线和 profile 定位精度。
- 改进 selector binding:支持 pattern 前缀复合 B-rep 快照、交集顶点引用、
  多面 match_mode=all、圆柱轴线/半径和面积下限等稳定匹配条件。
- 修复 MID_PLANE 法向统一后交线方向未同步的问题,恢复 00287955 基准面的正确位置;
  修复 00542223 sweep 路径反转后的切线契约和 00423838 的拓扑面数不稳定测试假设。
- 修正 CADFS 比较模块 import 路径,补充重建报告、批量重建脚本、目标文档和 README。
- 新增并扩展 engine、lowering、parser、selector binding、reports、integration 和
  Onshape pipeline 回归测试,覆盖代表性 CADFS 特征链及运行时兼容性。
2026-09-08 11:47:10 +08:00

554 lines
27 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/ellipse/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 _to_3d_vector(workplane: _Ctx, u: float, v: float) -> list[float]:
origin = _to_3d(workplane, 0.0, 0.0)
target = _to_3d(workplane, u, v)
return [target[index] - origin[index] for index in range(3)]
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"] == "ellipse":
output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1])
output["major_axis_mm"] = _to_3d_vector(workplane, edge["major_axis_mm"][0], edge["major_axis_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"]
]
for key in ("start_tangent_mm", "end_tangent_mm"):
if key in edge:
tangent = edge[key]
output[key] = _to_3d_vector(workplane, tangent[0], tangent[1])
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])
# 直接圆 profile 必须保留为一条完整的圆边。若拆成四条圆弧,后续按边
# 选择的圆角/倒角会把同一拓扑圆误解为四个独立目标。
return [_circle([cx, cy], radius)], []
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 _centripetal_parameters(points: list[list[float]], periodic: bool) -> list[float]:
pairs = list(zip(points, points[1:]))
if periodic:
pairs.append((points[-1], points[0]))
parameters = [0.0]
for start, end in pairs:
distance = math.dist(start, end)
if distance <= _TOLERANCE_MM:
raise ValueError("analytic_contours: centripetal bspline has coincident interpolation points")
parameters.append(parameters[-1] + math.sqrt(distance))
return parameters
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"]))
parameters = output.get("parameters")
if parameters is not None:
final_parameter = float(parameters[-1])
output["parameters"] = [final_parameter - float(value) for value in reversed(parameters)]
start_tangent = output.pop("start_tangent_mm", None)
end_tangent = output.pop("end_tangent_mm", None)
if end_tangent is not None:
output["start_tangent_mm"] = [-float(value) for value in end_tangent]
if start_tangent is not None:
output["end_tangent_mm"] = [-float(value) for value in start_tangent]
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 _ellipse_edges(segment: _Ctx) -> list[_Ctx]:
center = segment.get("center") or [0.0, 0.0]
major_radius = float(segment.get("major_radius_mm") or 0.0)
minor_radius = float(segment.get("minor_radius_mm") or 0.0)
major_axis = segment.get("major_axis") or []
if major_radius <= 0 or minor_radius <= 0:
raise ValueError("analytic_contours: ellipse radii must be > 0")
if len(major_axis) < 2:
raise ValueError("analytic_contours: ellipse major_axis must have two components")
axis_length = math.hypot(float(major_axis[0]), float(major_axis[1]))
if axis_length <= _TOLERANCE_MM:
raise ValueError("analytic_contours: ellipse major_axis is degenerate")
cx, cy = float(center[0]), float(center[1])
ux, uy = float(major_axis[0]) / axis_length, float(major_axis[1]) / axis_length
start = [cx + major_radius * ux, cy + major_radius * uy, 0.0]
return [{
"type": "ellipse", "start_mm": start, "end_mm": list(start), "center_mm": [cx, cy, 0.0],
"major_axis_mm": [ux, uy, 0.0], "major_radius_mm": major_radius, "minor_radius_mm": minor_radius,
}]
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 == "ellipse":
return _ellipse_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]
periodic = bool(segment.get("periodic"))
if periodic:
if _distance(converted[0], converted[-1]) > _TOLERANCE_MM:
raise ValueError("analytic_contours: periodic bspline endpoints do not meet")
# The duplicated closing interpolation point describes topology,
# not an additional periodic interpolation constraint. OCC's
# periodic interpolator receives each unique point exactly once.
interpolation_points = converted[:-1]
else:
interpolation_points = converted
parameterization = segment.get("parameterization")
if parameterization not in {None, "chord", "centripetal"}:
raise ValueError(f"analytic_contours: unsupported bspline parameterization {parameterization!r}")
parameters = segment.get("parameters")
if parameters is not None:
expected_count = len(interpolation_points) + int(periodic)
if len(parameters) != expected_count:
raise ValueError("analytic_contours: bspline parameter count does not match interpolation points")
parameters = [float(value) for value in parameters]
if not all(math.isfinite(value) for value in parameters):
raise ValueError("analytic_contours: bspline parameters must be finite")
if any(right - left <= _TOLERANCE_MM for left, right in zip(parameters, parameters[1:])):
raise ValueError("analytic_contours: bspline parameters must be strictly increasing")
output: _Ctx = {
"type": "bspline",
"start_mm": converted[0],
"end_mm": converted[-1],
"points_mm": interpolation_points,
"periodic": periodic,
**({"parameters": parameters} if parameters is not None else {}),
**({"parameters": _centripetal_parameters(interpolation_points, periodic)} if parameters is None and parameterization == "centripetal" else {}),
}
start_tangent = segment.get("start_tangent")
end_tangent = segment.get("end_tangent")
if (start_tangent is None) != (end_tangent is None):
raise ValueError("analytic_contours: bspline requires both endpoint tangents")
if start_tangent is not None:
if periodic:
raise ValueError("analytic_contours: periodic bspline does not accept endpoint tangents")
output["start_tangent_mm"] = _point(start_tangent)
output["end_tangent_mm"] = _point(end_tangent)
return [output]
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") == "ellipse":
center, axis = edge["center_mm"], edge["major_axis_mm"]
major_radius = float(edge["major_radius_mm"])
minor_radius = float(edge["minor_radius_mm"])
normal = edge.get("normal") or [0.0, 0.0, 1.0]
axis_length = math.sqrt(sum(float(value) * float(value) for value in axis))
normal_length = math.sqrt(sum(float(value) * float(value) for value in normal))
if axis_length <= _TOLERANCE_MM or normal_length <= _TOLERANCE_MM:
raise ValueError("analytic_contours: ellipse axis is degenerate")
x_axis = [float(value) / axis_length for value in axis]
z_axis = [float(value) / normal_length for value in normal]
y_axis = [z_axis[1] * x_axis[2] - z_axis[2] * x_axis[1], z_axis[2] * x_axis[0] - z_axis[0] * x_axis[2], z_axis[0] * x_axis[1] - z_axis[1] * x_axis[0]]
for step in range(1, 8):
angle = math.tau * step / 8
points.append((
float(center[0]) + major_radius * math.cos(angle) * x_axis[0] + minor_radius * math.sin(angle) * y_axis[0],
float(center[1]) + major_radius * math.cos(angle) * x_axis[1] + minor_radius * math.sin(angle) * y_axis[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 []):
# 开放轮廓(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, circle, ellipse and B-spline 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