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