4806 lines
255 KiB
Python
4806 lines
255 KiB
Python
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)
|