994d06aaea
- 新增 selector_candidate_demo,移除 provenance intent 后枚举候选 selector - 对候选分支执行有界重建与严格 STEP 比较 - 仅在候选遍历完整且唯一 strict 通过时生成 selector 映射记录 - 增加 selector 候选搜索、预算限制和记录生成的测试 - 保持生产 selector resolver 不受 Demo 逻辑影响 - 更新 CADFS 能力台账,记录 IMPRINT 派生 profile 的 lineage selector 缺口
710 lines
35 KiB
Python
710 lines
35 KiB
Python
"""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
|