"""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