from __future__ import annotations import math from copy import deepcopy from dataclasses import dataclass from typing import Any from .featurescript_parser import symbolic_string from .ir import Call, FeatureIR, ModelIR, SketchIR from .query_parser import parse_query, walk_calls UNSUPPORTED = {"draft", "thicken", "split", "moveFace", "replaceFace", "deleteFace", "import", "derive"} PLANES = { "Top": {"origin_mm": [0., 0., 0.], "x_dir": [1., 0., 0.], "normal": [0., 0., 1.]}, "Front": {"origin_mm": [0., 0., 0.], "x_dir": [1., 0., 0.], "normal": [0., -1., 0.]}, "Right": {"origin_mm": [0., 0., 0.], "x_dir": [0., 1., 0.], "normal": [1., 0., 0.]}, } # CADFS 未携带标准孔表的实体定义。下列条目由 source STEP 验证:该组合在 # CADFS 的 B-rep 中不是普通 blind counterbore,而是简化为贯穿的攻丝孔。 # 不能为未知目录项推测尺寸;未列入的孔仍按显式 FeatureScript 尺寸 lower。 _STANDARD_TAPPED_THROUGH_BORE_DIAMETERS = { ("ISO", "M10", "Clearance & tapped"): 15.0, } @dataclass class LoweringResult: cdsl: dict[str, Any] | None status: str diagnostics: list[dict[str, Any]] history: list[dict[str, Any]] class UnsupportedCapability(ValueError): def __init__(self, capability: str, message: str): super().__init__(message); self.capability = capability def plain(value: Any) -> Any: if isinstance(value, Call): return {"call": value.name, "args": [plain(arg) for arg in value.args], "line": value.line} if isinstance(value, list): return [plain(item) for item in value] if isinstance(value, dict): return {key: plain(item) for key, item in value.items()} return value def _bool(value: Any) -> bool: return value is True or (isinstance(value, str) and value.lower() == "true") def _number(value: Any, units: bool = False) -> float: if isinstance(value, (float, int)): return float(value) if isinstance(value, str): constants = {"mm": 1., "millimeter": 1., "cm": 10., "m": 1000., "inch": 25.4, "in": 25.4, "ft": 304.8, "degree": 1.} if value in constants: return constants[value] return float(value) if isinstance(value, Call) and value.name == "__binary__": left, op, right = value.args; a, b = _number(left, units), _number(right, units) return {"+": a + b, "-": a - b, "*": a * b, "/": a / b}[str(op)] raise ValueError(f"not a constant number: {plain(value)!r}") def _lookup_table_definition(value: Any) -> dict[str, str]: for call in walk_calls(value): if call.name != "lookupTablePath" or not call.args or not isinstance(call.args[0], dict): continue return {str(key): str(item) for key, item in call.args[0].items() if isinstance(item, (str, int, float))} return {} def _standard_tapped_through_bore_diameter(params: dict[str, Any], style: str, end_style: str) -> float | None: if style.upper() not in {"COUNTERBORE", "C_BORE"} or "BLIND" not in end_style or not _bool(params.get("isTappedThrough")): return None definition = _lookup_table_definition(params.get("standardBlindInLast")) return _STANDARD_TAPPED_THROUGH_BORE_DIAMETERS.get((definition.get("standard", ""), definition.get("size", ""), definition.get("type", ""))) def _point(value: Any) -> list[float]: if isinstance(value, Call) and value.name == "__binary__" and value.args[1] == "*": scale = _number(value.args[2], True); point = _point(value.args[0]); return [v * scale for v in point] if isinstance(value, Call) and value.name in {"v", "vector"} and len(value.args) >= 2: return [_number(value.args[0]), _number(value.args[1])] if isinstance(value, list) and len(value) >= 2: return [_number(value[0]), _number(value[1])] raise ValueError(f"not a 2D point: {plain(value)!r}") def _cross(a: list[float], b: list[float]) -> list[float]: return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]] def _dot(a: list[float], b: list[float]) -> float: return sum(left * right for left, right in zip(a, b)) def _unit(value: list[float], message: str) -> list[float]: length = math.sqrt(_dot(value, value)) if length <= 1e-9: raise ValueError(message) return [component / length for component in value] def _sub(a: list[float], b: list[float]) -> list[float]: return [a[index] - b[index] for index in range(3)] def _rotate(value: list[float], axis: list[float], angle_rad: float) -> list[float]: axis = _unit(axis, "rotation axis is degenerate") cosine, sine = math.cos(angle_rad), math.sin(angle_rad) cross = _cross(axis, value); projection = _dot(axis, value) * (1.0 - cosine) return [value[index] * cosine + cross[index] * sine + axis[index] * projection for index in range(3)] def _translate_frame(frame: dict[str, Any], offset: list[float]) -> dict[str, Any]: return {**frame, "origin_mm": [frame["origin_mm"][index] + offset[index] for index in range(3)]} def _rotate_point(point: list[float], axis: dict[str, list[float]], angle_rad: float) -> list[float]: relative = _rotate(_sub(point, axis["origin_mm"]), axis["direction"], angle_rad) return [axis["origin_mm"][index] + relative[index] for index in range(3)] def _rotate_frame(frame: dict[str, Any], axis: dict[str, list[float]], angle_rad: float) -> dict[str, Any]: return { **frame, "origin_mm": _rotate_point(frame["origin_mm"], axis, angle_rad), "x_dir": _rotate(frame["x_dir"], axis["direction"], angle_rad), "normal": _rotate(frame["normal"], axis["direction"], angle_rad), } def _reflect_vector(value: list[float], plane: dict[str, Any]) -> list[float]: normal = _unit(list(plane["normal"]), "mirror plane normal is degenerate") amount = 2.0 * _dot(value, normal) return [value[index] - amount * normal[index] for index in range(3)] def _reflect_point(point: list[float], plane: dict[str, Any]) -> list[float]: normal = _unit(list(plane["normal"]), "mirror plane normal is degenerate") amount = 2.0 * _dot(_sub(point, plane["origin_mm"]), normal) return [point[index] - amount * normal[index] for index in range(3)] def _reflect_frame(frame: dict[str, Any], plane: dict[str, Any]) -> dict[str, Any]: return { **frame, "origin_mm": _reflect_point(frame["origin_mm"], plane), "x_dir": _reflect_vector(frame["x_dir"], plane), "normal": _reflect_vector(frame["normal"], plane), } def _y_dir(plane: dict[str, Any]) -> list[float]: return _cross(plane["normal"], plane["x_dir"]) def _global(plane: dict[str, Any], point: list[float]) -> list[float]: y = _y_dir(plane); return [plane["origin_mm"][i] + plane["x_dir"][i]*point[0] + y[i]*point[1] for i in range(3)] def _shift_plane(plane: dict[str, Any], distance: float) -> dict[str, Any]: return {**plane, "origin_mm": [plane["origin_mm"][i] + plane["normal"][i]*distance for i in range(3)]} def _oriented_plane(plane: dict[str, Any], normal_sign: float, distance: float = 0.0) -> dict[str, Any]: shifted = _shift_plane(plane, distance) # 翻转端盖法向时同步翻转 x 轴,保持 normal × x_dir 的草图局部 y 轴 # 不变。否则同一 CADFS 草图会在附着端盖后被镜像。 return { **shifted, "x_dir": [normal_sign * value for value in plane["x_dir"]], "normal": [normal_sign * value for value in plane["normal"]], } def _attachment_plane(plane: dict[str, Any]) -> dict[str, Any]: """Choose the global-origin projection as the later sketch attachment origin. CADFS keeps a plane's physical location and the plane coordinates used by a later sketch separate. The latter is the global origin projected onto the physical plane, not necessarily the selected point or profile centre. Keep this only as an attachment frame; selectors and body topology continue to use the physical frame. """ normal = plane["normal"] origin = plane["origin_mm"] distance = _dot(origin, normal) return {**plane, "origin_mm": [distance * normal[index] for index in range(3)]} def _loft_cap_frames(start: dict[str, Any], end: dict[str, Any]) -> dict[str, dict[str, Any]] | None: """Record the physical outer normals for the first and last loft sections.""" direction = _sub(end["origin_mm"], start["origin_mm"]) if math.sqrt(_dot(direction, direction)) <= 1e-9: return None # Loft start/end caps face away from the section sequence. Sketch normals do # not necessarily have that orientation, so retain the CDSL frame handedness # while choosing the actual B-rep exterior normal for CAP references. start_sign = 1.0 if _dot(start["normal"], direction) <= 0 else -1.0 end_sign = 1.0 if _dot(end["normal"], direction) >= 0 else -1.0 return {"start": _oriented_plane(start, start_sign), "end": _oriented_plane(end, end_sign)} def _frame(origin: list[float], x_dir: list[float], normal: list[float]) -> dict[str, list[float]]: normal = _unit(normal, "reference plane normal is degenerate") x_dir = _sub(x_dir, [normal[index] * _dot(x_dir, normal) for index in range(3)]) return {"origin_mm": origin, "x_dir": _unit(x_dir, "reference plane x direction is degenerate"), "normal": normal} def _pattern_copy_cap_plane(value: Any, feature_frames: dict[str, dict[str, Any]]) -> dict[str, Any] | None: """Resolve one mirrored COPY(CAP_FACE) frame without flattening its source query.""" try: _call, pattern_owner, topology, kind, definition = _direct_make_query(value) except ValueError: return None if topology != "COPY" or kind != "face": return None derived = definition.get("derivedFrom") if derived is None: return None try: _source, source_owner, source_topology, source_kind, source_definition = _direct_make_query(derived) except ValueError: return None if source_topology != "CAP_FACE" or source_kind != "face": return None pattern_frame = feature_frames.get(pattern_owner) or {} transform = pattern_frame.get("copy_transform") or {} if transform.get("type") != "mirror" or source_owner not in pattern_frame.get("copy_source_features", []): return None source_frame = feature_frames.get(source_owner) if source_frame is None or "start" not in source_frame or "end" not in source_frame: return None cap = source_frame["start" if _bool(source_definition.get("isStart")) else "end"] plane = transform.get("plane") if not isinstance(plane, dict): return None reflected = _reflect_frame(cap, plane) # 镜像会把 source frame 变成左手系;CDSL workplane 必须始终由 # normal × x_dir 推导局部 y 轴。翻转 x_dir 后,镜像 CAP 上新草图的 # source-local y 方向保持物理不变,FeatureScript 导出的镜像侧 x 坐标 # 也能落回正确的世界位置。 reflected["x_dir"] = [-value for value in reflected["x_dir"]] return reflected def _plane_from_query( value: Any, feature_frames: dict[str, dict[str, Any]], sketch_by_source: dict[str, dict[str, Any]] | None = None, entity_by_sketch: dict[str, dict[str, dict[str, Any]]] | None = None, ) -> dict[str, Any]: for call in walk_calls(value): if call.name in {"makeId", "qCreatedBy"}: text = " ".join(symbolic_string(arg) for arg in call.args) for name, plane in PLANES.items(): if f"{name}.planeOp" in text: return dict(plane) copied_cap = _pattern_copy_cap_plane(value, feature_frames) if copied_cap is not None: return copied_cap query = parse_query(value) if query.topology_type == "IMPRINT" and sketch_by_source and query.source_sketch in sketch_by_source: return dict(sketch_by_source[query.source_sketch]["workplane"]) if query.topology_type == "OFFSET_FACE" and sketch_by_source is not None and entity_by_sketch is not None: return _offset_face_plane(value, feature_frames, sketch_by_source, entity_by_sketch) frame = feature_frames.get(query.owner_feature or "") if frame and query.topology_type == "CAP_FACE": cap = "start" if query.is_start is not False else "end" return dict(frame.get(f"{cap}_attachment") or frame[cap]) if frame and frame.get("start") == frame.get("end") and "qCreatedBy" in query.calls: return dict(frame.get("start_attachment") or frame["start"]) raise ValueError("unsupported or unresolved sketch workplane") def _bound_name(value: Any) -> str: return str(value or "BLIND").split(".")[-1].upper() def _is_new_body_operation(value: str) -> bool: return value.rsplit(".", 1)[-1] == "NEW" def _has_active_body(features: list[dict[str, Any]]) -> bool: """Whether the lowered history already has a result body to extend.""" return any(feature.get("atomic_id") not in {"reference_plane", "reference_axis"} for feature in features) def _end_condition(value: Any) -> dict[str, Any]: name = _bound_name(value) if name == "BLIND": return {"type": "blind", "solidworks_code": 0} if name == "SYMMETRIC": return {"type": "mid_plane", "solidworks_code": 8} if name == "THROUGH_ALL": return {"type": "through_all", "solidworks_code": 1} if name == "UP_TO_NEXT": return {"type": "through_next", "solidworks_code": 4} if name == "UP_TO_SURFACE": return {"type": "up_to_surface", "solidworks_code": 2} if name == "UP_TO_BODY": return {"type": "up_to_body", "solidworks_code": 6} if name == "UP_TO_VERTEX": return {"type": "up_to_vertex", "solidworks_code": 5} raise UnsupportedCapability(f"extrude_extent:{name.lower()}", f"current CDSL atomic set has no exact extrusion operation for {name}") def _extent_reference( value: Any, expected_kind: str, feature_frames: dict[str, dict[str, Any]], sketch_by_source: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], ) -> dict[str, Any]: query = parse_query(value) if query.kind not in {expected_kind, f"entitytype.{expected_kind}"}: raise ValueError(f"extrude extent target is not a {expected_kind}") if expected_kind == "face" and query.topology_type in {"CAP_FACE", "SWEPT_FACE"}: return _face_reference(value, feature_frames, sketch_by_source, entity_by_sketch) if not query.owner_feature: raise ValueError("extrude extent target owner is unresolved") owner = f"f_{query.owner_feature}" reference = { "kind": expected_kind, "owner_feature_id": owner, "stable_id": f"cadfs_{owner}_{expected_kind}", "source": "solidworks", "confidence": 1.0, } return reference def _direct_make_query(value: Any) -> tuple[Call, str, str, str, dict[str, Any]]: """Return one direct CADFS makeQuery without flattening its provenance.""" current = value if isinstance(current, Call) and current.name == "qUnion" and len(current.args) == 1 and isinstance(current.args[0], list): if len(current.args[0]) != 1: raise ValueError("query union does not identify one topology item") current = current.args[0][0] if not isinstance(current, Call) or current.name != "makeQuery" or len(current.args) < 3: raise ValueError("topology query is unresolved") owner_text = symbolic_string(current.args[0]) if "F" not in owner_text: raise ValueError("topology query owner is unresolved") owner = owner_text[owner_text.find("F"):].split(".", 1)[0] topology = str(current.args[1]).split(".")[-1].upper() kind = str(current.args[2]).split(".")[-1].lower() definition = next((arg for arg in current.args if isinstance(arg, dict)), {}) return current, owner, topology, kind, definition def _face_reference( value: Any, feature_frames: dict[str, dict[str, Any]], sketch_by_source: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], *, owner_feature_id: str | None = None, binding_feature_id: str | None = None, match_mode: str | None = None, rotation: tuple[dict[str, list[float]], float] | None = None, ) -> dict[str, Any]: """Lower one CAP/SWEPT face query into a bindable CDSL selector.""" _call, owner, topology, kind, _definition = _direct_make_query(value) if kind != "face" or topology not in {"CAP_FACE", "SWEPT_FACE"}: raise ValueError("intersection source is not a supported face query") source = parse_query(value) selector_owner = owner_feature_id or f"f_{owner}" reference: dict[str, Any] = { "kind": "face", "owner_feature_id": selector_owner, "stable_id": f"cadfs_{selector_owner}_{topology.lower()}", "source": "solidworks", "confidence": 1.0, } frame = feature_frames.get(owner) geometry: dict[str, Any] point: list[float] if topology == "CAP_FACE": if frame is None or "start" not in frame or "end" not in frame: raise ValueError("cap face source frame is unresolved") cap = frame["start" if source.is_start else "end"] point, normal = list(cap["origin_mm"]), list(cap["normal"]) if rotation is not None: axis, angle = rotation; point, normal = _rotate_point(point, axis, angle), _rotate(normal, axis["direction"], angle) geometry = {"normal": normal, "plane_offset_mm": _dot(normal, point)} else: sketch = sketch_by_source.get(source.source_sketch or "") entity = (entity_by_sketch.get(source.source_sketch or "") or {}).get(source.source_entity or "") if sketch is None or entity is None: raise ValueError("swept face source geometry is unresolved") # 直接 source 草图可能已被后续 transform 烘焙到 feature profile。 # SWEPT_FACE 必须在该实际 profile workplane 上还原,不能回退到变换前 # 的草图坐标系,否则 pattern copy 的平面 selector 会落在错误位置。 plane = (frame or {}).get("profile") or sketch["workplane"] if entity["type"] == "circle": point, direction = _global(plane, entity["center"]), list(plane["normal"]) if rotation is not None: axis, angle = rotation; point, direction = _rotate_point(point, axis, angle), _rotate(direction, axis["direction"], angle) geometry = {"axis_origin_mm": point, "axis_direction": direction, "radius_mm": entity["radius_mm"]} elif entity["type"] == "line": point = _global(plane, entity["start"]); end = _global(plane, entity["end"]) normal = _unit(_cross(_sub(end, point), plane["normal"]), "swept face source line is degenerate") if rotation is not None: axis, angle = rotation; point, normal = _rotate_point(point, axis, angle), _rotate(normal, axis["direction"], angle) geometry = {"normal": normal, "plane_offset_mm": _dot(normal, point)} if rotation is None and frame and frame.get("start") and frame.get("end"): span = math.dist(frame["start"]["origin_mm"], frame["end"]["origin_mm"]) length = math.dist(point, end) if span > 1e-6 and length > 1e-6: # 后续圆角和布尔可能将一个侧壁分裂成同平面的多个 face。 # 原始 SWEPT_FACE 的母线长度与拉伸跨度给出确定的面积下界, # 可排除与其共面的微小端盖,而不要求内核保留原始面积。 geometry["minimum_area_mm2"] = length * span * 0.5 else: raise ValueError("swept face source entity is unsupported") reference["geometry"] = geometry if binding_feature_id is not None: reference["binding_feature_id"] = binding_feature_id if match_mode is not None: reference["match_mode"] = match_mode return reference def _pattern_copy_face_reference( value: Any, feature_frames: dict[str, dict[str, Any]], sketch_by_source: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], feature_by_id: dict[str, dict[str, Any]], ) -> dict[str, Any]: """Preserve a circular-pattern COPY face as one specific replay instance.""" _call, pattern_owner, topology, kind, definition = _direct_make_query(value) if topology != "COPY" or kind != "face": raise ValueError("intersection copy source is unresolved") derived = definition.get("derivedFrom") if derived is None: raise ValueError("pattern copy has no derived face") _source_call, source_owner, _source_topology, source_kind, _source_definition = _direct_make_query(derived) if source_kind != "face": raise ValueError("pattern copy source is not a face") pattern_id = f"f_{pattern_owner}"; source_id = f"f_{source_owner}" pattern = feature_by_id.get(pattern_id) if pattern is None or pattern.get("atomic_id") != "pattern_circular": raise ValueError("pattern copy owner is not a circular pattern") if source_id not in (pattern.get("params") or {}).get("source_feature_ids", []): raise ValueError("pattern copy source feature is not replayed by its owner") try: instance = int(str(definition.get("instanceName"))) except (TypeError, ValueError) as error: raise ValueError("pattern copy instance is unresolved") from error axis = (pattern.get("params") or {}).get("axis") count = int((pattern.get("params") or {}).get("pattern_count") or 0) if not isinstance(axis, dict) or count < 1 or instance < 0 or instance >= count: raise ValueError("pattern copy instance transform is unresolved") angle = math.radians(float((pattern.get("params") or {}).get("sweep_angle_deg") or 360.0) * instance / count) return _face_reference( derived, feature_frames, sketch_by_source, entity_by_sketch, owner_feature_id=f"{pattern_id}.c{instance}.{source_id}", binding_feature_id=pattern_id, rotation=(axis, angle), ) def _intersection_vertex_reference( value: Any, feature_frames: dict[str, dict[str, Any]], sketch_by_source: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], feature_by_id: dict[str, dict[str, Any]], feature_id: str, ) -> dict[str, Any]: """Lower a CADFS INTERSECT vertex without replacing it with a coordinate.""" _call, owner, topology, kind, definition = _direct_make_query(value) if topology != "INTERSECT" or kind != "vertex": raise ValueError("up-to-vertex target is not an INTERSECT vertex") derived = definition.get("derivedFrom") if not isinstance(derived, list) or len(derived) < 2: raise ValueError("intersection vertex has no complete face provenance") components = [] for item in derived: _item_call, item_owner, item_topology, item_kind, _item_definition = _direct_make_query(item) if item_kind != "face": raise ValueError("intersection vertex source is not a face") if item_topology == "COPY": components.append(_pattern_copy_face_reference(item, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id)) elif item_topology == "SWEPT_FACE": components.append(_face_reference( item, feature_frames, sketch_by_source, entity_by_sketch, owner_feature_id=f"f_{item_owner}", binding_feature_id=f"f_{item_owner}", match_mode="all", )) else: raise ValueError(f"intersection vertex source topology {item_topology} is unsupported") return { "kind": "vertex", "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_f_{feature_id}_intersect_vertex", "source": "solidworks", "confidence": 1.0, "intersection_of": components, } def _arc(start: list[float], mid: list[float], end: list[float]) -> dict[str, Any]: ax, ay = start; bx, by = mid; cx, cy = end d = 2 * (ax*(by-cy) + bx*(cy-ay) + cx*(ay-by)) if abs(d) < 1e-9: raise ValueError("collinear arc points") ux = ((ax*ax+ay*ay)*(by-cy)+(bx*bx+by*by)*(cy-ay)+(cx*cx+cy*cy)*(ay-by))/d uy = ((ax*ax+ay*ay)*(cx-bx)+(bx*bx+by*by)*(ax-cx)+(cx*cx+cy*cy)*(bx-ax))/d cross = (mid[0]-start[0])*(end[1]-mid[1])-(mid[1]-start[1])*(end[0]-mid[0]) clockwise = cross < 0 # CADFS 的倒圆角会把中点按显示精度写回 FeatureScript。端点已经完整 # 表达等半径直角圆弧时,优先从端点恢复原始圆心,避免把显示精度误作 # 几何精度;任意三点圆弧仍保持原来的外接圆求解。 for center in ([ax, cy], [cx, ay]): first = [ax - center[0], ay - center[1]]; second = [cx - center[0], cy - center[1]] radius = math.hypot(*first) if radius <= 1e-9 or abs(math.hypot(*second) - radius) > 1e-9 or abs(_dot(first, second)) > 1e-9: continue start_angle = math.atan2(first[1], first[0]); end_angle = math.atan2(second[1], second[0]) sweep = end_angle - start_angle if clockwise and sweep >= 0: sweep -= math.tau elif not clockwise and sweep <= 0: sweep += math.tau expected = [center[0] + radius * math.cos(start_angle + sweep / 2), center[1] + radius * math.sin(start_angle + sweep / 2)] if math.dist(mid, expected) <= max(1e-6, radius * 5e-4): return {"type": "arc", "start": start, "end": end, "center": center, "radius_mm": radius, "clockwise": clockwise} return {"type": "arc", "start": start, "end": end, "center": [ux, uy], "radius_mm": math.hypot(ax-ux, ay-uy), "clockwise": clockwise} def _endpoint(segment: dict[str, Any], end: bool = False) -> tuple[int, int]: point = segment["end" if end else "start"]; return round(point[0]*1e5), round(point[1]*1e5) def _reverse_segment(segment: dict[str, Any]) -> dict[str, Any]: output = dict(segment) output["start"], output["end"] = segment["end"], segment["start"] if output["type"] == "arc": output["clockwise"] = not bool(segment["clockwise"]) elif output["type"] == "bspline": # OCC 的 periodic interpolator 对 reversed point list 会求出另一条 # 曲线。半边图只需要反转其拓扑走向,保留原插值点并以私有标记供 # 切线和面积计算使用;该标记不会进入 CDSL profile。 if output.get("periodic"): output["_reversed"] = not bool(segment.get("_reversed")) else: output["points"] = list(reversed(segment["points"])) 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", None) end_tangent = output.pop("end_tangent", None) if end_tangent is not None: output["start_tangent"] = [-float(value) for value in end_tangent] if start_tangent is not None: output["end_tangent"] = [-float(value) for value in start_tangent] return output def _segment_tangent(segment: dict[str, Any]) -> tuple[float, float]: if segment["type"] == "line": return segment["end"][0] - segment["start"][0], segment["end"][1] - segment["start"][1] if segment["type"] == "arc": radius = [segment["start"][0] - segment["center"][0], segment["start"][1] - segment["center"][1]] return (radius[1], -radius[0]) if segment["clockwise"] else (-radius[1], radius[0]) points = segment["points"] if segment.get("periodic") and segment.get("_reversed"): return points[0][0] - points[-2][0], points[0][1] - points[-2][1] return points[1][0] - points[0][0], points[1][1] - points[0][1] def _contour_area(segments: list[dict[str, Any]]) -> float: points: list[list[float]] = [] for segment in segments: points.append(segment["start"]) if segment["type"] == "arc": start = segment["start"]; end = segment["end"]; center = segment["center"] start_angle = math.atan2(start[1] - center[1], start[0] - center[0]) end_angle = math.atan2(end[1] - center[1], end[0] - center[0]) sweep = end_angle - start_angle if segment["clockwise"] and sweep >= 0: sweep -= 2 * math.pi if not segment["clockwise"] and sweep <= 0: sweep += 2 * math.pi angle = start_angle + sweep / 2 points.append([center[0] + segment["radius_mm"] * math.cos(angle), center[1] + segment["radius_mm"] * math.sin(angle)]) elif segment["type"] == "bspline": interior = segment["points"][1:-1] points.extend(reversed(interior) if segment.get("_reversed") else interior) return 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 def _split_line_vertices(edges: list[dict[str, Any]]) -> list[dict[str, Any]]: # FeatureScript 允许小轮廓端点落在一条未显式分段的长线上。面环遍历前将 # 长线按这些顶点切开,避免 T 形连接被误判为开链。 points = [point for edge in edges for point in (edge["start"], edge["end"])] output: list[dict[str, Any]] = [] for edge in edges: if edge["type"] != "line": output.append(edge); continue start, end = edge["start"], edge["end"] direction = [end[0] - start[0], end[1] - start[1]] length_squared = direction[0] * direction[0] + direction[1] * direction[1] if length_squared <= 1e-16: output.append(edge); continue cuts = {0.0, 1.0} for point in points: delta = [point[0] - start[0], point[1] - start[1]] parameter = (delta[0] * direction[0] + delta[1] * direction[1]) / length_squared cross = abs(delta[0] * direction[1] - delta[1] * direction[0]) if 1e-8 < parameter < 1.0 - 1e-8 and cross <= 1e-7 * max(1.0, math.sqrt(length_squared)): cuts.add(parameter) ordered = sorted(cuts) output.extend({ "type": "line", "start": [start[0] + direction[0] * left, start[1] + direction[1] * left], "end": [start[0] + direction[0] * right, start[1] + direction[1] * right], } for left, right in zip(ordered, ordered[1:])) return output def _contours(segments: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: closed_curves = [{"role": "unknown", "closed": True, "segments": [item]} for item in segments if item["type"] in {"circle", "ellipse"}] edges = _split_line_vertices([item for item in segments if item["type"] not in {"circle", "ellipse"}]) # CADFS 草图可包含共享边和多个 qSketchRegion。逐条贪心接线会在第一个 # 分叉处吃掉共用边,使其余合法区域退化为“open”。这里按局部切线建立平面 # 半边图;每条有向边沿入射顶点的左侧继续,稳定枚举所有有界面环。 half_edges: list[dict[str, Any]] = [] outgoing: dict[tuple[int, int], list[int]] = {} for edge_index, edge in enumerate(edges): for forward, segment in ((True, edge), (False, _reverse_segment(edge))): index = len(half_edges) half_edges.append({"edge_index": edge_index, "forward": forward, "segment": segment}) outgoing.setdefault(_endpoint(segment), []).append(index) for vertex, indexes in outgoing.items(): del vertex indexes.sort(key=lambda index: (math.atan2(*reversed(_segment_tangent(half_edges[index]["segment"]))), index)) next_edge: dict[int, int] = {} for index, item in enumerate(half_edges): arrivals = outgoing[_endpoint(item["segment"], True)] twin = next(candidate for candidate in arrivals if half_edges[candidate]["edge_index"] == item["edge_index"] and half_edges[candidate]["forward"] != item["forward"]) next_edge[index] = arrivals[(arrivals.index(twin) - 1) % len(arrivals)] visited: set[int] = set(); used_edges: set[int] = set(); contours = [] for start in range(len(half_edges)): if start in visited: continue chain: list[int] = []; current = start while current not in visited: visited.add(current); chain.append(current); current = next_edge[current] if current != start: continue contour = [half_edges[index]["segment"] for index in chain] if contour and _contour_area(contour) > 1e-8: # `_reversed` belongs only to the planar half-edge traversal. # A periodic B-spline remains the same physical closed profile # regardless of the retained OCC edge orientation. public_segments = [{key: value for key, value in segment.items() if key != "_reversed"} for segment in contour] contours.append({"role": "unknown", "closed": True, "segments": public_segments}) used_edges.update(half_edges[index]["edge_index"] for index in chain) construction = [edge for index, edge in enumerate(edges) if index not in used_edges] return contours + closed_curves, construction def _recover_imperial_grid(items: list[dict[str, Any]], point_records: list[dict[str, Any]]) -> None: # 部分 CADFS 导出会将英制草图的个别坐标截断为两位小数,但其余坐标仍 # 保留 0.001 in 网格。只在同一草图已能证明该网格时恢复相邻显示值; # 半径和 feature 参数不属于这一坐标恢复规则,绝不能在这里猜测或改写。 grid = 0.0254; tolerance = grid / 5 coordinates: list[float] = [] def collect(point: list[float] | None) -> None: if isinstance(point, list): coordinates.extend(float(value) for value in point[:2]) for item in items: if item["type"] in {"line", "arc"}: collect(item.get("start")); collect(item.get("end")) if item["type"] in {"circle", "ellipse"}: collect(item.get("center")) for point_record in point_records: collect(point_record.get("point")) nonzero = [value for value in coordinates if abs(value) > 1e-9] if not nonzero: return error = lambda value: abs(value - round(value / grid) * grid) aligned = [value for value in nonzero if error(value) <= tolerance] exact = [value for value in nonzero if error(value) <= 1e-8] if len(aligned) / len(nonzero) < 0.9 or len(exact) * 2 < len(nonzero): return def recover(point: list[float] | None) -> None: if not isinstance(point, list): return for index, value in enumerate(point[:2]): if error(float(value)) <= tolerance: point[index] = round(float(value) / grid) * grid for item in items: if item["type"] in {"line", "arc"}: recover(item.get("start")); recover(item.get("end")) if item["type"] in {"circle", "ellipse"}: recover(item.get("center")) for point_record in point_records: recover(point_record.get("point")) def _centripetal_parameters(points: list[list[float]]) -> list[float]: parameters = [0.0] for start, end in zip(points, points[1:]): distance = math.dist(start, end) if distance <= 1e-9: raise ValueError("fit spline has coincident interpolation points") parameters.append(parameters[-1] + math.sqrt(distance)) total = parameters[-1] return [value / total for value in parameters] def _lower_sketch(sketch: SketchIR, plane: dict[str, Any], allow_open: bool = False) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]: segments: list[dict[str, Any]] = []; explicit_construction: list[dict[str, Any]] = []; entities: dict[str, dict[str, Any]] = {}; points: list[dict[str, Any]] = []; unsupported = [] for entity in sketch.entities: p = entity.params if entity.operation == "skPoint": item = {"type": "point", "point": _point(p["position"])}; entities[entity.feature_id] = item; points.append(item); continue if entity.operation == "skLineSegment": item = {"type": "line", "start": _point(p["start"]), "end": _point(p["end"])} elif entity.operation == "skCircle": item = {"type": "circle", "center": _point(p["center"]), "radius_mm": _number(p["radius"], True)} elif entity.operation == "skEllipse": major_axis = _unit(_point(p["majorAxis"]), "ellipse major axis is degenerate") item = {"type": "ellipse", "center": _point(p["center"]), "major_radius_mm": _number(p["majorRadius"], True), "minor_radius_mm": _number(p["minorRadius"], True), "major_axis": major_axis} elif entity.operation == "skArc": item = _arc(_point(p["start"]), _point(p["mid"]), _point(p["end"])) elif entity.operation == "skFitSpline": spline_points = [_point(point) for point in p.get("points") or []] if len(spline_points) < 3: raise ValueError("fit spline needs at least 3 points") # FeatureScript 的 skFitSpline 以根号弦长参数化。参数域既决定 # 插值曲线,也决定端点导数的长度语义;半边遍历反转轮廓时会将 # 它按反向参数域同步变换,不能交给 OCC 默认重新计算。 item = { "type": "bspline", "start": spline_points[0], "end": spline_points[-1], "points": spline_points, "parameterization": "centripetal", "parameters": _centripetal_parameters(spline_points), } # skFitSpline uses a periodic curve when the author closes it by # repeating the first interpolation point. Passing that repeated # point into a non-periodic interpolator changes the profile area # and produces a seam that does not exist in CADFS. if _same_point(spline_points[0], spline_points[-1]): item["periodic"] = True if p.get("startDerivative") is not None: item["start_tangent"] = _point(p["startDerivative"]) if p.get("endDerivative") is not None: item["end_tangent"] = _point(p["endDerivative"]) else: unsupported.append(entity.operation); continue (explicit_construction if _bool(p.get("construction")) else segments).append(item); entities[entity.feature_id] = item if unsupported: raise ValueError("unsupported sketch entities: " + ",".join(sorted(set(unsupported)))) _recover_imperial_grid(segments + explicit_construction, points) if not segments: profile: dict[str, Any] = {"type": "analytic_contours", "contours": []} if explicit_construction: profile["construction"] = explicit_construction return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile, "role": "reference"}, entities if len(segments) == 1 and segments[0]["type"] == "circle" and not explicit_construction: profile = {"type": "circle", "center": segments[0]["center"], "radius_mm": segments[0]["radius_mm"]} else: contours, open_segments = _contours(segments) if open_segments: # qSketchRegion 只会选取闭合区域。与之无关的开链必须保留为 # reference geometry,不能因为它们存在就丢弃同一草图中的合法 # 区域;若草图没有任何闭合区域,则仍按原有规则拒绝实体 profile。 if not contours: if not allow_open: raise ValueError(f"sketch has {len(open_segments)} open non-construction segment(s)") profile = {"type": "analytic_contours", "contours": [], "construction": explicit_construction + open_segments} return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile, "role": "reference"}, entities explicit_construction = [*explicit_construction, *open_segments] construction = list(explicit_construction) if not contours: profile = {"type": "analytic_contours", "contours": [], "construction": construction} return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile, "role": "reference"}, entities profile = {"type": "analytic_contours", "contours": contours} if construction: profile["construction"] = construction return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile}, entities def _queries(value: Any) -> list[Any]: if isinstance(value, Call) and value.name == "qUnion" and value.args and isinstance(value.args[0], list): return value.args[0] return [value] def _source_refs(value: Any) -> list[tuple[str, str]]: refs = [] for call in walk_calls(value): if call.name in {"sQuery", "sketchEntityQuery"} and len(call.args) >= 3: refs.append((symbolic_string(call.args[0]).split(".", 1)[0], str(call.args[2]))) return refs def _source_sketch(params: dict[str, Any]) -> str | None: for key in ("entities", "sheetProfilesArray", "surfaceEntities"): if key in params: query = parse_query(params[key]) if query.source_sketch: return query.source_sketch return None def _sketch_region_query(value: Any) -> Any | None: """Return one explicit qSketchRegion when a profile query also carries context faces.""" matches = [ item for item in _queries(value) if any(call.name == "qSketchRegion" for call in walk_calls(item)) ] return matches[0] if len(matches) == 1 else None def _imprint_sketch(value: Any) -> str | None: for call in walk_calls(value): if call.name != "makeQuery" or not call.args: continue owner = symbolic_string(call.args[0]) if owner.endswith(".imprint"): return owner.split(".", 1)[0] return None def _profile_selection_side(value: Any) -> float | None: def numbers(item: Any): if isinstance(item, (int, float)): yield float(item) elif isinstance(item, list): for child in item: yield from numbers(child) elif isinstance(item, dict): for child in item.values(): yield from numbers(child) for call in walk_calls(value): if call.name in {"TD", "topologyDisambiguation"}: return next(numbers(call.args), None) return None def _profile_selection_sides(value: Any) -> list[float]: """Return every explicit topology side from one nested IMPRINT query.""" def numbers(item: Any): if isinstance(item, (int, float)): yield float(item) elif isinstance(item, list): for child in item: yield from numbers(child) elif isinstance(item, dict): for child in item.values(): yield from numbers(child) sides = [] for call in walk_calls(value): if call.name in {"TD", "topologyDisambiguation"}: side = next(numbers(call.args), None) if side is not None: sides.append(side) return sides def _partitioned_imprint_sketch( value: Any, entity_by_sketch: dict[str, dict[str, dict[str, Any]]], ) -> str | None: """Resolve a two-sided split IMPRINT union back to its bounded sketch region. A closed sketch curve that intersects a parent-face boundary is split into two IMPRINT regions. CADFS records the two halves as a qUnion, with the same outer face side and opposite sides around the intersection vertex. The union is exactly the curve's bounded sketch region. Do not accept a partial union, a mixed source curve, or an open curve here: each case has different material semantics and must remain an explicit capability gap. """ roots = _queries(value) if len(roots) != 2: return None source_sketch: str | None = None source_entity: str | None = None nested_sides: set[float] = set() for root in roots: try: _call, owner, topology, kind, _definition = _direct_make_query(root) except ValueError: return None info = parse_query(root) references = { (sketch, entity) for sketch, entity in _source_refs(root) if sketch == owner } sides = _profile_selection_sides(root) if topology != "IMPRINT" or kind != "face" or owner != info.source_sketch: return None if len(references) != 1 or len(sides) != 2 or sides[0] >= 0: return None sketch, entity = next(iter(references)) if source_sketch is None: source_sketch, source_entity = sketch, entity elif (source_sketch, source_entity) != (sketch, entity): return None nested_sides.add(sides[1]) if source_sketch is None or source_entity is None or nested_sides != {-1.0, 1.0}: return None entities = entity_by_sketch.get(source_sketch) or {} source = entities.get(source_entity) if source is None: source_id = max((key for key in entities if source_entity.startswith(key + ".")), key=len, default="") source = entities.get(source_id) if source is None or source.get("type") != "bspline" or not _same_point(source["start"], source["end"]): return None return source_sketch def _intersect_partition_profile_sketch( value: Any, sketch_by_source: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], feature_id: str, ) -> dict[str, Any] | None: """Materialize one full circular region selected through a split diameter. A sketch line that crosses nested concentric circles is split at the selected circle intersection. CADFS can select the two face sides around that split edge instead of naming the circular region directly. Both sides together are exactly the bounded region inside the selected circle: either its disk or the annulus ending at the immediately inner circle. Keep this deliberately narrow. A non-diameter line, any non-circular source, a partial side selection, or an ambiguous nested query remains an INTERSECT capability diagnostic rather than an invented profile. """ roots = _queries(value) if len(roots) != 2: return None source_sketch: str | None = None source_line: str | None = None source_circle: str | None = None outer_sides: set[float] = set() for root in roots: try: _call, owner, topology, kind, _definition = _direct_make_query(root) except ValueError: return None sides = _profile_selection_sides(root) if topology != "IMPRINT" or kind != "face" or len(sides) != 2 or sides[1] != -1.0: return None references = list(dict.fromkeys(_source_refs(root))) if len(references) != 2 or any(sketch != owner for sketch, _entity in references): return None entities = entity_by_sketch.get(owner) or {} line_ids = [entity_id for _sketch, entity_id in references if (entities.get(entity_id) or {}).get("type") == "line"] circle_ids = [entity_id for _sketch, entity_id in references if (entities.get(entity_id) or {}).get("type") == "circle"] if len(line_ids) != 1 or len(circle_ids) != 1: return None if source_sketch is None: source_sketch, source_line, source_circle = owner, line_ids[0], circle_ids[0] elif (source_sketch, source_line, source_circle) != (owner, line_ids[0], circle_ids[0]): return None outer_sides.add(sides[0]) if source_sketch is None or source_line is None or source_circle is None or outer_sides != {-1.0, 1.0}: return None sketch = sketch_by_source.get(source_sketch) entities = entity_by_sketch.get(source_sketch) or {} line, circle = entities.get(source_line), entities.get(source_circle) if sketch is None or line is None or circle is None: return None start, end, center = line.get("start"), line.get("end"), circle.get("center") if not all(isinstance(point, list) and len(point) == 2 for point in (start, end, center)): return None direction = [end[index] - start[index] for index in range(2)] length = math.hypot(*direction) if length <= 1e-9: return None projection = sum((center[index] - start[index]) * direction[index] for index in range(2)) / (length * length) distance = abs((center[0] - start[0]) * direction[1] - (center[1] - start[1]) * direction[0]) / length if not 1e-6 < projection < 1.0 - 1e-6 or distance > 1e-6: return None profile = _circle_imprint_region((sketch.get("profile") or {}).get("contours") or [], circle, 1.0) if profile is None: return None output = deepcopy(sketch) output["id"] = f"{sketch['id']}__{feature_id}" output["name"] = f"{sketch['name']}__{feature_id}" output["profile"] = profile return output def _same_point(left: list[float], right: list[float]) -> bool: return math.dist(left, right) <= 1e-5 def _matching_profile_segment(segment: dict[str, Any], source: dict[str, Any]) -> int: if segment.get("type") != source.get("type"): return 0 if segment["type"] == "circle": return 1 if _same_point(segment["center"], source["center"]) and abs(segment["radius_mm"] - source["radius_mm"]) <= 1e-5 else 0 if segment["type"] == "ellipse": if not ( _same_point(segment["center"], source["center"]) and abs(segment["major_radius_mm"] - source["major_radius_mm"]) <= 1e-5 and abs(segment["minor_radius_mm"] - source["minor_radius_mm"]) <= 1e-5 ): return 0 direction = _dot(segment["major_axis"], source["major_axis"]) return 1 if direction >= 0 else -1 if _same_point(segment["start"], source["start"]) and _same_point(segment["end"], source["end"]): return 1 if _same_point(segment["start"], source["end"]) and _same_point(segment["end"], source["start"]): return -1 return 0 def _circle_imprint_region(contours: list[dict[str, Any]], source: dict[str, Any], side: float | None) -> dict[str, Any] | None: """Materialize one bounded side of a nested circular IMPRINT edge.""" if source.get("type") != "circle" or side is None: return None center = source["center"]; radius = float(source["radius_mm"]) circles = sorted({ float(segment["radius_mm"]) for contour in contours for segment in contour.get("segments") or [] if segment.get("type") == "circle" and _same_point(segment.get("center") or [], center) }) index = next((item for item, value in enumerate(circles) if abs(value - radius) <= 1e-5), None) if index is None: return None # 标准 skCircle 的正侧位于圆内。若同心圆存在,选区应止于其直接 # 内层边界;负侧则选择与直接外层边界之间的环域。没有相邻边界时, # 只有圆内区域是可由 CDSL 闭合 profile 表达的。 inner = circles[index - 1] if side > 0 and index > 0 else radius outer = radius if side > 0 else (circles[index + 1] if index + 1 < len(circles) else None) if outer is None: return None if inner == 0 or (side > 0 and index == 0): return {"type": "circle", "center": list(center), "radius_mm": outer} return { "type": "analytic_contours", "contours": [ {"role": "outer", "closed": True, "segments": [{"type": "circle", "center": list(center), "radius_mm": outer}]}, {"role": "inner", "closed": True, "segments": [{"type": "circle", "center": list(center), "radius_mm": inner}]}, ], } def _circle_imprint_union_profile( contours: list[dict[str, Any]], query_values: list[Any], entities: dict[str, dict[str, Any]], ) -> dict[str, Any] | None: """Materialize one bounded union of concentric circular IMPRINT regions.""" resolved: list[tuple[dict[str, Any], float]] = [] for query_value in query_values: selection = parse_query(query_value) source_id = max((key for key in entities if selection.source_entity and selection.source_entity.startswith(key)), key=len, default="") source = entities.get(source_id) side = _profile_selection_side(query_value) if source is None or source.get("type") != "circle" or side is None: return None resolved.append((source, side)) if not resolved: return None center = resolved[0][0]["center"] if any(not _same_point(source["center"], center) for source, _side in resolved[1:]): return None radii = sorted({ float(segment["radius_mm"]) for contour in contours for segment in contour.get("segments") or [] if segment.get("type") == "circle" and _same_point(segment.get("center") or [], center) }) intervals: list[tuple[float, float]] = [] for source, side in resolved: radius = float(source["radius_mm"]) index = next((item for item, value in enumerate(radii) if abs(value - radius) <= 1e-5), None) if index is None: return None if side > 0: intervals.append((radii[index - 1] if index else 0.0, radius)) elif index + 1 < len(radii): intervals.append((radius, radii[index + 1])) else: # 最外圆的负侧是无界区域,不能变成任意默认实体。 return None intervals.sort() merged: list[list[float]] = [] for lower, upper in intervals: if merged and lower <= merged[-1][1] + 1e-5: merged[-1][1] = max(merged[-1][1], upper) else: merged.append([lower, upper]) if len(merged) != 1: return None inner, outer = merged[0] if inner <= 1e-5: return {"type": "circle", "center": list(center), "radius_mm": outer} return { "type": "analytic_contours", "contours": [ {"role": "outer", "closed": True, "segments": [{"type": "circle", "center": list(center), "radius_mm": outer}]}, {"role": "inner", "closed": True, "segments": [{"type": "circle", "center": list(center), "radius_mm": inner}]}, ], } def _surface_circle_radii(profile: dict[str, Any]) -> list[tuple[list[float], float]]: """Return the explicit circular wires retained by a surface extrusion.""" contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None if not isinstance(contours, list): return [] circles = [] for contour in contours: segments = contour.get("segments") or [] if len(segments) != 1 or segments[0].get("type") != "circle": continue circles.append((list(segments[0].get("center") or []), float(segments[0]["radius_mm"]))) return circles def _surface_trimmed_imprint_profile( profile_sketch: dict[str, Any], surface_profile_sketch: dict[str, Any] | None, previous_surfaces: list[dict[str, Any]], ) -> dict[str, Any]: """Materialize the bounded solid region created by a coaxial surface split. CADFS can retain an earlier, bidirectional surface extrusion while a later mixed extrusion selects an IMPRINT face. The selected circular face is then bounded by that surface, rather than by the sketch origin. CDSL has no general surface/solid trim operation yet, so preserve this proven circular case as its actual annular profile. Other surface combinations remain on the ordinary IMPRINT lowering path instead of guessing a trim. """ profile = profile_sketch.get("profile") or {} if profile.get("type") != "circle" or surface_profile_sketch is None: return profile_sketch center = list(profile.get("center") or []) outer = float(profile.get("radius_mm") or 0.0) if len(center) != 2 or outer <= 0: return profile_sketch selected_radii = _surface_circle_radii(surface_profile_sketch.get("profile") or {}) if not any(_same_point(item_center, center) and abs(radius - outer) <= 1e-5 for item_center, radius in selected_radii): return profile_sketch plane = profile_sketch.get("workplane") or {} origin = plane.get("origin_mm") or [] x_dir = plane.get("x_dir") or [] y_dir = _y_dir(plane) if len(origin) != 3 or len(x_dir) != 3: return profile_sketch world_center = [origin[index] + center[0] * x_dir[index] + center[1] * y_dir[index] for index in range(3)] candidates: list[float] = [] for surface in previous_surfaces: surface_plane = surface["workplane"] normal = surface_plane["normal"] surface_origin = surface_plane["origin_mm"] span_start = -float(surface.get("reverse_distance_mm") or 0.0) span_end = float(surface.get("distance_mm") or 0.0) projection = _dot(_sub(world_center, surface_origin), normal) if projection < span_start - 1e-5 or projection > span_end + 1e-5: continue surface_x_dir = surface_plane["x_dir"] surface_y_dir = _y_dir(surface_plane) local_center = [_dot(_sub(world_center, surface_origin), surface_x_dir), _dot(_sub(world_center, surface_origin), surface_y_dir)] for surface_center, radius in _surface_circle_radii(surface["profile"]): if _same_point(surface_center, local_center) and 1e-5 < radius < outer - 1e-5: candidates.append(radius) if not candidates: return profile_sketch inner = max(candidates) output = deepcopy(profile_sketch) output["profile"] = { "type": "analytic_contours", "contours": [ {"role": "outer", "closed": True, "segments": [{"type": "circle", "center": center, "radius_mm": outer}]}, {"role": "inner", "closed": True, "segments": [{"type": "circle", "center": center, "radius_mm": inner}]}, ], } return output def _open_imprint_profile_sketch( sketch: dict[str, Any], value: Any, feature_id: str, ) -> dict[str, Any] | None: """Materialize an open IMPRINT contour closed by its attached host face.""" query = parse_query(value) profile = sketch.get("profile") or {} segments = profile.get("construction") or [] if query.topology_type != "IMPRINT" or profile.get("contours") or len(segments) < 2: return None if any(segment.get("type") not in {"line", "arc", "bspline"} for segment in segments): return None return { "id": f"{sketch['id']}__{feature_id}", "name": f"{sketch['name']}__{feature_id}", "workplane": dict(sketch["workplane"]), "profile": { "type": "analytic_contours", "contours": [{"role": "open", "closed": False, "segments": deepcopy(segments)}], }, } def _profile_outer_circle(profile: dict[str, Any]) -> dict[str, Any] | None: if profile.get("type") == "circle": return profile contours = profile.get("contours") or [] outer = next((contour for contour in contours if contour.get("role") == "outer"), None) segments = (outer or {}).get("segments") or [] if len(segments) == 1 and segments[0].get("type") == "circle": return segments[0] return None def _imprint_cap_profiles( sketch: dict[str, Any], query_value: Any, entities: dict[str, dict[str, Any]], ) -> dict[tuple[str, str], dict[str, Any]]: """Record selected circular regions by their outer sketch edge provenance. A CADFS CAP_FACE can identify one output face through the source outer sketch edge, even when its producing extrude selected several adjacent IMPRINT regions. The aggregate selected profile is insufficient in that case: for example, the outer ring and the inner disk have different CAP faces although their union is a disk. Keep only the unambiguous bounded circular regions here; general multi-region provenance still needs the engine output-role model. """ contours = (sketch.get("profile") or {}).get("contours") or [] profiles: dict[tuple[str, str], dict[str, Any]] = {} for selection_value in _queries(query_value): selection = parse_query(selection_value) source_id = max((key for key in entities if selection.source_entity and selection.source_entity.startswith(key)), key=len, default="") source = entities.get(source_id) profile = _circle_imprint_region(contours, source or {}, _profile_selection_side(selection_value)) outer = _profile_outer_circle(profile or {}) if outer is None or source is None: continue outer_id = next(( entity_id for entity_id, entity in entities.items() if entity.get("type") == "circle" and _same_point(entity.get("center") or [], outer.get("center") or []) and abs(float(entity.get("radius_mm") or 0.0) - float(outer.get("radius_mm") or 0.0)) <= 1e-5 ), None) if outer_id is not None: profiles[(selection.source_sketch or "", outer_id)] = profile return profiles def _cap_face_profile_sketch( value: Any, feature_frames: dict[str, dict[str, Any]], cap_profiles: dict[str, dict[tuple[str, str], dict[str, Any]]], feature_id: str, ) -> dict[str, Any] | None: """Materialize one previously recorded CAP_FACE output role as a sketch.""" query = parse_query(value) if query.topology_type != "CAP_FACE" or not query.owner_feature: return None references = _source_refs(value) if len(references) != 1: return None profile = (cap_profiles.get(query.owner_feature) or {}).get(references[0]) frame = feature_frames.get(query.owner_feature) if profile is None or frame is None: return None cap_name = "start" if query.is_start else "end" cap = frame.get(f"{cap_name}_attachment") or frame[cap_name] return { "id": f"sketch_{query.owner_feature}__{feature_id}", "name": f"{query.owner_feature}__{feature_id}", "workplane": dict(cap), "profile": deepcopy(profile), } def _cap_face_selector(value: Any, feature_frames: dict[str, dict[str, Any]], feature_id: str, suffix: str) -> dict[str, Any] | None: """Capture one uniquely framed CAP_FACE/CAP_EDGE as a runtime face selector.""" query = parse_query(value) if query.topology_type not in {"CAP_FACE", "CAP_EDGE"} or not query.owner_feature or query.is_start is None: return None frame = feature_frames.get(query.owner_feature) if frame is None: return None cap = frame.get("start" if query.is_start else "end") if cap is None: return None normal = list(cap["normal"]) return { "kind": "face", "owner_feature_id": f"f_{query.owner_feature}", "stable_id": f"cadfs_{feature_id}_{suffix}", "source": "runtime_snapshot", "confidence": 1.0, "binding_feature_id": f"f_{query.owner_feature}", "geometry": {"normal": normal, "plane_offset_mm": _dot(normal, cap["origin_mm"])}, } def _cap_edge_hole_profile_sketch( value: Any, sketch_by_source: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], feature_frames: dict[str, dict[str, Any]], feature_id: str, ) -> tuple[dict[str, Any], dict[str, Any]] | None: """Materialize an outer IMPRINT profile with one CAP_EDGE-derived inner wire. The inner curve may already be a draft/offset B-rep edge, so it must be taken from its producer's selected cap face at runtime rather than guessed from the original sketch entity. This intentionally accepts one outer closed region and one uniquely framed cap edge only. """ roots = _queries(value) if len(roots) != 2: return None outer_value = next((item for item in roots if parse_query(item).topology_type == "IMPRINT"), None) inner_value = next((item for item in roots if parse_query(item).topology_type == "CAP_EDGE"), None) if outer_value is None or inner_value is None: return None # 同向的 TD region query 选择的是外轮廓两侧相邻的两个 IMPRINT 区域。 # 将二者并集解释成 "外轮廓减 CAP_EDGE" 会错误挖去中心,留下一个仅沿 # 边接触前序实体的薄环。只有两条边的 region side 相反时,才有明确的 # 外环 + 内孔语义可以交给受限的 CAP_EDGE profile atomic。 outer_side = _profile_selection_side(outer_value) inner_side = _profile_selection_side(inner_value) if outer_side is not None and inner_side is not None and outer_side == inner_side: return None outer = parse_query(outer_value) if not outer.source_sketch or outer.source_sketch not in sketch_by_source: return None outer_sketch = _profile_selection_sketch( sketch_by_source[outer.source_sketch], outer_value, entity_by_sketch[outer.source_sketch], feature_id, ) profile = outer_sketch.get("profile") or {} contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None if not ( profile.get("type") == "circle" or isinstance(contours, list) and len(contours) == 1 and bool(contours[0].get("closed")) ): return None selector = _cap_face_selector(inner_value, feature_frames, feature_id, "profile_hole") return (outer_sketch, selector) if selector is not None else None def _cap_edge_union_profile_sketch( value: Any, sketch_by_source: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], feature_id: str, ) -> dict[str, Any] | None: """Materialize the complete outer region selected around one CAP_EDGE.""" roots = _queries(value) if len(roots) != 2: return None outer_value = next((item for item in roots if parse_query(item).topology_type == "IMPRINT"), None) inner_value = next((item for item in roots if parse_query(item).topology_type == "CAP_EDGE"), None) if outer_value is None or inner_value is None: return None outer_side = _profile_selection_side(outer_value) inner_side = _profile_selection_side(inner_value) if outer_side is None or inner_side is None or outer_side != inner_side: return None outer = parse_query(outer_value) if not outer.source_sketch or outer.source_sketch not in sketch_by_source: return None return _profile_selection_sketch( sketch_by_source[outer.source_sketch], outer_value, entity_by_sketch[outer.source_sketch], feature_id, ) def _loft_cap_face_profile( params: dict[str, Any], sketch_by_source: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], feature_frames: dict[str, dict[str, Any]], feature_id: str, ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]] | None: """Resolve one CAP_FACE outer wire followed by one sketch-imprint loft section.""" profiles = params.get("sheetProfilesArray") if not isinstance(profiles, list) or len(profiles) != 2: return None values = [item.get("sheetProfileEntities") if isinstance(item, dict) else item for item in profiles] cap, sketch_query = (parse_query(value) for value in values) if cap.topology_type != "CAP_FACE" or sketch_query.topology_type != "IMPRINT": return None if not sketch_query.source_sketch or sketch_query.source_sketch not in sketch_by_source: return None selector = _cap_face_selector(values[0], feature_frames, feature_id, "loft_cap") frame = feature_frames.get(cap.owner_feature or "") cap_frame = frame and frame.get("start" if cap.is_start else "end") if selector is None or cap_frame is None: return None sketch = _profile_selection_sketch( sketch_by_source[sketch_query.source_sketch], values[1], entity_by_sketch[sketch_query.source_sketch], feature_id, ) return sketch, selector, dict(cap_frame) def _profile_selection_sketch( sketch: dict[str, Any], query_value: Any, entities: dict[str, dict[str, Any]], feature_id: str, ) -> dict[str, Any]: """Materialize a uniquely selected sketch region for an IMPRINT query.""" query = parse_query(query_value) if query.topology_type != "IMPRINT" or not query.source_entity: return sketch contours = (sketch.get("profile") or {}).get("contours") or [] query_values = _queries(query_value) selections = [parse_query(item) for item in query_values] union_profile = _circle_imprint_union_profile(contours, query_values, entities) if union_profile is not None: output = deepcopy(sketch) output["id"] = f"{sketch['id']}__{feature_id}" output["name"] = f"{sketch['name']}__{feature_id}" output["profile"] = union_profile return output # qUnion 可以从同一草图选择多个互不相同的 IMPRINT region。此前只取 # parse_query(qUnion(...)) 最后看到的一个 source entity,且在没有正反 # orientation 对时退回整张草图,导致未选圆也被错误拉伸。仅当每个 source # entity 唯一对应一条 contour 时,直接物化该明确选择集;同源的双侧 region # 继续交给下方的 orientation 逻辑,避免把环形区域猜成圆盘。 source_ids = [ max((key for key in entities if selection.source_entity and selection.source_entity.startswith(key)), key=len, default="") for selection in selections ] # 单一同心圆边的 IMPRINT 查询并不总是选择圆盘。它表达的是该边 # 一侧的 bounded region;例如 `00111611` F7 选 E6 的正侧时,真实 # profile 是 E7/E6 环域而不是 E6 圆盘。 if len(selections) == 1 and source_ids and source_ids[0]: source = entities[source_ids[0]] profile = _circle_imprint_region(contours, source, _profile_selection_side(_queries(query_value)[0])) if profile is not None: output = deepcopy(sketch) output["id"] = f"{sketch['id']}__{feature_id}" output["name"] = f"{sketch['name']}__{feature_id}" output["profile"] = profile return output if ( len(source_ids) == len(set(source_ids)) and all(source_ids) and all(selection.topology_type == "IMPRINT" and selection.source_entity for selection in selections) ): selected: list[dict[str, Any]] = [] for selected_id in source_ids: source = entities[selected_id] matches = [ contour for contour in contours if any(_matching_profile_segment(segment, source) for segment in contour.get("segments") or []) ] if len(matches) != 1: break selected.append(matches[0]) else: output = deepcopy(sketch) output["id"] = f"{sketch['id']}__{feature_id}" output["name"] = f"{sketch['name']}__{feature_id}" output["profile"] = {**output["profile"], "contours": selected} return output source_id = max((key for key in entities if query.source_entity.startswith(key)), key=len, default="") source = entities.get(source_id) if source is None or len(contours) < 2: return sketch selection_sides = [_profile_selection_side(item) for item in _queries(query_value)] if source.get("type") == "circle" and all(side is not None for side in selection_sides): # 两个相反 orientation 的同一圆形 IMPRINT region 表示其两侧区域的 # 并集。对于同心圆草图,CADFS 用这套写法选择内圆盘和相邻圆环;将它 # 们规整为最小包围圆盘,不能按普通 nested-circle profile 误作圆环。 circles = [ segment for contour in contours for segment in contour.get("segments") or [] if segment.get("type") == "circle" and _same_point(segment.get("center") or [], source.get("center") or []) ] radii = sorted({float(segment["radius_mm"]) for segment in circles}) if len(radii) >= 2 and any(side < 0 for side in selection_sides) and any(side > 0 for side in selection_sides): outer = next((radius for radius in radii if radius > float(source["radius_mm"]) + 1e-6), None) if outer is not None: output = deepcopy(sketch) output["id"] = f"{sketch['id']}__{feature_id}" output["name"] = f"{sketch['name']}__{feature_id}" output["profile"] = {"type": "circle", "center": list(source["center"]), "radius_mm": outer} return output matches: list[tuple[dict[str, Any], int]] = [] for contour in contours: direction = next((_matching_profile_segment(segment, source) for segment in contour.get("segments") or [] if _matching_profile_segment(segment, source)), 0) if direction: matches.append((contour, direction)) side = _profile_selection_side(query_value) if side is None or len(matches) < 2: return sketch selected = [contour for contour, direction in matches if direction == (1 if side > 0 else -1)] if len(selected) != 1: raise UnsupportedCapability("sketch_region_selection", "IMPRINT profile selection does not resolve one sketch region") output = deepcopy(sketch) output["id"] = f"{sketch['id']}__{feature_id}" output["name"] = f"{sketch['name']}__{feature_id}" output["profile"] = {**output["profile"], "contours": [selected[0]]} return output def _surface_profile_selection_sketch( sketch: dict[str, Any], query_value: Any, entities: dict[str, dict[str, Any]], feature_id: str, ) -> dict[str, Any]: """Materialize the explicit circular wires selected by surfaceEntities.""" references = _source_refs(query_value) selected = [] seen = set() for source, entity_id in references: if source != sketch["name"] or entity_id in seen: continue entity = entities.get(entity_id) if entity is None or entity.get("type") != "circle": raise UnsupportedCapability( "extrude_surface_profile", "current CDSL surface extrude requires explicitly selected circular wires", ) selected.append(entity); seen.add(entity_id) if not selected: raise ValueError("surface extrude profile query is unresolved") output = deepcopy(sketch) output["id"] = f"{sketch['id']}__{feature_id}_surface" output["name"] = f"{sketch['name']}__{feature_id}_surface" output["profile"] = { "type": "analytic_contours", "contours": [ {"role": "unknown", "closed": True, "segments": [deepcopy(entity)]} for entity in selected ], } return output def _pattern_source_features(value: Any, previous: list[str]) -> list[str]: sources = [] for call in walk_calls(value): if call.name != "makeQuery" or not call.args: continue owner = symbolic_string(call.args[0]) if "F" not in owner: continue source = "f_" + owner[owner.find("F"):].split(".", 1)[0] if source in previous and source not in sources: sources.append(source) if not sources: raise ValueError("pattern source features are unresolved") return sources def _pattern_body_history_sources( value: Any, sources: list[str], previous: list[str], feature_by_id: dict[str, dict[str, Any]], ) -> list[str]: """Extend one selected SWEPT_BODY with its fused additive history. CADFS body queries name the feature that originally created the body. A following ADD sweep can already have become part of that same body before `circularPattern` copies it. Replaying only the creator omits the fused geometry and makes later COPY(CAP_FACE) selectors impossible to bind. Keep this restricted to direct additive body producers whose replay is already supported by the runtime. """ has_swept_body = any( parse_query(item).topology_type == "SWEPT_BODY" and parse_query(item).kind in {"body", "entitytype.body"} for item in _queries(value) ) if not has_swept_body: return sources replayable_adds = { "extrude_add_blind", "extrude_add_two_sided", "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", "sphere_add", "box_add", "cylinder_add", } output = list(sources) source_indexes = [previous.index(source) for source in sources if source in previous] if not source_indexes: return output for feature_id in previous[max(source_indexes) + 1:]: feature = feature_by_id.get(feature_id) or {} if feature.get("atomic_id") not in replayable_adds: continue if (feature.get("params") or {}).get("result_mode") == "new_body": continue output.append(feature_id) return list(dict.fromkeys(output)) def _sweep_cap_frames(profile: dict[str, Any], path: dict[str, Any]) -> dict[str, dict[str, Any]] | None: """Record physical CAP_FACE frames for one line or B-spline sweep.""" segment = path.get("segment") or {} path_plane = path.get("workplane") or {} profile_plane = profile.get("workplane") or {} points = segment.get("points") if segment.get("type") == "bspline" else [segment.get("start"), segment.get("end")] if not isinstance(points, list) or len(points) < 2 or any(not isinstance(point, list) or len(point) != 2 for point in points): return None y_dir = _y_dir(path_plane) def point(value: list[float]) -> list[float]: return [ path_plane["origin_mm"][index] + path_plane["x_dir"][index] * value[0] + y_dir[index] * value[1] for index in range(3) ] def direction(value: Any, fallback: list[float]) -> list[float]: if isinstance(value, list) and len(value) == 2: return _unit([ path_plane["x_dir"][index] * value[0] + y_dir[index] * value[1] for index in range(3) ], "sweep cap tangent is degenerate") return _unit(fallback, "sweep path is degenerate") start, end = point(points[0]), point(points[-1]) start_direction = direction(segment.get("start_tangent"), _sub(point(points[1]), start)) end_direction = direction(segment.get("end_tangent"), _sub(end, point(points[-2]))) x_dir = list(profile_plane["x_dir"]) return { # OCC keeps the source profile as the start cap of a solid sweep. Its # plane follows the profile workplane, not the tangent of a curved # spine. The profile centre may lie at the path point while the plane # origin is elsewhere, so retain the actual profile plane frame. "start": _frame(list(profile_plane["origin_mm"]), x_dir, list(profile_plane["normal"])), "end": _frame(end, x_dir, end_direction), "profile": dict(profile_plane), } def _reversed_sweep_path_segment(segment: dict[str, Any]) -> dict[str, Any]: """Reverse one CADFS line/B-spline sweep path without changing its curve.""" output = deepcopy(segment) output["start"], output["end"] = segment["end"], segment["start"] if output.get("type") == "bspline": output["points"] = list(reversed(segment.get("points") or [])) parameters = segment.get("parameters") if isinstance(parameters, list) and parameters: start_parameter, end_parameter = float(parameters[0]), float(parameters[-1]) output["parameters"] = [start_parameter + end_parameter - float(value) for value in reversed(parameters)] start_tangent, end_tangent = segment.get("start_tangent"), segment.get("end_tangent") if isinstance(start_tangent, list) and isinstance(end_tangent, list): output["start_tangent"] = [-float(value) for value in end_tangent] output["end_tangent"] = [-float(value) for value in start_tangent] return output def _sweep_profile_attaches_at_path_end(profile: dict[str, Any], path: dict[str, Any]) -> bool: """Detect a circle profile placed at the terminal point of a CADFS path.""" segment = path.get("segment") or {} workplane = path.get("workplane") or {} profile_plane = profile.get("workplane") or {} profile_shape = profile.get("profile") or {} if profile_shape.get("type") != "circle" or segment.get("type") not in {"line", "bspline"}: return False start, end = segment.get("start"), segment.get("end") center = profile_shape.get("center") or [0.0, 0.0] if not all(isinstance(value, list) and len(value) == 2 for value in (start, end, center)): return False try: start_point, end_point = _global(workplane, start), _global(workplane, end) profile_center = _global(profile_plane, center) except (KeyError, TypeError, ValueError): return False return math.dist(profile_center, end_point) <= 1e-5 and math.dist(profile_center, start_point) > 1e-5 def _pattern_copy_body(value: Any) -> tuple[str, str, int]: """Resolve one CADFS circular-pattern body copy without flattening it to a feature.""" _call, pattern_owner, topology, kind, definition = _direct_make_query(value) if topology != "COPY" or kind not in {"body", "entitytype.body"}: raise UnsupportedCapability("delete_bodies", "current CDSL deleteBodies only supports circular pattern body copies") derived = definition.get("derivedFrom") if derived is None: raise ValueError("pattern copy deletion has no derived body") _source, source_owner, source_topology, source_kind, _source_definition = _direct_make_query(derived) if source_topology != "SWEPT_BODY" or source_kind not in {"body", "entitytype.body"}: raise UnsupportedCapability("delete_bodies", "pattern copy deletion source is not a direct swept body") try: instance = int(str(definition.get("instanceName"))) except (TypeError, ValueError) as error: raise ValueError("pattern copy deletion instance is unresolved") from error return f"f_{pattern_owner}", f"f_{source_owner}", instance def _boolean_body_sources(value: Any) -> list[str]: """Resolve CADFS SWEPT_BODY query members to their producing features.""" sources: list[str] = [] for query_value in _queries(value): query = parse_query(query_value) if query.topology_type != "SWEPT_BODY" or query.kind not in {"body", "entitytype.body"}: raise ValueError("booleanBodies requires explicit SWEPT_BODY queries") if not query.owner_feature: raise ValueError("booleanBodies source feature is unresolved") source = f"f_{query.owner_feature}" if source not in sources: sources.append(source) if not sources: raise ValueError("booleanBodies has no selected bodies") return sources def _pattern_remove_source(feature: dict[str, Any]) -> None: # circularPattern 的 REMOVE 会把源实体及其实例作为切削工具。仅直接的 # 加料拉伸/回转可无歧义改写为同一 profile 的切除;其他 source 需要 # body 生命周期与工具保留策略,不能猜测成任意 boolean。 cut_atomic = { "extrude_add_blind": "extrude_cut_blind", "extrude_add_two_sided": "extrude_cut_two_sided", "revolve_add": "revolve_cut", }.get(str(feature.get("atomic_id") or "")) if cut_atomic is None: raise UnsupportedCapability( "circular_pattern_remove_source", "current CDSL engine can only replay a REMOVE pattern from a direct additive extrusion or revolve", ) feature["atomic_id"] = cut_atomic feature["params"].pop("result_mode", None) def _transform_source_features(value: Any, previous: list[str]) -> list[str]: sources = [] for call in walk_calls(value): if call.name not in {"makeQuery", "qCreatedBy"} or not call.args: continue owner = symbolic_string(call.args[0]) if "F" not in owner: continue source = "f_" + owner[owner.find("F"):].split(".", 1)[0] if source in previous and source not in sources: sources.append(source) if not sources: raise ValueError("transform source features are unresolved") return sources def _circular_pattern_axis( value: Any, feature_frames: dict[str, dict[str, Any]], sketch_by_source: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], ) -> dict[str, list[float]]: query = parse_query(value) entity = (entity_by_sketch.get(query.source_sketch or "") or {}).get(query.source_entity or "") if entity is None or query.source_sketch not in sketch_by_source: raise ValueError("circular pattern axis is unresolved") plane = sketch_by_source[query.source_sketch]["workplane"] if entity["type"] == "line": start = _global(plane, entity["start"]); end = _global(plane, entity["end"]) direction = [end[index] - start[index] for index in range(3)] norm = math.sqrt(sum(component * component for component in direction)) if norm <= 1e-9: raise ValueError("circular pattern axis line is degenerate") return {"origin_mm": start, "direction": [component / norm for component in direction]} if entity["type"] == "circle": frame = feature_frames.get(query.owner_feature or "") if frame and query.is_start is not None: plane = frame["start" if query.is_start else "end"] return {"origin_mm": _global(plane, entity["center"]), "direction": list(plane["normal"])} raise ValueError("circular pattern axis must be a sketch line or circular edge") def _loft_profile_sketches(params: dict[str, Any]) -> list[str]: # CADFS loft 的 profile 是草图 IMPRINT 面;几何仍来自原始闭合草图, # 保留草图 source,不能把前序实体的选中面近似为新的放样轮廓。 profiles = params.get("sheetProfilesArray") if not isinstance(profiles, list): raise ValueError("loft sheetProfilesArray is unresolved") sources: list[str] = [] for profile in profiles: query_value = profile.get("sheetProfileEntities") if isinstance(profile, dict) else profile query = parse_query(query_value) if query.topology_type and query.topology_type != "IMPRINT": raise UnsupportedCapability( f"loft_profile_topology:{query.topology_type.lower()}", f"current CDSL loft only supports sketch-imprint profiles, not {query.topology_type}", ) if not query.source_sketch: raise ValueError("loft profile sketch query is unresolved") sources.append(query.source_sketch) if len(sources) < 2: raise ValueError("loft requires at least two profile sketches") if len(set(sources)) != len(sources): raise ValueError("loft profile sketches must be distinct") return sources def _profile_query_kind(params: dict[str, Any]) -> str | None: for key in ("entities", "sheetProfilesArray"): if key in params: return parse_query(params[key]).topology_type return None def _profile_executable(sketch: dict[str, Any]) -> bool: profile = sketch.get("profile") or {} if profile.get("type") == "circle": return True if profile.get("type") == "polygon": return len(profile.get("vertices") or []) >= 3 return bool(profile.get("contours")) def _default_plane(value: Any) -> dict[str, Any] | None: for call in walk_calls(value): text = " ".join(symbolic_string(arg) for arg in call.args) for name, plane in PLANES.items(): if f"{name}.planeOp" in text: return dict(plane) return None def _entity_from_query( query: Any, sketch_by_source: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], ) -> tuple[dict[str, Any], dict[str, Any], str]: info = parse_query(query); source = info.source_sketch or ""; token = info.source_entity or "" available = entity_by_sketch.get(source) or {} entity = available.get(token) if entity is None: entity_id = max((key for key in available if token.startswith(key + ".")), key=len, default="") entity = available.get(entity_id) sketch = sketch_by_source.get(source) if entity is None or sketch is None: raise ValueError("reference geometry source is unresolved") return entity, sketch["workplane"], token def _entity_point(entity: dict[str, Any], plane: dict[str, Any], token: str) -> list[float]: if entity["type"] == "point": return _global(plane, entity["point"]) if entity["type"] == "line": local = entity["end"] if ".end" in token else entity["start"] return _global(plane, local) if entity["type"] == "bspline": # FeatureScript spline query suffixes are zero-based output-vertex # indexes: ``E1.2.internal`` identifies the third interpolation # point, including a point that is also the wire end. Treating them # as one-based moves a reference plane to its preceding control point. index = next((int(part) for part in token.split(".") if part.isdigit()), 0) points = entity.get("points") or [] if not points: raise ValueError("B-spline reference point is unresolved") return _global(plane, points[max(0, min(index, len(points) - 1))]) raise ValueError("reference entity does not define a point") def _entity_line(entity: dict[str, Any], plane: dict[str, Any]) -> tuple[list[float], list[float]]: if entity["type"] != "line": raise ValueError("reference entity is not a line") return _global(plane, entity["start"]), _global(plane, entity["end"]) def _local(plane: dict[str, Any], point: list[float]) -> list[float]: relative = _sub(point, plane["origin_mm"]) return [_dot(relative, plane["x_dir"]), _dot(relative, _y_dir(plane))] def _revolve_swept_face_profile( query: Any, plane: dict[str, Any], feature_frames: dict[str, dict[str, Any]], sketch_by_source: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], ) -> dict[str, Any] | None: info = parse_query(query); frame = feature_frames.get(info.owner_feature or "") if info.topology_type != "SWEPT_FACE" or frame is None or not frame.get("revolve_full"): return None axis = frame.get("revolve_axis") if not isinstance(axis, dict): return None start, end = _query_line(query, feature_frames, sketch_by_source, entity_by_sketch) direction = _sub(end, start); axis_direction = axis["direction"] if abs(_dot(direction, axis_direction)) > 1e-6 * max(1.0, math.sqrt(_dot(direction, direction))): raise UnsupportedCapability("extrude_profile_topology:swept_face", "revolved swept face source line is not perpendicular to the revolve axis") start_offset = _dot(_sub(start, axis["origin_mm"]), axis_direction) end_offset = _dot(_sub(end, axis["origin_mm"]), axis_direction) if abs(start_offset - end_offset) > 1e-6: raise UnsupportedCapability("extrude_profile_topology:swept_face", "revolved swept face source line is not coplanar with the revolve axis") center = [axis["origin_mm"][index] + axis_direction[index] * start_offset for index in range(3)] radii = sorted([math.dist(start, center), math.dist(end, center)]) if radii[1] <= 1e-9: raise ValueError("revolved swept face source line is degenerate") local_center = _local(plane, center) contours = [{"role": "outer", "closed": True, "segments": [{"type": "circle", "center": local_center, "radius_mm": radii[1]}]}] if radii[0] > 1e-9: contours.append({"role": "inner", "closed": True, "segments": [{"type": "circle", "center": local_center, "radius_mm": radii[0]}]}) return {"type": "analytic_contours", "contours": contours} def _query_plane( query: Any, feature_frames: dict[str, dict[str, Any]], sketch_by_source: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], ) -> dict[str, Any]: try: return _plane_from_query(query, feature_frames, sketch_by_source, entity_by_sketch) except ValueError: info = parse_query(query) if info.topology_type != "SWEPT_FACE" or not info.owner_feature: raise entity, source_plane, _ = _entity_from_query(query, sketch_by_source, entity_by_sketch) start, end = _entity_line(entity, source_plane); frame = feature_frames.get(info.owner_feature) if frame is None: raise ValueError("swept face owner frame is unresolved") if frame.get("revolve_full"): axis = frame.get("revolve_axis") if not isinstance(axis, dict): raise ValueError("revolve swept face axis is unresolved") direction = _sub(end, start); axis_direction = axis["direction"] if abs(_dot(direction, axis_direction)) > 1e-6 * max(1.0, math.sqrt(_dot(direction, direction))): raise UnsupportedCapability("reference_plane:swept_face", "revolved swept face source line is not perpendicular to the revolve axis") return _frame(start, direction, axis_direction) direction = _unit(_sub(end, start), "swept face source line is degenerate") sketch = sketch_by_source.get(info.source_sketch or "") or {} contours = (sketch.get("profile") or {}).get("contours") or [] contour = next(( item for item in contours if any(_matching_profile_segment(segment, entity) for segment in item.get("segments") or []) ), None) points = [ segment["start"] for segment in (contour or {}).get("segments") or [] if isinstance(segment.get("start"), list) ] if points: center = _global(source_plane, [ sum(point[index] for point in points) / len(points) for index in range(2) ]) midpoint = [(start[index] + end[index]) / 2.0 for index in range(3)] inward = _sub(center, midpoint) inward = _sub(inward, [direction[index] * _dot(inward, direction) for index in range(3)]) normal = [-value for value in _unit(inward, "swept face interior is degenerate")] else: normal = _cross(direction, frame["end"]["normal"]) x_dir = direction if _dot(_cross(normal, x_dir), source_plane["normal"]) < 0: x_dir = [-value for value in x_dir] # 附着草图的局部原点是全局原点在实体侧面上的投影,不是 source # 草图边的任一端点。后者会把以原全局平面坐标表达的 F7/F9 等草图 # 平移一个完整边长,令后续 cut 落在主体之外。 return _attachment_plane(_frame(start, x_dir, normal)) def _offset_face_plane( value: Any, feature_frames: dict[str, dict[str, Any]], sketch_by_source: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], ) -> dict[str, Any]: """Recover one planar inner shell wall from its source profile edge.""" query = parse_query(value) frame = feature_frames.get(query.owner_feature or "") or {} if query.topology_type != "OFFSET_FACE" or not frame.get("shell_source"): raise ValueError("offset face owner is unresolved") thickness = float(frame.get("shell_thickness_mm") or 0.0) if thickness <= 0: raise ValueError("offset face shell thickness is unresolved") entity, source_plane, _ = _entity_from_query(value, sketch_by_source, entity_by_sketch) start, end = _entity_line(entity, source_plane) direction = _unit(_sub(end, start), "offset face source line is degenerate") source_sketch = sketch_by_source.get(query.source_sketch or "") or {} contours = (source_sketch.get("profile") or {}).get("contours") or [] contour = next(( item for item in contours if any(_matching_profile_segment(segment, entity) for segment in item.get("segments") or []) ), None) points = [ segment["start"] for segment in (contour or {}).get("segments") or [] if isinstance(segment.get("start"), list) ] if not points: raise ValueError("offset face source contour is unresolved") center = _global(source_plane, [ sum(point[index] for point in points) / len(points) for index in range(2) ]) midpoint = [(start[index] + end[index]) / 2.0 for index in range(3)] inward = _sub(center, midpoint) inward = _sub(inward, [direction[index] * _dot(inward, direction) for index in range(3)]) normal = _unit(inward, "offset face source interior is degenerate") x_dir = direction if _dot(_cross(normal, x_dir), source_plane["normal"]) < 0: x_dir = [-value for value in x_dir] offset = [start[index] + thickness * normal[index] for index in range(3)] return _attachment_plane(_frame(offset, x_dir, normal)) def _offset_face_profile_sketch( value: Any, feature_frames: dict[str, dict[str, Any]], sketches_by_id: dict[str, dict[str, Any]], sketch_by_source: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], feature_id: str, ) -> dict[str, Any] | None: """Materialize a planar OFFSET_FACE as the shell's shifted profile region.""" query = parse_query(value) frame = feature_frames.get(query.owner_feature or "") or {} profile_id = frame.get("shell_profile_sketch_id") profile = sketches_by_id.get(str(profile_id)) if profile_id else None if query.topology_type != "OFFSET_FACE" or profile is None: return None return { "id": f"sketch_{query.owner_feature}__{feature_id}", "name": f"{query.owner_feature}__{feature_id}", "workplane": _offset_face_plane(value, feature_frames, sketch_by_source, entity_by_sketch), "profile": deepcopy(profile["profile"]), } def _query_line( query: Any, feature_frames: dict[str, dict[str, Any]], sketch_by_source: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], ) -> tuple[list[float], list[float]]: info = parse_query(query) entity, plane, _ = _entity_from_query(query, sketch_by_source, entity_by_sketch) if info.topology_type == "CAP_EDGE" and info.owner_feature in feature_frames: frame = feature_frames[info.owner_feature]["start" if info.is_start else "end"] plane = frame if entity["type"] == "circle": center = _global(plane, entity["center"]) return center, [center[index] + plane["normal"][index] for index in range(3)] if info.topology_type == "SWEPT_FACE" and entity["type"] == "circle" and info.owner_feature in feature_frames: frame = feature_frames[info.owner_feature] start, end = frame.get("start"), frame.get("end") if start is None or end is None: raise ValueError("cylindrical swept face frame is unresolved") center = _global(plane, entity["center"]) return [center[index] + start["origin_mm"][index] - plane["origin_mm"][index] for index in range(3)], [center[index] + end["origin_mm"][index] - plane["origin_mm"][index] for index in range(3)] return _entity_line(entity, plane) def _transform_axis( query: Any, feature_frames: dict[str, dict[str, Any]], sketch_by_source: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], ) -> dict[str, list[float]]: start, end = _query_line(query, feature_frames, sketch_by_source, entity_by_sketch) return {"origin_mm": start, "direction": _unit(_sub(end, start), "transform axis is degenerate")} def _bake_transform( params: dict[str, Any], previous: list[str], feature_by_id: dict[str, dict[str, Any]], feature_source_by_id: dict[str, str], sketches_by_id: dict[str, dict[str, Any]], feature_frames: dict[str, dict[str, Any]], sketch_by_source: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], ) -> None: if _bool(params.get("makeCopy")): raise UnsupportedCapability("transform", "current CDSL engine cannot exactly copy transformed CADFS source bodies") sources = _transform_source_features(params.get("entities"), previous) if len(sources) != 1: raise UnsupportedCapability("transform", "current CDSL engine cannot exactly transform multiple selected CADFS source bodies") source_id = sources[0]; source = feature_by_id.get(source_id) if source is None or source.get("atomic_id") not in {"extrude_add_blind", "extrude_add_two_sided", "revolve_add"}: raise UnsupportedCapability("transform", "current CDSL engine can only bake a direct additive extrusion or revolve transform") sketch = sketches_by_id.get(str(source.get("sketch_id") or "")) source_feature_id = feature_source_by_id.get(source_id) if sketch is None or source_feature_id is None: raise UnsupportedCapability("transform", "current CDSL engine cannot resolve the transformed source feature geometry") transform_type = str(params.get("transformType") or "").split(".")[-1].upper() if transform_type == "TRANSLATION_3D": offset = [_number(params.get(key, 0.0), True) for key in ("dx", "dy", "dz")] transform_frame = lambda frame: _translate_frame(frame, offset) transform_axis = lambda axis: {**axis, "origin_mm": [axis["origin_mm"][index] + offset[index] for index in range(3)]} elif transform_type == "ROTATION": axis = _transform_axis(params.get("transformAxis"), feature_frames, sketch_by_source, entity_by_sketch) angle_rad = math.radians(_number(params.get("angle"), True)) transform_frame = lambda frame: _rotate_frame(frame, axis, angle_rad) transform_axis = lambda value: { **value, "origin_mm": _rotate_point(value["origin_mm"], axis, angle_rad), "direction": _rotate(value["direction"], axis["direction"], angle_rad), } else: raise UnsupportedCapability("transform", f"current CDSL engine cannot exactly bake {transform_type or 'unknown'} transform") sketch["workplane"] = transform_frame(sketch["workplane"]) frame = feature_frames.get(source_feature_id) if frame is not None: transformed = {key: transform_frame(value) for key, value in frame.items() if key in {"start", "end", "profile"}} for cap in ("start", "end"): if cap in transformed: transformed[f"{cap}_attachment"] = _attachment_plane(transformed[cap]) if isinstance(frame.get("revolve_axis"), dict): transformed["revolve_axis"] = transform_axis(frame["revolve_axis"]) if "revolve_full" in frame: transformed["revolve_full"] = frame["revolve_full"] feature_frames[source_feature_id] = transformed source_axis = source.get("params", {}).get("axis") if isinstance(source_axis, dict) and source_axis.get("origin_mm") and source_axis.get("direction"): source["params"]["axis"] = transform_axis(source_axis) def _query_point( query: Any, feature_frames: dict[str, dict[str, Any]], sketch_by_source: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], ) -> list[float]: info = parse_query(query) if info.topology_type == "CAP_VERTEX" and info.owner_feature in feature_frames: references = _source_refs(query) if len(references) >= 2: plane = feature_frames[info.owner_feature]["start" if info.is_start else "end"] lines = [] for source, token in references: available = entity_by_sketch.get(source) or {} entity = available.get(token) if entity is None: entity_id = max((key for key in available if token.startswith(key + ".")), key=len, default="") entity = available.get(entity_id) if entity and entity.get("type") == "line": lines.append(_entity_line(entity, plane)) if len(lines) >= 2: pairs = [(math.dist(left, right), left) for left in lines[0] for right in lines[1]] distance, point = min(pairs, key=lambda item: item[0]) if distance <= 1e-5: return point entity, plane, token = _entity_from_query(query, sketch_by_source, entity_by_sketch) if info.topology_type == "CAP_VERTEX" and info.owner_feature in feature_frames: plane = feature_frames[info.owner_feature]["start" if info.is_start else "end"] return _entity_point(entity, plane, token) def _cplane( params: dict[str, Any], feature_frames: dict[str, dict[str, Any]], sketch_by_source: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], ) -> dict[str, Any]: plane_type = str(params.get("cplaneType") or "OFFSET").split(".")[-1].upper() entities = _queries(params.get("entities")) if plane_type == "OFFSET": return _shift_plane(_query_plane(entities[0], feature_frames, sketch_by_source, entity_by_sketch), _number(params.get("offset", 0), True)) if plane_type == "LINE_ANGLE": line_query = next((item for item in entities if parse_query(item).source_entity), None) if line_query is None: raise ValueError("line-angle reference line is unresolved") base_query = next((item for item in entities if item is not line_query), line_query) try: base = _query_plane(base_query, feature_frames, sketch_by_source, entity_by_sketch) except ValueError: _, base, _ = _entity_from_query(line_query, sketch_by_source, entity_by_sketch) start, end = _query_line(line_query, feature_frames, sketch_by_source, entity_by_sketch) axis = _sub(end, start); angle = -_number(params.get("angle", 0.0)) opposite = _bool(params.get("oppositeDirection")) normal = _rotate(base["normal"], axis, math.radians(angle)) x_dir = _rotate(base["x_dir"], axis, math.radians(angle)) entity, _, _ = _entity_from_query(line_query, sketch_by_source, entity_by_sketch) info = parse_query(line_query) if entity.get("type") == "circle" and info.topology_type in {"SWEPT_FACE", "CAP_EDGE"}: # 圆柱面/端盖圆边用于 LINE_ANGLE 时,FeatureScript 取圆柱面上的 # 母线作为旋转轴,而不是圆心处的中心轴。将该轴移到旋转后的面 # 法向所指的圆柱半径位置,才能保持后续草图和端面引用的位置。 axis_direction = _unit(axis, "line-angle reference line is degenerate") radial = _sub(normal, [axis_direction[index] * _dot(normal, axis_direction) for index in range(3)]) start = [start[index] + _unit(radial, "line-angle cylinder radial direction is degenerate")[index] * entity["radius_mm"] for index in range(3)] return _frame(start, x_dir, normal) if plane_type == "PLANE_POINT": base_query = next((item for item in entities if _default_plane(item) or "qCreatedBy" in parse_query(item).calls), None) point_query = next((item for item in entities if item is not base_query), None) if base_query is None or point_query is None: raise ValueError("plane-point references are unresolved") base = _query_plane(base_query, feature_frames, sketch_by_source, entity_by_sketch) return _frame(_query_point(point_query, feature_frames, sketch_by_source, entity_by_sketch), base["x_dir"], base["normal"]) if plane_type == "CURVE_POINT": point_query = next((item for item in entities if parse_query(item).kind and "vertex" in parse_query(item).kind), None) curve_query = next((item for item in entities if item is not point_query), None) if point_query is None or curve_query is None: raise ValueError("curve-point references are unresolved") point = _query_point(point_query, feature_frames, sketch_by_source, entity_by_sketch) start, end = _query_line(curve_query, feature_frames, sketch_by_source, entity_by_sketch) _, source_plane, _ = _entity_from_query(curve_query, sketch_by_source, entity_by_sketch) return _frame(point, source_plane["normal"], _sub(end, start)) if plane_type == "THREE_POINT": if len(entities) != 3: raise ValueError("three-point plane requires exactly three points") first, second, third = [_query_point(item, feature_frames, sketch_by_source, entity_by_sketch) for item in entities] normal = _cross(_sub(second, first), _sub(third, first)) if _bool(params.get("oppositeDirection")): normal = [-value for value in normal] return _frame(first, _sub(second, first), normal) if plane_type == "LINE_POINT": line_query = next((item for item in entities if "edge" in (parse_query(item).kind or "")), None) point_query = next((item for item in entities if item is not line_query), None) if line_query is None or point_query is None: raise ValueError("line-point references are unresolved") # FeatureScript 的 LINE_POINT 平面经过指定点,法向与参考线平行。 # 点可以是线端点;它不是用来和直线共同定义平面的第三个方向。 _, source_plane, _ = _entity_from_query(line_query, sketch_by_source, entity_by_sketch) start, end = _query_line(line_query, feature_frames, sketch_by_source, entity_by_sketch) point = _query_point(point_query, feature_frames, sketch_by_source, entity_by_sketch) normal = _sub(end, start) if _bool(params.get("oppositeDirection")): normal = [-value for value in normal] return _frame(point, _cross(source_plane["normal"], normal), normal) if plane_type == "MID_PLANE": if len(entities) != 2: raise ValueError("mid-plane requires exactly two reference planes") first, second = [_query_plane(item, feature_frames, sketch_by_source, entity_by_sketch) for item in entities] first_normal = _unit(first["normal"], "first mid-plane normal is degenerate") second_normal = _unit(second["normal"], "second mid-plane normal is degenerate") intersection = _cross(first_normal, second_normal) intersection_length_squared = _dot(intersection, intersection) if intersection_length_squared <= 1e-12: alignment = 1.0 if _dot(first_normal, second_normal) >= 0 else -1.0 offset = _dot(_sub(second["origin_mm"], first["origin_mm"]), first_normal) * alignment return _shift_plane(first, offset / 2.0) # 两个相交面没有“中点偏移面”。CADFS 的 MID_PLANE 是两面形成的 # 二面角平分面:先令两个法向同向,再取其和作为平分面的法向;平面 # 经过两原平面的交线。此处的 local x 轴由交线推导,后续草图的 # (u, v) 坐标不依赖任意选择的输入 face frame。 if _dot(first_normal, second_normal) < 0: second_normal = [-value for value in second_normal] # 法向翻转也会反转两平面的交线方向。交点公式中的交线必须与已 # 对齐的法向保持同一方向,否则会将原点映射到交线的对称位置。 intersection = _cross(first_normal, second_normal) normal = _unit([first_normal[index] + second_normal[index] for index in range(3)], "mid-plane angle bisector is degenerate") first_offset = _dot(first_normal, first["origin_mm"]) second_offset = _dot(second_normal, second["origin_mm"]) first_term = _cross(second_normal, intersection) second_term = _cross(intersection, first_normal) origin = [ (first_offset * first_term[index] + second_offset * second_term[index]) / intersection_length_squared for index in range(3) ] return _frame(origin, _cross(normal, intersection), normal) raise UnsupportedCapability(f"reference_plane:{plane_type.lower()}", f"current converter has no exact {plane_type} reference plane") def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: diagnostics: list[dict[str, Any]] = []; history = [] sketches: list[dict[str, Any]] = []; sketches_by_id: dict[str, dict[str, Any]] = {}; sketch_by_source: dict[str, dict[str, Any]] = {}; entity_by_sketch: dict[str, dict[str, dict[str, Any]]] = {} feature_frames: dict[str, dict[str, Any]] = {}; cap_profiles: dict[str, dict[tuple[str, str], dict[str, Any]]] = {}; surface_profiles: list[dict[str, Any]] = []; swept_face_sketches: set[str] = set() features: list[dict[str, Any]] = []; complete = True; previous: list[str] = [] feature_by_id: dict[str, dict[str, Any]] = {}; feature_source_by_id: dict[str, str] = {} for step in model.steps: if isinstance(step, SketchIR): history.append({"feature_id": step.feature_id, "operation": "newSketch", "parameters": {"sketchPlane": plain(step.workplane)}, "entities": [{"entity_id": e.feature_id, "operation": e.operation, "parameters": plain(e.params)} for e in step.entities]}) try: swept_face = parse_query(step.workplane).topology_type == "SWEPT_FACE" plane = _query_plane(step.workplane, feature_frames, sketch_by_source, entity_by_sketch) if swept_face else _plane_from_query(step.workplane, feature_frames, sketch_by_source, entity_by_sketch) profile = _revolve_swept_face_profile(step.workplane, plane, feature_frames, sketch_by_source, entity_by_sketch) if swept_face and not step.entities else None if profile is None: lowered, entities = _lower_sketch(step, plane) else: lowered, entities = {"id": f"sketch_{step.feature_id}", "name": step.feature_id, "workplane": plane, "profile": profile}, {} swept_face_sketches.add(step.feature_id) sketches.append(lowered); sketches_by_id[lowered["id"]] = lowered; sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities except Exception as exc: # 开放草图不能作为实体 profile,但其几何仍可能是后续基准面、 # 阵列轴或旋转轴的精确引用。保留为 reference 草图,后续实体 # 特征仍由 _profile_executable 明确拒绝,不能静默把开放轮廓实体化。 try: plane = _query_plane(step.workplane, feature_frames, sketch_by_source, entity_by_sketch) if swept_face else _plane_from_query(step.workplane, feature_frames, sketch_by_source, entity_by_sketch) lowered, entities = _lower_sketch(step, plane, allow_open=True) sketches.append(lowered); sketches_by_id[lowered["id"]] = lowered; sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities except Exception: pass diagnostics.append({"code": "sketch_deferred", "feature_id": step.feature_id, "message": str(exc)}); complete = False continue item = step history.append({"feature_id": item.feature_id, "operation": item.operation, "source_span": {"line_start": item.line_start, "line_end": item.line_end or item.line_start}, "parameters": plain(item.params), "raw_source": item.raw_source}) if item.operation in UNSUPPORTED: diagnostics.append({"code": "unsupported_operation", "feature_id": item.feature_id, "operation": item.operation}); complete = False; continue try: fid = f"f_{item.feature_id}"; depends = list(previous[-1:]); p = item.params; feature: dict[str, Any] if item.operation == "transform": # 仅将单一、直接的原始实体变换烘焙回其输入几何。不能移动当前 # 聚合主体:CADFS transform 可能只选择 pattern copy 或多 body。 _bake_transform(p, previous, feature_by_id, feature_source_by_id, sketches_by_id, feature_frames, sketch_by_source, entity_by_sketch) continue elif item.operation == "deleteBodies": copies = [_pattern_copy_body(query) for query in _queries(p.get("entities"))] if not copies: raise ValueError("deleteBodies selection is empty") for pattern_id, source_id, instance in copies: pattern = feature_by_id.get(pattern_id) if pattern is None or pattern.get("atomic_id") != "pattern_circular": raise UnsupportedCapability("delete_bodies", "deleted body is not owned by a circular pattern") params = pattern["params"] if source_id not in params.get("source_feature_ids", []): raise ValueError("pattern copy deletion source is not replayed by its owner") count = int(params.get("pattern_count") or 0) if instance < 1 or instance >= count: raise ValueError("pattern copy deletion instance is outside the generated range") excluded = params.setdefault("excluded_instance_indices", []) if instance not in excluded: excluded.append(instance) continue elif item.operation == "cPlane": plane = _cplane(p, feature_frames, sketch_by_source, entity_by_sketch) feature = {"id": fid, "name": item.feature_id, "atomic_id": "reference_plane", "depends_on": depends, "params": {"plane": plane}, "execution_status": "supported"} attachment = _attachment_plane(plane) feature_frames[item.feature_id] = { "start": plane, "end": plane, "start_attachment": attachment, "end_attachment": attachment, } elif item.operation == "extrude": surface_profile_sketch = None if p.get("surfaceOperationType") is not None: surface_operation = str(p.get("surfaceOperationType") or "").rsplit(".", 1)[-1].upper() if surface_operation != "ADD": raise UnsupportedCapability("extrude_surface_operation", "current CDSL surface extrude supports only NewSurfaceOperationType.ADD") surface_source = _source_sketch({"surfaceEntities": p.get("surfaceEntities")}) if not surface_source or surface_source not in sketch_by_source: raise ValueError("surface extrude sketch query is unresolved") surface_profile_sketch = _surface_profile_selection_sketch( sketch_by_source[surface_source], p.get("surfaceEntities"), entity_by_sketch[surface_source], fid, ) sketches.append(surface_profile_sketch); sketches_by_id[surface_profile_sketch["id"]] = surface_profile_sketch profile_value = _sketch_region_query(p.get("entities")) or p.get("entities") profile_kind = parse_query(profile_value).topology_type source = parse_query(profile_value).source_sketch or _source_sketch(p) imprint = _imprint_sketch(profile_value) cap_profile_sketch = _cap_face_profile_sketch(profile_value, feature_frames, cap_profiles, fid) cap_edge_hole = _cap_edge_hole_profile_sketch( profile_value, sketch_by_source, entity_by_sketch, feature_frames, fid, ) cap_edge_union_profile = _cap_edge_union_profile_sketch( profile_value, sketch_by_source, entity_by_sketch, fid, ) intersect_profile_sketch = _intersect_partition_profile_sketch( profile_value, sketch_by_source, entity_by_sketch, fid, ) if profile_kind == "INTERSECT" else None offset_face_profile = _offset_face_profile_sketch( profile_value, feature_frames, sketches_by_id, sketch_by_source, entity_by_sketch, fid, ) if profile_kind == "OFFSET_FACE" else None if cap_edge_hole is not None: profile_sketch, hole_selector = cap_edge_hole if profile_sketch["id"] not in sketches_by_id: sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch elif cap_edge_union_profile is not None: profile_sketch = cap_edge_union_profile if profile_sketch["id"] not in sketches_by_id: sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch elif profile_kind == "SWEPT_EDGE" and imprint in swept_face_sketches: source = imprint elif profile_kind == "CAP_FACE" and cap_profile_sketch is not None: profile_sketch = cap_profile_sketch sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch elif intersect_profile_sketch is not None: profile_sketch = intersect_profile_sketch sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch elif offset_face_profile is not None: profile_sketch = offset_face_profile sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch elif profile_kind not in {None, "IMPRINT"}: partitioned = _partitioned_imprint_sketch(p.get("entities"), entity_by_sketch) if partitioned is not None: source = partitioned; profile_kind = "IMPRINT" else: raise UnsupportedCapability(f"extrude_profile_topology:{profile_kind.lower()}", f"current CDSL engine cannot exactly replay an extrude profile selected from {profile_kind}") if cap_edge_hole is None and cap_edge_union_profile is None and cap_profile_sketch is None and intersect_profile_sketch is None and offset_face_profile is None: if not source or source not in sketch_by_source: raise ValueError("extrude sketch query is unresolved") open_profile_sketch = _open_imprint_profile_sketch(sketch_by_source[source], profile_value, fid) profile_sketch = open_profile_sketch or _profile_selection_sketch(sketch_by_source[source], profile_value, entity_by_sketch[source], fid) if profile_sketch is not sketch_by_source[source]: sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch operation = str(p.get("operationType") or "NEW").upper() cutting = any(x in operation for x in ("REMOVE", "CUT")) if cutting and profile_kind == "IMPRINT": trimmed_profile_sketch = _surface_trimmed_imprint_profile(profile_sketch, surface_profile_sketch, surface_profiles) if trimmed_profile_sketch is not profile_sketch: for index, sketch in enumerate(sketches): if sketch is profile_sketch: sketches[index] = trimmed_profile_sketch; break sketches_by_id[trimmed_profile_sketch["id"]] = trimmed_profile_sketch profile_sketch = trimmed_profile_sketch if not _profile_executable(profile_sketch): raise ValueError("extrude sketch has no closed profile") end = _end_condition("SYMMETRIC" if _bool(p.get("symmetric")) else p.get("endBound")) if end["type"] == "up_to_body": end["reference"] = _extent_reference(p.get("endBoundEntityBody"), "body", feature_frames, sketch_by_source, entity_by_sketch) elif end["type"] == "up_to_surface": end["reference"] = _extent_reference(p.get("endBoundEntityFace"), "face", feature_frames, sketch_by_source, entity_by_sketch) elif end["type"] == "up_to_vertex": end["reference"] = _intersection_vertex_reference( p.get("endBoundEntityVertex"), feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, item.feature_id, ) if _bool(p.get("hasOffset")): offset = _number(p.get("offsetDistance"), True) if offset < 0: raise ValueError("extrude extent offset must be non-negative") end["offset_mm"] = offset depth_value = p.get("depth"); depth = _number(depth_value, True) if depth_value is not None else 1.0 # FeatureScript 的 operationType 缺省为 NEW。source 若要把结果 # 并入既有主体会显式给出 ADD;不能根据当前 CDSL history 猜成 ADD, # 否则会把应保留的独立 body 在转换时丢失。 reverse = _bool(p.get("oppositeDirection")); cutting = any(x in operation for x in ("REMOVE", "CUT")) second = _bool(p.get("hasSecondDirection")) if cutting and not second and end["type"] not in {"blind", "mid_plane", "through_all", "through_next", "up_to_surface", "up_to_vertex", "up_to_body"}: capability = f"extrude_cut_{end['type']}" raise UnsupportedCapability(capability, f"current CDSL atomic set has no exact {capability} operation") if not cutting and not second and end["type"] not in {"blind", "mid_plane", "through_all", "through_next", "up_to_surface", "up_to_vertex", "up_to_body"}: capability = f"extrude_add_{end['type']}" raise UnsupportedCapability(capability, f"current CDSL atomic set has no exact {capability} operation") if second: atomic = "extrude_cut_two_sided" if cutting else "extrude_add_two_sided" params = {"distance_mm": depth, "reverse": reverse, "end_condition": end} reverse_depth = _number(p.get("secondDirectionDepth", depth), True) reverse_end = _end_condition(p.get("secondDirectionBound")) if reverse_end["type"] == "up_to_body": reverse_end["reference"] = _extent_reference(p.get("secondDirectionBoundEntityBody"), "body", feature_frames, sketch_by_source, entity_by_sketch) elif reverse_end["type"] == "up_to_surface": reverse_end["reference"] = _extent_reference(p.get("secondDirectionBoundEntityFace"), "face", feature_frames, sketch_by_source, entity_by_sketch) elif reverse_end["type"] == "up_to_vertex": reverse_end["reference"] = _intersection_vertex_reference( p.get("secondDirectionBoundEntityVertex"), feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, item.feature_id, ) params.update({"reverse_distance_mm": reverse_depth, "reverse_end_condition": reverse_end}) elif end["type"] == "mid_plane": blind = _end_condition("BLIND") atomic = "extrude_cut_two_sided" if cutting else "extrude_add_two_sided" params = {"distance_mm": depth / 2, "reverse_distance_mm": depth / 2, "reverse": reverse, "end_condition": blind, "reverse_end_condition": dict(blind)} else: atomic = "extrude_cut_blind" if cutting else "extrude_add_blind" params = {"distance_mm": depth, "reverse": reverse, "end_condition": end} if _bool(p.get("hasDraft")): if second or end["type"] != "blind": raise UnsupportedCapability("extrude_draft_extent", "current CDSL draft supports only one-sided blind extrusions") params["draft"] = { "angle_deg": _number(p.get("draftAngle")), "pull_direction": _bool(p.get("draftPullDirection")), } if cap_edge_hole is not None: if cutting or second or end["type"] != "blind" or "draft" in params: raise UnsupportedCapability("extrude_cap_edge_profile", "current CAP_EDGE profile extrusion supports one-sided blind additive results") atomic = "extrude_add_blind_with_hole" if not cutting and _is_new_body_operation(operation): params["result_mode"] = "new_body" feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": profile_sketch["id"], "params": params, "execution_status": "supported"} if cap_edge_hole is not None: feature["selectors"] = [hole_selector] if surface_profile_sketch is not None: if end["type"] not in {"blind", "mid_plane"}: raise UnsupportedCapability("extrude_surface_extent", "current CDSL surface extrude supports blind and symmetric extents only") surface_params = {"distance_mm": params["distance_mm"], "reverse": bool(params.get("reverse"))} if "reverse_distance_mm" in params: surface_params["reverse_distance_mm"] = params["reverse_distance_mm"] surface_feature = { "id": f"{fid}_surface", "name": f"{item.feature_id} surface", "atomic_id": "extrude_surface", "depends_on": [fid], "sketch_id": surface_profile_sketch["id"], "params": surface_params, "execution_status": "supported", } plane = profile_sketch["workplane"] if end["type"] == "blind" and not second: direction = -1 if reverse else 1 # FeatureScript 的 CAP_FACE 是实体端盖,而不是原草图平面。 # 记录实际外法向后,后续附着在 start/end cap 的草图才能沿 # 正确一侧拉伸;不能复用 profile 的初始法向。 feature_frames[item.feature_id] = { "start": _oriented_plane(plane, -direction), "end": _oriented_plane(plane, direction, direction * depth), "profile": dict(plane), } if source and source in sketch_by_source: cap_profiles[item.feature_id] = _imprint_cap_profiles( sketch_by_source[source], p.get("entities"), entity_by_sketch[source], ) elif end["type"] == "mid_plane": direction = -1 if reverse else 1 feature_frames[item.feature_id] = { "start": _oriented_plane(plane, -direction, -direction * depth / 2), "end": _oriented_plane(plane, direction, direction * depth / 2), "profile": dict(plane), } elif item.operation == "loft": cap_face_loft = _loft_cap_face_profile(p, sketch_by_source, entity_by_sketch, feature_frames, fid) if cap_face_loft is not None: profile_sketch, cap_selector, cap_plane = cap_face_loft if profile_sketch["id"] not in sketches_by_id: sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch if not _profile_executable(profile_sketch): raise ValueError("loft profile sketch has no closed profile") feature = { "id": fid, "name": item.feature_id, "atomic_id": "loft_add_with_cap_face", "depends_on": depends, "params": {"profile_sketch_ids": [profile_sketch["id"]]}, "selectors": [cap_selector], "execution_status": "supported", } frames = _loft_cap_frames(cap_plane, profile_sketch["workplane"]) else: sources = _loft_profile_sketches(p) missing = [source for source in sources if source not in sketch_by_source] if missing: raise ValueError("loft profile sketches are unresolved: " + ", ".join(missing)) non_closed = [source for source in sources if not _profile_executable(sketch_by_source[source])] if non_closed: raise ValueError("loft profile sketches have no closed profile: " + ", ".join(non_closed)) feature = { "id": fid, "name": item.feature_id, "atomic_id": "loft_add", "depends_on": depends, "params": {"profile_sketch_ids": [sketch_by_source[source]["id"] for source in sources]}, "execution_status": "supported", } frames = _loft_cap_frames( sketch_by_source[sources[0]]["workplane"], sketch_by_source[sources[-1]]["workplane"], ) if frames is not None: feature_frames[item.feature_id] = frames elif item.operation == "sweep": profile_source = _source_sketch({"entities": p.get("profiles")}) if not profile_source or profile_source not in sketch_by_source: raise ValueError("sweep profile sketch is unresolved") profile_sketch = _profile_selection_sketch( sketch_by_source[profile_source], p.get("profiles"), entity_by_sketch[profile_source], fid, ) if profile_sketch is not sketch_by_source[profile_source]: sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch if not _profile_executable(profile_sketch): raise ValueError("sweep profile sketch has no closed profile") path_query = parse_query(p.get("path")) path_source = path_query.source_sketch path_entity = (entity_by_sketch.get(path_source or "") or {}).get(path_query.source_entity or "") path_sketch = sketch_by_source.get(path_source or "") if path_entity is None or path_sketch is None: raise ValueError("sweep path is unresolved") if path_entity.get("type") not in {"line", "bspline"}: raise UnsupportedCapability("sweep_path", "current CDSL sweep requires one line or B-spline path") operation = str(p.get("operationType") or "NEW").upper() if any(value in operation for value in ("REMOVE", "CUT")): raise UnsupportedCapability("sweep_remove", "current CDSL sweep supports additive solid results only") path_segment = deepcopy(path_entity) path = {"workplane": path_sketch["workplane"], "segment": path_segment} if _sweep_profile_attaches_at_path_end(profile_sketch, path): path_segment = _reversed_sweep_path_segment(path_segment) feature = { "id": fid, "name": item.feature_id, "atomic_id": "sweep_add", "depends_on": depends, "sketch_id": profile_sketch["id"], "params": {"path": {"workplane": path_sketch["workplane"], "segment": path_segment}}, "execution_status": "supported", } if _is_new_body_operation(operation): feature["params"]["result_mode"] = "new_body" frames = _sweep_cap_frames(profile_sketch, feature["params"]["path"]) if frames is not None: feature_frames[item.feature_id] = frames elif item.operation == "booleanBodies": operation = str(p.get("operationType") or "").split(".")[-1].upper() operation_map = { "UNION": "union", "SUBTRACTION": "subtract", "INTERSECTION": "intersect", } if operation not in operation_map: raise UnsupportedCapability("boolean_bodies_operation", "current CDSL booleanBodies supports union, subtraction and intersection") targets = _boolean_body_sources(p.get("targets")) tools = _boolean_body_sources(p.get("tools")) if set(targets) & set(tools): raise ValueError("booleanBodies targets and tools must be disjoint") missing = [source for source in targets + tools if source not in feature_by_id] if missing: raise ValueError("booleanBodies source features are unresolved: " + ", ".join(missing)) feature = { "id": fid, "name": item.feature_id, "atomic_id": "boolean_bodies", "depends_on": list(dict.fromkeys(targets + tools + depends)), "params": { "operation": operation_map[operation], "target_feature_ids": targets, "tool_feature_ids": tools, "keep_tools": _bool(p.get("keepTools")), }, "execution_status": "supported", } elif item.operation == "revolve": # surfaceOperationType alone does not make a body operation a # surface operation. CADFS emits it for closed sketch regions # that produce ordinary solids too. Only ToolBodyType.SURFACE # selects a sheet result; its profile is in surfaceEntities. surface_operation = str(p.get("bodyType") or "").rsplit(".", 1)[-1].upper() == "SURFACE" profile_entities = p.get("surfaceEntities") if surface_operation else p.get("entities") source = _source_sketch(p) if not source or source not in sketch_by_source: raise ValueError("revolve sketch query is unresolved") profile_sketch = _profile_selection_sketch(sketch_by_source[source], profile_entities, entity_by_sketch[source], fid) if profile_sketch is not sketch_by_source[source]: sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch if not _profile_executable(profile_sketch): raise ValueError("revolve sketch has no closed profile") axis_q = parse_query(p.get("axis")); axis_entity = (entity_by_sketch.get(axis_q.source_sketch or "") or {}).get(axis_q.source_entity or "") if not axis_entity or axis_entity.get("type") != "line": raise ValueError("revolve axis is unresolved") plane = sketch_by_source[axis_q.source_sketch]["workplane"]; start, end = _global(plane, axis_entity["start"]), _global(plane, axis_entity["end"]) direction = [end[i]-start[i] for i in range(3)]; norm = math.sqrt(sum(x*x for x in direction)); direction = [x/norm for x in direction] # 闭合实体回转携带 surfaceOperationType、但没有 bodyType 时, # 该字段只是曲面处理参数,不能被误读为 NewBodyOperationType.NEW。 # 它保持默认 ADD,F8 这类重叠回转因而会参与当前实体的融合。 # 完全没有该字段的普通实体回转仍保持 FeatureScript 的默认 NEW # body 语义;显式 ToolBodyType.SURFACE 则以独立 shell 执行。 default_operation = "NEW" if surface_operation or p.get("surfaceOperationType") is None else "ADD" operation = str(p.get("operationType") or default_operation).upper() surface_kind = str(p.get("surfaceOperationType") or "NEW").upper() if surface_operation and "NEW" not in surface_kind: raise UnsupportedCapability("revolve_surface_operation", "current CDSL surface revolve supports only NewSurfaceOperationType.NEW") atomic = "revolve_surface" if surface_operation else "revolve_cut" if "REMOVE" in operation else "revolve_add" full = "FULL" in str(p.get("revolveType") or "FULL").upper(); angle = 360.0 if full else _number(p.get("angle", 360.0)) params = {"angle_deg": angle, "reverse": _bool(p.get("oppositeDirection")), "axis": {"origin_mm": start, "direction": direction}} if atomic == "revolve_add" and _is_new_body_operation(operation): params["result_mode"] = "new_body" feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": profile_sketch["id"], "params": params, "execution_status": "supported"} if full: feature_frames[item.feature_id] = {"revolve_axis": {"origin_mm": start, "direction": direction}, "revolve_full": True} elif item.operation in {"fillet", "chamfer"}: key = "radius" if item.operation == "fillet" else "width" chamfer_type = str(p.get("chamferType") or "EQUAL_OFFSETS").split(".")[-1].upper() amount_value = p.get(key) if item.operation == "chamfer" and chamfer_type == "TWO_OFFSETS": amount_value = p.get("width1") amount = _number(amount_value, True); selectors = [] for index, query_value in enumerate(_queries(p.get("entities"))): query = parse_query(query_value); owner = query.owner_feature if not owner: raise ValueError("selector owner is unresolved") selector_kind = "face" if query.kind in {"face", "entitytype.face"} or query.topology_type in {"CAP_FACE", "SWEPT_FACE"} else "edge" refs = _source_refs(query_value) source_entity = (entity_by_sketch.get(query.source_sketch or "") or {}).get(query.source_entity or "") geometry: dict[str, Any] = {} frame = feature_frames.get(owner) cap = frame.get("start" if query.is_start else "end") if frame and {"start", "end"}.issubset(frame) else None if selector_kind == "face" and query.topology_type == "CAP_FACE" and cap: geometry = {"normal": cap["normal"], "plane_offset_mm": sum(cap["normal"][i] * cap["origin_mm"][i] for i in range(3))} elif selector_kind == "face" and query.topology_type == "SWEPT_FACE" and source_entity and source_entity["type"] == "circle": # 圆形 profile 拉伸得到的侧面以轴线、半径为标识。同一 feature 可以 # 生成多个半径相同的圆柱面,因此保留草图圆心作为轴原点来解消歧义。 source_plane = sketch_by_source.get(query.source_sketch or "", {}).get("workplane") if source_plane is not None: geometry = { "axis_origin_mm": _global(source_plane, source_entity["center"]), "axis_direction": source_plane["normal"], "radius_mm": source_entity["radius_mm"], } elif selector_kind == "edge" and query.topology_type == "OFFSET_EDGE" and source_entity and source_entity["type"] == "circle": offset_planes = (frame or {}).get("shell_offset_edge_planes") or {} source_key = f"{query.source_sketch}:{query.source_entity}" offset_plane = offset_planes.get(source_key) if offset_plane is not None: geometry = { "source_circle_center_mm": _global(offset_plane, source_entity["center"]), "source_circle_radius_mm": source_entity["radius_mm"], "source_plane_normal": offset_plane["normal"], } if selector_kind == "edge" and source_entity and cap: if query.topology_type == "SWEPT_EDGE" and frame: local_point = source_entity.get("point") if source_entity["type"] == "point" else None if len(refs) >= 2: left = (entity_by_sketch.get(refs[0][0]) or {}).get(refs[0][1]); right = (entity_by_sketch.get(refs[1][0]) or {}).get(refs[1][1]) if left and right and left.get("type") == right.get("type") == "line": local_point = next((a for a in (left["start"], left["end"]) for b in (right["start"], right["end"]) if math.dist(a, b) <= 1e-5), None) if local_point is None: raise ValueError("swept edge source intersection is unresolved") # CAP_FACE 的外法向会使 start/end frame 在反向拉伸 # 时翻转局部 x 轴。SWEPT_EDGE 的 source point 仍在 # 原草图 frame 中,不能把同一个局部坐标分别投到两个 # 朝向不同的 cap,否则一个竖直棱会伪造成跨整个截面的 # 对角 bbox。用 profile frame 定位起点,再只平移到 # end cap 的实际原点,保持 source 点在两端一致。 profile = frame.get("profile") if profile is None: start, end = _global(frame["start"], local_point), _global(frame["end"], local_point) else: start = _global(profile, local_point) offset = _sub(frame["end"]["origin_mm"], profile["origin_mm"]) end = [start[axis] + offset[axis] for axis in range(3)] geometry = {"curve_type": "line", "bbox_mm": [min(start[i], end[i]) for i in range(3)] + [max(start[i], end[i]) for i in range(3)]} elif source_entity["type"] == "circle": # Keep the source circle signature until the prefix has been # rebuilt. OCC may expose it as one edge or several arcs. selectors.append({"kind": selector_kind, "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": geometry or {"curve_type": "circle", "source_circle_center_mm": _global(cap, source_entity["center"]), "source_circle_radius_mm": source_entity["radius_mm"], "source_plane_normal": cap["normal"]}}) continue elif source_entity["type"] == "line": start, end = _global(cap, source_entity["start"]), _global(cap, source_entity["end"]) geometry = {"curve_type": "line", "bbox_mm": [min(start[i], end[i]) for i in range(3)] + [max(start[i], end[i]) for i in range(3)]} if not geometry: raise ValueError("selector geometry is unresolved") if selector_kind == "face" and not geometry: raise ValueError(f"{query.topology_type or 'face'} selector geometry is unresolved") selectors.append({"kind": selector_kind, "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": geometry}) params = {"radius_mm" if item.operation == "fillet" else "distance_mm": amount} if item.operation == "fillet": params["tangent_propagation"] = _bool(p.get("tangentPropagation")) elif _bool(p.get("tangentPropagation")): params["tangent_propagation"] = True elif chamfer_type == "TWO_OFFSETS": second = _number(p.get("width2"), True) if _bool(p.get("oppositeDirection")): params["distance_mm"], second = second, params["distance_mm"] params["distance_2_mm"] = second elif chamfer_type == "OFFSET_ANGLE": angle = math.radians(_number(p.get("angle"))) if _bool(p.get("oppositeDirection")): second = amount * math.tan(angle); params["distance_mm"] = second; params["distance_2_mm"] = amount else: params["angle_rad"] = angle feature = {"id": fid, "name": item.feature_id, "atomic_id": item.operation, "depends_on": depends, "params": params, "selectors": selectors, "execution_status": "supported"} elif item.operation == "shell": if _bool(p.get("oppositeDirection")): raise UnsupportedCapability("shell_outward", "current CDSL shell only supports inward wall offsets") thickness = _number(p.get("thickness"), True) selectors = []; offset_edge_planes: dict[str, dict[str, Any]] = {} for index, query_value in enumerate(_queries(p.get("entities"))): query = parse_query(query_value) _call, _owner, topology, kind, _definition = _direct_make_query(query_value) if kind not in {"face", "entitytype.face"}: raise UnsupportedCapability("shell_face_selector", "current CDSL shell requires face removal selectors") if topology == "CAP_FACE": selector = _face_reference(query_value, feature_frames, sketch_by_source, entity_by_sketch) source_frame = feature_frames.get(query.owner_feature or "") or {} cap = source_frame.get("start" if query.is_start else "end") if cap is not None: for source, entity_id in _source_refs(query_value): offset_edge_planes[f"{source}:{entity_id}"] = dict(cap) elif topology == "COPY": selector = _pattern_copy_face_reference( query_value, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, ) else: raise UnsupportedCapability("shell_face_selector", "current CDSL shell requires CAP_FACE or COPY(CAP_FACE) removal selectors") selector["stable_id"] = f"cadfs_{fid}_{index}" selectors.append(selector) if not selectors: raise ValueError("shell has no face removal selector") feature = {"id": fid, "name": item.feature_id, "atomic_id": "shell", "depends_on": depends, "params": {"thickness_mm": thickness, "inward": True}, "selectors": selectors, "execution_status": "supported"} owners = {selector["owner_feature_id"].removeprefix("f_") for selector in selectors} if len(owners) == 1: source = next(iter(owners)) source_feature = feature_by_id.get(f"f_{source}") or {} source_frame = feature_frames.get(source) if source_frame is not None and source_feature.get("sketch_id"): feature_frames[item.feature_id] = { **source_frame, "shell_source": source, "shell_thickness_mm": thickness, "shell_profile_sketch_id": source_feature["sketch_id"], } if offset_edge_planes: frame = feature_frames.setdefault(item.feature_id, {}) frame["shell_offset_edge_planes"] = offset_edge_planes elif item.operation == "hole": locations = _queries(p.get("locations")); positions = []; host_plane = None for location in locations: query = parse_query(location); source = query.source_sketch entity = (entity_by_sketch.get(source or "") or {}).get(query.source_entity or "") if not source or source not in sketch_by_source or not entity or entity.get("type") != "point": raise ValueError("hole location is unresolved") positions.append({"mm": [entity["point"][0], entity["point"][1], 0.0]}); host_plane = sketch_by_source[source]["workplane"] if not positions or host_plane is None: raise ValueError("hole has no resolved locations") frame = {**host_plane, "y_dir": _y_dir(host_plane)} if _bool(p.get("oppositeDirection")): frame = {**frame, "normal": [-v for v in frame["normal"]]} style = str(p.get("style") or "SIMPLE").split(".")[-1].lower(); end = str(p.get("endStyle") or "BLIND").upper() standard_through_diameter = _standard_tapped_through_bore_diameter(p, style, end) condition = "through_all_both" if "BOTH" in end else "through_all" if "THROUGH" in end or standard_through_diameter is not None else "blind" depth_value = p.get("holeDepth") or p.get("tappedDepth") if condition == "blind" and depth_value is None: raise ValueError("blind hole depth is unresolved") depth = _number(depth_value, True) if depth_value is not None else 1.0 condition_code = {"blind": 0, "through_all": 1, "through_all_both": 2}[condition] hole_params: dict[str, Any] = {"hole_type": style, "diameter_mm": standard_through_diameter or _number(p.get("holeDiameter"), True), "depth_mm": depth, "end_condition": {"type": condition, "solidworks_code": condition_code}, "positions": positions, "host_face": {"frame": frame}} if style.upper() in {"COUNTERSINK", "C_SINK"}: hole_params["countersink"] = {"diameter_mm": _number(p.get("countersinkDiameter") or p.get("cSinkDiameter") or p.get("majorDiameter"), True), "angle_rad": math.radians(_number(p.get("countersinkAngle") or p.get("cSinkAngle") or 90.0))} if standard_through_diameter is None and style.upper() in {"COUNTERBORE", "C_BORE"}: hole_params["counterbore"] = {"diameter_mm": _number(p.get("counterboreDiameter") or p.get("cBoreDiameter") or p.get("majorDiameter"), True), "depth_mm": _number(p.get("counterboreDepth") or p.get("cBoreDepth"), True)} if _bool(p.get("isTappedThrough")) or p.get("tapSize") is not None: hole_params["thread"] = {"source": "CADFS", "decorative": True} feature = {"id": fid, "name": item.feature_id, "atomic_id": "hole_wizard", "depends_on": depends, "params": hole_params, "execution_status": "supported"} elif item.operation == "circularPattern": sources = _pattern_source_features(p.get("entities"), previous) sources = _pattern_body_history_sources(p.get("entities"), sources, previous, feature_by_id) axis = _circular_pattern_axis(p.get("axis"), feature_frames, sketch_by_source, entity_by_sketch) count = int(_number(p.get("instanceCount"))) if count < 1: raise ValueError("circular pattern instanceCount must be positive") operation = str(p.get("operationType") or "NEW").split(".")[-1].upper() operation_mode = "remove" if any(value in operation for value in ("REMOVE", "CUT")) else "add" if operation_mode == "remove": for source in sources: source_feature = feature_by_id.get(source) if source_feature is None: raise ValueError("circular remove pattern source is unresolved") _pattern_remove_source(source_feature) feature = { "id": fid, "name": item.feature_id, "atomic_id": "pattern_circular", "depends_on": list(dict.fromkeys(sources + depends)), "params": { "source_feature_ids": sources, "axis": axis, "pattern_count": count, "sweep_angle_deg": _number(p.get("angle", 360.0)), "operation_mode": operation_mode, }, "execution_status": "supported", } elif item.operation == "mirror": owners = [] mirror_current_body = False for call in walk_calls(p.get("entities")): if call.name == "makeQuery" and call.args: owner = symbolic_string(call.args[0]); if "F" in owner: source_id = "f_" + owner[owner.find("F"):].split(".", 1)[0] if source_id in previous and source_id not in owners: owners.append(source_id) query = parse_query(call) if query.topology_type == "SWEPT_BODY" and query.kind in {"body", "entitytype.body"}: mirror_current_body = True if not owners: raise ValueError("mirror source features are unresolved") plane_query = p.get("mirrorPlane"); plane_info = parse_query(plane_query); plane_owner = f"f_{plane_info.owner_feature}" if plane_info.owner_feature else None if plane_owner and any(existing["id"] == plane_owner and existing["atomic_id"] == "reference_plane" for existing in features): mirror_plane = {"kind": "plane", "owner_feature_id": plane_owner, "stable_id": f"cadfs_{fid}_plane", "source": "runtime_snapshot", "confidence": 1.0} source_plane = feature_frames.get(plane_info.owner_feature or "", {}).get("start") if source_plane is None: raise ValueError("mirror plane frame is unresolved") else: plane = _default_plane(plane_query) if plane is None: raise ValueError("mirror plane is not a default or reference plane") plane_owner = f"{fid}_plane" features.append({"id": plane_owner, "name": f"{item.feature_id} plane", "atomic_id": "reference_plane", "depends_on": depends, "params": {"plane": plane}, "execution_status": "supported"}) previous.append(plane_owner) mirror_plane = {"kind": "plane", "owner_feature_id": plane_owner, "stable_id": f"cadfs_{fid}_plane", "source": "runtime_snapshot", "confidence": 1.0} source_plane = plane feature = {"id": fid, "name": item.feature_id, "atomic_id": "pattern_mirror", "depends_on": list(dict.fromkeys(owners + [plane_owner])), "params": {"source_feature_ids": owners, "mirror_plane": mirror_plane}, "selectors": [mirror_plane], "execution_status": "supported"} if mirror_current_body: feature["params"]["mirror_current_body"] = True # COPY(CAP_FACE) query 属于 pattern 的特定 instance,不能直接回退到 # source loft 的端盖。保存镜像变换和 source feature,供后续草图 # 在 lowering 期从 CADFS provenance 复算物理端盖 frame。 feature_frames[item.feature_id] = { "copy_transform": {"type": "mirror", "plane": source_plane}, "copy_source_features": [source[2:] for source in owners], } else: raise ValueError(f"operation mapping not implemented: {item.operation}") features.append(feature); feature_by_id[fid] = feature; feature_source_by_id[fid] = item.feature_id if item.operation == "extrude" and surface_profile_sketch is not None: features.append(surface_feature) surface_profiles.append({ "profile": deepcopy(surface_profile_sketch["profile"]), "workplane": dict(surface_profile_sketch["workplane"]), **surface_feature["params"], }) previous.append(fid) except UnsupportedCapability as exc: diagnostics.append({"code": "unsupported_engine_capability", "capability": exc.capability, "feature_id": item.feature_id, "operation": item.operation, "message": str(exc)}); complete = False except Exception as exc: diagnostics.append({"code": "feature_deferred", "feature_id": item.feature_id, "operation": item.operation, "message": str(exc)}); complete = False if not features: return LoweringResult(None, "deferred_no_executable_feature", diagnostics, history) cdsl = {"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": model.sample_id, "meta": {"unit": "mm", "source": "CADFS", "provenance": provenance, "capability_gaps": sorted({d.get("operation") for d in diagnostics if d.get("operation")})}, "geometry": {"sketches": sketches}, "features": features} return LoweringResult(cdsl, "converted_complete" if complete else "converted_partial", diagnostics, history)