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 class OpenSketchProfileError(ValueError): pass 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)] if isinstance(value, Call) and value.name == "round" and len(value.args) == 1: # CADFS sometimes serializes a known pattern count as ``round(8)``. # An already integral constant is provably unchanged, so it needs no # FeatureScript rounding-mode assumption. Non-integral calls remain # unsupported until that language-level semantic is represented. rounded_input = _number(value.args[0], units) if math.isfinite(rounded_input) and rounded_input.is_integer(): return rounded_input 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)} # 同一 feature 的多个圆形端盖可以共面。仅用平面方程会把它们误判为 # 同一个 selector;保留由 source circle 给出的物理圆心和最小面积, # 使 pattern COPY(CAP_FACE) 在 prefix B-rep 中仍可唯一绑定。 for sketch_id, entity_id in _source_refs(value): entity = (entity_by_sketch.get(sketch_id) or {}).get(entity_id) sketch = sketch_by_source.get(sketch_id) if entity is None or sketch is None or entity.get("type") != "circle": continue center = _global(cap, entity["center"]) if rotation is not None: axis, angle = rotation; center = _rotate_point(center, axis, angle) geometry["center_mm"] = center geometry["minimum_area_mm2"] = math.pi * float(entity["radius_mm"]) ** 2 * 0.5 break 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 _direct_linear_extrude_swept_face_shell_reference( value: Any, feature_frames: dict[str, dict[str, Any]], sketch_by_source: dict[str, dict[str, Any]], sketches_by_id: dict[str, dict[str, Any]], entity_by_sketch: dict[str, dict[str, dict[str, Any]]], feature_by_id: dict[str, dict[str, Any]], previous: list[str], ) -> dict[str, Any]: """Lower one source-proven linear-extrude side wall for a shell removal. This is deliberately narrower than generic ``SWEPT_FACE`` replay. The original sketch line defines an exact planar side wall only while its direct blind/two-sided extrusion is the immediately preceding producer; boolean, dress-up, copy, and transformed continuations have different ownership and are left to future topology-history contracts. """ _call, owner, topology, kind, _definition = _direct_make_query(value) producer_id = f"f_{owner}" producer = feature_by_id.get(producer_id) or {} frame = feature_frames.get(owner) or {} params = producer.get("params") or {} atomic_id = str(producer.get("atomic_id") or "") supported_atomics = { "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", } profile_source = frame.get("profile_source") source_sketch = sketch_by_source.get(profile_source) if isinstance(profile_source, str) else None profile_sketch = sketches_by_id.get(str(producer.get("sketch_id") or "")) if ( topology != "SWEPT_FACE" or kind not in {"face", "entitytype.face"} or atomic_id not in supported_atomics or previous[-1:] != [producer_id] or not isinstance(frame.get("profile"), dict) or not isinstance(frame.get("start"), dict) or not isinstance(frame.get("end"), dict) or source_sketch is None or profile_sketch is None or not _profile_matches_direct_source(profile_sketch, source_sketch) or (params.get("end_condition") or {}).get("type") != "blind" or ( atomic_id.endswith("two_sided") and (params.get("reverse_end_condition") or {}).get("type") != "blind" ) ): raise UnsupportedCapability( "shell_face_selector", "current CDSL shell SWEPT_FACE requires the immediately preceding direct blind/two-sided linear extrusion", ) query = parse_query(value) refs = _source_refs(value) if ( len(refs) != 1 or refs[0][0] != profile_source or query.source_sketch != profile_source or query.source_entity != refs[0][1] ): raise UnsupportedCapability( "shell_face_selector", "current CDSL shell SWEPT_FACE requires one direct source-profile line", ) entity = (entity_by_sketch.get(profile_source) or {}).get(refs[0][1]) if not entity or entity.get("type") != "line" or entity.get("construction"): raise UnsupportedCapability( "shell_face_selector", "current CDSL shell SWEPT_FACE requires one original non-construction source line", ) return _face_reference(value, feature_frames, sketch_by_source, entity_by_sketch) def _shell_offset_face_output_role_selector( value: Any, feature_by_id: dict[str, dict[str, Any]], sketches_by_id: dict[str, dict[str, Any]], previous: list[str], ) -> dict[str, Any]: """Lower a shell OFFSET_FACE only through its explicit CAP true dependency. An OFFSET_FACE is a generated B-rep face, so a bare geometric signature is not enough: a shell may generate several offset faces. CADFS can retain the exact semantic source as a nested true-dependency CAP query. Preserve that relation for the runtime topology registry instead of choosing a nearby shell face. """ _call, owner, topology, kind, _definition = _direct_make_query(value) owner_feature_id = f"f_{owner}" producer = feature_by_id.get(owner_feature_id) if ( topology != "OFFSET_FACE" or kind not in {"face", "entitytype.face"} or producer is None or producer.get("atomic_id") != "shell" or previous[-1:] != [owner_feature_id] ): raise UnsupportedCapability( "shell_offset_face_selector", "current CDSL shell OFFSET_FACE requires the immediately preceding direct shell owner", ) # Only a true-dependency disambiguation may establish a source relation. # ``walk_calls`` is deliberately not used here: arbitrary nested CAP_FACE # queries can describe ordering or source-profile evidence, but do not # prove which shell offset face they produced. source_roles = [] disambiguation = _definition.get("disambiguationData") for item in disambiguation if isinstance(disambiguation, list) else (): if ( not isinstance(item, Call) or item.name not in {"TDD", "trueDependencyDisambiguation"} or len(item.args) != 1 or not isinstance(item.args[0], list) ): continue for candidate in item.args[0]: try: cap = _cap_face_output_role_selector(candidate, feature_by_id, sketches_by_id) except ValueError: continue if cap is not None: source_roles.append(cap) unique_roles = { (item["owner_feature_id"], item["output_role"]): item for item in source_roles } if len(unique_roles) != 1: raise UnsupportedCapability( "shell_offset_face_selector", "current CDSL shell OFFSET_FACE requires one direct builder CAP_FACE true dependency", ) source = next(iter(unique_roles.values())) return { "kind": "face", "owner_feature_id": owner_feature_id, "output_role": "shell.offset_face", "output_role_source": { "owner_feature_id": source["owner_feature_id"], "output_role": source["output_role"], }, "source": "runtime_snapshot", "confidence": 1.0, } 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) reference = _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), ) reference["owner_match_required"] = True return reference 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) < 2: raise ValueError("fit spline needs at least 2 points") start_derivative = p.get("startDerivative") end_derivative = p.get("endDerivative") if len(spline_points) == 2: if _same_point(spline_points[0], spline_points[1]): raise ValueError("two-point fit spline endpoints must be distinct") if start_derivative is None or end_derivative is None: raise ValueError("two-point fit spline requires both endpoint derivatives") # 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 start_derivative is not None: item["start_tangent"] = _point(start_derivative) if end_derivative is not None: item["end_tangent"] = _point(end_derivative) else: unsupported.append(entity.operation); continue if _bool(p.get("construction")): # Keep construction provenance available to topology-query lowering, # but never let it participate in a planar IMPRINT arrangement. # ``construction`` is runtime-only mapping metadata and must not # leak into the public analytic-segment schema. entities[entity.feature_id] = {**item, "construction": True} explicit_construction.append(item) else: entities[entity.feature_id] = item segments.append(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 OpenSketchProfileError(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_ref_entity( sketch_id: str, token: str, entity_by_sketch: dict[str, dict[str, dict[str, Any]]], ) -> tuple[str, dict[str, Any]] | None: """Resolve an original sketch entity without accepting derived-name aliases.""" entities = entity_by_sketch.get(sketch_id) or {} if token in entities: return token, entities[token] entity_id = max((key for key in entities if token.startswith(key + ".")), key=len, default="") if not entity_id: return None suffix = token[len(entity_id) + 1:] # A named source endpoint is still direct provenance. Other suffixes # (trim offspring, mirrored construction, generated fillet arcs, ...) do # not identify one source-local endpoint and must not be guessed. if suffix not in {"start", "end"}: return None return entity_id, entities[entity_id] def _source_ref_endpoint_points(entity_id: str, token: str, entity: dict[str, Any]) -> list[list[float]]: """Return only endpoint coordinates that FeatureScript explicitly exposes.""" if entity.get("type") == "point" and token == entity_id: point = entity.get("point") return [point] if isinstance(point, list) and len(point) == 2 else [] start, end = entity.get("start"), entity.get("end") if not all(isinstance(point, list) and len(point) == 2 for point in (start, end)): return [] if token == entity_id: return [start, end] suffix = token[len(entity_id) + 1:] return [start] if suffix == "start" else [end] if suffix == "end" else [] def _shared_source_endpoint( refs: list[tuple[str, str]], sketch_id: str, entity_by_sketch: dict[str, dict[str, dict[str, Any]]], ) -> list[float] | None: """Find the one explicit endpoint shared by a set of source curves. ``SWEPT_EDGE`` provenance from CADFS uses an original-set disambiguation containing the source curves incident at a profile vertex. A single curve has two possible ends, so it cannot identify a swept edge by itself. This routine deliberately accepts only one shared endpoint from distinct direct source entities, with the direct ``skPoint`` case retained for point-based provenance. """ selected: dict[str, tuple[str, dict[str, Any]]] = {} for source, token in refs: if source != sketch_id: continue resolved = _source_ref_entity(source, token, entity_by_sketch) if resolved is None: return None entity_id, entity = resolved selected.setdefault(entity_id, (token, entity)) if not selected: return None if len(selected) == 1: entity_id, (token, entity) = next(iter(selected.items())) if entity.get("type") == "point": points = _source_ref_endpoint_points(entity_id, token, entity) return points[0] if len(points) == 1 else None return None endpoints: list[tuple[str, list[float]]] = [] for entity_id, (token, entity) in selected.items(): points = _source_ref_endpoint_points(entity_id, token, entity) if not points: return None endpoints.extend((entity_id, point) for point in points) candidates: list[list[float]] = [] for _entity_id, point in endpoints: incident = { candidate_id for candidate_id, candidate_point in endpoints if math.dist(point, candidate_point) <= 1e-5 } if len(incident) < 2 or any(math.dist(point, candidate) <= 1e-5 for candidate in candidates): continue candidates.append(point) return candidates[0] if len(candidates) == 1 else None def _endpoint_bbox(start: list[float], end: list[float], *, known_line: bool) -> dict[str, Any] | None: if math.dist(start, end) <= 1e-8: return None geometry = {"bbox_mm": [min(start[i], end[i]) for i in range(3)] + [max(start[i], end[i]) for i in range(3)]} if known_line: geometry["curve_type"] = "line" return geometry def _swept_edge_line_selector_geometry( owner: str, refs: list[tuple[str, str]], frame: dict[str, Any], feature_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]]], ) -> dict[str, Any] | None: """Lower a directly proven ``SWEPT_EDGE`` into an endpoint-bbox selector. This is intentionally not a general topology replay. It covers an edge created by a translational extrude from one profile vertex, or by a direct two-section loft whose source query identifies one profile vertex on each section. Both constructions provide exact source-local endpoints. A sweep, revolve, multi-section loft, generated/trimmed source, or any ambiguous original-set remains unsupported rather than selecting a nearby B-rep edge. """ producer = feature_by_id.get(f"f_{owner}") or {} atomic_id = str(producer.get("atomic_id") or "") source_ids = {source for source, _token in refs} if atomic_id in {"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided"}: profile_source = frame.get("profile_source") profile = frame.get("profile") if ( not isinstance(profile_source, str) or source_ids != {profile_source} or not isinstance(profile, dict) or not isinstance(frame.get("start"), dict) or not isinstance(frame.get("end"), dict) ): return None local = _shared_source_endpoint(refs, profile_source, entity_by_sketch) if local is None: return None base = _global(profile, local) start = [base[index] + frame["start"]["origin_mm"][index] - profile["origin_mm"][index] for index in range(3)] end = [base[index] + frame["end"]["origin_mm"][index] - profile["origin_mm"][index] for index in range(3)] return _endpoint_bbox(start, end, known_line=True) if atomic_id != "loft_add": return None profile_sources = frame.get("loft_profile_sources") if not isinstance(profile_sources, list) or len(profile_sources) != 2 or len(set(profile_sources)) != 2: return None if source_ids != set(profile_sources): return None points = [ _shared_source_endpoint(refs, source, entity_by_sketch) for source in profile_sources ] if any(point is None or source not in sketch_by_source for point, source in zip(points, profile_sources)): return None start = _global(sketch_by_source[profile_sources[0]]["workplane"], points[0]) end = _global(sketch_by_source[profile_sources[1]]["workplane"], points[1]) # A ThruSections loft may represent this side edge as a B-spline even # though its endpoint correspondence is exact. Do not claim a line type. return _endpoint_bbox(start, end, known_line=False) def _swept_edge_revolve_circle_selector_geometry( owner: str, refs: list[tuple[str, str]], frame: dict[str, Any], feature_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]]], ) -> dict[str, Any] | None: """Lower one direct full-revolve profile vertex to a circular edge signature. This deliberately accepts only an independent full solid revolve from its original sketch and original in-sketch axis. Its source query must prove exactly one profile endpoint, and that endpoint must have positive radial distance to the explicit axis. Partial/surface/additive revolutions and generated or transformed profile provenance have different topology and remain unsupported instead of being geometrically guessed. """ producer = feature_by_id.get(f"f_{owner}") or {} if ( producer.get("atomic_id") != "revolve_add" or (producer.get("params") or {}).get("result_mode") != "new_body" or not frame.get("revolve_full") ): return None profile_source = frame.get("profile_source") axis = frame.get("revolve_axis") if ( not isinstance(profile_source, str) or {source for source, _token in refs} != {profile_source} or profile_source not in sketch_by_source or not isinstance(axis, dict) ): return None local = _shared_source_endpoint(refs, profile_source, entity_by_sketch) if local is None: return None try: origin = [float(value) for value in axis["origin_mm"]] direction = [float(value) for value in axis["direction"]] except (KeyError, TypeError, ValueError): return None if len(origin) != 3 or len(direction) != 3 or not all(math.isfinite(value) for value in origin + direction): return None direction_length = math.sqrt(sum(value * value for value in direction)) if direction_length <= 1e-9: return None direction = [value / direction_length for value in direction] point = _global(sketch_by_source[profile_source]["workplane"], local) projection = sum((point[index] - origin[index]) * direction[index] for index in range(3)) center = [origin[index] + projection * direction[index] for index in range(3)] radius = math.dist(point, center) if not math.isfinite(radius) or radius <= 1e-8: return None return {"curve_type": "circle", "circle_center_mm": center, "radius_mm": radius} def _profile_matches_direct_source(selected: dict[str, Any], source: dict[str, Any]) -> bool: """Prove that a materialized profile selection changed no source geometry. ``IMPRINT`` is sometimes only the FeatureScript representation of selecting the one existing sketch region. It may also select a proper subset. The latter must not inherit a source-profile SWEPT_EDGE contract, so accept the former only when its physical workplane and complete profile are exactly the original lowered mappings. """ return ( selected.get("workplane") == source.get("workplane") and selected.get("profile") == source.get("profile") ) 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 _definition_topology_side(definition: dict[str, Any]) -> float | None: """Read the directly attached ``TD`` sign from one query definition.""" 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(definition.get("disambiguationData")): if call.name in {"TD", "topologyDisambiguation"}: side = next(numbers(call.args), None) if side in {-1.0, 1.0}: return side return None def _definition_order(definition: dict[str, Any]) -> int | None: """Read a finite non-negative ``OD`` index without guessing an intersection.""" 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(definition.get("disambiguationData")): if call.name in {"OD", "orderDisambiguation"}: value = next(numbers(call.args), None) if value is None or not math.isfinite(value) or value < 0 or value != round(value): return None return int(value) return None def _planar_imprint_selection(value: Any) -> tuple[str, dict[str, Any]] | None: """Lower one IMPRINT face query to source-edge and side evidence. The result deliberately retains the nested edge-fragment proof instead of flattening it to an arbitrary original profile. A fragment-side sign is only meaningful together with an exact ``INTERSECT`` vertex and optional FeatureScript order disambiguation; any other nested form stays outside this contract. """ try: _root, owner, topology, kind, definition = _direct_make_query(value) except ValueError: return None face_side = _definition_topology_side(definition) if topology != "IMPRINT" or kind != "face" or face_side is None: return None edge_queries: list[tuple[Call, dict[str, Any]]] = [] for call in walk_calls(definition.get("disambiguationData")): if call.name != "makeQuery": continue try: _edge, edge_owner, edge_topology, edge_kind, edge_definition = _direct_make_query(call) except ValueError: continue if edge_owner == owner and edge_topology == "IMPRINT" and edge_kind == "edge": edge_queries.append((call, edge_definition)) if len(edge_queries) != 1: return None _edge, edge_definition = edge_queries[0] sources = list(dict.fromkeys(_source_refs(edge_definition.get("derivedFrom")))) if len(sources) != 1 or sources[0][0] != owner: return None source_entity = sources[0][1] selection: dict[str, Any] = {"source_entity_id": source_entity, "face_side": face_side} intersections: list[dict[str, Any]] = [] for call in walk_calls(edge_definition.get("disambiguationData")): if call.name != "makeQuery": continue try: _intersection, intersection_owner, intersection_topology, intersection_kind, intersection_definition = _direct_make_query(call) except ValueError: continue if intersection_owner == owner and intersection_topology == "INTERSECT" and intersection_kind == "vertex": intersections.append(intersection_definition) if not intersections: return owner, selection if len(intersections) != 1: return None fragment_side = _definition_topology_side(edge_definition) intersection_sources = list(dict.fromkeys(_source_refs(intersections[0].get("derivedFrom")))) if fragment_side is None or len(intersection_sources) != 2 or any(sketch != owner for sketch, _entity in intersection_sources): return None anchor = [entity for _sketch, entity in intersection_sources if entity != source_entity] if len(anchor) != 1: return None fragment: dict[str, Any] = {"anchor_entity_id": anchor[0], "side": fragment_side} order = _definition_order(intersections[0]) if order is not None: fragment["intersection_index"] = order selection["fragment"] = fragment return owner, selection def _planar_imprint_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: """Create an exact planar-arrangement profile from IMPRINT face queries. The adapter later splits a bounded support face with these original analytic curves and consumes only the selected B-rep regions. This is a typed derived-profile contract, not a polygonization or a reconstruction of a potentially unrelated source contour. """ selections: list[dict[str, Any]] = [] source_sketch: str | None = None for root in _queries(value): parsed = _planar_imprint_selection(root) if parsed is None: return None owner, selection = parsed if source_sketch is None: source_sketch = owner elif source_sketch != owner: return None selections.append(selection) if source_sketch is None or not selections or source_sketch not in sketch_by_source: return None entities = entity_by_sketch.get(source_sketch) or {} source_entities: list[dict[str, Any]] = [] for entity_id, curve in entities.items(): if curve.get("construction") or curve.get("type") not in {"line", "arc", "circle", "ellipse", "bspline"}: continue source_entities.append({"id": entity_id, "curve": {key: deepcopy(value) for key, value in curve.items() if key != "construction"}}) known = {entry["id"] for entry in source_entities} for selection in selections: fragment = selection.get("fragment") or {} if selection["source_entity_id"] not in known or (fragment and fragment.get("anchor_entity_id") not in known): return None if len(source_entities) < 2: return None output = deepcopy(sketch_by_source[source_sketch]) output["id"] = f"{output['id']}__{feature_id}" output["name"] = f"{output['name']}__{feature_id}" output["profile"] = { "type": "planar_imprint", "source_entities": source_entities, "selections": selections, } 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 _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_face_output_role_selector( value: Any, feature_by_id: dict[str, dict[str, Any]], sketches_by_id: dict[str, dict[str, Any]], ) -> dict[str, Any] | None: """Reference one direct-builder cap face without reconstructing its sketch. A CAP_FACE is a B-rep output, not an alias for the profile that originally produced it. The selector is therefore legal only when its producer has a runtime builder role capable of proving the exact active face. The runtime rejects it if a later mutation makes that role non-unique or unavailable. """ try: _call, owner, topology, kind, _definition = _direct_make_query(value) except ValueError: return None if topology != "CAP_FACE" or kind != "face": return None query = parse_query(value) if query.is_start is None: return None owner_feature_id = f"f_{owner}" producer = feature_by_id.get(owner_feature_id) params = (producer or {}).get("params") or {} producer_sketch = sketches_by_id.get(str((producer or {}).get("sketch_id") or "")) def has_one_closed_outer_region(sketch: dict[str, Any] | None) -> bool: """Prove the narrowed draft builder can receive exactly one face.""" profile = (sketch or {}).get("profile") or {} if profile.get("type") == "circle": return True contours = profile.get("contours") return ( profile.get("type") == "analytic_contours" and isinstance(contours, list) and len(contours) == 1 and bool((contours[0] or {}).get("closed")) ) # This derived-profile contract exposes only caps that a direct, one-sided, # independently retained builder result can prove. The adapter supports # both a regular prism and the restricted one-face LocOpe drafted-prism # builder path. Fused/multi-extent/profile results still have no # unambiguous builder output in the active snapshot. if ( producer is None or producer.get("atomic_id") != "extrude_add_blind" or params.get("result_mode") != "new_body" or (params.get("end_condition") or {}).get("type") != "blind" # LocOpe_DPrism exposes one builder cap only for the one-face path. # A profile with holes is valid geometry but follows the fallback # tapered-extrude path, which intentionally carries no cap role. or params.get("draft") is not None and not has_one_closed_outer_region(producer_sketch) ): return None role_prefix = { "extrude_add_blind": "extrude", }[str(producer["atomic_id"])] return { "kind": "face", "owner_feature_id": owner_feature_id, "output_role": f"{role_prefix}.{'start' if query.is_start else 'end'}", "source": "runtime_snapshot", "confidence": 1.0, } def _profile_query_union_leaves(value: Any) -> list[Any]: """Flatten only associative query unions used to select one profile. FeatureScript histories can wrap a qUnion in a second qUnion when a local alias is later assigned to ``entities``. The wrapper changes neither the selected topology nor the source provenance. Keeping this normalization local to CAP_EDGE profile recognition avoids changing generic selector parsing, where query grouping may still be diagnostically meaningful. """ if isinstance(value, Call) and value.name == "qUnion" and value.args and isinstance(value.args[0], list): return [leaf for item in value.args[0] for leaf in _profile_query_union_leaves(item)] return [value] 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 = _profile_query_union_leaves(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 = _profile_query_union_leaves(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]], body_aliases: dict[str, str] | None = None, ) -> list[str]: """Resolve a selected SWEPT_BODY to its exact current member when known. 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. When the restricted one-body successor state proves that current member, use it directly: the runtime can transform its actual B-rep and retain a concrete COPY body member. Replaying a creator plus its additive history would make each replay fragment look like a separate source, which loses the instance ownership needed by a later COPY(BODY) boolean/transform. Without that one-to-one proof, retain the older bounded replay expansion. It can preserve geometry for patterns with fused history, but deliberately does not claim individual COPY body ownership. """ body_aliases = body_aliases or {} swept_body_sources = { f"f_{query.owner_feature}" for item in _queries(value) for query in [parse_query(item)] if query.topology_type == "SWEPT_BODY" and query.kind in {"body", "entitytype.body"} and query.owner_feature } has_swept_body = bool(swept_body_sources) if not has_swept_body: return sources resolved_sources = { source: _resolved_body_alias(source, body_aliases) for source in swept_body_sources } if any(resolved != source for source, resolved in resolved_sources.items()): return list(dict.fromkeys( resolved_sources.get(source, source) for source in 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 _boolean_body_references( value: Any, previous: list[str], feature_by_id: dict[str, dict[str, Any]], body_aliases: dict[str, str] | None = None, ) -> tuple[list[str], list[dict[str, Any]]]: """Keep direct pattern COPY bodies explicit for boolean selection. A body selected from a mirror or circular pattern is not its pattern's aggregate result. Reuse the established transform-query provenance parser, but limit this first boolean contract to direct feature bodies and pattern instances. Multi-source transform COPY output has distinct lifecycle semantics and remains a separate future contract. """ sources, instance_refs, transform_copy_refs = _transform_body_references( value, previous, feature_by_id, body_aliases, ) if transform_copy_refs: raise UnsupportedCapability( "boolean_pattern_copy", "current CDSL booleanBodies does not yet consume multi-source transform COPY bodies", ) return sources, instance_refs def _shell_target_body_source( value: Any, previous: list[str], body_aliases: dict[str, str], body_members: set[str], ) -> str: """Lower one CADFS shell ``parts`` query to its live body member. ``parts`` is not a hint to shell whichever aggregate currently contains the selected faces. It names the CADFS body that owns the shell operation. A direct SWEPT_BODY can follow only a lowering-time successor that already proves a one-to-one active member; patterns and fused aggregates deliberately never enter that alias map. """ queries = _queries(value) if len(queries) != 1: raise UnsupportedCapability( "shell_parts_body_source", "current CDSL shell.parts requires exactly one direct SWEPT_BODY", ) _call, owner, topology, kind, _definition = _direct_make_query(queries[0]) if topology != "SWEPT_BODY" or kind not in {"body", "entitytype.body"}: raise UnsupportedCapability( "shell_parts_body_source", "current CDSL shell.parts requires one direct SWEPT_BODY", ) source = f"f_{owner}" if source not in previous: raise ValueError("shell parts body source is unresolved") source = _resolved_body_alias(source, body_aliases) if source not in body_members: raise UnsupportedCapability( "shell_parts_body_source", "shell parts body no longer has one independently selectable member", ) return source 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 _resolved_body_alias(source: str, aliases: dict[str, str]) -> str: """Follow one proven direct body's current successor without guessing. CADFS ``SWEPT_BODY`` queries keep the original operation owner after a non-copy transform or a sole-body mutation. The source still names the same physical body, whose executable CDSL member is now the successor. This only follows lowering-time transitions that preserve a one-to-one body member. Boolean, multi-body, delete and replayed pattern output never enter the alias map. """ resolved = source visited = {source} while resolved in aliases: successor = aliases[resolved] if successor in visited: raise ValueError("body transform successor aliases contain a cycle") visited.add(successor) resolved = successor return resolved def _direct_transform_copy_member( value: Any, previous: list[str], feature_by_id: dict[str, dict[str, Any]], body_aliases: dict[str, str], visited: set[str] | None = None, ) -> tuple[str, dict[str, str] | None]: """Resolve an exact ``COPY`` chain emitted by explicit transform copies. FeatureScript represents a ``makeCopy`` transform result as ``owner.opPattern/COPY`` even when the owner is a plain transform rather than a CADFS pattern feature. The runtime already gives that transform a distinct body member under its feature ID. A multi-source transform COPY exposes one member for each selected source, so this resolver returns a structured reference for that case and validates its complete derived-from chain. It intentionally does not infer ownership for generic pattern, fused, or dress-up output. """ _call, owner, topology, kind, definition = _direct_make_query(value) if kind not in {"body", "entitytype.body"}: raise UnsupportedCapability("transform_pattern_copy", "CADFS transform COPY source is not a body") if topology == "SWEPT_BODY": source = f"f_{owner}" if source not in previous: raise ValueError("transform COPY source body is unresolved") return _resolved_body_alias(source, body_aliases), None if topology != "COPY": raise UnsupportedCapability( "transform_pattern_copy", "CADFS transform COPY source must descend from a direct swept body", ) try: instance = int(str(definition.get("instanceName"))) except (TypeError, ValueError) as error: raise ValueError("transform COPY instance is unresolved") from error if instance != 1: raise UnsupportedCapability( "transform_pattern_copy", "direct transform COPY provenance only has generated instance 1", ) member_id = f"f_{owner}" if member_id in (visited or set()): raise ValueError("transform COPY provenance contains a cycle") transform = feature_by_id.get(member_id) params = (transform or {}).get("params") or {} source_ids = params.get("source_feature_ids") or [] if ( member_id not in previous or transform is None or transform.get("atomic_id") != "transform_bodies" or not bool(params.get("make_copy")) or params.get("pattern_instance_refs") or params.get("transform_copy_refs") or not isinstance(source_ids, list) or not source_ids ): raise UnsupportedCapability( "transform_pattern_copy", "CADFS transform COPY must name an exact preceding explicit transform copy", ) derived = definition.get("derivedFrom") if derived is None: raise ValueError("transform COPY has no derived body") upstream, _upstream_copy_ref = _direct_transform_copy_member( derived, previous, feature_by_id, body_aliases, (visited or set()) | {member_id}, ) if len(source_ids) == 1 and source_ids == [upstream]: return member_id, None if len(source_ids) > 1 and upstream in source_ids: return member_id, { "transform_feature_id": member_id, "source_feature_id": upstream, } raise UnsupportedCapability( "transform_pattern_copy", "CADFS transform COPY derived body does not match its CDSL transform source", ) def _transform_copy_query_provenance( value: Any, previous: list[str], feature_by_id: dict[str, dict[str, Any]], visited: set[str] | None = None, ) -> tuple[Any, list[dict[str, Any]], str]: """Return a direct source query and exact transforms for a COPY descendant. The copied edge/vertex must be produced by the same restricted transform copy chain as its selected body. Applying the recorded CDSL transforms to the direct source reference preserves the physical location without a topology-nearest fallback. This is deliberately separate from runtime selector binding: the values are only used to lower a FeatureScript translation vector whose source points are explicit and unique. """ _call, owner, topology, _kind, definition = _direct_make_query(value) member_id = f"f_{owner}" if topology != "COPY": if member_id not in previous: raise ValueError("transform COPY reference owner is unresolved") return value, [], member_id try: instance = int(str(definition.get("instanceName"))) except (TypeError, ValueError) as error: raise ValueError("transform COPY reference instance is unresolved") from error if instance != 1: raise UnsupportedCapability( "transform_translation_entity", "COPY reference only has exact transform provenance for instance 1", ) if member_id in (visited or set()): raise ValueError("transform COPY reference provenance contains a cycle") transform = feature_by_id.get(member_id) params = (transform or {}).get("params") or {} source_ids = params.get("source_feature_ids") or [] transform_spec = params.get("transform") if ( member_id not in previous or transform is None or transform.get("atomic_id") != "transform_bodies" or not bool(params.get("make_copy")) or params.get("pattern_instance_refs") or not isinstance(source_ids, list) or len(source_ids) != 1 or not isinstance(transform_spec, dict) ): raise UnsupportedCapability( "transform_translation_entity", "COPY reference must name an exact preceding single-source transform copy", ) derived = definition.get("derivedFrom") if derived is None: raise ValueError("transform COPY reference has no derived geometry") source_query, transforms, upstream_member = _transform_copy_query_provenance( derived, previous, feature_by_id, (visited or set()) | {member_id}, ) if source_ids != [upstream_member]: raise UnsupportedCapability( "transform_translation_entity", "COPY reference derived geometry does not match its transform source", ) return source_query, [*transforms, transform_spec], member_id def _apply_body_transform_to_point(point: list[float], transform: dict[str, Any]) -> list[float]: """Apply one validated CDSL body transform to an explicit point.""" kind = str(transform.get("type") or "") if kind == "translation": offset = transform.get("translation_mm") if not isinstance(offset, list) or len(offset) != 3: raise ValueError("transform COPY translation is incomplete") return [point[index] + float(offset[index]) for index in range(3)] if kind == "rotation": axis = transform.get("axis") angle = transform.get("angle_deg") if not isinstance(axis, dict) or not isinstance(angle, (int, float)): raise ValueError("transform COPY rotation is incomplete") return _rotate_point(point, axis, math.radians(float(angle))) if kind == "uniform_scale": center = transform.get("center_mm") factor = transform.get("scale_factor") if not isinstance(center, list) or len(center) != 3 or not isinstance(factor, (int, float)): raise ValueError("transform COPY uniform scale is incomplete") return [float(center[index]) + (point[index] - float(center[index])) * float(factor) for index in range(3)] raise ValueError(f"transform COPY has unsupported transform type {kind!r}") def _transform_copy_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]]], feature_by_id: dict[str, dict[str, Any]], previous: list[str], ) -> list[float]: try: _call, _owner, topology, _kind, _definition = _direct_make_query(query) except ValueError: return _query_point(query, feature_frames, sketch_by_source, entity_by_sketch) if topology != "COPY": return _query_point(query, feature_frames, sketch_by_source, entity_by_sketch) source, transforms, _member = _transform_copy_query_provenance(query, previous, feature_by_id) point = _query_point(source, feature_frames, sketch_by_source, entity_by_sketch) for transform in transforms: point = _apply_body_transform_to_point(point, transform) return point def _transform_copy_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]]], feature_by_id: dict[str, dict[str, Any]], previous: list[str], ) -> tuple[list[float], list[float]]: try: _call, _owner, topology, _kind, _definition = _direct_make_query(query) except ValueError: return _query_line(query, feature_frames, sketch_by_source, entity_by_sketch) if topology != "COPY": return _query_line(query, feature_frames, sketch_by_source, entity_by_sketch) source, transforms, _member = _transform_copy_query_provenance(query, previous, feature_by_id) start, end = _query_line(source, feature_frames, sketch_by_source, entity_by_sketch) for transform in transforms: start = _apply_body_transform_to_point(start, transform) end = _apply_body_transform_to_point(end, transform) return start, end def _transform_source_features(value: Any, previous: list[str], body_aliases: dict[str, str] | None = None) -> list[str]: sources = [] body_aliases = body_aliases or {} 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] resolved = _resolved_body_alias(source, body_aliases) if source in previous and resolved not in sources: sources.append(resolved) if not sources: raise ValueError("transform source features are unresolved") return sources def _transform_body_references( value: Any, previous: list[str], feature_by_id: dict[str, dict[str, Any]], body_aliases: dict[str, str] | None = None, ) -> tuple[list[str], list[dict[str, Any]], list[dict[str, str]]]: """Lower direct CADFS body queries without flattening COPY provenance. A pattern COPY is not its pattern's aggregate result. Keep the producer, source body and instance index as a structured CDSL reference so runtime can select a proven body member without receiving an internal state ID. """ sources: list[str] = [] instance_refs: list[dict[str, Any]] = [] transform_copy_refs: list[dict[str, str]] = [] body_aliases = body_aliases or {} query_values = _queries(value) # Older CADFS exports represent a directly created body as # qCreatedBy(id + "F1", BODY), without a makeQuery topology wrapper. # It has no COPY provenance, so the established source-feature contract # remains exact and does not need a body-member instance reference. if query_values and all(isinstance(item, Call) and item.name == "qCreatedBy" for item in query_values): return _transform_source_features(value, previous, body_aliases), instance_refs, transform_copy_refs for query_value in query_values: _call, owner, topology, kind, definition = _direct_make_query(query_value) if kind not in {"body", "entitytype.body"}: raise UnsupportedCapability("transform_body_query", "CADFS transform requires direct body queries") if topology == "SWEPT_BODY": source = f"f_{owner}" if source not in previous: raise ValueError("transform source body is unresolved") source = _resolved_body_alias(source, body_aliases) if source not in sources: sources.append(source) continue if topology != "COPY": raise UnsupportedCapability( "transform_body_query", "CADFS transform requires SWEPT_BODY or circular-pattern COPY body queries", ) derived = definition.get("derivedFrom") if derived is None: raise ValueError("pattern copy transform source is unresolved") pattern_id = f"f_{owner}" owner_feature = feature_by_id.get(pattern_id) if owner_feature is not None and owner_feature.get("atomic_id") == "transform_bodies": source, transform_copy_ref = _direct_transform_copy_member( query_value, previous, feature_by_id, body_aliases, ) if transform_copy_ref is not None: if transform_copy_ref not in transform_copy_refs: transform_copy_refs.append(transform_copy_ref) elif source not in sources: sources.append(source) continue _source_call, source_owner, source_topology, source_kind, _source_definition = _direct_make_query(derived) source_id = _resolved_body_alias(f"f_{source_owner}", body_aliases) pattern = feature_by_id.get(pattern_id) if ( source_topology == "SWEPT_BODY" and source_kind in {"body", "entitytype.body"} and pattern is not None and pattern.get("atomic_id") == "pattern_mirror" and pattern_id in previous and source_id in (pattern.get("params") or {}).get("source_feature_ids", []) and (feature_by_id.get(source_id) or {}).get("params", {}).get("result_mode") == "new_body" ): try: instance = int(str(definition.get("instanceName"))) except (TypeError, ValueError) as error: raise ValueError("mirror copy transform instance is unresolved") from error if instance != 1: raise UnsupportedCapability( "transform_pattern_copy", "direct mirror COPY provenance only has generated instance 1", ) reference = { "pattern_feature_id": pattern_id, "source_feature_id": source_id, "instance_index": instance, } if reference not in instance_refs: instance_refs.append(reference) continue if ( source_topology != "SWEPT_BODY" or source_kind not in {"body", "entitytype.body"} or pattern is None or pattern.get("atomic_id") != "pattern_circular" or pattern_id not in previous or source_id not in (pattern.get("params") or {}).get("source_feature_ids", []) ): raise UnsupportedCapability( "transform_pattern_copy", "CADFS transform COPY body must name a direct source of a preceding circular pattern", ) try: instance = int(str(definition.get("instanceName"))) except (TypeError, ValueError) as error: raise ValueError("pattern copy transform instance is unresolved") from error count = int((pattern.get("params") or {}).get("pattern_count") or 0) excluded = {int(value) for value in (pattern.get("params") or {}).get("excluded_instance_indices") or []} if instance < 1 or instance >= count or instance in excluded: raise ValueError("pattern copy transform instance is outside the generated range") reference = { "pattern_feature_id": pattern_id, "source_feature_id": source_id, "instance_index": instance, } if reference not in instance_refs: instance_refs.append(reference) if not sources and not instance_refs and not transform_copy_refs: raise ValueError("transform source bodies are unresolved") return sources, instance_refs, transform_copy_refs def _body_transform( 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]]], feature_by_id: dict[str, dict[str, Any]] | None = None, previous: list[str] | None = None, ) -> dict[str, Any]: """Lower one CADFS body transform without changing the selected body.""" transform_type = str(params.get("transformType") or "").split(".")[-1].upper() # FeatureScript COPY has no geometric displacement, but it does create a # separate body member. Represent its identity geometry explicitly and let # the enclosing transform_bodies operation retain the source via # ``make_copy``. This stays on the OCC transform/history path rather than # aliasing the source's runtime body. if transform_type == "COPY": return {"type": "translation", "translation_mm": [0.0, 0.0, 0.0]} if transform_type == "TRANSLATION_3D": return { "type": "translation", "translation_mm": [_number(params.get(key, 0.0), True) for key in ("dx", "dy", "dz")], } if transform_type == "TRANSLATION_DISTANCE": return { "type": "translation", "translation_mm": _translation_distance_vector( params, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, previous, ), } if transform_type == "TRANSLATION_ENTITY": return { "type": "translation", "translation_mm": _translation_entity_vector( params, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, previous, ), } if transform_type == "ROTATION": axis = _transform_axis(params.get("transformAxis"), feature_frames, sketch_by_source, entity_by_sketch) return {"type": "rotation", "axis": axis, "angle_deg": _number(params.get("angle"), True)} if transform_type == "SCALE_UNIFORMLY": scale_factor = _number(params.get("scale")) if not math.isfinite(scale_factor) or scale_factor <= 0: raise UnsupportedCapability( "transform_uniform_scale", "SCALE_UNIFORMLY requires a finite positive scale factor", ) return { "type": "uniform_scale", "center_mm": _scale_center(params.get("scalePoint"), feature_frames, sketch_by_source, entity_by_sketch), "scale_factor": scale_factor, } raise UnsupportedCapability("transform", f"current CDSL engine cannot exactly execute {transform_type or 'unknown'} transform") def _delete_body_source(value: Any) -> str: """Resolve a directly owned body output without broad query expansion.""" _call, owner, topology, kind, _definition = _direct_make_query(value) if kind not in {"body", "entitytype.body"} or topology not in {"SWEPT_BODY", "COPY"}: raise UnsupportedCapability( "delete_bodies", "current CDSL deleteBodies requires a direct SWEPT_BODY or COPY body query", ) return f"f_{owner}" 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 if profile.get("type") == "planar_imprint": return bool(profile.get("source_entities") and profile.get("selections")) 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"] == "circle" and ".center" in token: return _global(plane, entity["center"]) 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 _convex_linear_offset_wall_endpoints( profile: dict[str, Any], entity: dict[str, Any], source_plane: dict[str, Any], offset_plane: dict[str, Any], thickness: float, ) -> tuple[list[float], list[float]] | None: """Offset one convex, closed, linear profile edge with its true neighbors. A shell's offset wall ends at the intersections with the offset adjacent walls. This is not equivalent to translating the selected outer edge: a rectangular wall, for example, shortens at both corners. Restrict this construction to one ordered convex line loop so every miter and its interior side are uniquely defined. """ contours = (profile.get("profile") or {}).get("contours") or [] if len(contours) != 1: return None contour = contours[0] segments = contour.get("segments") or [] if ( not contour.get("closed") or len(segments) < 3 or any(segment.get("type") != "line" for segment in segments) or any(not _same_point(segment["end"], segments[(index + 1) % len(segments)]["start"]) for index, segment in enumerate(segments)) ): return None matches = [index for index, segment in enumerate(segments) if _matching_profile_segment(segment, entity)] if len(matches) != 1: return None area_twice = sum( segment["start"][0] * segment["end"][1] - segment["end"][0] * segment["start"][1] for segment in segments ) if abs(area_twice) <= 1e-9: return None orientation = 1.0 if area_twice > 0 else -1.0 def interior_normal(segment: dict[str, Any]) -> list[float] | None: dx = segment["end"][0] - segment["start"][0] dy = segment["end"][1] - segment["start"][1] length = math.hypot(dx, dy) if length <= 1e-9: return None return [-orientation * dy / length, orientation * dx / length] normals = [interior_normal(segment) for segment in segments] if any(normal is None for normal in normals): return None # A convex loop has one consistent signed turn direction. Concave offset # boundaries can self-intersect and require the shell kernel's exact trim # history, so they deliberately remain deferred. turn_signs = [] for index, segment in enumerate(segments): next_segment = segments[(index + 1) % len(segments)] dx = segment["end"][0] - segment["start"][0] dy = segment["end"][1] - segment["start"][1] next_dx = next_segment["end"][0] - next_segment["start"][0] next_dy = next_segment["end"][1] - next_segment["start"][1] turn = dx * next_dy - dy * next_dx if abs(turn) <= 1e-9: return None turn_signs.append(1.0 if turn > 0 else -1.0) if any(sign != turn_signs[0] for sign in turn_signs): return None selected_index = matches[0] selected_normal = normals[selected_index] global_normal = [ source_plane["x_dir"][index] * selected_normal[0] + _y_dir(source_plane)[index] * selected_normal[1] for index in range(3) ] if abs(_dot(_unit(global_normal, "offset wall normal is degenerate"), offset_plane["normal"]) - 1.0) > 1e-6: return None def shifted_line(index: int) -> tuple[list[float], list[float]]: segment = segments[index] normal = normals[index] return ( [segment["start"][axis] + thickness * normal[axis] for axis in range(2)], [segment["end"][axis] + thickness * normal[axis] for axis in range(2)], ) def intersection( first_start: list[float], first_end: list[float], second_start: list[float], second_end: list[float], ) -> list[float] | None: first_direction = [first_end[0] - first_start[0], first_end[1] - first_start[1]] second_direction = [second_end[0] - second_start[0], second_end[1] - second_start[1]] denominator = first_direction[0] * second_direction[1] - first_direction[1] * second_direction[0] if abs(denominator) <= 1e-9: return None difference = [second_start[0] - first_start[0], second_start[1] - first_start[1]] scale = (difference[0] * second_direction[1] - difference[1] * second_direction[0]) / denominator return [first_start[axis] + scale * first_direction[axis] for axis in range(2)] previous = (selected_index - 1) % len(segments) following = (selected_index + 1) % len(segments) selected_start, selected_end = shifted_line(selected_index) previous_start, previous_end = shifted_line(previous) following_start, following_end = shifted_line(following) first = intersection(previous_start, previous_end, selected_start, selected_end) second = intersection(selected_start, selected_end, following_start, following_end) if first is None or second is None or _same_point(first, second): return None return _global(source_plane, first), _global(source_plane, second) 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_by_id: dict[str, dict[str, Any]], feature_id: str, ) -> dict[str, Any] | None: """Materialize one planar, linear-extrude OFFSET_FACE wall when proven. An offset face is a generated side wall, not the source extrusion's cap region. Reusing the full source profile therefore changes both the face extent and its topology. The wall is reconstructible only when direct source provenance proves one non-construction edge of a convex linear profile, one finite direct extrusion span, and one removed extrusion cap. All other OFFSET_FACE queries remain an explicit deferred capability rather than receiving a guessed profile. """ 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 shell_source = frame.get("shell_source") direct_frame = feature_frames.get(str(shell_source)) if shell_source else None direct_feature = feature_by_id.get(f"f_{shell_source}") if shell_source else None profile_source = (direct_frame or {}).get("profile_source") source_sketch = sketch_by_source.get(str(profile_source)) if profile_source else None refs = _source_refs(value) if ( query.topology_type != "OFFSET_FACE" or profile is None or direct_frame is None or direct_feature is None or direct_feature.get("atomic_id") != "extrude_add_blind" or (direct_feature.get("params") or {}).get("result_mode") != "new_body" or (direct_feature.get("params") or {}).get("draft") is not None or frame.get("shell_inward") is not True or frame.get("shell_removed_cap") not in {"start", "end"} or not isinstance(profile_source, str) or source_sketch is None or not _profile_matches_direct_source(profile, source_sketch) or len(refs) != 1 or refs[0][0] != profile_source or query.source_sketch != profile_source or query.source_entity != refs[0][1] ): return None entity = (entity_by_sketch.get(profile_source) or {}).get(refs[0][1]) if entity is None or entity.get("type") != "line" or entity.get("construction"): return None profile_plane = direct_frame.get("profile") start_plane = direct_frame.get("start") end_plane = direct_frame.get("end") if not all(isinstance(plane, dict) for plane in (profile_plane, start_plane, end_plane)): return None try: source_start, source_end = _convex_linear_offset_wall_endpoints( profile, entity, source_sketch["workplane"], _offset_face_plane(value, feature_frames, sketch_by_source, entity_by_sketch), float(frame["shell_thickness_mm"]), ) or (None, None) if source_start is None or source_end is None: return None line_direction = _unit(_sub(source_end, source_start), "offset face source line is degenerate") span = _sub(end_plane["origin_mm"], start_plane["origin_mm"]) span_direction = _unit(span, "offset face extrusion span is degenerate") profile_normal = _unit(profile_plane["normal"], "offset face extrusion profile normal is degenerate") if ( abs(_dot(line_direction, span_direction)) > 1e-6 or abs(abs(_dot(span_direction, profile_normal)) - 1.0) > 1e-6 ): return None cap_shift = _sub(start_plane["origin_mm"], profile_plane["origin_mm"]) if not all(math.isfinite(component) for point in (source_start, source_end, span, cap_shift) for component in point): return None offset_plane = _offset_face_plane(value, feature_frames, sketch_by_source, entity_by_sketch) except (KeyError, TypeError, ValueError): return None cap_shrink = [float(frame["shell_thickness_mm"]) * component for component in span_direction] start_shift = list(cap_shift) end_shift = [cap_shift[index] + span[index] for index in range(3)] if frame["shell_removed_cap"] == "end": start_shift = [start_shift[index] + cap_shrink[index] for index in range(3)] else: end_shift = [end_shift[index] - cap_shrink[index] for index in range(3)] corners = [ [source_start[index] + start_shift[index] for index in range(3)], [source_end[index] + start_shift[index] for index in range(3)], [source_end[index] + end_shift[index] for index in range(3)], [source_start[index] + end_shift[index] for index in range(3)], ] local_corners = [_local(offset_plane, corner) for corner in corners] if not all(math.isfinite(component) for point in local_corners for component in point): return None segments = [ {"type": "line", "start": local_corners[index], "end": local_corners[(index + 1) % len(local_corners)]} for index in range(len(local_corners)) ] return { "id": f"sketch_{query.owner_feature}__{feature_id}", "name": f"{query.owner_feature}__{feature_id}", "workplane": offset_plane, "profile": {"type": "analytic_contours", "contours": [{"role": "unknown", "closed": True, "segments": segments}]}, } 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_data = feature_frames[info.owner_feature] frame = frame_data["start" if info.is_start else "end"] # CAP outer normals may flip the x direction to preserve the later # sketch attachment handedness. A source sketch edge, however, keeps # its original physical in-plane coordinates at either cap. Preserve # that profile frame and move only its origin to the selected cap. profile = frame_data.get("profile") plane = {**profile, "origin_mm": list(frame["origin_mm"])} if isinstance(profile, dict) else frame if entity["type"] == "circle": center = _global(plane, entity["center"]) return center, [center[index] + frame["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 _scale_center( 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]]], ) -> list[float]: """Resolve the explicit center of a CADFS uniform scale. Origin point is a system datum with a known coordinate. Other centers must name exactly one direct sketch or CAP_VERTEX source whose physical point is available in the lowering state. COPY/SWEPT/OFFSET vertices need runtime topology provenance, so treating their nearest visible point as the scale center would change the operation's semantics. """ centers = _queries(value) if len(centers) != 1: raise UnsupportedCapability( "transform_uniform_scale_center", "SCALE_UNIFORMLY requires exactly one explicit scale point", ) center = centers[0] for call in walk_calls(center): if call.name == "qCreatedBy" and call.args and "Origin.pointOp" in symbolic_string(call.args[0]): return [0.0, 0.0, 0.0] info = parse_query(center) if info.kind not in {"vertex", "entitytype.vertex"} or info.topology_type not in {None, "CAP_VERTEX"}: raise UnsupportedCapability( "transform_uniform_scale_center", "SCALE_UNIFORMLY scale point must be Origin point or a direct sketch/CAP_VERTEX", ) try: return _query_point(center, feature_frames, sketch_by_source, entity_by_sketch) except ValueError as error: raise UnsupportedCapability( "transform_uniform_scale_center", "SCALE_UNIFORMLY scale point must resolve to one physical vertex", ) from error def _translation_distance_vector( 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]]], feature_by_id: dict[str, dict[str, Any]] | None = None, previous: list[str] | None = None, ) -> list[float]: """Resolve the restricted CADFS TRANSLATION_DISTANCE direction contract. An edge direction is only unambiguous when it comes from one source sketch line or its CAP_EDGE descendant. A direct CAP_FACE is also exact when its producer has recorded a physical start/end frame: its selected cap normal is the FeatureScript direction. A generic face normal, swept edge, offset edge, and curve tangent each need different source semantics, so they remain explicit capability gaps instead of borrowing a nearby direction from the active body. """ directions = _queries(params.get("transformDirection")) if len(directions) != 1: raise UnsupportedCapability( "transform_translation_direction", "TRANSLATION_DISTANCE requires exactly one direct linear sketch or CAP_EDGE direction", ) direction_query = directions[0] info = parse_query(direction_query) # System datum planes and previously lowered reference planes have an # explicit physical normal. ``_query_plane`` accepts only those two # qCreatedBy forms here, so this never treats an arbitrary produced face # as a translation direction. if "qCreatedBy" in info.calls and info.topology_type is None: try: direction = _unit( list(_query_plane(direction_query, feature_frames, sketch_by_source, entity_by_sketch)["normal"]), "transform reference-plane normal is degenerate", ) except ValueError as error: raise UnsupportedCapability( "transform_translation_direction", "TRANSLATION_DISTANCE reference-plane direction must have an explicit plane frame", ) from error elif info.kind in {"face", "entitytype.face"} and info.topology_type == "SWEPT_FACE": try: direction = _unit( list(_query_plane(direction_query, feature_frames, sketch_by_source, entity_by_sketch)["normal"]), "transform swept-face normal is degenerate", ) except (UnsupportedCapability, ValueError) as error: raise UnsupportedCapability( "transform_translation_direction", "TRANSLATION_DISTANCE SWEPT_FACE direction requires one direct planar source face", ) from error elif info.kind in {"face", "entitytype.face"} and info.topology_type == "CAP_FACE" and info.is_start is not None: frame = feature_frames.get(info.owner_feature or "") or {} cap = frame.get("start" if info.is_start else "end") try: direction = _unit(list((cap or {}).get("normal") or []), "transform cap-face normal is degenerate") except ValueError as error: raise UnsupportedCapability( "transform_translation_direction", "TRANSLATION_DISTANCE CAP_FACE direction requires a producer with a physical cap frame", ) from error else: if info.kind not in {"edge", "entitytype.edge"} or info.topology_type not in {None, "CAP_EDGE"}: raise UnsupportedCapability( "transform_translation_direction", "TRANSLATION_DISTANCE only supports a linear sketch/CAP_EDGE, exact transform copy, explicit reference plane, direct planar SWEPT_FACE, or framed CAP_FACE direction", ) try: entity, _plane, _token = _entity_from_query(direction_query, sketch_by_source, entity_by_sketch) if entity.get("type") != "line": raise ValueError("transform direction source is not linear") if feature_by_id is not None and previous is not None: start, end = _transform_copy_line( direction_query, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, previous, ) else: start, end = _query_line(direction_query, feature_frames, sketch_by_source, entity_by_sketch) direction = _unit(_sub(end, start), "transform translation direction is degenerate") except ValueError as error: raise UnsupportedCapability( "transform_translation_direction", "TRANSLATION_DISTANCE direction must resolve to a non-degenerate line", ) from error distance = _number(params.get("distance"), True) if distance < 0: raise UnsupportedCapability( "transform_translation_distance", "TRANSLATION_DISTANCE requires a non-negative distance", ) if _bool(params.get("oppositeDirection")): distance = -distance return [component * distance for component in direction] def _translation_entity_vector( 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]]], feature_by_id: dict[str, dict[str, Any]] | None = None, previous: list[str] | None = None, ) -> list[float]: """Resolve a direct CADFS TRANSLATION_ENTITY vector without topology guesses. FeatureScript accepts either a line entity, whose endpoint delta is the translation vector, or two vertices interpreted in selection order. This restricted lowering accepts raw sketch/CAP descendants and exact copies made by explicit single-source transforms. Other COPY/SWEPT/OFFSET geometry requires a kernel-proven successor relation. """ entities = _queries(params.get("transformLine")) if len(entities) == 1: line = entities[0] info = parse_query(line) if info.kind not in {"edge", "entitytype.edge"} or info.topology_type not in {None, "CAP_EDGE"}: raise UnsupportedCapability( "transform_translation_entity", "TRANSLATION_ENTITY requires a linear sketch/CAP_EDGE or its exact transform copy", ) try: entity, _plane, _token = _entity_from_query(line, sketch_by_source, entity_by_sketch) if entity.get("type") != "line": raise ValueError("transform line source is not linear") if feature_by_id is not None and previous is not None: start, end = _transform_copy_line( line, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, previous, ) else: start, end = _query_line(line, feature_frames, sketch_by_source, entity_by_sketch) except ValueError as error: raise UnsupportedCapability( "transform_translation_entity", "TRANSLATION_ENTITY line must resolve to a non-degenerate line", ) from error elif len(entities) == 2: first, second = entities first_info, second_info = parse_query(first), parse_query(second) if ( first_info.kind not in {"vertex", "entitytype.vertex"} or second_info.kind not in {"vertex", "entitytype.vertex"} or first_info.topology_type not in {None, "CAP_VERTEX"} or second_info.topology_type not in {None, "CAP_VERTEX"} ): raise UnsupportedCapability( "transform_translation_entity", "TRANSLATION_ENTITY requires exactly two sketch/CAP_VERTEX points or their exact transform copies", ) try: if feature_by_id is not None and previous is not None: start = _transform_copy_point( first, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, previous, ) end = _transform_copy_point( second, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, previous, ) else: start = _query_point(first, feature_frames, sketch_by_source, entity_by_sketch) end = _query_point(second, feature_frames, sketch_by_source, entity_by_sketch) except ValueError as error: raise UnsupportedCapability( "transform_translation_entity", "TRANSLATION_ENTITY vertices must resolve to unique points", ) from error else: raise UnsupportedCapability( "transform_translation_entity", "TRANSLATION_ENTITY requires one direct line or exactly two direct vertices", ) vector = _sub(end, start) if math.sqrt(sum(component * component for component in vector)) <= 1e-9: raise UnsupportedCapability("transform_translation_entity", "TRANSLATION_ENTITY vector is degenerate") if _bool(params.get("oppositeDirectionEntity")): vector = [-component for component in vector] return vector 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 == "TRANSLATION_DISTANCE": offset = _translation_distance_vector(params, feature_frames, sketch_by_source, entity_by_sketch) 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 == "TRANSLATION_ENTITY": # Baking is only semantics-preserving while the selected NEW result # has not been absorbed or changed by another body-mutating feature. if source.get("params", {}).get("result_mode") != "new_body" or previous[-1:] != [source_id]: raise UnsupportedCapability( "transform_body_lifecycle", "TRANSLATION_ENTITY bake requires an immediately preceding independent NEW body", ) offset = _translation_entity_vector(params, feature_frames, sketch_by_source, entity_by_sketch) 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 _record_non_copy_body_successors( aliases: dict[str, str], source_ids: list[str], successor_id: str, ) -> None: """Bind direct transform sources to their latest physical body member. A non-copy transform replaces exactly the selected independent members in the runtime. Preserve that one-to-one lifecycle fact for later CADFS queries that retain the original producer ID. This deliberately has no fallback for fused/dress-up/pattern members because those are not entered into ``aliases`` by lowering. """ for source in source_ids: for owner, current in list(aliases.items()): if current == source: aliases[owner] = successor_id aliases[source] = successor_id _SINGLE_BODY_FUSING_ATOMICS = frozenset({ "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_from_face", "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", }) _SINGLE_BODY_DRESSUP_ATOMICS = frozenset({"fillet", "chamfer", "shell"}) _SINGLE_BODY_CUT_ATOMICS = frozenset({ "extrude_cut_blind", "extrude_cut_two_sided", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard", "thread_cut", }) _SINGLE_BODY_NON_MUTATING_ATOMICS = frozenset({"reference_plane", "reference_axis", "extrude_surface", "revolve_surface"}) _LOWERING_BODY_MUTATING_ATOMICS = frozenset({ "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "extrude_from_face", "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add", "hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard", "fillet", "chamfer", "shell", "boolean_bodies", }) _LOWERING_CUT_ATOMICS = frozenset({ "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "revolve_cut", "thread_cut", "hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard", }) _LOWERING_PRIMARY_ATOMICS = frozenset({ "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_from_face", "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", "sphere_add", "box_add", "cylinder_add", "thread_add", "bend_add", }) def _record_lowered_body_members(members: set[str], feature: dict[str, Any]) -> None: """Mirror the runtime's independently selectable body-member contract. This projection is intentionally narrower than geometric body ownership. It only decides whether a later direct CADFS SWEPT_BODY can be emitted as a CDSL shell target. No aggregate/current-body fallback is permitted here. """ feature_id = str(feature["id"]) atomic_id = str(feature.get("atomic_id") or "") params = feature.get("params") or {} if atomic_id == "boolean_bodies": targets = {str(value) for value in params.get("target_feature_ids") or ()} tools = {str(value) for value in params.get("tool_feature_ids") or ()} members.difference_update(targets | tools) members.add(feature_id) if bool(params.get("keep_tools")): members.update(tools) return if atomic_id == "transform_bodies": sources = {str(value) for value in params.get("source_feature_ids") or ()} if bool(params.get("make_copy")): if len(sources) == 1: members.add(feature_id) return members.difference_update(sources) members.add(feature_id) return if atomic_id == "delete_bodies": members.difference_update(str(value) for value in params.get("target_feature_ids") or ()) return if atomic_id in {"pattern_linear", "pattern_mirror", "pattern_circular"}: sources = {str(value) for value in params.get("source_feature_ids") or ()} if not ( atomic_id == "pattern_circular" and str(params.get("operation_mode") or "add") == "add" and sources and sources <= members ): members.clear() return if atomic_id not in _LOWERING_BODY_MUTATING_ATOMICS: return if atomic_id in _LOWERING_CUT_ATOMICS or ( atomic_id == "extrude_from_face" and params.get("operation") == "cut" ): return if atomic_id in _LOWERING_PRIMARY_ATOMICS and params.get("result_mode") == "new_body": members.add(feature_id) return members.clear() members.add(feature_id) def _clear_single_body_successor_state( aliases: dict[str, str], state: dict[str, Any], ) -> None: """Discard only aliases derived from the restricted aggregate lineage.""" for source in state["sources"]: aliases.pop(source, None) state["owner"] = None state["sources"] = set() def _record_single_body_successor( aliases: dict[str, str], state: dict[str, Any], feature: dict[str, Any], ) -> None: """Track one CADFS body through exact single-aggregate successors. A ``SWEPT_BODY`` query names a CADFS body object, not a frozen feature result. When an ordinary additive feature or dress-up mutates the only active body, the original body query still denotes that same physical body. Runtime collapses those operations to one explicit body member, so lowering may follow the successor only while this state machine mirrors that one-member lifecycle exactly. Multi-body, boolean, pattern, delete, and other body-changing paths intentionally clear the proof rather than substituting ``session.body``. """ feature_id = str(feature["id"]) atomic_id = str(feature.get("atomic_id") or "") params = feature.get("params") or {} owner = state["owner"] sources: set[str] = state["sources"] def advance() -> None: for source in sources: if source != feature_id: aliases[source] = feature_id aliases.pop(feature_id, None) sources.add(feature_id) state["owner"] = feature_id if atomic_id in _SINGLE_BODY_NON_MUTATING_ATOMICS: return if atomic_id in _SINGLE_BODY_CUT_ATOMICS: # Runtime preserves the selected member keys for a cut. The cut # feature itself is not a new independently selectable body member. return if atomic_id in _SINGLE_BODY_FUSING_ATOMICS: if atomic_id == "extrude_from_face" and params.get("operation") == "cut": return if params.get("result_mode") == "new_body": if owner is None: state["owner"] = feature_id sources.add(feature_id) else: _clear_single_body_successor_state(aliases, state) return if owner is None: state["owner"] = feature_id sources.add(feature_id) else: advance() return if atomic_id in _SINGLE_BODY_DRESSUP_ATOMICS: if owner is not None: advance() return if atomic_id == "transform_bodies": source_ids = [str(value) for value in params.get("source_feature_ids") or ()] if bool(params.get("make_copy")): # The original member remains addressable, but the aggregate is no # longer a one-body lifecycle. Do not let a later ordinary ADD or # dress-up advance an alias across that unproven split. _clear_single_body_successor_state(aliases, state) return if owner is not None and source_ids == [owner] and not params.get("pattern_instance_refs"): advance() return _clear_single_body_successor_state(aliases, state) return if atomic_id == "pattern_circular": # A direct circular ADD over the sole current member takes the runtime # body-member path: it preserves that member and exposes each rotated # copy under an exact instance key. Keep the already-proven aliases so # a following CADFS COPY(SWEPT_BODY) resolves to the same member. source_ids = [str(value) for value in params.get("source_feature_ids") or ()] if ( owner is not None and str(params.get("operation_mode") or "add") == "add" and source_ids == [owner] ): return # The remaining body atomics either split ownership, select explicit # members, or replay feature geometry. Their CADFS body continuation is # not represented by this restricted one-member contract. _clear_single_body_successor_state(aliases, state) 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: frame_data = feature_frames[info.owner_feature] cap = frame_data["start" if info.is_start else "end"] profile = frame_data.get("profile") plane = {**profile, "origin_mm": list(cap["origin_mm"])} if isinstance(profile, dict) else cap references = _source_refs(query) if len(references) >= 2: 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: frame_data = feature_frames[info.owner_feature] cap = frame_data["start" if info.is_start else "end"] profile = frame_data.get("profile") plane = {**profile, "origin_mm": list(cap["origin_mm"])} if isinstance(profile, dict) else cap 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": offset = _number(params.get("offset", 0), True) # CPlane OFFSET preserves the source plane's local frame. CADFS uses # oppositeDirection only to select the other signed offset side. if _bool(params.get("oppositeDirection")): offset = -offset return _shift_plane(_query_plane(entities[0], feature_frames, sketch_by_source, entity_by_sketch), offset) 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 _mirror_plane_from_query( 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] | None: """Materialize the bounded planar mirror-face source into a CDSL frame. A full solid revolve turns a source line perpendicular to its axis into a planar annular face. That face has a source-defined physical plane even though it is neither a datum plane nor a separately lowered cPlane. The generic ``_query_plane`` path already proves that frame from the direct line and revolve axis; use it here only after checking the exact producer contract. Curved, partial, derived, or non-revolve swept faces remain unresolved instead of becoming a guessed mirror plane. """ plane = _default_plane(value) if plane is not None: return plane info = parse_query(value) if info.topology_type != "SWEPT_FACE" or not info.owner_feature: return None producer = feature_by_id.get(f"f_{info.owner_feature}") or {} frame = feature_frames.get(info.owner_feature) or {} if ( producer.get("atomic_id") != "revolve_add" or not frame.get("revolve_full") or not isinstance(frame.get("revolve_axis"), dict) ): return None try: return _query_plane(value, feature_frames, sketch_by_source, entity_by_sketch) except (UnsupportedCapability, ValueError): return None 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]] = {}; 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] = {} # Original CADFS operation owners can remain the query anchor after an # explicit non-copy body transform. Map only those proven transform # successors; all other body lifecycle transitions remain unaliased. body_transform_aliases: dict[str, str] = {} # This is the lowering-side mirror of runtime ``body_members``. It makes # a source-qualified shell.parts target possible only while that source is # still one explicit selectable body member. lowered_body_members: set[str] = set() # A direct SWEPT_BODY query can continue to name the sole CADFS body after # ordinary additive and dress-up successors. This state is deliberately # cleared before any aggregate, multi-member, or otherwise ambiguous body # transition can make that continuation non-unique. single_body_successor_state: dict[str, Any] = {"owner": None, "sources": set()} 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 if isinstance(exc, OpenSketchProfileError): continue 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": # 单一直接 source 可以烘焙回原始几何。多 body 与 COPY instance # 必须保留为显式 body graph transform,不能移动聚合主体。 sources, pattern_instance_refs, transform_copy_refs = _transform_body_references( p.get("entities"), previous, feature_by_id, body_transform_aliases, ) transform_type = str(p.get("transformType") or "").split(".")[-1].upper() identity_copy = transform_type == "COPY" # Uniform scaling changes the B-rep's dimensions. It cannot be # baked into a source sketch without also transforming every # dependent parameter and topology frame, so retain it as an # explicit body-graph operation even for one direct source. source_for_bake = sources[0] if len(sources) == 1 else None source_feature_for_bake = feature_by_id.get(source_for_bake or "") if ( transform_type not in {"SCALE_UNIFORMLY", "COPY"} and not _bool(p.get("makeCopy")) and len(sources) == 1 and not pattern_instance_refs # Baking mutates an already emitted source sketch and its # lowering-only frames. It is therefore equivalent to a # transform only for the immediately preceding, # independent NEW member. A boolean, dress-up, later add, # or even a reference feature can retain geometry from the # pre-transform source; changing that source retroactively # would invert CADFS history order. and ( # _bake_transform has a stronger lifecycle diagnostic # for TRANSLATION_ENTITY. Let it run even when the # source was absorbed so that this unrepresentable # body move is rejected rather than silently lowered # as an explicit transform of a successor member. transform_type == "TRANSLATION_ENTITY" or ( # A later transform may retain the original CADFS # owner while the physical member is an earlier # transform's successor. Baking it into the # original sketch would discard the first move. sources == _transform_source_features(p.get("entities"), previous) and previous[-1:] == sources and (source_feature_for_bake or {}).get("params", {}).get("result_mode") == "new_body" ) ) ): _bake_transform(p, previous, feature_by_id, feature_source_by_id, sketches_by_id, feature_frames, sketch_by_source, entity_by_sketch) continue missing = [source for source in sources if source not in feature_by_id] if missing: raise ValueError("transform source bodies are unresolved: " + ", ".join(missing)) pattern_dependencies = [reference["pattern_feature_id"] for reference in pattern_instance_refs] transform_copy_dependencies = [reference["transform_feature_id"] for reference in transform_copy_refs] transform_params: dict[str, Any] = { "transform": _body_transform( p, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, previous, ), "make_copy": identity_copy or _bool(p.get("makeCopy")), } if sources: transform_params["source_feature_ids"] = sources if pattern_instance_refs: transform_params["pattern_instance_refs"] = pattern_instance_refs if transform_copy_refs: transform_params["transform_copy_refs"] = transform_copy_refs feature = { "id": fid, "name": item.feature_id, "atomic_id": "transform_bodies", "depends_on": list(dict.fromkeys(sources + pattern_dependencies + transform_copy_dependencies + depends)), "params": transform_params, "execution_status": "supported", } elif item.operation == "deleteBodies": queries = _queries(p.get("entities")) if not queries: raise ValueError("deleteBodies selection is empty") targets = [] for query in queries: _call, owner, topology, kind, _definition = _direct_make_query(query) pattern_id = f"f_{owner}" pattern = feature_by_id.get(pattern_id) if topology != "COPY" or kind not in {"body", "entitytype.body"} or pattern is None or pattern.get("atomic_id") != "pattern_circular": target = _delete_body_source(query) if target not in targets: targets.append(target) continue pattern_id, source_id, instance = _pattern_copy_body(query) 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) if not targets: continue missing = [target for target in targets if target not in feature_by_id] if missing: raise ValueError("deleteBodies source bodies are unresolved: " + ", ".join(missing)) feature = { "id": fid, "name": item.feature_id, "atomic_id": "delete_bodies", "depends_on": list(dict.fromkeys(targets + depends)), "params": {"target_feature_ids": targets}, "execution_status": "supported", } 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_face_output_selector = _cap_face_output_role_selector( profile_value, feature_by_id, sketches_by_id, ) 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, ) planar_imprint_profile = _planar_imprint_profile_sketch( profile_value, sketch_by_source, entity_by_sketch, fid, ) if profile_kind == "INTERSECT" else None intersect_profile_sketch = _intersect_partition_profile_sketch( profile_value, sketch_by_source, entity_by_sketch, fid, ) if profile_kind == "INTERSECT" and planar_imprint_profile is None else None offset_face_profile = _offset_face_profile_sketch( profile_value, feature_frames, sketches_by_id, sketch_by_source, entity_by_sketch, feature_by_id, fid, ) if profile_kind == "OFFSET_FACE" else None profile_sketch: dict[str, Any] | None = None if cap_face_output_selector is not None: # Keep the B-rep face as a runtime-derived profile. It # must not be reconstructed from the original sketch. pass elif 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 planar_imprint_profile is not None: profile_sketch = planar_imprint_profile 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_face_output_selector is None and cap_edge_hole is None and cap_edge_union_profile is None and planar_imprint_profile 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" and profile_sketch is not None: 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 profile_sketch is not None and 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" if cap_face_output_selector is not None: params["operation"] = "cut" if cutting else "add" if second or end["type"] == "mid_plane": params["two_sided"] = True feature = { "id": fid, "name": item.feature_id, "atomic_id": "extrude_from_face", "depends_on": depends, "params": params, "selectors": [cap_face_output_selector], "execution_status": "supported", } else: if profile_sketch is None: raise ValueError("extrude profile is unresolved") 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 = _query_plane(profile_value, feature_frames, sketch_by_source, entity_by_sketch) if cap_face_output_selector is not None else 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), "profile_source": source, } elif ( second and end["type"] == "blind" and (params.get("reverse_end_condition") or {}).get("type") == "blind" ): # A two-sided blind extrusion has no cap at the source # plane. Keep the one-sided CAP convention: ``isStart`` # is the cap on the side opposite the primary extent and # ``isStart:false`` is the primary-extent cap. This makes # the zero-second-distance limit agree with the # one-sided frame above, regardless of oppositeDirection. direction = -1 if reverse else 1 reverse_depth = float(params["reverse_distance_mm"]) feature_frames[item.feature_id] = { "start": _oriented_plane(plane, -direction, -direction * reverse_depth), "end": _oriented_plane(plane, direction, direction * depth), "profile": dict(plane), "profile_source": 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), "profile_source": source, } 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: # This lowering-only provenance permits an exact source # endpoint pair for a two-section direct loft. It is not # a replacement for runtime topology history. if cap_face_loft is None: frames["loft_profile_sources"] = sources 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") # An omitted FeatureScript ``targets`` field is the exact # targetless-UNION form. ``_queries(None)`` intentionally # returns one placeholder for callers that need a diagnostic, # so it cannot be used to decide whether this field exists. has_targets = p.get("targets") is not None targets, target_instance_refs = ( _boolean_body_references( p.get("targets"), previous, feature_by_id, body_transform_aliases, ) if has_targets else ([], []) ) tools, tool_instance_refs = _boolean_body_references( p.get("tools"), previous, feature_by_id, body_transform_aliases, ) if not has_targets: # FeatureScript UNION permits one tools set without a # separate target. A union is commutative, so selecting # one exact member as the CDSL target preserves geometry # only when tools are not retained. keepTools would need # an explicit all-tools retention contract. if operation != "UNION" or _bool(p.get("keepTools")): raise UnsupportedCapability( "boolean_bodies_targets", "targetless booleanBodies is currently supported only for UNION with keepTools:false", ) selections = [("feature", value) for value in tools] + [("pattern", value) for value in tool_instance_refs] if len(selections) < 2: raise ValueError("targetless booleanBodies union requires at least two explicit bodies") kind, selected = selections.pop(0) if kind == "feature": targets, target_instance_refs = [selected], [] tools = [value for source_kind, value in selections if source_kind == "feature"] tool_instance_refs = [value for source_kind, value in selections if source_kind == "pattern"] else: targets, target_instance_refs = [], [selected] tools = [value for source_kind, value in selections if source_kind == "feature"] tool_instance_refs = [value for source_kind, value in selections if source_kind == "pattern"] target_keys = {("feature", source) for source in targets} | { ("pattern", reference["pattern_feature_id"], reference["source_feature_id"], reference["instance_index"]) for reference in target_instance_refs } tool_keys = {("feature", source) for source in tools} | { ("pattern", reference["pattern_feature_id"], reference["source_feature_id"], reference["instance_index"]) for reference in tool_instance_refs } if target_keys & tool_keys: 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)) pattern_dependencies = list(dict.fromkeys([ *(reference["pattern_feature_id"] for reference in target_instance_refs), *(reference["pattern_feature_id"] for reference in tool_instance_refs), ])) feature = { "id": fid, "name": item.feature_id, "atomic_id": "boolean_bodies", "depends_on": list(dict.fromkeys(targets + tools + pattern_dependencies + depends)), "params": { "operation": operation_map[operation], "keep_tools": _bool(p.get("keepTools")), }, "execution_status": "supported", } if targets: feature["params"]["target_feature_ids"] = targets if tools: feature["params"]["tool_feature_ids"] = tools if target_instance_refs: feature["params"]["target_pattern_instance_refs"] = target_instance_refs if tool_instance_refs: feature["params"]["tool_pattern_instance_refs"] = tool_instance_refs 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: frame = {"revolve_axis": {"origin_mm": start, "direction": direction}, "revolve_full": True} # A SWEPT_EDGE circle is only lowerable when both profile # and axis are still direct source-sketch entities. Keep # this lowering-only provenance out of the public CDSL. axis_direct = ( axis_q.source_sketch == source and axis_q.source_entity is not None and _source_ref_entity(source, axis_q.source_entity, entity_by_sketch) is not None ) if ( atomic == "revolve_add" and params.get("result_mode") == "new_body" and _profile_matches_direct_source(profile_sketch, sketch_by_source[source]) and axis_direct ): frame["profile_source"] = source feature_frames[item.feature_id] = frame 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 query.topology_type == "SWEPT_EDGE": geometry = _swept_edge_line_selector_geometry( owner, refs, frame or {}, feature_by_id, sketch_by_source, entity_by_sketch, ) if geometry is None: geometry = _swept_edge_revolve_circle_selector_geometry( owner, refs, frame or {}, feature_by_id, sketch_by_source, entity_by_sketch, ) if geometry is None: raise ValueError("swept edge source endpoint provenance is unsupported") elif selector_kind == "edge" and source_entity and cap: if 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": thickness = _number(p.get("thickness"), True) selectors = []; offset_edge_planes: dict[str, dict[str, Any]] = {} cap_removals: list[tuple[str, str]] = [] 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) if query.owner_feature and query.is_start is not None: cap_removals.append((query.owner_feature, "start" if query.is_start else "end")) 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 == "SWEPT_FACE": selector = _direct_linear_extrude_swept_face_shell_reference( query_value, feature_frames, sketch_by_source, sketches_by_id, entity_by_sketch, feature_by_id, previous, ) elif topology == "OFFSET_FACE": selector = _shell_offset_face_output_role_selector( query_value, feature_by_id, sketches_by_id, previous, ) 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, direct linear-extrude SWEPT_FACE, or COPY(CAP_FACE) removal selectors") # Feature-output roles resolve only through the active # kernel snapshot. A stable id would turn that semantic # evidence into a stale geometric selector. if selector.get("output_role") is None: selector["stable_id"] = f"cadfs_{fid}_{index}" selectors.append(selector) if not selectors: raise ValueError("shell has no face removal selector") # CADFS's oppositeDirection selects the exterior material # side. The runtime contract carries this directly to OCC's # signed offset; it is not a request to reverse removal-face # ownership or a candidate for a current-body fallback. shell_params = {"thickness_mm": thickness, "inward": not _bool(p.get("oppositeDirection"))} if p.get("parts") is not None: try: shell_params["target_feature_id"] = _shell_target_body_source( p["parts"], previous, body_transform_aliases, lowered_body_members, ) except (UnsupportedCapability, ValueError) as error: # Keep the established face-scoped execution path for # legacy histories, but make the omitted parts-owner # proof visible instead of silently treating the active # aggregate as an explicitly selected body. diagnostics.append({ "code": "unresolved_body_source", "capability": "shell_parts_body_source", "feature_id": item.feature_id, "operation": item.operation, "message": str(error), }) feature = {"id": fid, "name": item.feature_id, "atomic_id": "shell", "depends_on": depends, "params": shell_params, "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"): shell_frame = { **source_frame, "shell_source": source, "shell_thickness_mm": thickness, "shell_profile_sketch_id": source_feature["sketch_id"], } # A derived inner wall has a bounded span only when # this direct shell removes exactly one known cap of # the same extrusion. Additional removal faces can # change its trim topology, so do not infer a wall. if ( shell_params["inward"] and len(cap_removals) == 1 and cap_removals[0][0] == source ): shell_frame["shell_inward"] = True shell_frame["shell_removed_cap"] = cap_removals[0][1] feature_frames[item.feature_id] = shell_frame 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, body_transform_aliases, ) 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 = _mirror_plane_from_query( plane_query, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, ) 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 == "transform" and feature.get("atomic_id") == "transform_bodies" and not bool(feature["params"].get("make_copy")) and not feature["params"].get("pattern_instance_refs") ): _record_non_copy_body_successors( body_transform_aliases, list(feature["params"].get("source_feature_ids") or ()), fid, ) _record_single_body_successor( body_transform_aliases, single_body_successor_state, feature, ) _record_lowered_body_members(lowered_body_members, feature) 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)