Files
cdsl-cad/backend/engine/cdsl_engine/sketch_solver.py
T
likang 994d06aaea feat(selector): 增加离线候选遍历与严格回放验证 Demo
- 新增 selector_candidate_demo,移除 provenance intent 后枚举候选 selector
- 对候选分支执行有界重建与严格 STEP 比较
- 仅在候选遍历完整且唯一 strict 通过时生成 selector 映射记录
- 增加 selector 候选搜索、预算限制和记录生成的测试
- 保持生产 selector resolver 不受 Demo 逻辑影响
- 更新 CADFS 能力台账,记录 IMPRINT 派生 profile 的 lineage selector 缺口
2026-09-10 15:12:57 +08:00

710 lines
35 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,
source_entity_id: str | None = None,
) -> _Ctx:
output = {"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius_mm), "construction": construction}
if source_entity_id is not None:
output["source_entity_id"] = source_entity_id
return output
def _line(
start: list[float],
end: list[float],
construction: bool = False,
source_entity_id: str | None = None,
) -> _Ctx:
output = {"type": "line", "start": [float(start[0]), float(start[1])], "end": [float(end[0]), float(end[1])], "construction": construction}
if source_entity_id is not None:
output["source_entity_id"] = source_entity_id
return output
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)
if edge["type"] == "circle":
output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1])
output["x_dir_mm"] = _to_3d_vector(workplane, 1.0, 0.0)
output["normal"] = list(normal)
transformed.append(output)
continue
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 必须保留为一条完整的圆边。若拆成四条圆弧,后续按边
# 选择的圆角/倒角会把同一拓扑圆误解为四个独立目标。
source_entity_id = profile.get("source_entity_id")
return [_circle([cx, cy], radius, source_entity_id=source_entity_id if isinstance(source_entity_id, str) else None)], []
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]
edges = [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)]
source_entity_id = segment.get("source_entity_id")
if isinstance(source_entity_id, str) and source_entity_id:
# Region construction needs four arc segments, but this marker records
# that all four came from exactly one logical source circle. It is not
# an edge anchor: callers must rebuild one native circle wire and pass
# final-face identity checks before using it for lineage.
for edge in edges:
edge["logical_circle_source_entity_id"] = source_entity_id
return edges
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":
edges = [_contour_line(segment["start"], segment["end"])]
elif kind == "arc":
edges = [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))]
elif kind == "circle":
edges = _circle_edges(segment)
elif kind == "ellipse":
edges = _ellipse_edges(segment)
elif kind == "bspline":
points = segment.get("points") or []
if len(points) < 2:
raise ValueError("analytic_contours: bspline needs at least 2 interpolation points")
converted = [_point(point) for point in points]
periodic = bool(segment.get("periodic"))
start_tangent = segment.get("start_tangent")
end_tangent = segment.get("end_tangent")
if len(converted) == 2:
if periodic:
raise ValueError("analytic_contours: two-point bspline cannot be periodic")
if _distance(converted[0], converted[1]) <= _TOLERANCE_MM:
raise ValueError("analytic_contours: two-point bspline endpoints must be distinct")
if start_tangent is None or end_tangent is None:
raise ValueError("analytic_contours: two-point bspline requires both endpoint tangents")
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")
if len(converted) == 2 and parameters is None:
raise ValueError("analytic_contours: two-point bspline requires explicit parameters")
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 {}),
}
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)
edges = [output]
else:
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}")
source_entity_id = segment.get("source_entity_id")
# Only a one-edge construction has the direct, one-to-one source identity
# required by profile-to-prism lineage. Circles expanded to arcs carry a
# separate logical-circle marker, not a source edge identity; the adapter
# may use it only to reconstruct one native circle wire with exact final
# membership proof. Other multi-edge approximations remain unanchored.
if isinstance(source_entity_id, str) and len(edges) == 1:
edges[0]["source_entity_id"] = source_entity_id
return edges
def _imprint_segment_edges(segment: _Ctx) -> list[_Ctx]:
"""Convert an IMPRINT source while retaining its FeatureScript edge identity.
Closed contour assembly intentionally divides circles into four arcs so
that its loops have explicit vertices. An IMPRINT source id, however,
denotes one logical FeatureScript edge. Splitting that circle before the
OCC arrangement loses the one-to-one source/history mapping and makes a
valid fragment appear to be an ambiguous multi-edge source.
"""
if segment.get("type") != "circle":
return _segment_edges(segment)
center = segment.get("center") or [0.0, 0.0]
radius = float(segment.get("radius_mm") or 0.0)
if radius <= 0:
raise ValueError("planar_imprint: circle radius_mm must be > 0")
output: _Ctx = {
"type": "circle",
"center_mm": [float(center[0]), float(center[1]), 0.0],
"radius_mm": radius,
}
if "clockwise" in segment:
output["clockwise"] = bool(segment["clockwise"])
return [output]
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":
spline_points = edge["points_mm"]
if len(spline_points) == 2:
# GeomAPI_Interpolate with exactly two endpoint derivatives is
# the cubic Hermite curve over the explicit parameter span.
# There are no intermediate interpolation points to sample, so
# use its analytical points rather than incorrectly treating
# an otherwise valid curved loop as zero-area.
parameters = edge.get("parameters") or []
start_tangent = edge.get("start_tangent_mm")
end_tangent = edge.get("end_tangent_mm")
if len(parameters) != 2 or start_tangent is None or end_tangent is None:
raise ValueError("analytic_contours: two-point bspline sampling is unresolved")
parameter_span = float(parameters[1]) - float(parameters[0])
if parameter_span <= _TOLERANCE_MM:
raise ValueError("analytic_contours: two-point bspline parameter span is degenerate")
start_point, end_point = spline_points
for fraction in (0.25, 0.5, 0.75):
squared = fraction * fraction
cubed = squared * fraction
h00 = 2.0 * cubed - 3.0 * squared + 1.0
h10 = cubed - 2.0 * squared + fraction
h01 = -2.0 * cubed + 3.0 * squared
h11 = cubed - squared
points.append((
h00 * float(start_point[0]) + h10 * parameter_span * float(start_tangent[0])
+ h01 * float(end_point[0]) + h11 * parameter_span * float(end_tangent[0]),
h00 * float(start_point[1]) + h10 * parameter_span * float(start_tangent[1])
+ h01 * float(end_point[1]) + h11 * parameter_span * float(end_tangent[1]),
))
continue
points.extend((float(point[0]), float(point[1])) for point in spline_points[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"],
source_entity_id=segment.get("source_entity_id") if isinstance(segment.get("source_entity_id"), str) else None,
))
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),
source_entity_id=segment.get("source_entity_id") if isinstance(segment.get("source_entity_id"), str) else None,
))
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, []
def _gen_planar_imprint(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
"""Prepare source curves for an exact OCC planar-arrangement split.
Unlike ``analytic_contours``, these curves are intentionally not joined
into a guessed outer wire. FeatureScript's IMPRINT query identifies
regions in the arrangement of all source curves, including open curves
and split fragments, and the geometry adapter chooses those actual B-rep
regions after the split.
"""
source_entities: list[_Ctx] = []
for source in profile.get("source_entities") or []:
source_id = str(source.get("id") or "")
curve = source.get("curve") or {}
if not source_id:
raise ValueError("planar_imprint: source entity id is missing")
edges = _imprint_segment_edges(curve)
if not edges:
raise ValueError(f"planar_imprint: source entity {source_id!r} has no curve")
source_entities.append({"id": source_id, "edges": edges})
if len(source_entities) < 2:
raise ValueError("planar_imprint: at least two source entities are required")
meta["_imprint_entities"] = source_entities
meta["_imprint_selections"] = deepcopy(profile.get("selections") or [])
return [], []
CORE_SHAPE_GENERATORS: dict[str, Any] = {
"circle": _gen_circle,
"polygon": _gen_polygon,
"analytic_contours": _gen_analytic_contours,
"planar_imprint": _gen_planar_imprint,
}
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,
"_imprint_entities": [], "_imprint_selections": [],
}
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
if meta["_imprint_entities"]:
output["imprint_entities_mm"] = [
{
"id": entity["id"],
"edges": _transform_contours(entity["edges"], workplane) if workplane else entity["edges"],
}
for entity in meta["_imprint_entities"]
]
output["imprint_selections"] = meta["_imprint_selections"]
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