11322 lines
544 KiB
Python
11322 lines
544 KiB
Python
from __future__ import annotations
|
||
|
||
import math
|
||
from copy import deepcopy
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
from .featurescript_parser import symbolic_string
|
||
from .ir import Call, FeatureIR, ModelIR, SketchIR
|
||
from .query_parser import parse_query, query_expr, walk_calls
|
||
|
||
|
||
UNSUPPORTED = {"draft", "thicken", "split", "moveFace", "replaceFace", "deleteFace", "import", "derive"}
|
||
|
||
# The parser preserves every direct ``operation(context, id + "F...", ...)``
|
||
# call. Keep lowering's executable surface explicit so newly observed source
|
||
# APIs get a stable unsupported-operation diagnostic rather than disappearing
|
||
# from history or falling through to an incidental implementation error.
|
||
LOWERABLE_OPERATIONS = {
|
||
"assignVariable", "transform", "deleteBodies", "cPoint", "cPlane", "extrude", "loft", "sweep",
|
||
"booleanBodies", "revolve", "fillet", "chamfer", "shell", "hole",
|
||
"circularPattern", "mirror",
|
||
}
|
||
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},
|
||
# Keep an executable-independent typed expression as well as the raw
|
||
# source AST. This records set boundaries and filters without claiming
|
||
# that an unsupported family can be resolved from geometry.
|
||
"query_expr": query_expr(value),
|
||
"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 _resolve_source_variables(value: Any, variables: dict[str, Any]) -> Any:
|
||
"""Resolve exact earlier ``getVariable(context, name)`` source calls.
|
||
|
||
CADFS variables are ordered context state, not topology. Lowering captures
|
||
the declared expression before it can be consumed by geometry parameters;
|
||
a missing or malformed lookup remains a deterministic source error rather
|
||
than becoming a guessed numeric default.
|
||
"""
|
||
if isinstance(value, Call):
|
||
if value.name == "getVariable":
|
||
if len(value.args) != 2 or value.args[0] != "context" or not isinstance(value.args[1], str):
|
||
raise ValueError("getVariable must name one source context variable")
|
||
name = value.args[1]
|
||
if name not in variables:
|
||
raise ValueError(f"source variable is unavailable: {name}")
|
||
return deepcopy(variables[name])
|
||
return Call(value.name, [_resolve_source_variables(argument, variables) for argument in value.args], value.line, value.raw)
|
||
if isinstance(value, list):
|
||
return [_resolve_source_variables(item, variables) for item in value]
|
||
if isinstance(value, dict):
|
||
return {key: _resolve_source_variables(item, variables) for key, item in value.items()}
|
||
return value
|
||
|
||
|
||
def _assign_variable_params(params: dict[str, Any]) -> tuple[str, Any, dict[str, Any]]:
|
||
"""Lower the two exported scalar assignVariable forms without coercion."""
|
||
name = params.get("name")
|
||
if not isinstance(name, str) or not name:
|
||
raise ValueError("assignVariable requires one non-empty source name")
|
||
variants = [(key, params[key]) for key in ("anyValue", "lengthValue") if key in params]
|
||
if len(variants) != 1:
|
||
raise UnsupportedCapability(
|
||
"assign_variable_value",
|
||
"assignVariable requires exactly one anyValue or lengthValue source expression",
|
||
)
|
||
key, value = variants[0]
|
||
numeric_value = _number(value, units=key == "lengthValue")
|
||
if not math.isfinite(numeric_value):
|
||
raise UnsupportedCapability("assign_variable_value", "assignVariable value must be finite")
|
||
return name, value, {
|
||
"name": name,
|
||
"value": numeric_value,
|
||
"value_kind": "length" if key == "lengthValue" else "any",
|
||
}
|
||
|
||
|
||
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
|
||
# Workplane materialization must follow the outer query expression. A
|
||
# A derived topology query may contain a nested CAP/SWEPT face, but that
|
||
# producer frame is not proof that the outer query has one active face
|
||
# successor. ``parse_query`` visits nested calls for diagnostic context,
|
||
# so it cannot define this boundary. The explicit mirrored COPY(CAP_FACE)
|
||
# contract was handled above; COPY and MERGE workplanes both require a
|
||
# dedicated runtime face relation before a sketch can attach to them.
|
||
try:
|
||
_call, owner, topology, outer_kind, _definition = _direct_make_query(value)
|
||
except ValueError:
|
||
owner = topology = outer_kind = None
|
||
query = parse_query(value)
|
||
direct_created_by = value
|
||
if isinstance(direct_created_by, Call) and direct_created_by.name == "qUnion" and len(direct_created_by.args) == 1 and isinstance(direct_created_by.args[0], list) and len(direct_created_by.args[0]) == 1:
|
||
direct_created_by = direct_created_by.args[0][0]
|
||
if isinstance(direct_created_by, Call) and direct_created_by.name == "qCreatedBy":
|
||
owner = query.owner_feature
|
||
if topology == "IMPRINT" and sketch_by_source and query.source_sketch in sketch_by_source:
|
||
return dict(sketch_by_source[query.source_sketch]["workplane"])
|
||
if topology == "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)
|
||
if topology == "COPY" and outer_kind == "face":
|
||
raise ValueError(
|
||
"COPY(FACE) workplane requires a dedicated complete/proven runtime face relation"
|
||
)
|
||
if topology == "MERGE" and outer_kind == "face":
|
||
raise ValueError(
|
||
"MERGE(FACE) workplane requires a dedicated complete/proven runtime face relation"
|
||
)
|
||
frame = feature_frames.get(owner or "")
|
||
if frame and topology == "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 isinstance(direct_created_by, Call) and direct_created_by.name == "qCreatedBy":
|
||
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:
|
||
# FeatureScript ``NewBodyOperationType.NEW`` creates an independent
|
||
# context body. ``ADD`` is a boolean union into its merge scope, so it
|
||
# must keep CDSL's default fusing result mode rather than being rewritten
|
||
# as an independent member.
|
||
normalized = str(value or "").upper()
|
||
return normalized.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_primary_add_up_to_surface: bool = False,
|
||
allow_direct_prism_swept_lineage: bool = False,
|
||
featurescript_version: str | None = None,
|
||
) -> dict[str, Any]:
|
||
# qOwnerBody projects a selected topology member to its containing active
|
||
# body. Its nested makeQuery retains the member kind, so recognize the
|
||
# narrow executable bridge before checking the outer extent kind.
|
||
if expected_kind == "body":
|
||
owner_body = _direct_owner_body_extent_selector(
|
||
value,
|
||
feature_by_id=feature_by_id or {},
|
||
feature_frames=feature_frames,
|
||
sketch_by_source=sketch_by_source,
|
||
sketches_by_id=sketches_by_id or {},
|
||
entity_by_sketch=entity_by_sketch,
|
||
previous=previous or [],
|
||
featurescript_version=featurescript_version,
|
||
)
|
||
if owner_body is not None:
|
||
return owner_body
|
||
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 primary ADD prism is transient: its cap only becomes selectable
|
||
# after the exact union topology delta proves one active successor.
|
||
# This is intentionally a separate consumer opt-in from the direct
|
||
# new-body cap bridge, and remains limited to the immediately
|
||
# following one-sided up-to-surface extent.
|
||
primary_add_cap_output_role = (
|
||
_cap_face_output_role_selector(
|
||
value,
|
||
feature_by_id or {},
|
||
sketches_by_id or {},
|
||
allow_primary_add_up_to_surface=True,
|
||
)
|
||
if feature_by_id is not None and sketches_by_id is not None
|
||
else None
|
||
)
|
||
if (
|
||
allow_primary_add_up_to_surface
|
||
and primary_add_cap_output_role is not None
|
||
and previous[-1:] == [primary_add_cap_output_role["owner_feature_id"]]
|
||
):
|
||
return primary_add_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,
|
||
allow_immediate_retained_source_edge=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
|
||
# A direct source owner alone is not evidence for an active result
|
||
# topology member. In particular, a deferred CAP/SWEPT query cannot be
|
||
# converted into an extent reference via a stable ID or a geometric
|
||
# fallback: that would authorize an arbitrary later/current body and can
|
||
# also leave an owner that was never lowered. Keep the feature deferred
|
||
# until a bounded provenance contract above proves the exact reference.
|
||
raise UnsupportedCapability(
|
||
f"extrude_extent_{expected_kind}_selector",
|
||
f"current CDSL {expected_kind} extent requires a complete/proven active selector 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") not in {"blind", "through_all"}
|
||
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 _direct_prism_cap_vertex_extent_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 immediate direct-prism CAP_VERTEX target to kernel history.
|
||
|
||
A CAP vertex is not identified by its cap-plane coordinates. The two
|
||
source profile edges must name exactly one original shared endpoint, and
|
||
OCC must carry that source vertex to the requested prism cap. This first
|
||
contract deliberately excludes continuation, draft, multi-profile, and
|
||
additive/cutting body lifecycles.
|
||
"""
|
||
if featurescript_version != "1511":
|
||
return None
|
||
try:
|
||
_call, owner, topology, kind, _definition = _direct_make_query(value)
|
||
except ValueError:
|
||
return None
|
||
query = parse_query(value)
|
||
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
|
||
if producer_id not in previous:
|
||
return None
|
||
producer_index = previous.index(producer_id)
|
||
intervening = previous[producer_index + 1:]
|
||
# An independent member can remain selectable across datum features and
|
||
# later independent new-body creation. Any operation that may mutate,
|
||
# fuse, delete, copy, or transform the producer is deliberately excluded;
|
||
# runtime still has to prove the exact member-preservation relation.
|
||
preserves_member = all(
|
||
(feature_by_id.get(feature_id) or {}).get("atomic_id") == "reference_plane"
|
||
or (
|
||
(feature_by_id.get(feature_id) or {}).get("atomic_id") == "extrude_add_blind"
|
||
and ((feature_by_id.get(feature_id) or {}).get("params") or {}).get("result_mode") == "new_body"
|
||
and (((feature_by_id.get(feature_id) or {}).get("params") or {}).get("end_condition") or {}).get("type") == "blind"
|
||
and ((feature_by_id.get(feature_id) or {}).get("params") or {}).get("draft") is None
|
||
)
|
||
for feature_id in intervening
|
||
)
|
||
if (
|
||
topology != "CAP_VERTEX"
|
||
or kind not in {"vertex", "entitytype.vertex"}
|
||
or query.is_start is None
|
||
or not preserves_member
|
||
or producer.get("atomic_id") != "extrude_add_blind"
|
||
or params.get("result_mode") != "new_body"
|
||
or (params.get("end_condition") or {}).get("type") not in {"blind", "through_all"}
|
||
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
|
||
refs = _source_refs(value)
|
||
if len(refs) != 2 or {source for source, _token in refs} != {profile_source}:
|
||
return None
|
||
source_ids = _direct_profile_source_entity_ids(profile_sketch)
|
||
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 or entity_id in resolved_ids:
|
||
return None
|
||
resolved_ids.append(entity_id)
|
||
if _shared_source_endpoint(refs, profile_source, entity_by_sketch) is None:
|
||
return None
|
||
intent = _selector_intent(
|
||
value,
|
||
query_family="CAP_VERTEX",
|
||
kind="vertex",
|
||
evidence="kernel_history",
|
||
allowed=("boundary", "continuation") if intervening else ("boundary",),
|
||
)
|
||
intent.pop("source_entity", None)
|
||
intent["source_entities"] = [
|
||
{"sketch_id": profile_source, "entity_id": entity_id}
|
||
for entity_id in sorted(resolved_ids)
|
||
]
|
||
intent["lineage_role"] = f"extrude.{'start' if query.is_start else 'end'}"
|
||
return {
|
||
"kind": "vertex",
|
||
"owner_feature_id": producer_id,
|
||
"source": "runtime_snapshot",
|
||
"confidence": 1.0,
|
||
"selector_intent": intent,
|
||
}
|
||
|
||
|
||
def _direct_source_vertex_extent_reference(
|
||
value: Any,
|
||
sketch_by_source: dict[str, dict[str, Any]],
|
||
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
|
||
*,
|
||
featurescript_version: str | None,
|
||
) -> dict[str, Any] | None:
|
||
"""Capture one direct source-sketch vertex as an immutable extent datum.
|
||
|
||
This is deliberately not a topology selector. A direct ``sQuery`` vertex
|
||
is defined by the source sketch's explicit workplane and entity endpoint,
|
||
so it can supply an extrusion distance without asking the runtime to find
|
||
a similarly placed B-rep vertex. Composed, derived, runtime-topology, and
|
||
multi-item query forms stay outside this datum contract.
|
||
"""
|
||
if featurescript_version != "1511":
|
||
return None
|
||
queries = _queries(value)
|
||
if len(queries) != 1 or not _is_direct_hole_location_query(queries[0]):
|
||
return None
|
||
source = _direct_hole_location(queries[0], sketch_by_source, entity_by_sketch)
|
||
if source is None:
|
||
return None
|
||
local_point, plane = source
|
||
info = parse_query(queries[0])
|
||
if not isinstance(info.source_sketch, str) or not isinstance(info.source_entity, str):
|
||
return None
|
||
return {
|
||
"kind": "source_vertex",
|
||
"source_sketch_id": info.source_sketch,
|
||
"source_entity_id": info.source_entity,
|
||
"point_mm": _global(plane, local_point[:2]),
|
||
}
|
||
|
||
|
||
def _direct_prism_cap_vertex_datum_point(
|
||
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,
|
||
) -> list[float] | None:
|
||
"""Calculate one source-defined direct-prism cap vertex for a datum.
|
||
|
||
This is deliberately separate from runtime selector resolution. A datum
|
||
frame needs a physical source point, while an extent needs a live active
|
||
B-rep vertex. Both nevertheless require the same direct prism and OSD
|
||
proof; no stale frame, transformed body, geometry search, or STEP result
|
||
may stand in for a CAP_VERTEX query.
|
||
"""
|
||
if featurescript_version != "1511":
|
||
return None
|
||
try:
|
||
_call, owner, topology, kind, _definition = _direct_make_query(value)
|
||
except ValueError:
|
||
return None
|
||
query = parse_query(value)
|
||
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
|
||
if producer_id not in previous:
|
||
return None
|
||
producer_index = previous.index(producer_id)
|
||
intervening = previous[producer_index + 1:]
|
||
|
||
def preserves_datum_source(feature_id: str) -> bool:
|
||
feature = feature_by_id.get(feature_id) or {}
|
||
atomic_id = feature.get("atomic_id")
|
||
params = feature.get("params") or {}
|
||
if atomic_id == "reference_plane":
|
||
return True
|
||
if (
|
||
atomic_id == "extrude_add_blind"
|
||
and params.get("result_mode") == "new_body"
|
||
and (params.get("end_condition") or {}).get("type") == "blind"
|
||
and params.get("draft") is None
|
||
):
|
||
return True
|
||
# A direct one-cap shell can intervene for a datum-only CAP_VERTEX:
|
||
# makeQuery(owner.opExtrude, CAP_VERTEX) names the source-defined
|
||
# producer cap position, not a current shell-result vertex. Restrict
|
||
# it to one exact producer cap role; side/set shells lack that proof.
|
||
selectors = feature.get("selectors") or ()
|
||
return (
|
||
atomic_id == "shell"
|
||
and len(selectors) == 1
|
||
and isinstance(selectors[0], dict)
|
||
and selectors[0].get("kind") == "face"
|
||
and selectors[0].get("owner_feature_id") == producer_id
|
||
and selectors[0].get("output_role") in {"extrude.start", "extrude.end"}
|
||
and (selectors[0].get("selector_intent") or {}).get("query_family") == "CAP_FACE"
|
||
and (selectors[0].get("selector_intent") or {}).get("evidence") == "operation_role"
|
||
)
|
||
|
||
preserves_source_datum = all(
|
||
preserves_datum_source(feature_id)
|
||
for feature_id in intervening
|
||
)
|
||
if (
|
||
topology != "CAP_VERTEX"
|
||
or kind not in {"vertex", "entitytype.vertex"}
|
||
or query.is_start is None
|
||
or not preserves_source_datum
|
||
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
|
||
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
|
||
refs = _source_refs(value)
|
||
if len(refs) != 2 or {source for source, _token in refs} != {profile_source}:
|
||
return None
|
||
source_ids = _direct_profile_source_entity_ids(profile_sketch)
|
||
resolved_ids: set[str] = set()
|
||
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 or entity_id in resolved_ids:
|
||
return None
|
||
resolved_ids.add(entity_id)
|
||
local_point = _shared_source_endpoint(refs, profile_source, entity_by_sketch)
|
||
cap = frame.get("start" if query.is_start else "end")
|
||
profile = frame.get("profile")
|
||
if (
|
||
local_point is None
|
||
or not isinstance(cap, dict)
|
||
or not isinstance(profile, dict)
|
||
or not isinstance(cap.get("origin_mm"), list)
|
||
):
|
||
return None
|
||
return _global({**profile, "origin_mm": list(cap["origin_mm"])}, local_point)
|
||
|
||
|
||
def _q_owner_body_input(value: Any) -> Any | None:
|
||
"""Return the sole source input of a direct ``qOwnerBody`` expression."""
|
||
if not isinstance(value, Call) or value.name != "qOwnerBody" or len(value.args) != 1:
|
||
return None
|
||
return value.args[0]
|
||
|
||
|
||
def _direct_owner_body_extent_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 exact topology-to-active-body ``qOwnerBody`` extent.
|
||
|
||
This is deliberately an ownership projection over a direct, already
|
||
executable topology selector. It does not infer an owner from the
|
||
current aggregate, an OCC proximity search, or a body-producing feature.
|
||
"""
|
||
if featurescript_version != "1511":
|
||
return None
|
||
query_input = _q_owner_body_input(value)
|
||
if query_input is None:
|
||
return None
|
||
try:
|
||
_call, owner, topology, kind, _definition = _direct_make_query(query_input)
|
||
except ValueError:
|
||
return None
|
||
if kind != "face" or topology not in {"CAP_FACE", "SWEPT_FACE"}:
|
||
return None
|
||
producer_id = f"f_{owner}"
|
||
if topology == "SWEPT_FACE":
|
||
input_selector = _direct_prism_swept_selector(
|
||
query_input,
|
||
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,
|
||
)
|
||
else:
|
||
input_selector = _cap_face_output_role_selector(
|
||
query_input, feature_by_id, sketches_by_id,
|
||
) if previous[-1:] == [producer_id] else None
|
||
if input_selector is None:
|
||
return None
|
||
intent = _selector_intent(
|
||
value,
|
||
query_family="OWNER_BODY",
|
||
kind="body",
|
||
evidence="kernel_history",
|
||
allowed=("boundary",),
|
||
)
|
||
intent.update({
|
||
"owner_body_contract": "exact_input_owner",
|
||
"body_scope": "active_member",
|
||
"empty_policy": "reject",
|
||
"multiple_policy": "one",
|
||
})
|
||
return {
|
||
"kind": "body",
|
||
"source": "runtime_snapshot",
|
||
"confidence": 1.0,
|
||
"query_input": input_selector,
|
||
"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 ""))
|
||
immediate_producer = previous[-1:] == [producer_id]
|
||
immediate_primary_cut = False
|
||
if len(previous) >= 2 and previous[-2] == producer_id:
|
||
cut = feature_by_id.get(previous[-1]) or {}
|
||
cut_params = cut.get("params") or {}
|
||
immediate_primary_cut = (
|
||
cut.get("atomic_id") == "extrude_cut_blind"
|
||
and cut.get("depends_on") == [producer_id]
|
||
and (cut_params.get("end_condition") or {}).get("type") == "blind"
|
||
and cut_params.get("draft") is None
|
||
)
|
||
if (
|
||
topology != "SWEPT_FACE"
|
||
or kind not in {"face", "entitytype.face"}
|
||
or atomic_id not in supported_atomics
|
||
or not (immediate_producer or immediate_primary_cut)
|
||
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,
|
||
# The only non-immediate shell tuple keeps one exact target-side
|
||
# BRepAlgoAPI_Cut continuation between the prism and shell.
|
||
allow_continuation=immediate_primary_cut,
|
||
)
|
||
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 _shell_retained_direct_prism_cap_offset_face_profile_selector(
|
||
value: Any,
|
||
feature_by_id: dict[str, dict[str, Any]],
|
||
sketches_by_id: dict[str, dict[str, Any]],
|
||
previous: list[str],
|
||
*,
|
||
featurescript_version: str | None,
|
||
) -> dict[str, Any] | None:
|
||
"""Use a retained shell cap as an extrusion profile only with full source proof.
|
||
|
||
This is deliberately distinct from the TDD OFFSET_FACE selector bridge.
|
||
The exported query names the entire original direct-prism profile through
|
||
one OSD set, while the immediately preceding shell removes the opposite
|
||
cap. The runtime still has to prove the retained cap -> offset-face
|
||
kernel relation; source-profile membership alone never selects a wall.
|
||
"""
|
||
if featurescript_version != "1511":
|
||
return None
|
||
try:
|
||
_call, shell_name, topology, kind, definition = _direct_make_query(value)
|
||
except ValueError:
|
||
return None
|
||
shell_id = f"f_{shell_name}"
|
||
shell = feature_by_id.get(shell_id) or {}
|
||
if (
|
||
topology != "OFFSET_FACE"
|
||
or kind not in {"face", "entitytype.face"}
|
||
or previous[-1:] != [shell_id]
|
||
or shell.get("atomic_id") != "shell"
|
||
):
|
||
return None
|
||
|
||
# This contract describes one retained cap only. More than one removal
|
||
# target can alter the cap's topology and must remain a diagnostic.
|
||
shell_selectors = shell.get("selectors") or []
|
||
if len(shell_selectors) != 1 or not isinstance(shell_selectors[0], dict):
|
||
return None
|
||
removed = shell_selectors[0]
|
||
removed_intent = removed.get("selector_intent") or {}
|
||
source_id = removed.get("owner_feature_id")
|
||
removed_role = removed.get("output_role")
|
||
if (
|
||
not isinstance(source_id, str)
|
||
or source_id not in feature_by_id
|
||
or removed_role not in {"extrude.start", "extrude.end"}
|
||
or removed_intent.get("query_family") != "CAP_FACE"
|
||
or shell.get("depends_on") != [source_id]
|
||
):
|
||
return None
|
||
source_feature = feature_by_id[source_id]
|
||
source_params = source_feature.get("params") or {}
|
||
source_sketch = sketches_by_id.get(str(source_feature.get("sketch_id") or ""))
|
||
if (
|
||
source_feature.get("atomic_id") != "extrude_add_blind"
|
||
or source_params.get("result_mode") != "new_body"
|
||
or (source_params.get("end_condition") or {}).get("type") != "blind"
|
||
or source_params.get("draft") is not None
|
||
or not isinstance(source_sketch, dict)
|
||
):
|
||
return None
|
||
source_ids = _direct_profile_source_entity_ids(source_sketch)
|
||
profile_source = source_sketch.get("source_sketch_id")
|
||
query = parse_query(value)
|
||
refs = _source_refs(value)
|
||
if (
|
||
not source_ids
|
||
or not isinstance(profile_source, str)
|
||
or query.source_sketch != profile_source
|
||
or len(refs) != len(source_ids)
|
||
or {sketch_id for sketch_id, _entity_id in refs} != {profile_source}
|
||
or {entity_id for _sketch_id, entity_id in refs} != source_ids
|
||
):
|
||
return None
|
||
disambiguation = definition.get("disambiguationData")
|
||
if (
|
||
not isinstance(disambiguation, list)
|
||
or len(disambiguation) != 1
|
||
or not isinstance(disambiguation[0], Call)
|
||
or disambiguation[0].name not in {"OSD", "originalSetDisambiguation"}
|
||
or len(disambiguation[0].args) != 1
|
||
or not isinstance(disambiguation[0].args[0], list)
|
||
):
|
||
return None
|
||
|
||
retained_role = "extrude.end" if removed_role == "extrude.start" else "extrude.start"
|
||
intent = _selector_intent(
|
||
value,
|
||
query_family="OFFSET_FACE",
|
||
kind="face",
|
||
evidence="operation_role",
|
||
allowed=("boundary", "replacement"),
|
||
output_role="shell.offset_face",
|
||
disambiguation={
|
||
"source_profile_entity_ids": sorted(source_ids),
|
||
"removed_cap_role": removed_role,
|
||
},
|
||
)
|
||
intent["consumer_contract"] = "shell_retained_direct_prism_cap_offset_face_profile"
|
||
return {
|
||
"kind": "face",
|
||
"owner_feature_id": shell_id,
|
||
"output_role": "shell.offset_face",
|
||
"output_role_source": {
|
||
"owner_feature_id": source_id,
|
||
"output_role": retained_role,
|
||
},
|
||
"source": "runtime_snapshot",
|
||
"confidence": 1.0,
|
||
"selector_intent": intent,
|
||
}
|
||
|
||
|
||
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 _direct_query_set_operands(value: Any) -> tuple[str, str, list[Any]] | None:
|
||
"""Return a direct source set only when its operand boundary is explicit."""
|
||
if not isinstance(value, Call):
|
||
return None
|
||
if value.name == "qUnion":
|
||
if len(value.args) != 1 or not isinstance(value.args[0], list):
|
||
return None
|
||
operands = value.args[0]
|
||
contract = "proven_operand_union"
|
||
operator = "union"
|
||
elif value.name == "qIntersection":
|
||
operands = value.args[0] if len(value.args) == 1 and isinstance(value.args[0], list) else value.args
|
||
contract = "proven_operand_intersection"
|
||
operator = "intersection"
|
||
elif value.name == "qSubtraction":
|
||
operands = value.args
|
||
contract = "proven_operand_subtraction"
|
||
operator = "subtraction"
|
||
else:
|
||
return None
|
||
if (
|
||
not isinstance(operands, list)
|
||
or len(operands) < 2
|
||
or contract == "proven_operand_subtraction" and len(operands) != 2
|
||
):
|
||
return None
|
||
return contract, operator, operands
|
||
|
||
|
||
def _query_set_leaf_values(value: Any) -> list[Any]:
|
||
"""Flatten only the leaves of an explicit recursive source set tree.
|
||
|
||
The flattened list is an implementation detail of lowering: it lets the
|
||
existing direct-provenance constructors handle every ``makeQuery`` leaf.
|
||
``_proven_query_set_selector`` below reconstructs the exact source tree,
|
||
so flattening here never turns nested FeatureScript set semantics into an
|
||
unstructured selector list.
|
||
"""
|
||
source_set = _direct_query_set_operands(value)
|
||
if source_set is None:
|
||
return [value]
|
||
return [leaf for operand in source_set[2] for leaf in _query_set_leaf_values(operand)]
|
||
|
||
|
||
def _proven_query_set_selector(value: Any, selectors: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||
"""Preserve a recursive FeatureScript query set with proven operands.
|
||
|
||
A selector set is not a list of equivalent single-selector decisions.
|
||
Every set node retains its own source expression and runtime composes only
|
||
already-proven direct leaf results. Filters and non-``makeQuery`` leaves
|
||
stay deferred, but unions/intersections/subtractions can be freely nested
|
||
when every branch has the same exact source provenance contract.
|
||
"""
|
||
if _direct_query_set_operands(value) is None:
|
||
return None
|
||
|
||
def direct_leaf(selector_value: Any, selector: dict[str, Any]) -> set[str] | None:
|
||
"""Return the leaf derivations only for an executable provenance leaf."""
|
||
intent = selector.get("selector_intent")
|
||
policy = intent.get("derivation_policy") if isinstance(intent, dict) else None
|
||
expression = intent.get("query_expr") if isinstance(intent, dict) else None
|
||
if (
|
||
not isinstance(selector_value, Call)
|
||
or selector_value.name != "makeQuery"
|
||
or
|
||
selector.get("source") != "runtime_snapshot"
|
||
or any(selector.get(key) is not None for key in (
|
||
"stable_id", "snapshot_id", "geometry", "binding_feature_id",
|
||
))
|
||
or not isinstance(intent, dict)
|
||
or intent.get("query_family") in {None, "GEOMETRIC", "QUERY_SET"}
|
||
or not isinstance(policy, dict)
|
||
or policy.get("multiplicity") == "none"
|
||
or not isinstance(expression, dict)
|
||
or expression.get("root") != query_expr(selector_value)["root"]
|
||
):
|
||
return None
|
||
return {item for item in policy.get("allowed") or () if isinstance(item, str)}
|
||
|
||
cursor = 0
|
||
|
||
def build(node: Any) -> tuple[dict[str, Any], set[str]] | None:
|
||
nonlocal cursor
|
||
source_set = _direct_query_set_operands(node)
|
||
if source_set is None:
|
||
if cursor >= len(selectors):
|
||
return None
|
||
selector = selectors[cursor]
|
||
cursor += 1
|
||
allowed = direct_leaf(node, selector)
|
||
return (selector, allowed) if allowed else None
|
||
|
||
contract, _operator, operands = source_set
|
||
children: list[dict[str, Any]] = []
|
||
allowed: set[str] = set()
|
||
for operand in operands:
|
||
child = build(operand)
|
||
if child is None:
|
||
return None
|
||
child_selector, child_allowed = child
|
||
children.append(child_selector)
|
||
allowed.update(child_allowed)
|
||
kinds = {child.get("kind") for child in children}
|
||
if len(kinds) != 1 or next(iter(kinds), None) not in {"face", "edge"} or not allowed:
|
||
return None
|
||
kind = next(iter(kinds))
|
||
intent = _selector_intent(
|
||
node,
|
||
query_family="QUERY_SET",
|
||
kind=kind,
|
||
evidence="kernel_history",
|
||
allowed=tuple(item for item in (
|
||
"continuation", "fragment", "merge", "intersection", "boundary", "replacement",
|
||
) if item in allowed),
|
||
multiplicity="source_qualified",
|
||
)
|
||
# A parent set has no singular source anchor. The exact anchors remain
|
||
# in its recursively matched child selectors.
|
||
intent.pop("source_entity", None)
|
||
intent.update({
|
||
"query_set_contract": contract,
|
||
"set_kind": kind,
|
||
"body_scope": "active_member",
|
||
"empty_policy": "reject",
|
||
"multiple_policy": "all",
|
||
})
|
||
return {
|
||
"kind": kind,
|
||
"source": "runtime_snapshot",
|
||
"confidence": 1.0,
|
||
"query_operands": children,
|
||
"selector_intent": intent,
|
||
}, allowed
|
||
|
||
result = build(value)
|
||
if result is None or cursor != len(selectors):
|
||
return None
|
||
return result[0]
|
||
|
||
|
||
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 _has_one_exact_retained_source_edge(
|
||
selected: dict[str, Any],
|
||
source: dict[str, Any],
|
||
source_entity_id: str,
|
||
) -> bool:
|
||
"""Prove one source curve remains one unchanged selected profile edge.
|
||
|
||
A bounded IMPRINT region may deliberately omit other source loops while
|
||
retaining an original outer boundary. That is not a complete-direct-
|
||
profile contract, but it can still supply one source-qualified prism wall
|
||
when the exact curve survives unchanged and the adapter preserves its OCC
|
||
handle. Do not use labels alone: transformed, split, duplicate, or
|
||
cross-frame curves are not equivalent source edges.
|
||
"""
|
||
if selected.get("workplane") != source.get("workplane"):
|
||
return False
|
||
|
||
def segments(sketch: dict[str, Any]) -> list[dict[str, Any]]:
|
||
profile = sketch.get("profile") or {}
|
||
if profile.get("type") != "analytic_contours":
|
||
return []
|
||
return [
|
||
segment
|
||
for contour in profile.get("contours") or ()
|
||
if isinstance(contour, dict)
|
||
for segment in contour.get("segments") or ()
|
||
if isinstance(segment, dict) and segment.get("source_entity_id") == source_entity_id
|
||
]
|
||
|
||
source_segments = segments(source)
|
||
selected_segments = segments(selected)
|
||
return len(source_segments) == len(selected_segments) == 1 and selected_segments[0] == source_segments[0]
|
||
|
||
|
||
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,
|
||
allow_immediate_retained_source_edge: 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
|
||
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
|
||
two_sided_cap_contract = (
|
||
topology == "CAP_EDGE"
|
||
and featurescript_version == "2491"
|
||
and producer.get("atomic_id") == "extrude_add_two_sided"
|
||
and (params.get("reverse_end_condition") or {}).get("type") == "blind"
|
||
)
|
||
if featurescript_version != "1511" and not two_sided_cap_contract:
|
||
return None
|
||
immediate_producer = previous[-1:] == [producer_id]
|
||
result_mode = params.get("result_mode")
|
||
# A primary ADD is represented by the default/fuse result mode. Its
|
||
# direct prism result may continue into the active body only when the
|
||
# runtime's singular OCC union records a complete relation. Lowering
|
||
# keeps the source contract identical to an independent prism; it merely
|
||
# permits the resolver to demand that extra continuation proof.
|
||
is_primary_add = (
|
||
topology == "CAP_EDGE"
|
||
and producer.get("atomic_id") == "extrude_add_blind"
|
||
and result_mode != "new_body"
|
||
)
|
||
direct_profile = _profile_matches_direct_source(profile_sketch or {}, source_sketch or {})
|
||
if (
|
||
producer.get("atomic_id") not in (
|
||
{"extrude_add_two_sided"}
|
||
if two_sided_cap_contract
|
||
else {"extrude_add_blind", "extrude_cut_blind"}
|
||
)
|
||
or (not immediate_producer and (not allow_continuation or producer_id not in previous))
|
||
or (result_mode != "new_body" and not is_primary_add)
|
||
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 direct_profile
|
||
and not (
|
||
allow_immediate_retained_source_edge
|
||
and immediate_producer
|
||
and topology == "SWEPT_FACE"
|
||
)
|
||
):
|
||
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",) if two_sided_cap_contract else (
|
||
("boundary", "continuation") if (allow_continuation or is_primary_add) 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
|
||
retained_source_edge = not direct_profile
|
||
if retained_source_edge and not _has_one_exact_retained_source_edge(
|
||
profile_sketch, source_sketch, refs[0][1],
|
||
):
|
||
return None
|
||
intent = _selector_intent(
|
||
value,
|
||
query_family="SWEPT_FACE",
|
||
kind="face",
|
||
evidence="kernel_history",
|
||
allowed=("boundary",) if retained_source_edge else (
|
||
("boundary", "continuation") if allow_continuation else ("boundary",)
|
||
),
|
||
)
|
||
if retained_source_edge:
|
||
intent["consumer_contract"] = "immediate_retained_source_prism_swept_face_up_to_surface"
|
||
return {
|
||
"kind": "face",
|
||
"owner_feature_id": producer_id,
|
||
"source": "runtime_snapshot",
|
||
"confidence": 1.0,
|
||
"selector_intent": intent,
|
||
}
|
||
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", "continuation") if allow_continuation else ("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 _direct_prism_shell_offset_edge_tdd_selector(
|
||
value: Any,
|
||
*,
|
||
owner: 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,
|
||
) -> dict[str, Any] | None:
|
||
"""Lower one retained direct-prism cap edge from an outer shell OFFSET_EDGE.
|
||
|
||
The TDD form is not a request for the shell's inner wall. It names a
|
||
CAP_EDGE on the cap retained by an immediately following shell, so the
|
||
executable witness is the direct-prism cap boundary followed by one exact
|
||
shell continuation. OSD-only pairs, generated inner-wall edges, and
|
||
delayed shell consumers deliberately remain deferred.
|
||
"""
|
||
if featurescript_version != "1511":
|
||
return None
|
||
try:
|
||
_call, shell_owner, topology, kind, definition = _direct_make_query(value)
|
||
except ValueError:
|
||
return None
|
||
if shell_owner != owner or topology != "OFFSET_EDGE" or kind not in {"edge", "entitytype.edge"}:
|
||
return None
|
||
disambiguation = definition.get("disambiguationData")
|
||
if not isinstance(disambiguation, list) or len(disambiguation) != 2:
|
||
return None
|
||
original_set, tdd = disambiguation
|
||
if (
|
||
not isinstance(original_set, Call) or original_set.name != "OSD" or len(original_set.args) != 1
|
||
or not isinstance(original_set.args[0], list) or len(original_set.args[0]) != 1
|
||
or not isinstance(tdd, Call) or tdd.name != "TDD" or len(tdd.args) != 1
|
||
or not isinstance(tdd.args[0], list) or len(tdd.args[0]) != 1
|
||
):
|
||
return None
|
||
outer_refs = _source_refs(original_set)
|
||
nested = tdd.args[0][0]
|
||
try:
|
||
_nested_call, producer_owner, nested_topology, nested_kind, nested_definition = _direct_make_query(nested)
|
||
except ValueError:
|
||
return None
|
||
if nested_topology != "CAP_EDGE" or nested_kind not in {"edge", "entitytype.edge"}:
|
||
return None
|
||
nested_disambiguation = nested_definition.get("disambiguationData")
|
||
if (
|
||
len(outer_refs) != 1
|
||
or not isinstance(nested_disambiguation, list) or len(nested_disambiguation) != 1
|
||
or not isinstance(nested_disambiguation[0], Call) or nested_disambiguation[0].name != "OSD"
|
||
or len(nested_disambiguation[0].args) != 1
|
||
or not isinstance(nested_disambiguation[0].args[0], list) or len(nested_disambiguation[0].args[0]) != 1
|
||
or _source_refs(nested_disambiguation[0]) != outer_refs
|
||
):
|
||
return None
|
||
cap_selector = _direct_prism_swept_selector(
|
||
nested,
|
||
owner=producer_owner,
|
||
selector_kind="edge",
|
||
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,
|
||
allow_continuation=True,
|
||
)
|
||
if cap_selector is None:
|
||
return None
|
||
producer_id = f"f_{producer_owner}"
|
||
shell_id = f"f_{shell_owner}"
|
||
shell = feature_by_id.get(shell_id) or {}
|
||
cap_intent = cap_selector.get("selector_intent") or {}
|
||
cap_role = cap_intent.get("lineage_role")
|
||
shell_selectors = shell.get("selectors") or []
|
||
if (
|
||
previous[-1:] != [shell_id]
|
||
or shell.get("atomic_id") != "shell"
|
||
or shell.get("depends_on") != [producer_id]
|
||
or (shell.get("params") or {}).get("inward") is not True
|
||
or len(shell_selectors) != 1
|
||
or not isinstance(shell_selectors[0], dict)
|
||
or shell_selectors[0].get("owner_feature_id") != producer_id
|
||
or shell_selectors[0].get("output_role")
|
||
!= ("extrude.end" if cap_role == "extrude.start" else "extrude.start")
|
||
):
|
||
return None
|
||
source_entity = cap_intent.get("source_entity")
|
||
if not isinstance(source_entity, dict):
|
||
return None
|
||
intent = _selector_intent(
|
||
value,
|
||
query_family="OFFSET_EDGE",
|
||
kind="edge",
|
||
evidence="kernel_history",
|
||
allowed=("boundary", "continuation"),
|
||
disambiguation={
|
||
"type": "offset_edge_tdd_cap_continuation",
|
||
"shell_feature_id": shell_id,
|
||
"outer_owner_feature_id": shell_id,
|
||
"tdd_cap_owner_feature_id": producer_id,
|
||
"tdd_cap_role": cap_role,
|
||
"source_entity": dict(source_entity),
|
||
},
|
||
)
|
||
intent["source_entity"] = dict(source_entity)
|
||
intent["lineage_role"] = cap_role
|
||
intent["consumer_contract"] = "direct_prism_shell_offset_edge_tdd"
|
||
return {
|
||
"kind": "edge",
|
||
"owner_feature_id": producer_id,
|
||
"source": "runtime_snapshot",
|
||
"confidence": 1.0,
|
||
"selector_intent": intent,
|
||
}
|
||
|
||
|
||
def _direct_prism_shell_offset_edge_vertex_selector(
|
||
value: Any,
|
||
*,
|
||
owner: 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,
|
||
) -> dict[str, Any] | None:
|
||
"""Lower an OSD-only shell ``OFFSET_EDGE`` anchored at one profile vertex.
|
||
|
||
This is deliberately distinct from the retained-cap TDD form. Two
|
||
incident source profile edges name one source vertex, whose direct-prism
|
||
swept edge must survive the immediately following shell by a complete
|
||
kernel continuation. A shell-wall generated from a removed cap edge is
|
||
neither this source vertex nor a substitute for it.
|
||
"""
|
||
if featurescript_version != "1511":
|
||
return None
|
||
try:
|
||
_call, shell_owner, topology, kind, definition = _direct_make_query(value)
|
||
except ValueError:
|
||
return None
|
||
if shell_owner != owner or topology != "OFFSET_EDGE" or kind not in {"edge", "entitytype.edge"}:
|
||
return None
|
||
disambiguation = definition.get("disambiguationData")
|
||
if (
|
||
not isinstance(disambiguation, list) or len(disambiguation) != 1
|
||
or not isinstance(disambiguation[0], Call) or disambiguation[0].name != "OSD"
|
||
or len(disambiguation[0].args) != 1 or not isinstance(disambiguation[0].args[0], list)
|
||
):
|
||
return None
|
||
refs = _source_refs(disambiguation[0])
|
||
shell_id = f"f_{shell_owner}"
|
||
shell = feature_by_id.get(shell_id) or {}
|
||
depends_on = shell.get("depends_on") or []
|
||
if previous[-1:] != [shell_id] or len(depends_on) != 1:
|
||
return None
|
||
producer_id = depends_on[0]
|
||
producer = feature_by_id.get(producer_id) or {}
|
||
producer_owner = producer_id.removeprefix("f_")
|
||
frame = feature_frames.get(producer_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
|
||
params = producer.get("params") or {}
|
||
source_ids = _direct_profile_source_entity_ids(profile_sketch or {})
|
||
if (
|
||
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
|
||
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)
|
||
or len(refs) != 2
|
||
or {source for source, _token in refs} != {profile_source}
|
||
or _shared_source_endpoint(refs, profile_source, entity_by_sketch) is None
|
||
):
|
||
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 or entity_id in resolved_ids:
|
||
return None
|
||
resolved_ids.append(entity_id)
|
||
shell_selectors = shell.get("selectors") or []
|
||
if (
|
||
shell.get("atomic_id") != "shell"
|
||
or (shell.get("params") or {}).get("inward") is not True
|
||
or len(shell_selectors) != 1
|
||
or not isinstance(shell_selectors[0], dict)
|
||
or shell_selectors[0].get("kind") != "face"
|
||
or shell_selectors[0].get("owner_feature_id") != producer_id
|
||
or shell_selectors[0].get("output_role") not in {"extrude.start", "extrude.end"}
|
||
):
|
||
return None
|
||
source_entities = [
|
||
{"sketch_id": profile_source, "entity_id": entity_id}
|
||
for entity_id in sorted(resolved_ids)
|
||
]
|
||
intent = _selector_intent(
|
||
value,
|
||
query_family="OFFSET_EDGE",
|
||
kind="edge",
|
||
evidence="kernel_history",
|
||
allowed=("boundary", "continuation"),
|
||
disambiguation={
|
||
"type": "offset_edge_vertex_continuation",
|
||
"shell_feature_id": shell_id,
|
||
"outer_owner_feature_id": shell_id,
|
||
"prism_owner_feature_id": producer_id,
|
||
"removed_cap_role": shell_selectors[0]["output_role"],
|
||
"source_entities": source_entities,
|
||
},
|
||
)
|
||
intent.pop("source_entity", None)
|
||
intent["source_entities"] = source_entities
|
||
intent["consumer_contract"] = "direct_prism_shell_offset_edge_vertex"
|
||
return {
|
||
"kind": "edge",
|
||
"owner_feature_id": producer_id,
|
||
"source": "runtime_snapshot",
|
||
"confidence": 1.0,
|
||
"selector_intent": intent,
|
||
}
|
||
|
||
|
||
def _direct_blend_edge_selector(
|
||
value: Any,
|
||
*,
|
||
owner: 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,
|
||
) -> dict[str, Any] | None:
|
||
"""Lower one exact direct-prism dress-up ``BLEND_EDGE`` source tuple.
|
||
|
||
The FeatureScript query names both the original edge and the original face
|
||
that the first dress-up blends into. Retain those source semantics rather
|
||
than collapsing the result to a nearby patch boundary edge. This accepts
|
||
only one source edge, one complete direct-prism CAP_FACE or same-anchor
|
||
SWEPT_FACE, and one immediate native fillet/chamfer producer; split,
|
||
merge/COPY and mixed query sets stay deferred for a later component
|
||
contract.
|
||
"""
|
||
try:
|
||
_call, outer_owner, topology, kind, definition = _direct_make_query(value)
|
||
except ValueError:
|
||
return None
|
||
if topology != "BLEND_EDGE" or kind not in {"edge", "entitytype.edge"} or outer_owner != owner:
|
||
return None
|
||
blended_from = definition.get("blendedFrom")
|
||
blended_into = definition.get("blendedInto")
|
||
if not isinstance(blended_from, list) or len(blended_from) != 2 or not isinstance(blended_into, list) or len(blended_into) != 1:
|
||
return None
|
||
edge_value, source_face_value = blended_from
|
||
target_face_value = blended_into[0]
|
||
try:
|
||
_edge_call, edge_owner, edge_topology, edge_kind, _edge_definition = _direct_make_query(edge_value)
|
||
_face_call, face_owner, face_topology, face_kind, _face_definition = _direct_make_query(source_face_value)
|
||
_target_call, target_owner, target_topology, target_kind, _target_definition = _direct_make_query(target_face_value)
|
||
except ValueError:
|
||
return None
|
||
face_family = face_topology if face_topology == target_topology else None
|
||
if (
|
||
edge_topology != "CAP_EDGE" or edge_kind not in {"edge", "entitytype.edge"}
|
||
or face_family not in {"CAP_FACE", "SWEPT_FACE"}
|
||
or face_kind not in {"face", "entitytype.face"}
|
||
or target_kind not in {"face", "entitytype.face"}
|
||
or len({edge_owner, face_owner, target_owner}) != 1
|
||
):
|
||
return None
|
||
source_feature_id = f"f_{edge_owner}"
|
||
dressup_feature_id = f"f_{owner}"
|
||
dressup = feature_by_id.get(dressup_feature_id) or {}
|
||
if (
|
||
previous[-1:] != [dressup_feature_id]
|
||
or dressup.get("atomic_id") not in {"fillet", "chamfer"}
|
||
or source_feature_id not in previous
|
||
):
|
||
return None
|
||
edge_selector = _direct_prism_swept_selector(
|
||
edge_value,
|
||
owner=edge_owner,
|
||
selector_kind="edge",
|
||
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,
|
||
allow_continuation=True,
|
||
)
|
||
if face_family == "CAP_FACE":
|
||
source_face_selector = _cap_face_output_role_selector(
|
||
source_face_value, feature_by_id, sketches_by_id,
|
||
)
|
||
target_face_selector = _cap_face_output_role_selector(
|
||
target_face_value, feature_by_id, sketches_by_id,
|
||
)
|
||
else:
|
||
source_face_selector = _direct_prism_swept_selector(
|
||
source_face_value,
|
||
owner=face_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,
|
||
allow_continuation=True,
|
||
)
|
||
target_face_selector = _direct_prism_swept_selector(
|
||
target_face_value,
|
||
owner=target_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,
|
||
allow_continuation=True,
|
||
)
|
||
if edge_selector is None or source_face_selector is None or target_face_selector is None:
|
||
return None
|
||
edge_intent = edge_selector.get("selector_intent") or {}
|
||
source_face_intent = source_face_selector.get("selector_intent") or {}
|
||
target_face_intent = target_face_selector.get("selector_intent") or {}
|
||
if (
|
||
edge_intent.get("query_family") != "CAP_EDGE"
|
||
or source_face_intent.get("query_family") != face_family
|
||
or target_face_intent.get("query_family") != face_family
|
||
or source_face_selector.get("owner_feature_id") != source_feature_id
|
||
or target_face_selector.get("owner_feature_id") != source_feature_id
|
||
):
|
||
return None
|
||
if face_family == "CAP_FACE" and source_face_selector.get("output_role") != target_face_selector.get("output_role"):
|
||
return None
|
||
source_profile = sketches_by_id.get(str((feature_by_id.get(source_feature_id) or {}).get("sketch_id") or "")) or {}
|
||
source_profile_ids = _direct_profile_source_entity_ids(source_profile)
|
||
source_sketch_id = (feature_frames.get(edge_owner) or {}).get("profile_source")
|
||
if face_family == "CAP_FACE":
|
||
source_refs = _source_refs(source_face_value)
|
||
target_refs = _source_refs(target_face_value)
|
||
required_refs = {(source_sketch_id, entity_id) for entity_id in source_profile_ids}
|
||
if (
|
||
not isinstance(source_sketch_id, str)
|
||
or not source_profile_ids
|
||
or set(source_refs) != required_refs
|
||
or set(target_refs) != required_refs
|
||
or len(source_refs) != len(source_profile_ids)
|
||
or len(target_refs) != len(source_profile_ids)
|
||
):
|
||
return None
|
||
edge_source = edge_intent.get("source_entity")
|
||
edge_role = edge_intent.get("lineage_role")
|
||
face_source_entity = source_face_intent.get("source_entity")
|
||
target_face_entity = target_face_intent.get("source_entity")
|
||
face_role = source_face_selector.get("output_role")
|
||
if not isinstance(edge_source, dict) or edge_role not in {"extrude.start", "extrude.end"}:
|
||
return None
|
||
if face_family == "CAP_FACE":
|
||
if face_role not in {"extrude.start", "extrude.end"}:
|
||
return None
|
||
elif not isinstance(face_source_entity, dict) or face_source_entity != target_face_entity:
|
||
return None
|
||
intent = _selector_intent(
|
||
value,
|
||
query_family="BLEND_EDGE",
|
||
kind="edge",
|
||
evidence="kernel_history",
|
||
allowed=("boundary",),
|
||
)
|
||
intent["blend_sources"] = {
|
||
"edge": {
|
||
"query_family": "CAP_EDGE",
|
||
"owner_feature_id": source_feature_id,
|
||
"source_entity": dict(edge_source),
|
||
"lineage_role": edge_role,
|
||
},
|
||
"face": (
|
||
{
|
||
"query_family": "CAP_FACE",
|
||
"owner_feature_id": source_feature_id,
|
||
"output_role": face_role,
|
||
}
|
||
if face_family == "CAP_FACE" else {
|
||
"query_family": "SWEPT_FACE",
|
||
"owner_feature_id": source_feature_id,
|
||
"source_entity": dict(face_source_entity),
|
||
}
|
||
),
|
||
}
|
||
return {
|
||
"kind": "edge",
|
||
"owner_feature_id": dressup_feature_id,
|
||
"source": "runtime_snapshot",
|
||
"confidence": 1.0,
|
||
"selector_intent": intent,
|
||
}
|
||
|
||
|
||
def _direct_prism_blend_face_attachment(
|
||
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:
|
||
"""Attach one immediate direct-prism ``BLEND_FACE`` sketch at runtime.
|
||
|
||
``BLEND_FACE`` is the patch generated by a dress-up input edge, rather
|
||
than an arbitrary face adjacent to that edge. Accept it only when the
|
||
preceding native dress-up already selected one role-qualified direct-prism
|
||
cap edge. The runtime then requires that exact ``Generated(edge ->
|
||
patch_face)`` relation in the active snapshot before it materializes the
|
||
sketch frame.
|
||
"""
|
||
if featurescript_version != "1511":
|
||
return None
|
||
try:
|
||
_call, dressup_owner, topology, kind, definition = _direct_make_query(value)
|
||
except ValueError:
|
||
return None
|
||
refs = _source_refs(definition.get("disambiguationData"))
|
||
dressup_id = f"f_{dressup_owner}"
|
||
dressup = feature_by_id.get(dressup_id) or {}
|
||
if (
|
||
topology != "BLEND_FACE"
|
||
or kind not in {"face", "entitytype.face"}
|
||
or previous[-1:] != [dressup_id]
|
||
or dressup.get("atomic_id") not in {"fillet", "chamfer"}
|
||
or len(dressup.get("depends_on") or ()) != 1
|
||
or len(refs) != 1
|
||
):
|
||
return None
|
||
source_sketch_id, source_token = refs[0]
|
||
resolved_source = _source_ref_entity(source_sketch_id, source_token, entity_by_sketch)
|
||
if resolved_source is None or resolved_source[0] != source_token:
|
||
return None
|
||
source_entity_id, source_entity = resolved_source
|
||
producer_id = (dressup.get("depends_on") or [None])[0]
|
||
producer = feature_by_id.get(str(producer_id)) or {}
|
||
params = producer.get("params") or {}
|
||
profile = sketches_by_id.get(str(producer.get("sketch_id") or ""))
|
||
source_sketch = sketch_by_source.get(source_sketch_id)
|
||
if (
|
||
not isinstance(producer_id, str)
|
||
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
|
||
or profile is None
|
||
or source_sketch is None
|
||
or profile.get("source_sketch_id") != source_sketch_id
|
||
or not _profile_matches_direct_source(profile, source_sketch)
|
||
or source_entity.get("construction")
|
||
or source_entity_id not in _direct_profile_source_entity_ids(profile)
|
||
):
|
||
return None
|
||
|
||
def leaves(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
output: list[dict[str, Any]] = []
|
||
for selector in items:
|
||
nested = selector.get("query_operands")
|
||
if isinstance(nested, list):
|
||
output.extend(leaves([item for item in nested if isinstance(item, dict)]))
|
||
else:
|
||
output.append(selector)
|
||
return output
|
||
|
||
matches = []
|
||
for selector in leaves([item for item in dressup.get("selectors") or () if isinstance(item, dict)]):
|
||
intent = selector.get("selector_intent") or {}
|
||
if (
|
||
selector.get("kind") == "edge"
|
||
and selector.get("owner_feature_id") == producer_id
|
||
and selector.get("source") == "runtime_snapshot"
|
||
and intent.get("query_family") == "CAP_EDGE"
|
||
and intent.get("source_entity") == {
|
||
"sketch_id": source_sketch_id,
|
||
"entity_id": source_entity_id,
|
||
}
|
||
and intent.get("lineage_role") in {"extrude.start", "extrude.end"}
|
||
):
|
||
matches.append(selector)
|
||
if len(matches) != 1:
|
||
return None
|
||
cap_intent = matches[0]["selector_intent"]
|
||
intent = _selector_intent(
|
||
value,
|
||
query_family="BLEND_FACE",
|
||
kind="face",
|
||
evidence="kernel_history",
|
||
allowed=("boundary",),
|
||
)
|
||
intent["blend_face_source"] = {
|
||
"query_family": "CAP_EDGE",
|
||
"owner_feature_id": producer_id,
|
||
"source_entity": dict(cap_intent["source_entity"]),
|
||
"lineage_role": cap_intent["lineage_role"],
|
||
}
|
||
return {
|
||
"kind": "face",
|
||
"owner_feature_id": dressup_id,
|
||
"source": "runtime_snapshot",
|
||
"confidence": 1.0,
|
||
"selector_intent": intent,
|
||
}
|
||
|
||
|
||
def _direct_full_revolve_swept_edge_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,
|
||
) -> dict[str, Any] | None:
|
||
"""Lower one full-revolve source vertex to exact ``MakeRevol`` history.
|
||
|
||
This is intentionally separate from the direct-prism contract. A full
|
||
independent solid revolve has one usable native witness only:
|
||
``BRepPrimAPI_MakeRevol.Generated(source_vertex)``. The profile vertex is
|
||
identified by the complete incident set in the FeatureScript OSD query;
|
||
circle centre/radius remains diagnostic geometry and never binds the edge.
|
||
"""
|
||
query = parse_query(value)
|
||
if query.topology_type != "SWEPT_EDGE" or selector_kind != "edge":
|
||
return None
|
||
# The full-revolve contract is separately registered for each source
|
||
# tuple. Do not infer other revisions from a merely similar makeQuery AST.
|
||
if featurescript_version not in {"1511", "2491"}:
|
||
return None
|
||
producer_id = f"f_{owner}"
|
||
producer = feature_by_id.get(producer_id) 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
|
||
if (
|
||
producer.get("atomic_id") != "revolve_add"
|
||
or (producer.get("params") or {}).get("result_mode") != "new_body"
|
||
or not frame.get("revolve_full")
|
||
or not isinstance(frame.get("revolve_axis"), dict)
|
||
or producer_id not in previous
|
||
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)
|
||
or frame.get("revolve_profile_contract") not in {
|
||
"original_source",
|
||
"verified_complete_materialization",
|
||
}
|
||
# The 1511 contract does not let an IMPRINT profile borrow the source
|
||
# sketch's vertex identities merely because lowering reuses equal
|
||
# profile data. 2491 separately admits its verified complete form.
|
||
or (
|
||
featurescript_version == "1511"
|
||
and frame.get("revolve_profile_contract") != "original_source"
|
||
)
|
||
):
|
||
return None
|
||
refs = _source_refs(value)
|
||
if len(refs) < 2 or {source for source, _token in refs} != {profile_source}:
|
||
return None
|
||
source_ids = _direct_profile_source_entity_ids(profile_sketch)
|
||
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",
|
||
# Earlier dress-ups may preserve this edge through exact one-to-one
|
||
# OCC history. The resolver rejects any split, merge or missing branch.
|
||
allowed=("boundary", "continuation"),
|
||
)
|
||
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 _direct_primary_cut_copy_cap_edge_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 primary-cut ``COPY(CAP_EDGE)`` to its proven input lineage.
|
||
|
||
FeatureScript's boolean COPY query is not permission to select a similar
|
||
final edge. This narrow form keeps its outer COPY AST and delegates only
|
||
its explicit CAP_EDGE source to the transient-prism and cut-builder chain
|
||
already registered in the same replay. The tool is never active topology.
|
||
"""
|
||
if featurescript_version != "1511":
|
||
return None
|
||
try:
|
||
_outer_call, owner, topology, kind, definition = _direct_make_query(value)
|
||
except ValueError:
|
||
return None
|
||
derived = definition.get("derivedFrom") if isinstance(definition, dict) else None
|
||
if topology != "COPY" or kind not in {"edge", "entitytype.edge"} or derived is None:
|
||
return None
|
||
try:
|
||
_inner_call, inner_owner, inner_topology, inner_kind, _inner_definition = _direct_make_query(derived)
|
||
except ValueError:
|
||
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
|
||
inner_query = parse_query(derived)
|
||
refs = _source_refs(derived)
|
||
if (
|
||
inner_owner != owner
|
||
or inner_topology != "CAP_EDGE"
|
||
or inner_kind not in {"edge", "entitytype.edge"}
|
||
or inner_query.is_start is None
|
||
or previous[-1:] != [producer_id]
|
||
or producer.get("atomic_id") != "extrude_cut_blind"
|
||
or params.get("result_mode") is not None
|
||
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)
|
||
or len(refs) != 1
|
||
or refs[0][0] != profile_source
|
||
or inner_query.source_sketch != profile_source
|
||
or inner_query.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
|
||
input_intent = _selector_intent(
|
||
derived,
|
||
query_family="CAP_EDGE",
|
||
kind="edge",
|
||
evidence="kernel_history",
|
||
allowed=("boundary", "continuation"),
|
||
)
|
||
input_intent["lineage_role"] = f"extrude.{'start' if inner_query.is_start else 'end'}"
|
||
intent = _selector_intent(
|
||
value,
|
||
query_family="COPY",
|
||
kind="edge",
|
||
evidence="kernel_history",
|
||
allowed=("boundary", "continuation"),
|
||
)
|
||
# The anchor belongs to the nested CAP_EDGE, not the outer boolean COPY.
|
||
intent.pop("source_entity", None)
|
||
intent["copy_contract"] = "primary_cut_cap_edge"
|
||
return {
|
||
"kind": "edge",
|
||
"owner_feature_id": producer_id,
|
||
"source": "runtime_snapshot",
|
||
"confidence": 1.0,
|
||
"query_input": {
|
||
"kind": "edge",
|
||
"owner_feature_id": producer_id,
|
||
"source": "runtime_snapshot",
|
||
"confidence": 1.0,
|
||
"selector_intent": input_intent,
|
||
},
|
||
"selector_intent": intent,
|
||
}
|
||
|
||
|
||
def _direct_primary_cut_copy_cap_face_attachment(
|
||
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 an immediate primary-cut ``COPY(CAP_FACE)`` sketch attachment.
|
||
|
||
The emitted selector names no face by geometry or static cap frame. It
|
||
instead requires the runtime to prove the direct tool cap and its exact
|
||
same-owner subtract successor before materializing the sketch workplane.
|
||
"""
|
||
if featurescript_version != "1511":
|
||
return None
|
||
try:
|
||
_outer, owner, topology, kind, definition = _direct_make_query(value)
|
||
except ValueError:
|
||
return None
|
||
derived = definition.get("derivedFrom") if isinstance(definition, dict) else None
|
||
if topology != "COPY" or kind not in {"face", "entitytype.face"} or derived is None:
|
||
return None
|
||
try:
|
||
_inner, inner_owner, inner_topology, inner_kind, _inner_definition = _direct_make_query(derived)
|
||
except ValueError:
|
||
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
|
||
inner_query = parse_query(derived)
|
||
refs = _source_refs(derived)
|
||
source_ids = _direct_profile_source_entity_ids(profile_sketch or {})
|
||
if (
|
||
inner_owner != owner
|
||
or inner_topology != "CAP_FACE"
|
||
or inner_kind not in {"face", "entitytype.face"}
|
||
or inner_query.is_start is None
|
||
or previous[-1:] != [producer_id]
|
||
or producer.get("atomic_id") != "extrude_cut_blind"
|
||
or params.get("result_mode") is not None
|
||
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)
|
||
or not source_ids
|
||
or len(refs) != len(source_ids)
|
||
or {source for source, _token in refs} != {profile_source}
|
||
or {token for _source, token in refs} != source_ids
|
||
):
|
||
return None
|
||
for entity_id in source_ids:
|
||
entity = (entity_by_sketch.get(profile_source) or {}).get(entity_id)
|
||
if not entity or entity.get("construction"):
|
||
return None
|
||
role = f"extrude.{'start' if inner_query.is_start else 'end'}"
|
||
input_intent = _selector_intent(
|
||
derived, query_family="CAP_FACE", kind="face", evidence="kernel_history",
|
||
allowed=("boundary", "continuation"),
|
||
)
|
||
input_intent.pop("source_entity", None)
|
||
input_intent["source_entities"] = [
|
||
{"sketch_id": profile_source, "entity_id": entity_id} for entity_id in sorted(source_ids)
|
||
]
|
||
input_intent["lineage_role"] = role
|
||
intent = _selector_intent(
|
||
value, query_family="COPY", kind="face", evidence="kernel_history",
|
||
allowed=("boundary", "continuation"),
|
||
)
|
||
intent.pop("source_entity", None)
|
||
intent["copy_contract"] = "primary_cut_cap_face_workplane"
|
||
return {
|
||
"kind": "face", "owner_feature_id": producer_id,
|
||
"source": "runtime_snapshot", "confidence": 1.0,
|
||
"query_input": {
|
||
"kind": "face", "owner_feature_id": producer_id,
|
||
"source": "runtime_snapshot", "confidence": 1.0,
|
||
"selector_intent": input_intent,
|
||
},
|
||
"selector_intent": intent,
|
||
}
|
||
|
||
|
||
def _direct_prism_cap_face_attachment(
|
||
value: Any,
|
||
*,
|
||
feature_by_id: dict[str, dict[str, Any]],
|
||
sketches_by_id: dict[str, dict[str, Any]],
|
||
previous: list[str],
|
||
featurescript_version: str | None,
|
||
) -> dict[str, Any] | None:
|
||
"""Attach a following sketch to one exact direct-prism cap at runtime.
|
||
|
||
The direct CAP role is a builder result, not a copied static workplane.
|
||
This bridge intentionally applies only while the independent blind prism
|
||
remains the immediately preceding active body. It does not interpret
|
||
CAP_EDGE, boolean successors, or an attached profile's future splitter
|
||
boundary; those need their own source-qualified contracts.
|
||
"""
|
||
if featurescript_version != "1511":
|
||
return None
|
||
try:
|
||
_call, _owner, topology, kind, _definition = _direct_make_query(value)
|
||
except ValueError:
|
||
return None
|
||
if topology != "CAP_FACE" or kind not in {"face", "entitytype.face"}:
|
||
return None
|
||
selector = _cap_face_output_role_selector(value, feature_by_id, sketches_by_id)
|
||
if selector is None or previous[-1:] != [selector.get("owner_feature_id")]:
|
||
return None
|
||
intent = selector.get("selector_intent") or {}
|
||
if (
|
||
selector.get("kind") != "face"
|
||
or selector.get("output_role") not in {"extrude.start", "extrude.end"}
|
||
or intent.get("query_family") != "CAP_FACE"
|
||
or intent.get("evidence") != "operation_role"
|
||
or intent.get("consumer_contract") is not None
|
||
):
|
||
return None
|
||
intent["consumer_contract"] = "direct_prism_cap_face_workplane"
|
||
return selector
|
||
|
||
|
||
def _direct_primary_cut_copy_swept_face_attachment(
|
||
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 direct primary-cut ``COPY(SWEPT_FACE)`` sketch attachment.
|
||
|
||
The source edge is only an anchor for the cut tool's native prism history.
|
||
The eventual workplane is materialized exclusively from the exact active
|
||
face reached through that tool face's same-owner subtract continuation.
|
||
"""
|
||
if featurescript_version != "1511":
|
||
return None
|
||
try:
|
||
_outer, owner, topology, kind, definition = _direct_make_query(value)
|
||
except ValueError:
|
||
return None
|
||
derived = definition.get("derivedFrom") if isinstance(definition, dict) else None
|
||
if topology != "COPY" or kind not in {"face", "entitytype.face"} or derived is None:
|
||
return None
|
||
try:
|
||
_inner, inner_owner, inner_topology, inner_kind, _inner_definition = _direct_make_query(derived)
|
||
except ValueError:
|
||
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
|
||
refs = _source_refs(derived)
|
||
if (
|
||
inner_owner != owner
|
||
or inner_topology != "SWEPT_FACE"
|
||
or inner_kind not in {"face", "entitytype.face"}
|
||
or previous[-1:] != [producer_id]
|
||
or producer.get("atomic_id") != "extrude_cut_blind"
|
||
or params.get("result_mode") is not None
|
||
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)
|
||
or len(refs) != 1
|
||
or refs[0][0] != profile_source
|
||
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
|
||
input_intent = _selector_intent(
|
||
derived, query_family="SWEPT_FACE", kind="face", evidence="kernel_history",
|
||
allowed=("boundary", "continuation"),
|
||
)
|
||
input_intent["source_entity"] = {"sketch_id": refs[0][0], "entity_id": refs[0][1]}
|
||
intent = _selector_intent(
|
||
value, query_family="COPY", kind="face", evidence="kernel_history",
|
||
allowed=("boundary", "continuation"),
|
||
)
|
||
intent.pop("source_entity", None)
|
||
intent["copy_contract"] = "primary_cut_swept_face_workplane"
|
||
return {
|
||
"kind": "face", "owner_feature_id": producer_id,
|
||
"source": "runtime_snapshot", "confidence": 1.0,
|
||
"query_input": {
|
||
"kind": "face", "owner_feature_id": producer_id,
|
||
"source": "runtime_snapshot", "confidence": 1.0,
|
||
"selector_intent": input_intent,
|
||
},
|
||
"selector_intent": intent,
|
||
}
|
||
|
||
|
||
def _planar_imprint_prism_selector(
|
||
value: Any,
|
||
*,
|
||
owner: str,
|
||
selector_kind: str,
|
||
feature_by_id: dict[str, dict[str, Any]],
|
||
feature_frames: dict[str, dict[str, Any]],
|
||
sketches_by_id: dict[str, dict[str, Any]],
|
||
previous: list[str],
|
||
featurescript_version: str | None,
|
||
) -> dict[str, Any] | None:
|
||
"""Lower a bounded IMPRINT prism query only when its source set is exact.
|
||
|
||
An IMPRINT profile may split one FeatureScript curve into several selected
|
||
B-rep fragments. The adapter records each exact fragment and the resolver
|
||
must return the complete set. This helper therefore does not reuse the
|
||
direct-profile one-to-one contract or turn a set-valued query into an
|
||
arbitrary geometric candidate.
|
||
"""
|
||
query = parse_query(value)
|
||
topology = query.topology_type
|
||
if topology not in {"CAP_FACE", "SWEPT_FACE", "SWEPT_EDGE"} or 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 ""))
|
||
profile = (profile_sketch or {}).get("profile") or {}
|
||
if (
|
||
producer.get("atomic_id") not in {"extrude_add_blind", "extrude_cut_blind"}
|
||
or previous[-1:] != [producer_id]
|
||
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 profile_sketch is None
|
||
or profile_sketch.get("source_sketch_id") != profile_source
|
||
or profile.get("type") != "planar_imprint"
|
||
):
|
||
return None
|
||
source_ids = {
|
||
str(entry.get("id"))
|
||
for entry in profile.get("source_entities") or ()
|
||
if isinstance(entry, dict) and isinstance(entry.get("id"), str) and entry.get("id")
|
||
}
|
||
refs = list(dict.fromkeys(_source_refs(value)))
|
||
if not source_ids or not refs or {sketch_id for sketch_id, _entity_id in refs} != {profile_source}:
|
||
return None
|
||
ref_ids = {entity_id for _sketch_id, entity_id in refs}
|
||
if not ref_ids.issubset(source_ids):
|
||
return None
|
||
|
||
if topology == "CAP_FACE":
|
||
if selector_kind != "face" or query.is_start is None or ref_ids != source_ids:
|
||
return None
|
||
intent = _selector_intent(
|
||
value,
|
||
query_family="CAP_FACE",
|
||
kind="face",
|
||
evidence="kernel_history",
|
||
allowed=("boundary", "fragment"),
|
||
multiplicity="all_fragments",
|
||
)
|
||
# The complete OSD source set proves the output role. Its last parsed
|
||
# source token is diagnostic context, not a single-edge CAP anchor.
|
||
intent.pop("source_entity", None)
|
||
output_role = f"extrude.{'start' if query.is_start else 'end'}"
|
||
intent["output_role"] = output_role
|
||
intent["disambiguation"] = {
|
||
"type": "complete_imprint_profile_source_set",
|
||
"source_entity_ids": sorted(source_ids),
|
||
}
|
||
return {
|
||
"kind": "face",
|
||
"owner_feature_id": producer_id,
|
||
"output_role": output_role,
|
||
"source": "runtime_snapshot",
|
||
"confidence": 1.0,
|
||
"selector_intent": intent,
|
||
}
|
||
|
||
if topology == "SWEPT_FACE":
|
||
if selector_kind != "face" or len(refs) != 1:
|
||
return None
|
||
_source_sketch, source_entity = refs[0]
|
||
intent = _selector_intent(
|
||
value,
|
||
query_family="SWEPT_FACE",
|
||
kind="face",
|
||
evidence="kernel_history",
|
||
allowed=("boundary", "fragment"),
|
||
multiplicity="all_fragments",
|
||
)
|
||
intent["source_entity"] = {"sketch_id": profile_source, "entity_id": source_entity}
|
||
return {
|
||
"kind": "face",
|
||
"owner_feature_id": producer_id,
|
||
"source": "runtime_snapshot",
|
||
"confidence": 1.0,
|
||
"selector_intent": intent,
|
||
}
|
||
|
||
if selector_kind != "edge" or len(refs) != 2 or len(ref_ids) != 2:
|
||
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(ref_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 _same_profile_frame(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
||
"""Require identical source frames, without inventing a coordinate map."""
|
||
for key in ("origin_mm", "x_dir", "normal"):
|
||
first, second = left.get(key), right.get(key)
|
||
if (
|
||
not isinstance(first, list)
|
||
or not isinstance(second, list)
|
||
or len(first) != 3
|
||
or len(second) != 3
|
||
or any(not isinstance(value, (int, float)) or not math.isfinite(float(value)) for value in [*first, *second])
|
||
or any(float(a) != float(b) for a, b in zip(first, second))
|
||
):
|
||
return False
|
||
return True
|
||
|
||
|
||
def _direct_sketch_region_union_leaves(value: Any) -> list[Call] | None:
|
||
"""Flatten only associative qUnion wrappers around direct region leaves."""
|
||
if not isinstance(value, Call) or value.name != "qUnion" or len(value.args) != 1 or not isinstance(value.args[0], list):
|
||
return None
|
||
leaves: list[Call] = []
|
||
for operand in value.args[0]:
|
||
if isinstance(operand, Call) and operand.name == "qUnion":
|
||
nested = _direct_sketch_region_union_leaves(operand)
|
||
if nested is None:
|
||
return None
|
||
leaves.extend(nested)
|
||
elif isinstance(operand, Call) and operand.name == "qSketchRegion" and len(operand.args) == 2:
|
||
leaves.append(operand)
|
||
else:
|
||
return None
|
||
return leaves
|
||
|
||
|
||
def _multi_source_sketch_region_profile(
|
||
value: Any,
|
||
sketch_by_source: dict[str, dict[str, Any]],
|
||
feature_id: str,
|
||
) -> dict[str, Any] | None:
|
||
"""Materialize a direct same-frame ``qUnion(qSketchRegion(...))`` profile.
|
||
|
||
This is profile geometry, not a topology selector: each source region is
|
||
resolved independently by the runtime so a contour nested in a different
|
||
sketch cannot accidentally become a hole in another sketch's region.
|
||
"""
|
||
leaves = _direct_sketch_region_union_leaves(value)
|
||
if leaves is None or len(leaves) < 2:
|
||
return None
|
||
sources: list[str] = []
|
||
for leaf in leaves:
|
||
if str(leaf.args[1]).lower() != "true":
|
||
return None
|
||
source = symbolic_string(leaf.args[0]).split(".", 1)[0]
|
||
if not source:
|
||
return None
|
||
sources.append(source)
|
||
# qUnion is a set union. Repeating the whole source region cannot create
|
||
# another profile region, so preserve first-occurrence source ordering.
|
||
sources = list(dict.fromkeys(sources))
|
||
if len(sources) < 2 or any(source not in sketch_by_source for source in sources):
|
||
return None
|
||
sketches = [sketch_by_source[source] for source in sources]
|
||
if any(sketch.get("attachment") is not None or not _profile_executable(sketch) for sketch in sketches):
|
||
return None
|
||
frame = sketches[0].get("workplane")
|
||
if not isinstance(frame, dict) or any(not _same_profile_frame(frame, sketch.get("workplane") or {}) for sketch in sketches[1:]):
|
||
return None
|
||
profiles = [deepcopy(sketch["profile"]) for sketch in sketches]
|
||
if any(profile.get("type") not in {"circle", "polygon", "analytic_contours"} for profile in profiles):
|
||
return None
|
||
return {
|
||
"id": f"sketch_regions_{feature_id}",
|
||
"name": f"regions_{feature_id}",
|
||
"workplane": deepcopy(frame),
|
||
"profile": {
|
||
"type": "multi_source_regions",
|
||
"source_sketch_ids": sources,
|
||
"profiles": profiles,
|
||
},
|
||
}
|
||
|
||
|
||
def _has_composed_sketch_region_union(value: Any) -> bool:
|
||
"""Identify composed region input that must not fall back to one source."""
|
||
leaves = _direct_sketch_region_union_leaves(value)
|
||
if leaves is None:
|
||
return False
|
||
sources = {
|
||
symbolic_string(leaf.args[0]).split(".", 1)[0]
|
||
for leaf in leaves
|
||
}
|
||
return len(leaves) >= 2 and len(sources - {""}) >= 2
|
||
|
||
|
||
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_derived = intersections[0].get("derivedFrom")
|
||
intersection_sources = list(dict.fromkeys(_source_refs(intersection_derived)))
|
||
if fragment_side is None or len(intersection_sources) != 2:
|
||
return None
|
||
same_sketch_anchor = [
|
||
entity for sketch, entity in intersection_sources
|
||
if sketch == owner and entity != source_entity
|
||
]
|
||
fragment: dict[str, Any]
|
||
if len(same_sketch_anchor) == 1 and all(sketch == owner for sketch, _entity in intersection_sources):
|
||
fragment = {"anchor_entity_id": same_sketch_anchor[0], "side": fragment_side}
|
||
else:
|
||
external_queries: list[Call] = []
|
||
for call in walk_calls(intersection_derived):
|
||
if call.name != "makeQuery":
|
||
continue
|
||
try:
|
||
_edge, _edge_owner, edge_topology, edge_kind, _edge_definition = _direct_make_query(call)
|
||
except ValueError:
|
||
continue
|
||
if edge_topology == "CAP_EDGE" and edge_kind in {"edge", "entitytype.edge"}:
|
||
external_queries.append(call)
|
||
local_sources = [(sketch, entity) for sketch, entity in intersection_sources if sketch == owner]
|
||
if len(local_sources) != 1 or local_sources[0][1] != source_entity or len(external_queries) != 1:
|
||
return None
|
||
# Kept only while lowering this profile. The CDSL profile receives a
|
||
# typed selector through the external-anchor contract below.
|
||
fragment = {"_external_anchor_query": external_queries[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,
|
||
*,
|
||
feature_by_id: dict[str, dict[str, Any]],
|
||
feature_frames: dict[str, dict[str, Any]],
|
||
sketches_by_id: dict[str, dict[str, Any]],
|
||
previous: list[str],
|
||
featurescript_version: str | None,
|
||
) -> 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]] = []
|
||
external_anchors: 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
|
||
fragment = selection.get("fragment") or {}
|
||
external_query = fragment.pop("_external_anchor_query", None)
|
||
if external_query is not None:
|
||
try:
|
||
_call, external_owner, external_topology, external_kind, _definition = _direct_make_query(external_query)
|
||
except ValueError:
|
||
return None
|
||
if external_topology != "CAP_EDGE" or external_kind not in {"edge", "entitytype.edge"}:
|
||
return None
|
||
external_selector = _direct_prism_swept_selector(
|
||
external_query,
|
||
owner=external_owner,
|
||
selector_kind="edge",
|
||
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,
|
||
allow_continuation=False,
|
||
)
|
||
attachment = (sketch_by_source.get(owner) or {}).get("attachment")
|
||
attachment_intent = attachment.get("selector_intent") if isinstance(attachment, dict) else None
|
||
external_intent = external_selector.get("selector_intent") if isinstance(external_selector, dict) else None
|
||
if (
|
||
external_selector is None
|
||
or not isinstance(attachment, dict)
|
||
or attachment.get("owner_feature_id") != external_selector.get("owner_feature_id")
|
||
or attachment.get("output_role") != (external_intent or {}).get("lineage_role")
|
||
or not isinstance(attachment_intent, dict)
|
||
or attachment_intent.get("consumer_contract") != "direct_prism_cap_face_workplane"
|
||
):
|
||
return None
|
||
anchor_id = f"cap_boundary_{len(external_anchors)}"
|
||
fragment["external_anchor_id"] = anchor_id
|
||
external_anchors.append({"id": anchor_id, "selector": external_selector})
|
||
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.get("anchor_entity_id") and fragment.get("anchor_entity_id") not in known):
|
||
return None
|
||
# A same-sketch fragment needs a second source curve as its splitter
|
||
# anchor. The current source admission for an attached boundary is only
|
||
# the one-circle / one-edge form: a periodic B-spline has seam ambiguity,
|
||
# and multi-curve or multi-anchor arrangements need a set-cardinality
|
||
# contract rather than reusing this singleton policy.
|
||
single_external_circle_fragment = (
|
||
len(source_entities) == 1
|
||
and source_entities[0].get("curve", {}).get("type") == "circle"
|
||
and bool(external_anchors)
|
||
and len(external_anchors) == 1
|
||
and len(external_anchors) == len(selections)
|
||
and all(
|
||
isinstance(selection.get("fragment"), dict)
|
||
and isinstance(selection["fragment"].get("external_anchor_id"), str)
|
||
and selection["fragment"]["external_anchor_id"]
|
||
for selection in selections
|
||
)
|
||
)
|
||
if external_anchors and not single_external_circle_fragment:
|
||
return None
|
||
if len(source_entities) < 2 and not single_external_circle_fragment:
|
||
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,
|
||
}
|
||
if external_anchors:
|
||
output["profile"]["external_anchors"] = external_anchors
|
||
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]
|
||
outer_sources = {
|
||
segment.get("source_entity_id")
|
||
for contour in contours
|
||
for segment in contour.get("segments") or []
|
||
if (
|
||
segment.get("type") == "circle"
|
||
and _same_point(segment.get("center") or [], center)
|
||
and abs(float(segment.get("radius_mm") or 0.0) - outer) <= 1e-5
|
||
and isinstance(segment.get("source_entity_id"), str)
|
||
and segment["source_entity_id"]
|
||
)
|
||
}
|
||
# The union's outer boundary is still one original source circle when the
|
||
# source sketch names it uniquely. Preserve that exact source identity so
|
||
# a later direct-prism builder can expose its native swept-face history.
|
||
# An annulus or a duplicate boundary intentionally carries no inferred
|
||
# label: a source query then remains unresolved rather than borrowing a
|
||
# geometrically equal circle.
|
||
outer_source = next(iter(outer_sources)) if len(outer_sources) == 1 else None
|
||
if inner <= 1e-5:
|
||
return {
|
||
"type": "circle",
|
||
"center": list(center),
|
||
"radius_mm": outer,
|
||
**({"source_entity_id": outer_source} if outer_source is not None else {}),
|
||
}
|
||
return {
|
||
"type": "analytic_contours",
|
||
"contours": [
|
||
{
|
||
"role": "outer",
|
||
"closed": True,
|
||
"segments": [{
|
||
"type": "circle", "center": list(center), "radius_mm": outer,
|
||
**({"source_entity_id": outer_source} if outer_source is not None else {}),
|
||
}],
|
||
},
|
||
{"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]],
|
||
*,
|
||
allow_initial_loft: bool = False,
|
||
allow_two_sided_circle_shell: bool = False,
|
||
allow_primary_add_shell: bool = False,
|
||
allow_primary_add_up_to_surface: bool = False,
|
||
allow_primary_add_dressup: bool = False,
|
||
allow_two_sided_up_to_surface_pair: bool = False,
|
||
source_sketches: dict[str, dict[str, Any]] | None = None,
|
||
) -> 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 ""))
|
||
|
||
# Both far caps of a symmetric direct prism are exact builder outputs, but
|
||
# neither is independently valid as a one-sided extent target. The caller
|
||
# admits them only as the complete opposite-role pair below.
|
||
if allow_two_sided_up_to_surface_pair:
|
||
profile_source = (producer_sketch or {}).get("source_sketch_id")
|
||
source_sketch = (source_sketches or {}).get(str(profile_source or ""))
|
||
source_ids = _direct_profile_source_entity_ids(producer_sketch or {})
|
||
refs = list(dict.fromkeys(_source_refs(value)))
|
||
ref_ids = {entity_id for _sketch_id, entity_id in refs}
|
||
if (
|
||
producer is not None
|
||
and producer.get("atomic_id") == "extrude_add_two_sided"
|
||
and params.get("result_mode") == "new_body"
|
||
and (params.get("end_condition") or {}).get("type") == "blind"
|
||
and (params.get("reverse_end_condition") or {}).get("type") == "blind"
|
||
and params.get("draft") is None
|
||
and isinstance(profile_source, str)
|
||
and source_sketch is not None
|
||
and _profile_matches_direct_source(producer_sketch or {}, source_sketch)
|
||
and source_ids
|
||
and refs
|
||
and {sketch_id for sketch_id, _entity_id in refs} == {profile_source}
|
||
and ref_ids == source_ids
|
||
and query.source_sketch == profile_source
|
||
and query.source_entity in source_ids
|
||
):
|
||
role = f"extrude.{'start' if query.is_start else 'end'}"
|
||
intent = _selector_intent(
|
||
value,
|
||
query_family="CAP_FACE",
|
||
kind="face",
|
||
evidence="operation_role",
|
||
allowed=("boundary",),
|
||
output_role=role,
|
||
disambiguation={"source_profile_entity_ids": sorted(source_ids)},
|
||
)
|
||
intent["consumer_contract"] = "symmetric_direct_prism_two_sided_up_to_surface_cap_pair"
|
||
return {
|
||
"kind": "face",
|
||
"owner_feature_id": owner_feature_id,
|
||
"output_role": role,
|
||
"source": "runtime_snapshot",
|
||
"confidence": 1.0,
|
||
"selector_intent": intent,
|
||
}
|
||
|
||
# The two builders of a symmetric prism share the source plane, so only
|
||
# their far First/LastShape handles identify the physical CAP faces. The
|
||
# executor records those final-snapshot roles exactly. Keep this bridge
|
||
# confined to a shell removing either cap of one direct circle: unlike a
|
||
# general CAP_FACE it has an explicit one-edge source anchor and no
|
||
# cross-feature continuation or extent semantics.
|
||
if allow_two_sided_circle_shell:
|
||
profile = (producer_sketch or {}).get("profile") or {}
|
||
source_sketch_id = (producer_sketch or {}).get("source_sketch_id")
|
||
source_entity_id = profile.get("source_entity_id")
|
||
refs = _source_refs(value)
|
||
if (
|
||
producer is not None
|
||
and producer.get("atomic_id") == "extrude_add_two_sided"
|
||
and params.get("result_mode") == "new_body"
|
||
and (params.get("end_condition") or {}).get("type") == "blind"
|
||
and (params.get("reverse_end_condition") or {}).get("type") == "blind"
|
||
and params.get("draft") is None
|
||
and profile.get("type") == "circle"
|
||
and isinstance(source_sketch_id, str)
|
||
and isinstance(source_entity_id, str)
|
||
and refs == [(source_sketch_id, source_entity_id)]
|
||
and query.source_sketch == source_sketch_id
|
||
and query.source_entity == source_entity_id
|
||
):
|
||
role = f"extrude.{'start' if query.is_start else 'end'}"
|
||
return {
|
||
"kind": "face",
|
||
"owner_feature_id": owner_feature_id,
|
||
"output_role": role,
|
||
"source": "runtime_snapshot",
|
||
"confidence": 1.0,
|
||
"selector_intent": _selector_intent(
|
||
value,
|
||
query_family="CAP_FACE",
|
||
kind="face",
|
||
evidence="operation_role",
|
||
allowed=("boundary",),
|
||
output_role=role,
|
||
),
|
||
}
|
||
|
||
# A loft CAP is identified by its OSD profile provenance, not by
|
||
# ``isStart``. FeatureScript's CAP naming direction is independent of
|
||
# the ThruSections wire order.
|
||
if producer and producer.get("atomic_id") == "loft_add":
|
||
if not allow_initial_loft:
|
||
return None
|
||
profile_sources = params.get("cap_output_profile_sources")
|
||
if (
|
||
params.get("initial_output_roles") is not True
|
||
or not isinstance(profile_sources, list)
|
||
or len(profile_sources) != 2
|
||
or len(set(profile_sources)) != 2
|
||
):
|
||
return None
|
||
imprint_owners = set()
|
||
for call in walk_calls(_definition.get("disambiguationData")):
|
||
if call.name != "makeQuery":
|
||
continue
|
||
try:
|
||
_direct, direct_owner, direct_topology, direct_kind, _direct_definition = _direct_make_query(call)
|
||
except ValueError:
|
||
continue
|
||
if direct_topology == "IMPRINT" and direct_kind == "face":
|
||
imprint_owners.add(direct_owner)
|
||
if len(imprint_owners) != 1:
|
||
return None
|
||
source = next(iter(imprint_owners))
|
||
if source not in profile_sources:
|
||
return None
|
||
role = ("loft.start", "loft.end")[profile_sources.index(source)]
|
||
return {
|
||
"kind": "face",
|
||
"owner_feature_id": owner_feature_id,
|
||
"output_role": role,
|
||
"source": "runtime_snapshot",
|
||
"confidence": 1.0,
|
||
"selector_intent": _selector_intent(
|
||
value,
|
||
query_family="CAP_FACE",
|
||
kind="face",
|
||
evidence="operation_role",
|
||
allowed=("boundary",),
|
||
output_role=role,
|
||
disambiguation={"type": "loft_profile_source", "source_sketch_id": source},
|
||
),
|
||
}
|
||
|
||
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.
|
||
# A primary ADD has no independently active tool body. It may expose this
|
||
# CAP role only to its immediate shell, one-sided up-to-surface, or
|
||
# dress-up consumer:
|
||
# execution registers the prism as transient and the resolver must prove
|
||
# the exact union successor in the active final member. Other CAP_FACE
|
||
# consumers keep the existing independent-new-body contract.
|
||
primary_add_consumer = (
|
||
(
|
||
allow_primary_add_shell
|
||
or allow_primary_add_up_to_surface
|
||
or allow_primary_add_dressup
|
||
)
|
||
and producer is not None
|
||
and producer.get("atomic_id") == "extrude_add_blind"
|
||
and params.get("result_mode") != "new_body"
|
||
)
|
||
if (
|
||
producer is None
|
||
or producer.get("atomic_id") != "extrude_add_blind"
|
||
or (params.get("result_mode") != "new_body" and not primary_add_consumer)
|
||
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"])]
|
||
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'}",
|
||
)
|
||
if primary_add_consumer:
|
||
selector_intent["consumer_contract"] = {
|
||
"shell": "primary_add_shell_union_continuation",
|
||
"up_to_surface": "primary_add_up_to_surface_union_continuation",
|
||
"dressup": "primary_add_dressup_union_continuation",
|
||
}[
|
||
"up_to_surface" if allow_primary_add_up_to_surface
|
||
else "dressup" if allow_primary_add_dressup
|
||
else "shell"
|
||
]
|
||
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,
|
||
}
|
||
|
||
|
||
def _two_sided_up_to_surface_cap_pair(
|
||
forward_value: Any,
|
||
reverse_value: Any,
|
||
*,
|
||
feature_by_id: dict[str, dict[str, Any]],
|
||
sketches_by_id: dict[str, dict[str, Any]],
|
||
source_sketches: dict[str, dict[str, Any]],
|
||
previous: list[str],
|
||
featurescript_version: str | None,
|
||
) -> tuple[dict[str, Any], dict[str, Any]] | None:
|
||
"""Lower opposite direct CAP faces only as one symmetric-extent pair."""
|
||
if featurescript_version != "1511":
|
||
return None
|
||
forward = _cap_face_output_role_selector(
|
||
forward_value, feature_by_id, sketches_by_id,
|
||
allow_two_sided_up_to_surface_pair=True, source_sketches=source_sketches,
|
||
)
|
||
reverse = _cap_face_output_role_selector(
|
||
reverse_value, feature_by_id, sketches_by_id,
|
||
allow_two_sided_up_to_surface_pair=True, source_sketches=source_sketches,
|
||
)
|
||
if (
|
||
forward is None
|
||
or reverse is None
|
||
or forward["owner_feature_id"] != reverse["owner_feature_id"]
|
||
or previous[-1:] != [forward["owner_feature_id"]]
|
||
or {forward["output_role"], reverse["output_role"]} != {"extrude.start", "extrude.end"}
|
||
):
|
||
return None
|
||
forward_ids = (forward.get("selector_intent") or {}).get("disambiguation", {}).get("source_profile_entity_ids")
|
||
reverse_ids = (reverse.get("selector_intent") or {}).get("disambiguation", {}).get("source_profile_entity_ids")
|
||
if not isinstance(forward_ids, list) or forward_ids != reverse_ids:
|
||
return None
|
||
return forward, reverse
|
||
|
||
|
||
def _two_sided_up_to_surface_shell_swept_face_pair(
|
||
forward_value: Any,
|
||
reverse_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,
|
||
) -> tuple[dict[str, Any], dict[str, Any]] | None:
|
||
"""Lower two retained prism side walls through one immediate shell.
|
||
|
||
The source queries remain source-edge anchored ``SWEPT_FACE`` lineage;
|
||
this function only admits their paired extent consumer when the shell is
|
||
the exact current lifecycle successor. The topology registry must still
|
||
prove each source edge -> prism wall -> shell offset-face continuation at
|
||
runtime, so this does not select a face by geometry or body position.
|
||
"""
|
||
if featurescript_version != "1511" or len(previous) < 2:
|
||
return None
|
||
try:
|
||
_forward_call, forward_owner, forward_topology, forward_kind, _forward_definition = _direct_make_query(forward_value)
|
||
_reverse_call, reverse_owner, reverse_topology, reverse_kind, _reverse_definition = _direct_make_query(reverse_value)
|
||
except ValueError:
|
||
return None
|
||
if (
|
||
forward_topology != "SWEPT_FACE"
|
||
or reverse_topology != "SWEPT_FACE"
|
||
or forward_kind not in {"face", "entitytype.face"}
|
||
or reverse_kind not in {"face", "entitytype.face"}
|
||
or forward_owner != reverse_owner
|
||
):
|
||
return None
|
||
producer_id = f"f_{forward_owner}"
|
||
shell_id = previous[-1]
|
||
shell = feature_by_id.get(shell_id) or {}
|
||
if (
|
||
previous[-2] != producer_id
|
||
or shell.get("atomic_id") != "shell"
|
||
or shell.get("depends_on") != [producer_id]
|
||
):
|
||
return None
|
||
forward = _direct_prism_swept_selector(
|
||
forward_value,
|
||
owner=forward_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,
|
||
allow_continuation=True,
|
||
)
|
||
reverse = _direct_prism_swept_selector(
|
||
reverse_value,
|
||
owner=reverse_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,
|
||
allow_continuation=True,
|
||
)
|
||
if forward is None or reverse is None:
|
||
return None
|
||
forward_source = (forward.get("selector_intent") or {}).get("source_entity") or {}
|
||
reverse_source = (reverse.get("selector_intent") or {}).get("source_entity") or {}
|
||
if (
|
||
forward_source == reverse_source
|
||
or not isinstance(forward_source.get("sketch_id"), str)
|
||
or not isinstance(forward_source.get("entity_id"), str)
|
||
or not isinstance(reverse_source.get("entity_id"), str)
|
||
):
|
||
return None
|
||
shell_source_entities = {
|
||
(intent.get("source_entity") or {}).get("entity_id")
|
||
for selector in shell.get("selectors") or ()
|
||
if isinstance(selector, dict)
|
||
for intent in [selector.get("selector_intent")]
|
||
if isinstance(intent, dict)
|
||
and intent.get("query_family") == "SWEPT_FACE"
|
||
and isinstance((intent.get("source_entity") or {}).get("entity_id"), str)
|
||
}
|
||
if {
|
||
forward_source["entity_id"],
|
||
reverse_source["entity_id"],
|
||
} & shell_source_entities:
|
||
# A shell may retain a planar closing descendant of a removed side.
|
||
# It is not the physical offset face requested by this bridge.
|
||
return None
|
||
source_sketch_id = forward_source["sketch_id"]
|
||
forward_entity = (entity_by_sketch.get(source_sketch_id) or {}).get(forward_source["entity_id"])
|
||
reverse_entity = (entity_by_sketch.get(source_sketch_id) or {}).get(reverse_source["entity_id"])
|
||
if (
|
||
forward_entity is None
|
||
or reverse_entity is None
|
||
or forward_entity.get("type") != "line"
|
||
or reverse_entity.get("type") != "line"
|
||
or forward_entity.get("construction")
|
||
or reverse_entity.get("construction")
|
||
):
|
||
return None
|
||
for selector in (forward, reverse):
|
||
selector["selector_intent"]["consumer_contract"] = "symmetric_direct_prism_shell_swept_face_up_to_surface_pair"
|
||
return forward, reverse
|
||
|
||
|
||
def _initial_direct_sweep_cap_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:
|
||
"""Bind a direct PipeShell cap by profile and path-end source anchors.
|
||
|
||
The builder's ``FirstShape``/``LastShape`` facts identify the result. A
|
||
CADFS CAP query additionally carries the profile edge and path endpoint;
|
||
require that exact pair so ``isStart`` cannot select a cap by position
|
||
alone. Only the narrow source contract emitted below for an initial,
|
||
direct, independent sweep is accepted.
|
||
"""
|
||
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
|
||
producer = feature_by_id.get(f"f_{owner}")
|
||
params = (producer or {}).get("params") or {}
|
||
contract = params.get("cap_output_contract")
|
||
if (
|
||
producer is None
|
||
or producer.get("atomic_id") != "sweep_add"
|
||
or params.get("result_mode") != "new_body"
|
||
or params.get("initial_output_roles") is not True
|
||
or not isinstance(contract, dict)
|
||
):
|
||
return None
|
||
required = ("profile_source", "profile_entity", "path_source", "path_entity", "path_reversed")
|
||
if (
|
||
any(not isinstance(contract.get(name), str) or not contract[name] for name in required[:-1])
|
||
or not isinstance(contract.get("path_reversed"), bool)
|
||
):
|
||
return None
|
||
sketch = sketches_by_id.get(str(producer.get("sketch_id") or "")) or {}
|
||
if sketch.get("source_sketch_id") != contract["profile_source"]:
|
||
return None
|
||
refs = _source_refs(definition.get("disambiguationData"))
|
||
profile_ref = (contract["profile_source"], contract["profile_entity"])
|
||
path_prefix = f"{contract['path_entity']}."
|
||
path_refs = [ref for ref in refs if ref[0] == contract["path_source"] and ref[1].startswith(path_prefix)]
|
||
if len(refs) != 2 or profile_ref not in refs or len(path_refs) != 1:
|
||
return None
|
||
suffix = path_refs[0][1][len(path_prefix):]
|
||
if suffix not in {"start", "end"}:
|
||
return None
|
||
expected_endpoint = "start" if query.is_start else "end"
|
||
# ``isStart`` is a source statement about the path endpoint. If lowering
|
||
# reversed the path to attach the profile, invert it before choosing the
|
||
# physical PipeShell output role.
|
||
if suffix != expected_endpoint:
|
||
return None
|
||
role_endpoint = suffix if not contract["path_reversed"] else ("end" if suffix == "start" else "start")
|
||
role = f"sweep.{role_endpoint}"
|
||
return {
|
||
"kind": "face",
|
||
"owner_feature_id": f"f_{owner}",
|
||
"output_role": role,
|
||
"source": "runtime_snapshot",
|
||
"confidence": 1.0,
|
||
"selector_intent": _selector_intent(
|
||
value,
|
||
query_family="CAP_FACE",
|
||
kind="face",
|
||
evidence="operation_role",
|
||
allowed=("boundary",),
|
||
output_role=role,
|
||
disambiguation={
|
||
"type": "sweep_profile_path_endpoint",
|
||
**{name: contract[name] for name in required},
|
||
"path_endpoint": suffix,
|
||
},
|
||
),
|
||
}
|
||
|
||
|
||
def _initial_direct_sweep_cap_edge_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] | None:
|
||
"""Bind the one-edge boundary of an immediate direct PipeShell cap.
|
||
|
||
PipeShell has no per-edge history callback. This contract consequently
|
||
admits only the same direct source pair as the cap-face bridge and only a
|
||
profile with one retained direct source edge. The adapter independently
|
||
proves that the role face has one exact final boundary edge.
|
||
"""
|
||
try:
|
||
_call, owner, topology, kind, definition = _direct_make_query(value)
|
||
except ValueError:
|
||
return None
|
||
if topology != "CAP_EDGE" or kind not in {"edge", "entitytype.edge"}:
|
||
return None
|
||
query = parse_query(value)
|
||
if query.is_start is None:
|
||
return None
|
||
producer_id = f"f_{owner}"
|
||
producer = feature_by_id.get(producer_id)
|
||
params = (producer or {}).get("params") or {}
|
||
contract = params.get("cap_output_contract")
|
||
if (
|
||
producer is None
|
||
or previous[-1:] != [producer_id]
|
||
or producer.get("atomic_id") != "sweep_add"
|
||
or params.get("result_mode") != "new_body"
|
||
or params.get("initial_output_roles") is not True
|
||
or not isinstance(contract, dict)
|
||
):
|
||
return None
|
||
required = ("profile_source", "profile_entity", "path_source", "path_entity", "path_reversed")
|
||
if (
|
||
any(not isinstance(contract.get(name), str) or not contract[name] for name in required[:-1])
|
||
or not isinstance(contract.get("path_reversed"), bool)
|
||
):
|
||
return None
|
||
sketch = sketches_by_id.get(str(producer.get("sketch_id") or "")) or {}
|
||
profile = sketch.get("profile") or {}
|
||
if (
|
||
sketch.get("source_sketch_id") != contract["profile_source"]
|
||
or profile.get("type") != "circle"
|
||
or profile.get("source_entity_id") != contract["profile_entity"]
|
||
):
|
||
return None
|
||
refs = _source_refs(definition.get("disambiguationData"))
|
||
profile_ref = (contract["profile_source"], contract["profile_entity"])
|
||
path_prefix = f"{contract['path_entity']}."
|
||
path_refs = [ref for ref in refs if ref[0] == contract["path_source"] and ref[1].startswith(path_prefix)]
|
||
if len(refs) != 2 or profile_ref not in refs or len(path_refs) != 1:
|
||
return None
|
||
suffix = path_refs[0][1][len(path_prefix):]
|
||
expected_endpoint = "start" if query.is_start else "end"
|
||
if suffix != expected_endpoint:
|
||
return None
|
||
role_endpoint = suffix if not contract["path_reversed"] else ("end" if suffix == "start" else "start")
|
||
role = f"sweep.{role_endpoint}"
|
||
intent = _selector_intent(
|
||
value,
|
||
query_family="CAP_EDGE",
|
||
kind="edge",
|
||
evidence="kernel_history",
|
||
allowed=("boundary",),
|
||
disambiguation={
|
||
"type": "sweep_profile_path_endpoint",
|
||
**{name: contract[name] for name in required},
|
||
"path_endpoint": suffix,
|
||
},
|
||
)
|
||
# ``parse_query`` retains the endpoint as its convenience primary source
|
||
# for this two-anchor OSD. CAP_EDGE lineage is anchored on the profile
|
||
# edge; the path endpoint remains explicit disambiguation evidence.
|
||
intent["source_entity"] = {
|
||
"sketch_id": contract["profile_source"],
|
||
"entity_id": contract["profile_entity"],
|
||
}
|
||
intent["lineage_role"] = role
|
||
return {
|
||
"kind": "edge",
|
||
"owner_feature_id": producer_id,
|
||
"source": "runtime_snapshot",
|
||
"confidence": 1.0,
|
||
"selector_intent": intent,
|
||
}
|
||
|
||
|
||
def _initial_direct_sweep_swept_face_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] | None:
|
||
"""Bind one direct PipeShell side face through `Generated(profile_edge)`.
|
||
|
||
The complete direct profile/path source contract remains mandatory. The
|
||
consumer receives only its selected profile edge as its runtime anchor:
|
||
the path edge is retained as source disambiguation, not turned into
|
||
geometry. Listing every source edge in the contract prevents a partial
|
||
or solver-rebuilt profile from looking like a direct builder history.
|
||
"""
|
||
try:
|
||
_call, owner, topology, kind, definition = _direct_make_query(value)
|
||
except ValueError:
|
||
return None
|
||
if topology != "SWEPT_FACE" or kind not in {"face", "entitytype.face"}:
|
||
return None
|
||
producer_id = f"f_{owner}"
|
||
producer = feature_by_id.get(producer_id)
|
||
params = (producer or {}).get("params") or {}
|
||
contract = params.get("swept_face_contract")
|
||
if (
|
||
producer is None
|
||
or previous[-1:] != [producer_id]
|
||
or producer.get("atomic_id") != "sweep_add"
|
||
or params.get("result_mode") != "new_body"
|
||
or params.get("initial_output_roles") is not True
|
||
or not isinstance(contract, dict)
|
||
):
|
||
return None
|
||
required = ("profile_source", "profile_entities", "path_source", "path_entity", "path_reversed")
|
||
if (
|
||
any(not isinstance(contract.get(name), str) or not contract[name] for name in ("profile_source", "path_source", "path_entity"))
|
||
or not isinstance(contract.get("profile_entities"), list)
|
||
or not contract["profile_entities"]
|
||
or any(not isinstance(entity, str) or not entity for entity in contract["profile_entities"])
|
||
or len(set(contract["profile_entities"])) != len(contract["profile_entities"])
|
||
or not isinstance(contract.get("path_reversed"), bool)
|
||
):
|
||
return None
|
||
sketch = sketches_by_id.get(str(producer.get("sketch_id") or "")) or {}
|
||
profile_entities = _direct_sweep_profile_entities(sketch)
|
||
if sketch.get("source_sketch_id") != contract["profile_source"] or profile_entities != contract["profile_entities"]:
|
||
return None
|
||
refs = _source_refs(definition.get("disambiguationData"))
|
||
path_ref = (contract["path_source"], contract["path_entity"])
|
||
profile_refs = [ref for ref in refs if ref[0] == contract["profile_source"] and ref[1] in profile_entities]
|
||
if len(refs) != 2 or len(profile_refs) != 1 or path_ref not in refs:
|
||
return None
|
||
selected_profile_entity = profile_refs[0][1]
|
||
intent = _selector_intent(
|
||
value,
|
||
query_family="SWEPT_FACE",
|
||
kind="face",
|
||
evidence="kernel_history",
|
||
allowed=("boundary",),
|
||
disambiguation={
|
||
"type": "sweep_profile_path",
|
||
**{name: contract[name] for name in required},
|
||
},
|
||
)
|
||
intent["source_entity"] = {
|
||
"sketch_id": contract["profile_source"],
|
||
"entity_id": selected_profile_entity,
|
||
}
|
||
return {
|
||
"kind": "face",
|
||
"owner_feature_id": producer_id,
|
||
"source": "runtime_snapshot",
|
||
"confidence": 1.0,
|
||
"selector_intent": intent,
|
||
}
|
||
|
||
|
||
def _direct_sweep_profile_entities(sketch: dict[str, Any]) -> list[str] | None:
|
||
"""Return one unchanged direct closed profile's complete source edge set.
|
||
|
||
PipeShell can report `Generated(edge)` for every profile edge, but only if
|
||
CDSL retains the full original contour. This deliberately rejects holes,
|
||
regions and any unlabeled/split edge rather than guessing a correspondence.
|
||
"""
|
||
profile = sketch.get("profile") or {}
|
||
if profile.get("type") == "circle":
|
||
entity = profile.get("source_entity_id")
|
||
return [entity] if isinstance(entity, str) and entity else None
|
||
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
|
||
if not isinstance(contours, list) or len(contours) != 1:
|
||
return None
|
||
contour = contours[0] or {}
|
||
segments = contour.get("segments") if isinstance(contour, dict) else None
|
||
if not contour.get("closed") or not isinstance(segments, list) or not segments:
|
||
return None
|
||
entities = [segment.get("source_entity_id") for segment in segments if isinstance(segment, dict)]
|
||
if len(entities) != len(segments) or any(not isinstance(entity, str) or not entity for entity in entities):
|
||
return None
|
||
return entities if len(set(entities)) == len(entities) else None
|
||
|
||
|
||
def _direct_sweep_profile_vertex_entity_pairs(sketch: dict[str, Any]) -> set[tuple[str, str]]:
|
||
"""Return exact adjacent source-edge pairs for a direct closed contour."""
|
||
entities = _direct_sweep_profile_entities(sketch)
|
||
if entities is None or len(entities) < 2:
|
||
return set()
|
||
return {
|
||
tuple(sorted((entities[index], entities[(index + 1) % len(entities)])))
|
||
for index in range(len(entities))
|
||
}
|
||
|
||
|
||
def _initial_direct_sweep_swept_edge_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] | None:
|
||
"""Bind a PipeShell swept edge through `Generated(profile_vertex)`.
|
||
|
||
CADFS identifies the source vertex by exactly two incident profile edges
|
||
and separately supplies the direct path edge. The path remains semantic
|
||
disambiguation: the runtime anchor is the exact profile vertex only.
|
||
"""
|
||
try:
|
||
_call, owner, topology, kind, definition = _direct_make_query(value)
|
||
except ValueError:
|
||
return None
|
||
if topology != "SWEPT_EDGE" or kind not in {"edge", "entitytype.edge"}:
|
||
return None
|
||
producer_id = f"f_{owner}"
|
||
producer = feature_by_id.get(producer_id)
|
||
params = (producer or {}).get("params") or {}
|
||
contract = params.get("swept_edge_contract")
|
||
if (
|
||
producer is None
|
||
or previous[-1:] != [producer_id]
|
||
or producer.get("atomic_id") != "sweep_add"
|
||
or params.get("result_mode") != "new_body"
|
||
or params.get("initial_output_roles") is not True
|
||
or not isinstance(contract, dict)
|
||
):
|
||
return None
|
||
required = ("profile_source", "profile_entities", "path_source", "path_entity", "path_reversed")
|
||
if (
|
||
any(not isinstance(contract.get(name), str) or not contract[name] for name in ("profile_source", "path_source", "path_entity"))
|
||
or contract["profile_source"] == contract["path_source"]
|
||
or not isinstance(contract.get("profile_entities"), list)
|
||
or len(contract["profile_entities"]) < 2
|
||
or any(not isinstance(entity, str) or not entity for entity in contract["profile_entities"])
|
||
or len(set(contract["profile_entities"])) != len(contract["profile_entities"])
|
||
or not isinstance(contract.get("path_reversed"), bool)
|
||
):
|
||
return None
|
||
sketch = sketches_by_id.get(str(producer.get("sketch_id") or "")) or {}
|
||
profile_entities = _direct_sweep_profile_entities(sketch)
|
||
if sketch.get("source_sketch_id") != contract["profile_source"] or profile_entities != contract["profile_entities"]:
|
||
return None
|
||
refs = _source_refs(definition.get("disambiguationData"))
|
||
path_ref = (contract["path_source"], contract["path_entity"])
|
||
profile_refs = [ref for ref in refs if ref[0] == contract["profile_source"] and ref[1] in profile_entities]
|
||
vertex_entities = tuple(sorted(ref[1] for ref in profile_refs))
|
||
if (
|
||
len(refs) != 3
|
||
or len(set(refs)) != 3
|
||
or len(profile_refs) != 2
|
||
or len(set(vertex_entities)) != 2
|
||
or path_ref not in refs
|
||
or vertex_entities not in _direct_sweep_profile_vertex_entity_pairs(sketch)
|
||
):
|
||
return None
|
||
intent = _selector_intent(
|
||
value,
|
||
query_family="SWEPT_EDGE",
|
||
kind="edge",
|
||
evidence="kernel_history",
|
||
allowed=("boundary",),
|
||
disambiguation={
|
||
"type": "sweep_profile_vertex_path",
|
||
**{name: contract[name] for name in required},
|
||
"profile_vertex_entities": list(vertex_entities),
|
||
},
|
||
)
|
||
intent.pop("source_entity", None)
|
||
intent["source_entities"] = [
|
||
{"sketch_id": contract["profile_source"], "entity_id": entity_id}
|
||
for entity_id in vertex_entities
|
||
]
|
||
return {
|
||
"kind": "edge",
|
||
"owner_feature_id": producer_id,
|
||
"source": "runtime_snapshot",
|
||
"confidence": 1.0,
|
||
"selector_intent": intent,
|
||
}
|
||
|
||
|
||
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
|
||
|
||
def line_loop(contour: dict[str, Any]) -> list[list[float]] | None:
|
||
segments = contour.get("segments") or []
|
||
if not isinstance(segments, list) or len(segments) < 3 or not all(
|
||
isinstance(segment, dict) and segment.get("type") == "line"
|
||
and isinstance(segment.get("start"), list) and isinstance(segment.get("end"), list)
|
||
for segment in segments
|
||
):
|
||
return None
|
||
points = [list(segment["start"]) for segment in segments]
|
||
if any(math.dist(segment["end"], segments[(index + 1) % len(segments)]["start"]) > 1e-6
|
||
for index, segment in enumerate(segments)):
|
||
return None
|
||
return points
|
||
|
||
def strictly_contains(outer: list[list[float]], inner: list[list[float]]) -> bool:
|
||
"""Return whether a closed line loop contains every inner vertex.
|
||
|
||
This is source-profile construction, not a selector fallback: both
|
||
loops originate from the exact FeatureScript sketch entities. Boundary
|
||
contact remains rejected because it has split/region semantics that
|
||
cannot be represented by a single direct analytic profile.
|
||
"""
|
||
def inside(point: list[float]) -> bool:
|
||
crossings = 0
|
||
for index, start in enumerate(outer):
|
||
end = outer[(index + 1) % len(outer)]
|
||
cross = (end[0] - start[0]) * (point[1] - start[1]) - (end[1] - start[1]) * (point[0] - start[0])
|
||
if abs(cross) <= 1e-8 and min(start[0], end[0]) - 1e-8 <= point[0] <= max(start[0], end[0]) + 1e-8 and min(start[1], end[1]) - 1e-8 <= point[1] <= max(start[1], end[1]) + 1e-8:
|
||
return False
|
||
if (start[1] > point[1]) != (end[1] > point[1]):
|
||
x = start[0] + (point[1] - start[1]) * (end[0] - start[0]) / (end[1] - start[1])
|
||
if abs(x - point[0]) <= 1e-8:
|
||
return False
|
||
if x > point[0]:
|
||
crossings += 1
|
||
return bool(crossings % 2)
|
||
return bool(inner) and all(inside(point) for point in inner)
|
||
|
||
# A single IMPRINT query over one outer direct line loop denotes the
|
||
# source-side region, not merely that loop's curve. If every other source
|
||
# line loop lies strictly inside it, preserve those nested boundaries so
|
||
# the derived profile retains its holes and their CAP-edge provenance.
|
||
# Disjoint, touching, curved, split and multiple-outer arrangements keep
|
||
# the existing explicit-region paths below.
|
||
if len(selections) == 1:
|
||
source_id = max((key for key in entities if selections[0].source_entity and selections[0].source_entity.startswith(key)), key=len, default="")
|
||
side = _profile_selection_side(query_values[0]) if query_values else None
|
||
if source_id and side is not None:
|
||
matches = [
|
||
(contour, next((_matching_profile_segment(segment, entities[source_id]) for segment in contour.get("segments") or [] if _matching_profile_segment(segment, entities[source_id])), 0))
|
||
for contour in contours
|
||
if any(_matching_profile_segment(segment, entities[source_id]) for segment in contour.get("segments") or [])
|
||
]
|
||
outer_loop = line_loop(matches[0][0]) if len(matches) == 1 and matches[0][1] == (1 if side > 0 else -1) else None
|
||
other_loops = [line_loop(contour) for contour in contours if not matches or contour is not matches[0][0]]
|
||
if outer_loop is not None and other_loops and all(loop is not None and strictly_contains(outer_loop, loop) for loop in other_loops):
|
||
output = deepcopy(sketch)
|
||
output["id"] = f"{sketch['id']}__{feature_id}"
|
||
output["name"] = f"{sketch['name']}__{feature_id}"
|
||
# Keep the source profile roles/segments intact. The solver
|
||
# performs the established containment-parity classification.
|
||
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,
|
||
*,
|
||
allow_open_wire: bool = False,
|
||
) -> dict[str, Any]:
|
||
"""Materialize explicitly selected source wires from ``surfaceEntities``.
|
||
|
||
Mixed solid/surface extrusion continues to accept one or more circular wires.
|
||
A pure ``ToolBodyType.SURFACE`` extrusion may additionally carry one or
|
||
more original lines. Each connected, non-branching open chain becomes
|
||
one shell wire; it never becomes a solid profile or active-body member.
|
||
"""
|
||
references = _source_refs(query_value)
|
||
selected: list[tuple[str, dict[str, Any]]] = []
|
||
seen = set()
|
||
duplicate_source = False
|
||
for source, entity_id in references:
|
||
if source != sketch["name"]:
|
||
raise UnsupportedCapability(
|
||
"extrude_surface_profile",
|
||
"surface extrude source wires must come from one source sketch",
|
||
)
|
||
if entity_id in seen:
|
||
duplicate_source = True
|
||
continue
|
||
entity = entities.get(entity_id)
|
||
if entity is None or entity.get("construction"):
|
||
raise UnsupportedCapability(
|
||
"extrude_surface_profile",
|
||
"current CDSL surface extrude requires one explicit original source wire",
|
||
)
|
||
selected.append((entity_id, 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"
|
||
if allow_open_wire and all(entity.get("type") == "line" for _entity_id, entity in selected):
|
||
if duplicate_source:
|
||
raise UnsupportedCapability(
|
||
"extrude_surface_profile",
|
||
"pure ToolBodyType.SURFACE open-wire extrusion requires distinct source lines",
|
||
)
|
||
records: list[tuple[str, dict[str, Any], list[float], list[float]]] = []
|
||
for entity_id, entity in selected:
|
||
start, end = entity.get("start"), entity.get("end")
|
||
if (
|
||
not isinstance(start, list)
|
||
or not isinstance(end, list)
|
||
or len(start) != 2
|
||
or len(end) != 2
|
||
or not all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in [*start, *end])
|
||
or _same_point(start, end)
|
||
):
|
||
raise UnsupportedCapability(
|
||
"extrude_surface_profile",
|
||
"pure ToolBodyType.SURFACE open-wire extrusion requires finite non-degenerate source lines",
|
||
)
|
||
records.append((entity_id, entity, list(start), list(end)))
|
||
|
||
# A query set may deliberately contain disconnected wires, but each
|
||
# component must itself have one unambiguous pair of terminal points.
|
||
# Query encounter order is only a tie-breaker for a proven chain's
|
||
# direction; it is never used to join or choose nearby geometry.
|
||
remaining_components = set(range(len(records)))
|
||
contours: list[dict[str, Any]] = []
|
||
while remaining_components:
|
||
seed = min(remaining_components)
|
||
component = {seed}
|
||
frontier = [seed]
|
||
while frontier:
|
||
index = frontier.pop()
|
||
_entity_id, _entity, start, end = records[index]
|
||
for candidate in remaining_components - component:
|
||
_candidate_id, _candidate, candidate_start, candidate_end = records[candidate]
|
||
if any(
|
||
_same_point(point, candidate_point)
|
||
for point in (start, end)
|
||
for candidate_point in (candidate_start, candidate_end)
|
||
):
|
||
component.add(candidate)
|
||
frontier.append(candidate)
|
||
remaining_components -= component
|
||
|
||
def incident_count(point: list[float]) -> int:
|
||
return sum(
|
||
int(_same_point(point, start)) + int(_same_point(point, end))
|
||
for index in component
|
||
for _entity_id, _entity, start, end in [records[index]]
|
||
)
|
||
|
||
terminals = [
|
||
endpoint
|
||
for index in sorted(component)
|
||
for endpoint in records[index][2:]
|
||
if incident_count(endpoint) == 1
|
||
]
|
||
if len(terminals) != 2:
|
||
raise UnsupportedCapability(
|
||
"extrude_surface_profile",
|
||
"pure ToolBodyType.SURFACE source lines must form non-branching open chains",
|
||
)
|
||
current = terminals[0]
|
||
component_remaining = set(component)
|
||
segments: list[dict[str, Any]] = []
|
||
while component_remaining:
|
||
matches = [
|
||
index for index in component_remaining
|
||
if _same_point(current, records[index][2]) or _same_point(current, records[index][3])
|
||
]
|
||
if len(matches) != 1:
|
||
raise UnsupportedCapability(
|
||
"extrude_surface_profile",
|
||
"pure ToolBodyType.SURFACE source lines must form non-branching open chains",
|
||
)
|
||
index = matches[0]
|
||
entity_id, entity, start, end = records[index]
|
||
if _same_point(current, start):
|
||
segment = deepcopy(entity)
|
||
current = end
|
||
else:
|
||
segment = _reversed_sweep_path_segment(entity)
|
||
current = start
|
||
segment.setdefault("source_entity_id", entity_id)
|
||
segments.append(segment)
|
||
component_remaining.remove(index)
|
||
if incident_count(current) != 1:
|
||
raise UnsupportedCapability(
|
||
"extrude_surface_profile",
|
||
"pure ToolBodyType.SURFACE source lines must form non-branching open chains",
|
||
)
|
||
contours.append({
|
||
"role": "open", "closed": False, "surface_wire": True,
|
||
"segments": segments,
|
||
})
|
||
output["profile"] = {
|
||
"type": "analytic_contours",
|
||
"contours": contours,
|
||
}
|
||
return output
|
||
if any(entity.get("type") != "circle" for _entity_id, entity in selected):
|
||
raise UnsupportedCapability(
|
||
"extrude_surface_profile",
|
||
"current CDSL mixed surface extrude requires explicitly selected circular wires",
|
||
)
|
||
output["profile"] = {
|
||
"type": "analytic_contours",
|
||
"contours": [
|
||
{"role": "unknown", "closed": True, "segments": [deepcopy(entity)]}
|
||
for _entity_id, 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 direct open sweep path."""
|
||
segment = path.get("segment") or {}
|
||
path_plane = path.get("workplane") or {}
|
||
profile_plane = profile.get("workplane") or {}
|
||
# A cross-sketch spatial path deliberately has no single planar frame.
|
||
# Its source capture can execute a sweep, but it has not established the
|
||
# planar cap-frame contract used by downstream CAP_FACE lowering.
|
||
if not isinstance(path_plane, dict) or not path_plane:
|
||
return None
|
||
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])
|
||
if segment.get("type") == "arc":
|
||
center = segment.get("center")
|
||
if not isinstance(center, list) or len(center) != 2:
|
||
return None
|
||
start_radial = [float(points[0][0]) - float(center[0]), float(points[0][1]) - float(center[1])]
|
||
end_radial = [float(points[-1][0]) - float(center[0]), float(points[-1][1]) - float(center[1])]
|
||
if bool(segment.get("clockwise", False)):
|
||
start_tangent, end_tangent = [start_radial[1], -start_radial[0]], [end_radial[1], -end_radial[0]]
|
||
else:
|
||
start_tangent, end_tangent = [-start_radial[1], start_radial[0]], [-end_radial[1], end_radial[0]]
|
||
start_direction = direction(start_tangent, _sub(end, start))
|
||
end_direction = direction(end_tangent, _sub(end, start))
|
||
else:
|
||
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/arc/B-spline sweep path without changing its curve."""
|
||
output = deepcopy(segment)
|
||
if output.get("type") in {"line", "arc"}:
|
||
output["start"], output["end"] = segment["end"], segment["start"]
|
||
if output.get("type") == "arc":
|
||
output["clockwise"] = not bool(segment.get("clockwise", False))
|
||
if output.get("type") == "bspline":
|
||
output["points"] = list(reversed(segment.get("points") or []))
|
||
if "start" in segment and "end" in segment:
|
||
output["start"], output["end"] = segment["end"], segment["start"]
|
||
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 _reversed_spatial_sweep_path_segment(segment: dict[str, Any]) -> dict[str, Any]:
|
||
"""Reverse one globally captured source curve without changing its locus."""
|
||
output = deepcopy(segment)
|
||
if output.get("type") in {"line", "arc"}:
|
||
output["start_mm"], output["end_mm"] = segment["end_mm"], segment["start_mm"]
|
||
if output.get("type") == "arc":
|
||
output["clockwise"] = not bool(segment.get("clockwise", False))
|
||
if output.get("type") == "bspline":
|
||
output["points_mm"] = list(reversed(segment.get("points_mm") 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_mm"), segment.get("end_tangent_mm")
|
||
if isinstance(start_tangent, list) and isinstance(end_tangent, list):
|
||
output["start_tangent_mm"] = [-float(value) for value in end_tangent]
|
||
output["end_tangent_mm"] = [-float(value) for value in start_tangent]
|
||
return output
|
||
|
||
|
||
def _sweep_path_endpoint(segment: dict[str, Any], endpoint: str) -> list[float] | None:
|
||
if endpoint not in {"start", "end"}:
|
||
return None
|
||
if segment.get("type") == "bspline":
|
||
points = segment.get("points")
|
||
value = points[0 if endpoint == "start" else -1] if isinstance(points, list) and points else None
|
||
else:
|
||
value = segment.get(endpoint)
|
||
if (
|
||
not isinstance(value, list)
|
||
or len(value) != 2
|
||
or not all(isinstance(component, (int, float)) and math.isfinite(float(component)) for component in value)
|
||
):
|
||
return None
|
||
return [float(value[0]), float(value[1])]
|
||
|
||
|
||
def _same_sweep_path_point(left: list[float], right: list[float]) -> bool:
|
||
return math.dist(left, right) <= 1e-8
|
||
|
||
|
||
def _reversed_sweep_path(path: dict[str, Any]) -> dict[str, Any]:
|
||
output = deepcopy(path)
|
||
segments = output.get("segments")
|
||
if isinstance(segments, list):
|
||
reverse_segment = (
|
||
_reversed_spatial_sweep_path_segment
|
||
if "workplane" not in output
|
||
else _reversed_sweep_path_segment
|
||
)
|
||
output["segments"] = [reverse_segment(segment) for segment in reversed(segments)]
|
||
else:
|
||
segment = output.get("segment")
|
||
if not isinstance(segment, dict):
|
||
raise ValueError("sweep path has no reversible segment")
|
||
output["segment"] = _reversed_sweep_path_segment(segment)
|
||
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."""
|
||
profile_plane = profile.get("workplane") or {}
|
||
profile_shape = profile.get("profile") or {}
|
||
segments = path.get("segments")
|
||
if isinstance(segments, list):
|
||
sequence = segments
|
||
else:
|
||
segment = path.get("segment")
|
||
sequence = [segment] if isinstance(segment, dict) else []
|
||
if profile_shape.get("type") != "circle" or not sequence:
|
||
return False
|
||
center = profile_shape.get("center") or [0.0, 0.0]
|
||
if not isinstance(center, list) or len(center) != 2:
|
||
return False
|
||
try:
|
||
profile_center = _global(profile_plane, center)
|
||
except (KeyError, TypeError, ValueError):
|
||
return False
|
||
if "workplane" not in path:
|
||
def spatial_endpoint(segment: dict[str, Any], endpoint: str) -> list[float] | None:
|
||
if segment.get("type") == "bspline":
|
||
points = segment.get("points_mm")
|
||
value = points[0 if endpoint == "start" else -1] if isinstance(points, list) and points else None
|
||
else:
|
||
value = segment.get(f"{endpoint}_mm")
|
||
if (
|
||
not isinstance(value, list)
|
||
or len(value) != 3
|
||
or not all(isinstance(component, (int, float)) and math.isfinite(float(component)) for component in value)
|
||
):
|
||
return None
|
||
return [float(component) for component in value]
|
||
|
||
start_point = spatial_endpoint(sequence[0], "start")
|
||
end_point = spatial_endpoint(sequence[-1], "end")
|
||
if start_point is None or end_point is None:
|
||
return False
|
||
else:
|
||
workplane = path.get("workplane") or {}
|
||
start, end = _sweep_path_endpoint(sequence[0], "start"), _sweep_path_endpoint(sequence[-1], "end")
|
||
if not all(isinstance(value, list) and len(value) == 2 for value in (start, end)):
|
||
return False
|
||
try:
|
||
start_point, end_point = _global(workplane, start), _global(workplane, end)
|
||
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]], list[dict[str, str]]]:
|
||
"""Keep direct pattern COPY bodies explicit for boolean selection.
|
||
|
||
A body selected from a mirror, circular pattern, or multi-source transform
|
||
COPY is not its producer's aggregate result. Reuse the established
|
||
transform-query provenance parser so each selected body stays qualified by
|
||
its source member at runtime.
|
||
"""
|
||
sources, instance_refs, transform_copy_refs = _transform_body_references(
|
||
value, previous, feature_by_id, body_aliases,
|
||
)
|
||
return sources, instance_refs, transform_copy_refs
|
||
|
||
|
||
def _ordered_boolean_body_references(
|
||
value: Any,
|
||
previous: list[str],
|
||
feature_by_id: dict[str, dict[str, Any]],
|
||
body_aliases: dict[str, str] | None = None,
|
||
) -> list[tuple[str, Any]]:
|
||
"""Resolve targetless body operands without losing FeatureScript order.
|
||
|
||
The ordinary helper groups feature, pattern and transform-COPY references
|
||
for the CDSL schema. That grouping is correct for an explicit target/tool
|
||
pair but cannot select one qualified member from a targetless source set.
|
||
Each source query must therefore resolve to exactly one qualified member
|
||
here before the caller applies its explicit target-selection policy.
|
||
"""
|
||
ordered: list[tuple[str, Any]] = []
|
||
seen: set[tuple[Any, ...]] = set()
|
||
for query_value in _queries(value):
|
||
sources, instance_refs, transform_copy_refs = _boolean_body_references(
|
||
query_value, previous, feature_by_id, body_aliases,
|
||
)
|
||
candidates = (
|
||
[("feature", source) for source in sources]
|
||
+ [("pattern", reference) for reference in instance_refs]
|
||
+ [("transform_copy", reference) for reference in transform_copy_refs]
|
||
)
|
||
if len(candidates) != 1:
|
||
raise UnsupportedCapability(
|
||
"boolean_bodies_targets",
|
||
"targetless booleanBodies requires each source operand to resolve one explicit body member",
|
||
)
|
||
kind, candidate = candidates[0]
|
||
if kind == "feature":
|
||
identity = (kind, candidate)
|
||
elif kind == "pattern":
|
||
identity = (
|
||
kind,
|
||
candidate["pattern_feature_id"],
|
||
candidate["source_feature_id"],
|
||
candidate["instance_index"],
|
||
)
|
||
else:
|
||
identity = (
|
||
kind,
|
||
candidate["transform_feature_id"],
|
||
candidate["source_feature_id"],
|
||
)
|
||
if identity not in seen:
|
||
seen.add(identity)
|
||
ordered.append((kind, candidate))
|
||
return ordered
|
||
|
||
|
||
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:
|
||
raise UnsupportedCapability(
|
||
"hole_scope_body_source",
|
||
"hole scope body is no longer an independently selectable member",
|
||
)
|
||
return source
|
||
|
||
|
||
def _is_direct_hole_location_query(value: Any) -> bool:
|
||
"""Return whether ``value`` has Hole's direct original-vertex form.
|
||
|
||
This is intentionally narrower than ``parse_query``'s recursive source
|
||
extraction. A wrapper can contain an ``sQuery`` leaf, but that does not
|
||
make it a direct Hole location or turn a missing sketch frame into its
|
||
primary diagnostic.
|
||
"""
|
||
if not isinstance(value, Call) or value.name not in {"sQuery", "sketchEntityQuery"} or len(value.args) < 3:
|
||
return False
|
||
info = parse_query(value)
|
||
return (
|
||
info.topology_type is None
|
||
and info.kind in {"vertex", "entitytype.vertex"}
|
||
and isinstance(info.source_sketch, str)
|
||
and isinstance(info.source_entity, str)
|
||
)
|
||
|
||
|
||
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, an
|
||
explicitly computed original arc 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 _is_direct_hole_location_query(value):
|
||
return None
|
||
info = parse_query(value)
|
||
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") == "arc" and suffix == "center":
|
||
# ``_arc`` derives this exact source datum from skArc's required
|
||
# start/mid/end inputs. It remains source-sketch provenance, unlike
|
||
# a center inferred from a runtime curve or a trimmed arc offspring.
|
||
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_selection(
|
||
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,
|
||
permit_construction: bool = False,
|
||
allowed_curve_types: frozenset[str] = frozenset({"line", "arc", "bspline"}),
|
||
) -> tuple[str, list[tuple[str, dict[str, Any]]], dict[str, Any]] | None:
|
||
"""Resolve the exact source entities named by one direct sketch-wire query.
|
||
|
||
``qBodyType(qCreatedBy(sketch, EDGE), WIRE)`` filters the source sketch's
|
||
reference-wire entities; it does not select runtime body topology. The
|
||
optional ``qConstructionFilter(..., NO)`` is evaluated against source
|
||
construction metadata. Without it, any construction curve in the source
|
||
result makes a sweep-path query ambiguous and remains deferred. The
|
||
source-only datum-axis caller may explicitly retain construction curves,
|
||
because FeatureScript permits a construction wire as a plane axis. A single-item
|
||
qUnion is only the set identity here, never permission to flatten or pick
|
||
among several source sketches.
|
||
"""
|
||
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 not value.args[0]
|
||
):
|
||
return None
|
||
operands = value.args[0]
|
||
# A direct union of sketch edges is already an explicit source set. It
|
||
# has different semantics from qCreatedBy(..., WIRE): do not flatten
|
||
# nested queries or evaluate filters here. Each leaf must identify one
|
||
# original source curve from exactly one sketch.
|
||
if all(
|
||
isinstance(operand, Call)
|
||
and operand.name in {"sQuery", "sketchEntityQuery"}
|
||
and len(operand.args) >= 3
|
||
for operand in operands
|
||
):
|
||
infos = [parse_query(operand) for operand in operands]
|
||
if any(
|
||
info.topology_type is not None
|
||
or info.kind not in {"edge", "entitytype.edge"}
|
||
or not isinstance(info.source_sketch, str)
|
||
or not isinstance(info.source_entity, str)
|
||
for info in infos
|
||
):
|
||
return None
|
||
sources = {info.source_sketch for info in infos}
|
||
if len(sources) != 1:
|
||
return None
|
||
source = next(iter(sources))
|
||
sketch = sketch_by_source.get(source)
|
||
entities = entity_by_sketch.get(source) or {}
|
||
entity_ids = [str(info.source_entity) for info in infos]
|
||
if (
|
||
sketch is None
|
||
or len(set(entity_ids)) != len(entity_ids)
|
||
or any(entity_id not in entities for entity_id in entity_ids)
|
||
):
|
||
return None
|
||
candidates = [(entity_id, entities[entity_id]) for entity_id in entity_ids]
|
||
if (
|
||
any(entity.get("construction") for _entity_id, entity in candidates)
|
||
or any(entity.get("type") not in allowed_curve_types for _entity_id, entity in candidates)
|
||
):
|
||
return None
|
||
return source, candidates, sketch
|
||
if len(operands) != 1:
|
||
return None
|
||
current = operands[0]
|
||
has_construction_filter = isinstance(current, Call) and current.name == "qConstructionFilter"
|
||
if has_construction_filter:
|
||
if len(current.args) != 2 or 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
|
||
# ``qCreatedBy(..., EDGE)`` cannot select direct sketch points. They remain
|
||
# in the source-entity map for vertex consumers, but do not participate in
|
||
# this source wire or its construction-filter semantics.
|
||
all_candidates = [
|
||
(entity_id, entity)
|
||
for entity_id, entity in entities.items()
|
||
if entity.get("type") != "point"
|
||
]
|
||
if not all_candidates:
|
||
return None
|
||
if (
|
||
not permit_construction
|
||
and not has_construction_filter
|
||
and any(entity.get("construction") for _entity_id, entity in all_candidates)
|
||
):
|
||
return None
|
||
candidates = [
|
||
(entity_id, entity)
|
||
for entity_id, entity in all_candidates
|
||
if not entity.get("construction") or (permit_construction and not has_construction_filter)
|
||
]
|
||
if not candidates or any(entity.get("type") not in allowed_curve_types for _entity_id, entity in candidates):
|
||
return None
|
||
return str(source), candidates, sketch
|
||
|
||
|
||
def _direct_closed_sketch_wire_profile(
|
||
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,
|
||
feature_id: str,
|
||
) -> dict[str, Any] | None:
|
||
"""Materialize one source-only closed wire for an independent surface loft.
|
||
|
||
This is deliberately narrower than general ``qBodyType`` evaluation: the
|
||
exact source query must enumerate one sketch's non-construction wire
|
||
curves, and those curves must form exactly one closed, non-branching
|
||
contour. No runtime topology or geometric matching participates.
|
||
"""
|
||
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
|
||
or not isinstance(value.args[0][0], Call)
|
||
or value.args[0][0].name != "qConstructionFilter"
|
||
):
|
||
return None
|
||
selection = _direct_sketch_wire_selection(
|
||
value, sketch_by_source, entity_by_sketch,
|
||
featurescript_version=featurescript_version,
|
||
standard_library=standard_library,
|
||
standard_library_version=standard_library_version,
|
||
allowed_curve_types=frozenset({"line", "arc", "bspline", "circle"}),
|
||
)
|
||
if selection is None:
|
||
return None
|
||
source, candidates, sketch = selection
|
||
if len(candidates) == 1 and candidates[0][1].get("type") == "circle":
|
||
entity_id, entity = candidates[0]
|
||
segment = deepcopy(entity)
|
||
segment["source_entity_id"] = entity_id
|
||
contours = [{"role": "unknown", "closed": True, "segments": [segment]}]
|
||
else:
|
||
if any(entity.get("type") == "circle" for _entity_id, entity in candidates):
|
||
return None
|
||
records: list[tuple[str, dict[str, Any], list[float], list[float]]] = []
|
||
for entity_id, entity in candidates:
|
||
start = _sweep_path_endpoint(entity, "start")
|
||
end = _sweep_path_endpoint(entity, "end")
|
||
if start is None or end is None or _same_sweep_path_point(start, end):
|
||
return None
|
||
records.append((entity_id, entity, start, end))
|
||
if len(records) < 2:
|
||
return None
|
||
|
||
def incident_count(point: list[float]) -> int:
|
||
return sum(
|
||
int(_same_sweep_path_point(point, start)) + int(_same_sweep_path_point(point, end))
|
||
for _entity_id, _entity, start, end in records
|
||
)
|
||
|
||
if any(incident_count(point) != 2 for _entity_id, _entity, start, end in records for point in (start, end)):
|
||
return None
|
||
remaining = set(range(len(records)))
|
||
first_id, first_entity, first_start, first_end = records[0]
|
||
ordered = [{**deepcopy(first_entity), "source_entity_id": first_id}]
|
||
remaining.remove(0)
|
||
current = first_end
|
||
while remaining:
|
||
matches = [
|
||
index for index in remaining
|
||
if _same_sweep_path_point(current, records[index][2]) or _same_sweep_path_point(current, records[index][3])
|
||
]
|
||
if len(matches) != 1:
|
||
return None
|
||
index = matches[0]
|
||
entity_id, entity, start, end = records[index]
|
||
segment = deepcopy(entity) if _same_sweep_path_point(current, start) else _reversed_sweep_path_segment(entity)
|
||
segment["source_entity_id"] = entity_id
|
||
current = end if _same_sweep_path_point(current, start) else start
|
||
ordered.append(segment)
|
||
remaining.remove(index)
|
||
if not _same_sweep_path_point(current, first_start):
|
||
return None
|
||
contours = [{"role": "unknown", "closed": True, "segments": ordered}]
|
||
output = deepcopy(sketch)
|
||
output["id"] = f"{sketch['id']}__{feature_id}"
|
||
output["name"] = f"{sketch['name']}__{feature_id}"
|
||
output["source_sketch_id"] = source
|
||
output["profile"] = {"type": "analytic_contours", "contours": contours}
|
||
return output
|
||
|
||
|
||
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 open line/arc/B-spline source-wire path contract."""
|
||
selection = _direct_sketch_wire_selection(
|
||
value, sketch_by_source, entity_by_sketch,
|
||
featurescript_version=featurescript_version,
|
||
standard_library=standard_library,
|
||
standard_library_version=standard_library_version,
|
||
)
|
||
if selection is None:
|
||
return None
|
||
source, candidates, sketch = selection
|
||
if len(candidates) != 1:
|
||
return None
|
||
entity_id, entity = candidates[0]
|
||
# A sketch circle has coincident endpoints and is never an unambiguous
|
||
# open sweep spine. A source ``skArc`` carries its exact directed arc
|
||
# data, so it is safe to retain under the same singleton query gate.
|
||
if entity.get("type") not in {"line", "arc", "bspline"}:
|
||
return None
|
||
return source, entity_id, entity, sketch
|
||
|
||
|
||
def _direct_sketch_circle_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 closed source-circle wire without using a parsed leaf.
|
||
|
||
A direct ``sQuery`` circle follows the legacy singleton path. This helper
|
||
is deliberately for the distinct ``qBodyType(qCreatedBy(..., EDGE),
|
||
WIRE)`` source-wire form, whose complete query result has already been
|
||
checked by ``_direct_sketch_wire_selection``. A circle cannot enter the
|
||
open line/arc/B-spline helper: it has no endpoint roles.
|
||
"""
|
||
selection = _direct_sketch_wire_selection(
|
||
value, sketch_by_source, entity_by_sketch,
|
||
featurescript_version=featurescript_version,
|
||
standard_library=standard_library,
|
||
standard_library_version=standard_library_version,
|
||
allowed_curve_types=frozenset({"circle"}),
|
||
)
|
||
if selection is None:
|
||
return None
|
||
source, candidates, sketch = selection
|
||
if len(candidates) != 1:
|
||
return None
|
||
entity_id, entity = candidates[0]
|
||
if entity.get("type") != "circle":
|
||
return None
|
||
return source, entity_id, entity, sketch
|
||
|
||
|
||
def _direct_segmented_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, list[dict[str, Any]], dict[str, Any]] | None:
|
||
"""Resolve one source-only connected, non-branching, open path wire.
|
||
|
||
Ordering uses only exact direct source endpoints. It rejects closed,
|
||
disconnected, branching, degenerate, and construction-ambiguous source
|
||
sets rather than letting OCC pick a wire or infer an orientation.
|
||
"""
|
||
selection = _direct_sketch_wire_selection(
|
||
value, sketch_by_source, entity_by_sketch,
|
||
featurescript_version=featurescript_version,
|
||
standard_library=standard_library,
|
||
standard_library_version=standard_library_version,
|
||
)
|
||
if selection is None:
|
||
return None
|
||
source, candidates, sketch = selection
|
||
if len(candidates) < 2:
|
||
return None
|
||
records: list[tuple[str, dict[str, Any], list[float], list[float]]] = []
|
||
for entity_id, entity in candidates:
|
||
start, end = _sweep_path_endpoint(entity, "start"), _sweep_path_endpoint(entity, "end")
|
||
if start is None or end is None or _same_sweep_path_point(start, end):
|
||
return None
|
||
records.append((entity_id, entity, start, end))
|
||
|
||
def incident_count(point: list[float]) -> int:
|
||
return sum(
|
||
int(_same_sweep_path_point(point, start)) + int(_same_sweep_path_point(point, end))
|
||
for _entity_id, _entity, start, end in records
|
||
)
|
||
|
||
terminals = [
|
||
(index, endpoint)
|
||
for index, (_entity_id, _entity, start, end) in enumerate(records)
|
||
for endpoint in (start, end)
|
||
if incident_count(endpoint) == 1
|
||
]
|
||
if len(terminals) != 2:
|
||
return None
|
||
_start_index, current = terminals[0]
|
||
ordered: list[dict[str, Any]] = []
|
||
remaining = set(range(len(records)))
|
||
while remaining:
|
||
matching = [
|
||
index for index in remaining
|
||
if _same_sweep_path_point(current, records[index][2]) or _same_sweep_path_point(current, records[index][3])
|
||
]
|
||
if len(matching) != 1:
|
||
return None
|
||
index = matching[0]
|
||
entity_id, entity, start, end = records[index]
|
||
if _same_sweep_path_point(current, start):
|
||
segment = deepcopy(entity)
|
||
current = end
|
||
elif _same_sweep_path_point(current, end):
|
||
segment = _reversed_sweep_path_segment(entity)
|
||
current = start
|
||
else: # pragma: no cover - guarded by matching above
|
||
return None
|
||
segment["source_entity_id"] = entity_id
|
||
ordered.append(segment)
|
||
remaining.remove(index)
|
||
if incident_count(current) != 1:
|
||
return None
|
||
return source, ordered, sketch
|
||
|
||
|
||
def _spatial_sweep_path_segment(
|
||
entity_id: str,
|
||
entity: dict[str, Any],
|
||
sketch: dict[str, Any],
|
||
source: str,
|
||
) -> tuple[dict[str, Any], list[float], list[float]] | None:
|
||
"""Capture one direct source sketch curve in its explicit global frame."""
|
||
plane = sketch.get("workplane")
|
||
if not isinstance(plane, dict):
|
||
return None
|
||
kind = entity.get("type")
|
||
if kind not in {"line", "arc", "bspline"}:
|
||
return None
|
||
|
||
def point(value: Any) -> list[float] | None:
|
||
if not isinstance(value, list) or len(value) != 2 or not all(isinstance(component, (int, float)) and math.isfinite(float(component)) for component in value):
|
||
return None
|
||
try:
|
||
return _global(plane, [float(value[0]), float(value[1])])
|
||
except (KeyError, TypeError, ValueError):
|
||
return None
|
||
|
||
def vector(value: Any) -> list[float] | None:
|
||
if not isinstance(value, list) or len(value) != 2 or not all(isinstance(component, (int, float)) and math.isfinite(float(component)) for component in value):
|
||
return None
|
||
try:
|
||
y_dir = _y_dir(plane)
|
||
return [plane["x_dir"][index] * float(value[0]) + y_dir[index] * float(value[1]) for index in range(3)]
|
||
except (KeyError, TypeError, ValueError):
|
||
return None
|
||
|
||
output: dict[str, Any] = {
|
||
"type": kind,
|
||
"source_sketch_id": source,
|
||
"source_entity_id": entity_id,
|
||
}
|
||
if kind == "bspline":
|
||
values = entity.get("points")
|
||
if not isinstance(values, list) or len(values) < 2:
|
||
return None
|
||
points = [point(value) for value in values]
|
||
if any(value is None for value in points) or entity.get("periodic"):
|
||
return None
|
||
output["points_mm"] = points
|
||
parameters = entity.get("parameters")
|
||
if parameters is not None:
|
||
if not isinstance(parameters, list) or len(parameters) != len(points):
|
||
return None
|
||
try:
|
||
output["parameters"] = [float(value) for value in parameters]
|
||
except (TypeError, ValueError):
|
||
return None
|
||
start_tangent = entity.get("start_tangent")
|
||
end_tangent = entity.get("end_tangent")
|
||
if (start_tangent is None) != (end_tangent is None):
|
||
return None
|
||
if start_tangent is not None:
|
||
start_vector, end_vector = vector(start_tangent), vector(end_tangent)
|
||
if start_vector is None or end_vector is None:
|
||
return None
|
||
output["start_tangent_mm"] = start_vector
|
||
output["end_tangent_mm"] = end_vector
|
||
if len(points) == 2 and start_tangent is None:
|
||
return None
|
||
return output, points[0], points[-1]
|
||
|
||
start, end = point(entity.get("start")), point(entity.get("end"))
|
||
if start is None or end is None:
|
||
return None
|
||
output["start_mm"] = start
|
||
output["end_mm"] = end
|
||
if kind == "arc":
|
||
center = point(entity.get("center"))
|
||
radius = entity.get("radius_mm")
|
||
normal = plane.get("normal")
|
||
if (
|
||
center is None
|
||
or not isinstance(radius, (int, float))
|
||
or not math.isfinite(float(radius))
|
||
or float(radius) <= 0
|
||
or not isinstance(normal, list)
|
||
or len(normal) != 3
|
||
or not all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in normal)
|
||
):
|
||
return None
|
||
output["center_mm"] = center
|
||
output["normal"] = [float(value) for value in normal]
|
||
output["radius_mm"] = float(radius)
|
||
output["clockwise"] = bool(entity.get("clockwise", False))
|
||
return output, start, end
|
||
|
||
|
||
def _direct_spatial_segmented_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,
|
||
) -> list[dict[str, Any]] | None:
|
||
"""Resolve a direct union of source sketch wires into one global open wire.
|
||
|
||
This remains source-only. Each outer operand must be the exact versioned
|
||
wire query accepted by ``_direct_sketch_wire_selection``. Curves are
|
||
carried through their explicit sketch frames and may join only at exact
|
||
global source endpoints; no datum/topology/result geometry is consulted.
|
||
"""
|
||
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]) < 2
|
||
):
|
||
return None
|
||
records: list[tuple[dict[str, Any], list[float], list[float]]] = []
|
||
source_ids: set[tuple[str, str]] = set()
|
||
for operand in value.args[0]:
|
||
selection = _direct_sketch_wire_selection(
|
||
Call("qUnion", [[operand]]), sketch_by_source, entity_by_sketch,
|
||
featurescript_version=featurescript_version,
|
||
standard_library=standard_library,
|
||
standard_library_version=standard_library_version,
|
||
)
|
||
if selection is None:
|
||
return None
|
||
source, candidates, sketch = selection
|
||
for entity_id, entity in candidates:
|
||
key = (source, entity_id)
|
||
if key in source_ids:
|
||
return None
|
||
source_ids.add(key)
|
||
captured = _spatial_sweep_path_segment(entity_id, entity, sketch, source)
|
||
if captured is None:
|
||
return None
|
||
segment, start, end = captured
|
||
if _same_point(start, end):
|
||
return None
|
||
records.append((segment, start, end))
|
||
if len(records) < 2:
|
||
return None
|
||
|
||
def incident_count(point: list[float]) -> int:
|
||
return sum(
|
||
int(_same_point(point, start)) + int(_same_point(point, end))
|
||
for _segment, start, end in records
|
||
)
|
||
|
||
terminals = [
|
||
endpoint
|
||
for _segment, start, end in records
|
||
for endpoint in (start, end)
|
||
if incident_count(endpoint) == 1
|
||
]
|
||
if len(terminals) != 2:
|
||
return None
|
||
current = terminals[0]
|
||
remaining = set(range(len(records)))
|
||
ordered: list[dict[str, Any]] = []
|
||
while remaining:
|
||
matching = [
|
||
index for index in remaining
|
||
if _same_point(current, records[index][1]) or _same_point(current, records[index][2])
|
||
]
|
||
if len(matching) != 1:
|
||
return None
|
||
index = matching[0]
|
||
segment, start, end = records[index]
|
||
if _same_point(current, start):
|
||
current = end
|
||
elif _same_point(current, end):
|
||
segment = _reversed_spatial_sweep_path_segment(segment)
|
||
current = start
|
||
else: # pragma: no cover - guarded by matching above
|
||
return None
|
||
ordered.append(segment)
|
||
remaining.remove(index)
|
||
return ordered if incident_count(current) == 1 else None
|
||
|
||
|
||
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},
|
||
)
|
||
source_member_aliases = {
|
||
str(alias.get("source_feature_id")): str(alias.get("active_member_feature_id"))
|
||
for alias in params.get("source_member_aliases") or ()
|
||
if isinstance(alias, dict)
|
||
and isinstance(alias.get("source_feature_id"), str)
|
||
and isinstance(alias.get("active_member_feature_id"), str)
|
||
}
|
||
# A transform COPY preserves the FeatureScript owner of its source body
|
||
# even when an earlier proven single-body successor supplies the current
|
||
# runtime member. The explicit mapping is source provenance, not an
|
||
# instruction to select a different body or a current-aggregate fallback.
|
||
if len(source_ids) == 1 and (
|
||
source_ids == [upstream]
|
||
or source_member_aliases == {upstream: source_ids[0]}
|
||
):
|
||
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_terminal_source(value: Any) -> str | None:
|
||
"""Return the original direct body owner at one COPY chain's root."""
|
||
try:
|
||
_call, owner, topology, kind, definition = _direct_make_query(value)
|
||
except ValueError:
|
||
return None
|
||
if kind not in {"body", "entitytype.body"}:
|
||
return None
|
||
if topology == "SWEPT_BODY":
|
||
return f"f_{owner}"
|
||
if topology != "COPY" or definition.get("derivedFrom") is None:
|
||
return None
|
||
return _transform_copy_terminal_source(definition["derivedFrom"])
|
||
|
||
|
||
def _transform_source_member_aliases(
|
||
value: Any,
|
||
sources: list[str],
|
||
) -> list[dict[str, str]]:
|
||
"""Bind original transform query owners to their proved runtime members.
|
||
|
||
This is only emitted for one-to-one direct body references. Pattern and
|
||
multi-source COPY references already have their own instance-qualified
|
||
contracts, so they must not be collapsed into this lifecycle mapping.
|
||
"""
|
||
query_values = _queries(value)
|
||
if len(sources) != 1 or len(query_values) != 1:
|
||
return []
|
||
aliases: list[dict[str, str]] = []
|
||
for query_value, active_member in zip(query_values, sources):
|
||
semantic_source = _transform_copy_terminal_source(query_value)
|
||
if semantic_source is None or semantic_source == active_member:
|
||
continue
|
||
alias = {
|
||
"source_feature_id": semantic_source,
|
||
"active_member_feature_id": active_member,
|
||
}
|
||
if alias not in aliases:
|
||
aliases.append(alias)
|
||
return aliases
|
||
|
||
|
||
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 _initial_direct_loft_cap_output_roles(
|
||
params: dict[str, Any],
|
||
sources: list[str],
|
||
features: list[dict[str, Any]],
|
||
featurescript_version: str | None,
|
||
) -> bool:
|
||
"""Whether an initial direct loft exposes exact endpoint CAP roles."""
|
||
if featurescript_version != "1511" or _has_active_body(features) or len(sources) != 2 or len(set(sources)) != 2:
|
||
return False
|
||
operation = str(params.get("operationType") or "NEW").rsplit(".", 1)[-1].upper()
|
||
if operation != "NEW":
|
||
return False
|
||
if any(params.get(key) not in (None, [], {}, False) for key in (
|
||
"wireProfilesArray", "connections", "matchConnections", "startCondition",
|
||
"endCondition", "startMagnitude", "endMagnitude",
|
||
)):
|
||
return False
|
||
profiles = params.get("sheetProfilesArray")
|
||
if not isinstance(profiles, list) or len(profiles) != 2:
|
||
return False
|
||
for source, profile in zip(sources, profiles):
|
||
value = profile.get("sheetProfileEntities") if isinstance(profile, dict) else None
|
||
try:
|
||
_call, owner, topology, kind, _definition = _direct_make_query(value)
|
||
except ValueError:
|
||
return False
|
||
if owner != source or topology != "IMPRINT" or kind != "face":
|
||
return False
|
||
return True
|
||
|
||
|
||
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") == "multi_source_regions":
|
||
return bool(profile.get("source_sketch_ids")) and all(
|
||
_profile_executable({"profile": child})
|
||
for child in profile.get("profiles") or []
|
||
)
|
||
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 _contour_line_interior_normal(
|
||
contour: dict[str, Any],
|
||
entity: dict[str, Any],
|
||
source_plane: dict[str, Any],
|
||
) -> list[float] | None:
|
||
"""Return the source-proven interior side of one closed contour line.
|
||
|
||
A vertex average is not an interior witness: on a valid asymmetric
|
||
polygon it can lie on the selected boundary line. The ordered contour's
|
||
signed area instead gives the exact left/right interior side for the
|
||
matched source line, without inspecting any resulting B-rep geometry.
|
||
"""
|
||
segments = contour.get("segments") or []
|
||
if not contour.get("closed") or entity.get("type") != "line" or not segments:
|
||
return None
|
||
matches = [segment for segment in segments if _matching_profile_segment(segment, entity)]
|
||
if len(matches) != 1:
|
||
return None
|
||
try:
|
||
area = _contour_area(segments)
|
||
if not math.isfinite(area) or abs(area) <= 1e-9:
|
||
return None
|
||
segment = matches[0]
|
||
start = _global(source_plane, segment["start"])
|
||
end = _global(source_plane, segment["end"])
|
||
traversal = _unit(_sub(end, start), "swept face source line is degenerate")
|
||
left = _cross(source_plane["normal"], traversal)
|
||
orientation = 1.0 if area > 0 else -1.0
|
||
return _unit([orientation * value for value in left], "swept face source contour is degenerate")
|
||
except (KeyError, TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
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)
|
||
interior_normal = _contour_line_interior_normal(contour, entity, source_plane) if isinstance(contour, dict) else None
|
||
if interior_normal is not None:
|
||
normal = [-value for value in interior_normal]
|
||
else:
|
||
# Keep the existing fallback for source contours without one
|
||
# complete ordered line-loop witness.
|
||
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:
|
||
# A new body does not mutate the preceding member. Existing
|
||
# aliases still identify that member exactly (for example a
|
||
# later SWEPT_BODY query of a shell's original direct-prism
|
||
# owner), even though there is no longer one aggregate body
|
||
# through which a future ordinary ADD may advance them.
|
||
# Detach the single-body state without erasing those proven
|
||
# member aliases.
|
||
state["owner"] = None
|
||
state["sources"] = set()
|
||
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)
|
||
cpoint_frame = feature_frames.get(info.owner_feature or "")
|
||
if (
|
||
info.topology_type is None
|
||
and info.kind in {"vertex", "entitytype.vertex"}
|
||
and "qCreatedBy" in info.calls
|
||
and isinstance(cpoint_frame, dict)
|
||
and isinstance(cpoint_frame.get("point_mm"), list)
|
||
):
|
||
return list(cpoint_frame["point_mm"])
|
||
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_prism_line_angle_swept_edge_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]]],
|
||
*,
|
||
previous: list[str],
|
||
featurescript_version: str | None,
|
||
) -> tuple[list[float], list[float]] | None:
|
||
"""Return a source-determined datum axis for one direct prism SWEPT_EDGE.
|
||
|
||
This is deliberately a datum calculation, not a topology resolver. A
|
||
direct blind prism turns one explicitly identified source-profile vertex
|
||
into the line connecting its two cap copies. The producer frame records
|
||
that its profile was unchanged, so the two source curves and the prism
|
||
span determine that line without asking the current body for an edge.
|
||
"""
|
||
if featurescript_version != "1511":
|
||
return None
|
||
try:
|
||
_call, owner, topology, kind, _definition = _direct_make_query(query)
|
||
except ValueError:
|
||
return None
|
||
producer_id = f"f_{owner}"
|
||
frame = feature_frames.get(owner) or {}
|
||
profile_source = frame.get("profile_source")
|
||
if (
|
||
topology != "SWEPT_EDGE"
|
||
or kind not in {"edge", "entitytype.edge"}
|
||
or previous[-1:] != [producer_id]
|
||
or frame.get("direct_prism_line_angle_axis") is not True
|
||
or not isinstance(profile_source, str)
|
||
or profile_source not in sketch_by_source
|
||
or not isinstance(frame.get("profile"), dict)
|
||
or not isinstance(frame.get("start"), dict)
|
||
or not isinstance(frame.get("end"), dict)
|
||
):
|
||
return None
|
||
refs = _source_refs(query)
|
||
if len(refs) != 2 or {source for source, _token in refs} != {profile_source}:
|
||
return None
|
||
source_ids = set(frame.get("direct_prism_source_entity_ids") or ())
|
||
resolved_ids: set[str] = set()
|
||
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
|
||
resolved_ids.add(entity_id)
|
||
if len(resolved_ids) != 2:
|
||
return None
|
||
local = _shared_source_endpoint(refs, profile_source, entity_by_sketch)
|
||
if local is None:
|
||
return None
|
||
try:
|
||
source_point = _global(sketch_by_source[profile_source]["workplane"], local)
|
||
profile_origin = frame["profile"]["origin_mm"]
|
||
start_origin = frame["start"]["origin_mm"]
|
||
end_origin = frame["end"]["origin_mm"]
|
||
start = [source_point[index] + start_origin[index] - profile_origin[index] for index in range(3)]
|
||
direction = _unit(_sub(end_origin, start_origin), "line-angle swept-edge axis is degenerate")
|
||
except (KeyError, TypeError, ValueError):
|
||
return None
|
||
if not all(math.isfinite(component) for component in start + direction):
|
||
return None
|
||
return start, direction
|
||
|
||
|
||
def _direct_line_angle_wire_axis(
|
||
query: 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[list[float], list[float]] | None:
|
||
"""Return one exact source-wire line axis for ``LINE_ANGLE``.
|
||
|
||
This interprets only one direct ``qBodyType(qCreatedBy(sketch, EDGE),
|
||
WIRE)`` source query. It does not expose a runtime ``qBodyType`` selector:
|
||
one line must be named from one source sketch, with no derived topology or
|
||
current-body information. Construction lines are valid datum axes, unlike
|
||
sweep paths.
|
||
"""
|
||
selection = _direct_sketch_wire_selection(
|
||
Call("qUnion", [[query]]), sketch_by_source, entity_by_sketch,
|
||
featurescript_version=featurescript_version,
|
||
standard_library=standard_library,
|
||
standard_library_version=standard_library_version,
|
||
permit_construction=True,
|
||
)
|
||
if selection is None:
|
||
return None
|
||
_source, candidates, sketch = selection
|
||
if len(candidates) != 1 or candidates[0][1].get("type") != "line":
|
||
return None
|
||
_entity_id, entity = candidates[0]
|
||
start, end = _entity_line(entity, sketch["workplane"])
|
||
return start, _unit(_sub(end, start), "line-angle reference axis is degenerate")
|
||
|
||
|
||
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,
|
||
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] | None:
|
||
"""Resolve one explicit datum point or direct sketch point/endpoint."""
|
||
info = parse_query(query)
|
||
cpoint_frame = feature_frames.get(info.owner_feature or "")
|
||
if (
|
||
info.topology_type is None
|
||
and info.kind in {"vertex", "entitytype.vertex"}
|
||
and "qCreatedBy" in info.calls
|
||
and isinstance(cpoint_frame, dict)
|
||
and isinstance(cpoint_frame.get("point_mm"), list)
|
||
):
|
||
return list(cpoint_frame["point_mm"])
|
||
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]]],
|
||
*,
|
||
previous: list[str],
|
||
featurescript_version: str | None,
|
||
standard_library: str | None,
|
||
standard_library_version: str | None,
|
||
) -> 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)
|
||
or _direct_prism_line_angle_swept_edge_axis(
|
||
entities[0], feature_frames, sketch_by_source, entity_by_sketch,
|
||
previous=previous, featurescript_version=featurescript_version,
|
||
)
|
||
or _direct_line_angle_wire_axis(
|
||
entities[0], sketch_by_source, entity_by_sketch,
|
||
featurescript_version=featurescript_version,
|
||
standard_library=standard_library,
|
||
standard_library_version=standard_library_version,
|
||
)
|
||
)
|
||
second_axis = (
|
||
_direct_line_angle_axis(entities[1], sketch_by_source, entity_by_sketch)
|
||
or _direct_prism_line_angle_swept_edge_axis(
|
||
entities[1], feature_frames, sketch_by_source, entity_by_sketch,
|
||
previous=previous, featurescript_version=featurescript_version,
|
||
)
|
||
or _direct_line_angle_wire_axis(
|
||
entities[1], sketch_by_source, entity_by_sketch,
|
||
featurescript_version=featurescript_version,
|
||
standard_library=standard_library,
|
||
standard_library_version=standard_library_version,
|
||
)
|
||
)
|
||
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, feature_frames, 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 _cpoint_parameter(params: dict[str, Any]) -> float:
|
||
"""Read the finite source-line parameter shared by every datum form."""
|
||
parameter = _number(params.get("parameter"))
|
||
if not math.isfinite(parameter) or not 0.0 <= parameter <= 1.0:
|
||
raise UnsupportedCapability("reference_point", "cPoint parameter must be a finite value in [0, 1]")
|
||
return parameter
|
||
|
||
|
||
def _direct_source_line_cpoint(
|
||
params: dict[str, Any],
|
||
sketch_by_source: dict[str, dict[str, Any]],
|
||
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
|
||
) -> list[float]:
|
||
"""Evaluate one `cPoint` on a direct source-sketch line.
|
||
|
||
This is source datum geometry, not a runtime edge selector. FeatureScript
|
||
parameterizes a line segment affinely, so the source endpoint coordinates
|
||
and a finite unit-interval parameter completely determine the point.
|
||
Derived CAP/SWEPT edges and curved source entities need their own contracts.
|
||
"""
|
||
entities = _queries(params.get("entities"))
|
||
if len(entities) != 1:
|
||
raise UnsupportedCapability("reference_point", "cPoint requires exactly one direct source line")
|
||
query = parse_query(entities[0])
|
||
if (
|
||
query.topology_type is not None
|
||
or query.kind not in {"edge", "entitytype.edge"}
|
||
or not isinstance(query.source_sketch, str)
|
||
or not isinstance(query.source_entity, str)
|
||
):
|
||
raise UnsupportedCapability("reference_point", "cPoint source must be one direct sketch line")
|
||
resolved = _source_ref_entity(query.source_sketch, query.source_entity, entity_by_sketch)
|
||
sketch = sketch_by_source.get(query.source_sketch)
|
||
if resolved is None or sketch is None:
|
||
raise ValueError("cPoint source line is unresolved")
|
||
entity_id, entity = resolved
|
||
if entity_id != query.source_entity or entity.get("type") != "line":
|
||
raise UnsupportedCapability("reference_point", "cPoint only supports an original unsuffixed source line")
|
||
parameter = _cpoint_parameter(params)
|
||
start, end = _entity_line(entity, sketch["workplane"])
|
||
return [start[index] + parameter * (end[index] - start[index]) for index in range(3)]
|
||
|
||
|
||
def _direct_prism_swept_edge_cpoint(
|
||
value: 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]]],
|
||
*,
|
||
previous: list[str],
|
||
featurescript_version: str | None,
|
||
) -> list[float] | None:
|
||
"""Evaluate a source-defined direct-prism SWEPT_EDGE datum point.
|
||
|
||
The LINE_ANGLE datum bridge proves the source vertex and prism span.
|
||
cPoint interpolates that source-defined span; it never resolves a runtime
|
||
edge or inspects the resulting body.
|
||
"""
|
||
try:
|
||
_call, owner, topology, _kind, _definition = _direct_make_query(value)
|
||
except ValueError:
|
||
return None
|
||
if topology != "SWEPT_EDGE":
|
||
return None
|
||
axis = _direct_prism_line_angle_swept_edge_axis(
|
||
value, feature_frames, sketch_by_source, entity_by_sketch,
|
||
previous=previous, featurescript_version=featurescript_version,
|
||
)
|
||
frame = feature_frames.get(owner) or {}
|
||
start_frame, end_frame = frame.get("start"), frame.get("end")
|
||
if (
|
||
axis is None
|
||
or not isinstance(start_frame, dict)
|
||
or not isinstance(end_frame, dict)
|
||
or not isinstance(start_frame.get("origin_mm"), list)
|
||
or not isinstance(end_frame.get("origin_mm"), list)
|
||
):
|
||
return None
|
||
parameter = _cpoint_parameter(params)
|
||
start, _direction = axis
|
||
span = _sub(end_frame["origin_mm"], start_frame["origin_mm"])
|
||
return [start[index] + parameter * span[index] for index in range(3)]
|
||
|
||
|
||
def _direct_prism_cap_edge_cpoint(
|
||
value: Any,
|
||
params: dict[str, 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,
|
||
) -> list[float] | None:
|
||
"""Evaluate one direct-prism CAP_EDGE datum from its source line and role.
|
||
|
||
Source datum points remain valid through earlier cPoint/reference-plane
|
||
features, but no body-mutating feature may intervene. The cap is not
|
||
looked up from the current B-rep.
|
||
"""
|
||
if featurescript_version != "1511":
|
||
return None
|
||
try:
|
||
_call, owner, topology, kind, _definition = _direct_make_query(value)
|
||
except ValueError:
|
||
return None
|
||
query = parse_query(value)
|
||
producer_id = f"f_{owner}"
|
||
producer = feature_by_id.get(producer_id) or {}
|
||
producer_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
|
||
if producer_id not in previous:
|
||
return None
|
||
producer_index = previous.index(producer_id)
|
||
intervening = previous[producer_index + 1:]
|
||
if (
|
||
topology != "CAP_EDGE"
|
||
or kind not in {"edge", "entitytype.edge"}
|
||
or query.is_start is None
|
||
or any((feature_by_id.get(feature_id) or {}).get("atomic_id") not in {"reference_plane", "reference_point"} for feature_id in intervening)
|
||
or producer.get("atomic_id") != "extrude_add_blind"
|
||
or producer_params.get("result_mode") != "new_body"
|
||
or (producer_params.get("end_condition") or {}).get("type") != "blind"
|
||
or producer_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
|
||
refs = _source_refs(value)
|
||
if len(refs) != 1 or refs[0][0] != profile_source or query.source_entity != refs[0][1]:
|
||
return None
|
||
resolved = _source_ref_entity(profile_source, refs[0][1], entity_by_sketch)
|
||
source_ids = _direct_profile_source_entity_ids(profile_sketch)
|
||
if resolved is None:
|
||
return None
|
||
entity_id, entity = resolved
|
||
if (
|
||
entity_id != refs[0][1]
|
||
or entity_id not in source_ids
|
||
or entity.get("construction")
|
||
or entity.get("type") != "line"
|
||
):
|
||
return None
|
||
cap = frame.get("start" if query.is_start else "end")
|
||
profile = frame.get("profile")
|
||
if (
|
||
not isinstance(cap, dict)
|
||
or not isinstance(profile, dict)
|
||
or not isinstance(cap.get("origin_mm"), list)
|
||
or not isinstance(profile.get("origin_mm"), list)
|
||
):
|
||
return None
|
||
parameter = _cpoint_parameter(params)
|
||
start, end = _entity_line(entity, source_sketch["workplane"])
|
||
source_point = [start[index] + parameter * (end[index] - start[index]) for index in range(3)]
|
||
return [source_point[index] + cap["origin_mm"][index] - profile["origin_mm"][index] for index in range(3)]
|
||
|
||
|
||
def _cpoint_datum(
|
||
params: dict[str, 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,
|
||
) -> list[float]:
|
||
"""Select the only source-datum cPoint forms presently proven by source."""
|
||
entities = _queries(params.get("entities"))
|
||
if len(entities) != 1:
|
||
raise UnsupportedCapability("reference_point", "cPoint requires exactly one direct source line")
|
||
query = entities[0]
|
||
topology = parse_query(query).topology_type
|
||
if topology is None:
|
||
return _direct_source_line_cpoint(params, sketch_by_source, entity_by_sketch)
|
||
point = (
|
||
_direct_prism_swept_edge_cpoint(
|
||
query, params, feature_frames, sketch_by_source, entity_by_sketch,
|
||
previous=previous, featurescript_version=featurescript_version,
|
||
)
|
||
if topology == "SWEPT_EDGE" else _direct_prism_cap_edge_cpoint(
|
||
query, params,
|
||
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 topology == "CAP_EDGE" else None
|
||
)
|
||
if point is None:
|
||
raise UnsupportedCapability("reference_point", "cPoint datum source is unsupported")
|
||
return point
|
||
|
||
|
||
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]]],
|
||
*,
|
||
feature_by_id: dict[str, dict[str, Any]],
|
||
sketches_by_id: dict[str, dict[str, Any]],
|
||
previous: list[str],
|
||
featurescript_version: str | None = None,
|
||
standard_library: str | None = None,
|
||
standard_library_version: str | None = None,
|
||
) -> dict[str, Any]:
|
||
plane_type = str(params.get("cplaneType") or "OFFSET").split(".")[-1].upper()
|
||
entities = _queries(params.get("entities"))
|
||
|
||
def datum_point(query: Any) -> list[float]:
|
||
# The origin point is an explicit system datum. A CAP_VERTEX is not:
|
||
# it must satisfy the direct source-prism contract above rather than
|
||
# falling through to the legacy frame-based point reconstruction.
|
||
if any(
|
||
call.name == "qCreatedBy" and call.args
|
||
and "Origin.pointOp" in symbolic_string(call.args[0])
|
||
for call in walk_calls(query)
|
||
):
|
||
return [0.0, 0.0, 0.0]
|
||
if parse_query(query).topology_type == "CAP_VERTEX":
|
||
point = _direct_prism_cap_vertex_datum_point(
|
||
query,
|
||
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 point is None:
|
||
raise ValueError("CAP_VERTEX datum source is unsupported")
|
||
return point
|
||
return _query_point(query, feature_frames, sketch_by_source, entity_by_sketch)
|
||
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,
|
||
) or _direct_prism_line_angle_swept_edge_axis(
|
||
entities[0], feature_frames, sketch_by_source, entity_by_sketch,
|
||
previous=previous, featurescript_version=featurescript_version,
|
||
) or _direct_line_angle_wire_axis(
|
||
entities[0], sketch_by_source, entity_by_sketch,
|
||
featurescript_version=featurescript_version,
|
||
standard_library=standard_library,
|
||
standard_library_version=standard_library_version,
|
||
)
|
||
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,
|
||
previous=previous,
|
||
featurescript_version=featurescript_version,
|
||
standard_library=standard_library,
|
||
standard_library_version=standard_library_version,
|
||
)
|
||
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":
|
||
# A PLANE_POINT definition is typed source data: one FACE plane and
|
||
# one VERTEX point. Do not infer their roles from qCreatedBy text;
|
||
# a direct CAP_FACE is equally a valid plane query and must retain
|
||
# that source topology rather than falling back to a runtime face.
|
||
face_queries = [
|
||
item for item in entities
|
||
if (parse_query(item).kind or "").lower() in {"face", "entitytype.face"}
|
||
]
|
||
point_queries = [
|
||
item for item in entities
|
||
if (parse_query(item).kind or "").lower() in {"vertex", "entitytype.vertex"}
|
||
]
|
||
if len(entities) != 2 or len(face_queries) != 1 or len(point_queries) != 1:
|
||
raise ValueError("plane-point requires exactly one face and one vertex")
|
||
base_query, point_query = face_queries[0], point_queries[0]
|
||
base = _query_plane(base_query, feature_frames, sketch_by_source, entity_by_sketch)
|
||
return _frame(datum_point(point_query), 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 = [datum_point(item) 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]],
|
||
sketches_by_id: dict[str, dict[str, Any]],
|
||
previous: list[str],
|
||
mirror_source_ids: list[str],
|
||
featurescript_version: str | None,
|
||
) -> 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. A direct independent blind prism similarly has a
|
||
physical cap or one source-line side plane while it is still the immediate
|
||
mirror source. Those faces can materialize a CDSL datum plane without
|
||
resolving a runtime topology selector. Curved, partial, derived, mutated,
|
||
or unrelated faces remain unresolved instead of becoming guessed planes.
|
||
"""
|
||
plane = _default_plane(value)
|
||
if plane is not None:
|
||
return plane
|
||
try:
|
||
_call, owner, topology, kind, _definition = _direct_make_query(value)
|
||
except ValueError:
|
||
return None
|
||
if kind not in {"face", "entitytype.face"}:
|
||
return None
|
||
producer_id = f"f_{owner}"
|
||
producer = feature_by_id.get(producer_id) or {}
|
||
frame = feature_frames.get(owner) or {}
|
||
|
||
if topology == "SWEPT_FACE" and (
|
||
producer.get("atomic_id") == "revolve_add"
|
||
and frame.get("revolve_full")
|
||
and isinstance(frame.get("revolve_axis"), dict)
|
||
):
|
||
try:
|
||
return _query_plane(value, feature_frames, sketch_by_source, entity_by_sketch)
|
||
except (UnsupportedCapability, ValueError):
|
||
return None
|
||
|
||
# A CAP/SWEPT source can only be a static mirror datum while its physical
|
||
# direct-prism boundary is current and the mirror is operating on that
|
||
# same independently selectable body. Do not reuse a frame after a
|
||
# dress-up, Boolean, copy, or any other lifecycle transition.
|
||
params = producer.get("params") or {}
|
||
if (
|
||
featurescript_version != "1511"
|
||
or topology not in {"CAP_FACE", "SWEPT_FACE"}
|
||
or producer_id not in mirror_source_ids
|
||
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
|
||
try:
|
||
if topology == "CAP_FACE":
|
||
if _cap_face_output_role_selector(value, feature_by_id, sketches_by_id) is None:
|
||
return None
|
||
elif _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,
|
||
) is None:
|
||
return None
|
||
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]] = {}; source_variables: 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] = {}
|
||
sketch_workplanes = {step.feature_id: step.workplane for step in model.sketches}
|
||
# 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:
|
||
attachment = _direct_prism_cap_face_attachment(
|
||
step.workplane,
|
||
feature_by_id=feature_by_id,
|
||
sketches_by_id=sketches_by_id,
|
||
previous=previous,
|
||
featurescript_version=model.featurescript_version,
|
||
)
|
||
if attachment is not None:
|
||
# CAP faces are native builder results. The exact plane is
|
||
# resolved only when the consuming feature executes.
|
||
lowered, entities = _lower_sketch(step, PLANES["Top"])
|
||
lowered["attachment"] = attachment
|
||
sketches.append(lowered); sketches_by_id[lowered["id"]] = lowered; sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities
|
||
continue
|
||
attachment = _direct_primary_cut_copy_cap_face_attachment(
|
||
step.workplane,
|
||
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 attachment is not None:
|
||
# This placeholder must never be consumed: attached
|
||
# sketches remain local through preflight and are
|
||
# materialized from the exact active face at execution.
|
||
lowered, entities = _lower_sketch(step, PLANES["Top"])
|
||
lowered["attachment"] = attachment
|
||
sketches.append(lowered); sketches_by_id[lowered["id"]] = lowered; sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities
|
||
continue
|
||
attachment = _direct_primary_cut_copy_swept_face_attachment(
|
||
step.workplane,
|
||
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 attachment is not None:
|
||
# The swept tool face has no static source frame. It is
|
||
# resolved through the native prism and subtract history
|
||
# only when the immediate consumer executes.
|
||
lowered, entities = _lower_sketch(step, PLANES["Top"])
|
||
lowered["attachment"] = attachment
|
||
sketches.append(lowered); sketches_by_id[lowered["id"]] = lowered; sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities
|
||
continue
|
||
attachment = _direct_prism_blend_face_attachment(
|
||
step.workplane,
|
||
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 attachment is not None:
|
||
# BLEND_FACE is a dress-up patch. Its physical plane and
|
||
# native orientation are available only from the exact
|
||
# runtime ``Generated(edge -> face)`` relation.
|
||
lowered, entities = _lower_sketch(step, PLANES["Top"])
|
||
lowered["attachment"] = attachment
|
||
sketches.append(lowered); sketches_by_id[lowered["id"]] = lowered; sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities
|
||
continue
|
||
# The outer query, rather than recursively parsed diagnostics,
|
||
# determines whether a static SWEPT_FACE frame is admissible.
|
||
# In particular MERGE(FACE) often contains SWEPT_FACE inputs
|
||
# but is a distinct result that must be resolved at runtime.
|
||
try:
|
||
_outer, _owner, outer_topology, _outer_kind, _definition = _direct_make_query(step.workplane)
|
||
except ValueError:
|
||
outer_topology = None
|
||
swept_face = outer_topology == "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 or item.operation not in LOWERABLE_OPERATIONS:
|
||
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 = _resolve_source_variables(item.params, source_variables); feature: dict[str, Any]
|
||
if item.operation == "assignVariable":
|
||
variable_name, source_value, variable_params = _assign_variable_params(p)
|
||
if variable_name in source_variables:
|
||
raise UnsupportedCapability(
|
||
"assign_variable_redeclaration",
|
||
f"assignVariable redeclares source variable {variable_name}",
|
||
)
|
||
source_variables[variable_name] = deepcopy(source_value)
|
||
feature = {
|
||
"id": fid,
|
||
"name": item.feature_id,
|
||
"atomic_id": "assign_variable",
|
||
"depends_on": depends,
|
||
"params": variable_params,
|
||
"execution_status": "supported",
|
||
}
|
||
elif 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
|
||
source_member_aliases = _transform_source_member_aliases(p.get("entities"), sources)
|
||
if source_member_aliases:
|
||
transform_params["source_member_aliases"] = source_member_aliases
|
||
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 == "cPoint":
|
||
point = _cpoint_datum(
|
||
p,
|
||
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,
|
||
)
|
||
feature_frames[item.feature_id] = {"point_mm": point}
|
||
feature = {
|
||
"id": fid,
|
||
"name": item.feature_id,
|
||
"atomic_id": "reference_point",
|
||
"depends_on": depends,
|
||
"params": {"point_mm": point},
|
||
"execution_status": "supported",
|
||
}
|
||
elif item.operation == "cPlane":
|
||
plane = _cplane(
|
||
p, 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,
|
||
standard_library=model.standard_library,
|
||
standard_library_version=model.standard_library_version,
|
||
)
|
||
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
|
||
pure_surface_operation = (
|
||
str(p.get("bodyType") or "").rsplit(".", 1)[-1].upper() == "SURFACE"
|
||
)
|
||
if pure_surface_operation:
|
||
if p.get("surfaceOperationType") is not None:
|
||
raise UnsupportedCapability(
|
||
"extrude_surface_operation",
|
||
"pure ToolBodyType.SURFACE extrusion cannot also declare a surface operation",
|
||
)
|
||
surface_body_operation = str(p.get("operationType") or "").rsplit(".", 1)[-1].upper()
|
||
if surface_body_operation not in {"", "ADD", "NEW"}:
|
||
raise UnsupportedCapability(
|
||
"extrude_surface_operation",
|
||
"pure ToolBodyType.SURFACE extrusion supports only an independent ADD surface operation",
|
||
)
|
||
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, allow_open_wire=True,
|
||
)
|
||
sketches.append(surface_profile_sketch); sketches_by_id[surface_profile_sketch["id"]] = surface_profile_sketch
|
||
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 = (
|
||
p.get("surfaceEntities") if pure_surface_operation
|
||
else _sketch_region_query(p.get("entities")) or p.get("entities")
|
||
)
|
||
profile_operation = str(p.get("operationType") or "NEW").upper()
|
||
profile_kind = parse_query(profile_value).topology_type
|
||
multi_source_region_profile = (
|
||
_multi_source_sketch_region_profile(p.get("entities"), sketch_by_source, fid)
|
||
if not pure_surface_operation else None
|
||
)
|
||
if (
|
||
not pure_surface_operation
|
||
and multi_source_region_profile is None
|
||
and _has_composed_sketch_region_union(p.get("entities"))
|
||
):
|
||
raise UnsupportedCapability(
|
||
"extrude_multi_source_sketch_region",
|
||
"extrude qSketchRegion union requires direct executable source profiles on one identical unattached frame",
|
||
)
|
||
source = (
|
||
None if multi_source_region_profile is not None
|
||
else 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,
|
||
)
|
||
retained_offset_cap_selector = _shell_retained_direct_prism_cap_offset_face_profile_selector(
|
||
profile_value, feature_by_id, sketches_by_id, previous,
|
||
featurescript_version=model.featurescript_version,
|
||
) if profile_kind == "OFFSET_FACE" else None
|
||
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
|
||
)
|
||
# ``parse_query`` retains nested-query information and its
|
||
# last nested makeQuery can be a CAP_EDGE/INTERSECT leaf.
|
||
# Only the external-boundary form needs direct qUnion-root
|
||
# inspection here; ordinary single IMPRINT profiles keep
|
||
# their established materialization path.
|
||
planar_imprint_selections = [
|
||
selection
|
||
for root in _queries(profile_value)
|
||
if (selection := _planar_imprint_selection(root)) is not None
|
||
]
|
||
has_external_planar_imprint_root = any(
|
||
isinstance((selection[1].get("fragment") or {}).get("_external_anchor_query"), Call)
|
||
for selection in planar_imprint_selections
|
||
)
|
||
planar_imprint_profile = _planar_imprint_profile_sketch(
|
||
profile_value,
|
||
sketch_by_source,
|
||
entity_by_sketch,
|
||
fid,
|
||
feature_by_id=feature_by_id,
|
||
feature_frames=feature_frames,
|
||
sketches_by_id=sketches_by_id,
|
||
previous=previous,
|
||
featurescript_version=model.featurescript_version,
|
||
) if (
|
||
profile_kind == "INTERSECT"
|
||
or (
|
||
has_external_planar_imprint_root
|
||
and _is_new_body_operation(profile_operation)
|
||
)
|
||
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 or retained_offset_cap_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 multi_source_region_profile is not None:
|
||
profile_sketch = multi_source_region_profile
|
||
sketches.append(profile_sketch); sketches_by_id[profile_sketch["id"]] = profile_sketch
|
||
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 pure_surface_operation:
|
||
profile_sketch = surface_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 (
|
||
not pure_surface_operation
|
||
and cap_face_output_selector is None
|
||
and retained_offset_cap_selector is None
|
||
and multi_source_region_profile 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 = profile_operation
|
||
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 pure_surface_operation
|
||
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":
|
||
# A two-sided face extent is one paired provenance
|
||
# request. Defer resolving either side until the reverse
|
||
# query is available below, where the exact CAP/SWEPT
|
||
# pair contracts can prove both members together.
|
||
if not second:
|
||
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=True,
|
||
allow_primary_add_up_to_surface=True,
|
||
allow_direct_prism_swept_lineage=True,
|
||
featurescript_version=model.featurescript_version,
|
||
)
|
||
elif end["type"] == "up_to_vertex":
|
||
end["reference"] = (
|
||
_direct_source_vertex_extent_reference(
|
||
p.get("endBoundEntityVertex"), sketch_by_source, entity_by_sketch,
|
||
featurescript_version=model.featurescript_version,
|
||
)
|
||
or _direct_prism_cap_vertex_extent_selector(
|
||
p.get("endBoundEntityVertex"), 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,
|
||
)
|
||
or _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_vertex":
|
||
reverse_end["reference"] = (
|
||
_direct_source_vertex_extent_reference(
|
||
p.get("secondDirectionBoundEntityVertex"), sketch_by_source, entity_by_sketch,
|
||
featurescript_version=model.featurescript_version,
|
||
)
|
||
or _direct_prism_cap_vertex_extent_selector(
|
||
p.get("secondDirectionBoundEntityVertex"), 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,
|
||
)
|
||
or _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})
|
||
if end["type"] == "up_to_surface" and reverse_end["type"] == "up_to_surface":
|
||
cap_pair = _two_sided_up_to_surface_cap_pair(
|
||
p.get("endBoundEntityFace"), p.get("secondDirectionBoundEntityFace"),
|
||
feature_by_id=feature_by_id, sketches_by_id=sketches_by_id,
|
||
source_sketches=sketch_by_source, previous=previous,
|
||
featurescript_version=model.featurescript_version,
|
||
)
|
||
if cap_pair is not None:
|
||
end["reference"], reverse_end["reference"] = cap_pair
|
||
else:
|
||
swept_pair = _two_sided_up_to_surface_shell_swept_face_pair(
|
||
p.get("endBoundEntityFace"), p.get("secondDirectionBoundEntityFace"),
|
||
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 swept_pair is not None:
|
||
end["reference"], reverse_end["reference"] = swept_pair
|
||
else:
|
||
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,
|
||
featurescript_version=model.featurescript_version,
|
||
)
|
||
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,
|
||
featurescript_version=model.featurescript_version,
|
||
)
|
||
elif reverse_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,
|
||
featurescript_version=model.featurescript_version,
|
||
)
|
||
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,
|
||
featurescript_version=model.featurescript_version,
|
||
)
|
||
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 pure_surface_operation:
|
||
if second or end["type"] not in {"blind", "mid_plane"}:
|
||
raise UnsupportedCapability(
|
||
"extrude_surface_extent",
|
||
"pure ToolBodyType.SURFACE extrusion supports blind and symmetric extents only",
|
||
)
|
||
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"
|
||
derived_face_selector = cap_face_output_selector or retained_offset_cap_selector
|
||
if derived_face_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": [derived_face_selector],
|
||
"execution_status": "supported",
|
||
}
|
||
else:
|
||
if profile_sketch is None:
|
||
raise ValueError("extrude profile is unresolved")
|
||
if pure_surface_operation:
|
||
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"]
|
||
feature = {"id": fid, "name": item.feature_id, "atomic_id": "extrude_surface", "depends_on": depends, "sketch_id": profile_sketch["id"], "params": surface_params, "execution_status": "supported"}
|
||
else:
|
||
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 and not pure_surface_operation:
|
||
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 derived_face_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,
|
||
}
|
||
# This source-only datum contract is intentionally narrower
|
||
# than the runtime SWEPT_EDGE selector: a LINE_ANGLE axis can
|
||
# be computed from the original profile vertex and the prism
|
||
# span only while the whole profile was retained unchanged.
|
||
frame = feature_frames.get(item.feature_id)
|
||
if (
|
||
atomic == "extrude_add_blind"
|
||
and params.get("result_mode") == "new_body"
|
||
and end["type"] == "blind"
|
||
and not second
|
||
and params.get("draft") is None
|
||
and isinstance(source, str)
|
||
and profile_sketch is not None
|
||
and source in sketch_by_source
|
||
and profile_sketch.get("source_sketch_id") == source
|
||
and _profile_matches_direct_source(profile_sketch, sketch_by_source[source])
|
||
and isinstance(frame, dict)
|
||
):
|
||
source_entity_ids = _direct_profile_source_entity_ids(profile_sketch)
|
||
if source_entity_ids:
|
||
frame["direct_prism_line_angle_axis"] = True
|
||
frame["direct_prism_source_entity_ids"] = sorted(source_entity_ids)
|
||
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:
|
||
wire_profiles = p.get("wireProfilesArray")
|
||
body_type = str(p.get("bodyType") or "").rsplit(".", 1)[-1].upper()
|
||
is_surface_wire_loft = body_type == "SURFACE" and isinstance(wire_profiles, list)
|
||
surface_loft = (
|
||
is_surface_wire_loft
|
||
and len(wire_profiles) == 2
|
||
and not p.get("sheetProfilesArray")
|
||
and not p.get("guidesArray")
|
||
and p.get("spine") is None
|
||
and str(p.get("operationType") or "NEW").rsplit(".", 1)[-1].upper() == "NEW"
|
||
and all(p.get(key) in (None, False, [], {}) for key in (
|
||
"connections", "matchConnections", "startCondition", "endCondition",
|
||
"startMagnitude", "endMagnitude",
|
||
))
|
||
)
|
||
if surface_loft:
|
||
profiles = [
|
||
_direct_closed_sketch_wire_profile(
|
||
profile.get("wireProfileEntities") if isinstance(profile, dict) else None,
|
||
sketch_by_source, entity_by_sketch,
|
||
featurescript_version=model.featurescript_version,
|
||
standard_library=model.standard_library,
|
||
standard_library_version=model.standard_library_version,
|
||
feature_id=f"{fid}_{index}",
|
||
)
|
||
for index, profile in enumerate(wire_profiles)
|
||
]
|
||
if any(profile is None for profile in profiles):
|
||
raise UnsupportedCapability(
|
||
"loft_surface_wire_profiles",
|
||
"current CDSL surface loft requires two direct closed source-wire profiles",
|
||
)
|
||
profile_sketches = [profile for profile in profiles if profile is not None]
|
||
if len({profile["source_sketch_id"] for profile in profile_sketches}) != len(profile_sketches):
|
||
raise UnsupportedCapability(
|
||
"loft_surface_wire_profiles",
|
||
"current CDSL surface loft requires profiles from distinct source sketches",
|
||
)
|
||
for profile in profile_sketches:
|
||
sketches.append(profile); sketches_by_id[profile["id"]] = profile
|
||
feature = {
|
||
"id": fid,
|
||
"name": item.feature_id,
|
||
"atomic_id": "loft_surface",
|
||
"depends_on": depends,
|
||
"params": {"profile_sketch_ids": [profile["id"] for profile in profile_sketches]},
|
||
"execution_status": "supported",
|
||
}
|
||
frames = None
|
||
elif is_surface_wire_loft:
|
||
raise UnsupportedCapability(
|
||
"loft_surface_wire_profiles",
|
||
"current CDSL surface loft requires two direct closed source-wire profiles",
|
||
)
|
||
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",
|
||
}
|
||
if _initial_direct_loft_cap_output_roles(p, sources, features, model.featurescript_version):
|
||
feature["params"].update({
|
||
"initial_output_roles": True,
|
||
"cap_output_profile_sources": list(sources),
|
||
})
|
||
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 "")
|
||
# A raw arc/circle leaf must be named by exactly one source
|
||
# query: the query parser retains a representative qUnion
|
||
# leaf, and accepting it would discard the other curves. A
|
||
# single original curve selected by the versioned source-wire
|
||
# contract is different: its complete source set is already
|
||
# proven to contain precisely that one line/arc/B-spline.
|
||
direct_single_path_source = path_query.calls.count("sQuery") == 1
|
||
path: dict[str, Any] | None = None
|
||
# A multi-leaf query is a source wire request, never an
|
||
# authorization to use the parser's representative leaf.
|
||
# Resolve the complete same-sketch or spatial wire before
|
||
# considering the singleton segment path. In particular,
|
||
# parse_query retains the last leaf of qUnion for context;
|
||
# treating that arc or line as the path would discard every
|
||
# preceding curve in the FeatureScript query.
|
||
if not direct_single_path_source:
|
||
segmented_wire_path = _direct_segmented_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 segmented_wire_path is not None:
|
||
path_source, path_segments, path_sketch = segmented_wire_path
|
||
path = {"workplane": path_sketch["workplane"], "segments": path_segments}
|
||
else:
|
||
spatial_segments = _direct_spatial_segmented_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 spatial_segments is not None:
|
||
path = {"segments": spatial_segments}
|
||
# Do not fall back to the representative direct leaf.
|
||
path_entity = path_sketch = None
|
||
if path is None and (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
|
||
elif not direct_single_path_source:
|
||
# Unlike a raw direct circle leaf, a qBodyType source
|
||
# wire has no parser entity that may stand in for the
|
||
# query result. Admit it only after this helper proves
|
||
# its complete selected source set is exactly one
|
||
# original circle.
|
||
circle_wire_path = _direct_sketch_circle_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 circle_wire_path is not None:
|
||
path_source, _path_entity_id, path_entity, path_sketch = circle_wire_path
|
||
elif direct_single_path_source:
|
||
segmented_wire_path = _direct_segmented_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 segmented_wire_path is not None:
|
||
path_source, path_segments, path_sketch = segmented_wire_path
|
||
path = {"workplane": path_sketch["workplane"], "segments": path_segments}
|
||
else:
|
||
spatial_segments = _direct_spatial_segmented_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 spatial_segments is not None:
|
||
path = {"segments": spatial_segments}
|
||
if path_entity is None or path_sketch is None:
|
||
if path is None:
|
||
raise UnsupportedCapability(
|
||
"sweep_path_query",
|
||
"current CDSL sweep requires one direct source sketch line/arc/B-spline, a connected source-sketch wire, or a connected union of source wires",
|
||
)
|
||
if path is None and path_entity.get("type") not in {"line", "arc", "circle", "bspline"}:
|
||
raise UnsupportedCapability("sweep_path", "current CDSL sweep requires one line, arc, circle, or B-spline path")
|
||
operation = str(p.get("operationType") or "NEW").upper()
|
||
is_cut_operation = any(value in operation for value in ("REMOVE", "CUT"))
|
||
if "INTERSECT" in operation:
|
||
raise UnsupportedCapability("sweep_intersect", "current CDSL sweep does not yet support intersection body semantics")
|
||
if path is None:
|
||
path = {"workplane": path_sketch["workplane"], "segment": deepcopy(path_entity)}
|
||
path_reversed = _sweep_profile_attaches_at_path_end(profile_sketch, path)
|
||
if path_reversed:
|
||
path = _reversed_sweep_path(path)
|
||
feature = {
|
||
"id": fid,
|
||
"name": item.feature_id,
|
||
"atomic_id": "sweep_cut" if is_cut_operation else "sweep_add",
|
||
"depends_on": depends,
|
||
"sketch_id": profile_sketch["id"],
|
||
"params": {"path": path},
|
||
"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
|
||
# PipeShell FirstShape/LastShape can prove two physical cap
|
||
# faces only for one direct profile and one direct source
|
||
# path. Preserve that singleton pair for CAP_FACE. Its
|
||
# Generated(profile_edge) history is distinct and can retain
|
||
# a complete direct analytic contour for SWEPT_FACE.
|
||
path_segment = feature["params"]["path"].get("segment") or {}
|
||
closed_circle_path = path_segment.get("type") == "circle"
|
||
profile_shape = profile_sketch.get("profile") or {}
|
||
if (
|
||
feature["params"].get("result_mode") == "new_body"
|
||
and profile_sketch.get("source_sketch_id") == profile_source
|
||
and profile_shape.get("source_entity_id")
|
||
and isinstance(path_segment.get("source_entity_id"), str)
|
||
and path_segment["source_entity_id"]
|
||
and direct_single_path_source
|
||
and isinstance(feature["params"]["path"].get("workplane"), dict)
|
||
and not feature["params"]["path"].get("segments")
|
||
and not closed_circle_path
|
||
and frames is not None
|
||
):
|
||
feature["params"]["initial_output_roles"] = True
|
||
feature["params"]["cap_output_contract"] = {
|
||
"profile_source": profile_source,
|
||
"profile_entity": str(profile_shape["source_entity_id"]),
|
||
"path_source": path_source,
|
||
"path_entity": path_segment["source_entity_id"],
|
||
"path_reversed": path_reversed,
|
||
}
|
||
direct_profile_entities = _direct_sweep_profile_entities(profile_sketch)
|
||
if (
|
||
feature["params"].get("result_mode") == "new_body"
|
||
and profile_sketch.get("source_sketch_id") == profile_source
|
||
and direct_profile_entities is not None
|
||
and isinstance(path_segment.get("source_entity_id"), str)
|
||
and path_segment["source_entity_id"]
|
||
and direct_single_path_source
|
||
and isinstance(feature["params"]["path"].get("workplane"), dict)
|
||
and not feature["params"]["path"].get("segments")
|
||
and not closed_circle_path
|
||
):
|
||
feature["params"]["initial_output_roles"] = True
|
||
feature["params"]["swept_face_contract"] = {
|
||
"profile_source": profile_source,
|
||
"profile_entities": direct_profile_entities,
|
||
"path_source": path_source,
|
||
"path_entity": path_segment["source_entity_id"],
|
||
"path_reversed": path_reversed,
|
||
}
|
||
if (
|
||
feature["params"].get("result_mode") == "new_body"
|
||
and profile_source != path_source
|
||
and profile_sketch.get("source_sketch_id") == profile_source
|
||
and direct_profile_entities is not None
|
||
and len(direct_profile_entities) >= 2
|
||
and isinstance(path_segment.get("source_entity_id"), str)
|
||
and path_segment["source_entity_id"]
|
||
and direct_single_path_source
|
||
and isinstance(feature["params"]["path"].get("workplane"), dict)
|
||
and not feature["params"]["path"].get("segments")
|
||
and not closed_circle_path
|
||
):
|
||
feature["params"]["initial_output_roles"] = True
|
||
feature["params"]["swept_edge_contract"] = {
|
||
"profile_source": profile_source,
|
||
"profile_entities": direct_profile_entities,
|
||
"path_source": path_source,
|
||
"path_entity": path_segment["source_entity_id"],
|
||
"path_reversed": path_reversed,
|
||
}
|
||
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, target_transform_copy_refs = (
|
||
_boolean_body_references(
|
||
p.get("targets"), previous, feature_by_id, body_transform_aliases,
|
||
)
|
||
if has_targets else ([], [], [])
|
||
)
|
||
tools, tool_instance_refs, tool_transform_copy_refs = _boolean_body_references(
|
||
p.get("tools"), previous, feature_by_id, body_transform_aliases,
|
||
)
|
||
if not has_targets:
|
||
# A targetless FeatureScript boolean denotes an ordered
|
||
# set of body operands. For UNION and INTERSECTION, the
|
||
# first explicit source member can be made CDSL's left
|
||
# operand and every following member its right set. This
|
||
# preserves the operation while keeping the source body
|
||
# set explicit; SUBTRACTION has no such source-proven
|
||
# targetless ordering contract.
|
||
if operation not in {"UNION", "INTERSECTION"}:
|
||
raise UnsupportedCapability(
|
||
"boolean_bodies_targets",
|
||
"targetless booleanBodies currently supports only UNION or INTERSECTION",
|
||
)
|
||
selections = _ordered_boolean_body_references(
|
||
p.get("tools"), previous, feature_by_id, body_transform_aliases,
|
||
)
|
||
if len(selections) < 2:
|
||
raise ValueError("targetless booleanBodies requires at least two explicit bodies")
|
||
# FeatureScript supplies a set here, not a directional
|
||
# target/tool pair. Preserve the existing deterministic
|
||
# lifecycle policy: select an explicit direct member when
|
||
# the set has one; otherwise choose its first qualified
|
||
# COPY member. UNION/INTERSECTION are commutative, so the
|
||
# choice does not alter their shape, while it avoids
|
||
# rewriting a direct source as a pattern aggregate.
|
||
target_index = next(
|
||
(index for index, (kind, _value) in enumerate(selections) if kind == "feature"),
|
||
0,
|
||
)
|
||
target_kind, target = selections.pop(target_index)
|
||
right_operands = selections
|
||
targets = [target] if target_kind == "feature" else []
|
||
target_instance_refs = [target] if target_kind == "pattern" else []
|
||
target_transform_copy_refs = [target] if target_kind == "transform_copy" else []
|
||
tools = [value for kind, value in right_operands if kind == "feature"]
|
||
tool_instance_refs = [value for kind, value in right_operands if kind == "pattern"]
|
||
tool_transform_copy_refs = [value for kind, value in right_operands if kind == "transform_copy"]
|
||
targetless_body_set = True
|
||
else:
|
||
targetless_body_set = False
|
||
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
|
||
} | {
|
||
("transform_copy", reference["transform_feature_id"], reference["source_feature_id"])
|
||
for reference in target_transform_copy_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
|
||
} | {
|
||
("transform_copy", reference["transform_feature_id"], reference["source_feature_id"])
|
||
for reference in tool_transform_copy_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),
|
||
]))
|
||
transform_copy_dependencies = list(dict.fromkeys([
|
||
*(reference["transform_feature_id"] for reference in target_transform_copy_refs),
|
||
*(reference["transform_feature_id"] for reference in tool_transform_copy_refs),
|
||
]))
|
||
feature = {
|
||
"id": fid,
|
||
"name": item.feature_id,
|
||
"atomic_id": "boolean_bodies",
|
||
"depends_on": list(dict.fromkeys(targets + tools + pattern_dependencies + transform_copy_dependencies + depends)),
|
||
"params": {
|
||
"operation": operation_map[operation],
|
||
"keep_tools": _bool(p.get("keepTools")),
|
||
},
|
||
"execution_status": "supported",
|
||
}
|
||
if targetless_body_set:
|
||
feature["params"]["targetless_body_set"] = True
|
||
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
|
||
if target_transform_copy_refs:
|
||
feature["params"]["target_transform_copy_refs"] = target_transform_copy_refs
|
||
if tool_transform_copy_refs:
|
||
feature["params"]["tool_transform_copy_refs"] = tool_transform_copy_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")
|
||
profile_kind = parse_query(profile_entities).topology_type
|
||
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
|
||
# Keep the FeatureScript profile provenance separate
|
||
# from geometry equality. Some 1511 IMPRINT queries
|
||
# lower to identical contours but are not the original
|
||
# sketch topology; 2491 has explicit evidence for one
|
||
# complete unchanged materialization.
|
||
frame["revolve_profile_contract"] = (
|
||
"verified_complete_materialization"
|
||
if profile_kind == "IMPRINT"
|
||
else "original_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 = []
|
||
source_set = _direct_query_set_operands(p.get("entities"))
|
||
query_values = _query_set_leaf_values(p.get("entities")) if source_set is not None else _queries(p.get("entities"))
|
||
offset_edge_forms: set[str] = set()
|
||
for query_value in query_values:
|
||
try:
|
||
_call, _owner, topology, _kind, definition = _direct_make_query(query_value)
|
||
except ValueError:
|
||
continue
|
||
if topology != "OFFSET_EDGE":
|
||
continue
|
||
disambiguation = definition.get("disambiguationData")
|
||
if isinstance(disambiguation, list):
|
||
offset_edge_forms.add(
|
||
"tdd" if any(isinstance(item, Call) and item.name == "TDD" for item in disambiguation)
|
||
else "osd"
|
||
)
|
||
mixed_offset_edge_forms = len(offset_edge_forms) > 1
|
||
for index, query_value in enumerate(query_values):
|
||
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")
|
||
# A deferred source query still describes a CADFS result,
|
||
# but it cannot bind to a producer omitted from the CDSL
|
||
# prefix. Keeping that missing owner in a supported
|
||
# dress-up would create semantically invalid CDSL and
|
||
# falsely move this lowering failure into validation.
|
||
if f"f_{owner}" not in feature_by_id:
|
||
raise UnsupportedCapability(
|
||
"selector_owner_unavailable",
|
||
f"selector owner {owner} has no executable CDSL producer",
|
||
)
|
||
selector_kind = "face" if outer_kind in {"face", "entitytype.face"} else "edge"
|
||
if outer_topology == "BLEND_EDGE":
|
||
blend_selector = _direct_blend_edge_selector(
|
||
query_value,
|
||
owner=owner,
|
||
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 blend_selector is not None:
|
||
selectors.append(blend_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 == "OFFSET_EDGE":
|
||
offset_edge_selector = _direct_prism_shell_offset_edge_tdd_selector(
|
||
query_value,
|
||
owner=owner,
|
||
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 offset_edge_selector is None and not mixed_offset_edge_forms:
|
||
offset_edge_selector = _direct_prism_shell_offset_edge_vertex_selector(
|
||
query_value,
|
||
owner=owner,
|
||
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 offset_edge_selector is not None:
|
||
selectors.append(offset_edge_selector)
|
||
continue
|
||
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 == "COPY":
|
||
copy_selector = _direct_primary_cut_copy_cap_edge_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 copy_selector is not None:
|
||
selectors.append(copy_selector)
|
||
continue
|
||
if outer_topology == "CAP_FACE":
|
||
# A primary ADD has only a transient prism tool. Its
|
||
# cap may drive the immediately following dress-up
|
||
# only when runtime proves the exact one-to-one
|
||
# extrude-to-union successor in the active member.
|
||
cap_selector = _cap_face_output_role_selector(
|
||
query_value,
|
||
feature_by_id,
|
||
sketches_by_id,
|
||
allow_primary_add_dressup=True,
|
||
)
|
||
if (
|
||
cap_selector is not None
|
||
and previous[-1:] == [cap_selector["owner_feature_id"]]
|
||
):
|
||
selectors.append(cap_selector)
|
||
continue
|
||
if outer_topology in {"CAP_FACE", "CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE"}:
|
||
imprint_selector = _planar_imprint_prism_selector(
|
||
query_value,
|
||
owner=owner,
|
||
selector_kind=selector_kind,
|
||
feature_by_id=feature_by_id,
|
||
feature_frames=feature_frames,
|
||
sketches_by_id=sketches_by_id,
|
||
previous=previous,
|
||
featurescript_version=model.featurescript_version,
|
||
)
|
||
if imprint_selector is not None:
|
||
selectors.append(imprint_selector)
|
||
continue
|
||
if outer_topology in {"CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE"}:
|
||
sweep_cap_edge_selector = _initial_direct_sweep_cap_edge_selector(
|
||
query_value, feature_by_id, sketches_by_id, previous,
|
||
) if outer_topology == "CAP_EDGE" else None
|
||
if sweep_cap_edge_selector is not None:
|
||
selectors.append(sweep_cap_edge_selector)
|
||
continue
|
||
sweep_swept_face_selector = _initial_direct_sweep_swept_face_selector(
|
||
query_value, feature_by_id, sketches_by_id, previous,
|
||
) if outer_topology == "SWEPT_FACE" else None
|
||
if sweep_swept_face_selector is not None:
|
||
selectors.append(sweep_swept_face_selector)
|
||
continue
|
||
sweep_swept_edge_selector = _initial_direct_sweep_swept_edge_selector(
|
||
query_value, feature_by_id, sketches_by_id, previous,
|
||
) if outer_topology == "SWEPT_EDGE" else None
|
||
if sweep_swept_edge_selector is not None:
|
||
selectors.append(sweep_swept_edge_selector)
|
||
continue
|
||
# A direct PipeShell producer with an explicit source
|
||
# contract must not fall through to the legacy
|
||
# cylinder geometry hint when its profile/path pair is
|
||
# incomplete or contradictory. Preserve the source
|
||
# query for diagnostics and let runtime reject it.
|
||
sweep_producer = feature_by_id.get(f"f_{owner}") or {}
|
||
sweep_params = sweep_producer.get("params") or {}
|
||
if (
|
||
outer_topology in {"SWEPT_FACE", "SWEPT_EDGE"}
|
||
and sweep_producer.get("atomic_id") == "sweep_add"
|
||
and sweep_params.get("initial_output_roles") is True
|
||
and isinstance(
|
||
sweep_params.get(
|
||
"swept_face_contract" if outer_topology == "SWEPT_FACE" else "swept_edge_contract"
|
||
),
|
||
dict,
|
||
)
|
||
):
|
||
selectors.append({
|
||
"kind": selector_kind,
|
||
"owner_feature_id": f"f_{owner}",
|
||
"source": "runtime_snapshot",
|
||
"confidence": 1.0,
|
||
"selector_intent": _deferred_featurescript_selector_intent(
|
||
query_value, kind=selector_kind,
|
||
),
|
||
})
|
||
continue
|
||
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 in {"CAP_EDGE", "SWEPT_EDGE"},
|
||
)
|
||
if lineage_selector is not None:
|
||
selectors.append(lineage_selector)
|
||
continue
|
||
if outer_topology == "SWEPT_EDGE":
|
||
revolve_selector = _direct_full_revolve_swept_edge_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,
|
||
)
|
||
if revolve_selector is not None:
|
||
selectors.append(revolve_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)})
|
||
# Dress-up consumers must retain the source set expression,
|
||
# including a direct qUnion, because its ordered child
|
||
# provenance is what lets the runtime apply exact set
|
||
# algebra. (The shell consumer below intentionally keeps
|
||
# its legacy flat direct-union contract.)
|
||
query_set = _proven_query_set_selector(p.get("entities"), selectors) if source_set is not None else None
|
||
if query_set is not None:
|
||
selectors = [query_set]
|
||
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]] = []
|
||
source_set = _direct_query_set_operands(p.get("entities"))
|
||
query_values = (
|
||
_query_set_leaf_values(p.get("entities"))
|
||
if source_set is not None
|
||
else _queries(p.get("entities"))
|
||
)
|
||
for index, query_value in enumerate(query_values):
|
||
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,
|
||
allow_initial_loft=True,
|
||
allow_two_sided_circle_shell=True,
|
||
allow_primary_add_shell=True,
|
||
)
|
||
if selector is None:
|
||
selector = _initial_direct_sweep_cap_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)
|
||
# A direct qUnion is already represented by the ordered flat
|
||
# removal-face list expected by the established shell
|
||
# contract. Preserve a QUERY_SET parent only for set
|
||
# operators whose intersection/subtraction or nested union
|
||
# semantics cannot be represented by that list.
|
||
needs_query_set_parent = bool(
|
||
source_set is not None
|
||
and (
|
||
source_set[1] != "union"
|
||
or any(_direct_query_set_operands(item) is not None for item in source_set[2])
|
||
)
|
||
)
|
||
query_set = _proven_query_set_selector(p.get("entities"), selectors) if needs_query_set_parent else None
|
||
if query_set is not None:
|
||
selectors = [query_set]
|
||
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"}
|
||
def selector_owners(selector: dict[str, Any]) -> set[str]:
|
||
owners: set[str] = set()
|
||
owner = selector.get("owner_feature_id")
|
||
if isinstance(owner, str) and owner:
|
||
owners.add(owner.removeprefix("f_"))
|
||
for child in selector.get("query_operands") or ():
|
||
if isinstance(child, dict):
|
||
owners.update(selector_owners(child))
|
||
return owners
|
||
|
||
owners = set().union(*(selector_owners(selector) 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; host_attachment = None
|
||
for location in locations:
|
||
location_query = parse_query(location)
|
||
# A direct source vertex only has a usable local position
|
||
# once its source sketch has an executable, explicit frame.
|
||
# Do not label an unresolved host workplane as a malformed
|
||
# vertex selector: it is an upstream sketch dependency.
|
||
if (
|
||
_is_direct_hole_location_query(location)
|
||
and
|
||
isinstance(location_query.source_sketch, str)
|
||
and location_query.source_sketch not in sketch_by_source
|
||
):
|
||
raise UnsupportedCapability(
|
||
"hole_location_sketch_unavailable",
|
||
"hole location source sketch is not executable",
|
||
)
|
||
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
|
||
attachment = (
|
||
sketch_by_source.get(location_query.source_sketch or "", {}).get("attachment")
|
||
if isinstance(location_query.source_sketch, str) else None
|
||
)
|
||
if attachment is not None and not isinstance(attachment, dict):
|
||
raise UnsupportedCapability("hole_location_attachment", "hole location sketch attachment is invalid")
|
||
if host_attachment is not None and attachment != host_attachment:
|
||
raise UnsupportedCapability("hole_location_attachment", "hole locations must share one runtime attachment")
|
||
host_attachment = attachment
|
||
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")):
|
||
if host_attachment is not None:
|
||
raise UnsupportedCapability("hole_location_attachment", "attached hole workplane does not yet support 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": host_attachment if host_attachment is not None else {"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,
|
||
sketches_by_id,
|
||
previous,
|
||
owners,
|
||
model.featurescript_version,
|
||
)
|
||
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
|
||
and not pure_surface_operation
|
||
):
|
||
features.append(surface_feature)
|
||
surface_profiles.append({
|
||
"profile": deepcopy(surface_profile_sketch["profile"]),
|
||
"workplane": dict(surface_profile_sketch["workplane"]),
|
||
**surface_feature["params"],
|
||
})
|
||
if item.operation == "extrude" and pure_surface_operation:
|
||
# A surface shell has no CAP/SWEPT body lifecycle. Do not let
|
||
# subsequent source queries inherit a static prism frame.
|
||
feature_frames.pop(item.feature_id, None)
|
||
if item.operation != "assignVariable":
|
||
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)
|