Files
cdsl-cad/cadfs_to_cdsl/lowering.py
T
likang 994d06aaea feat(selector): 增加离线候选遍历与严格回放验证 Demo
- 新增 selector_candidate_demo,移除 provenance intent 后枚举候选 selector
- 对候选分支执行有界重建与严格 STEP 比较
- 仅在候选遍历完整且唯一 strict 通过时生成 selector 映射记录
- 增加 selector 候选搜索、预算限制和记录生成的测试
- 保持生产 selector resolver 不受 Demo 逻辑影响
- 更新 CADFS 能力台账,记录 IMPRINT 派生 profile 的 lineage selector 缺口
2026-09-10 15:12:57 +08:00

6277 lines
316 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import math
from copy import deepcopy
from dataclasses import dataclass
from typing import Any
from .featurescript_parser import symbolic_string
from .ir import Call, FeatureIR, ModelIR, SketchIR
from .query_parser import parse_query, walk_calls
UNSUPPORTED = {"draft", "thicken", "split", "moveFace", "replaceFace", "deleteFace", "import", "derive"}
PLANES = {
"Top": {"origin_mm": [0., 0., 0.], "x_dir": [1., 0., 0.], "normal": [0., 0., 1.]},
"Front": {"origin_mm": [0., 0., 0.], "x_dir": [1., 0., 0.], "normal": [0., -1., 0.]},
"Right": {"origin_mm": [0., 0., 0.], "x_dir": [0., 1., 0.], "normal": [1., 0., 0.]},
}
# CADFS 未携带标准孔表的实体定义。下列条目由 source STEP 验证:该组合在
# CADFS 的 B-rep 中不是普通 blind counterbore,而是简化为贯穿的攻丝孔。
# 不能为未知目录项推测尺寸;未列入的孔仍按显式 FeatureScript 尺寸 lower。
_STANDARD_TAPPED_THROUGH_BORE_DIAMETERS = {
("ISO", "M10", "Clearance & tapped"): 15.0,
}
@dataclass
class LoweringResult:
cdsl: dict[str, Any] | None
status: str
diagnostics: list[dict[str, Any]]
history: list[dict[str, Any]]
class UnsupportedCapability(ValueError):
def __init__(self, capability: str, message: str):
super().__init__(message); self.capability = capability
class OpenSketchProfileError(ValueError):
pass
def plain(value: Any) -> Any:
if isinstance(value, Call): return {"call": value.name, "args": [plain(arg) for arg in value.args], "line": value.line}
if isinstance(value, list): return [plain(item) for item in value]
if isinstance(value, dict): return {key: plain(item) for key, item in value.items()}
return value
def _selector_intent(
value: Any,
*,
query_family: str,
kind: str,
evidence: str,
allowed: tuple[str, ...] = ("continuation",),
multiplicity: str = "one",
output_role: str | None = None,
disambiguation: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Keep the source query as semantics, separate from runtime topology IDs."""
query = parse_query(value)
intent: dict[str, Any] = {
"version": "1.0",
"kind": kind,
"query_family": query_family,
# Version metadata is populated from the enclosing ModelIR only when
# the exported source actually contains it. ``"0"`` used to look
# like a real FeatureScript version and caused downstream capability
# checks to make an unjustified compatibility decision.
"source_query": {"ast": query.ast},
"derivation_policy": {"allowed": list(allowed), "multiplicity": multiplicity},
"evidence": evidence,
}
if query.source_sketch and query.source_entity:
intent["source_entity"] = {"sketch_id": query.source_sketch, "entity_id": query.source_entity}
if output_role is not None:
intent["output_role"] = output_role
if disambiguation is not None:
intent["disambiguation"] = disambiguation
return intent
def _deferred_featurescript_selector_intent(value: Any, *, kind: str) -> dict[str, Any]:
"""Preserve an unsupported CADFS topology query without authorizing hints.
Geometry remains useful diagnostic context, but it cannot be mistaken for
an implementation of the source query. A non-empty policy keeps the
intent structurally valid while ``none`` makes the resolver reject it
before stable-ID or geometry matching.
"""
query = parse_query(value)
try:
_call, _owner, family, _query_kind, _definition = _direct_make_query(value)
except ValueError:
family = str(query.topology_type or "FEATURESCRIPT_QUERY").rsplit(".", 1)[-1].upper()
if not family or not family.replace("_", "").isalnum() or not family[0].isalpha():
family = "FEATURESCRIPT_QUERY"
return _selector_intent(
value,
query_family=family,
kind=kind,
evidence="feature_script_query",
allowed=("continuation",),
multiplicity="none",
disambiguation={
"type": "deferred_source_query",
"query_combinators": list(query.query_combinators),
"filters": list(query.filters),
"body_scope": list(query.body_scope),
"source_refs": [
{"sketch_id": sketch_id, "entity_id": entity_id}
for sketch_id, entity_id in _source_refs(value)
],
},
)
def _explicit_datum_selector_intent(value: Any, *, kind: str) -> dict[str, Any]:
"""Mark a source datum as an explicit geometric, not lineage, selector."""
return _selector_intent(
value,
query_family="GEOMETRIC",
kind=kind,
evidence="explicit_datum",
)
def _finalize_selector_intents(cdsl: dict[str, Any], model: ModelIR) -> None:
"""Bind source-version metadata for top-level and nested selectors."""
source_version = model.featurescript_version
stack: list[Any] = [cdsl]
while stack:
value = stack.pop()
if isinstance(value, list):
stack.extend(value)
continue
if not isinstance(value, dict):
continue
intent = value.get("selector_intent")
if isinstance(intent, dict):
source_query = intent.get("source_query")
if isinstance(source_query, dict):
if source_version:
source_query["featurescript_version"] = source_version
if model.standard_library:
source_query["standard_library"] = model.standard_library
if model.standard_library_version:
source_query["standard_library_version"] = model.standard_library_version
if model.standard_library_imports:
source_query["standard_library_imports"] = deepcopy(model.standard_library_imports)
stack.extend(value.values())
def _bool(value: Any) -> bool:
return value is True or (isinstance(value, str) and value.lower() == "true")
def _number(value: Any, units: bool = False) -> float:
if isinstance(value, (float, int)): return float(value)
if isinstance(value, str):
constants = {"mm": 1., "millimeter": 1., "cm": 10., "m": 1000., "inch": 25.4, "in": 25.4, "ft": 304.8, "degree": 1.}
if value in constants: return constants[value]
return float(value)
if isinstance(value, Call) and value.name == "__binary__":
left, op, right = value.args; a, b = _number(left, units), _number(right, units)
return {"+": a + b, "-": a - b, "*": a * b, "/": a / b}[str(op)]
if isinstance(value, Call) and value.name == "round" and len(value.args) == 1:
# CADFS sometimes serializes a known pattern count as ``round(8)``.
# An already integral constant is provably unchanged, so it needs no
# FeatureScript rounding-mode assumption. Non-integral calls remain
# unsupported until that language-level semantic is represented.
rounded_input = _number(value.args[0], units)
if math.isfinite(rounded_input) and rounded_input.is_integer():
return rounded_input
raise ValueError(f"not a constant number: {plain(value)!r}")
def _lookup_table_definition(value: Any) -> dict[str, str]:
for call in walk_calls(value):
if call.name != "lookupTablePath" or not call.args or not isinstance(call.args[0], dict): continue
return {str(key): str(item) for key, item in call.args[0].items() if isinstance(item, (str, int, float))}
return {}
def _standard_tapped_through_bore_diameter(params: dict[str, Any], style: str, end_style: str) -> float | None:
if style.upper() not in {"COUNTERBORE", "C_BORE"} or "BLIND" not in end_style or not _bool(params.get("isTappedThrough")):
return None
definition = _lookup_table_definition(params.get("standardBlindInLast"))
return _STANDARD_TAPPED_THROUGH_BORE_DIAMETERS.get((definition.get("standard", ""), definition.get("size", ""), definition.get("type", "")))
def _point(value: Any) -> list[float]:
if isinstance(value, Call) and value.name == "__binary__" and value.args[1] == "*":
scale = _number(value.args[2], True); point = _point(value.args[0]); return [v * scale for v in point]
if isinstance(value, Call) and value.name in {"v", "vector"} and len(value.args) >= 2:
return [_number(value.args[0]), _number(value.args[1])]
if isinstance(value, list) and len(value) >= 2: return [_number(value[0]), _number(value[1])]
raise ValueError(f"not a 2D point: {plain(value)!r}")
def _cross(a: list[float], b: list[float]) -> list[float]:
return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]]
def _dot(a: list[float], b: list[float]) -> float: return sum(left * right for left, right in zip(a, b))
def _unit(value: list[float], message: str) -> list[float]:
length = math.sqrt(_dot(value, value))
if length <= 1e-9: raise ValueError(message)
return [component / length for component in value]
def _sub(a: list[float], b: list[float]) -> list[float]: return [a[index] - b[index] for index in range(3)]
def _rotate(value: list[float], axis: list[float], angle_rad: float) -> list[float]:
axis = _unit(axis, "rotation axis is degenerate")
cosine, sine = math.cos(angle_rad), math.sin(angle_rad)
cross = _cross(axis, value); projection = _dot(axis, value) * (1.0 - cosine)
return [value[index] * cosine + cross[index] * sine + axis[index] * projection for index in range(3)]
def _translate_frame(frame: dict[str, Any], offset: list[float]) -> dict[str, Any]:
return {**frame, "origin_mm": [frame["origin_mm"][index] + offset[index] for index in range(3)]}
def _rotate_point(point: list[float], axis: dict[str, list[float]], angle_rad: float) -> list[float]:
relative = _rotate(_sub(point, axis["origin_mm"]), axis["direction"], angle_rad)
return [axis["origin_mm"][index] + relative[index] for index in range(3)]
def _rotate_frame(frame: dict[str, Any], axis: dict[str, list[float]], angle_rad: float) -> dict[str, Any]:
return {
**frame,
"origin_mm": _rotate_point(frame["origin_mm"], axis, angle_rad),
"x_dir": _rotate(frame["x_dir"], axis["direction"], angle_rad),
"normal": _rotate(frame["normal"], axis["direction"], angle_rad),
}
def _reflect_vector(value: list[float], plane: dict[str, Any]) -> list[float]:
normal = _unit(list(plane["normal"]), "mirror plane normal is degenerate")
amount = 2.0 * _dot(value, normal)
return [value[index] - amount * normal[index] for index in range(3)]
def _reflect_point(point: list[float], plane: dict[str, Any]) -> list[float]:
normal = _unit(list(plane["normal"]), "mirror plane normal is degenerate")
amount = 2.0 * _dot(_sub(point, plane["origin_mm"]), normal)
return [point[index] - amount * normal[index] for index in range(3)]
def _reflect_frame(frame: dict[str, Any], plane: dict[str, Any]) -> dict[str, Any]:
return {
**frame,
"origin_mm": _reflect_point(frame["origin_mm"], plane),
"x_dir": _reflect_vector(frame["x_dir"], plane),
"normal": _reflect_vector(frame["normal"], plane),
}
def _y_dir(plane: dict[str, Any]) -> list[float]: return _cross(plane["normal"], plane["x_dir"])
def _global(plane: dict[str, Any], point: list[float]) -> list[float]:
y = _y_dir(plane); return [plane["origin_mm"][i] + plane["x_dir"][i]*point[0] + y[i]*point[1] for i in range(3)]
def _shift_plane(plane: dict[str, Any], distance: float) -> dict[str, Any]:
return {**plane, "origin_mm": [plane["origin_mm"][i] + plane["normal"][i]*distance for i in range(3)]}
def _oriented_plane(plane: dict[str, Any], normal_sign: float, distance: float = 0.0) -> dict[str, Any]:
shifted = _shift_plane(plane, distance)
# 翻转端盖法向时同步翻转 x 轴,保持 normal × x_dir 的草图局部 y 轴
# 不变。否则同一 CADFS 草图会在附着端盖后被镜像。
return {
**shifted,
"x_dir": [normal_sign * value for value in plane["x_dir"]],
"normal": [normal_sign * value for value in plane["normal"]],
}
def _attachment_plane(plane: dict[str, Any]) -> dict[str, Any]:
"""Choose the global-origin projection as the later sketch attachment origin.
CADFS keeps a plane's physical location and the plane coordinates used by
a later sketch separate. The latter is the global origin projected onto
the physical plane, not necessarily the selected point or profile centre.
Keep this only as an attachment frame; selectors and body topology
continue to use the physical frame.
"""
normal = plane["normal"]
origin = plane["origin_mm"]
distance = _dot(origin, normal)
return {**plane, "origin_mm": [distance * normal[index] for index in range(3)]}
def _loft_cap_frames(start: dict[str, Any], end: dict[str, Any]) -> dict[str, dict[str, Any]] | None:
"""Record the physical outer normals for the first and last loft sections."""
direction = _sub(end["origin_mm"], start["origin_mm"])
if math.sqrt(_dot(direction, direction)) <= 1e-9:
return None
# Loft start/end caps face away from the section sequence. Sketch normals do
# not necessarily have that orientation, so retain the CDSL frame handedness
# while choosing the actual B-rep exterior normal for CAP references.
start_sign = 1.0 if _dot(start["normal"], direction) <= 0 else -1.0
end_sign = 1.0 if _dot(end["normal"], direction) >= 0 else -1.0
return {"start": _oriented_plane(start, start_sign), "end": _oriented_plane(end, end_sign)}
def _frame(origin: list[float], x_dir: list[float], normal: list[float]) -> dict[str, list[float]]:
normal = _unit(normal, "reference plane normal is degenerate")
x_dir = _sub(x_dir, [normal[index] * _dot(x_dir, normal) for index in range(3)])
return {"origin_mm": origin, "x_dir": _unit(x_dir, "reference plane x direction is degenerate"), "normal": normal}
def _pattern_copy_cap_plane(value: Any, feature_frames: dict[str, dict[str, Any]]) -> dict[str, Any] | None:
"""Resolve one mirrored COPY(CAP_FACE) frame without flattening its source query."""
try:
_call, pattern_owner, topology, kind, definition = _direct_make_query(value)
except ValueError:
return None
if topology != "COPY" or kind != "face":
return None
derived = definition.get("derivedFrom")
if derived is None:
return None
try:
_source, source_owner, source_topology, source_kind, source_definition = _direct_make_query(derived)
except ValueError:
return None
if source_topology != "CAP_FACE" or source_kind != "face":
return None
pattern_frame = feature_frames.get(pattern_owner) or {}
transform = pattern_frame.get("copy_transform") or {}
if transform.get("type") != "mirror" or source_owner not in pattern_frame.get("copy_source_features", []):
return None
source_frame = feature_frames.get(source_owner)
if source_frame is None or "start" not in source_frame or "end" not in source_frame:
return None
cap = source_frame["start" if _bool(source_definition.get("isStart")) else "end"]
plane = transform.get("plane")
if not isinstance(plane, dict):
return None
reflected = _reflect_frame(cap, plane)
# 镜像会把 source frame 变成左手系;CDSL workplane 必须始终由
# normal × x_dir 推导局部 y 轴。翻转 x_dir 后,镜像 CAP 上新草图的
# source-local y 方向保持物理不变,FeatureScript 导出的镜像侧 x 坐标
# 也能落回正确的世界位置。
reflected["x_dir"] = [-value for value in reflected["x_dir"]]
return reflected
def _plane_from_query(
value: Any,
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]] | None = None,
entity_by_sketch: dict[str, dict[str, dict[str, Any]]] | None = None,
) -> dict[str, Any]:
for call in walk_calls(value):
if call.name in {"makeId", "qCreatedBy"}:
text = " ".join(symbolic_string(arg) for arg in call.args)
for name, plane in PLANES.items():
if f"{name}.planeOp" in text: return dict(plane)
copied_cap = _pattern_copy_cap_plane(value, feature_frames)
if copied_cap is not None:
return copied_cap
query = parse_query(value)
if query.topology_type == "IMPRINT" and sketch_by_source and query.source_sketch in sketch_by_source:
return dict(sketch_by_source[query.source_sketch]["workplane"])
if query.topology_type == "OFFSET_FACE" and sketch_by_source is not None and entity_by_sketch is not None:
return _offset_face_plane(value, feature_frames, sketch_by_source, entity_by_sketch)
frame = feature_frames.get(query.owner_feature or "")
if frame and query.topology_type == "CAP_FACE":
cap = "start" if query.is_start is not False else "end"
return dict(frame.get(f"{cap}_attachment") or frame[cap])
if frame and frame.get("start") == frame.get("end") and "qCreatedBy" in query.calls:
return dict(frame.get("start_attachment") or frame["start"])
raise ValueError("unsupported or unresolved sketch workplane")
def _bound_name(value: Any) -> str:
return str(value or "BLIND").split(".")[-1].upper()
def _is_new_body_operation(value: str) -> bool:
return value.rsplit(".", 1)[-1] == "NEW"
def _has_active_body(features: list[dict[str, Any]]) -> bool:
"""Whether the lowered history already has a result body to extend."""
return any(feature.get("atomic_id") not in {"reference_plane", "reference_axis"} for feature in features)
def _end_condition(value: Any) -> dict[str, Any]:
name = _bound_name(value)
if name == "BLIND": return {"type": "blind", "solidworks_code": 0}
if name == "SYMMETRIC": return {"type": "mid_plane", "solidworks_code": 8}
if name == "THROUGH_ALL": return {"type": "through_all", "solidworks_code": 1}
if name == "UP_TO_NEXT": return {"type": "through_next", "solidworks_code": 4}
if name == "UP_TO_SURFACE": return {"type": "up_to_surface", "solidworks_code": 2}
if name == "UP_TO_BODY": return {"type": "up_to_body", "solidworks_code": 6}
if name == "UP_TO_VERTEX": return {"type": "up_to_vertex", "solidworks_code": 5}
raise UnsupportedCapability(f"extrude_extent:{name.lower()}", f"current CDSL atomic set has no exact extrusion operation for {name}")
def _extent_reference(
value: Any,
expected_kind: str,
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
*,
feature_by_id: dict[str, dict[str, Any]] | None = None,
sketches_by_id: dict[str, dict[str, Any]] | None = None,
previous: list[str] | None = None,
allow_cap_output_role: bool = False,
allow_direct_prism_swept_lineage: bool = False,
featurescript_version: str | None = None,
) -> 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":
# An up-to-surface reference may consume a direct cap only while its
# producing prism is the active preceding result. A later body
# mutation, composed query, or unsupported producer keeps the source
# query as deferred evidence instead of choosing a similar face.
cap_output_role = (
_cap_face_output_role_selector(value, feature_by_id or {}, sketches_by_id or {})
if feature_by_id is not None and sketches_by_id is not None
else None
)
if (
allow_cap_output_role
and cap_output_role is not None
and previous[-1:] == [cap_output_role["owner_feature_id"]]
):
return cap_output_role
# A direct blind prism side wall is not an output-role shortcut. Its
# source edge and every later continuation must be proven by the
# topology registry. This bounded consumer only emits the executable
# intent for a one-sided extent; all other face queries stay deferred.
if (
allow_direct_prism_swept_lineage
and query.topology_type == "SWEPT_FACE"
and query.owner_feature
and feature_by_id is not None
and sketches_by_id is not None
):
swept_lineage = _direct_prism_swept_selector(
value,
owner=query.owner_feature,
selector_kind="face",
feature_by_id=feature_by_id,
feature_frames=feature_frames,
sketch_by_source=sketch_by_source,
sketches_by_id=sketches_by_id,
entity_by_sketch=entity_by_sketch,
previous=previous or [],
featurescript_version=featurescript_version,
allow_continuation=True,
)
if swept_lineage is not None:
return swept_lineage
if expected_kind == "body":
swept_body = _direct_swept_body_extent_selector(
value,
feature_by_id=feature_by_id or {},
previous=previous or [],
featurescript_version=featurescript_version,
)
if swept_body is not None:
return swept_body
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,
"selector_intent": _deferred_featurescript_selector_intent(value, kind=expected_kind),
}
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 _direct_swept_body_extent_selector(
value: Any,
*,
feature_by_id: dict[str, dict[str, Any]],
previous: list[str],
featurescript_version: str | None,
) -> dict[str, Any] | None:
"""Lower one direct active body query for a one-sided ``up_to_body`` extent.
A single-item ``qUnion`` is the FeatureScript set identity, but it does not
grant general query composition. The producer has to be the immediately
active independent prism, so runtime can prove the selected body from the
body graph without using a stable ID, aggregate body, or geometry score.
"""
if featurescript_version != "1511":
return None
try:
_call, owner, topology, kind, _definition = _direct_make_query(value)
except ValueError:
return None
producer_id = f"f_{owner}"
producer = feature_by_id.get(producer_id) or {}
params = producer.get("params") or {}
if (
topology != "SWEPT_BODY"
or kind not in {"body", "entitytype.body"}
or previous[-1:] != [producer_id]
or producer.get("atomic_id") != "extrude_add_blind"
or params.get("result_mode") != "new_body"
or (params.get("end_condition") or {}).get("type") != "blind"
or params.get("draft") is not None
):
return None
intent = _selector_intent(
value,
query_family="SWEPT_BODY",
kind="body",
evidence="active_body_member",
allowed=("boundary",),
)
intent["body_member_contract"] = "direct_new_body"
return {
"kind": "body",
"owner_feature_id": producer_id,
"source": "runtime_snapshot",
"confidence": 1.0,
"selector_intent": intent,
}
def _face_reference(
value: Any,
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
*,
owner_feature_id: str | None = None,
binding_feature_id: str | None = None,
match_mode: str | None = None,
rotation: tuple[dict[str, list[float]], float] | None = None,
) -> dict[str, Any]:
"""Lower one CAP/SWEPT face query into a bindable CDSL selector."""
_call, owner, topology, kind, _definition = _direct_make_query(value)
if kind != "face" or topology not in {"CAP_FACE", "SWEPT_FACE"}:
raise ValueError("intersection source is not a supported face query")
source = parse_query(value)
selector_owner = owner_feature_id or f"f_{owner}"
reference: dict[str, Any] = {
"kind": "face",
"owner_feature_id": selector_owner,
"stable_id": f"cadfs_{selector_owner}_{topology.lower()}",
"source": "solidworks",
"confidence": 1.0,
}
frame = feature_frames.get(owner)
geometry: dict[str, Any]
point: list[float]
if topology == "CAP_FACE":
if frame is None or "start" not in frame or "end" not in frame:
raise ValueError("cap face source frame is unresolved")
cap = frame["start" if source.is_start else "end"]
point, normal = list(cap["origin_mm"]), list(cap["normal"])
if rotation is not None:
axis, angle = rotation; point, normal = _rotate_point(point, axis, angle), _rotate(normal, axis["direction"], angle)
geometry = {"normal": normal, "plane_offset_mm": _dot(normal, point)}
# 同一 feature 的多个圆形端盖可以共面。仅用平面方程会把它们误判为
# 同一个 selector;保留由 source circle 给出的物理圆心和最小面积,
# 使 pattern COPY(CAP_FACE) 在 prefix B-rep 中仍可唯一绑定。
for sketch_id, entity_id in _source_refs(value):
entity = (entity_by_sketch.get(sketch_id) or {}).get(entity_id)
sketch = sketch_by_source.get(sketch_id)
if entity is None or sketch is None or entity.get("type") != "circle":
continue
center = _global(cap, entity["center"])
if rotation is not None:
axis, angle = rotation; center = _rotate_point(center, axis, angle)
geometry["center_mm"] = center
geometry["minimum_area_mm2"] = math.pi * float(entity["radius_mm"]) ** 2 * 0.5
break
else:
sketch = sketch_by_source.get(source.source_sketch or "")
entity = (entity_by_sketch.get(source.source_sketch or "") or {}).get(source.source_entity or "")
if sketch is None or entity is None:
raise ValueError("swept face source geometry is unresolved")
# 直接 source 草图可能已被后续 transform 烘焙到 feature profile。
# SWEPT_FACE 必须在该实际 profile workplane 上还原,不能回退到变换前
# 的草图坐标系,否则 pattern copy 的平面 selector 会落在错误位置。
plane = (frame or {}).get("profile") or sketch["workplane"]
if entity["type"] == "circle":
point, direction = _global(plane, entity["center"]), list(plane["normal"])
if rotation is not None:
axis, angle = rotation; point, direction = _rotate_point(point, axis, angle), _rotate(direction, axis["direction"], angle)
geometry = {"axis_origin_mm": point, "axis_direction": direction, "radius_mm": entity["radius_mm"]}
elif entity["type"] == "line":
point = _global(plane, entity["start"]); end = _global(plane, entity["end"])
normal = _unit(_cross(_sub(end, point), plane["normal"]), "swept face source line is degenerate")
if rotation is not None:
axis, angle = rotation; point, normal = _rotate_point(point, axis, angle), _rotate(normal, axis["direction"], angle)
geometry = {"normal": normal, "plane_offset_mm": _dot(normal, point)}
if rotation is None and frame and frame.get("start") and frame.get("end"):
span = math.dist(frame["start"]["origin_mm"], frame["end"]["origin_mm"])
length = math.dist(point, end)
if span > 1e-6 and length > 1e-6:
# 后续圆角和布尔可能将一个侧壁分裂成同平面的多个 face。
# 原始 SWEPT_FACE 的母线长度与拉伸跨度给出确定的面积下界,
# 可排除与其共面的微小端盖,而不要求内核保留原始面积。
geometry["minimum_area_mm2"] = length * span * 0.5
else:
raise ValueError("swept face source entity is unsupported")
reference["geometry"] = geometry
reference["selector_intent"] = _deferred_featurescript_selector_intent(value, kind="face")
if binding_feature_id is not None: reference["binding_feature_id"] = binding_feature_id
if match_mode is not None: reference["match_mode"] = match_mode
return reference
def _direct_linear_extrude_swept_face_shell_reference(
value: Any,
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
sketches_by_id: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
feature_by_id: dict[str, dict[str, Any]],
previous: list[str],
featurescript_version: str | None,
) -> dict[str, Any]:
"""Lower one source-proven linear-extrude side wall for a shell removal.
This is deliberately narrower than generic ``SWEPT_FACE`` replay. The
original sketch line defines an exact planar side wall only while its
direct blind/two-sided extrusion is the immediately preceding producer;
boolean, dress-up, copy, and transformed continuations have different
ownership and are left to future topology-history contracts.
"""
_call, owner, topology, kind, _definition = _direct_make_query(value)
producer_id = f"f_{owner}"
producer = feature_by_id.get(producer_id) or {}
frame = feature_frames.get(owner) or {}
params = producer.get("params") or {}
atomic_id = str(producer.get("atomic_id") or "")
supported_atomics = {
"extrude_add_blind", "extrude_add_two_sided",
"extrude_cut_blind", "extrude_cut_two_sided",
}
profile_source = frame.get("profile_source")
source_sketch = sketch_by_source.get(profile_source) if isinstance(profile_source, str) else None
profile_sketch = sketches_by_id.get(str(producer.get("sketch_id") or ""))
if (
topology != "SWEPT_FACE"
or kind not in {"face", "entitytype.face"}
or atomic_id not in supported_atomics
or previous[-1:] != [producer_id]
or not isinstance(frame.get("profile"), dict)
or not isinstance(frame.get("start"), dict)
or not isinstance(frame.get("end"), dict)
or source_sketch is None
or profile_sketch is None
or not _profile_matches_direct_source(profile_sketch, source_sketch)
or (params.get("end_condition") or {}).get("type") != "blind"
or (
atomic_id.endswith("two_sided")
and (params.get("reverse_end_condition") or {}).get("type") != "blind"
)
):
raise UnsupportedCapability(
"shell_face_selector",
"current CDSL shell SWEPT_FACE requires the immediately preceding direct blind/two-sided linear extrusion",
)
query = parse_query(value)
refs = _source_refs(value)
if (
len(refs) != 1
or refs[0][0] != profile_source
or query.source_sketch != profile_source
or query.source_entity != refs[0][1]
):
raise UnsupportedCapability(
"shell_face_selector",
"current CDSL shell SWEPT_FACE requires one direct source-profile line",
)
entity = (entity_by_sketch.get(profile_source) or {}).get(refs[0][1])
if not entity or entity.get("type") != "line" or entity.get("construction"):
raise UnsupportedCapability(
"shell_face_selector",
"current CDSL shell SWEPT_FACE requires one original non-construction source line",
)
lineage_selector = _direct_prism_swept_selector(
value,
owner=owner,
selector_kind="face",
feature_by_id=feature_by_id,
feature_frames=feature_frames,
sketch_by_source=sketch_by_source,
sketches_by_id=sketches_by_id,
entity_by_sketch=entity_by_sketch,
previous=previous,
featurescript_version=featurescript_version,
)
if lineage_selector is not None:
return lineage_selector
return _face_reference(value, feature_frames, sketch_by_source, entity_by_sketch)
def _shell_offset_face_output_role_selector(
value: Any,
feature_by_id: dict[str, dict[str, Any]],
sketches_by_id: dict[str, dict[str, Any]],
previous: list[str],
) -> dict[str, Any]:
"""Lower a shell OFFSET_FACE only through its explicit CAP true dependency.
An OFFSET_FACE is a generated B-rep face, so a bare geometric signature is
not enough: a shell may generate several offset faces. CADFS can retain
the exact semantic source as a nested true-dependency CAP query. Preserve
that relation for the runtime topology registry instead of choosing a
nearby shell face.
"""
_call, owner, topology, kind, _definition = _direct_make_query(value)
owner_feature_id = f"f_{owner}"
producer = feature_by_id.get(owner_feature_id)
if (
topology != "OFFSET_FACE"
or kind not in {"face", "entitytype.face"}
or producer is None
or producer.get("atomic_id") != "shell"
or previous[-1:] != [owner_feature_id]
):
raise UnsupportedCapability(
"shell_offset_face_selector",
"current CDSL shell OFFSET_FACE requires the immediately preceding direct shell owner",
)
# Only a true-dependency disambiguation may establish a source relation.
# ``walk_calls`` is deliberately not used here: arbitrary nested CAP_FACE
# queries can describe ordering or source-profile evidence, but do not
# prove which shell offset face they produced.
source_roles = []
disambiguation = _definition.get("disambiguationData")
for item in disambiguation if isinstance(disambiguation, list) else ():
if (
not isinstance(item, Call)
or item.name not in {"TDD", "trueDependencyDisambiguation"}
or len(item.args) != 1
or not isinstance(item.args[0], list)
):
continue
for candidate in item.args[0]:
try:
cap = _cap_face_output_role_selector(candidate, feature_by_id, sketches_by_id)
except ValueError:
continue
if cap is not None:
source_roles.append(cap)
unique_roles = {
(item["owner_feature_id"], item["output_role"]): item
for item in source_roles
}
if len(unique_roles) != 1:
raise UnsupportedCapability(
"shell_offset_face_selector",
"current CDSL shell OFFSET_FACE requires one direct builder CAP_FACE true dependency",
)
source = next(iter(unique_roles.values()))
return {
"kind": "face",
"owner_feature_id": owner_feature_id,
"output_role": "shell.offset_face",
"output_role_source": {
"owner_feature_id": source["owner_feature_id"],
"output_role": source["output_role"],
},
"source": "runtime_snapshot",
"confidence": 1.0,
"selector_intent": _selector_intent(
value,
query_family="OFFSET_FACE",
kind="face",
evidence="operation_role",
allowed=("boundary", "replacement"),
output_role="shell.offset_face",
disambiguation={"type": "true_dependency", "sources": [
{"owner_feature_id": source["owner_feature_id"], "output_role": source["output_role"]},
]},
),
}
def _pattern_copy_face_reference(
value: Any,
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
feature_by_id: dict[str, dict[str, Any]],
) -> dict[str, Any]:
"""Preserve a circular-pattern COPY face as one specific replay instance."""
_call, pattern_owner, topology, kind, definition = _direct_make_query(value)
if topology != "COPY" or kind != "face": raise ValueError("intersection copy source is unresolved")
derived = definition.get("derivedFrom")
if derived is None: raise ValueError("pattern copy has no derived face")
_source_call, source_owner, _source_topology, source_kind, _source_definition = _direct_make_query(derived)
if source_kind != "face": raise ValueError("pattern copy source is not a face")
pattern_id = f"f_{pattern_owner}"; source_id = f"f_{source_owner}"
pattern = feature_by_id.get(pattern_id)
if pattern is None or pattern.get("atomic_id") != "pattern_circular": raise ValueError("pattern copy owner is not a circular pattern")
if source_id not in (pattern.get("params") or {}).get("source_feature_ids", []):
raise ValueError("pattern copy source feature is not replayed by its owner")
try: instance = int(str(definition.get("instanceName")))
except (TypeError, ValueError) as error: raise ValueError("pattern copy instance is unresolved") from error
axis = (pattern.get("params") or {}).get("axis")
count = int((pattern.get("params") or {}).get("pattern_count") or 0)
if not isinstance(axis, dict) or count < 1 or instance < 0 or instance >= count:
raise ValueError("pattern copy instance transform is unresolved")
angle = math.radians(float((pattern.get("params") or {}).get("sweep_angle_deg") or 360.0) * instance / count)
reference = _face_reference(
derived, feature_frames, sketch_by_source, entity_by_sketch,
owner_feature_id=f"{pattern_id}.c{instance}.{source_id}",
binding_feature_id=pattern_id,
rotation=(axis, angle),
)
reference["owner_match_required"] = True
# The outer COPY query, not its CAP/SWEPT source, defines the semantic
# request for the pattern instance.
reference["selector_intent"] = _deferred_featurescript_selector_intent(value, kind="face")
return reference
def _intersection_vertex_reference(
value: Any,
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
feature_by_id: dict[str, dict[str, Any]],
feature_id: str,
) -> dict[str, Any]:
"""Lower a CADFS INTERSECT vertex without replacing it with a coordinate."""
_call, owner, topology, kind, definition = _direct_make_query(value)
if topology != "INTERSECT" or kind != "vertex": raise ValueError("up-to-vertex target is not an INTERSECT vertex")
derived = definition.get("derivedFrom")
if not isinstance(derived, list) or len(derived) < 2:
raise ValueError("intersection vertex has no complete face provenance")
components = []
for item in derived:
_item_call, item_owner, item_topology, item_kind, _item_definition = _direct_make_query(item)
if item_kind != "face": raise ValueError("intersection vertex source is not a face")
if item_topology == "COPY":
components.append(_pattern_copy_face_reference(item, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id))
elif item_topology == "SWEPT_FACE":
components.append(_face_reference(
item, feature_frames, sketch_by_source, entity_by_sketch,
owner_feature_id=f"f_{item_owner}", binding_feature_id=f"f_{item_owner}", match_mode="all",
))
else:
raise ValueError(f"intersection vertex source topology {item_topology} is unsupported")
return {
"kind": "vertex",
"owner_feature_id": f"f_{owner}",
"stable_id": f"cadfs_f_{feature_id}_intersect_vertex",
"source": "solidworks",
"confidence": 1.0,
"intersection_of": components,
"selector_intent": _deferred_featurescript_selector_intent(value, kind="vertex"),
}
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)
# A line cut by an intervening profile vertex no longer maps one-to-one
# to its FeatureScript source entity. Keep the semantic source anchor
# only when this operation retained the original B-rep edge intact.
preserve_source = len(ordered) == 2 and isinstance(edge.get("source_entity_id"), str)
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],
**({"source_entity_id": edge["source_entity_id"]} if preserve_source else {}),
} 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 = []
def sketch_result(profile: dict[str, Any], *, role: str | None = None) -> dict[str, Any]:
result = {
"id": f"sketch_{sketch.feature_id}",
"name": sketch.feature_id,
"source_sketch_id": sketch.feature_id,
"workplane": plane,
"profile": profile,
}
if role is not None:
result["role"] = role
return result
for entity in sketch.entities:
p = entity.params
if entity.operation == "skPoint":
item = {"type": "point", "point": _point(p["position"])}; entities[entity.feature_id] = item; points.append(item); continue
if entity.operation == "skLineSegment": item = {"type": "line", "start": _point(p["start"]), "end": _point(p["end"])}
elif entity.operation == "skCircle": item = {"type": "circle", "center": _point(p["center"]), "radius_mm": _number(p["radius"], True)}
elif entity.operation == "skEllipse":
major_axis = _unit(_point(p["majorAxis"]), "ellipse major axis is degenerate")
item = {"type": "ellipse", "center": _point(p["center"]), "major_radius_mm": _number(p["majorRadius"], True), "minor_radius_mm": _number(p["minorRadius"], True), "major_axis": major_axis}
elif entity.operation == "skArc": item = _arc(_point(p["start"]), _point(p["mid"]), _point(p["end"]))
elif entity.operation == "skFitSpline":
spline_points = [_point(point) for point in p.get("points") or []]
if len(spline_points) < 2: raise ValueError("fit spline needs at least 2 points")
start_derivative = p.get("startDerivative")
end_derivative = p.get("endDerivative")
if len(spline_points) == 2:
if _same_point(spline_points[0], spline_points[1]):
raise ValueError("two-point fit spline endpoints must be distinct")
if start_derivative is None or end_derivative is None:
raise ValueError("two-point fit spline requires both endpoint derivatives")
# FeatureScript 的 skFitSpline 以根号弦长参数化。参数域既决定
# 插值曲线,也决定端点导数的长度语义;半边遍历反转轮廓时会将
# 它按反向参数域同步变换,不能交给 OCC 默认重新计算。
item = {
"type": "bspline", "start": spline_points[0], "end": spline_points[-1],
"points": spline_points, "parameterization": "centripetal",
"parameters": _centripetal_parameters(spline_points),
}
# skFitSpline uses a periodic curve when the author closes it by
# repeating the first interpolation point. Passing that repeated
# point into a non-periodic interpolator changes the profile area
# and produces a seam that does not exist in CADFS.
if _same_point(spline_points[0], spline_points[-1]):
item["periodic"] = True
if start_derivative is not None: item["start_tangent"] = _point(start_derivative)
if end_derivative is not None: item["end_tangent"] = _point(end_derivative)
else: unsupported.append(entity.operation); continue
if _bool(p.get("construction")):
# Keep construction provenance available to topology-query lowering,
# but never let it participate in a planar IMPRINT arrangement.
# ``construction`` is runtime-only mapping metadata and must not
# leak into the public analytic-segment schema.
entities[entity.feature_id] = {**item, "construction": True}
explicit_construction.append(item)
else:
# This is source construction provenance, not a geometric
# selector hint. Derived or split segments deliberately do not
# inherit this identity later in lowering.
item["source_entity_id"] = entity.feature_id
entities[entity.feature_id] = item
segments.append(item)
if unsupported: raise ValueError("unsupported sketch entities: " + ",".join(sorted(set(unsupported))))
_recover_imperial_grid(segments + explicit_construction, points)
if not segments:
profile: dict[str, Any] = {"type": "analytic_contours", "contours": []}
if explicit_construction: profile["construction"] = explicit_construction
return sketch_result(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"],
"source_entity_id": segments[0]["source_entity_id"],
}
else:
contours, open_segments = _contours(segments)
if open_segments:
# qSketchRegion 只会选取闭合区域。与之无关的开链必须保留为
# reference geometry,不能因为它们存在就丢弃同一草图中的合法
# 区域;若草图没有任何闭合区域,则仍按原有规则拒绝实体 profile。
if not contours:
if not allow_open: raise OpenSketchProfileError(f"sketch has {len(open_segments)} open non-construction segment(s)")
profile = {"type": "analytic_contours", "contours": [], "construction": explicit_construction + open_segments}
return sketch_result(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 sketch_result(profile, role="reference"), entities
profile = {"type": "analytic_contours", "contours": contours}
if construction: profile["construction"] = construction
return sketch_result(profile), entities
def _queries(value: Any) -> list[Any]:
if isinstance(value, Call) and value.name == "qUnion" and value.args and isinstance(value.args[0], list): return value.args[0]
return [value]
def _source_refs(value: Any) -> list[tuple[str, str]]:
refs = []
for call in walk_calls(value):
if call.name in {"sQuery", "sketchEntityQuery"} and len(call.args) >= 3:
refs.append((symbolic_string(call.args[0]).split(".", 1)[0], str(call.args[2])))
return refs
def _source_ref_entity(
sketch_id: str,
token: str,
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
) -> tuple[str, dict[str, Any]] | None:
"""Resolve an original sketch entity without accepting derived-name aliases."""
entities = entity_by_sketch.get(sketch_id) or {}
if token in entities:
return token, entities[token]
entity_id = max((key for key in entities if token.startswith(key + ".")), key=len, default="")
if not entity_id:
return None
suffix = token[len(entity_id) + 1:]
# A named source endpoint is still direct provenance. Other suffixes
# (trim offspring, mirrored construction, generated fillet arcs, ...) do
# not identify one source-local endpoint and must not be guessed.
if suffix not in {"start", "end"}:
return None
return entity_id, entities[entity_id]
def _source_ref_endpoint_points(entity_id: str, token: str, entity: dict[str, Any]) -> list[list[float]]:
"""Return only endpoint coordinates that FeatureScript explicitly exposes."""
if entity.get("type") == "point" and token == entity_id:
point = entity.get("point")
return [point] if isinstance(point, list) and len(point) == 2 else []
start, end = entity.get("start"), entity.get("end")
if not all(isinstance(point, list) and len(point) == 2 for point in (start, end)):
return []
if token == entity_id:
return [start, end]
suffix = token[len(entity_id) + 1:]
return [start] if suffix == "start" else [end] if suffix == "end" else []
def _shared_source_endpoint(
refs: list[tuple[str, str]],
sketch_id: str,
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
) -> list[float] | None:
"""Find the one explicit endpoint shared by a set of source curves.
``SWEPT_EDGE`` provenance from CADFS uses an original-set disambiguation
containing the source curves incident at a profile vertex. A single curve
has two possible ends, so it cannot identify a swept edge by itself. This
routine deliberately accepts only one shared endpoint from distinct direct
source entities, with the direct ``skPoint`` case retained for point-based
provenance.
"""
selected: dict[str, tuple[str, dict[str, Any]]] = {}
for source, token in refs:
if source != sketch_id:
continue
resolved = _source_ref_entity(source, token, entity_by_sketch)
if resolved is None:
return None
entity_id, entity = resolved
selected.setdefault(entity_id, (token, entity))
if not selected:
return None
if len(selected) == 1:
entity_id, (token, entity) = next(iter(selected.items()))
if entity.get("type") == "point":
points = _source_ref_endpoint_points(entity_id, token, entity)
return points[0] if len(points) == 1 else None
return None
endpoints: list[tuple[str, list[float]]] = []
for entity_id, (token, entity) in selected.items():
points = _source_ref_endpoint_points(entity_id, token, entity)
if not points:
return None
endpoints.extend((entity_id, point) for point in points)
candidates: list[list[float]] = []
for _entity_id, point in endpoints:
incident = {
candidate_id
for candidate_id, candidate_point in endpoints
if math.dist(point, candidate_point) <= 1e-5
}
if len(incident) < 2 or any(math.dist(point, candidate) <= 1e-5 for candidate in candidates):
continue
candidates.append(point)
return candidates[0] if len(candidates) == 1 else None
def _endpoint_bbox(start: list[float], end: list[float], *, known_line: bool) -> dict[str, Any] | None:
if math.dist(start, end) <= 1e-8:
return None
geometry = {"bbox_mm": [min(start[i], end[i]) for i in range(3)] + [max(start[i], end[i]) for i in range(3)]}
if known_line:
geometry["curve_type"] = "line"
return geometry
def _swept_edge_line_selector_geometry(
owner: str,
refs: list[tuple[str, str]],
frame: dict[str, Any],
feature_by_id: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
) -> dict[str, Any] | None:
"""Lower a directly proven ``SWEPT_EDGE`` into an endpoint-bbox selector.
This is intentionally not a general topology replay. It covers an edge
created by a translational extrude from one profile vertex, or by a direct
two-section loft whose source query identifies one profile vertex on each
section. Both constructions provide exact source-local endpoints. A
sweep, revolve, multi-section loft, generated/trimmed source, or any
ambiguous original-set remains unsupported rather than selecting a nearby
B-rep edge.
"""
producer = feature_by_id.get(f"f_{owner}") or {}
atomic_id = str(producer.get("atomic_id") or "")
source_ids = {source for source, _token in refs}
if atomic_id in {"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided"}:
profile_source = frame.get("profile_source")
profile = frame.get("profile")
if (
not isinstance(profile_source, str)
or source_ids != {profile_source}
or not isinstance(profile, dict)
or not isinstance(frame.get("start"), dict)
or not isinstance(frame.get("end"), dict)
):
return None
local = _shared_source_endpoint(refs, profile_source, entity_by_sketch)
if local is None:
return None
base = _global(profile, local)
start = [base[index] + frame["start"]["origin_mm"][index] - profile["origin_mm"][index] for index in range(3)]
end = [base[index] + frame["end"]["origin_mm"][index] - profile["origin_mm"][index] for index in range(3)]
return _endpoint_bbox(start, end, known_line=True)
if atomic_id != "loft_add":
return None
profile_sources = frame.get("loft_profile_sources")
if not isinstance(profile_sources, list) or len(profile_sources) != 2 or len(set(profile_sources)) != 2:
return None
if source_ids != set(profile_sources):
return None
points = [
_shared_source_endpoint(refs, source, entity_by_sketch)
for source in profile_sources
]
if any(point is None or source not in sketch_by_source for point, source in zip(points, profile_sources)):
return None
start = _global(sketch_by_source[profile_sources[0]]["workplane"], points[0])
end = _global(sketch_by_source[profile_sources[1]]["workplane"], points[1])
# A ThruSections loft may represent this side edge as a B-spline even
# though its endpoint correspondence is exact. Do not claim a line type.
return _endpoint_bbox(start, end, known_line=False)
def _swept_edge_revolve_circle_selector_geometry(
owner: str,
refs: list[tuple[str, str]],
frame: dict[str, Any],
feature_by_id: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
) -> dict[str, Any] | None:
"""Lower one direct full-revolve profile vertex to a circular edge signature.
This deliberately accepts only an independent full solid revolve from its
original sketch and original in-sketch axis. Its source query must prove
exactly one profile endpoint, and that endpoint must have positive radial
distance to the explicit axis. Partial/surface/additive revolutions and
generated or transformed profile provenance have different topology and
remain unsupported instead of being geometrically guessed.
"""
producer = feature_by_id.get(f"f_{owner}") or {}
if (
producer.get("atomic_id") != "revolve_add"
or (producer.get("params") or {}).get("result_mode") != "new_body"
or not frame.get("revolve_full")
):
return None
profile_source = frame.get("profile_source")
axis = frame.get("revolve_axis")
if (
not isinstance(profile_source, str)
or {source for source, _token in refs} != {profile_source}
or profile_source not in sketch_by_source
or not isinstance(axis, dict)
):
return None
local = _shared_source_endpoint(refs, profile_source, entity_by_sketch)
if local is None:
return None
try:
origin = [float(value) for value in axis["origin_mm"]]
direction = [float(value) for value in axis["direction"]]
except (KeyError, TypeError, ValueError):
return None
if len(origin) != 3 or len(direction) != 3 or not all(math.isfinite(value) for value in origin + direction):
return None
direction_length = math.sqrt(sum(value * value for value in direction))
if direction_length <= 1e-9:
return None
direction = [value / direction_length for value in direction]
point = _global(sketch_by_source[profile_source]["workplane"], local)
projection = sum((point[index] - origin[index]) * direction[index] for index in range(3))
center = [origin[index] + projection * direction[index] for index in range(3)]
radius = math.dist(point, center)
if not math.isfinite(radius) or radius <= 1e-8:
return None
return {"curve_type": "circle", "circle_center_mm": center, "radius_mm": radius}
def _profile_matches_direct_source(selected: dict[str, Any], source: dict[str, Any]) -> bool:
"""Prove that a materialized profile selection changed no source geometry.
``IMPRINT`` is sometimes only the FeatureScript representation of selecting
the one existing sketch region. It may also select a proper subset. The
latter must not inherit a source-profile SWEPT_EDGE contract, so accept the
former only when its physical workplane and complete profile are exactly
the original lowered mappings.
"""
return (
selected.get("workplane") == source.get("workplane")
and selected.get("profile") == source.get("profile")
)
def _direct_profile_source_entity_ids(profile_sketch: dict[str, Any]) -> set[str]:
"""Return only source entities still represented by one direct profile edge.
This follows the explicit source labels carried by the CDSL profile. A
source entity dropped by region selection or split during contour
preparation is intentionally absent instead of being recovered by shape
comparison.
"""
profile = profile_sketch.get("profile") or {}
source_ids: set[str] = set()
direct_circle = profile.get("source_entity_id") if profile.get("type") == "circle" else None
if isinstance(direct_circle, str) and direct_circle:
source_ids.add(direct_circle)
for contour in profile.get("contours") or ():
if not isinstance(contour, dict):
continue
for segment in contour.get("segments") or ():
source_entity_id = segment.get("source_entity_id") if isinstance(segment, dict) else None
if isinstance(source_entity_id, str) and source_entity_id:
source_ids.add(source_entity_id)
return source_ids
def _direct_prism_swept_selector(
value: Any,
*,
owner: str,
selector_kind: str,
feature_by_id: dict[str, dict[str, Any]],
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
sketches_by_id: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
previous: list[str],
featurescript_version: str | None,
allow_continuation: bool = False,
) -> dict[str, Any] | None:
"""Lower one direct source-profile prism query to kernel lineage intent.
This is deliberately narrower than generic SWEPT support. By default the
producer must be the immediately preceding independent blind prism. A
specifically enabled consumer may opt into proven continuation: it still
requires the complete direct source sketch, and the runtime must prove
every boundary/continuation edge to the active body. Any
IMPRINT-derived subset, split source edge, draft, fused result, or other
generator keeps the existing bounded diagnostic path.
"""
query = parse_query(value)
topology = query.topology_type
if topology not in {"CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE"}:
return None
if featurescript_version != "1511":
return None
producer_id = f"f_{owner}"
producer = feature_by_id.get(producer_id) or {}
params = producer.get("params") or {}
frame = feature_frames.get(owner) or {}
profile_source = frame.get("profile_source")
profile_sketch = sketches_by_id.get(str(producer.get("sketch_id") or ""))
source_sketch = sketch_by_source.get(profile_source) if isinstance(profile_source, str) else None
immediate_producer = previous[-1:] == [producer_id]
if (
producer.get("atomic_id") not in {"extrude_add_blind", "extrude_cut_blind"}
or (not immediate_producer and (not allow_continuation or producer_id not in previous))
or params.get("result_mode") != "new_body"
or (params.get("end_condition") or {}).get("type") != "blind"
or params.get("draft") is not None
or not isinstance(profile_source, str)
or source_sketch is None
or profile_sketch is None
or profile_sketch.get("source_sketch_id") != profile_source
or not _profile_matches_direct_source(profile_sketch, source_sketch)
):
return None
source_ids = _direct_profile_source_entity_ids(profile_sketch)
refs = _source_refs(value)
if topology == "CAP_EDGE":
if (
selector_kind != "edge"
or query.is_start is None
or len(refs) != 1
or refs[0][0] != profile_source
or query.source_sketch != profile_source
or query.source_entity != refs[0][1]
or refs[0][1] not in source_ids
):
return None
entity = (entity_by_sketch.get(profile_source) or {}).get(refs[0][1])
if not entity or entity.get("construction"):
return None
intent = _selector_intent(
value,
query_family="CAP_EDGE",
kind="edge",
evidence="kernel_history",
allowed=("boundary", "continuation") if allow_continuation else ("boundary",),
)
intent["lineage_role"] = f"extrude.{'start' if query.is_start else 'end'}"
return {
"kind": "edge",
"owner_feature_id": producer_id,
"source": "runtime_snapshot",
"confidence": 1.0,
"selector_intent": intent,
}
if topology == "SWEPT_FACE":
if (
selector_kind != "face"
or len(refs) != 1
or refs[0][0] != profile_source
or query.source_sketch != profile_source
or query.source_entity != refs[0][1]
or refs[0][1] not in source_ids
):
return None
entity = (entity_by_sketch.get(profile_source) or {}).get(refs[0][1])
if not entity or entity.get("construction"):
return None
return {
"kind": "face",
"owner_feature_id": producer_id,
"source": "runtime_snapshot",
"confidence": 1.0,
"selector_intent": _selector_intent(
value,
query_family="SWEPT_FACE",
kind="face",
evidence="kernel_history",
allowed=("boundary", "continuation") if allow_continuation else ("boundary",),
),
}
if selector_kind != "edge" or len(refs) < 2 or {source for source, _token in refs} != {profile_source}:
return None
resolved_ids: list[str] = []
for source, token in refs:
resolved = _source_ref_entity(source, token, entity_by_sketch)
if resolved is None:
return None
entity_id, entity = resolved
if entity.get("construction") or entity_id not in source_ids:
return None
if entity_id not in resolved_ids:
resolved_ids.append(entity_id)
if len(resolved_ids) < 2 or _shared_source_endpoint(refs, profile_source, entity_by_sketch) is None:
return None
intent = _selector_intent(
value,
query_family="SWEPT_EDGE",
kind="edge",
evidence="kernel_history",
allowed=("boundary",),
)
intent.pop("source_entity", None)
intent["source_entities"] = [
{"sketch_id": profile_source, "entity_id": entity_id}
for entity_id in sorted(resolved_ids)
]
return {
"kind": "edge",
"owner_feature_id": producer_id,
"source": "runtime_snapshot",
"confidence": 1.0,
"selector_intent": intent,
}
def _intersection_source_face_descriptor(
value: Any,
*,
allowed_input_features: set[str],
primary_tool_owner: str | None = None,
feature_by_id: dict[str, dict[str, Any]],
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
sketches_by_id: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
featurescript_version: str | None,
) -> dict[str, Any] | None:
"""Describe one exact boolean-input face without lowering a generic query.
This is intentionally narrower than the standalone CAP/SWEPT selector
paths. The source must be an independently retained direct prism, or the
one transient direct prism built by the enclosing primary REMOVE. It
records the semantic anchor used to locate the *input* face; section-edge
selection is deferred to runtime builder history.
"""
if featurescript_version != "1511":
return None
try:
_call, owner, topology, kind, _definition = _direct_make_query(value)
except ValueError:
return None
owner_feature_id = f"f_{owner}"
if kind != "face" or owner_feature_id not in allowed_input_features:
return None
producer = feature_by_id.get(owner_feature_id) or {}
params = producer.get("params") or {}
is_primary_tool = owner_feature_id == primary_tool_owner
if (
producer.get("atomic_id") != ("extrude_cut_blind" if is_primary_tool else "extrude_add_blind")
or (params.get("result_mode") is not None if is_primary_tool else params.get("result_mode") != "new_body")
or (params.get("end_condition") or {}).get("type") != "blind"
or params.get("draft") is not None
):
return None
if topology == "CAP_FACE":
query = parse_query(value)
if query.is_start is None:
return None
return {
"query_family": "CAP_FACE",
"owner_feature_id": owner_feature_id,
"output_role": f"extrude.{'start' if query.is_start else 'end'}",
}
if topology != "SWEPT_FACE":
return None
frame = feature_frames.get(owner) or {}
profile_source = frame.get("profile_source")
profile_sketch = sketches_by_id.get(str(producer.get("sketch_id") or ""))
source_sketch = sketch_by_source.get(profile_source) if isinstance(profile_source, str) else None
refs = _source_refs(value)
if (
not isinstance(profile_source, str)
or source_sketch is None
or profile_sketch is None
or profile_sketch.get("source_sketch_id") != profile_source
or not _profile_matches_direct_source(profile_sketch, source_sketch)
or len(refs) != 1
or refs[0][0] != profile_source
or parse_query(value).source_sketch != profile_source
or parse_query(value).source_entity != refs[0][1]
or refs[0][1] not in _direct_profile_source_entity_ids(profile_sketch)
):
return None
entity = (entity_by_sketch.get(profile_source) or {}).get(refs[0][1])
if not entity or entity.get("construction"):
return None
return {
"query_family": "SWEPT_FACE",
"owner_feature_id": owner_feature_id,
"source_entity": {"sketch_id": profile_source, "entity_id": refs[0][1]},
}
def _direct_boolean_intersection_selector(
value: Any,
*,
feature_by_id: dict[str, dict[str, Any]],
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
sketches_by_id: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
featurescript_version: str | None,
) -> dict[str, Any] | None:
"""Lower one source-qualified `INTERSECT EDGE` boolean query.
The operation accepts exactly two direct prism source faces from the
explicitly named target/tool members of a single `boolean_bodies` node.
COPY, transformed, patterned, TDD/TD/IMPRINT-derived and primary-tool
booleans intentionally retain deferred source intent.
"""
if featurescript_version != "1511":
return None
try:
_call, owner, topology, kind, definition = _direct_make_query(value)
except ValueError:
return None
if topology != "INTERSECT" or kind != "edge":
return None
boolean_id = f"f_{owner}"
boolean = feature_by_id.get(boolean_id) or {}
params = boolean.get("params") or {}
target_ids = params.get("target_feature_ids")
tool_ids = params.get("tool_feature_ids")
if (
boolean.get("atomic_id") != "boolean_bodies"
or params.get("operation") not in {"subtract", "intersect"}
or bool(params.get("keep_tools"))
or not isinstance(target_ids, list)
or not isinstance(tool_ids, list)
or len(target_ids) != 1
or len(tool_ids) != 1
):
return None
derived = definition.get("derivedFrom")
if not isinstance(derived, list) or len(derived) != 2:
return None
allowed_inputs = {str(target_ids[0]), str(tool_ids[0])}
sources = [
_intersection_source_face_descriptor(
item,
allowed_input_features=allowed_inputs,
feature_by_id=feature_by_id,
feature_frames=feature_frames,
sketch_by_source=sketch_by_source,
sketches_by_id=sketches_by_id,
entity_by_sketch=entity_by_sketch,
featurescript_version=featurescript_version,
)
for item in derived
]
if any(source is None for source in sources):
return None
owners = {str(source["owner_feature_id"]) for source in sources if source is not None}
if owners != allowed_inputs:
return None
return {
"kind": "edge",
"owner_feature_id": boolean_id,
"source": "runtime_snapshot",
"confidence": 1.0,
"selector_intent": _selector_intent(
value,
query_family="INTERSECT",
kind="edge",
evidence="kernel_history",
allowed=("intersection",),
) | {
"intersection_sources": sources,
"disambiguation": {"type": "source_qualified_boolean_section"},
},
}
def _direct_primary_cut_intersection_selector(
value: Any,
*,
feature_by_id: dict[str, dict[str, Any]],
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
sketches_by_id: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
previous: list[str],
featurescript_version: str | None,
) -> dict[str, Any] | None:
"""Lower one unique source-qualified section from a primary REMOVE.
The primary cut's tool is not a CDSL body member. Runtime creates a
transient direct-prism snapshot only for this exact source pair, then
binds the final section edge through the cut builder. Any FeatureScript
disambiguation encoding remains deferred until its API semantics are
independently verified; OCC section-list position is not a substitute.
"""
if featurescript_version != "1511":
return None
try:
_call, owner, topology, kind, definition = _direct_make_query(value)
except ValueError:
return None
if (
topology != "INTERSECT"
or kind != "edge"
or definition.get("disambiguationData") not in (None, [])
):
return None
primary_id = f"f_{owner}"
primary = feature_by_id.get(primary_id) or {}
primary_params = primary.get("params") or {}
if (
primary.get("atomic_id") != "extrude_cut_blind"
or primary_params.get("result_mode") is not None
or (primary_params.get("end_condition") or {}).get("type") != "blind"
or primary_params.get("draft") is not None
):
return None
derived = definition.get("derivedFrom")
if not isinstance(derived, list) or len(derived) != 2:
return None
try:
input_owners = [f"f_{_direct_make_query(item)[1]}" for item in derived]
except ValueError:
return None
if input_owners.count(primary_id) != 1 or len(set(input_owners)) != 2:
return None
target_id = next(item for item in input_owners if item != primary_id)
# At the primary cut, one immediately preceding body is both the runtime
# target and the only allowed source-qualified target producer.
if previous[-2:] != [target_id, primary_id]:
return None
sources = [
_intersection_source_face_descriptor(
item,
allowed_input_features={target_id, primary_id},
primary_tool_owner=primary_id,
feature_by_id=feature_by_id,
feature_frames=feature_frames,
sketch_by_source=sketch_by_source,
sketches_by_id=sketches_by_id,
entity_by_sketch=entity_by_sketch,
featurescript_version=featurescript_version,
)
for item in derived
]
if any(source is None for source in sources):
return None
return {
"kind": "edge",
"owner_feature_id": primary_id,
"source": "runtime_snapshot",
"confidence": 1.0,
"selector_intent": _selector_intent(
value,
query_family="INTERSECT",
kind="edge",
evidence="kernel_history",
allowed=("intersection",),
) | {
"intersection_sources": sources,
"disambiguation": {"type": "source_qualified_primary_section"},
},
}
def _uses_planar_imprint_profile(
owner: str,
feature_by_id: dict[str, dict[str, Any]],
sketches_by_id: dict[str, dict[str, Any]],
) -> bool:
"""Return whether a producer's profile was selected by B-rep IMPRINT."""
feature = feature_by_id.get(f"f_{owner}") or {}
sketch = sketches_by_id.get(str(feature.get("sketch_id") or "")) or {}
return (sketch.get("profile") or {}).get("type") == "planar_imprint"
def _source_sketch(params: dict[str, Any]) -> str | None:
for key in ("entities", "sheetProfilesArray", "surfaceEntities"):
if key in params:
query = parse_query(params[key])
if query.source_sketch: return query.source_sketch
return None
def _sketch_region_query(value: Any) -> Any | None:
"""Return one explicit qSketchRegion when a profile query also carries context faces."""
matches = [
item for item in _queries(value)
if any(call.name == "qSketchRegion" for call in walk_calls(item))
]
return matches[0] if len(matches) == 1 else None
def _imprint_sketch(value: Any) -> str | None:
for call in walk_calls(value):
if call.name != "makeQuery" or not call.args: continue
owner = symbolic_string(call.args[0])
if owner.endswith(".imprint"): return owner.split(".", 1)[0]
return None
def _profile_selection_side(value: Any) -> float | None:
def numbers(item: Any):
if isinstance(item, (int, float)): yield float(item)
elif isinstance(item, list):
for child in item: yield from numbers(child)
elif isinstance(item, dict):
for child in item.values(): yield from numbers(child)
for call in walk_calls(value):
if call.name in {"TD", "topologyDisambiguation"}:
return next(numbers(call.args), None)
return None
def _profile_selection_sides(value: Any) -> list[float]:
"""Return every explicit topology side from one nested IMPRINT query."""
def numbers(item: Any):
if isinstance(item, (int, float)): yield float(item)
elif isinstance(item, list):
for child in item: yield from numbers(child)
elif isinstance(item, dict):
for child in item.values(): yield from numbers(child)
sides = []
for call in walk_calls(value):
if call.name in {"TD", "topologyDisambiguation"}:
side = next(numbers(call.args), None)
if side is not None: sides.append(side)
return sides
def _partitioned_imprint_sketch(
value: Any,
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
) -> str | None:
"""Resolve a two-sided split IMPRINT union back to its bounded sketch region.
A closed sketch curve that intersects a parent-face boundary is split into
two IMPRINT regions. CADFS records the two halves as a qUnion, with the
same outer face side and opposite sides around the intersection vertex.
The union is exactly the curve's bounded sketch region. Do not accept a
partial union, a mixed source curve, or an open curve here: each case has
different material semantics and must remain an explicit capability gap.
"""
roots = _queries(value)
if len(roots) != 2:
return None
source_sketch: str | None = None
source_entity: str | None = None
nested_sides: set[float] = set()
for root in roots:
try:
_call, owner, topology, kind, _definition = _direct_make_query(root)
except ValueError:
return None
info = parse_query(root)
references = {
(sketch, entity)
for sketch, entity in _source_refs(root)
if sketch == owner
}
sides = _profile_selection_sides(root)
if topology != "IMPRINT" or kind != "face" or owner != info.source_sketch:
return None
if len(references) != 1 or len(sides) != 2 or sides[0] >= 0:
return None
sketch, entity = next(iter(references))
if source_sketch is None:
source_sketch, source_entity = sketch, entity
elif (source_sketch, source_entity) != (sketch, entity):
return None
nested_sides.add(sides[1])
if source_sketch is None or source_entity is None or nested_sides != {-1.0, 1.0}:
return None
entities = entity_by_sketch.get(source_sketch) or {}
source = entities.get(source_entity)
if source is None:
source_id = max((key for key in entities if source_entity.startswith(key + ".")), key=len, default="")
source = entities.get(source_id)
if source is None or source.get("type") != "bspline" or not _same_point(source["start"], source["end"]):
return None
return source_sketch
def _intersect_partition_profile_sketch(
value: Any,
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
feature_id: str,
) -> dict[str, Any] | None:
"""Materialize one full circular region selected through a split diameter.
A sketch line that crosses nested concentric circles is split at the
selected circle intersection. CADFS can select the two face sides around
that split edge instead of naming the circular region directly. Both
sides together are exactly the bounded region inside the selected circle:
either its disk or the annulus ending at the immediately inner circle.
Keep this deliberately narrow. A non-diameter line, any non-circular
source, a partial side selection, or an ambiguous nested query remains an
INTERSECT capability diagnostic rather than an invented profile.
"""
roots = _queries(value)
if len(roots) != 2:
return None
source_sketch: str | None = None
source_line: str | None = None
source_circle: str | None = None
outer_sides: set[float] = set()
for root in roots:
try:
_call, owner, topology, kind, _definition = _direct_make_query(root)
except ValueError:
return None
sides = _profile_selection_sides(root)
if topology != "IMPRINT" or kind != "face" or len(sides) != 2 or sides[1] != -1.0:
return None
references = list(dict.fromkeys(_source_refs(root)))
if len(references) != 2 or any(sketch != owner for sketch, _entity in references):
return None
entities = entity_by_sketch.get(owner) or {}
line_ids = [entity_id for _sketch, entity_id in references if (entities.get(entity_id) or {}).get("type") == "line"]
circle_ids = [entity_id for _sketch, entity_id in references if (entities.get(entity_id) or {}).get("type") == "circle"]
if len(line_ids) != 1 or len(circle_ids) != 1:
return None
if source_sketch is None:
source_sketch, source_line, source_circle = owner, line_ids[0], circle_ids[0]
elif (source_sketch, source_line, source_circle) != (owner, line_ids[0], circle_ids[0]):
return None
outer_sides.add(sides[0])
if source_sketch is None or source_line is None or source_circle is None or outer_sides != {-1.0, 1.0}:
return None
sketch = sketch_by_source.get(source_sketch)
entities = entity_by_sketch.get(source_sketch) or {}
line, circle = entities.get(source_line), entities.get(source_circle)
if sketch is None or line is None or circle is None:
return None
start, end, center = line.get("start"), line.get("end"), circle.get("center")
if not all(isinstance(point, list) and len(point) == 2 for point in (start, end, center)):
return None
direction = [end[index] - start[index] for index in range(2)]
length = math.hypot(*direction)
if length <= 1e-9:
return None
projection = sum((center[index] - start[index]) * direction[index] for index in range(2)) / (length * length)
distance = abs((center[0] - start[0]) * direction[1] - (center[1] - start[1]) * direction[0]) / length
if not 1e-6 < projection < 1.0 - 1e-6 or distance > 1e-6:
return None
profile = _circle_imprint_region((sketch.get("profile") or {}).get("contours") or [], circle, 1.0)
if profile is None:
return None
output = deepcopy(sketch)
output["id"] = f"{sketch['id']}__{feature_id}"
output["name"] = f"{sketch['name']}__{feature_id}"
output["profile"] = profile
return output
def _definition_topology_side(definition: dict[str, Any]) -> float | None:
"""Read the directly attached ``TD`` sign from one query definition."""
def numbers(item: Any):
if isinstance(item, (int, float)):
yield float(item)
elif isinstance(item, list):
for child in item:
yield from numbers(child)
elif isinstance(item, dict):
for child in item.values():
yield from numbers(child)
for call in walk_calls(definition.get("disambiguationData")):
if call.name in {"TD", "topologyDisambiguation"}:
side = next(numbers(call.args), None)
if side in {-1.0, 1.0}:
return side
return None
def _definition_order(definition: dict[str, Any]) -> int | None:
"""Read a finite non-negative ``OD`` index without guessing an intersection."""
def numbers(item: Any):
if isinstance(item, (int, float)):
yield float(item)
elif isinstance(item, list):
for child in item:
yield from numbers(child)
elif isinstance(item, dict):
for child in item.values():
yield from numbers(child)
for call in walk_calls(definition.get("disambiguationData")):
if call.name in {"OD", "orderDisambiguation"}:
value = next(numbers(call.args), None)
if value is None or not math.isfinite(value) or value < 0 or value != round(value):
return None
return int(value)
return None
def _planar_imprint_selection(value: Any) -> tuple[str, dict[str, Any]] | None:
"""Lower one IMPRINT face query to source-edge and side evidence.
The result deliberately retains the nested edge-fragment proof instead of
flattening it to an arbitrary original profile. A fragment-side sign is
only meaningful together with an exact ``INTERSECT`` vertex and optional
FeatureScript order disambiguation; any other nested form stays outside
this contract.
"""
try:
_root, owner, topology, kind, definition = _direct_make_query(value)
except ValueError:
return None
face_side = _definition_topology_side(definition)
if topology != "IMPRINT" or kind != "face" or face_side is None:
return None
edge_queries: list[tuple[Call, dict[str, Any]]] = []
for call in walk_calls(definition.get("disambiguationData")):
if call.name != "makeQuery":
continue
try:
_edge, edge_owner, edge_topology, edge_kind, edge_definition = _direct_make_query(call)
except ValueError:
continue
if edge_owner == owner and edge_topology == "IMPRINT" and edge_kind == "edge":
edge_queries.append((call, edge_definition))
if len(edge_queries) != 1:
return None
_edge, edge_definition = edge_queries[0]
sources = list(dict.fromkeys(_source_refs(edge_definition.get("derivedFrom"))))
if len(sources) != 1 or sources[0][0] != owner:
return None
source_entity = sources[0][1]
selection: dict[str, Any] = {"source_entity_id": source_entity, "face_side": face_side}
intersections: list[dict[str, Any]] = []
for call in walk_calls(edge_definition.get("disambiguationData")):
if call.name != "makeQuery":
continue
try:
_intersection, intersection_owner, intersection_topology, intersection_kind, intersection_definition = _direct_make_query(call)
except ValueError:
continue
if intersection_owner == owner and intersection_topology == "INTERSECT" and intersection_kind == "vertex":
intersections.append(intersection_definition)
if not intersections:
return owner, selection
if len(intersections) != 1:
return None
fragment_side = _definition_topology_side(edge_definition)
intersection_sources = list(dict.fromkeys(_source_refs(intersections[0].get("derivedFrom"))))
if fragment_side is None or len(intersection_sources) != 2 or any(sketch != owner for sketch, _entity in intersection_sources):
return None
anchor = [entity for _sketch, entity in intersection_sources if entity != source_entity]
if len(anchor) != 1:
return None
fragment: dict[str, Any] = {"anchor_entity_id": anchor[0], "side": fragment_side}
order = _definition_order(intersections[0])
if order is not None:
fragment["intersection_index"] = order
selection["fragment"] = fragment
return owner, selection
def _planar_imprint_profile_sketch(
value: Any,
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
feature_id: str,
) -> dict[str, Any] | None:
"""Create an exact planar-arrangement profile from IMPRINT face queries.
The adapter later splits a bounded support face with these original
analytic curves and consumes only the selected B-rep regions. This is a
typed derived-profile contract, not a polygonization or a reconstruction
of a potentially unrelated source contour.
"""
selections: list[dict[str, Any]] = []
source_sketch: str | None = None
for root in _queries(value):
parsed = _planar_imprint_selection(root)
if parsed is None:
return None
owner, selection = parsed
if source_sketch is None:
source_sketch = owner
elif source_sketch != owner:
return None
selections.append(selection)
if source_sketch is None or not selections or source_sketch not in sketch_by_source:
return None
entities = entity_by_sketch.get(source_sketch) or {}
source_entities: list[dict[str, Any]] = []
for entity_id, curve in entities.items():
if curve.get("construction") or curve.get("type") not in {"line", "arc", "circle", "ellipse", "bspline"}:
continue
source_entities.append({"id": entity_id, "curve": {key: deepcopy(value) for key, value in curve.items() if key != "construction"}})
known = {entry["id"] for entry in source_entities}
for selection in selections:
fragment = selection.get("fragment") or {}
if selection["source_entity_id"] not in known or (fragment and fragment.get("anchor_entity_id") not in known):
return None
if len(source_entities) < 2:
return None
output = deepcopy(sketch_by_source[source_sketch])
output["id"] = f"{output['id']}__{feature_id}"
output["name"] = f"{output['name']}__{feature_id}"
output["profile"] = {
"type": "planar_imprint",
"source_entities": source_entities,
"selections": selections,
}
return output
def _same_point(left: list[float], right: list[float]) -> bool:
return math.dist(left, right) <= 1e-5
def _matching_profile_segment(segment: dict[str, Any], source: dict[str, Any]) -> int:
if segment.get("type") != source.get("type"): return 0
if segment["type"] == "circle":
return 1 if _same_point(segment["center"], source["center"]) and abs(segment["radius_mm"] - source["radius_mm"]) <= 1e-5 else 0
if segment["type"] == "ellipse":
if not (
_same_point(segment["center"], source["center"])
and abs(segment["major_radius_mm"] - source["major_radius_mm"]) <= 1e-5
and abs(segment["minor_radius_mm"] - source["minor_radius_mm"]) <= 1e-5
):
return 0
direction = _dot(segment["major_axis"], source["major_axis"])
return 1 if direction >= 0 else -1
if _same_point(segment["start"], source["start"]) and _same_point(segment["end"], source["end"]): return 1
if _same_point(segment["start"], source["end"]) and _same_point(segment["end"], source["start"]): return -1
return 0
def _circle_imprint_region(contours: list[dict[str, Any]], source: dict[str, Any], side: float | None) -> dict[str, Any] | None:
"""Materialize one bounded side of a nested circular IMPRINT edge."""
if source.get("type") != "circle" or side is None: return None
center = source["center"]; radius = float(source["radius_mm"])
circles = sorted({
float(segment["radius_mm"])
for contour in contours
for segment in contour.get("segments") or []
if segment.get("type") == "circle" and _same_point(segment.get("center") or [], center)
})
index = next((item for item, value in enumerate(circles) if abs(value - radius) <= 1e-5), None)
if index is None: return None
# 标准 skCircle 的正侧位于圆内。若同心圆存在,选区应止于其直接
# 内层边界;负侧则选择与直接外层边界之间的环域。没有相邻边界时,
# 只有圆内区域是可由 CDSL 闭合 profile 表达的。
inner = circles[index - 1] if side > 0 and index > 0 else radius
outer = radius if side > 0 else (circles[index + 1] if index + 1 < len(circles) else None)
if outer is None: return None
if inner == 0 or (side > 0 and index == 0): return {"type": "circle", "center": list(center), "radius_mm": outer}
return {
"type": "analytic_contours",
"contours": [
{"role": "outer", "closed": True, "segments": [{"type": "circle", "center": list(center), "radius_mm": outer}]},
{"role": "inner", "closed": True, "segments": [{"type": "circle", "center": list(center), "radius_mm": inner}]},
],
}
def _circle_imprint_union_profile(
contours: list[dict[str, Any]],
query_values: list[Any],
entities: dict[str, dict[str, Any]],
) -> dict[str, Any] | None:
"""Materialize one bounded union of concentric circular IMPRINT regions."""
resolved: list[tuple[dict[str, Any], float]] = []
for query_value in query_values:
selection = parse_query(query_value)
source_id = max((key for key in entities if selection.source_entity and selection.source_entity.startswith(key)), key=len, default="")
source = entities.get(source_id)
side = _profile_selection_side(query_value)
if source is None or source.get("type") != "circle" or side is None:
return None
resolved.append((source, side))
if not resolved:
return None
center = resolved[0][0]["center"]
if any(not _same_point(source["center"], center) for source, _side in resolved[1:]):
return None
radii = sorted({
float(segment["radius_mm"])
for contour in contours
for segment in contour.get("segments") or []
if segment.get("type") == "circle" and _same_point(segment.get("center") or [], center)
})
intervals: list[tuple[float, float]] = []
for source, side in resolved:
radius = float(source["radius_mm"])
index = next((item for item, value in enumerate(radii) if abs(value - radius) <= 1e-5), None)
if index is None:
return None
if side > 0:
intervals.append((radii[index - 1] if index else 0.0, radius))
elif index + 1 < len(radii):
intervals.append((radius, radii[index + 1]))
else:
# 最外圆的负侧是无界区域,不能变成任意默认实体。
return None
intervals.sort()
merged: list[list[float]] = []
for lower, upper in intervals:
if merged and lower <= merged[-1][1] + 1e-5:
merged[-1][1] = max(merged[-1][1], upper)
else:
merged.append([lower, upper])
if len(merged) != 1:
return None
inner, outer = merged[0]
if inner <= 1e-5:
return {"type": "circle", "center": list(center), "radius_mm": outer}
return {
"type": "analytic_contours",
"contours": [
{"role": "outer", "closed": True, "segments": [{"type": "circle", "center": list(center), "radius_mm": outer}]},
{"role": "inner", "closed": True, "segments": [{"type": "circle", "center": list(center), "radius_mm": inner}]},
],
}
def _surface_circle_radii(profile: dict[str, Any]) -> list[tuple[list[float], float]]:
"""Return the explicit circular wires retained by a surface extrusion."""
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
if not isinstance(contours, list): return []
circles = []
for contour in contours:
segments = contour.get("segments") or []
if len(segments) != 1 or segments[0].get("type") != "circle": continue
circles.append((list(segments[0].get("center") or []), float(segments[0]["radius_mm"])))
return circles
def _surface_trimmed_imprint_profile(
profile_sketch: dict[str, Any],
surface_profile_sketch: dict[str, Any] | None,
previous_surfaces: list[dict[str, Any]],
) -> dict[str, Any]:
"""Materialize the bounded solid region created by a coaxial surface split.
CADFS can retain an earlier, bidirectional surface extrusion while a later
mixed extrusion selects an IMPRINT face. The selected circular face is
then bounded by that surface, rather than by the sketch origin. CDSL has
no general surface/solid trim operation yet, so preserve this proven
circular case as its actual annular profile. Other surface combinations
remain on the ordinary IMPRINT lowering path instead of guessing a trim.
"""
profile = profile_sketch.get("profile") or {}
if profile.get("type") != "circle" or surface_profile_sketch is None:
return profile_sketch
center = list(profile.get("center") or [])
outer = float(profile.get("radius_mm") or 0.0)
if len(center) != 2 or outer <= 0:
return profile_sketch
selected_radii = _surface_circle_radii(surface_profile_sketch.get("profile") or {})
if not any(_same_point(item_center, center) and abs(radius - outer) <= 1e-5 for item_center, radius in selected_radii):
return profile_sketch
plane = profile_sketch.get("workplane") or {}
origin = plane.get("origin_mm") or []
x_dir = plane.get("x_dir") or []
y_dir = _y_dir(plane)
if len(origin) != 3 or len(x_dir) != 3:
return profile_sketch
world_center = [origin[index] + center[0] * x_dir[index] + center[1] * y_dir[index] for index in range(3)]
candidates: list[float] = []
for surface in previous_surfaces:
surface_plane = surface["workplane"]
normal = surface_plane["normal"]
surface_origin = surface_plane["origin_mm"]
span_start = -float(surface.get("reverse_distance_mm") or 0.0)
span_end = float(surface.get("distance_mm") or 0.0)
projection = _dot(_sub(world_center, surface_origin), normal)
if projection < span_start - 1e-5 or projection > span_end + 1e-5:
continue
surface_x_dir = surface_plane["x_dir"]
surface_y_dir = _y_dir(surface_plane)
local_center = [_dot(_sub(world_center, surface_origin), surface_x_dir), _dot(_sub(world_center, surface_origin), surface_y_dir)]
for surface_center, radius in _surface_circle_radii(surface["profile"]):
if _same_point(surface_center, local_center) and 1e-5 < radius < outer - 1e-5:
candidates.append(radius)
if not candidates:
return profile_sketch
inner = max(candidates)
output = deepcopy(profile_sketch)
output["profile"] = {
"type": "analytic_contours",
"contours": [
{"role": "outer", "closed": True, "segments": [{"type": "circle", "center": center, "radius_mm": outer}]},
{"role": "inner", "closed": True, "segments": [{"type": "circle", "center": center, "radius_mm": inner}]},
],
}
return output
def _open_imprint_profile_sketch(
sketch: dict[str, Any],
value: Any,
feature_id: str,
) -> dict[str, Any] | None:
"""Materialize an open IMPRINT contour closed by its attached host face."""
query = parse_query(value)
profile = sketch.get("profile") or {}
segments = profile.get("construction") or []
if query.topology_type != "IMPRINT" or profile.get("contours") or len(segments) < 2:
return None
if any(segment.get("type") not in {"line", "arc", "bspline"} for segment in segments):
return None
return {
"id": f"{sketch['id']}__{feature_id}",
"name": f"{sketch['name']}__{feature_id}",
"workplane": dict(sketch["workplane"]),
"profile": {
"type": "analytic_contours",
"contours": [{"role": "open", "closed": False, "segments": deepcopy(segments)}],
},
}
def _cap_face_selector(value: Any, feature_frames: dict[str, dict[str, Any]], feature_id: str, suffix: str) -> dict[str, Any] | None:
"""Capture one uniquely framed CAP_FACE/CAP_EDGE as a runtime face selector."""
query = parse_query(value)
if query.topology_type not in {"CAP_FACE", "CAP_EDGE"} or not query.owner_feature or query.is_start is None:
return None
frame = feature_frames.get(query.owner_feature)
if frame is None:
return None
cap = frame.get("start" if query.is_start else "end")
if cap is None:
return None
normal = list(cap["normal"])
return {
"kind": "face",
"owner_feature_id": f"f_{query.owner_feature}",
"stable_id": f"cadfs_{feature_id}_{suffix}",
"source": "runtime_snapshot",
"confidence": 1.0,
"binding_feature_id": f"f_{query.owner_feature}",
"geometry": {"normal": normal, "plane_offset_mm": _dot(normal, cap["origin_mm"])},
"selector_intent": _deferred_featurescript_selector_intent(value, kind="face"),
}
def _cap_face_output_role_selector(
value: Any,
feature_by_id: dict[str, dict[str, Any]],
sketches_by_id: dict[str, dict[str, Any]],
) -> dict[str, Any] | None:
"""Reference one direct-builder cap face without reconstructing its sketch.
A CAP_FACE is a B-rep output, not an alias for the profile that originally
produced it. The selector is therefore legal only when its producer has a
runtime builder role capable of proving the exact active face. The runtime
rejects it if a later mutation makes that role non-unique or unavailable.
"""
try:
_call, owner, topology, kind, _definition = _direct_make_query(value)
except ValueError:
return None
if topology != "CAP_FACE" or kind != "face":
return None
query = parse_query(value)
if query.is_start is None:
return None
owner_feature_id = f"f_{owner}"
producer = feature_by_id.get(owner_feature_id)
params = (producer or {}).get("params") or {}
producer_sketch = sketches_by_id.get(str((producer or {}).get("sketch_id") or ""))
def has_one_closed_outer_region(sketch: dict[str, Any] | None) -> bool:
"""Prove the narrowed draft builder can receive exactly one face."""
profile = (sketch or {}).get("profile") or {}
if profile.get("type") == "circle":
return True
contours = profile.get("contours")
return (
profile.get("type") == "analytic_contours"
and isinstance(contours, list)
and len(contours) == 1
and bool((contours[0] or {}).get("closed"))
)
# This derived-profile contract exposes only caps that a direct, one-sided,
# independently retained builder result can prove. The adapter supports
# both a regular prism and the restricted one-face LocOpe drafted-prism
# builder path. Fused/multi-extent/profile results still have no
# unambiguous builder output in the active snapshot.
if (
producer is None
or producer.get("atomic_id") != "extrude_add_blind"
or params.get("result_mode") != "new_body"
or (params.get("end_condition") or {}).get("type") != "blind"
# LocOpe_DPrism exposes one builder cap only for the one-face path.
# A profile with holes is valid geometry but follows the fallback
# tapered-extrude path, which intentionally carries no cap role.
or params.get("draft") is not None and not has_one_closed_outer_region(producer_sketch)
):
return None
role_prefix = {
"extrude_add_blind": "extrude",
}[str(producer["atomic_id"])]
return {
"kind": "face",
"owner_feature_id": owner_feature_id,
"output_role": f"{role_prefix}.{'start' if query.is_start else 'end'}",
"source": "runtime_snapshot",
"confidence": 1.0,
"selector_intent": _selector_intent(
value,
query_family="CAP_FACE",
kind="face",
evidence="operation_role",
allowed=("boundary", "continuation"),
output_role=f"{role_prefix}.{'start' if query.is_start else 'end'}",
),
}
def _profile_query_union_leaves(value: Any) -> list[Any]:
"""Flatten only associative query unions used to select one profile.
FeatureScript histories can wrap a qUnion in a second qUnion when a local
alias is later assigned to ``entities``. The wrapper changes neither the
selected topology nor the source provenance. Keeping this normalization
local to CAP_EDGE profile recognition avoids changing generic selector
parsing, where query grouping may still be diagnostically meaningful.
"""
if isinstance(value, Call) and value.name == "qUnion" and value.args and isinstance(value.args[0], list):
return [leaf for item in value.args[0] for leaf in _profile_query_union_leaves(item)]
return [value]
def _cap_edge_hole_profile_sketch(
value: Any,
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
feature_frames: dict[str, dict[str, Any]],
feature_id: str,
) -> tuple[dict[str, Any], dict[str, Any]] | None:
"""Materialize an outer IMPRINT profile with one CAP_EDGE-derived inner wire.
The inner curve may already be a draft/offset B-rep edge, so it must be
taken from its producer's selected cap face at runtime rather than guessed
from the original sketch entity. This intentionally accepts one outer
closed region and one uniquely framed cap edge only.
"""
roots = _profile_query_union_leaves(value)
if len(roots) != 2:
return None
outer_value = next((item for item in roots if parse_query(item).topology_type == "IMPRINT"), None)
inner_value = next((item for item in roots if parse_query(item).topology_type == "CAP_EDGE"), None)
if outer_value is None or inner_value is None:
return None
# 同向的 TD region query 选择的是外轮廓两侧相邻的两个 IMPRINT 区域。
# 将二者并集解释成 "外轮廓减 CAP_EDGE" 会错误挖去中心,留下一个仅沿
# 边接触前序实体的薄环。只有两条边的 region side 相反时,才有明确的
# 外环 + 内孔语义可以交给受限的 CAP_EDGE profile atomic。
outer_side = _profile_selection_side(outer_value)
inner_side = _profile_selection_side(inner_value)
if outer_side is not None and inner_side is not None and outer_side == inner_side:
return None
outer = parse_query(outer_value)
if not outer.source_sketch or outer.source_sketch not in sketch_by_source:
return None
outer_sketch = _profile_selection_sketch(
sketch_by_source[outer.source_sketch], outer_value,
entity_by_sketch[outer.source_sketch], feature_id,
)
profile = outer_sketch.get("profile") or {}
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
if not (
profile.get("type") == "circle"
or isinstance(contours, list) and len(contours) == 1 and bool(contours[0].get("closed"))
):
return None
selector = _cap_face_selector(inner_value, feature_frames, feature_id, "profile_hole")
return (outer_sketch, selector) if selector is not None else None
def _cap_edge_union_profile_sketch(
value: Any,
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
feature_id: str,
) -> dict[str, Any] | None:
"""Materialize the complete outer region selected around one CAP_EDGE."""
roots = _profile_query_union_leaves(value)
if len(roots) != 2:
return None
outer_value = next((item for item in roots if parse_query(item).topology_type == "IMPRINT"), None)
inner_value = next((item for item in roots if parse_query(item).topology_type == "CAP_EDGE"), None)
if outer_value is None or inner_value is None:
return None
outer_side = _profile_selection_side(outer_value)
inner_side = _profile_selection_side(inner_value)
if outer_side is None or inner_side is None or outer_side != inner_side:
return None
outer = parse_query(outer_value)
if not outer.source_sketch or outer.source_sketch not in sketch_by_source:
return None
return _profile_selection_sketch(
sketch_by_source[outer.source_sketch], outer_value,
entity_by_sketch[outer.source_sketch], feature_id,
)
def _loft_cap_face_profile(
params: dict[str, Any],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
feature_frames: dict[str, dict[str, Any]],
feature_id: str,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]] | None:
"""Resolve one CAP_FACE outer wire followed by one sketch-imprint loft section."""
profiles = params.get("sheetProfilesArray")
if not isinstance(profiles, list) or len(profiles) != 2:
return None
values = [item.get("sheetProfileEntities") if isinstance(item, dict) else item for item in profiles]
cap, sketch_query = (parse_query(value) for value in values)
if cap.topology_type != "CAP_FACE" or sketch_query.topology_type != "IMPRINT":
return None
if not sketch_query.source_sketch or sketch_query.source_sketch not in sketch_by_source:
return None
selector = _cap_face_selector(values[0], feature_frames, feature_id, "loft_cap")
frame = feature_frames.get(cap.owner_feature or "")
cap_frame = frame and frame.get("start" if cap.is_start else "end")
if selector is None or cap_frame is None:
return None
sketch = _profile_selection_sketch(
sketch_by_source[sketch_query.source_sketch], values[1],
entity_by_sketch[sketch_query.source_sketch], feature_id,
)
return sketch, selector, dict(cap_frame)
def _profile_selection_sketch(
sketch: dict[str, Any],
query_value: Any,
entities: dict[str, dict[str, Any]],
feature_id: str,
) -> dict[str, Any]:
"""Materialize a uniquely selected sketch region for an IMPRINT query."""
query = parse_query(query_value)
if query.topology_type != "IMPRINT" or not query.source_entity:
return sketch
contours = (sketch.get("profile") or {}).get("contours") or []
query_values = _queries(query_value)
selections = [parse_query(item) for item in query_values]
union_profile = _circle_imprint_union_profile(contours, query_values, entities)
if union_profile is not None:
output = deepcopy(sketch)
output["id"] = f"{sketch['id']}__{feature_id}"
output["name"] = f"{sketch['name']}__{feature_id}"
output["profile"] = union_profile
return output
# qUnion 可以从同一草图选择多个互不相同的 IMPRINT region。此前只取
# parse_query(qUnion(...)) 最后看到的一个 source entity,且在没有正反
# orientation 对时退回整张草图,导致未选圆也被错误拉伸。仅当每个 source
# entity 唯一对应一条 contour 时,直接物化该明确选择集;同源的双侧 region
# 继续交给下方的 orientation 逻辑,避免把环形区域猜成圆盘。
source_ids = [
max((key for key in entities if selection.source_entity and selection.source_entity.startswith(key)), key=len, default="")
for selection in selections
]
# 单一同心圆边的 IMPRINT 查询并不总是选择圆盘。它表达的是该边
# 一侧的 bounded region;例如 `00111611` F7 选 E6 的正侧时,真实
# profile 是 E7/E6 环域而不是 E6 圆盘。
if len(selections) == 1 and source_ids and source_ids[0]:
source = entities[source_ids[0]]
profile = _circle_imprint_region(contours, source, _profile_selection_side(_queries(query_value)[0]))
if profile is not None:
output = deepcopy(sketch)
output["id"] = f"{sketch['id']}__{feature_id}"
output["name"] = f"{sketch['name']}__{feature_id}"
output["profile"] = profile
return output
if (
len(source_ids) == len(set(source_ids))
and all(source_ids)
and all(selection.topology_type == "IMPRINT" and selection.source_entity for selection in selections)
):
selected: list[dict[str, Any]] = []
for selected_id in source_ids:
source = entities[selected_id]
matches = [
contour for contour in contours
if any(_matching_profile_segment(segment, source) for segment in contour.get("segments") or [])
]
if len(matches) != 1:
break
selected.append(matches[0])
else:
output = deepcopy(sketch)
output["id"] = f"{sketch['id']}__{feature_id}"
output["name"] = f"{sketch['name']}__{feature_id}"
output["profile"] = {**output["profile"], "contours": selected}
return output
source_id = max((key for key in entities if query.source_entity.startswith(key)), key=len, default="")
source = entities.get(source_id)
if source is None or len(contours) < 2:
return sketch
selection_sides = [_profile_selection_side(item) for item in _queries(query_value)]
if source.get("type") == "circle" and all(side is not None for side in selection_sides):
# 两个相反 orientation 的同一圆形 IMPRINT region 表示其两侧区域的
# 并集。对于同心圆草图,CADFS 用这套写法选择内圆盘和相邻圆环;将它
# 们规整为最小包围圆盘,不能按普通 nested-circle profile 误作圆环。
circles = [
segment for contour in contours for segment in contour.get("segments") or []
if segment.get("type") == "circle" and _same_point(segment.get("center") or [], source.get("center") or [])
]
radii = sorted({float(segment["radius_mm"]) for segment in circles})
if len(radii) >= 2 and any(side < 0 for side in selection_sides) and any(side > 0 for side in selection_sides):
outer = next((radius for radius in radii if radius > float(source["radius_mm"]) + 1e-6), None)
if outer is not None:
output = deepcopy(sketch)
output["id"] = f"{sketch['id']}__{feature_id}"
output["name"] = f"{sketch['name']}__{feature_id}"
output["profile"] = {"type": "circle", "center": list(source["center"]), "radius_mm": outer}
return output
matches: list[tuple[dict[str, Any], int]] = []
for contour in contours:
direction = next((_matching_profile_segment(segment, source) for segment in contour.get("segments") or [] if _matching_profile_segment(segment, source)), 0)
if direction: matches.append((contour, direction))
side = _profile_selection_side(query_value)
if side is None or len(matches) < 2:
return sketch
selected = [contour for contour, direction in matches if direction == (1 if side > 0 else -1)]
if len(selected) != 1:
raise UnsupportedCapability("sketch_region_selection", "IMPRINT profile selection does not resolve one sketch region")
output = deepcopy(sketch)
output["id"] = f"{sketch['id']}__{feature_id}"
output["name"] = f"{sketch['name']}__{feature_id}"
output["profile"] = {**output["profile"], "contours": [selected[0]]}
return output
def _surface_profile_selection_sketch(
sketch: dict[str, Any],
query_value: Any,
entities: dict[str, dict[str, Any]],
feature_id: str,
) -> dict[str, Any]:
"""Materialize the explicit circular wires selected by surfaceEntities."""
references = _source_refs(query_value)
selected = []
seen = set()
for source, entity_id in references:
if source != sketch["name"] or entity_id in seen:
continue
entity = entities.get(entity_id)
if entity is None or entity.get("type") != "circle":
raise UnsupportedCapability(
"extrude_surface_profile",
"current CDSL surface extrude requires explicitly selected circular wires",
)
selected.append(entity); seen.add(entity_id)
if not selected:
raise ValueError("surface extrude profile query is unresolved")
output = deepcopy(sketch)
output["id"] = f"{sketch['id']}__{feature_id}_surface"
output["name"] = f"{sketch['name']}__{feature_id}_surface"
output["profile"] = {
"type": "analytic_contours",
"contours": [
{"role": "unknown", "closed": True, "segments": [deepcopy(entity)]}
for entity in selected
],
}
return output
def _pattern_source_features(value: Any, previous: list[str]) -> list[str]:
sources = []
for call in walk_calls(value):
if call.name != "makeQuery" or not call.args:
continue
owner = symbolic_string(call.args[0])
if "F" not in owner:
continue
source = "f_" + owner[owner.find("F"):].split(".", 1)[0]
if source in previous and source not in sources:
sources.append(source)
if not sources:
raise ValueError("pattern source features are unresolved")
return sources
def _pattern_body_history_sources(
value: Any,
sources: list[str],
previous: list[str],
feature_by_id: dict[str, dict[str, Any]],
body_aliases: dict[str, str] | None = None,
) -> list[str]:
"""Resolve a selected SWEPT_BODY to its exact current member when known.
CADFS body queries name the feature that originally created the body. A
following ADD sweep can already have become part of that same body before
`circularPattern` copies it. When the restricted one-body successor state
proves that current member, use it directly: the runtime can transform its
actual B-rep and retain a concrete COPY body member. Replaying a creator
plus its additive history would make each replay fragment look like a
separate source, which loses the instance ownership needed by a later
COPY(BODY) boolean/transform.
Without that one-to-one proof, retain the older bounded replay expansion.
It can preserve geometry for patterns with fused history, but deliberately
does not claim individual COPY body ownership.
"""
body_aliases = body_aliases or {}
swept_body_sources = {
f"f_{query.owner_feature}"
for item in _queries(value)
for query in [parse_query(item)]
if query.topology_type == "SWEPT_BODY"
and query.kind in {"body", "entitytype.body"}
and query.owner_feature
}
has_swept_body = bool(swept_body_sources)
if not has_swept_body:
return sources
resolved_sources = {
source: _resolved_body_alias(source, body_aliases)
for source in swept_body_sources
}
if any(resolved != source for source, resolved in resolved_sources.items()):
return list(dict.fromkeys(
resolved_sources.get(source, source)
for source in sources
))
replayable_adds = {
"extrude_add_blind", "extrude_add_two_sided", "loft_add",
"loft_add_with_cap_face", "sweep_add", "revolve_add",
"sphere_add", "box_add", "cylinder_add",
}
output = list(sources)
source_indexes = [previous.index(source) for source in sources if source in previous]
if not source_indexes:
return output
for feature_id in previous[max(source_indexes) + 1:]:
feature = feature_by_id.get(feature_id) or {}
if feature.get("atomic_id") not in replayable_adds:
continue
if (feature.get("params") or {}).get("result_mode") == "new_body":
continue
output.append(feature_id)
return list(dict.fromkeys(output))
def _sweep_cap_frames(profile: dict[str, Any], path: dict[str, Any]) -> dict[str, dict[str, Any]] | None:
"""Record physical CAP_FACE frames for one line or B-spline sweep."""
segment = path.get("segment") or {}
path_plane = path.get("workplane") or {}
profile_plane = profile.get("workplane") or {}
points = segment.get("points") if segment.get("type") == "bspline" else [segment.get("start"), segment.get("end")]
if not isinstance(points, list) or len(points) < 2 or any(not isinstance(point, list) or len(point) != 2 for point in points):
return None
y_dir = _y_dir(path_plane)
def point(value: list[float]) -> list[float]:
return [
path_plane["origin_mm"][index] + path_plane["x_dir"][index] * value[0] + y_dir[index] * value[1]
for index in range(3)
]
def direction(value: Any, fallback: list[float]) -> list[float]:
if isinstance(value, list) and len(value) == 2:
return _unit([
path_plane["x_dir"][index] * value[0] + y_dir[index] * value[1]
for index in range(3)
], "sweep cap tangent is degenerate")
return _unit(fallback, "sweep path is degenerate")
start, end = point(points[0]), point(points[-1])
start_direction = direction(segment.get("start_tangent"), _sub(point(points[1]), start))
end_direction = direction(segment.get("end_tangent"), _sub(end, point(points[-2])))
x_dir = list(profile_plane["x_dir"])
return {
# OCC keeps the source profile as the start cap of a solid sweep. Its
# plane follows the profile workplane, not the tangent of a curved
# spine. The profile centre may lie at the path point while the plane
# origin is elsewhere, so retain the actual profile plane frame.
"start": _frame(list(profile_plane["origin_mm"]), x_dir, list(profile_plane["normal"])),
"end": _frame(end, x_dir, end_direction),
"profile": dict(profile_plane),
}
def _reversed_sweep_path_segment(segment: dict[str, Any]) -> dict[str, Any]:
"""Reverse one CADFS line/B-spline sweep path without changing its curve."""
output = deepcopy(segment)
output["start"], output["end"] = segment["end"], segment["start"]
if output.get("type") == "bspline":
output["points"] = list(reversed(segment.get("points") or []))
parameters = segment.get("parameters")
if isinstance(parameters, list) and parameters:
start_parameter, end_parameter = float(parameters[0]), float(parameters[-1])
output["parameters"] = [start_parameter + end_parameter - float(value) for value in reversed(parameters)]
start_tangent, end_tangent = segment.get("start_tangent"), segment.get("end_tangent")
if isinstance(start_tangent, list) and isinstance(end_tangent, list):
output["start_tangent"] = [-float(value) for value in end_tangent]
output["end_tangent"] = [-float(value) for value in start_tangent]
return output
def _sweep_profile_attaches_at_path_end(profile: dict[str, Any], path: dict[str, Any]) -> bool:
"""Detect a circle profile placed at the terminal point of a CADFS path."""
segment = path.get("segment") or {}
workplane = path.get("workplane") or {}
profile_plane = profile.get("workplane") or {}
profile_shape = profile.get("profile") or {}
if profile_shape.get("type") != "circle" or segment.get("type") not in {"line", "bspline"}:
return False
start, end = segment.get("start"), segment.get("end")
center = profile_shape.get("center") or [0.0, 0.0]
if not all(isinstance(value, list) and len(value) == 2 for value in (start, end, center)):
return False
try:
start_point, end_point = _global(workplane, start), _global(workplane, end)
profile_center = _global(profile_plane, center)
except (KeyError, TypeError, ValueError):
return False
return math.dist(profile_center, end_point) <= 1e-5 and math.dist(profile_center, start_point) > 1e-5
def _pattern_copy_body(value: Any) -> tuple[str, str, int]:
"""Resolve one CADFS circular-pattern body copy without flattening it to a feature."""
_call, pattern_owner, topology, kind, definition = _direct_make_query(value)
if topology != "COPY" or kind not in {"body", "entitytype.body"}:
raise UnsupportedCapability("delete_bodies", "current CDSL deleteBodies only supports circular pattern body copies")
derived = definition.get("derivedFrom")
if derived is None:
raise ValueError("pattern copy deletion has no derived body")
_source, source_owner, source_topology, source_kind, _source_definition = _direct_make_query(derived)
if source_topology != "SWEPT_BODY" or source_kind not in {"body", "entitytype.body"}:
raise UnsupportedCapability("delete_bodies", "pattern copy deletion source is not a direct swept body")
try:
instance = int(str(definition.get("instanceName")))
except (TypeError, ValueError) as error:
raise ValueError("pattern copy deletion instance is unresolved") from error
return f"f_{pattern_owner}", f"f_{source_owner}", instance
def _boolean_body_sources(value: Any) -> list[str]:
"""Resolve CADFS SWEPT_BODY query members to their producing features."""
sources: list[str] = []
for query_value in _queries(value):
query = parse_query(query_value)
if query.topology_type != "SWEPT_BODY" or query.kind not in {"body", "entitytype.body"}:
raise ValueError("booleanBodies requires explicit SWEPT_BODY queries")
if not query.owner_feature:
raise ValueError("booleanBodies source feature is unresolved")
source = f"f_{query.owner_feature}"
if source not in sources:
sources.append(source)
if not sources:
raise ValueError("booleanBodies has no selected bodies")
return sources
def _boolean_body_references(
value: Any,
previous: list[str],
feature_by_id: dict[str, dict[str, Any]],
body_aliases: dict[str, str] | None = None,
) -> tuple[list[str], list[dict[str, Any]]]:
"""Keep direct pattern COPY bodies explicit for boolean selection.
A body selected from a mirror or circular pattern is not its pattern's
aggregate result. Reuse the established transform-query provenance parser,
but limit this first boolean contract to direct feature bodies and pattern
instances. Multi-source transform COPY output has distinct lifecycle
semantics and remains a separate future contract.
"""
sources, instance_refs, transform_copy_refs = _transform_body_references(
value, previous, feature_by_id, body_aliases,
)
if transform_copy_refs:
raise UnsupportedCapability(
"boolean_pattern_copy",
"current CDSL booleanBodies does not yet consume multi-source transform COPY bodies",
)
return sources, instance_refs
def _shell_target_body_source(
value: Any,
previous: list[str],
body_aliases: dict[str, str],
body_members: set[str],
) -> str:
"""Lower one CADFS shell ``parts`` query to its live body member.
``parts`` is not a hint to shell whichever aggregate currently contains
the selected faces. It names the CADFS body that owns the shell operation.
A direct SWEPT_BODY can follow only a lowering-time successor that already
proves a one-to-one active member; patterns and fused aggregates deliberately
never enter that alias map.
"""
queries = _queries(value)
if len(queries) != 1:
raise UnsupportedCapability(
"shell_parts_body_source",
"current CDSL shell.parts requires exactly one direct SWEPT_BODY",
)
_call, owner, topology, kind, _definition = _direct_make_query(queries[0])
if topology != "SWEPT_BODY" or kind not in {"body", "entitytype.body"}:
raise UnsupportedCapability(
"shell_parts_body_source",
"current CDSL shell.parts requires one direct SWEPT_BODY",
)
source = f"f_{owner}"
if source not in previous:
raise ValueError("shell parts body source is unresolved")
source = _resolved_body_alias(source, body_aliases)
if source not in body_members:
raise UnsupportedCapability(
"shell_parts_body_source",
"shell parts body no longer has one independently selectable member",
)
return source
def _hole_scope_body_source(
value: Any,
previous: list[str],
body_aliases: dict[str, str],
body_members: set[str],
) -> str:
"""Lower one CADFS ``hole.scope`` query to its sole live body member.
Hole scope is an ownership constraint, not permission to cut the current
aggregate. This first contract accepts one direct CADFS ``SWEPT_BODY``
only while the lowering-side body graph proves that it remains the one
independently selectable active member.
"""
queries = _queries(value)
if len(queries) != 1:
raise UnsupportedCapability(
"hole_scope_body_source",
"current CDSL hole.scope requires exactly one direct SWEPT_BODY",
)
_call, owner, topology, kind, _definition = _direct_make_query(queries[0])
if topology != "SWEPT_BODY" or kind not in {"body", "entitytype.body"}:
raise UnsupportedCapability(
"hole_scope_body_source",
"current CDSL hole.scope requires one direct SWEPT_BODY",
)
source = f"f_{owner}"
if source not in previous:
raise ValueError("hole scope body source is unresolved")
source = _resolved_body_alias(source, body_aliases)
if source not in body_members or len(body_members) != 1:
raise UnsupportedCapability(
"hole_scope_body_source",
"hole scope body is no longer the sole independently selectable member",
)
return source
def _direct_hole_location(
value: Any,
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
) -> tuple[list[float], dict[str, Any]] | None:
"""Resolve one original sketch vertex accepted by FeatureScript hole.
The Hole API exposes locations as sketch vertices. A circle centre and
direct line/arc endpoints are source vertices too, but derived suffixes,
topology output, query combinators, and geometry proximity do not prove a
location. Return local plane coordinates because the hole executor uses
its host frame to construct the final 3D point.
"""
if not isinstance(value, Call) or value.name not in {"sQuery", "sketchEntityQuery"} or len(value.args) < 3:
return None
info = parse_query(value)
if (
info.topology_type is not None
or info.kind not in {"vertex", "entitytype.vertex"}
or not isinstance(info.source_sketch, str)
or not isinstance(info.source_entity, str)
):
return None
sketch = sketch_by_source.get(info.source_sketch)
entities = entity_by_sketch.get(info.source_sketch) or {}
if sketch is None:
return None
token = info.source_entity
entity_id = max((key for key in entities if token == key or token.startswith(key + ".")), key=len, default="")
entity = entities.get(entity_id)
if entity is None:
return None
suffix = "" if token == entity_id else token[len(entity_id) + 1:]
point: list[float] | None = None
if entity.get("type") == "point" and not suffix:
point = entity.get("point")
elif entity.get("type") == "circle" and suffix == "center":
point = entity.get("center")
elif entity.get("type") in {"line", "arc"} and suffix in {"start", "end"}:
point = entity.get(suffix)
if not isinstance(point, list) or len(point) != 2 or not all(isinstance(component, (int, float)) for component in point):
return None
return [float(point[0]), float(point[1]), 0.0], sketch["workplane"]
def _direct_sketch_wire_path(
value: Any,
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
*,
featurescript_version: str | None,
standard_library: str | None,
standard_library_version: str | None,
) -> tuple[str, str, dict[str, Any], dict[str, Any]] | None:
"""Resolve one direct 1511 sketch-wire query used as a sweep path.
``qBodyType(qCreatedBy(sketch, EDGE), WIRE)`` filters the source sketch's
reference-wire entities; it does not select runtime body topology. The
construction filter is part of the source query contract. A single-item
qUnion is only the set identity here, never permission to flatten or pick
among several path entities.
"""
if (
featurescript_version != "1511"
or standard_library != "onshape/std/geometry.fs"
or standard_library_version != "1511.0"
):
return None
if (
not isinstance(value, Call)
or value.name != "qUnion"
or len(value.args) != 1
or not isinstance(value.args[0], list)
or len(value.args[0]) != 1
):
return None
current = value.args[0][0]
if not isinstance(current, Call) or current.name != "qConstructionFilter" or len(current.args) != 2:
return None
if symbolic_string(current.args[1]).rsplit(".", 1)[-1].upper() != "NO":
return None
current = current.args[0]
if not isinstance(current, Call) or current.name != "qBodyType" or len(current.args) != 2:
return None
if symbolic_string(current.args[1]).rsplit(".", 1)[-1].upper() != "WIRE":
return None
current = current.args[0]
if not isinstance(current, Call) or current.name != "qCreatedBy" or len(current.args) != 2:
return None
if symbolic_string(current.args[1]).rsplit(".", 1)[-1].upper() != "EDGE":
return None
source = parse_query(current).owner_feature
sketch = sketch_by_source.get(source or "")
entities = entity_by_sketch.get(source or "") or {}
if sketch is None:
return None
candidates = [
(entity_id, entity)
for entity_id, entity in entities.items()
if not entity.get("construction")
]
if len(candidates) != 1:
return None
entity_id, entity = candidates[0]
if entity.get("type") not in {"line", "bspline"}:
return None
return str(source), entity_id, entity, sketch
def _pattern_remove_source(feature: dict[str, Any]) -> None:
# circularPattern 的 REMOVE 会把源实体及其实例作为切削工具。仅直接的
# 加料拉伸/回转可无歧义改写为同一 profile 的切除;其他 source 需要
# body 生命周期与工具保留策略,不能猜测成任意 boolean。
cut_atomic = {
"extrude_add_blind": "extrude_cut_blind",
"extrude_add_two_sided": "extrude_cut_two_sided",
"revolve_add": "revolve_cut",
}.get(str(feature.get("atomic_id") or ""))
if cut_atomic is None:
raise UnsupportedCapability(
"circular_pattern_remove_source",
"current CDSL engine can only replay a REMOVE pattern from a direct additive extrusion or revolve",
)
feature["atomic_id"] = cut_atomic
feature["params"].pop("result_mode", None)
def _resolved_body_alias(source: str, aliases: dict[str, str]) -> str:
"""Follow one proven direct body's current successor without guessing.
CADFS ``SWEPT_BODY`` queries keep the original operation owner after a
non-copy transform or a sole-body mutation. The source still names the
same physical body, whose executable CDSL member is now the successor.
This only follows lowering-time transitions that preserve a one-to-one
body member. Boolean, multi-body, delete and replayed pattern output
never enter the alias map.
"""
resolved = source
visited = {source}
while resolved in aliases:
successor = aliases[resolved]
if successor in visited:
raise ValueError("body transform successor aliases contain a cycle")
visited.add(successor)
resolved = successor
return resolved
def _direct_transform_copy_member(
value: Any,
previous: list[str],
feature_by_id: dict[str, dict[str, Any]],
body_aliases: dict[str, str],
visited: set[str] | None = None,
) -> tuple[str, dict[str, str] | None]:
"""Resolve an exact ``COPY`` chain emitted by explicit transform copies.
FeatureScript represents a ``makeCopy`` transform result as
``owner.opPattern/COPY`` even when the owner is a plain transform rather
than a CADFS pattern feature. The runtime already gives that transform a
distinct body member under its feature ID. A multi-source transform COPY
exposes one member for each selected source, so this resolver returns a
structured reference for that case and validates its complete derived-from
chain. It intentionally does not infer ownership for generic pattern,
fused, or dress-up output.
"""
_call, owner, topology, kind, definition = _direct_make_query(value)
if kind not in {"body", "entitytype.body"}:
raise UnsupportedCapability("transform_pattern_copy", "CADFS transform COPY source is not a body")
if topology == "SWEPT_BODY":
source = f"f_{owner}"
if source not in previous:
raise ValueError("transform COPY source body is unresolved")
return _resolved_body_alias(source, body_aliases), None
if topology != "COPY":
raise UnsupportedCapability(
"transform_pattern_copy",
"CADFS transform COPY source must descend from a direct swept body",
)
try:
instance = int(str(definition.get("instanceName")))
except (TypeError, ValueError) as error:
raise ValueError("transform COPY instance is unresolved") from error
if instance != 1:
raise UnsupportedCapability(
"transform_pattern_copy",
"direct transform COPY provenance only has generated instance 1",
)
member_id = f"f_{owner}"
if member_id in (visited or set()):
raise ValueError("transform COPY provenance contains a cycle")
transform = feature_by_id.get(member_id)
params = (transform or {}).get("params") or {}
source_ids = params.get("source_feature_ids") or []
if (
member_id not in previous
or transform is None
or transform.get("atomic_id") != "transform_bodies"
or not bool(params.get("make_copy"))
or params.get("pattern_instance_refs")
or params.get("transform_copy_refs")
or not isinstance(source_ids, list)
or not source_ids
):
raise UnsupportedCapability(
"transform_pattern_copy",
"CADFS transform COPY must name an exact preceding explicit transform copy",
)
derived = definition.get("derivedFrom")
if derived is None:
raise ValueError("transform COPY has no derived body")
upstream, _upstream_copy_ref = _direct_transform_copy_member(
derived, previous, feature_by_id, body_aliases, (visited or set()) | {member_id},
)
if len(source_ids) == 1 and source_ids == [upstream]:
return member_id, None
if len(source_ids) > 1 and upstream in source_ids:
return member_id, {
"transform_feature_id": member_id,
"source_feature_id": upstream,
}
raise UnsupportedCapability(
"transform_pattern_copy",
"CADFS transform COPY derived body does not match its CDSL transform source",
)
def _transform_copy_query_provenance(
value: Any,
previous: list[str],
feature_by_id: dict[str, dict[str, Any]],
visited: set[str] | None = None,
) -> tuple[Any, list[dict[str, Any]], str]:
"""Return a direct source query and exact transforms for a COPY descendant.
The copied edge/vertex must be produced by the same restricted transform
copy chain as its selected body. Applying the recorded CDSL transforms to
the direct source reference preserves the physical location without a
topology-nearest fallback. This is deliberately separate from runtime
selector binding: the values are only used to lower a FeatureScript
translation vector whose source points are explicit and unique.
"""
_call, owner, topology, _kind, definition = _direct_make_query(value)
member_id = f"f_{owner}"
if topology != "COPY":
if member_id not in previous:
raise ValueError("transform COPY reference owner is unresolved")
return value, [], member_id
try:
instance = int(str(definition.get("instanceName")))
except (TypeError, ValueError) as error:
raise ValueError("transform COPY reference instance is unresolved") from error
if instance != 1:
raise UnsupportedCapability(
"transform_translation_entity",
"COPY reference only has exact transform provenance for instance 1",
)
if member_id in (visited or set()):
raise ValueError("transform COPY reference provenance contains a cycle")
transform = feature_by_id.get(member_id)
params = (transform or {}).get("params") or {}
source_ids = params.get("source_feature_ids") or []
transform_spec = params.get("transform")
if (
member_id not in previous
or transform is None
or transform.get("atomic_id") != "transform_bodies"
or not bool(params.get("make_copy"))
or params.get("pattern_instance_refs")
or not isinstance(source_ids, list)
or len(source_ids) != 1
or not isinstance(transform_spec, dict)
):
raise UnsupportedCapability(
"transform_translation_entity",
"COPY reference must name an exact preceding single-source transform copy",
)
derived = definition.get("derivedFrom")
if derived is None:
raise ValueError("transform COPY reference has no derived geometry")
source_query, transforms, upstream_member = _transform_copy_query_provenance(
derived, previous, feature_by_id, (visited or set()) | {member_id},
)
if source_ids != [upstream_member]:
raise UnsupportedCapability(
"transform_translation_entity",
"COPY reference derived geometry does not match its transform source",
)
return source_query, [*transforms, transform_spec], member_id
def _apply_body_transform_to_point(point: list[float], transform: dict[str, Any]) -> list[float]:
"""Apply one validated CDSL body transform to an explicit point."""
kind = str(transform.get("type") or "")
if kind == "translation":
offset = transform.get("translation_mm")
if not isinstance(offset, list) or len(offset) != 3:
raise ValueError("transform COPY translation is incomplete")
return [point[index] + float(offset[index]) for index in range(3)]
if kind == "rotation":
axis = transform.get("axis")
angle = transform.get("angle_deg")
if not isinstance(axis, dict) or not isinstance(angle, (int, float)):
raise ValueError("transform COPY rotation is incomplete")
return _rotate_point(point, axis, math.radians(float(angle)))
if kind == "uniform_scale":
center = transform.get("center_mm")
factor = transform.get("scale_factor")
if not isinstance(center, list) or len(center) != 3 or not isinstance(factor, (int, float)):
raise ValueError("transform COPY uniform scale is incomplete")
return [float(center[index]) + (point[index] - float(center[index])) * float(factor) for index in range(3)]
raise ValueError(f"transform COPY has unsupported transform type {kind!r}")
def _transform_copy_point(
query: Any,
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
feature_by_id: dict[str, dict[str, Any]],
previous: list[str],
) -> list[float]:
try:
_call, _owner, topology, _kind, _definition = _direct_make_query(query)
except ValueError:
return _query_point(query, feature_frames, sketch_by_source, entity_by_sketch)
if topology != "COPY":
return _query_point(query, feature_frames, sketch_by_source, entity_by_sketch)
source, transforms, _member = _transform_copy_query_provenance(query, previous, feature_by_id)
point = _query_point(source, feature_frames, sketch_by_source, entity_by_sketch)
for transform in transforms:
point = _apply_body_transform_to_point(point, transform)
return point
def _transform_copy_line(
query: Any,
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
feature_by_id: dict[str, dict[str, Any]],
previous: list[str],
) -> tuple[list[float], list[float]]:
try:
_call, _owner, topology, _kind, _definition = _direct_make_query(query)
except ValueError:
return _query_line(query, feature_frames, sketch_by_source, entity_by_sketch)
if topology != "COPY":
return _query_line(query, feature_frames, sketch_by_source, entity_by_sketch)
source, transforms, _member = _transform_copy_query_provenance(query, previous, feature_by_id)
start, end = _query_line(source, feature_frames, sketch_by_source, entity_by_sketch)
for transform in transforms:
start = _apply_body_transform_to_point(start, transform)
end = _apply_body_transform_to_point(end, transform)
return start, end
def _transform_source_features(value: Any, previous: list[str], body_aliases: dict[str, str] | None = None) -> list[str]:
sources = []
body_aliases = body_aliases or {}
for call in walk_calls(value):
if call.name not in {"makeQuery", "qCreatedBy"} or not call.args:
continue
owner = symbolic_string(call.args[0])
if "F" not in owner:
continue
source = "f_" + owner[owner.find("F"):].split(".", 1)[0]
resolved = _resolved_body_alias(source, body_aliases)
if source in previous and resolved not in sources:
sources.append(resolved)
if not sources:
raise ValueError("transform source features are unresolved")
return sources
def _transform_body_references(
value: Any,
previous: list[str],
feature_by_id: dict[str, dict[str, Any]],
body_aliases: dict[str, str] | None = None,
) -> tuple[list[str], list[dict[str, Any]], list[dict[str, str]]]:
"""Lower direct CADFS body queries without flattening COPY provenance.
A pattern COPY is not its pattern's aggregate result. Keep the producer,
source body and instance index as a structured CDSL reference so runtime
can select a proven body member without receiving an internal state ID.
"""
sources: list[str] = []
instance_refs: list[dict[str, Any]] = []
transform_copy_refs: list[dict[str, str]] = []
body_aliases = body_aliases or {}
query_values = _queries(value)
# Older CADFS exports represent a directly created body as
# qCreatedBy(id + "F1", BODY), without a makeQuery topology wrapper.
# It has no COPY provenance, so the established source-feature contract
# remains exact and does not need a body-member instance reference.
if query_values and all(isinstance(item, Call) and item.name == "qCreatedBy" for item in query_values):
return _transform_source_features(value, previous, body_aliases), instance_refs, transform_copy_refs
for query_value in query_values:
_call, owner, topology, kind, definition = _direct_make_query(query_value)
if kind not in {"body", "entitytype.body"}:
raise UnsupportedCapability("transform_body_query", "CADFS transform requires direct body queries")
if topology == "SWEPT_BODY":
source = f"f_{owner}"
if source not in previous:
raise ValueError("transform source body is unresolved")
source = _resolved_body_alias(source, body_aliases)
if source not in sources:
sources.append(source)
continue
if topology != "COPY":
raise UnsupportedCapability(
"transform_body_query",
"CADFS transform requires SWEPT_BODY or circular-pattern COPY body queries",
)
derived = definition.get("derivedFrom")
if derived is None:
raise ValueError("pattern copy transform source is unresolved")
pattern_id = f"f_{owner}"
owner_feature = feature_by_id.get(pattern_id)
if owner_feature is not None and owner_feature.get("atomic_id") == "transform_bodies":
source, transform_copy_ref = _direct_transform_copy_member(
query_value, previous, feature_by_id, body_aliases,
)
if transform_copy_ref is not None:
if transform_copy_ref not in transform_copy_refs:
transform_copy_refs.append(transform_copy_ref)
elif source not in sources:
sources.append(source)
continue
_source_call, source_owner, source_topology, source_kind, _source_definition = _direct_make_query(derived)
source_id = _resolved_body_alias(f"f_{source_owner}", body_aliases)
pattern = feature_by_id.get(pattern_id)
if (
source_topology == "SWEPT_BODY"
and source_kind in {"body", "entitytype.body"}
and pattern is not None
and pattern.get("atomic_id") == "pattern_mirror"
and pattern_id in previous
and source_id in (pattern.get("params") or {}).get("source_feature_ids", [])
and (feature_by_id.get(source_id) or {}).get("params", {}).get("result_mode") == "new_body"
):
try:
instance = int(str(definition.get("instanceName")))
except (TypeError, ValueError) as error:
raise ValueError("mirror copy transform instance is unresolved") from error
if instance != 1:
raise UnsupportedCapability(
"transform_pattern_copy",
"direct mirror COPY provenance only has generated instance 1",
)
reference = {
"pattern_feature_id": pattern_id,
"source_feature_id": source_id,
"instance_index": instance,
}
if reference not in instance_refs:
instance_refs.append(reference)
continue
if (
source_topology != "SWEPT_BODY"
or source_kind not in {"body", "entitytype.body"}
or pattern is None
or pattern.get("atomic_id") != "pattern_circular"
or pattern_id not in previous
or source_id not in (pattern.get("params") or {}).get("source_feature_ids", [])
):
raise UnsupportedCapability(
"transform_pattern_copy",
"CADFS transform COPY body must name a direct source of a preceding circular pattern",
)
try:
instance = int(str(definition.get("instanceName")))
except (TypeError, ValueError) as error:
raise ValueError("pattern copy transform instance is unresolved") from error
count = int((pattern.get("params") or {}).get("pattern_count") or 0)
excluded = {int(value) for value in (pattern.get("params") or {}).get("excluded_instance_indices") or []}
if instance < 1 or instance >= count or instance in excluded:
raise ValueError("pattern copy transform instance is outside the generated range")
reference = {
"pattern_feature_id": pattern_id,
"source_feature_id": source_id,
"instance_index": instance,
}
if reference not in instance_refs:
instance_refs.append(reference)
if not sources and not instance_refs and not transform_copy_refs:
raise ValueError("transform source bodies are unresolved")
return sources, instance_refs, transform_copy_refs
def _body_transform(
params: dict[str, Any],
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
feature_by_id: dict[str, dict[str, Any]] | None = None,
previous: list[str] | None = None,
) -> dict[str, Any]:
"""Lower one CADFS body transform without changing the selected body."""
transform_type = str(params.get("transformType") or "").split(".")[-1].upper()
# FeatureScript COPY has no geometric displacement, but it does create a
# separate body member. Represent its identity geometry explicitly and let
# the enclosing transform_bodies operation retain the source via
# ``make_copy``. This stays on the OCC transform/history path rather than
# aliasing the source's runtime body.
if transform_type == "COPY":
return {"type": "translation", "translation_mm": [0.0, 0.0, 0.0]}
if transform_type == "TRANSLATION_3D":
return {
"type": "translation",
"translation_mm": [_number(params.get(key, 0.0), True) for key in ("dx", "dy", "dz")],
}
if transform_type == "TRANSLATION_DISTANCE":
return {
"type": "translation",
"translation_mm": _translation_distance_vector(
params, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, previous,
),
}
if transform_type == "TRANSLATION_ENTITY":
return {
"type": "translation",
"translation_mm": _translation_entity_vector(
params, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, previous,
),
}
if transform_type == "ROTATION":
axis = _transform_axis(params.get("transformAxis"), feature_frames, sketch_by_source, entity_by_sketch)
return {"type": "rotation", "axis": axis, "angle_deg": _number(params.get("angle"), True)}
if transform_type == "SCALE_UNIFORMLY":
scale_factor = _number(params.get("scale"))
if not math.isfinite(scale_factor) or scale_factor <= 0:
raise UnsupportedCapability(
"transform_uniform_scale",
"SCALE_UNIFORMLY requires a finite positive scale factor",
)
return {
"type": "uniform_scale",
"center_mm": _scale_center(params.get("scalePoint"), feature_frames, sketch_by_source, entity_by_sketch),
"scale_factor": scale_factor,
}
raise UnsupportedCapability("transform", f"current CDSL engine cannot exactly execute {transform_type or 'unknown'} transform")
def _delete_body_source(value: Any) -> str:
"""Resolve a directly owned body output without broad query expansion."""
_call, owner, topology, kind, _definition = _direct_make_query(value)
if kind not in {"body", "entitytype.body"} or topology not in {"SWEPT_BODY", "COPY"}:
raise UnsupportedCapability(
"delete_bodies",
"current CDSL deleteBodies requires a direct SWEPT_BODY or COPY body query",
)
return f"f_{owner}"
def _circular_pattern_axis(
value: Any,
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
) -> dict[str, list[float]]:
query = parse_query(value)
entity = (entity_by_sketch.get(query.source_sketch or "") or {}).get(query.source_entity or "")
if entity is None or query.source_sketch not in sketch_by_source:
raise ValueError("circular pattern axis is unresolved")
plane = sketch_by_source[query.source_sketch]["workplane"]
if entity["type"] == "line":
start = _global(plane, entity["start"]); end = _global(plane, entity["end"])
direction = [end[index] - start[index] for index in range(3)]
norm = math.sqrt(sum(component * component for component in direction))
if norm <= 1e-9:
raise ValueError("circular pattern axis line is degenerate")
return {"origin_mm": start, "direction": [component / norm for component in direction]}
if entity["type"] == "circle":
frame = feature_frames.get(query.owner_feature or "")
if frame and query.is_start is not None:
plane = frame["start" if query.is_start else "end"]
return {"origin_mm": _global(plane, entity["center"]), "direction": list(plane["normal"])}
raise ValueError("circular pattern axis must be a sketch line or circular edge")
def _loft_profile_sketches(params: dict[str, Any]) -> list[str]:
# CADFS loft 的 profile 是草图 IMPRINT 面;几何仍来自原始闭合草图,
# 保留草图 source,不能把前序实体的选中面近似为新的放样轮廓。
profiles = params.get("sheetProfilesArray")
if not isinstance(profiles, list):
raise ValueError("loft sheetProfilesArray is unresolved")
sources: list[str] = []
for profile in profiles:
query_value = profile.get("sheetProfileEntities") if isinstance(profile, dict) else profile
query = parse_query(query_value)
if query.topology_type and query.topology_type != "IMPRINT":
raise UnsupportedCapability(
f"loft_profile_topology:{query.topology_type.lower()}",
f"current CDSL loft only supports sketch-imprint profiles, not {query.topology_type}",
)
if not query.source_sketch:
raise ValueError("loft profile sketch query is unresolved")
sources.append(query.source_sketch)
if len(sources) < 2:
raise ValueError("loft requires at least two profile sketches")
if len(set(sources)) != len(sources):
raise ValueError("loft profile sketches must be distinct")
return sources
def _profile_query_kind(params: dict[str, Any]) -> str | None:
for key in ("entities", "sheetProfilesArray"):
if key in params:
return parse_query(params[key]).topology_type
return None
def _profile_executable(sketch: dict[str, Any]) -> bool:
profile = sketch.get("profile") or {}
if profile.get("type") == "circle": return True
if profile.get("type") == "polygon": return len(profile.get("vertices") or []) >= 3
if profile.get("type") == "planar_imprint":
return bool(profile.get("source_entities") and profile.get("selections"))
return bool(profile.get("contours"))
def _default_plane(value: Any) -> dict[str, Any] | None:
for call in walk_calls(value):
text = " ".join(symbolic_string(arg) for arg in call.args)
for name, plane in PLANES.items():
if f"{name}.planeOp" in text: return dict(plane)
return None
def _entity_from_query(
query: Any,
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
) -> tuple[dict[str, Any], dict[str, Any], str]:
info = parse_query(query); source = info.source_sketch or ""; token = info.source_entity or ""
available = entity_by_sketch.get(source) or {}
entity = available.get(token)
if entity is None:
entity_id = max((key for key in available if token.startswith(key + ".")), key=len, default="")
entity = available.get(entity_id)
sketch = sketch_by_source.get(source)
if entity is None or sketch is None:
raise ValueError("reference geometry source is unresolved")
return entity, sketch["workplane"], token
def _entity_point(entity: dict[str, Any], plane: dict[str, Any], token: str) -> list[float]:
if entity["type"] == "point": return _global(plane, entity["point"])
if entity["type"] == "circle" and ".center" in token:
return _global(plane, entity["center"])
if entity["type"] == "line":
local = entity["end"] if ".end" in token else entity["start"]
return _global(plane, local)
if entity["type"] == "arc":
if token.endswith(".start"):
return _global(plane, entity["start"])
if token.endswith(".end"):
return _global(plane, entity["end"])
raise ValueError("arc reference point is unresolved")
if entity["type"] == "bspline":
points = entity.get("points") or []
if not points: raise ValueError("B-spline reference point is unresolved")
# ``.start`` and ``.end`` are distinct direct source endpoints.
# FeatureScript's ``.N.internal`` suffixes are zero-based output
# vertex indexes; neither form may silently fall back to the first
# interpolation point.
if token.endswith(".start"):
index = 0
elif token.endswith(".end"):
index = len(points) - 1
else:
index = next((int(part) for part in token.split(".") if part.isdigit()), -1)
if index < 0:
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 _curve_endpoint_tangent(entity: dict[str, Any], endpoint: str) -> list[float]:
"""Return the exact directed source tangent at one direct curve endpoint.
``CURVE_POINT`` is defined by the curve tangent at its selected point, not
by a chord through adjacent fit points. The CADFS source carries exact
derivatives for the bounded B-spline case below, while an analytic arc has
an exact radial construction. Interior spline vertices and splines without
an exported endpoint derivative deliberately stay unsupported.
"""
if endpoint not in {"start", "end"}:
raise UnsupportedCapability(
"reference_plane:curve_point",
"CURVE_POINT requires a direct source-curve endpoint",
)
curve_type = entity.get("type")
if curve_type == "arc":
point = entity.get(endpoint)
center = entity.get("center")
if not (
isinstance(point, list) and len(point) == 2
and isinstance(center, list) and len(center) == 2
):
raise ValueError("arc endpoint tangent is unresolved")
radial = [float(point[index]) - float(center[index]) for index in range(2)]
return [radial[1], -radial[0]] if entity.get("clockwise") else [-radial[1], radial[0]]
if curve_type == "bspline":
tangent = entity.get(f"{endpoint}_tangent")
if not (
isinstance(tangent, list)
and len(tangent) == 2
and all(isinstance(value, (int, float)) and math.isfinite(value) for value in tangent)
):
raise UnsupportedCapability(
"reference_plane:curve_point",
"CURVE_POINT B-spline endpoint has no exported tangent",
)
return [float(value) for value in tangent]
raise UnsupportedCapability(
"reference_plane:curve_point",
"CURVE_POINT supports only direct lines, arcs, or B-spline endpoints with exported tangents",
)
def _curve_interpolation_tangent(entity: dict[str, Any], index: int) -> list[float]:
"""Return the kernel-proven tangent at one direct B-spline fit point.
An ``E<n>.<index>.internal`` source token denotes an interpolation vertex
emitted by the source sketch solver. It is neither a derived topology
suffix nor permission to estimate a tangent from adjacent fit points. We
reproduce the CDSL runtime's explicit-parameter, non-scaling OCC
interpolator and verify it still passes through that exact source point.
"""
if entity.get("type") != "bspline":
raise UnsupportedCapability(
"reference_plane:curve_point",
"CURVE_POINT internal point requires a direct B-spline source curve",
)
if entity.get("periodic"):
raise UnsupportedCapability(
"reference_plane:curve_point",
"CURVE_POINT does not support periodic B-spline interpolation points",
)
points = entity.get("points")
parameters = entity.get("parameters")
start_tangent = entity.get("start_tangent")
end_tangent = entity.get("end_tangent")
valid_vector = lambda value: (
isinstance(value, list)
and len(value) == 2
and all(isinstance(component, (int, float)) and math.isfinite(component) for component in value)
)
if (
not isinstance(points, list)
or not all(valid_vector(point) for point in points)
or not isinstance(parameters, list)
or not valid_vector(start_tangent)
or not valid_vector(end_tangent)
):
raise UnsupportedCapability(
"reference_plane:curve_point",
"CURVE_POINT B-spline interpolation point has no complete source interpolation data",
)
if index < 0 or index >= len(points):
raise UnsupportedCapability(
"reference_plane:curve_point",
"CURVE_POINT B-spline interpolation point is out of range",
)
try:
# Import the runtime helper lazily: conversion remains usable in a
# parser-only environment until this kernel-backed capability is used.
from engine.cdsl_engine.build123d_adapter import interpolated_bspline_point_and_tangent
point, tangent = interpolated_bspline_point_and_tangent(
[(*map(float, value), 0.0) for value in points],
start_tangent=(*map(float, start_tangent), 0.0),
end_tangent=(*map(float, end_tangent), 0.0),
parameters=[float(value) for value in parameters],
interpolation_index=index,
)
except (ImportError, TypeError, ValueError) as exc:
raise UnsupportedCapability(
"reference_plane:curve_point",
f"CURVE_POINT B-spline interpolation tangent is unavailable: {exc}",
) from exc
source_point = points[index]
if math.dist(point[:2], source_point) > 1e-7:
raise UnsupportedCapability(
"reference_plane:curve_point",
"CURVE_POINT B-spline interpolation does not reproduce its source vertex",
)
if math.hypot(tangent[0], tangent[1]) <= 1e-12:
raise UnsupportedCapability(
"reference_plane:curve_point",
"CURVE_POINT B-spline interpolation tangent is degenerate",
)
return [tangent[0], tangent[1]]
def _curve_point_tangent(
curve_query: Any,
point_query: Any,
point: list[float],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
) -> tuple[dict[str, Any], list[float]]:
"""Resolve one direct source curve's tangent at its explicitly named end."""
curve, plane, _ = _entity_from_query(curve_query, sketch_by_source, entity_by_sketch)
curve_info = parse_query(curve_query)
point_info = parse_query(point_query)
_point_entity, _point_plane, point_token = _entity_from_query(
point_query, sketch_by_source, entity_by_sketch,
)
source_entity_id = curve.get("source_entity_id")
if (
not isinstance(source_entity_id, str)
or curve_info.source_sketch != point_info.source_sketch
or not point_token.startswith(source_entity_id + ".")
):
raise UnsupportedCapability(
"reference_plane:curve_point",
"CURVE_POINT point must be an endpoint of the same direct source curve",
)
suffix = point_token[len(source_entity_id) + 1:]
interpolation_index: int | None = None
if suffix in {"start", "end"}:
local_point = curve.get(suffix)
else:
parts = suffix.split(".")
if (
curve.get("type") == "bspline"
and len(parts) == 2
and parts[0].isdigit()
and parts[1] == "internal"
):
interpolation_index = int(parts[0])
points = curve.get("points")
local_point = points[interpolation_index] if isinstance(points, list) and 0 <= interpolation_index < len(points) else None
else:
local_point = None
if not isinstance(local_point, list) or len(local_point) != 2:
raise UnsupportedCapability(
"reference_plane:curve_point",
"CURVE_POINT point must name a direct source-curve endpoint or B-spline interpolation vertex",
)
expected_point = _global(plane, local_point)
if math.dist(point, expected_point) > 1e-7:
raise UnsupportedCapability(
"reference_plane:curve_point",
"CURVE_POINT point does not match its source-curve endpoint",
)
local_tangent = (
_curve_interpolation_tangent(curve, interpolation_index)
if interpolation_index is not None
else _curve_endpoint_tangent(curve, suffix)
)
y_dir = _y_dir(plane)
tangent = [
local_tangent[0] * plane["x_dir"][index] + local_tangent[1] * y_dir[index]
for index in range(3)
]
return plane, tangent
def _local(plane: dict[str, Any], point: list[float]) -> list[float]:
relative = _sub(point, plane["origin_mm"])
return [_dot(relative, plane["x_dir"]), _dot(relative, _y_dir(plane))]
def _revolve_swept_face_profile(
query: Any,
plane: dict[str, Any],
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
) -> dict[str, Any] | None:
info = parse_query(query); frame = feature_frames.get(info.owner_feature or "")
if info.topology_type != "SWEPT_FACE" or frame is None or not frame.get("revolve_full"):
return None
axis = frame.get("revolve_axis")
if not isinstance(axis, dict): return None
start, end = _query_line(query, feature_frames, sketch_by_source, entity_by_sketch)
direction = _sub(end, start); axis_direction = axis["direction"]
if abs(_dot(direction, axis_direction)) > 1e-6 * max(1.0, math.sqrt(_dot(direction, direction))):
raise UnsupportedCapability("extrude_profile_topology:swept_face", "revolved swept face source line is not perpendicular to the revolve axis")
start_offset = _dot(_sub(start, axis["origin_mm"]), axis_direction)
end_offset = _dot(_sub(end, axis["origin_mm"]), axis_direction)
if abs(start_offset - end_offset) > 1e-6:
raise UnsupportedCapability("extrude_profile_topology:swept_face", "revolved swept face source line is not coplanar with the revolve axis")
center = [axis["origin_mm"][index] + axis_direction[index] * start_offset for index in range(3)]
radii = sorted([math.dist(start, center), math.dist(end, center)])
if radii[1] <= 1e-9:
raise ValueError("revolved swept face source line is degenerate")
local_center = _local(plane, center)
contours = [{"role": "outer", "closed": True, "segments": [{"type": "circle", "center": local_center, "radius_mm": radii[1]}]}]
if radii[0] > 1e-9:
contours.append({"role": "inner", "closed": True, "segments": [{"type": "circle", "center": local_center, "radius_mm": radii[0]}]})
return {"type": "analytic_contours", "contours": contours}
def _query_plane(
query: Any,
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
) -> dict[str, Any]:
try:
return _plane_from_query(query, feature_frames, sketch_by_source, entity_by_sketch)
except ValueError:
info = parse_query(query)
if info.topology_type != "SWEPT_FACE" or not info.owner_feature:
raise
entity, source_plane, _ = _entity_from_query(query, sketch_by_source, entity_by_sketch)
start, end = _entity_line(entity, source_plane); frame = feature_frames.get(info.owner_feature)
if frame is None: raise ValueError("swept face owner frame is unresolved")
if frame.get("revolve_full"):
axis = frame.get("revolve_axis")
if not isinstance(axis, dict): raise ValueError("revolve swept face axis is unresolved")
direction = _sub(end, start); axis_direction = axis["direction"]
if abs(_dot(direction, axis_direction)) > 1e-6 * max(1.0, math.sqrt(_dot(direction, direction))):
raise UnsupportedCapability("reference_plane:swept_face", "revolved swept face source line is not perpendicular to the revolve axis")
return _frame(start, direction, axis_direction)
direction = _unit(_sub(end, start), "swept face source line is degenerate")
sketch = sketch_by_source.get(info.source_sketch or "") or {}
contours = (sketch.get("profile") or {}).get("contours") or []
contour = next((
item for item in contours
if any(_matching_profile_segment(segment, entity) for segment in item.get("segments") or [])
), None)
points = [
segment["start"]
for segment in (contour or {}).get("segments") or []
if isinstance(segment.get("start"), list)
]
if points:
center = _global(source_plane, [
sum(point[index] for point in points) / len(points)
for index in range(2)
])
midpoint = [(start[index] + end[index]) / 2.0 for index in range(3)]
inward = _sub(center, midpoint)
inward = _sub(inward, [direction[index] * _dot(inward, direction) for index in range(3)])
normal = [-value for value in _unit(inward, "swept face interior is degenerate")]
else:
normal = _cross(direction, frame["end"]["normal"])
x_dir = direction
if _dot(_cross(normal, x_dir), source_plane["normal"]) < 0:
x_dir = [-value for value in x_dir]
# 附着草图的局部原点是全局原点在实体侧面上的投影,不是 source
# 草图边的任一端点。后者会把以原全局平面坐标表达的 F7/F9 等草图
# 平移一个完整边长,令后续 cut 落在主体之外。
return _attachment_plane(_frame(start, x_dir, normal))
def _offset_face_plane(
value: Any,
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
) -> dict[str, Any]:
"""Recover one planar inner shell wall from its source profile edge."""
query = parse_query(value)
frame = feature_frames.get(query.owner_feature or "") or {}
if query.topology_type != "OFFSET_FACE" or not frame.get("shell_source"):
raise ValueError("offset face owner is unresolved")
thickness = float(frame.get("shell_thickness_mm") or 0.0)
if thickness <= 0:
raise ValueError("offset face shell thickness is unresolved")
entity, source_plane, _ = _entity_from_query(value, sketch_by_source, entity_by_sketch)
start, end = _entity_line(entity, source_plane)
direction = _unit(_sub(end, start), "offset face source line is degenerate")
source_sketch = sketch_by_source.get(query.source_sketch or "") or {}
contours = (source_sketch.get("profile") or {}).get("contours") or []
contour = next((
item for item in contours
if any(_matching_profile_segment(segment, entity) for segment in item.get("segments") or [])
), None)
points = [
segment["start"]
for segment in (contour or {}).get("segments") or []
if isinstance(segment.get("start"), list)
]
if not points:
raise ValueError("offset face source contour is unresolved")
center = _global(source_plane, [
sum(point[index] for point in points) / len(points)
for index in range(2)
])
midpoint = [(start[index] + end[index]) / 2.0 for index in range(3)]
inward = _sub(center, midpoint)
inward = _sub(inward, [direction[index] * _dot(inward, direction) for index in range(3)])
normal = _unit(inward, "offset face source interior is degenerate")
x_dir = direction
if _dot(_cross(normal, x_dir), source_plane["normal"]) < 0:
x_dir = [-value for value in x_dir]
offset = [start[index] + thickness * normal[index] for index in range(3)]
return _attachment_plane(_frame(offset, x_dir, normal))
def _convex_linear_offset_wall_endpoints(
profile: dict[str, Any],
entity: dict[str, Any],
source_plane: dict[str, Any],
offset_plane: dict[str, Any],
thickness: float,
) -> tuple[list[float], list[float]] | None:
"""Offset one convex, closed, linear profile edge with its true neighbors.
A shell's offset wall ends at the intersections with the offset adjacent
walls. This is not equivalent to translating the selected outer edge: a
rectangular wall, for example, shortens at both corners. Restrict this
construction to one ordered convex line loop so every miter and its
interior side are uniquely defined.
"""
contours = (profile.get("profile") or {}).get("contours") or []
if len(contours) != 1:
return None
contour = contours[0]
segments = contour.get("segments") or []
if (
not contour.get("closed")
or len(segments) < 3
or any(segment.get("type") != "line" for segment in segments)
or any(not _same_point(segment["end"], segments[(index + 1) % len(segments)]["start"]) for index, segment in enumerate(segments))
):
return None
matches = [index for index, segment in enumerate(segments) if _matching_profile_segment(segment, entity)]
if len(matches) != 1:
return None
area_twice = sum(
segment["start"][0] * segment["end"][1] - segment["end"][0] * segment["start"][1]
for segment in segments
)
if abs(area_twice) <= 1e-9:
return None
orientation = 1.0 if area_twice > 0 else -1.0
def interior_normal(segment: dict[str, Any]) -> list[float] | None:
dx = segment["end"][0] - segment["start"][0]
dy = segment["end"][1] - segment["start"][1]
length = math.hypot(dx, dy)
if length <= 1e-9:
return None
return [-orientation * dy / length, orientation * dx / length]
normals = [interior_normal(segment) for segment in segments]
if any(normal is None for normal in normals):
return None
# A convex loop has one consistent signed turn direction. Concave offset
# boundaries can self-intersect and require the shell kernel's exact trim
# history, so they deliberately remain deferred.
turn_signs = []
for index, segment in enumerate(segments):
next_segment = segments[(index + 1) % len(segments)]
dx = segment["end"][0] - segment["start"][0]
dy = segment["end"][1] - segment["start"][1]
next_dx = next_segment["end"][0] - next_segment["start"][0]
next_dy = next_segment["end"][1] - next_segment["start"][1]
turn = dx * next_dy - dy * next_dx
if abs(turn) <= 1e-9:
return None
turn_signs.append(1.0 if turn > 0 else -1.0)
if any(sign != turn_signs[0] for sign in turn_signs):
return None
selected_index = matches[0]
selected_normal = normals[selected_index]
global_normal = [
source_plane["x_dir"][index] * selected_normal[0] + _y_dir(source_plane)[index] * selected_normal[1]
for index in range(3)
]
if abs(_dot(_unit(global_normal, "offset wall normal is degenerate"), offset_plane["normal"]) - 1.0) > 1e-6:
return None
def shifted_line(index: int) -> tuple[list[float], list[float]]:
segment = segments[index]
normal = normals[index]
return (
[segment["start"][axis] + thickness * normal[axis] for axis in range(2)],
[segment["end"][axis] + thickness * normal[axis] for axis in range(2)],
)
def intersection(
first_start: list[float], first_end: list[float], second_start: list[float], second_end: list[float],
) -> list[float] | None:
first_direction = [first_end[0] - first_start[0], first_end[1] - first_start[1]]
second_direction = [second_end[0] - second_start[0], second_end[1] - second_start[1]]
denominator = first_direction[0] * second_direction[1] - first_direction[1] * second_direction[0]
if abs(denominator) <= 1e-9:
return None
difference = [second_start[0] - first_start[0], second_start[1] - first_start[1]]
scale = (difference[0] * second_direction[1] - difference[1] * second_direction[0]) / denominator
return [first_start[axis] + scale * first_direction[axis] for axis in range(2)]
previous = (selected_index - 1) % len(segments)
following = (selected_index + 1) % len(segments)
selected_start, selected_end = shifted_line(selected_index)
previous_start, previous_end = shifted_line(previous)
following_start, following_end = shifted_line(following)
first = intersection(previous_start, previous_end, selected_start, selected_end)
second = intersection(selected_start, selected_end, following_start, following_end)
if first is None or second is None or _same_point(first, second):
return None
return _global(source_plane, first), _global(source_plane, second)
def _offset_face_profile_sketch(
value: Any,
feature_frames: dict[str, dict[str, Any]],
sketches_by_id: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
feature_by_id: dict[str, dict[str, Any]],
feature_id: str,
) -> dict[str, Any] | None:
"""Materialize one planar, linear-extrude OFFSET_FACE wall when proven.
An offset face is a generated side wall, not the source extrusion's cap
region. Reusing the full source profile therefore changes both the face
extent and its topology. The wall is reconstructible only when direct
source provenance proves one non-construction edge of a convex linear
profile, one finite direct extrusion span, and one removed extrusion cap.
All other OFFSET_FACE queries remain an explicit deferred capability
rather than receiving a guessed profile.
"""
query = parse_query(value)
frame = feature_frames.get(query.owner_feature or "") or {}
profile_id = frame.get("shell_profile_sketch_id")
profile = sketches_by_id.get(str(profile_id)) if profile_id else None
shell_source = frame.get("shell_source")
direct_frame = feature_frames.get(str(shell_source)) if shell_source else None
direct_feature = feature_by_id.get(f"f_{shell_source}") if shell_source else None
profile_source = (direct_frame or {}).get("profile_source")
source_sketch = sketch_by_source.get(str(profile_source)) if profile_source else None
refs = _source_refs(value)
if (
query.topology_type != "OFFSET_FACE"
or profile is None
or direct_frame is None
or direct_feature is None
or direct_feature.get("atomic_id") != "extrude_add_blind"
or (direct_feature.get("params") or {}).get("result_mode") != "new_body"
or (direct_feature.get("params") or {}).get("draft") is not None
or frame.get("shell_inward") is not True
or frame.get("shell_removed_cap") not in {"start", "end"}
or not isinstance(profile_source, str)
or source_sketch is None
or not _profile_matches_direct_source(profile, source_sketch)
or len(refs) != 1
or refs[0][0] != profile_source
or query.source_sketch != profile_source
or query.source_entity != refs[0][1]
):
return None
entity = (entity_by_sketch.get(profile_source) or {}).get(refs[0][1])
if entity is None or entity.get("type") != "line" or entity.get("construction"):
return None
profile_plane = direct_frame.get("profile")
start_plane = direct_frame.get("start")
end_plane = direct_frame.get("end")
if not all(isinstance(plane, dict) for plane in (profile_plane, start_plane, end_plane)):
return None
try:
source_start, source_end = _convex_linear_offset_wall_endpoints(
profile, entity, source_sketch["workplane"],
_offset_face_plane(value, feature_frames, sketch_by_source, entity_by_sketch),
float(frame["shell_thickness_mm"]),
) or (None, None)
if source_start is None or source_end is None:
return None
line_direction = _unit(_sub(source_end, source_start), "offset face source line is degenerate")
span = _sub(end_plane["origin_mm"], start_plane["origin_mm"])
span_direction = _unit(span, "offset face extrusion span is degenerate")
profile_normal = _unit(profile_plane["normal"], "offset face extrusion profile normal is degenerate")
if (
abs(_dot(line_direction, span_direction)) > 1e-6
or abs(abs(_dot(span_direction, profile_normal)) - 1.0) > 1e-6
):
return None
cap_shift = _sub(start_plane["origin_mm"], profile_plane["origin_mm"])
if not all(math.isfinite(component) for point in (source_start, source_end, span, cap_shift) for component in point):
return None
offset_plane = _offset_face_plane(value, feature_frames, sketch_by_source, entity_by_sketch)
except (KeyError, TypeError, ValueError):
return None
cap_shrink = [float(frame["shell_thickness_mm"]) * component for component in span_direction]
start_shift = list(cap_shift)
end_shift = [cap_shift[index] + span[index] for index in range(3)]
if frame["shell_removed_cap"] == "end":
start_shift = [start_shift[index] + cap_shrink[index] for index in range(3)]
else:
end_shift = [end_shift[index] - cap_shrink[index] for index in range(3)]
corners = [
[source_start[index] + start_shift[index] for index in range(3)],
[source_end[index] + start_shift[index] for index in range(3)],
[source_end[index] + end_shift[index] for index in range(3)],
[source_start[index] + end_shift[index] for index in range(3)],
]
local_corners = [_local(offset_plane, corner) for corner in corners]
if not all(math.isfinite(component) for point in local_corners for component in point):
return None
segments = [
{"type": "line", "start": local_corners[index], "end": local_corners[(index + 1) % len(local_corners)]}
for index in range(len(local_corners))
]
return {
"id": f"sketch_{query.owner_feature}__{feature_id}",
"name": f"{query.owner_feature}__{feature_id}",
"workplane": offset_plane,
"profile": {"type": "analytic_contours", "contours": [{"role": "unknown", "closed": True, "segments": segments}]},
}
def _query_line(
query: Any,
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
) -> tuple[list[float], list[float]]:
info = parse_query(query)
entity, plane, _ = _entity_from_query(query, sketch_by_source, entity_by_sketch)
if info.topology_type == "CAP_EDGE" and info.owner_feature in feature_frames:
frame_data = feature_frames[info.owner_feature]
frame = frame_data["start" if info.is_start else "end"]
# CAP outer normals may flip the x direction to preserve the later
# sketch attachment handedness. A source sketch edge, however, keeps
# its original physical in-plane coordinates at either cap. Preserve
# that profile frame and move only its origin to the selected cap.
profile = frame_data.get("profile")
plane = {**profile, "origin_mm": list(frame["origin_mm"])} if isinstance(profile, dict) else frame
if entity["type"] == "circle":
center = _global(plane, entity["center"])
return center, [center[index] + frame["normal"][index] for index in range(3)]
if info.topology_type == "SWEPT_FACE" and entity["type"] == "circle" and info.owner_feature in feature_frames:
frame = feature_frames[info.owner_feature]
start, end = frame.get("start"), frame.get("end")
if start is None or end is None: raise ValueError("cylindrical swept face frame is unresolved")
center = _global(plane, entity["center"])
return [center[index] + start["origin_mm"][index] - plane["origin_mm"][index] for index in range(3)], [center[index] + end["origin_mm"][index] - plane["origin_mm"][index] for index in range(3)]
return _entity_line(entity, plane)
def _transform_axis(
query: Any,
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
) -> dict[str, list[float]]:
start, end = _query_line(query, feature_frames, sketch_by_source, entity_by_sketch)
return {"origin_mm": start, "direction": _unit(_sub(end, start), "transform axis is degenerate")}
def _scale_center(
value: Any,
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
) -> list[float]:
"""Resolve the explicit center of a CADFS uniform scale.
Origin point is a system datum with a known coordinate. Other centers must
name exactly one direct sketch or CAP_VERTEX source whose physical point is
available in the lowering state. COPY/SWEPT/OFFSET vertices need runtime
topology provenance, so treating their nearest visible point as the scale
center would change the operation's semantics.
"""
centers = _queries(value)
if len(centers) != 1:
raise UnsupportedCapability(
"transform_uniform_scale_center",
"SCALE_UNIFORMLY requires exactly one explicit scale point",
)
center = centers[0]
for call in walk_calls(center):
if call.name == "qCreatedBy" and call.args and "Origin.pointOp" in symbolic_string(call.args[0]):
return [0.0, 0.0, 0.0]
info = parse_query(center)
if info.kind not in {"vertex", "entitytype.vertex"} or info.topology_type not in {None, "CAP_VERTEX"}:
raise UnsupportedCapability(
"transform_uniform_scale_center",
"SCALE_UNIFORMLY scale point must be Origin point or a direct sketch/CAP_VERTEX",
)
try:
return _query_point(center, feature_frames, sketch_by_source, entity_by_sketch)
except ValueError as error:
raise UnsupportedCapability(
"transform_uniform_scale_center",
"SCALE_UNIFORMLY scale point must resolve to one physical vertex",
) from error
def _translation_distance_vector(
params: dict[str, Any],
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
feature_by_id: dict[str, dict[str, Any]] | None = None,
previous: list[str] | None = None,
) -> list[float]:
"""Resolve the restricted CADFS TRANSLATION_DISTANCE direction contract.
An edge direction is only unambiguous when it comes from one source
sketch line or its CAP_EDGE descendant. A direct CAP_FACE is also exact
when its producer has recorded a physical start/end frame: its selected
cap normal is the FeatureScript direction. A generic face normal, swept
edge, offset edge, and curve tangent each need different source semantics,
so they remain explicit capability gaps instead of borrowing a nearby
direction from the active body.
"""
directions = _queries(params.get("transformDirection"))
if len(directions) != 1:
raise UnsupportedCapability(
"transform_translation_direction",
"TRANSLATION_DISTANCE requires exactly one direct linear sketch or CAP_EDGE direction",
)
direction_query = directions[0]
info = parse_query(direction_query)
# System datum planes and previously lowered reference planes have an
# explicit physical normal. ``_query_plane`` accepts only those two
# qCreatedBy forms here, so this never treats an arbitrary produced face
# as a translation direction.
if "qCreatedBy" in info.calls and info.topology_type is None:
try:
direction = _unit(
list(_query_plane(direction_query, feature_frames, sketch_by_source, entity_by_sketch)["normal"]),
"transform reference-plane normal is degenerate",
)
except ValueError as error:
raise UnsupportedCapability(
"transform_translation_direction",
"TRANSLATION_DISTANCE reference-plane direction must have an explicit plane frame",
) from error
elif info.kind in {"face", "entitytype.face"} and info.topology_type == "SWEPT_FACE":
try:
direction = _unit(
list(_query_plane(direction_query, feature_frames, sketch_by_source, entity_by_sketch)["normal"]),
"transform swept-face normal is degenerate",
)
except (UnsupportedCapability, ValueError) as error:
raise UnsupportedCapability(
"transform_translation_direction",
"TRANSLATION_DISTANCE SWEPT_FACE direction requires one direct planar source face",
) from error
elif info.kind in {"face", "entitytype.face"} and info.topology_type == "CAP_FACE" and info.is_start is not None:
frame = feature_frames.get(info.owner_feature or "") or {}
cap = frame.get("start" if info.is_start else "end")
try:
direction = _unit(list((cap or {}).get("normal") or []), "transform cap-face normal is degenerate")
except ValueError as error:
raise UnsupportedCapability(
"transform_translation_direction",
"TRANSLATION_DISTANCE CAP_FACE direction requires a producer with a physical cap frame",
) from error
else:
if info.kind not in {"edge", "entitytype.edge"} or info.topology_type not in {None, "CAP_EDGE"}:
raise UnsupportedCapability(
"transform_translation_direction",
"TRANSLATION_DISTANCE only supports a linear sketch/CAP_EDGE, exact transform copy, explicit reference plane, direct planar SWEPT_FACE, or framed CAP_FACE direction",
)
try:
entity, _plane, _token = _entity_from_query(direction_query, sketch_by_source, entity_by_sketch)
if entity.get("type") != "line":
raise ValueError("transform direction source is not linear")
if feature_by_id is not None and previous is not None:
start, end = _transform_copy_line(
direction_query, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, previous,
)
else:
start, end = _query_line(direction_query, feature_frames, sketch_by_source, entity_by_sketch)
direction = _unit(_sub(end, start), "transform translation direction is degenerate")
except ValueError as error:
raise UnsupportedCapability(
"transform_translation_direction",
"TRANSLATION_DISTANCE direction must resolve to a non-degenerate line",
) from error
distance = _number(params.get("distance"), True)
if distance < 0:
raise UnsupportedCapability(
"transform_translation_distance",
"TRANSLATION_DISTANCE requires a non-negative distance",
)
if _bool(params.get("oppositeDirection")):
distance = -distance
return [component * distance for component in direction]
def _translation_entity_vector(
params: dict[str, Any],
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
feature_by_id: dict[str, dict[str, Any]] | None = None,
previous: list[str] | None = None,
) -> list[float]:
"""Resolve a direct CADFS TRANSLATION_ENTITY vector without topology guesses.
FeatureScript accepts either a line entity, whose endpoint delta is the
translation vector, or two vertices interpreted in selection order. This
restricted lowering accepts raw sketch/CAP descendants and exact copies
made by explicit single-source transforms. Other COPY/SWEPT/OFFSET
geometry requires a kernel-proven successor relation.
"""
entities = _queries(params.get("transformLine"))
if len(entities) == 1:
line = entities[0]
info = parse_query(line)
if info.kind not in {"edge", "entitytype.edge"} or info.topology_type not in {None, "CAP_EDGE"}:
raise UnsupportedCapability(
"transform_translation_entity",
"TRANSLATION_ENTITY requires a linear sketch/CAP_EDGE or its exact transform copy",
)
try:
entity, _plane, _token = _entity_from_query(line, sketch_by_source, entity_by_sketch)
if entity.get("type") != "line":
raise ValueError("transform line source is not linear")
if feature_by_id is not None and previous is not None:
start, end = _transform_copy_line(
line, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, previous,
)
else:
start, end = _query_line(line, feature_frames, sketch_by_source, entity_by_sketch)
except ValueError as error:
raise UnsupportedCapability(
"transform_translation_entity",
"TRANSLATION_ENTITY line must resolve to a non-degenerate line",
) from error
elif len(entities) == 2:
first, second = entities
first_info, second_info = parse_query(first), parse_query(second)
if (
first_info.kind not in {"vertex", "entitytype.vertex"}
or second_info.kind not in {"vertex", "entitytype.vertex"}
or first_info.topology_type not in {None, "CAP_VERTEX"}
or second_info.topology_type not in {None, "CAP_VERTEX"}
):
raise UnsupportedCapability(
"transform_translation_entity",
"TRANSLATION_ENTITY requires exactly two sketch/CAP_VERTEX points or their exact transform copies",
)
try:
if feature_by_id is not None and previous is not None:
start = _transform_copy_point(
first, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, previous,
)
end = _transform_copy_point(
second, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, previous,
)
else:
start = _query_point(first, feature_frames, sketch_by_source, entity_by_sketch)
end = _query_point(second, feature_frames, sketch_by_source, entity_by_sketch)
except ValueError as error:
raise UnsupportedCapability(
"transform_translation_entity",
"TRANSLATION_ENTITY vertices must resolve to unique points",
) from error
else:
raise UnsupportedCapability(
"transform_translation_entity",
"TRANSLATION_ENTITY requires one direct line or exactly two direct vertices",
)
vector = _sub(end, start)
if math.sqrt(sum(component * component for component in vector)) <= 1e-9:
raise UnsupportedCapability("transform_translation_entity", "TRANSLATION_ENTITY vector is degenerate")
if _bool(params.get("oppositeDirectionEntity")):
vector = [-component for component in vector]
return vector
def _bake_transform(
params: dict[str, Any],
previous: list[str],
feature_by_id: dict[str, dict[str, Any]],
feature_source_by_id: dict[str, str],
sketches_by_id: dict[str, dict[str, Any]],
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
) -> None:
if _bool(params.get("makeCopy")):
raise UnsupportedCapability("transform", "current CDSL engine cannot exactly copy transformed CADFS source bodies")
sources = _transform_source_features(params.get("entities"), previous)
if len(sources) != 1:
raise UnsupportedCapability("transform", "current CDSL engine cannot exactly transform multiple selected CADFS source bodies")
source_id = sources[0]; source = feature_by_id.get(source_id)
if source is None or source.get("atomic_id") not in {"extrude_add_blind", "extrude_add_two_sided", "revolve_add"}:
raise UnsupportedCapability("transform", "current CDSL engine can only bake a direct additive extrusion or revolve transform")
sketch = sketches_by_id.get(str(source.get("sketch_id") or ""))
source_feature_id = feature_source_by_id.get(source_id)
if sketch is None or source_feature_id is None:
raise UnsupportedCapability("transform", "current CDSL engine cannot resolve the transformed source feature geometry")
transform_type = str(params.get("transformType") or "").split(".")[-1].upper()
if transform_type == "TRANSLATION_3D":
offset = [_number(params.get(key, 0.0), True) for key in ("dx", "dy", "dz")]
transform_frame = lambda frame: _translate_frame(frame, offset)
transform_axis = lambda axis: {**axis, "origin_mm": [axis["origin_mm"][index] + offset[index] for index in range(3)]}
elif transform_type == "TRANSLATION_DISTANCE":
offset = _translation_distance_vector(params, feature_frames, sketch_by_source, entity_by_sketch)
transform_frame = lambda frame: _translate_frame(frame, offset)
transform_axis = lambda axis: {**axis, "origin_mm": [axis["origin_mm"][index] + offset[index] for index in range(3)]}
elif transform_type == "TRANSLATION_ENTITY":
# Baking is only semantics-preserving while the selected NEW result
# has not been absorbed or changed by another body-mutating feature.
if source.get("params", {}).get("result_mode") != "new_body" or previous[-1:] != [source_id]:
raise UnsupportedCapability(
"transform_body_lifecycle",
"TRANSLATION_ENTITY bake requires an immediately preceding independent NEW body",
)
offset = _translation_entity_vector(params, feature_frames, sketch_by_source, entity_by_sketch)
transform_frame = lambda frame: _translate_frame(frame, offset)
transform_axis = lambda axis: {**axis, "origin_mm": [axis["origin_mm"][index] + offset[index] for index in range(3)]}
elif transform_type == "ROTATION":
axis = _transform_axis(params.get("transformAxis"), feature_frames, sketch_by_source, entity_by_sketch)
angle_rad = math.radians(_number(params.get("angle"), True))
transform_frame = lambda frame: _rotate_frame(frame, axis, angle_rad)
transform_axis = lambda value: {
**value,
"origin_mm": _rotate_point(value["origin_mm"], axis, angle_rad),
"direction": _rotate(value["direction"], axis["direction"], angle_rad),
}
else:
raise UnsupportedCapability("transform", f"current CDSL engine cannot exactly bake {transform_type or 'unknown'} transform")
sketch["workplane"] = transform_frame(sketch["workplane"])
frame = feature_frames.get(source_feature_id)
if frame is not None:
transformed = {key: transform_frame(value) for key, value in frame.items() if key in {"start", "end", "profile"}}
for cap in ("start", "end"):
if cap in transformed:
transformed[f"{cap}_attachment"] = _attachment_plane(transformed[cap])
if isinstance(frame.get("revolve_axis"), dict): transformed["revolve_axis"] = transform_axis(frame["revolve_axis"])
if "revolve_full" in frame: transformed["revolve_full"] = frame["revolve_full"]
feature_frames[source_feature_id] = transformed
source_axis = source.get("params", {}).get("axis")
if isinstance(source_axis, dict) and source_axis.get("origin_mm") and source_axis.get("direction"):
source["params"]["axis"] = transform_axis(source_axis)
def _record_non_copy_body_successors(
aliases: dict[str, str],
source_ids: list[str],
successor_id: str,
) -> None:
"""Bind direct transform sources to their latest physical body member.
A non-copy transform replaces exactly the selected independent members in
the runtime. Preserve that one-to-one lifecycle fact for later CADFS
queries that retain the original producer ID. This deliberately has no
fallback for fused/dress-up/pattern members because those are not entered
into ``aliases`` by lowering.
"""
for source in source_ids:
for owner, current in list(aliases.items()):
if current == source:
aliases[owner] = successor_id
aliases[source] = successor_id
_SINGLE_BODY_FUSING_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided",
"extrude_from_face", "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add",
})
_SINGLE_BODY_DRESSUP_ATOMICS = frozenset({"fillet", "chamfer", "shell"})
_SINGLE_BODY_CUT_ATOMICS = frozenset({
"extrude_cut_blind", "extrude_cut_two_sided", "revolve_cut",
"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard", "thread_cut",
})
_SINGLE_BODY_NON_MUTATING_ATOMICS = frozenset({"reference_plane", "reference_axis", "extrude_surface", "revolve_surface"})
_LOWERING_BODY_MUTATING_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided",
"extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "extrude_from_face",
"loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", "revolve_cut",
"sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add",
"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard", "fillet", "chamfer",
"shell", "boolean_bodies",
})
_LOWERING_CUT_ATOMICS = frozenset({
"extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "revolve_cut",
"thread_cut", "hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard",
})
_LOWERING_PRIMARY_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_from_face",
"loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", "sphere_add", "box_add",
"cylinder_add", "thread_add", "bend_add",
})
def _record_lowered_body_members(members: set[str], feature: dict[str, Any]) -> None:
"""Mirror the runtime's independently selectable body-member contract.
This projection is intentionally narrower than geometric body ownership.
It only decides whether a later direct CADFS SWEPT_BODY can be emitted as a
CDSL shell target. No aggregate/current-body fallback is permitted here.
"""
feature_id = str(feature["id"])
atomic_id = str(feature.get("atomic_id") or "")
params = feature.get("params") or {}
if atomic_id == "boolean_bodies":
targets = {str(value) for value in params.get("target_feature_ids") or ()}
tools = {str(value) for value in params.get("tool_feature_ids") or ()}
members.difference_update(targets | tools)
members.add(feature_id)
if bool(params.get("keep_tools")):
members.update(tools)
return
if atomic_id == "transform_bodies":
sources = {str(value) for value in params.get("source_feature_ids") or ()}
if bool(params.get("make_copy")):
if len(sources) == 1:
members.add(feature_id)
return
members.difference_update(sources)
members.add(feature_id)
return
if atomic_id == "delete_bodies":
members.difference_update(str(value) for value in params.get("target_feature_ids") or ())
return
if atomic_id in {"pattern_linear", "pattern_mirror", "pattern_circular"}:
sources = {str(value) for value in params.get("source_feature_ids") or ()}
if not (
atomic_id == "pattern_circular"
and str(params.get("operation_mode") or "add") == "add"
and sources
and sources <= members
):
members.clear()
return
if atomic_id not in _LOWERING_BODY_MUTATING_ATOMICS:
return
if atomic_id in _LOWERING_CUT_ATOMICS or (
atomic_id == "extrude_from_face" and params.get("operation") == "cut"
):
return
if atomic_id in _LOWERING_PRIMARY_ATOMICS and params.get("result_mode") == "new_body":
members.add(feature_id)
return
members.clear()
members.add(feature_id)
def _clear_single_body_successor_state(
aliases: dict[str, str],
state: dict[str, Any],
) -> None:
"""Discard only aliases derived from the restricted aggregate lineage."""
for source in state["sources"]:
aliases.pop(source, None)
state["owner"] = None
state["sources"] = set()
def _record_single_body_successor(
aliases: dict[str, str],
state: dict[str, Any],
feature: dict[str, Any],
) -> None:
"""Track one CADFS body through exact single-aggregate successors.
A ``SWEPT_BODY`` query names a CADFS body object, not a frozen feature
result. When an ordinary additive feature or dress-up mutates the only
active body, the original body query still denotes that same physical
body. Runtime collapses those operations to one explicit body member, so
lowering may follow the successor only while this state machine mirrors
that one-member lifecycle exactly. Multi-body, boolean, pattern, delete,
and other body-changing paths intentionally clear the proof rather than
substituting ``session.body``.
"""
feature_id = str(feature["id"])
atomic_id = str(feature.get("atomic_id") or "")
params = feature.get("params") or {}
owner = state["owner"]
sources: set[str] = state["sources"]
def advance() -> None:
for source in sources:
if source != feature_id:
aliases[source] = feature_id
aliases.pop(feature_id, None)
sources.add(feature_id)
state["owner"] = feature_id
if atomic_id in _SINGLE_BODY_NON_MUTATING_ATOMICS:
return
if atomic_id in _SINGLE_BODY_CUT_ATOMICS:
# Runtime preserves the selected member keys for a cut. The cut
# feature itself is not a new independently selectable body member.
return
if atomic_id in _SINGLE_BODY_FUSING_ATOMICS:
if atomic_id == "extrude_from_face" and params.get("operation") == "cut":
return
if params.get("result_mode") == "new_body":
if owner is None:
state["owner"] = feature_id
sources.add(feature_id)
else:
_clear_single_body_successor_state(aliases, state)
return
if owner is None:
state["owner"] = feature_id
sources.add(feature_id)
else:
advance()
return
if atomic_id in _SINGLE_BODY_DRESSUP_ATOMICS:
if owner is not None:
advance()
return
if atomic_id == "transform_bodies":
source_ids = [str(value) for value in params.get("source_feature_ids") or ()]
if bool(params.get("make_copy")):
# The original member remains addressable, but the aggregate is no
# longer a one-body lifecycle. Do not let a later ordinary ADD or
# dress-up advance an alias across that unproven split.
_clear_single_body_successor_state(aliases, state)
return
if owner is not None and source_ids == [owner] and not params.get("pattern_instance_refs"):
advance()
return
_clear_single_body_successor_state(aliases, state)
return
if atomic_id == "pattern_circular":
# A direct circular ADD over the sole current member takes the runtime
# body-member path: it preserves that member and exposes each rotated
# copy under an exact instance key. Keep the already-proven aliases so
# a following CADFS COPY(SWEPT_BODY) resolves to the same member.
source_ids = [str(value) for value in params.get("source_feature_ids") or ()]
if (
owner is not None
and str(params.get("operation_mode") or "add") == "add"
and source_ids == [owner]
):
return
# The remaining body atomics either split ownership, select explicit
# members, or replay feature geometry. Their CADFS body continuation is
# not represented by this restricted one-member contract.
_clear_single_body_successor_state(aliases, state)
def _query_point(
query: Any,
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
) -> list[float]:
info = parse_query(query)
if info.topology_type == "CAP_VERTEX" and info.owner_feature in feature_frames:
frame_data = feature_frames[info.owner_feature]
cap = frame_data["start" if info.is_start else "end"]
profile = frame_data.get("profile")
plane = {**profile, "origin_mm": list(cap["origin_mm"])} if isinstance(profile, dict) else cap
references = _source_refs(query)
if len(references) >= 2:
lines = []
for source, token in references:
available = entity_by_sketch.get(source) or {}
entity = available.get(token)
if entity is None:
entity_id = max((key for key in available if token.startswith(key + ".")), key=len, default="")
entity = available.get(entity_id)
if entity and entity.get("type") == "line": lines.append(_entity_line(entity, plane))
if len(lines) >= 2:
pairs = [(math.dist(left, right), left) for left in lines[0] for right in lines[1]]
distance, point = min(pairs, key=lambda item: item[0])
if distance <= 1e-5: return point
entity, plane, token = _entity_from_query(query, sketch_by_source, entity_by_sketch)
if info.topology_type == "CAP_VERTEX" and info.owner_feature in feature_frames:
frame_data = feature_frames[info.owner_feature]
cap = frame_data["start" if info.is_start else "end"]
profile = frame_data.get("profile")
plane = {**profile, "origin_mm": list(cap["origin_mm"])} if isinstance(profile, dict) else cap
return _entity_point(entity, plane, token)
def _featurescript_perpendicular_vector(value: list[float]) -> list[float]:
"""Match FeatureScript 1511's deterministic ``perpendicularVector``.
``LINE_ANGLE`` with one selected axis has no second reference that can
determine its zero-angle direction. The standard library deliberately
supplies one with ``perpendicularVector``; using an arbitrary local frame
here changes subsequent sketch coordinates. Keep the same branch
thresholds as the 1511 implementation instead of deriving a direction
from a current body or a topology hint.
"""
direction = _unit(value, "line-angle reference axis is degenerate")
if abs(direction[0]) > 1.036663652861932668633 * abs(direction[1]):
different = [0.0, 0.0, 1.0] if abs(direction[0]) > .951702989392233451722 * abs(direction[2]) else [0.0, 1.0, 0.0]
else:
different = [1.0, 0.0, 0.0] if abs(direction[1]) > .920419947455385938102 * abs(direction[2]) else [0.0, 1.0, 0.0]
return _unit(_cross(different, direction), "line-angle reference axis is degenerate")
def _direct_line_angle_source_entity(
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] | None:
"""Resolve one exact sketch entity without widening it to topology.
``LINE_ANGLE`` accepts direct sketch construction geometry as well as
profile geometry. The source entity key must therefore match exactly;
suffix matching would incorrectly turn a trim, CAP, SWEPT, COPY, or other
derived query into an original sketch reference.
"""
info = parse_query(query)
direct_calls = {"sQuery", "sketchEntityQuery", "__binary__"}
if (
info.topology_type is not None
or not info.calls
or any(call not in direct_calls for call in info.calls)
or not isinstance(info.source_sketch, str)
or not isinstance(info.source_entity, str)
):
return None
sketch = sketch_by_source.get(info.source_sketch)
entity = (entity_by_sketch.get(info.source_sketch) or {}).get(info.source_entity)
if sketch is None or entity is None:
return None
return entity, sketch["workplane"], info.source_entity
def _derived_line_angle_cylinder_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]]],
) -> tuple[dict[str, Any], dict[str, Any], str] | None:
"""Accept the legacy derived-cylinder contract without widening it.
The older LINE_ANGLE compatibility path is not a general fallback for a
query which happens to mention a source line. It is limited to a direct
source circle selected as a CAP_EDGE or SWEPT_FACE of an operation whose
recorded frame can prove its cylinder axis.
"""
try:
_call, owner, topology, kind, _definition = _direct_make_query(query)
except ValueError:
return None
if (
(topology == "CAP_EDGE" and kind != "edge")
or (topology == "SWEPT_FACE" and kind != "face")
or topology not in {"CAP_EDGE", "SWEPT_FACE"}
):
return None
info = parse_query(query)
if info.owner_feature != owner or not isinstance(info.source_sketch, str) or not isinstance(info.source_entity, str):
return None
references = _source_refs(query)
if references != [(info.source_sketch, info.source_entity)]:
return None
resolved = _source_ref_entity(info.source_sketch, info.source_entity, entity_by_sketch)
sketch = sketch_by_source.get(info.source_sketch)
frame = feature_frames.get(owner)
if (
resolved is None
or sketch is None
or frame is None
or resolved[0] != info.source_entity
or resolved[1].get("type") != "circle"
or not isinstance(frame.get("start"), dict)
or not isinstance(frame.get("end"), dict)
):
return None
return resolved[1], sketch["workplane"], info.source_entity
def _direct_line_angle_axis(
query: Any,
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
) -> tuple[list[float], list[float]] | None:
"""Return the exact ``evAxis`` result for a direct sketch line/circle.
FeatureScript's ``lineAnglePlane`` passes selected axes to ``evAxis``.
A direct ``skLineSegment`` and a direct ``skCircle`` have unique source
axes. This does not infer an axis from a resulting edge: CAP/SWEPT/COPY
and suffix-derived references stay out of this source-only path.
"""
info = parse_query(query)
if info.kind not in {"edge", "entitytype.edge"}:
return None
direct = _direct_line_angle_source_entity(query, sketch_by_source, entity_by_sketch)
if direct is None:
return None
entity, plane, _token = direct
if entity.get("type") == "line":
start, end = _entity_line(entity, plane)
return start, _unit(_sub(end, start), "line-angle reference axis is degenerate")
if entity.get("type") == "circle":
center = entity.get("center")
if not isinstance(center, list) or len(center) != 2:
return None
return _global(plane, center), _unit(list(plane["normal"]), "line-angle reference axis is degenerate")
return None
def _direct_line_angle_reference_plane(
query: Any,
feature_frames: dict[str, dict[str, Any]],
) -> dict[str, Any] | None:
"""Resolve only a default plane or an already-lowered reference plane."""
default = _default_plane(query)
if default is not None:
return default
info = parse_query(query)
if (
info.topology_type is not None
or "qCreatedBy" not in info.calls
or not info.owner_feature
):
return None
frame = feature_frames.get(info.owner_feature)
if not isinstance(frame, dict) or frame.get("start") != frame.get("end"):
return None
start = frame.get("start")
return dict(start) if isinstance(start, dict) else None
def _direct_line_angle_reference_point(
query: Any,
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
) -> list[float] | None:
"""Resolve one direct sketch point or direct line endpoint, and nothing else."""
info = parse_query(query)
if (
info.topology_type is not None
or info.kind not in {"vertex", "entitytype.vertex"}
or not isinstance(info.source_sketch, str)
or not isinstance(info.source_entity, str)
):
return None
sketch = sketch_by_source.get(info.source_sketch)
resolved = _source_ref_entity(info.source_sketch, info.source_entity, entity_by_sketch)
if sketch is None or resolved is None:
return None
entity_id, entity = resolved
token = info.source_entity
if entity.get("type") == "point" and token == entity_id:
return _entity_point(entity, sketch["workplane"], token)
if entity.get("type") == "line" and token in {f"{entity_id}.start", f"{entity_id}.end"}:
return _entity_point(entity, sketch["workplane"], token)
return None
def _direct_line_angle_two_entity_plane(
entities: list[Any],
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] | None:
"""Implement FeatureScript's exact two-entity ``lineAnglePlane`` rule.
The first entity normally supplies the axis. If it has no direct axis and
the second entity does, FeatureScript swaps the axis and treats the
original first entity as the plane/point reference. Parallel axes use
their origin-to-origin vector; nonparallel axes use the second direction.
"""
if len(entities) != 2:
return None
first_axis = _direct_line_angle_axis(entities[0], sketch_by_source, entity_by_sketch)
second_axis = _direct_line_angle_axis(entities[1], sketch_by_source, entity_by_sketch)
if first_axis is None:
if second_axis is None:
return None
axis_origin, axis_direction = second_axis
reference = entities[0]
reference_axis = None
else:
axis_origin, axis_direction = first_axis
reference = entities[1]
reference_axis = second_axis
if reference_axis is not None:
reference_origin, reference_direction = reference_axis
if math.sqrt(_dot(_cross(axis_direction, reference_direction), _cross(axis_direction, reference_direction))) <= 1e-9:
second_in_plane_direction = _sub(reference_origin, axis_origin)
else:
second_in_plane_direction = reference_direction
else:
reference_plane = _direct_line_angle_reference_plane(reference, feature_frames)
if reference_plane is not None:
second_in_plane_direction = _cross(axis_direction, reference_plane["normal"])
else:
reference_point = _direct_line_angle_reference_point(
reference, sketch_by_source, entity_by_sketch,
)
if reference_point is None:
return None
second_in_plane_direction = _sub(reference_point, axis_origin)
normal = _cross(axis_direction, second_in_plane_direction)
_unit(normal, "line-angle reference selection is degenerate")
signed_angle = _number(params.get("angle", 0.0))
if _bool(params.get("oppositeDirection")):
signed_angle = -signed_angle
return _frame(
axis_origin,
axis_direction,
_rotate(normal, axis_direction, math.radians(signed_angle)),
)
def _cplane(
params: dict[str, Any],
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
) -> dict[str, Any]:
plane_type = str(params.get("cplaneType") or "OFFSET").split(".")[-1].upper()
entities = _queries(params.get("entities"))
if plane_type == "OFFSET":
offset = _number(params.get("offset", 0), True)
# CPlane OFFSET preserves the source plane's local frame. CADFS uses
# oppositeDirection only to select the other signed offset side.
if _bool(params.get("oppositeDirection")):
offset = -offset
return _shift_plane(_query_plane(entities[0], feature_frames, sketch_by_source, entity_by_sketch), offset)
if plane_type == "LINE_ANGLE":
# ``lineAnglePlane`` resolves the direct source contract before any
# topology-derived workplane compatibility path. In particular, a
# two-entity plane is defined by the axis plus the second entity's
# direction, not by rotating an arbitrary source workplane frame.
if len(entities) == 1:
direct_axis = _direct_line_angle_axis(
entities[0], sketch_by_source, entity_by_sketch,
)
if direct_axis is not None:
origin, axis = direct_axis
signed_angle = _number(params.get("angle", 0.0))
if _bool(params.get("oppositeDirection")):
signed_angle = -signed_angle
base_normal = _featurescript_perpendicular_vector(axis)
normal = _rotate(base_normal, axis, math.radians(signed_angle))
return _frame(origin, axis, normal)
if len(entities) == 2:
direct_plane = _direct_line_angle_two_entity_plane(
entities, params, feature_frames, sketch_by_source, entity_by_sketch,
)
if direct_plane is not None:
return direct_plane
# The retained compatibility path is specifically a cylinder
# generatrix contract. Do not use it as a generic derived-topology
# fallback: a CAP/SWEPT line or a query combinator has different
# FeatureScript provenance and needs its own source proof.
candidates = [
item for item in entities
if _derived_line_angle_cylinder_axis(
item, feature_frames, sketch_by_source, entity_by_sketch,
) is not None
]
if len(entities) != 2 or len(candidates) != 1:
raise ValueError("line-angle reference selection is unsupported")
line_query = candidates[0]
base_query = next(item for item in entities if item is not line_query)
base = _direct_line_angle_reference_plane(base_query, feature_frames)
if base is None:
raise ValueError("line-angle cylinder reference plane is unsupported")
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, _plane, _token = _derived_line_angle_cylinder_axis(
line_query, feature_frames, sketch_by_source, entity_by_sketch,
) or (None, None, None)
if entity is None: # Kept for type narrowing; the candidate was checked above.
raise ValueError("line-angle reference selection is unsupported")
# 圆柱面/端盖圆边用于 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)
curve, source_plane, _ = _entity_from_query(curve_query, sketch_by_source, entity_by_sketch)
if curve.get("type") == "line":
start, end = _query_line(curve_query, feature_frames, sketch_by_source, entity_by_sketch)
tangent = _sub(end, start)
else:
source_plane, tangent = _curve_point_tangent(
curve_query, point_query, point, sketch_by_source, entity_by_sketch,
)
return _frame(point, source_plane["normal"], tangent)
if plane_type == "THREE_POINT":
if len(entities) != 3: raise ValueError("three-point plane requires exactly three points")
first, second, third = [_query_point(item, feature_frames, sketch_by_source, entity_by_sketch) for item in entities]
normal = _cross(_sub(second, first), _sub(third, first))
if _bool(params.get("oppositeDirection")): normal = [-value for value in normal]
return _frame(first, _sub(second, first), normal)
if plane_type == "LINE_POINT":
line_query = next((item for item in entities if "edge" in (parse_query(item).kind or "")), None)
point_query = next((item for item in entities if item is not line_query), None)
if line_query is None or point_query is None: raise ValueError("line-point references are unresolved")
# FeatureScript 的 LINE_POINT 平面经过指定点,法向与参考线平行。
# 点可以是线端点;它不是用来和直线共同定义平面的第三个方向。
_, source_plane, _ = _entity_from_query(line_query, sketch_by_source, entity_by_sketch)
start, end = _query_line(line_query, feature_frames, sketch_by_source, entity_by_sketch)
point = _query_point(point_query, feature_frames, sketch_by_source, entity_by_sketch)
normal = _sub(end, start)
if _bool(params.get("oppositeDirection")): normal = [-value for value in normal]
return _frame(point, _cross(source_plane["normal"], normal), normal)
if plane_type == "MID_PLANE":
if len(entities) != 2: raise ValueError("mid-plane requires exactly two reference planes")
first, second = [_query_plane(item, feature_frames, sketch_by_source, entity_by_sketch) for item in entities]
first_normal = _unit(first["normal"], "first mid-plane normal is degenerate")
second_normal = _unit(second["normal"], "second mid-plane normal is degenerate")
intersection = _cross(first_normal, second_normal)
intersection_length_squared = _dot(intersection, intersection)
if intersection_length_squared <= 1e-12:
alignment = 1.0 if _dot(first_normal, second_normal) >= 0 else -1.0
offset = _dot(_sub(second["origin_mm"], first["origin_mm"]), first_normal) * alignment
return _shift_plane(first, offset / 2.0)
# 两个相交面没有“中点偏移面”。CADFS 的 MID_PLANE 是两面形成的
# 二面角平分面:先令两个法向同向,再取其和作为平分面的法向;平面
# 经过两原平面的交线。此处的 local x 轴由交线推导,后续草图的
# (u, v) 坐标不依赖任意选择的输入 face frame。
if _dot(first_normal, second_normal) < 0:
second_normal = [-value for value in second_normal]
# 法向翻转也会反转两平面的交线方向。交点公式中的交线必须与已
# 对齐的法向保持同一方向,否则会将原点映射到交线的对称位置。
intersection = _cross(first_normal, second_normal)
normal = _unit([first_normal[index] + second_normal[index] for index in range(3)], "mid-plane angle bisector is degenerate")
first_offset = _dot(first_normal, first["origin_mm"])
second_offset = _dot(second_normal, second["origin_mm"])
first_term = _cross(second_normal, intersection)
second_term = _cross(intersection, first_normal)
origin = [
(first_offset * first_term[index] + second_offset * second_term[index]) / intersection_length_squared
for index in range(3)
]
return _frame(origin, _cross(normal, intersection), normal)
raise UnsupportedCapability(f"reference_plane:{plane_type.lower()}", f"current converter has no exact {plane_type} reference plane")
def _mirror_plane_from_query(
value: Any,
feature_frames: dict[str, dict[str, Any]],
sketch_by_source: dict[str, dict[str, Any]],
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
feature_by_id: dict[str, dict[str, Any]],
) -> dict[str, Any] | None:
"""Materialize the bounded planar mirror-face source into a CDSL frame.
A full solid revolve turns a source line perpendicular to its axis into a
planar annular face. That face has a source-defined physical plane even
though it is neither a datum plane nor a separately lowered cPlane. The
generic ``_query_plane`` path already proves that frame from the direct
line and revolve axis; use it here only after checking the exact producer
contract. Curved, partial, derived, or non-revolve swept faces remain
unresolved instead of becoming a guessed mirror plane.
"""
plane = _default_plane(value)
if plane is not None:
return plane
info = parse_query(value)
if info.topology_type != "SWEPT_FACE" or not info.owner_feature:
return None
producer = feature_by_id.get(f"f_{info.owner_feature}") or {}
frame = feature_frames.get(info.owner_feature) or {}
if (
producer.get("atomic_id") != "revolve_add"
or not frame.get("revolve_full")
or not isinstance(frame.get("revolve_axis"), dict)
):
return None
try:
return _query_plane(value, feature_frames, sketch_by_source, entity_by_sketch)
except (UnsupportedCapability, ValueError):
return None
def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
diagnostics: list[dict[str, Any]] = []; history = []
sketches: list[dict[str, Any]] = []; sketches_by_id: dict[str, dict[str, Any]] = {}; sketch_by_source: dict[str, dict[str, Any]] = {}; entity_by_sketch: dict[str, dict[str, dict[str, Any]]] = {}
feature_frames: dict[str, dict[str, Any]] = {}; surface_profiles: list[dict[str, Any]] = []; swept_face_sketches: set[str] = set()
features: list[dict[str, Any]] = []; complete = True; previous: list[str] = []
feature_by_id: dict[str, dict[str, Any]] = {}; feature_source_by_id: dict[str, str] = {}
# Original CADFS operation owners can remain the query anchor after an
# explicit non-copy body transform. Map only those proven transform
# successors; all other body lifecycle transitions remain unaliased.
body_transform_aliases: dict[str, str] = {}
# This is the lowering-side mirror of runtime ``body_members``. It makes
# a source-qualified shell.parts target possible only while that source is
# still one explicit selectable body member.
lowered_body_members: set[str] = set()
# A direct SWEPT_BODY query can continue to name the sole CADFS body after
# ordinary additive and dress-up successors. This state is deliberately
# cleared before any aggregate, multi-member, or otherwise ambiguous body
# transition can make that continuation non-unique.
single_body_successor_state: dict[str, Any] = {"owner": None, "sources": set()}
for step in model.steps:
if isinstance(step, SketchIR):
history.append({"feature_id": step.feature_id, "operation": "newSketch", "parameters": {"sketchPlane": plain(step.workplane)}, "entities": [{"entity_id": e.feature_id, "operation": e.operation, "parameters": plain(e.params)} for e in step.entities]})
try:
swept_face = parse_query(step.workplane).topology_type == "SWEPT_FACE"
plane = _query_plane(step.workplane, feature_frames, sketch_by_source, entity_by_sketch) if swept_face else _plane_from_query(step.workplane, feature_frames, sketch_by_source, entity_by_sketch)
profile = _revolve_swept_face_profile(step.workplane, plane, feature_frames, sketch_by_source, entity_by_sketch) if swept_face and not step.entities else None
if profile is None: lowered, entities = _lower_sketch(step, plane)
else:
lowered, entities = {"id": f"sketch_{step.feature_id}", "name": step.feature_id, "workplane": plane, "profile": profile}, {}
swept_face_sketches.add(step.feature_id)
sketches.append(lowered); sketches_by_id[lowered["id"]] = lowered; sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities
except Exception as exc:
# 开放草图不能作为实体 profile,但其几何仍可能是后续基准面、
# 阵列轴或旋转轴的精确引用。保留为 reference 草图,后续实体
# 特征仍由 _profile_executable 明确拒绝,不能静默把开放轮廓实体化。
try:
plane = _query_plane(step.workplane, feature_frames, sketch_by_source, entity_by_sketch) if swept_face else _plane_from_query(step.workplane, feature_frames, sketch_by_source, entity_by_sketch)
lowered, entities = _lower_sketch(step, plane, allow_open=True)
sketches.append(lowered); sketches_by_id[lowered["id"]] = lowered; sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities
if isinstance(exc, OpenSketchProfileError): continue
except Exception:
pass
diagnostics.append({"code": "sketch_deferred", "feature_id": step.feature_id, "message": str(exc)}); complete = False
continue
item = step
history.append({"feature_id": item.feature_id, "operation": item.operation, "source_span": {"line_start": item.line_start, "line_end": item.line_end or item.line_start}, "parameters": plain(item.params), "raw_source": item.raw_source})
if item.operation in UNSUPPORTED:
diagnostics.append({"code": "unsupported_operation", "feature_id": item.feature_id, "operation": item.operation}); complete = False; continue
try:
fid = f"f_{item.feature_id}"; depends = list(previous[-1:]); p = item.params; feature: dict[str, Any]
if item.operation == "transform":
# 单一直接 source 可以烘焙回原始几何。多 body 与 COPY instance
# 必须保留为显式 body graph transform,不能移动聚合主体。
sources, pattern_instance_refs, transform_copy_refs = _transform_body_references(
p.get("entities"), previous, feature_by_id, body_transform_aliases,
)
transform_type = str(p.get("transformType") or "").split(".")[-1].upper()
identity_copy = transform_type == "COPY"
# Uniform scaling changes the B-rep's dimensions. It cannot be
# baked into a source sketch without also transforming every
# dependent parameter and topology frame, so retain it as an
# explicit body-graph operation even for one direct source.
source_for_bake = sources[0] if len(sources) == 1 else None
source_feature_for_bake = feature_by_id.get(source_for_bake or "")
if (
transform_type not in {"SCALE_UNIFORMLY", "COPY"}
and not _bool(p.get("makeCopy"))
and len(sources) == 1
and not pattern_instance_refs
# Baking mutates an already emitted source sketch and its
# lowering-only frames. It is therefore equivalent to a
# transform only for the immediately preceding,
# independent NEW member. A boolean, dress-up, later add,
# or even a reference feature can retain geometry from the
# pre-transform source; changing that source retroactively
# would invert CADFS history order.
and (
# _bake_transform has a stronger lifecycle diagnostic
# for TRANSLATION_ENTITY. Let it run even when the
# source was absorbed so that this unrepresentable
# body move is rejected rather than silently lowered
# as an explicit transform of a successor member.
transform_type == "TRANSLATION_ENTITY"
or (
# A later transform may retain the original CADFS
# owner while the physical member is an earlier
# transform's successor. Baking it into the
# original sketch would discard the first move.
sources == _transform_source_features(p.get("entities"), previous)
and
previous[-1:] == sources
and (source_feature_for_bake or {}).get("params", {}).get("result_mode") == "new_body"
)
)
):
_bake_transform(p, previous, feature_by_id, feature_source_by_id, sketches_by_id, feature_frames, sketch_by_source, entity_by_sketch)
continue
missing = [source for source in sources if source not in feature_by_id]
if missing:
raise ValueError("transform source bodies are unresolved: " + ", ".join(missing))
pattern_dependencies = [reference["pattern_feature_id"] for reference in pattern_instance_refs]
transform_copy_dependencies = [reference["transform_feature_id"] for reference in transform_copy_refs]
transform_params: dict[str, Any] = {
"transform": _body_transform(
p, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, previous,
),
"make_copy": identity_copy or _bool(p.get("makeCopy")),
}
if sources:
transform_params["source_feature_ids"] = sources
if pattern_instance_refs:
transform_params["pattern_instance_refs"] = pattern_instance_refs
if transform_copy_refs:
transform_params["transform_copy_refs"] = transform_copy_refs
feature = {
"id": fid,
"name": item.feature_id,
"atomic_id": "transform_bodies",
"depends_on": list(dict.fromkeys(sources + pattern_dependencies + transform_copy_dependencies + depends)),
"params": transform_params,
"execution_status": "supported",
}
elif item.operation == "deleteBodies":
queries = _queries(p.get("entities"))
if not queries:
raise ValueError("deleteBodies selection is empty")
targets = []
for query in queries:
_call, owner, topology, kind, _definition = _direct_make_query(query)
pattern_id = f"f_{owner}"
pattern = feature_by_id.get(pattern_id)
if topology != "COPY" or kind not in {"body", "entitytype.body"} or pattern is None or pattern.get("atomic_id") != "pattern_circular":
target = _delete_body_source(query)
if target not in targets:
targets.append(target)
continue
pattern_id, source_id, instance = _pattern_copy_body(query)
params = pattern["params"]
if source_id not in params.get("source_feature_ids", []):
raise ValueError("pattern copy deletion source is not replayed by its owner")
count = int(params.get("pattern_count") or 0)
if instance < 1 or instance >= count:
raise ValueError("pattern copy deletion instance is outside the generated range")
excluded = params.setdefault("excluded_instance_indices", [])
if instance not in excluded:
excluded.append(instance)
if not targets:
continue
missing = [target for target in targets if target not in feature_by_id]
if missing:
raise ValueError("deleteBodies source bodies are unresolved: " + ", ".join(missing))
feature = {
"id": fid,
"name": item.feature_id,
"atomic_id": "delete_bodies",
"depends_on": list(dict.fromkeys(targets + depends)),
"params": {"target_feature_ids": targets},
"execution_status": "supported",
}
elif item.operation == "cPlane":
plane = _cplane(p, feature_frames, sketch_by_source, entity_by_sketch)
feature = {"id": fid, "name": item.feature_id, "atomic_id": "reference_plane", "depends_on": depends, "params": {"plane": plane}, "execution_status": "supported"}
attachment = _attachment_plane(plane)
feature_frames[item.feature_id] = {
"start": plane,
"end": plane,
"start_attachment": attachment,
"end_attachment": attachment,
}
elif item.operation == "extrude":
surface_profile_sketch = None
if p.get("surfaceOperationType") is not None:
surface_operation = str(p.get("surfaceOperationType") or "").rsplit(".", 1)[-1].upper()
if surface_operation != "ADD":
raise UnsupportedCapability("extrude_surface_operation", "current CDSL surface extrude supports only NewSurfaceOperationType.ADD")
surface_source = _source_sketch({"surfaceEntities": p.get("surfaceEntities")})
if not surface_source or surface_source not in sketch_by_source:
raise ValueError("surface extrude sketch query is unresolved")
surface_profile_sketch = _surface_profile_selection_sketch(
sketch_by_source[surface_source], p.get("surfaceEntities"), entity_by_sketch[surface_source], fid,
)
sketches.append(surface_profile_sketch); sketches_by_id[surface_profile_sketch["id"]] = surface_profile_sketch
profile_value = _sketch_region_query(p.get("entities")) or p.get("entities")
profile_kind = parse_query(profile_value).topology_type
source = parse_query(profile_value).source_sketch or _source_sketch(p)
imprint = _imprint_sketch(profile_value)
cap_face_output_selector = _cap_face_output_role_selector(
profile_value, feature_by_id, sketches_by_id,
)
cap_edge_hole = _cap_edge_hole_profile_sketch(
profile_value, sketch_by_source, entity_by_sketch, feature_frames, fid,
)
cap_edge_union_profile = _cap_edge_union_profile_sketch(
profile_value, sketch_by_source, entity_by_sketch, fid,
)
# First let the established profile materializer handle
# single-region, circle and open-IMPRINT contracts. A
# multi-face IMPRINT query reaches the arrangement path only
# when that materializer cannot reduce the source sketch.
materialized_imprint = (
_profile_selection_sketch(
sketch_by_source[source], profile_value, entity_by_sketch[source], fid,
)
if profile_kind == "IMPRINT" and source in sketch_by_source
else None
)
planar_imprint_profile = _planar_imprint_profile_sketch(
profile_value, sketch_by_source, entity_by_sketch, fid,
) if (
profile_kind == "INTERSECT"
or (
profile_kind == "IMPRINT"
and len(_queries(profile_value)) > 1
and isinstance(source, str)
and materialized_imprint is sketch_by_source.get(source)
)
) else None
intersect_profile_sketch = _intersect_partition_profile_sketch(
profile_value, sketch_by_source, entity_by_sketch, fid,
) if profile_kind == "INTERSECT" and planar_imprint_profile is None else None
offset_face_profile = _offset_face_profile_sketch(
profile_value, feature_frames, sketches_by_id, sketch_by_source, entity_by_sketch, feature_by_id, fid,
) if profile_kind == "OFFSET_FACE" else None
profile_sketch: dict[str, Any] | None = None
if cap_face_output_selector is not None:
# Keep the B-rep face as a runtime-derived profile. It
# must not be reconstructed from the original sketch.
pass
elif cap_edge_hole is not None:
profile_sketch, hole_selector = cap_edge_hole
if profile_sketch["id"] not in sketches_by_id:
sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch
elif cap_edge_union_profile is not None:
profile_sketch = cap_edge_union_profile
if profile_sketch["id"] not in sketches_by_id:
sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch
elif profile_kind == "SWEPT_EDGE" and imprint in swept_face_sketches: source = imprint
elif planar_imprint_profile is not None:
profile_sketch = planar_imprint_profile
sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch
elif intersect_profile_sketch is not None:
profile_sketch = intersect_profile_sketch
sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch
elif offset_face_profile is not None:
profile_sketch = offset_face_profile
sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch
elif profile_kind not in {None, "IMPRINT"}:
partitioned = _partitioned_imprint_sketch(p.get("entities"), entity_by_sketch)
if partitioned is not None:
source = partitioned; profile_kind = "IMPRINT"
else:
raise UnsupportedCapability(f"extrude_profile_topology:{profile_kind.lower()}", f"current CDSL engine cannot exactly replay an extrude profile selected from {profile_kind}")
if cap_face_output_selector is None and cap_edge_hole is None and cap_edge_union_profile is None and planar_imprint_profile is None and intersect_profile_sketch is None and offset_face_profile is None:
if not source or source not in sketch_by_source: raise ValueError("extrude sketch query is unresolved")
open_profile_sketch = _open_imprint_profile_sketch(sketch_by_source[source], profile_value, fid)
profile_sketch = open_profile_sketch or _profile_selection_sketch(sketch_by_source[source], profile_value, entity_by_sketch[source], fid)
if profile_sketch is not sketch_by_source[source]: sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch
operation = str(p.get("operationType") or "NEW").upper()
cutting = any(x in operation for x in ("REMOVE", "CUT"))
if cutting and profile_kind == "IMPRINT" and profile_sketch is not None:
trimmed_profile_sketch = _surface_trimmed_imprint_profile(profile_sketch, surface_profile_sketch, surface_profiles)
if trimmed_profile_sketch is not profile_sketch:
for index, sketch in enumerate(sketches):
if sketch is profile_sketch:
sketches[index] = trimmed_profile_sketch; break
sketches_by_id[trimmed_profile_sketch["id"]] = trimmed_profile_sketch
profile_sketch = trimmed_profile_sketch
if profile_sketch is not None and not _profile_executable(profile_sketch): raise ValueError("extrude sketch has no closed profile")
second = _bool(p.get("hasSecondDirection"))
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,
feature_by_id=feature_by_id, sketches_by_id=sketches_by_id, previous=previous,
featurescript_version=model.featurescript_version,
)
elif end["type"] == "up_to_surface":
end["reference"] = _extent_reference(
p.get("endBoundEntityFace"), "face", feature_frames, sketch_by_source, entity_by_sketch,
feature_by_id=feature_by_id, sketches_by_id=sketches_by_id, previous=previous,
allow_cap_output_role=not second,
allow_direct_prism_swept_lineage=not second,
featurescript_version=model.featurescript_version,
)
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"))
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,
feature_by_id=feature_by_id, sketches_by_id=sketches_by_id, previous=previous,
)
elif reverse_end["type"] == "up_to_vertex":
reverse_end["reference"] = _intersection_vertex_reference(
p.get("secondDirectionBoundEntityVertex"), feature_frames, sketch_by_source, entity_by_sketch, feature_by_id, item.feature_id,
)
params.update({"reverse_distance_mm": reverse_depth, "reverse_end_condition": reverse_end})
elif end["type"] == "mid_plane":
blind = _end_condition("BLIND")
atomic = "extrude_cut_two_sided" if cutting else "extrude_add_two_sided"
params = {"distance_mm": depth / 2, "reverse_distance_mm": depth / 2, "reverse": reverse, "end_condition": blind, "reverse_end_condition": dict(blind)}
else:
atomic = "extrude_cut_blind" if cutting else "extrude_add_blind"
params = {"distance_mm": depth, "reverse": reverse, "end_condition": end}
if _bool(p.get("hasDraft")):
if second or end["type"] != "blind":
raise UnsupportedCapability("extrude_draft_extent", "current CDSL draft supports only one-sided blind extrusions")
params["draft"] = {
"angle_deg": _number(p.get("draftAngle")),
"pull_direction": _bool(p.get("draftPullDirection")),
}
if cap_edge_hole is not None:
if cutting or second or end["type"] != "blind" or "draft" in params:
raise UnsupportedCapability("extrude_cap_edge_profile", "current CAP_EDGE profile extrusion supports one-sided blind additive results")
atomic = "extrude_add_blind_with_hole"
if not cutting and _is_new_body_operation(operation): params["result_mode"] = "new_body"
if cap_face_output_selector is not None:
params["operation"] = "cut" if cutting else "add"
if second or end["type"] == "mid_plane":
params["two_sided"] = True
feature = {
"id": fid,
"name": item.feature_id,
"atomic_id": "extrude_from_face",
"depends_on": depends,
"params": params,
"selectors": [cap_face_output_selector],
"execution_status": "supported",
}
else:
if profile_sketch is None:
raise ValueError("extrude profile is unresolved")
feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": profile_sketch["id"], "params": params, "execution_status": "supported"}
if cap_edge_hole is not None: feature["selectors"] = [hole_selector]
if surface_profile_sketch is not None:
if end["type"] not in {"blind", "mid_plane"}:
raise UnsupportedCapability("extrude_surface_extent", "current CDSL surface extrude supports blind and symmetric extents only")
surface_params = {"distance_mm": params["distance_mm"], "reverse": bool(params.get("reverse"))}
if "reverse_distance_mm" in params:
surface_params["reverse_distance_mm"] = params["reverse_distance_mm"]
surface_feature = {
"id": f"{fid}_surface",
"name": f"{item.feature_id} surface",
"atomic_id": "extrude_surface",
"depends_on": [fid],
"sketch_id": surface_profile_sketch["id"],
"params": surface_params,
"execution_status": "supported",
}
plane = _query_plane(profile_value, feature_frames, sketch_by_source, entity_by_sketch) if cap_face_output_selector is not None else profile_sketch["workplane"]
if end["type"] == "blind" and not second:
direction = -1 if reverse else 1
# FeatureScript 的 CAP_FACE 是实体端盖,而不是原草图平面。
# 记录实际外法向后,后续附着在 start/end cap 的草图才能沿
# 正确一侧拉伸;不能复用 profile 的初始法向。
feature_frames[item.feature_id] = {
"start": _oriented_plane(plane, -direction),
"end": _oriented_plane(plane, direction, direction * depth),
"profile": dict(plane),
"profile_source": source,
}
elif (
second
and end["type"] == "blind"
and (params.get("reverse_end_condition") or {}).get("type") == "blind"
):
# A two-sided blind extrusion has no cap at the source
# plane. Keep the one-sided CAP convention: ``isStart``
# is the cap on the side opposite the primary extent and
# ``isStart:false`` is the primary-extent cap. This makes
# the zero-second-distance limit agree with the
# one-sided frame above, regardless of oppositeDirection.
direction = -1 if reverse else 1
reverse_depth = float(params["reverse_distance_mm"])
feature_frames[item.feature_id] = {
"start": _oriented_plane(plane, -direction, -direction * reverse_depth),
"end": _oriented_plane(plane, direction, direction * depth),
"profile": dict(plane),
"profile_source": source,
}
elif end["type"] == "mid_plane":
direction = -1 if reverse else 1
feature_frames[item.feature_id] = {
"start": _oriented_plane(plane, -direction, -direction * depth / 2),
"end": _oriented_plane(plane, direction, direction * depth / 2),
"profile": dict(plane),
"profile_source": source,
}
elif item.operation == "loft":
cap_face_loft = _loft_cap_face_profile(p, sketch_by_source, entity_by_sketch, feature_frames, fid)
if cap_face_loft is not None:
profile_sketch, cap_selector, cap_plane = cap_face_loft
if profile_sketch["id"] not in sketches_by_id:
sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch
if not _profile_executable(profile_sketch): raise ValueError("loft profile sketch has no closed profile")
feature = {
"id": fid,
"name": item.feature_id,
"atomic_id": "loft_add_with_cap_face",
"depends_on": depends,
"params": {"profile_sketch_ids": [profile_sketch["id"]]},
"selectors": [cap_selector],
"execution_status": "supported",
}
frames = _loft_cap_frames(cap_plane, profile_sketch["workplane"])
else:
sources = _loft_profile_sketches(p)
missing = [source for source in sources if source not in sketch_by_source]
if missing:
raise ValueError("loft profile sketches are unresolved: " + ", ".join(missing))
non_closed = [source for source in sources if not _profile_executable(sketch_by_source[source])]
if non_closed:
raise ValueError("loft profile sketches have no closed profile: " + ", ".join(non_closed))
feature = {
"id": fid,
"name": item.feature_id,
"atomic_id": "loft_add",
"depends_on": depends,
"params": {"profile_sketch_ids": [sketch_by_source[source]["id"] for source in sources]},
"execution_status": "supported",
}
frames = _loft_cap_frames(
sketch_by_source[sources[0]]["workplane"], sketch_by_source[sources[-1]]["workplane"],
)
if frames is not None:
# This lowering-only provenance permits an exact source
# endpoint pair for a two-section direct loft. It is not
# a replacement for runtime topology history.
if cap_face_loft is None:
frames["loft_profile_sources"] = sources
feature_frames[item.feature_id] = frames
elif item.operation == "sweep":
profile_source = _source_sketch({"entities": p.get("profiles")})
if not profile_source or profile_source not in sketch_by_source:
raise ValueError("sweep profile sketch is unresolved")
profile_sketch = _profile_selection_sketch(
sketch_by_source[profile_source], p.get("profiles"), entity_by_sketch[profile_source], fid,
)
if profile_sketch is not sketch_by_source[profile_source]:
sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch
if not _profile_executable(profile_sketch):
raise ValueError("sweep profile sketch has no closed profile")
path_query = parse_query(p.get("path"))
path_source = path_query.source_sketch
path_entity = (entity_by_sketch.get(path_source or "") or {}).get(path_query.source_entity or "")
path_sketch = sketch_by_source.get(path_source or "")
if path_entity is None or path_sketch is None:
direct_wire_path = _direct_sketch_wire_path(
p.get("path"), sketch_by_source, entity_by_sketch,
featurescript_version=model.featurescript_version,
standard_library=model.standard_library,
standard_library_version=model.standard_library_version,
)
if direct_wire_path is not None:
path_source, _path_entity_id, path_entity, path_sketch = direct_wire_path
if path_entity is None or path_sketch is None:
raise UnsupportedCapability(
"sweep_path_query",
"current CDSL sweep requires one direct sketch line or B-spline path",
)
if path_entity.get("type") not in {"line", "bspline"}:
raise UnsupportedCapability("sweep_path", "current CDSL sweep requires one line or B-spline path")
operation = str(p.get("operationType") or "NEW").upper()
if any(value in operation for value in ("REMOVE", "CUT")):
raise UnsupportedCapability("sweep_remove", "current CDSL sweep supports additive solid results only")
path_segment = deepcopy(path_entity)
path = {"workplane": path_sketch["workplane"], "segment": path_segment}
if _sweep_profile_attaches_at_path_end(profile_sketch, path):
path_segment = _reversed_sweep_path_segment(path_segment)
feature = {
"id": fid,
"name": item.feature_id,
"atomic_id": "sweep_add",
"depends_on": depends,
"sketch_id": profile_sketch["id"],
"params": {"path": {"workplane": path_sketch["workplane"], "segment": path_segment}},
"execution_status": "supported",
}
if _is_new_body_operation(operation): feature["params"]["result_mode"] = "new_body"
frames = _sweep_cap_frames(profile_sketch, feature["params"]["path"])
if frames is not None:
feature_frames[item.feature_id] = frames
elif item.operation == "booleanBodies":
operation = str(p.get("operationType") or "").split(".")[-1].upper()
operation_map = {
"UNION": "union",
"SUBTRACTION": "subtract",
"INTERSECTION": "intersect",
}
if operation not in operation_map:
raise UnsupportedCapability("boolean_bodies_operation", "current CDSL booleanBodies supports union, subtraction and intersection")
# An omitted FeatureScript ``targets`` field is the exact
# targetless-UNION form. ``_queries(None)`` intentionally
# returns one placeholder for callers that need a diagnostic,
# so it cannot be used to decide whether this field exists.
has_targets = p.get("targets") is not None
targets, target_instance_refs = (
_boolean_body_references(
p.get("targets"), previous, feature_by_id, body_transform_aliases,
)
if has_targets else ([], [])
)
tools, tool_instance_refs = _boolean_body_references(
p.get("tools"), previous, feature_by_id, body_transform_aliases,
)
if not has_targets:
# FeatureScript UNION permits one tools set without a
# separate target. A union is commutative, so selecting
# one exact member as the CDSL target preserves geometry
# only when tools are not retained. keepTools would need
# an explicit all-tools retention contract.
if operation != "UNION" or _bool(p.get("keepTools")):
raise UnsupportedCapability(
"boolean_bodies_targets",
"targetless booleanBodies is currently supported only for UNION with keepTools:false",
)
selections = [("feature", value) for value in tools] + [("pattern", value) for value in tool_instance_refs]
if len(selections) < 2:
raise ValueError("targetless booleanBodies union requires at least two explicit bodies")
kind, selected = selections.pop(0)
if kind == "feature":
targets, target_instance_refs = [selected], []
tools = [value for source_kind, value in selections if source_kind == "feature"]
tool_instance_refs = [value for source_kind, value in selections if source_kind == "pattern"]
else:
targets, target_instance_refs = [], [selected]
tools = [value for source_kind, value in selections if source_kind == "feature"]
tool_instance_refs = [value for source_kind, value in selections if source_kind == "pattern"]
target_keys = {("feature", source) for source in targets} | {
("pattern", reference["pattern_feature_id"], reference["source_feature_id"], reference["instance_index"])
for reference in target_instance_refs
}
tool_keys = {("feature", source) for source in tools} | {
("pattern", reference["pattern_feature_id"], reference["source_feature_id"], reference["instance_index"])
for reference in tool_instance_refs
}
if target_keys & tool_keys:
raise ValueError("booleanBodies targets and tools must be disjoint")
missing = [source for source in targets + tools if source not in feature_by_id]
if missing:
raise ValueError("booleanBodies source features are unresolved: " + ", ".join(missing))
pattern_dependencies = list(dict.fromkeys([
*(reference["pattern_feature_id"] for reference in target_instance_refs),
*(reference["pattern_feature_id"] for reference in tool_instance_refs),
]))
feature = {
"id": fid,
"name": item.feature_id,
"atomic_id": "boolean_bodies",
"depends_on": list(dict.fromkeys(targets + tools + pattern_dependencies + depends)),
"params": {
"operation": operation_map[operation],
"keep_tools": _bool(p.get("keepTools")),
},
"execution_status": "supported",
}
if targets:
feature["params"]["target_feature_ids"] = targets
if tools:
feature["params"]["tool_feature_ids"] = tools
if target_instance_refs:
feature["params"]["target_pattern_instance_refs"] = target_instance_refs
if tool_instance_refs:
feature["params"]["tool_pattern_instance_refs"] = tool_instance_refs
elif item.operation == "revolve":
# surfaceOperationType alone does not make a body operation a
# surface operation. CADFS emits it for closed sketch regions
# that produce ordinary solids too. Only ToolBodyType.SURFACE
# selects a sheet result; its profile is in surfaceEntities.
surface_operation = str(p.get("bodyType") or "").rsplit(".", 1)[-1].upper() == "SURFACE"
profile_entities = p.get("surfaceEntities") if surface_operation else p.get("entities")
source = _source_sketch(p)
if not source or source not in sketch_by_source: raise ValueError("revolve sketch query is unresolved")
profile_sketch = _profile_selection_sketch(sketch_by_source[source], profile_entities, entity_by_sketch[source], fid)
if profile_sketch is not sketch_by_source[source]: sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch
if not _profile_executable(profile_sketch): raise ValueError("revolve sketch has no closed profile")
axis_q = parse_query(p.get("axis")); axis_entity = (entity_by_sketch.get(axis_q.source_sketch or "") or {}).get(axis_q.source_entity or "")
if not axis_entity or axis_entity.get("type") != "line": raise ValueError("revolve axis is unresolved")
plane = sketch_by_source[axis_q.source_sketch]["workplane"]; start, end = _global(plane, axis_entity["start"]), _global(plane, axis_entity["end"])
direction = [end[i]-start[i] for i in range(3)]; norm = math.sqrt(sum(x*x for x in direction)); direction = [x/norm for x in direction]
# 闭合实体回转携带 surfaceOperationType、但没有 bodyType 时,
# 该字段只是曲面处理参数,不能被误读为 NewBodyOperationType.NEW。
# 它保持默认 ADD,F8 这类重叠回转因而会参与当前实体的融合。
# 完全没有该字段的普通实体回转仍保持 FeatureScript 的默认 NEW
# body 语义;显式 ToolBodyType.SURFACE 则以独立 shell 执行。
default_operation = "NEW" if surface_operation or p.get("surfaceOperationType") is None else "ADD"
operation = str(p.get("operationType") or default_operation).upper()
surface_kind = str(p.get("surfaceOperationType") or "NEW").upper()
if surface_operation and "NEW" not in surface_kind:
raise UnsupportedCapability("revolve_surface_operation", "current CDSL surface revolve supports only NewSurfaceOperationType.NEW")
atomic = "revolve_surface" if surface_operation else "revolve_cut" if "REMOVE" in operation else "revolve_add"
full = "FULL" in str(p.get("revolveType") or "FULL").upper(); angle = 360.0 if full else _number(p.get("angle", 360.0))
params = {"angle_deg": angle, "reverse": _bool(p.get("oppositeDirection")), "axis": {"origin_mm": start, "direction": direction}}
if atomic == "revolve_add" and _is_new_body_operation(operation): params["result_mode"] = "new_body"
feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": profile_sketch["id"], "params": params, "execution_status": "supported"}
if full:
frame = {"revolve_axis": {"origin_mm": start, "direction": direction}, "revolve_full": True}
# A SWEPT_EDGE circle is only lowerable when both profile
# and axis are still direct source-sketch entities. Keep
# this lowering-only provenance out of the public CDSL.
axis_direct = (
axis_q.source_sketch == source
and axis_q.source_entity is not None
and _source_ref_entity(source, axis_q.source_entity, entity_by_sketch) is not None
)
if (
atomic == "revolve_add"
and params.get("result_mode") == "new_body"
and _profile_matches_direct_source(profile_sketch, sketch_by_source[source])
and axis_direct
):
frame["profile_source"] = source
feature_frames[item.feature_id] = frame
elif item.operation in {"fillet", "chamfer"}:
key = "radius" if item.operation == "fillet" else "width"
chamfer_type = str(p.get("chamferType") or "EQUAL_OFFSETS").split(".")[-1].upper()
amount_value = p.get(key)
if item.operation == "chamfer" and chamfer_type == "TWO_OFFSETS": amount_value = p.get("width1")
amount = _number(amount_value, True); selectors = []
for index, query_value in enumerate(_queries(p.get("entities"))):
query = parse_query(query_value)
# ``parse_query`` retains nested source metadata for
# diagnostics, but its recursive walk ends on an inner
# CAP/SWEPT query. A fillet/chamfer consumes the outer
# makeQuery result, so use the direct outer expression
# for its topology kind and owner.
try:
_outer_call, outer_owner, outer_topology, outer_kind, _outer_definition = _direct_make_query(query_value)
except ValueError:
outer_owner = query.owner_feature
outer_topology = query.topology_type
outer_kind = query.kind
owner = outer_owner
if not owner: raise ValueError("selector owner is unresolved")
selector_kind = "face" if outer_kind in {"face", "entitytype.face"} else "edge"
if outer_topology == "INTERSECT":
intersection_selector = _direct_boolean_intersection_selector(
query_value,
feature_by_id=feature_by_id,
feature_frames=feature_frames,
sketch_by_source=sketch_by_source,
sketches_by_id=sketches_by_id,
entity_by_sketch=entity_by_sketch,
featurescript_version=model.featurescript_version,
)
if intersection_selector is None:
intersection_selector = _direct_primary_cut_intersection_selector(
query_value,
feature_by_id=feature_by_id,
feature_frames=feature_frames,
sketch_by_source=sketch_by_source,
sketches_by_id=sketches_by_id,
entity_by_sketch=entity_by_sketch,
previous=previous,
featurescript_version=model.featurescript_version,
)
if intersection_selector is not None:
selectors.append(intersection_selector)
continue
selectors.append({
"kind": selector_kind,
"owner_feature_id": f"f_{owner}",
"stable_id": f"cadfs_{fid}_{index}",
"source": "runtime_snapshot",
"confidence": 1.0,
"selector_intent": _deferred_featurescript_selector_intent(
query_value, kind=selector_kind,
),
})
continue
if outer_topology in {"CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE"}:
lineage_selector = _direct_prism_swept_selector(
query_value,
owner=owner,
selector_kind=selector_kind,
feature_by_id=feature_by_id,
feature_frames=feature_frames,
sketch_by_source=sketch_by_source,
sketches_by_id=sketches_by_id,
entity_by_sketch=entity_by_sketch,
previous=previous,
featurescript_version=model.featurescript_version,
allow_continuation=outer_topology == "CAP_EDGE",
)
if lineage_selector is not None:
selectors.append(lineage_selector)
continue
if _uses_planar_imprint_profile(owner, feature_by_id, sketches_by_id):
selectors.append({
"kind": selector_kind,
"owner_feature_id": f"f_{owner}",
"stable_id": f"cadfs_{fid}_{index}",
"source": "runtime_snapshot",
"confidence": 1.0,
"selector_intent": _deferred_featurescript_selector_intent(
query_value, kind=selector_kind,
),
})
continue
refs = _source_refs(query_value)
source_entity = (entity_by_sketch.get(query.source_sketch or "") or {}).get(query.source_entity or "")
geometry: dict[str, Any] = {}
frame = feature_frames.get(owner)
cap = frame.get("start" if query.is_start else "end") if frame and {"start", "end"}.issubset(frame) else None
if selector_kind == "face" and query.topology_type == "CAP_FACE" and cap:
geometry = {"normal": cap["normal"], "plane_offset_mm": sum(cap["normal"][i] * cap["origin_mm"][i] for i in range(3))}
elif selector_kind == "face" and query.topology_type == "SWEPT_FACE" and source_entity and source_entity["type"] == "circle":
# 圆形 profile 拉伸得到的侧面以轴线、半径为标识。同一 feature 可以
# 生成多个半径相同的圆柱面,因此保留草图圆心作为轴原点来解消歧义。
source_plane = sketch_by_source.get(query.source_sketch or "", {}).get("workplane")
if source_plane is not None:
geometry = {
"axis_origin_mm": _global(source_plane, source_entity["center"]),
"axis_direction": source_plane["normal"],
"radius_mm": source_entity["radius_mm"],
}
elif selector_kind == "edge" and query.topology_type == "OFFSET_EDGE" and source_entity and source_entity["type"] == "circle":
offset_planes = (frame or {}).get("shell_offset_edge_planes") or {}
source_key = f"{query.source_sketch}:{query.source_entity}"
offset_plane = offset_planes.get(source_key)
if offset_plane is not None:
geometry = {
"source_circle_center_mm": _global(offset_plane, source_entity["center"]),
"source_circle_radius_mm": source_entity["radius_mm"],
"source_plane_normal": offset_plane["normal"],
}
if selector_kind == "edge" and query.topology_type == "SWEPT_EDGE":
geometry = _swept_edge_line_selector_geometry(
owner, refs, frame or {}, feature_by_id, sketch_by_source, entity_by_sketch,
)
if geometry is None:
geometry = _swept_edge_revolve_circle_selector_geometry(
owner, refs, frame or {}, feature_by_id, sketch_by_source, entity_by_sketch,
)
if geometry is None:
raise ValueError("swept edge source endpoint provenance is unsupported")
elif selector_kind == "edge" and source_entity and cap:
if source_entity["type"] == "circle":
# Keep the source circle signature until the prefix has been
# rebuilt. OCC may expose it as one edge or several arcs.
selectors.append({"kind": selector_kind, "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": geometry or {"curve_type": "circle", "source_circle_center_mm": _global(cap, source_entity["center"]), "source_circle_radius_mm": source_entity["radius_mm"], "source_plane_normal": cap["normal"]}, "selector_intent": _deferred_featurescript_selector_intent(query_value, kind=selector_kind)})
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, "selector_intent": _deferred_featurescript_selector_intent(query_value, kind=selector_kind)})
params = {"radius_mm" if item.operation == "fillet" else "distance_mm": amount}
if item.operation == "fillet": params["tangent_propagation"] = _bool(p.get("tangentPropagation"))
elif _bool(p.get("tangentPropagation")):
params["tangent_propagation"] = True
elif chamfer_type == "TWO_OFFSETS":
second = _number(p.get("width2"), True)
if _bool(p.get("oppositeDirection")): params["distance_mm"], second = second, params["distance_mm"]
params["distance_2_mm"] = second
elif chamfer_type == "OFFSET_ANGLE":
angle = math.radians(_number(p.get("angle")))
if _bool(p.get("oppositeDirection")):
second = amount * math.tan(angle); params["distance_mm"] = second; params["distance_2_mm"] = amount
else: params["angle_rad"] = angle
feature = {"id": fid, "name": item.feature_id, "atomic_id": item.operation, "depends_on": depends, "params": params, "selectors": selectors, "execution_status": "supported"}
elif item.operation == "shell":
thickness = _number(p.get("thickness"), True)
selectors = []; offset_edge_planes: dict[str, dict[str, Any]] = {}
cap_removals: list[tuple[str, str]] = []
for index, query_value in enumerate(_queries(p.get("entities"))):
query = parse_query(query_value)
_call, _owner, topology, kind, _definition = _direct_make_query(query_value)
if kind not in {"face", "entitytype.face"}:
raise UnsupportedCapability("shell_face_selector", "current CDSL shell requires face removal selectors")
if topology == "CAP_FACE":
# A direct CAP_FACE names the extrusion's physical
# builder output. It may serve a shell only while
# that independent prism is still the active,
# immediately preceding body. Other CAP histories
# retain their deferred source query and cannot pick a
# geometrically similar face here.
selector = _cap_face_output_role_selector(
query_value, feature_by_id, sketches_by_id,
)
if selector is None or previous[-1:] != [selector["owner_feature_id"]]:
selector = _face_reference(query_value, feature_frames, sketch_by_source, entity_by_sketch)
if query.owner_feature and query.is_start is not None:
cap_removals.append((query.owner_feature, "start" if query.is_start else "end"))
source_frame = feature_frames.get(query.owner_feature or "") or {}
cap = source_frame.get("start" if query.is_start else "end")
if cap is not None:
for source, entity_id in _source_refs(query_value):
offset_edge_planes[f"{source}:{entity_id}"] = dict(cap)
elif topology == "SWEPT_FACE":
selector = _direct_linear_extrude_swept_face_shell_reference(
query_value, feature_frames, sketch_by_source, sketches_by_id,
entity_by_sketch, feature_by_id, previous, model.featurescript_version,
)
elif topology == "OFFSET_FACE":
selector = _shell_offset_face_output_role_selector(
query_value, feature_by_id, sketches_by_id, previous,
)
elif topology == "COPY":
selector = _pattern_copy_face_reference(
query_value, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id,
)
else:
raise UnsupportedCapability("shell_face_selector", "current CDSL shell requires CAP_FACE, direct linear-extrude SWEPT_FACE, or COPY(CAP_FACE) removal selectors")
# Feature-output roles resolve only through the active
# kernel snapshot. A stable id would turn that semantic
# evidence into a stale geometric selector.
if selector.get("output_role") is None and not isinstance(selector.get("selector_intent"), dict):
selector["stable_id"] = f"cadfs_{fid}_{index}"
selectors.append(selector)
if not selectors:
raise ValueError("shell has no face removal selector")
# CADFS's oppositeDirection selects the exterior material
# side. The runtime contract carries this directly to OCC's
# signed offset; it is not a request to reverse removal-face
# ownership or a candidate for a current-body fallback.
shell_params = {"thickness_mm": thickness, "inward": not _bool(p.get("oppositeDirection"))}
if p.get("parts") is not None:
try:
shell_params["target_feature_id"] = _shell_target_body_source(
p["parts"], previous, body_transform_aliases, lowered_body_members,
)
except (UnsupportedCapability, ValueError) as error:
# Keep the established face-scoped execution path for
# legacy histories, but make the omitted parts-owner
# proof visible instead of silently treating the active
# aggregate as an explicitly selected body.
diagnostics.append({
"code": "unresolved_body_source",
"capability": "shell_parts_body_source",
"feature_id": item.feature_id,
"operation": item.operation,
"message": str(error),
})
feature = {"id": fid, "name": item.feature_id, "atomic_id": "shell", "depends_on": depends, "params": shell_params, "selectors": selectors, "execution_status": "supported"}
owners = {selector["owner_feature_id"].removeprefix("f_") for selector in selectors}
if len(owners) == 1:
source = next(iter(owners))
source_feature = feature_by_id.get(f"f_{source}") or {}
source_frame = feature_frames.get(source)
if source_frame is not None and source_feature.get("sketch_id"):
shell_frame = {
**source_frame,
"shell_source": source,
"shell_thickness_mm": thickness,
"shell_profile_sketch_id": source_feature["sketch_id"],
}
# A derived inner wall has a bounded span only when
# this direct shell removes exactly one known cap of
# the same extrusion. Additional removal faces can
# change its trim topology, so do not infer a wall.
if (
shell_params["inward"]
and len(cap_removals) == 1
and cap_removals[0][0] == source
):
shell_frame["shell_inward"] = True
shell_frame["shell_removed_cap"] = cap_removals[0][1]
feature_frames[item.feature_id] = shell_frame
if offset_edge_planes:
frame = feature_frames.setdefault(item.feature_id, {})
frame["shell_offset_edge_planes"] = offset_edge_planes
elif item.operation == "hole":
locations = _queries(p.get("locations")); positions = []; host_plane = None
for location in locations:
resolved_location = _direct_hole_location(location, sketch_by_source, entity_by_sketch)
if resolved_location is None:
raise UnsupportedCapability(
"hole_location_vertex",
"hole location requires one direct original sketch vertex",
)
position, location_plane = resolved_location
if host_plane is not None and location_plane != host_plane:
raise UnsupportedCapability(
"hole_location_plane",
"hole locations must share one explicit sketch plane",
)
positions.append({"mm": position})
host_plane = location_plane
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}
if p.get("scope") is None:
raise UnsupportedCapability(
"hole_scope_body_source",
"current CDSL CADFS hole lowering requires an explicit scope",
)
hole_params["scope_feature_id"] = _hole_scope_body_source(
p["scope"], previous, body_transform_aliases, lowered_body_members,
)
feature = {"id": fid, "name": item.feature_id, "atomic_id": "hole_wizard", "depends_on": depends, "params": hole_params, "execution_status": "supported"}
elif item.operation == "circularPattern":
sources = _pattern_source_features(p.get("entities"), previous)
sources = _pattern_body_history_sources(
p.get("entities"), sources, previous, feature_by_id,
body_transform_aliases,
)
axis = _circular_pattern_axis(p.get("axis"), feature_frames, sketch_by_source, entity_by_sketch)
count = int(_number(p.get("instanceCount")))
if count < 1:
raise ValueError("circular pattern instanceCount must be positive")
operation = str(p.get("operationType") or "NEW").split(".")[-1].upper()
operation_mode = "remove" if any(value in operation for value in ("REMOVE", "CUT")) else "add"
if operation_mode == "remove":
for source in sources:
source_feature = feature_by_id.get(source)
if source_feature is None:
raise ValueError("circular remove pattern source is unresolved")
_pattern_remove_source(source_feature)
feature = {
"id": fid,
"name": item.feature_id,
"atomic_id": "pattern_circular",
"depends_on": list(dict.fromkeys(sources + depends)),
"params": {
"source_feature_ids": sources,
"axis": axis,
"pattern_count": count,
"sweep_angle_deg": _number(p.get("angle", 360.0)),
"operation_mode": operation_mode,
},
"execution_status": "supported",
}
elif item.operation == "mirror":
owners = []
mirror_current_body = False
for call in walk_calls(p.get("entities")):
if call.name == "makeQuery" and call.args:
owner = symbolic_string(call.args[0]);
if "F" in owner:
source_id = "f_" + owner[owner.find("F"):].split(".", 1)[0]
if source_id in previous and source_id not in owners: owners.append(source_id)
query = parse_query(call)
if query.topology_type == "SWEPT_BODY" and query.kind in {"body", "entitytype.body"}:
mirror_current_body = True
if not owners: raise ValueError("mirror source features are unresolved")
plane_query = p.get("mirrorPlane"); plane_info = parse_query(plane_query); plane_owner = f"f_{plane_info.owner_feature}" if plane_info.owner_feature else None
if plane_owner and any(existing["id"] == plane_owner and existing["atomic_id"] == "reference_plane" for existing in features):
mirror_plane = {"kind": "plane", "owner_feature_id": plane_owner, "stable_id": f"cadfs_{fid}_plane", "source": "runtime_snapshot", "confidence": 1.0, "selector_intent": _explicit_datum_selector_intent(plane_query, kind="plane")}
source_plane = feature_frames.get(plane_info.owner_feature or "", {}).get("start")
if source_plane is None: raise ValueError("mirror plane frame is unresolved")
else:
plane = _mirror_plane_from_query(
plane_query, feature_frames, sketch_by_source, entity_by_sketch, feature_by_id,
)
if plane is None: raise ValueError("mirror plane is not a default or reference plane")
plane_owner = f"{fid}_plane"
features.append({"id": plane_owner, "name": f"{item.feature_id} plane", "atomic_id": "reference_plane", "depends_on": depends, "params": {"plane": plane}, "execution_status": "supported"})
previous.append(plane_owner)
mirror_plane = {"kind": "plane", "owner_feature_id": plane_owner, "stable_id": f"cadfs_{fid}_plane", "source": "runtime_snapshot", "confidence": 1.0, "selector_intent": _explicit_datum_selector_intent(plane_query, kind="plane")}
source_plane = plane
feature = {"id": fid, "name": item.feature_id, "atomic_id": "pattern_mirror", "depends_on": list(dict.fromkeys(owners + [plane_owner])), "params": {"source_feature_ids": owners, "mirror_plane": mirror_plane}, "selectors": [mirror_plane], "execution_status": "supported"}
if mirror_current_body: feature["params"]["mirror_current_body"] = True
# COPY(CAP_FACE) query 属于 pattern 的特定 instance,不能直接回退到
# source loft 的端盖。保存镜像变换和 source feature,供后续草图
# 在 lowering 期从 CADFS provenance 复算物理端盖 frame。
feature_frames[item.feature_id] = {
"copy_transform": {"type": "mirror", "plane": source_plane},
"copy_source_features": [source[2:] for source in owners],
}
else:
raise ValueError(f"operation mapping not implemented: {item.operation}")
features.append(feature); feature_by_id[fid] = feature; feature_source_by_id[fid] = item.feature_id
if (
item.operation == "transform"
and feature.get("atomic_id") == "transform_bodies"
and not bool(feature["params"].get("make_copy"))
and not feature["params"].get("pattern_instance_refs")
):
_record_non_copy_body_successors(
body_transform_aliases,
list(feature["params"].get("source_feature_ids") or ()),
fid,
)
_record_single_body_successor(
body_transform_aliases,
single_body_successor_state,
feature,
)
_record_lowered_body_members(lowered_body_members, feature)
if item.operation == "extrude" and surface_profile_sketch is not None:
features.append(surface_feature)
surface_profiles.append({
"profile": deepcopy(surface_profile_sketch["profile"]),
"workplane": dict(surface_profile_sketch["workplane"]),
**surface_feature["params"],
})
previous.append(fid)
except UnsupportedCapability as exc:
diagnostics.append({"code": "unsupported_engine_capability", "capability": exc.capability, "feature_id": item.feature_id, "operation": item.operation, "message": str(exc)}); complete = False
except Exception as exc:
diagnostics.append({"code": "feature_deferred", "feature_id": item.feature_id, "operation": item.operation, "message": str(exc)}); complete = False
if not features: return LoweringResult(None, "deferred_no_executable_feature", diagnostics, history)
source_featurescript = {
**({"version": model.featurescript_version} if model.featurescript_version else {}),
**({"standard_library": model.standard_library} if model.standard_library else {}),
**({"standard_library_version": model.standard_library_version} if model.standard_library_version else {}),
**({"standard_library_imports": deepcopy(model.standard_library_imports)} if model.standard_library_imports else {}),
}
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")}),
"source_featurescript": source_featurescript},
"geometry": {"sketches": sketches}, "features": features}
_finalize_selector_intents(cdsl, model)
return LoweringResult(cdsl, "converted_complete" if complete else "converted_partial", diagnostics, history)