From 3fb08423da25712b4e704693c267c9585d964bcd Mon Sep 17 00:00:00 2001 From: ganjihong Date: Wed, 9 Sep 2026 12:58:58 +0800 Subject: [PATCH] refactor(cdsl_engine): split runtime_types into specs + topology with shim Phase 1 of the decoupling refactor (behavior-preserving move): - specs.py: vector math, plane/axis helpers, parametric feature specs - topology.py: diagnostics, planning contracts, TopologyRegistry - runtime_types.py: compatibility shim re-exporting all public names No behavior change; all historical import paths keep working. --- backend/engine/cdsl_engine/runtime_types.py | 1684 +------------------ backend/engine/cdsl_engine/specs.py | 619 +++++++ backend/engine/cdsl_engine/topology.py | 1010 +++++++++++ 3 files changed, 1703 insertions(+), 1610 deletions(-) create mode 100644 backend/engine/cdsl_engine/specs.py create mode 100644 backend/engine/cdsl_engine/topology.py diff --git a/backend/engine/cdsl_engine/runtime_types.py b/backend/engine/cdsl_engine/runtime_types.py index d2b06446..e9fe27fb 100644 --- a/backend/engine/cdsl_engine/runtime_types.py +++ b/backend/engine/cdsl_engine/runtime_types.py @@ -1,1615 +1,79 @@ -"""Runtime-neutral CDSL planning, diagnostics, and topology contracts. +"""Compatibility shim for the runtime-neutral CDSL contracts. -This module deliberately has no build123d dependency. The planner and -selector resolver can therefore be used by validation, batch reporting, and -any geometry adapter without importing OCC objects. +The contracts now live in two focused modules: + +- ``specs``: vector math, canonical plane/axis helpers, and parametric + feature spec dataclasses. +- ``topology``: diagnostics, planning contracts, and the topology registry + with explainable selector resolution. + +This module keeps the historical ``cdsl_engine.runtime_types`` import path +stable for the package, its tests, and external consumers. New code should +import from ``specs`` or ``topology`` directly. """ from __future__ import annotations -import warnings -from dataclasses import dataclass, field -from math import cos, pi, radians, sqrt -from typing import Any, Iterable - - -Vector3 = tuple[float, float, float] - - -def pattern_instance_member_id(pattern_feature_id: str, source_feature_id: str, instance_index: int) -> str: - """Return the runtime-only body-member key for one proven pattern copy. - - CDSL keeps the three source fields separately, so callers never need to - manufacture this internal key. The body graph uses the same derivation in - runtime and capability preflight. - """ - return f"pattern:{pattern_feature_id}:{source_feature_id}:copy:{instance_index}" - - -def transform_copy_member_id(transform_feature_id: str, source_member_id: str) -> str: - """Return the runtime-only key for one source of a multi-body COPY. - - A multi-source ``transform_bodies`` COPY has several independently - addressable outputs. CDSL records the transform and its selected source as - separate fields; the opaque key stays internal to the body graph. - """ - return f"transform:{transform_feature_id}:{source_member_id}:copy" - -# y_dir 与 x_dir / normal 点积的绝对值不超过该值时,认为 y_dir 是正交的, -# 予以保留;否则视为偏斜数据,正交化并显式警告。 -_Y_DIR_ORTHOGONALITY_TOL = 1e-6 - - -def _vector3(value: Any, *, field_name: str) -> Vector3: - if not isinstance(value, (list, tuple)) or len(value) != 3: - raise ValueError(f"{field_name} must contain three coordinates") - try: - return (float(value[0]), float(value[1]), float(value[2])) - except (TypeError, ValueError) as error: - raise ValueError(f"{field_name} must contain numeric coordinates") from error - - -def _length(value: Vector3) -> float: - return sqrt(sum(component * component for component in value)) - - -def _unit(value: Vector3, *, field_name: str) -> Vector3: - magnitude = _length(value) - if magnitude <= 1e-12: - raise ValueError(f"{field_name} must be non-zero") - return tuple(component / magnitude for component in value) # type: ignore[return-value] - - -def _dot(left: Vector3, right: Vector3) -> float: - return sum(a * b for a, b in zip(left, right)) - - -def _cross(left: Vector3, right: Vector3) -> Vector3: - return ( - left[1] * right[2] - left[2] * right[1], - left[2] * right[0] - left[0] * right[2], - left[0] * right[1] - left[1] * right[0], - ) - - -def vector_add(left: Vector3, right: Vector3) -> Vector3: - return tuple(a + b for a, b in zip(left, right)) # type: ignore[return-value] - - -def vector_subtract(left: Vector3, right: Vector3) -> Vector3: - return tuple(a - b for a, b in zip(left, right)) # type: ignore[return-value] - - -def vector_scale(value: Vector3, factor: float) -> Vector3: - return tuple(component * factor for component in value) # type: ignore[return-value] - - -def vector_dot(left: Vector3, right: Vector3) -> float: - return _dot(left, right) - - -def vector_cross(left: Vector3, right: Vector3) -> Vector3: - return _cross(left, right) - - -def vector_unit(value: Vector3, *, field_name: str = "vector") -> Vector3: - return _unit(value, field_name=field_name) - - -def canonical_plane_signature(normal: Vector3, point_mm: Vector3) -> tuple[Vector3, float]: - """Normalize a plane sign so source and OCC face orientations compare.""" - unit_normal = _unit(normal, field_name="plane.normal") - offset = _dot(unit_normal, point_mm) - for component in unit_normal: - if abs(component) <= 1e-12: - continue - if component < 0: - unit_normal = tuple(-value for value in unit_normal) # type: ignore[assignment] - offset = -offset - break - return unit_normal, offset - - -def normalize_selector_geometry(geometry: Any) -> dict[str, Any]: - """Convert legacy SolidWorks selector evidence into runtime-neutral units. - - Current CDSL records may already carry ``*_mm`` fields. Older exported - evidence instead stores SolidWorks surface parameters, boxes, and areas in - SI units. The selector remains the source of truth; this function only - makes its geometric signature comparable to an OCC topology snapshot. - """ - if not isinstance(geometry, dict): - return {} - result = dict(geometry) - surface = geometry.get("surface") - if isinstance(surface, dict): - surface_type = str(surface.get("type") or "").lower() - if surface_type: - result.setdefault("surface_type", surface_type) - parameters = surface.get("parameters") - if surface_type == "plane" and isinstance(parameters, list) and len(parameters) >= 6: - try: - raw_normal = _vector3(parameters[:3], field_name="selector surface normal") - # SolidWorks evidence uses metres for surface locations. - point_mm = tuple(float(value) * 1000.0 for value in parameters[3:6]) - plane_normal, plane_offset = canonical_plane_signature(raw_normal, point_mm) # type: ignore[arg-type] - result.setdefault("plane_normal", list(plane_normal)) - result.setdefault("plane_offset_mm", plane_offset) - except (TypeError, ValueError): - pass - curve = geometry.get("curve") - if isinstance(curve, dict) and curve.get("type"): - result.setdefault("curve_type", str(curve["type"]).lower()) - raw_box = geometry.get("box") - if isinstance(raw_box, list) and len(raw_box) == 6: - try: - result.setdefault("bbox_mm", [float(value) * 1000.0 for value in raw_box]) - except (TypeError, ValueError): - pass - raw_area = geometry.get("area") - if raw_area is not None: - try: - result.setdefault("area_mm2", float(raw_area) * 1_000_000.0) - except (TypeError, ValueError): - pass - for raw_key, normalized_key in (("start", "start_mm"), ("end", "end_mm")): - value = geometry.get(raw_key) - if isinstance(value, list) and len(value) == 3: - try: - result.setdefault(normalized_key, [float(component) * 1000.0 for component in value]) - except (TypeError, ValueError): - pass - return result - - -@dataclass(frozen=True) -class AxisSpec: - """Canonical axis with a normalized direction.""" - - origin_mm: Vector3 - direction: Vector3 - - @classmethod - def from_mapping(cls, value: dict[str, Any]) -> "AxisSpec": - return cls( - origin_mm=_vector3(value.get("origin_mm"), field_name="axis.origin_mm"), - direction=_unit(_vector3(value.get("direction"), field_name="axis.direction"), field_name="axis.direction"), - ) - - def as_dict(self) -> dict[str, list[float]]: - return {"origin_mm": list(self.origin_mm), "direction": list(self.direction)} - - -@dataclass(frozen=True) -class PlaneSpec: - """Canonical right-handed plane frame. - - SolidWorks exports may contain a redundant or non-orthogonal y direction. - The runtime persists the orthonormalized frame so later features all use - the same coordinate system. - """ - - origin_mm: Vector3 - x_dir: Vector3 - y_dir: Vector3 - normal: Vector3 - - @classmethod - def from_mapping(cls, value: dict[str, Any]) -> "PlaneSpec": - origin = _vector3(value.get("origin_mm"), field_name="plane.origin_mm") - normal = _unit(_vector3(value.get("normal"), field_name="plane.normal"), field_name="plane.normal") - x_raw = _vector3(value.get("x_dir"), field_name="plane.x_dir") - projected_x = tuple(x_raw[index] - _dot(x_raw, normal) * normal[index] for index in range(3)) - x_dir = _unit(projected_x, field_name="plane.x_dir") - generated = _unit(_cross(normal, x_dir), field_name="plane.y_dir") - y_raw = value.get("y_dir") - if y_raw is None: - # y_dir 缺失:用 normal × x_dir 补全右手系(默认行为)。 - y_dir = generated - else: - y_vec = _unit(_vector3(y_raw, field_name="plane.y_dir"), field_name="plane.y_dir") - if abs(_dot(y_vec, x_dir)) <= _Y_DIR_ORTHOGONALITY_TOL and abs(_dot(y_vec, normal)) <= _Y_DIR_ORTHOGONALITY_TOL: - # 输入 y_dir 与 x_dir / normal 正交:尊重文档作者给的坐标方向, - # 不再静默丢弃(SolidWorks 导出的非标准 y_dir 得以保留)。 - y_dir = y_vec - else: - # 偏斜 y_dir:正交化并显式警告,避免"静默丢语义"。 - warnings.warn( - f"plane y_dir {list(y_vec)} is not orthogonal to x_dir/normal; re-orthogonalized to {list(generated)}", - UserWarning, - stacklevel=2, - ) - y_dir = generated - return cls(origin_mm=origin, x_dir=x_dir, y_dir=y_dir, normal=normal) - - def as_dict(self) -> dict[str, list[float]]: - return { - "origin_mm": list(self.origin_mm), - "x_dir": list(self.x_dir), - "y_dir": list(self.y_dir), - "normal": list(self.normal), - } - - -@dataclass(frozen=True) -class HoleSpec: - """Runtime-neutral definition of a cylindrical Hole Wizard operation. - - The spec deliberately contains no OCC planes or shapes. The runtime - resolves the host frame and the adapter turns this definition into a - cutting tool, keeping source-contract parsing separate from B-rep work. - """ - - diameter_mm: float - depth_mm: float - end_condition: str - positions_mm: tuple[Vector3, ...] - countersink: tuple[float, float] | None = None - counterbore: tuple[float, float] | None = None - - @classmethod - def from_feature(cls, atomic_id: str, params: dict[str, Any], *, wizard: bool) -> "HoleSpec": - try: - diameter = float(params.get("diameter_mm") or 0.0) - depth = float(params.get("depth_mm") or 0.0) - except (TypeError, ValueError) as error: - raise ValueError("hole dimensions must be numeric") from error - if diameter <= 0 or depth <= 0: - raise ValueError("hole requires positive diameter_mm and depth_mm") - condition = str((params.get("end_condition") or {"type": "blind"}).get("type") or "blind") - if condition not in {"blind", "through_all", "through_all_both"}: - raise ValueError(f"unsupported hole extent {condition!r}") - raw_positions = params.get("positions") or [] - positions = tuple(_vector3(item.get("mm"), field_name="hole position") for item in raw_positions if isinstance(item, dict)) - if len(positions) != len(raw_positions) or not positions: - raise ValueError("hole requires non-empty positions with three-dimensional mm coordinates") - - raw_sink: dict[str, Any] | None = params.get("countersink") if wizard else None - raw_bore: dict[str, Any] | None = params.get("counterbore") if wizard else None - if atomic_id == "hole_countersink": - raw_sink = {"diameter_mm": params.get("countersink_diameter_mm"), "angle_rad": params.get("countersink_angle_rad")} - if atomic_id == "hole_counterbore": - raw_bore = {"diameter_mm": params.get("counterbore_diameter_mm"), "depth_mm": params.get("counterbore_depth_mm")} - - def dimensions(value: dict[str, Any] | None, second: str, label: str) -> tuple[float, float] | None: - if value is None: - return None - try: - first_value = float(value.get("diameter_mm") or 0.0) - second_value = float(value.get(second) or 0.0) - except (AttributeError, TypeError, ValueError) as error: - raise ValueError(f"{label} dimensions must be numeric") from error - if first_value <= diameter or second_value <= 0: - raise ValueError(f"{label} requires a diameter larger than the main hole and a positive {second}") - return first_value, second_value - - return cls( - diameter_mm=diameter, - depth_mm=depth, - end_condition=condition, - positions_mm=positions, - countersink=dimensions(raw_sink, "angle_rad", "countersink"), - counterbore=dimensions(raw_bore, "depth_mm", "counterbore"), - ) - - -@dataclass(frozen=True) -class ThreadSpec: - """Runtime-neutral definition of a parametric screw thread. - - The spec deliberately contains no OCC planes or shapes. The adapter turns - this definition into a threaded solid segment keeping source-contract - parsing separate from B-rep work. - - ``axis.origin_mm`` anchors the leading end face of the threaded segment and - ``axis.direction`` is the outward thread axis; ``angle_deg`` is the full - flank angle (60 for ISO metric V threads, 30 for trapezoidal leadscrews). - ``internal`` selects the cutting form used by later thread_cut support. - """ - - major_diameter_mm: float - minor_diameter_mm: float - pitch_mm: float - length_mm: float - axis: AxisSpec - angle_deg: float = 60.0 - internal: bool = False - lefthand: bool = False - relief_length_mm: float = 0.0 - crest_radius_mm: float = 0.0 - root_radius_mm: float = 0.0 - - @classmethod - def from_feature(cls, atomic_id: str, params: dict[str, Any]) -> "ThreadSpec": - try: - major = float(params.get("major_diameter_mm") or 0.0) - minor = float(params.get("minor_diameter_mm") or 0.0) - pitch = float(params.get("pitch_mm") or 0.0) - length = float(params.get("length_mm") or 0.0) - except (TypeError, ValueError) as error: - raise ValueError("thread dimensions must be numeric") from error - if major <= 0 or minor <= 0 or pitch <= 0 or length <= 0: - raise ValueError("thread requires positive major/minor/pitch/length in millimetres") - if minor >= major: - raise ValueError("thread minor diameter must be smaller than the major diameter") - raw_axis = params.get("axis") - if not isinstance(raw_axis, dict): - raise ValueError("thread requires an axis definition") - angle = 60.0 - raw_angle = params.get("angle_deg") - if raw_angle is not None: - try: - angle = float(raw_angle) - except (TypeError, ValueError) as error: - raise ValueError("thread angle_deg must be numeric") from error - if not 0 < angle < 180: - raise ValueError("thread angle_deg must be between 0 and 180") - relief = float(params.get("relief_length_mm") or 0.0) - crest_r = float(params.get("crest_radius_mm") or 0.0) - root_r = float(params.get("root_radius_mm") or 0.0) - if relief < 0: - raise ValueError("thread relief_length_mm must be non-negative") - if crest_r < 0 or root_r < 0: - raise ValueError("thread crest/root radius must be non-negative") - # 清根退化保护:crest + root 圆角半径之和不能超过可用牙高的一半, - # 否则梯形牙截面会被 fillet 完全吃掉,无法形成封闭截面。 - depth_radius = (major - minor) / 2.0 - if crest_r + root_r > 0.5 * depth_radius: - raise ValueError("thread crest_radius + root_radius exceeds half the thread depth") - # thread_cut 语义上恒为内螺纹:CDSL 输入无需显式传 internal,即使传 - # internal=False 也强制为 True(外螺纹切槽没有任何物理意义,且外轮廓 - # 刀具做布尔差会在 coincident faces 上退化)。 - internal = True if atomic_id == "thread_cut" else bool(params.get("internal", False)) - return cls( - major_diameter_mm=major, - minor_diameter_mm=minor, - pitch_mm=pitch, - length_mm=length, - axis=AxisSpec.from_mapping(raw_axis), - angle_deg=angle, - internal=internal, - lefthand=bool(params.get("lefthand", False)), - relief_length_mm=relief, - crest_radius_mm=crest_r, - root_radius_mm=root_r, - ) - - -@dataclass(frozen=True) -class BendLeg: - """One straight wing of a bent sheet-metal chain (runtime-neutral). - - ``leg_mm`` is the straight mid-plane distance from the previous fold vertex - to the fold vertex leaving this wing (the unfolded chord length between the - two surrounding fold vertices). When the wing has a following wing, - ``bend_angle_deg`` is the required *interior* angle between the two wings - (0 < angle < 180, 90 = a right-angle bend), ``inner_radius_mm`` the inner - bend-surface fillet radius (>= 0; the outer radius is always - ``inner_radius_mm + thickness_mm``) and ``side`` the fold direction - (+1 / -1). A trailing wing carries no fold; stray fold fields on the last - wing are ignored for tolerance. - """ - - leg_mm: float - bend_angle_deg: float | None = None - inner_radius_mm: float = 0.0 - side: int = 1 - - -@dataclass(frozen=True) -class BendSpec: - """Runtime-neutral definition of a sheet-metal bend (``bend_add``). - - The part is described by its mid-plane centreline: ``chain`` lists the - straight wings joined by equal-thickness bend corners. ``thickness_mm`` - and ``width_mm`` are the sheet thickness and the full length along the fold - (width) axis. ``frame.origin_mm`` anchors the start of the first wing's - mid-plane path, ``frame.x_dir`` is the fold/width axis and - ``frame.normal`` is the mid-plane normal of the first wing; the first wing - extends along ``normal x x_dir``. - - The spec deliberately contains no OCC planes or shapes. The adapter turns - this definition into a bent solid keeping source-contract parsing separate - from B-rep work (same pattern as :class:`ThreadSpec`). - """ - - thickness_mm: float - width_mm: float - frame: PlaneSpec - chain: tuple[BendLeg, ...] - - @classmethod - def from_feature(cls, params: dict[str, Any]) -> "BendSpec": - try: - thickness = float(params.get("thickness_mm") or 0.0) - width = float(params.get("width_mm") or 0.0) - except (TypeError, ValueError) as error: - raise ValueError("bend thickness/width must be numeric") from error - if thickness <= 0 or width <= 0: - raise ValueError("bend requires positive thickness_mm and width_mm in millimetres") - raw_chain = params.get("chain") - if not isinstance(raw_chain, (list, tuple)) or not raw_chain: - raise ValueError("bend requires a non-empty chain of wing segments") - chain: list[BendLeg] = [] - for index, raw_leg in enumerate(raw_chain): - if not isinstance(raw_leg, dict): - raise ValueError(f"bend chain[{index}] must be an object with leg_mm") - try: - leg = float(raw_leg.get("leg_mm") or 0.0) - except (TypeError, ValueError) as error: - raise ValueError(f"bend chain[{index}].leg_mm must be numeric") from error - if leg <= 0: - raise ValueError(f"bend chain[{index}].leg_mm must be positive") - bend_angle: float | None = None - if index < len(raw_chain) - 1: - raw_angle = raw_leg.get("bend_angle_deg") - if raw_angle is None: - raise ValueError( - f"bend chain[{index}] needs bend_angle_deg for the fold towards the next wing" - ) - try: - bend_angle = float(raw_angle) - except (TypeError, ValueError) as error: - raise ValueError(f"bend chain[{index}].bend_angle_deg must be numeric") from error - if not 0 < bend_angle < 180: - raise ValueError("bend interior angle must be between 0 and 180 degrees") - radius = float(raw_leg.get("inner_radius_mm") or 0.0) - raw_side = raw_leg.get("side") - side = 1 if raw_side is None else int(raw_side) - if radius < 0: - raise ValueError(f"bend chain[{index}].inner_radius_mm must be non-negative") - if side not in (1, -1): - raise ValueError(f"bend chain[{index}].side must be +1 or -1") - chain.append(BendLeg( - leg_mm=leg, - bend_angle_deg=bend_angle, - inner_radius_mm=radius, - side=side, - )) - raw_frame = params.get("frame") - if isinstance(raw_frame, dict): - frame = PlaneSpec.from_mapping(raw_frame) - else: - # frame 缺省:首翼沿世界 +X 延伸、厚度沿 +Y、折痕沿 +Z(贴 XY 平面)。 - frame = PlaneSpec( - origin_mm=(0.0, 0.0, 0.0), - x_dir=(0.0, 0.0, 1.0), - y_dir=(1.0, 0.0, 0.0), - normal=(0.0, 1.0, 0.0), - ) - return cls(thickness_mm=thickness, width_mm=width, frame=frame, chain=tuple(chain)) - - -@dataclass(frozen=True) -class GearSpec: - """Runtime-neutral definition of an involute spur/helical/herringbone gear. - - ``module_mm``/``teeth_count``/``pressure_angle_rad`` follow the ISO - involute convention: pitch radius ``m*z/2``, base radius - ``m*z/2*cos(alpha)``, addendum ``m`` and dedendum ``1.25*m``. ``width_mm`` - is the axial face width. ``helix_angle_rad`` is the pitch-cylinder helix - angle (0 = spur); ``herringbone=True`` builds a symmetric double-helical - gear whose helix reverses at mid-width (the mid-plane seam is shared - phase, no V-notch). ``axis.origin_mm`` is the centre of the ``z = 0`` end - face and ``axis.direction`` the outward gear axis. - """ - - module_mm: float - teeth_count: int - width_mm: float - axis: AxisSpec - helix_angle_rad: float = 0.0 - pressure_angle_rad: float = pi / 9.0 # 20 degrees - herringbone: bool = False - - @classmethod - def from_feature(cls, params: dict[str, Any]) -> "GearSpec": - try: - module = float(params.get("module_mm") or 0.0) - width = float(params.get("width_mm") or 0.0) - teeth = int(params.get("teeth_count") or 0) - helix_raw = params.get("helix_angle_rad") - helix = float(helix_raw) if helix_raw is not None else 0.0 - pressure_raw = params.get("pressure_angle_rad") - pressure = float(pressure_raw) if pressure_raw is not None else pi / 9.0 - except (TypeError, ValueError) as error: - raise ValueError("gear dimensions must be numeric") from error - if module <= 0 or width <= 0: - raise ValueError("gear requires positive module_mm and width_mm") - if not 8 <= teeth <= 200: - raise ValueError("gear teeth_count must be between 8 and 200") - if not 0.0 <= helix < radians(45.0): - raise ValueError("gear helix_angle_rad must be within [0, 45) degrees") - if not 0.0 < pressure <= radians(35.0): - raise ValueError("gear pressure_angle_rad must be within (0, 35] degrees") - herringbone = bool(params.get("herringbone", False)) - if herringbone and helix <= 0.0: - raise ValueError("herringbone gear requires helix_angle_rad > 0") - raw_axis = params.get("axis") - if not isinstance(raw_axis, dict): - raise ValueError("gear requires an axis definition") - return cls( - module_mm=module, - teeth_count=teeth, - width_mm=width, - axis=AxisSpec.from_mapping(raw_axis), - helix_angle_rad=helix, - pressure_angle_rad=pressure, - herringbone=herringbone, - ) - - @property - def pitch_radius_mm(self) -> float: - return self.module_mm * self.teeth_count / 2.0 - - @property - def base_radius_mm(self) -> float: - return self.pitch_radius_mm * cos(self.pressure_angle_rad) - - @property - def tip_radius_mm(self) -> float: - return self.pitch_radius_mm + self.module_mm - - @property - def root_radius_mm(self) -> float: - return self.pitch_radius_mm - 1.25 * self.module_mm - - -@dataclass(frozen=True) -class RackSpec: - """Runtime-neutral definition of a straight-sided rack (``rack_add``). - - The rack is the linear counterpart of a gear: tooth pitch ``p = pi*m``, - addendum ``m`` above the reference line and dedendum ``1.25*m`` below it, - flanks inclined by ``pressure_angle_rad``. ``teeth_count`` sets the exact - overall length ``teeth_count * pi * m`` (both ends land in tooth valley - centres). ``thickness_mm`` is the width along the tooth crest direction. - ``axis.origin_mm`` is the corner point where the length-start end face, - the thickness-start face and the tooth-valley plane intersect, and - ``axis.direction`` is the direction the teeth point (local +Z). - """ - - module_mm: float - teeth_count: int - thickness_mm: float - axis: AxisSpec - pressure_angle_rad: float = pi / 9.0 # 20 degrees - - @classmethod - def from_feature(cls, params: dict[str, Any]) -> "RackSpec": - try: - module = float(params.get("module_mm") or 0.0) - thickness = float(params.get("thickness_mm") or 0.0) - teeth = int(params.get("teeth_count") or 0) - pressure_raw = params.get("pressure_angle_rad") - pressure = float(pressure_raw) if pressure_raw is not None else pi / 9.0 - except (TypeError, ValueError) as error: - raise ValueError("rack dimensions must be numeric") from error - if module <= 0 or thickness <= 0: - raise ValueError("rack requires positive module_mm and thickness_mm") - if not 1 <= teeth <= 2000: - raise ValueError("rack teeth_count must be between 1 and 2000") - if not 0.0 < pressure <= radians(35.0): - raise ValueError("rack pressure_angle_rad must be within (0, 35] degrees") - raw_axis = params.get("axis") - if not isinstance(raw_axis, dict): - raise ValueError("rack requires an axis definition") - return cls( - module_mm=module, - teeth_count=teeth, - thickness_mm=thickness, - axis=AxisSpec.from_mapping(raw_axis), - pressure_angle_rad=pressure, - ) - - @property - def pitch_mm(self) -> float: - return pi * self.module_mm - - @property - def length_mm(self) -> float: - return self.pitch_mm * self.teeth_count - - @property - def addendum_mm(self) -> float: - return self.module_mm - - @property - def dedendum_mm(self) -> float: - return 1.25 * self.module_mm - - -@dataclass(frozen=True) -class RuntimeDiagnostic: - code: str - message: str - feature_id: str | None = None - detail: dict[str, Any] = field(default_factory=dict) - - def as_dict(self) -> dict[str, Any]: - output: dict[str, Any] = {"code": self.code, "message": self.message} - if self.feature_id is not None: - output["feature_id"] = self.feature_id - if self.detail: - output["detail"] = self.detail - return output - - -@dataclass(frozen=True) -class CapabilityResult: - feature_id: str - atomic_id: str - resolved_status: str - required_capabilities: tuple[str, ...] = () - blockers: tuple[RuntimeDiagnostic, ...] = () - - @property - def executable(self) -> bool: - return self.resolved_status == "executable" - - def as_dict(self) -> dict[str, Any]: - return { - "feature_id": self.feature_id, - "atomic_id": self.atomic_id, - "resolved_status": self.resolved_status, - "required_capabilities": list(self.required_capabilities), - "blockers": [blocker.as_dict() for blocker in self.blockers], - } - - -@dataclass(frozen=True) -class FeaturePlanNode: - feature_id: str - atomic_id: str - name: str | None - depends_on: tuple[str, ...] - params: dict[str, Any] - selectors: tuple[dict[str, Any], ...] - sketch_id: str | None - declared_status: str | None - source_feature: dict[str, Any] - - -@dataclass -class FeatureResult: - feature_id: str - atomic_id: str - status: str - body_id: str | None = None - surface_id: str | None = None - context: PlaneSpec | AxisSpec | None = None - replay_definition: dict[str, Any] | None = None - diagnostics: list[RuntimeDiagnostic] = field(default_factory=list) - - def as_dict(self) -> dict[str, Any]: - output: dict[str, Any] = { - "feature_id": self.feature_id, - "atomic_id": self.atomic_id, - "status": self.status, - "diagnostics": [diagnostic.as_dict() for diagnostic in self.diagnostics], - } - if self.body_id is not None: - output["body_id"] = self.body_id - if self.surface_id is not None: - output["surface_id"] = self.surface_id - if self.context is not None: - output["context"] = self.context.as_dict() - if self.replay_definition is not None: - output["replay_definition"] = self.replay_definition - return output - - -@dataclass(frozen=True) -class TopologyRecord: - """Runtime-side signature of a topology item or context object. - - ``feature_id`` identifies the feature that produced this *snapshot*. - ``owner_feature_ids`` is durable semantic provenance for an unchanged - current B-rep item. Boolean and dress-up operations replace OCC objects, - so keeping these concepts separate prevents a later mutation from making - every surviving face appear to be owned by that mutation. - """ - - record_id: str - kind: str - feature_id: str - body_id: str | None = None - geometry: dict[str, Any] = field(default_factory=dict) - value: Any = None - owner_feature_ids: tuple[str, ...] = () - # Builder-produced roles describe a particular result subshape. They are - # deliberately separate from the geometric signature: equal geometry does - # not prove that two faces have the same feature-output meaning. - output_roles: tuple[str, ...] = () - # Generated roles may carry the direct feature-output role that the kernel - # operation transformed. This is semantic provenance, not a stable-id - # shortcut: the resolver still requires the exact active result snapshot. - output_role_sources: tuple[tuple[str, str, str], ...] = () - - @property - def owners(self) -> tuple[str, ...]: - """Return durable provenance, retaining compatibility for contexts.""" - return self.owner_feature_ids or (self.feature_id,) - - def public_dict(self) -> dict[str, Any]: - output: dict[str, Any] = { - "record_id": self.record_id, - "kind": self.kind, - "feature_id": self.feature_id, - "geometry": self.geometry, - } - if self.body_id is not None: - output["body_id"] = self.body_id - if self.owner_feature_ids: - output["owner_feature_ids"] = list(self.owner_feature_ids) - if self.output_roles: - output["output_roles"] = list(self.output_roles) - if self.output_role_sources: - output["output_role_sources"] = [ - {"output_role": role, "owner_feature_id": owner, "source_output_role": source_role} - for role, owner, source_role in self.output_role_sources - ] - return output - - -@dataclass(frozen=True) -class TopologyDeltaRelation: - """One opaque kernel-history relationship for a topology subshape. - - Geometry adapters retain ownership of the values in this structure. They - are intentionally opaque to the runtime: a build123d/OCC adapter may use - ``TopoDS_Shape`` values while another adapter can use its native handles. - The registry only asks whether a handle is exactly the same topology item; - it never uses this contract to score nearby geometry. - """ - - event: str - kind: str - source_value: Any - result_values: tuple[Any, ...] = () - output_role: str | None = None - - def __post_init__(self) -> None: - if self.event not in {"preserved", "modified", "generated", "deleted"}: - raise ValueError(f"unsupported topology delta event {self.event!r}") - if self.kind not in {"face", "edge", "vertex"}: - raise ValueError(f"unsupported topology delta kind {self.kind!r}") - if self.event == "deleted" and (self.result_values or self.output_role is not None): - raise ValueError("deleted topology delta relations cannot have result values or an output role") - if self.output_role is not None and (not isinstance(self.output_role, str) or not self.output_role): - raise ValueError("topology delta output_role must be a non-empty string when provided") - - -@dataclass(frozen=True) -class TopologyDelta: - """Kernel-backed topology history for one adapter operation. - - ``operation`` is evidence only. The runtime transfers durable provenance - solely from a unique exact relationship, never from an operation name or a - geometric resemblance. - """ - - operation: str - relations: tuple[TopologyDeltaRelation, ...] = () - - -@dataclass(frozen=True) -class SelectorResolution: - selector: dict[str, Any] - status: str - record: TopologyRecord | None = None - candidates: tuple[dict[str, Any], ...] = () - diagnostic: RuntimeDiagnostic | None = None - - def as_dict(self) -> dict[str, Any]: - output = { - "selector": self.selector, - "status": self.status, - "candidates": list(self.candidates), - } - if self.record is not None: - output["record"] = self.record.public_dict() - output["selected"] = self.record.public_dict() - score = next( - (candidate.get("score") for candidate in self.candidates if candidate.get("record_id") == self.record.record_id), - None, - ) - if score is not None: - output["score"] = score - if self.diagnostic is not None: - output["diagnostic"] = self.diagnostic.as_dict() - return output - - -class TopologyRegistry: - """Feature-scoped context/topology registry with explainable matching.""" - - def __init__(self) -> None: - self._records: list[TopologyRecord] = [] - self._by_feature: dict[str, list[TopologyRecord]] = {} - self._active_body_id: str | None = None - self._topology_deltas: list[dict[str, Any]] = [] - # #8 selector 持久性:old_record_id -> [new_record_id]。fillet/chamfer - # 会把一条直线边拆分为若干段(中间直段 + 两端圆弧),旧边不再与任何 - # 新边几何等价;这里记录"位置轨迹延续"的直段后继,使后续 selector 的 - # stable_id 引用可以解析到 active body 内的新形态。 - self._successors: dict[str, list[str]] = {} - - def register(self, record: TopologyRecord) -> None: - self._records.append(record) - self._by_feature.setdefault(record.feature_id, []).append(record) - - def records_for_feature(self, feature_id: str) -> tuple[TopologyRecord, ...]: - return tuple(self._by_feature.get(feature_id, ())) - - def records(self) -> tuple[TopologyRecord, ...]: - return tuple(self._records) - - def topology_deltas(self) -> tuple[dict[str, Any], ...]: - """Return serializable evidence derived from exact adapter history.""" - return tuple(self._topology_deltas) - - def register_context(self, feature_id: str, context: PlaneSpec | AxisSpec) -> TopologyRecord: - kind = "plane" if isinstance(context, PlaneSpec) else "axis" - record = TopologyRecord( - record_id=f"{feature_id}:{kind}", - kind=kind, - feature_id=feature_id, - geometry=context.as_dict(), - value=context, - ) - self.register(record) - return record - - def replace_body_topology( - self, feature_id: str, body_id: str, records: Iterable[TopologyRecord], - *, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None, - additional_predecessors: Iterable[TopologyRecord] = (), - ) -> None: - self.replace_body_topologies( - feature_id, [(body_id, records)], active_body_id=active_body_id, topology_delta=topology_delta, - additional_predecessors=additional_predecessors, - ) - - def replace_body_topologies( - self, feature_id: str, bodies: Iterable[tuple[str, Iterable[TopologyRecord]]], - *, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None, - additional_predecessors: Iterable[TopologyRecord] = (), - ) -> None: - """Record a fresh B-rep snapshot after a feature mutates the body. - - OCC topology object identity is invalidated by most body mutations. - We therefore keep old objects out of active selector resolution but - carry their semantic owners forward when, and only when, one current - object has one geometrically equivalent predecessor. A changed or - split object intentionally becomes owned by this feature instead of - being guessed as belonging to an older one. - - ``active_body_id`` names the whole-body group when ``body_id`` is a - member of a multi-solid body (issue #7): the group id keeps the next - mutation's predecessor lookup scoped to every solid of the previous - body, while each member keeps its own ``body:{feature}:{index}`` id. - """ - active_previous = [ - record for record in self._records - if self._active_body_id is not None and record.body_id is not None - and ( - record.body_id == self._active_body_id - or record.body_id.startswith(f"{self._active_body_id}:") - ) - ] - # A pattern COPY can carry a chain of exact transform/boolean builder - # histories before its final aggregate snapshot is registered. These - # temporary records are valid predecessors only for that documented - # kernel-history bridge. They are deliberately excluded from geometric - # fallback matching: an equal-looking final face never proves that it - # belongs to one particular copy instance. - transient_previous = list(additional_predecessors) - previous = [*active_previous, *transient_previous] - # 同一 source feature 的 pattern copy 可以产生完全相同的几何面。它们 - # 必须保留为多个实例,不能在跨 body 的全局 predecessor 匹配中互相消费。 - # pattern 的 Compound 成员顺序是稳定的:已有实例以同一 member index - # 延续,新增实例只会出现在末尾。按该 index 限定后继匹配。 - current = [(body_id, list(records)) for body_id, records in bodies] - current_records = [record for _body_id, records in current for record in records] - ( - exact_predecessors, - exact_successors, - kernel_covered_predecessors, - exact_output_roles, - exact_output_role_sources, - delta_evidence, - ) = self._exact_delta_links( - topology_delta, previous, current_records, - ) - previous_member_ids = { - suffix for record in active_previous - for suffix in [str(record.body_id).rsplit(":", 1)[-1]] - if suffix.isdigit() - } - use_member_indexes = len(current) > 1 and previous_member_ids - consumed_predecessors: set[str] = set() - registered: list[TopologyRecord] = [] - for body_id, records in current: - member_id = str(body_id).rsplit(":", 1)[-1] - local_predecessors = [ - record for record in active_previous - if not use_member_indexes or str(record.body_id).rsplit(":", 1)[-1] == member_id - ] - for record in records: - exact_predecessor_id = exact_predecessors.get(record.record_id) - # Member order is a useful isolation boundary for geometric - # fallback matching, especially for coincident pattern - # copies. It is not a provenance boundary when a kernel - # builder explicitly relates one source subshape to one - # result subshape: boolean/delete lifecycle can remove an - # earlier member and shift a surviving source to another - # member index. A unique OCC continuation remains exact - # evidence across that index change. - predecessor = next( - (prior for prior in previous if prior.record_id == exact_predecessor_id), - None, - ) - if predecessor is None and record.record_id not in exact_predecessors: - predecessor = self._unique_equivalent_predecessor(record, local_predecessors, consumed_predecessors) - owners = predecessor.owners if predecessor is not None else (feature_id,) - output_roles = set(record.output_roles) - output_roles.update(exact_output_roles.get(record.record_id, ())) - output_role_sources = set(record.output_role_sources) - output_role_sources.update(exact_output_role_sources.get(record.record_id, ())) - # A feature-output role can survive a later operation only - # through the same unique kernel continuation used for owner - # provenance. Geometry equivalence alone never carries it. - if predecessor is not None and record.record_id in exact_predecessors: - output_roles.update(predecessor.output_roles) - if predecessor is not None and record.record_id not in exact_predecessors: - consumed_predecessors.add(predecessor.record_id) - registered.append(TopologyRecord( - record_id=record.record_id, - kind=record.kind, - feature_id=feature_id, - body_id=body_id, - geometry=dict(record.geometry), - value=record.value, - owner_feature_ids=owners, - output_roles=tuple(sorted(output_roles)), - output_role_sources=tuple(sorted(output_role_sources)), - )) - for record in registered: - self.register(record) - for predecessor_id, successor_ids in exact_successors.items(): - known = self._successors.setdefault(predecessor_id, []) - for successor_id in successor_ids: - if successor_id not in known: - known.append(successor_id) - if delta_evidence is not None: - self._topology_deltas.append({ - "feature_id": feature_id, - "operation": topology_delta.operation, - "relations": delta_evidence, - }) - # #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续 - # selector 的 stable_id 引用解析到 active body 内的新形态。多条演化 - # 候选时只登记"漂移显著最小"的那条(例如底面边圆角后既有缩短的直段 - # 也有圆角过渡带的新边,前者的端点与原边重合、漂移更小);漂移并列 - # (如竖直边被完整消费成两条等距直段)属于本质歧义,保守不登记。 - for prior in previous: - if ( - prior.record_id in consumed_predecessors - or prior.record_id in exact_successors - or prior.record_id in kernel_covered_predecessors - ): - continue - candidates = sorted( - ( - (self._evolved_drift(prior, record), record.record_id) - for record in registered if self._evolved_equivalent(prior, record) - ), - key=lambda item: item[0], - ) - if not candidates: - continue - best, second = candidates[0], (candidates[1] if len(candidates) > 1 else None) - if second is None or (second[0] - best[0]) > max(0.5, 0.2 * best[0]): - self._successors[prior.record_id] = [best[1]] - self._active_body_id = active_body_id or body_id - - @staticmethod - def _same_topology_value(left: Any, right: Any) -> bool: - """Compare adapter handles only through their exact topology identity.""" - left_value = getattr(left, "wrapped", left) - right_value = getattr(right, "wrapped", right) - if left_value is right_value: - return True - for candidate, other in ((left_value, right_value), (right_value, left_value)): - for method_name in ("IsSame", "is_same"): - method = getattr(candidate, method_name, None) - if callable(method): - try: - return bool(method(other)) - except (AttributeError, TypeError, ValueError): - continue - return False - - @classmethod - def _exact_delta_links( - cls, - topology_delta: TopologyDelta | None, - previous: list[TopologyRecord], - current: list[TopologyRecord], - ) -> tuple[ - dict[str, str], - dict[str, list[str]], - set[str], - dict[str, tuple[str, ...]], - dict[str, tuple[tuple[str, str, str], ...]], - list[dict[str, Any]] | None, - ]: - """Bind opaque kernel history to snapshots without geometric guessing. - - Ownership transfer is intentionally limited to a single source item and - a single output item. Split/merge history remains useful evidence, but - has no unique owner continuation until a later operation-specific - contract can express it. - """ - if topology_delta is None: - return {}, {}, set(), {}, {}, None - candidate_sources: dict[str, set[str]] = {} - kernel_covered_predecessors: set[str] = set() - output_roles: dict[str, set[str]] = {} - output_role_sources: dict[str, set[tuple[str, str, str]]] = {} - relation_links: list[tuple[str, str] | None] = [] - evidence: list[dict[str, Any]] = [] - for relation in topology_delta.relations: - sources = [ - record for record in previous - if record.kind == relation.kind and cls._same_topology_value(record.value, relation.source_value) - ] - outputs = [ - record for record in current - if record.kind == relation.kind - and any(cls._same_topology_value(record.value, value) for value in relation.result_values) - ] - item = { - "event": relation.event, - "kind": relation.kind, - "source_record_ids": [record.record_id for record in sources], - "result_record_ids": [record.record_id for record in outputs], - "proof": "kernel_history", - } - if relation.output_role is not None: - item["output_role"] = relation.output_role - role_is_unique = len(relation.result_values) == 1 and len(outputs) == 1 - item["output_role_status"] = ( - "unique_result_snapshot" if role_is_unique else "non_unique_or_missing_result_snapshot" - ) - if role_is_unique: - output_roles.setdefault(outputs[0].record_id, set()).add(relation.output_role) - for source in sources: - for source_role in source.output_roles: - for source_owner in source.owners: - output_role_sources.setdefault(outputs[0].record_id, set()).add( - (relation.output_role, source_owner, source_role) - ) - if len(sources) == 1: - kernel_covered_predecessors.add(sources[0].record_id) - can_transfer = ( - relation.event in {"preserved", "modified"} - and len(relation.result_values) == 1 - and len(sources) == 1 - and len(outputs) == 1 - ) - if can_transfer: - source_id, result_id = sources[0].record_id, outputs[0].record_id - candidate_sources.setdefault(result_id, set()).add(source_id) - relation_links.append((source_id, result_id)) - else: - relation_links.append(None) - evidence.append(item) - predecessors = { - result_id: next(iter(source_ids)) - for result_id, source_ids in candidate_sources.items() - if len(source_ids) == 1 - } - successors: dict[str, list[str]] = {} - for result_id, source_id in predecessors.items(): - successors.setdefault(source_id, []).append(result_id) - for item, relation, link in zip(evidence, topology_delta.relations, relation_links): - if link is not None: - _source_id, result_id = link - item["status"] = ( - "unique_exact_continuation" - if len(candidate_sources[result_id]) == 1 else "ambiguous_exact_continuation" - ) - elif relation.event in {"preserved", "modified"}: - item["status"] = "non_unique_or_incomplete" - else: - item["status"] = "recorded_without_owner_transfer" - return ( - predecessors, - successors, - kernel_covered_predecessors, - {record_id: tuple(sorted(roles)) for record_id, roles in output_roles.items()}, - {record_id: tuple(sorted(sources)) for record_id, sources in output_role_sources.items()}, - evidence, - ) - - @staticmethod - def _numbers_equal(left: Any, right: Any, *, tolerance: float = 1e-6) -> bool: - try: - return abs(float(left) - float(right)) <= tolerance - except (TypeError, ValueError): - return False - - @classmethod - def _vectors_equal(cls, left: Any, right: Any, *, tolerance: float = 1e-6) -> bool: - try: - first = _vector3(left, field_name="prior topology geometry") - second = _vector3(right, field_name="current topology geometry") - except ValueError: - return False - return all(abs(a - b) <= tolerance for a, b in zip(first, second)) - - @classmethod - def _geometry_equivalent(cls, prior: TopologyRecord, current: TopologyRecord) -> bool: - """Check a complete, orientation-aware snapshot signature. - - This is intentionally much stricter than selector scoring. Selector - scoring may compare partial source evidence; provenance transfer must - never manufacture ownership from a merely similar candidate. - """ - if prior.kind != current.kind: - return False - left, right = prior.geometry, current.geometry - for key in ("surface_type", "curve_type"): - if left.get(key) != right.get(key): - return False - for key in ("bbox_mm", "center_mm", "normal", "plane_normal"): - if key in left or key in right: - if key not in left or key not in right: - return False - left_value, right_value = left[key], right[key] - if key == "bbox_mm": - if not isinstance(left_value, (list, tuple)) or not isinstance(right_value, (list, tuple)): - return False - if len(left_value) != 6 or len(right_value) != 6: - return False - if not all(cls._numbers_equal(a, b) for a, b in zip(left_value, right_value)): - return False - elif not cls._vectors_equal(left_value, right_value): - return False - for key in ("area_mm2", "length_mm", "plane_offset_mm"): - if key in left or key in right: - if key not in left or key not in right or not cls._numbers_equal(left[key], right[key]): - return False - for key in ("adjacency_signature", "adjacent_face_count", "incident_edge_count"): - if key in left or key in right: - if key not in left or key not in right or left[key] != right[key]: - return False - left_start, left_end = left.get("start_mm"), left.get("end_mm") - right_start, right_end = right.get("start_mm"), right.get("end_mm") - if any(value is not None for value in (left_start, left_end, right_start, right_end)): - if None in (left_start, left_end, right_start, right_end): - return False - same_direction = cls._vectors_equal(left_start, right_start) and cls._vectors_equal(left_end, right_end) - reverse_direction = cls._vectors_equal(left_start, right_end) and cls._vectors_equal(left_end, right_start) - if not same_direction and not reverse_direction: - return False - return True - - @classmethod - def _unique_equivalent_predecessor( - cls, - current: TopologyRecord, - predecessors: Iterable[TopologyRecord], - consumed_predecessors: set[str], - ) -> TopologyRecord | None: - matches = [ - record for record in predecessors - if record.record_id not in consumed_predecessors and cls._geometry_equivalent(record, current) - ] - return matches[0] if len(matches) == 1 else None - - @staticmethod - def _evolved_drift(prior: TopologyRecord, current: TopologyRecord) -> float | None: - """Endpoint drift between direction-aligned straight edges. - - Returns the minimum total endpoint drift (mm) when the two edges are - collinear straight lines (either orientation), otherwise ``None``. - """ - if prior.kind != current.kind: - return None - left, right = prior.geometry, current.geometry - if left.get("curve_type") != "line" or right.get("curve_type") != "line": - return None - if None in (left.get("start_mm"), left.get("end_mm"), right.get("start_mm"), right.get("end_mm")): - return None - - def _delta(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]: - return (b[0] - a[0], b[1] - a[1], b[2] - a[2]) - - def _dist(a: tuple[float, float, float], b: tuple[float, float, float]) -> float: - return sqrt(sum((a[i] - b[i]) ** 2 for i in range(3))) - - left_dir = _delta(left["start_mm"], left["end_mm"]) - right_dir = _delta(right["start_mm"], right["end_mm"]) - if _length(left_dir) <= 1e-9 or _length(right_dir) <= 1e-9: - return None - cross = ( - left_dir[1] * right_dir[2] - left_dir[2] * right_dir[1], - left_dir[2] * right_dir[0] - left_dir[0] * right_dir[2], - left_dir[0] * right_dir[1] - left_dir[1] * right_dir[0], - ) - if _length(cross) / (_length(left_dir) * _length(right_dir)) > 1e-3: - return None - same_order = _dist(left["start_mm"], right["start_mm"]) + _dist(left["end_mm"], right["end_mm"]) - reversed_order = _dist(left["start_mm"], right["end_mm"]) + _dist(left["end_mm"], right["start_mm"]) - return min(same_order, reversed_order) - - @classmethod - def _evolved_equivalent(cls, prior: TopologyRecord, current: TopologyRecord, *, drift_mm: float = 5.0) -> bool: - """Loose "position trajectory" equivalence used for evolved successors. - - Unlike ``_geometry_equivalent`` (strict, anti-false-positive provenance), - this deliberately tolerates small endpoint drift: fillet/chamfer split a - straight edge into segments (a middle straight run plus end arcs). The - straight run keeps the same direction and stays within ``drift_mm`` of the - original edge, so it can serve as the edge's evolved successor. Uniqueness - is enforced by the caller (only a single best candidate is recorded). - """ - drift = cls._evolved_drift(prior, current) - return drift is not None and drift <= drift_mm - - @staticmethod - def _vector_score(expected: Any, actual: Any, tolerance: float = 1e-4) -> float | None: - try: - left = _vector3(expected, field_name="selector geometry") - right = _vector3(actual, field_name="record geometry") - except ValueError: - return None - error = _length(tuple(a - b for a, b in zip(left, right))) - return max(0.0, 1.0 - error / tolerance) - - @classmethod - def _geometry_score(cls, selector_geometry: dict[str, Any], record_geometry: dict[str, Any]) -> float | None: - if not selector_geometry: - return 0.0 - scores: list[float] = [] - for key in ("center_mm", "circle_center_mm", "normal", "origin_mm", "direction", "plane_normal", "start_mm", "end_mm"): - if key in selector_geometry: - score = cls._vector_score(selector_geometry[key], record_geometry.get(key)) - if score is None: - return None - scores.append(score) - for key in ("surface_type", "curve_type"): - if key in selector_geometry: - if record_geometry.get(key) != selector_geometry[key]: - # #8 selector 持久性:fillet/chamfer 会把直线边演化为圆弧、 - # 平面演化为柱面,但被选中拓扑的位置锚定(bbox/center/端点) - # 不变。曲线/曲面类型变化不再一票否决,而是记低分:位置完全 - # 重合的候选(同一条边的形态演化)仍可胜出;位置不重合的 - # 相邻边会被 0 分项拉低,仍被 minimum_score 挡住。 - scores.append(0.5) - else: - scores.append(1.0) - if "bbox_mm" in selector_geometry: - expected = selector_geometry["bbox_mm"] - actual = record_geometry.get("bbox_mm") - if not isinstance(expected, list) or not isinstance(actual, list) or len(expected) != len(actual): - return None - delta = max(abs(float(a) - float(b)) for a, b in zip(expected, actual)) - scores.append(max(0.0, 1.0 - delta / 1e-4)) - if "plane_offset_mm" in selector_geometry: - try: - delta = abs(float(selector_geometry["plane_offset_mm"]) - float(record_geometry.get("plane_offset_mm"))) - except (TypeError, ValueError): - return None - scores.append(max(0.0, 1.0 - delta / 1e-4)) - if "radius_mm" in selector_geometry: - try: - delta = abs(float(selector_geometry["radius_mm"]) - float(record_geometry.get("radius_mm"))) - except (TypeError, ValueError): - return None - scores.append(max(0.0, 1.0 - delta / 1e-4)) - if "area_mm2" in selector_geometry: - try: - expected_area = float(selector_geometry["area_mm2"]) - actual_area = float(record_geometry.get("area_mm2")) - except (TypeError, ValueError): - return None - relative_delta = abs(expected_area - actual_area) / max(abs(expected_area), 1e-9) - scores.append(max(0.0, 1.0 - relative_delta / 1e-4)) - return sum(scores) / len(scores) if scores else 0.0 - - def resolve( - self, - selector: dict[str, Any], - *, - minimum_score: float = 0.8, - active_body_id: str | None = None, - ) -> SelectorResolution: - kind = selector.get("kind") - owner = selector.get("owner_feature_id") - candidates = [record for record in self._records if record.kind == kind] - if active_body_id and kind in {"face", "edge", "vertex", "body"}: - # #7 multi-body:记录 body_id 可能是 body:{feature}:{index}(多体 - # 成员),用前缀匹配把整个主体的记录纳入候选,同时保证旧 body 的 - # 记录(不同 feature 前缀)不会泄漏进来。 - candidates = [ - record for record in candidates - if record.body_id == active_body_id - or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:")) - ] - if owner: - candidates = [record for record in candidates if owner in record.owners] - if kind == "plane" and selector.get("frame") is not None: - # #6 pattern 引用重解析:pattern 重放 source(pattern_mirror)时, - # mirror_plane 的 plane 引用由运行时随实例变换后内联为显式 frame - # (_translated_node / _mirrored_node),这里直接构造 PlaneSpec, - # 不再走 stable_id / 几何匹配,避免解析到未随实例变换的原始面。 - try: - plane = PlaneSpec.from_mapping(selector.get("frame") or {}) - except (TypeError, ValueError): - plane = None - if plane is not None: - record = TopologyRecord( - record_id=f"inline:{id(plane)}", - kind="plane", - feature_id=str(owner or "inline"), - geometry=plane.as_dict(), - value=plane, - ) - return SelectorResolution( - selector=selector, - status="resolved", - record=record, - candidates=({"score": 1.0, **record.public_dict()},), - ) - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_frame_incomplete", - message="An inline plane frame requires origin_mm, x_dir and normal", - detail={"frame": selector.get("frame")}, - ), - ) - geometry = normalize_selector_geometry(selector.get("geometry")) - if selector.get("snapshot_id") and not owner: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_owner_required", - message="A snapshot selector requires owner_feature_id", - detail={"minimum_score": minimum_score}, - ), - ) - output_role = str(selector.get("output_role") or "").strip() - if output_role: - if not owner: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_owner_required", - message="A feature output role selector requires owner_feature_id", - detail={"output_role": output_role}, - ), - ) - if active_body_id is None: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_active_body_required", - message="A feature output role selector requires an active body snapshot", - detail={"output_role": output_role}, - ), - ) - if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")): - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_mixed_evidence", - message="A feature output role selector cannot mix stable or geometry evidence", - detail={"output_role": output_role}, - ), - ) - role_candidates = [record for record in candidates if output_role in record.output_roles] - role_source = selector.get("output_role_source") - if role_source is not None: - source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None - source_role = role_source.get("output_role") if isinstance(role_source, dict) else None - if not isinstance(source_owner, str) or not isinstance(source_role, str): - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_source_invalid", - message="An output role selector source requires owner_feature_id and output_role", - ), - ) - if output_role != "shell.offset_face" or source_role not in {"extrude.start", "extrude.end"}: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_source_unsupported", - message="Output role sources are currently supported only for shell.offset_face from an extrusion cap", - ), - ) - role_candidates = [ - record for record in role_candidates - if (output_role, source_owner, source_role) in record.output_role_sources - ] - public_candidates = tuple( - {"score": 1.0, **record.public_dict()} for record in role_candidates - ) - if len(role_candidates) == 1: - return SelectorResolution( - selector=selector, - status="resolved", - record=role_candidates[0], - candidates=public_candidates, - ) - if len(role_candidates) > 1: - return SelectorResolution( - selector=selector, - status="ambiguous", - candidates=public_candidates, - diagnostic=RuntimeDiagnostic( - code="selector_output_role_ambiguous", - message="More than one active topology record has the requested output role", - detail={"output_role": output_role, "candidate_count": len(role_candidates)}, - ), - ) - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_not_found", - message="No active topology record has the requested output role", - detail={"output_role": output_role, "candidate_count": 0}, - ), - ) - stable_id = str(selector.get("stable_id") or "").strip() - if stable_id: - # #8 selector 持久性:stable_id 是跨 body 演化的持久标识符,精确 - # 匹配在 active body 过滤之前对整个记录集(kind + owner 过滤)执行。 - # 命中已过期(旧 body)的记录时,经演化后继映射解析到 active body - # 内的新形态(fillet/chamfer 拆段后的直段后继);无后继则回落到 - # 几何打分流程。 - stable_records = [ - record for record in self._records - if record.kind == kind and (not owner or owner in record.owners) - ] - exact = [record for record in stable_records if record.record_id == stable_id] - if len(exact) == 1: - record = exact[0] - is_active = active_body_id is None or ( - record.body_id == active_body_id - or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:")) - ) - if not is_active: - successors = [ - candidate for candidate in stable_records - if candidate.record_id in self._successors.get(record.record_id, ()) - and ( - candidate.body_id == active_body_id - or (candidate.body_id is not None and active_body_id and candidate.body_id.startswith(f"{active_body_id}:")) - ) - ] - if len(successors) == 1: - record = successors[0] - is_active = True - elif len(successors) > 1: - return SelectorResolution( - selector=selector, - status="ambiguous", - candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors), - diagnostic=RuntimeDiagnostic( - code="selector_ambiguous", - message="More than one evolved successor record satisfies the stable_id", - detail={"stable_id": stable_id, "candidate_count": len(successors)}, - ), - ) - if is_active: - # A stable ID is only a lookup accelerator for snapshot-aware - # selectors. It cannot revive a B-rep entity whose geometric - # signature changed after an upstream rebuild. - if selector.get("snapshot_id"): - score = self._geometry_score(geometry, record.geometry) if geometry else None - if score is None or score < minimum_score: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=({"score": round(float(score or 0), 6), **record.public_dict()},), - diagnostic=RuntimeDiagnostic( - code="selector_geometry_mismatch", - message="The stable selector record no longer matches its geometry signature", - detail={"stable_id": stable_id, "score": score, "minimum_score": minimum_score}, - ), - ) - return SelectorResolution( - selector=selector, - status="resolved", - record=record, - candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},), - ) - if not geometry: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_stable_id_inactive", - message="The stable selector record is not active and has no geometry signature for rebinding", - detail={"stable_id": stable_id}, - ), - ) - if len(exact) > 1: - return SelectorResolution( - selector=selector, - status="ambiguous", - candidates=tuple({"score": 1.0, **record.public_dict()} for record in exact), - diagnostic=RuntimeDiagnostic( - code="selector_ambiguous", - message="More than one runtime topology record has the requested stable_id", - detail={"stable_id": stable_id, "candidate_count": len(exact)}, - ), - ) - if selector.get("snapshot_id") and not geometry: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_geometry_mismatch", - message="A snapshot selector requires a geometry signature", - detail={"minimum_score": minimum_score}, - ), - ) - scored: list[tuple[float, TopologyRecord]] = [] - for candidate in candidates: - # An owner-qualified context selector is deterministic when it has - # a single runtime candidate even if its source stable_id cannot - # survive the SolidWorks -> OCC boundary. - score = 1.0 if not geometry else self._geometry_score(geometry, candidate.geometry) - if score is not None: - scored.append((score, candidate)) - scored.sort(key=lambda item: (-item[0], item[1].record_id)) - public_candidates = tuple({"score": round(score, 6), **record.public_dict()} for score, record in scored) - if not scored or scored[0][0] < minimum_score: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=public_candidates, - diagnostic=RuntimeDiagnostic( - code="selector_not_found", - message="No runtime topology record satisfies the selector", - detail={"candidate_count": len(scored), "minimum_score": minimum_score}, - ), - ) - best_score, best_record = scored[0] - if len(scored) > 1 and abs(scored[1][0] - best_score) <= 1e-9: - return SelectorResolution( - selector=selector, - status="ambiguous", - candidates=public_candidates, - diagnostic=RuntimeDiagnostic( - code="selector_ambiguous", - message="More than one runtime topology record has the best selector score", - detail={"best_score": best_score, "candidate_count": len(scored)}, - ), - ) - return SelectorResolution(selector=selector, status="resolved", record=best_record, candidates=public_candidates) +from .specs import ( + AxisSpec, + BendLeg, + BendSpec, + GearSpec, + HoleSpec, + PlaneSpec, + RackSpec, + ThreadSpec, + Vector3, + canonical_plane_signature, + normalize_selector_geometry, + pattern_instance_member_id, + transform_copy_member_id, + vector_add, + vector_cross, + vector_dot, + vector_scale, + vector_subtract, + vector_unit, +) +from .topology import ( + CapabilityResult, + FeaturePlanNode, + FeatureResult, + RuntimeDiagnostic, + SelectorResolution, + TopologyDelta, + TopologyDeltaRelation, + TopologyRecord, + TopologyRegistry, +) + +__all__ = [ + "AxisSpec", + "BendLeg", + "BendSpec", + "CapabilityResult", + "FeaturePlanNode", + "FeatureResult", + "GearSpec", + "HoleSpec", + "PlaneSpec", + "RackSpec", + "RuntimeDiagnostic", + "SelectorResolution", + "ThreadSpec", + "TopologyDelta", + "TopologyDeltaRelation", + "TopologyRecord", + "TopologyRegistry", + "Vector3", + "canonical_plane_signature", + "normalize_selector_geometry", + "pattern_instance_member_id", + "transform_copy_member_id", + "vector_add", + "vector_cross", + "vector_dot", + "vector_scale", + "vector_subtract", + "vector_unit", +] diff --git a/backend/engine/cdsl_engine/specs.py b/backend/engine/cdsl_engine/specs.py new file mode 100644 index 00000000..30a9a29d --- /dev/null +++ b/backend/engine/cdsl_engine/specs.py @@ -0,0 +1,619 @@ +"""Runtime-neutral CDSL vector math, frames, and parametric feature specs. + +This module deliberately has no build123d dependency. The spec dataclasses +and canonical vector/plane helpers can therefore be used by validation, batch +reporting, and any geometry adapter without importing OCC objects. +""" + +from __future__ import annotations + +import warnings +from dataclasses import dataclass +from math import cos, pi, radians, sqrt +from typing import Any + + +Vector3 = tuple[float, float, float] + + +def pattern_instance_member_id(pattern_feature_id: str, source_feature_id: str, instance_index: int) -> str: + """Return the runtime-only body-member key for one proven pattern copy. + + CDSL keeps the three source fields separately, so callers never need to + manufacture this internal key. The body graph uses the same derivation in + runtime and capability preflight. + """ + return f"pattern:{pattern_feature_id}:{source_feature_id}:copy:{instance_index}" + + +def transform_copy_member_id(transform_feature_id: str, source_member_id: str) -> str: + """Return the runtime-only key for one source of a multi-body COPY. + + A multi-source ``transform_bodies`` COPY has several independently + addressable outputs. CDSL records the transform and its selected source as + separate fields; the opaque key stays internal to the body graph. + """ + return f"transform:{transform_feature_id}:{source_member_id}:copy" + +# y_dir 与 x_dir / normal 点积的绝对值不超过该值时,认为 y_dir 是正交的, +# 予以保留;否则视为偏斜数据,正交化并显式警告。 +_Y_DIR_ORTHOGONALITY_TOL = 1e-6 + + +def _vector3(value: Any, *, field_name: str) -> Vector3: + if not isinstance(value, (list, tuple)) or len(value) != 3: + raise ValueError(f"{field_name} must contain three coordinates") + try: + return (float(value[0]), float(value[1]), float(value[2])) + except (TypeError, ValueError) as error: + raise ValueError(f"{field_name} must contain numeric coordinates") from error + + +def _length(value: Vector3) -> float: + return sqrt(sum(component * component for component in value)) + + +def _unit(value: Vector3, *, field_name: str) -> Vector3: + magnitude = _length(value) + if magnitude <= 1e-12: + raise ValueError(f"{field_name} must be non-zero") + return tuple(component / magnitude for component in value) # type: ignore[return-value] + + +def _dot(left: Vector3, right: Vector3) -> float: + return sum(a * b for a, b in zip(left, right)) + + +def _cross(left: Vector3, right: Vector3) -> Vector3: + return ( + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ) + + +def vector_add(left: Vector3, right: Vector3) -> Vector3: + return tuple(a + b for a, b in zip(left, right)) # type: ignore[return-value] + + +def vector_subtract(left: Vector3, right: Vector3) -> Vector3: + return tuple(a - b for a, b in zip(left, right)) # type: ignore[return-value] + + +def vector_scale(value: Vector3, factor: float) -> Vector3: + return tuple(component * factor for component in value) # type: ignore[return-value] + + +def vector_dot(left: Vector3, right: Vector3) -> float: + return _dot(left, right) + + +def vector_cross(left: Vector3, right: Vector3) -> Vector3: + return _cross(left, right) + + +def vector_unit(value: Vector3, *, field_name: str = "vector") -> Vector3: + return _unit(value, field_name=field_name) + + +def canonical_plane_signature(normal: Vector3, point_mm: Vector3) -> tuple[Vector3, float]: + """Normalize a plane sign so source and OCC face orientations compare.""" + unit_normal = _unit(normal, field_name="plane.normal") + offset = _dot(unit_normal, point_mm) + for component in unit_normal: + if abs(component) <= 1e-12: + continue + if component < 0: + unit_normal = tuple(-value for value in unit_normal) # type: ignore[assignment] + offset = -offset + break + return unit_normal, offset + + +def normalize_selector_geometry(geometry: Any) -> dict[str, Any]: + """Convert legacy SolidWorks selector evidence into runtime-neutral units. + + Current CDSL records may already carry ``*_mm`` fields. Older exported + evidence instead stores SolidWorks surface parameters, boxes, and areas in + SI units. The selector remains the source of truth; this function only + makes its geometric signature comparable to an OCC topology snapshot. + """ + if not isinstance(geometry, dict): + return {} + result = dict(geometry) + surface = geometry.get("surface") + if isinstance(surface, dict): + surface_type = str(surface.get("type") or "").lower() + if surface_type: + result.setdefault("surface_type", surface_type) + parameters = surface.get("parameters") + if surface_type == "plane" and isinstance(parameters, list) and len(parameters) >= 6: + try: + raw_normal = _vector3(parameters[:3], field_name="selector surface normal") + # SolidWorks evidence uses metres for surface locations. + point_mm = tuple(float(value) * 1000.0 for value in parameters[3:6]) + plane_normal, plane_offset = canonical_plane_signature(raw_normal, point_mm) # type: ignore[arg-type] + result.setdefault("plane_normal", list(plane_normal)) + result.setdefault("plane_offset_mm", plane_offset) + except (TypeError, ValueError): + pass + curve = geometry.get("curve") + if isinstance(curve, dict) and curve.get("type"): + result.setdefault("curve_type", str(curve["type"]).lower()) + raw_box = geometry.get("box") + if isinstance(raw_box, list) and len(raw_box) == 6: + try: + result.setdefault("bbox_mm", [float(value) * 1000.0 for value in raw_box]) + except (TypeError, ValueError): + pass + raw_area = geometry.get("area") + if raw_area is not None: + try: + result.setdefault("area_mm2", float(raw_area) * 1_000_000.0) + except (TypeError, ValueError): + pass + for raw_key, normalized_key in (("start", "start_mm"), ("end", "end_mm")): + value = geometry.get(raw_key) + if isinstance(value, list) and len(value) == 3: + try: + result.setdefault(normalized_key, [float(component) * 1000.0 for component in value]) + except (TypeError, ValueError): + pass + return result + + +@dataclass(frozen=True) +class AxisSpec: + """Canonical axis with a normalized direction.""" + + origin_mm: Vector3 + direction: Vector3 + + @classmethod + def from_mapping(cls, value: dict[str, Any]) -> "AxisSpec": + return cls( + origin_mm=_vector3(value.get("origin_mm"), field_name="axis.origin_mm"), + direction=_unit(_vector3(value.get("direction"), field_name="axis.direction"), field_name="axis.direction"), + ) + + def as_dict(self) -> dict[str, list[float]]: + return {"origin_mm": list(self.origin_mm), "direction": list(self.direction)} + + +@dataclass(frozen=True) +class PlaneSpec: + """Canonical right-handed plane frame. + + SolidWorks exports may contain a redundant or non-orthogonal y direction. + The runtime persists the orthonormalized frame so later features all use + the same coordinate system. + """ + + origin_mm: Vector3 + x_dir: Vector3 + y_dir: Vector3 + normal: Vector3 + + @classmethod + def from_mapping(cls, value: dict[str, Any]) -> "PlaneSpec": + origin = _vector3(value.get("origin_mm"), field_name="plane.origin_mm") + normal = _unit(_vector3(value.get("normal"), field_name="plane.normal"), field_name="plane.normal") + x_raw = _vector3(value.get("x_dir"), field_name="plane.x_dir") + projected_x = tuple(x_raw[index] - _dot(x_raw, normal) * normal[index] for index in range(3)) + x_dir = _unit(projected_x, field_name="plane.x_dir") + generated = _unit(_cross(normal, x_dir), field_name="plane.y_dir") + y_raw = value.get("y_dir") + if y_raw is None: + # y_dir 缺失:用 normal × x_dir 补全右手系(默认行为)。 + y_dir = generated + else: + y_vec = _unit(_vector3(y_raw, field_name="plane.y_dir"), field_name="plane.y_dir") + if abs(_dot(y_vec, x_dir)) <= _Y_DIR_ORTHOGONALITY_TOL and abs(_dot(y_vec, normal)) <= _Y_DIR_ORTHOGONALITY_TOL: + # 输入 y_dir 与 x_dir / normal 正交:尊重文档作者给的坐标方向, + # 不再静默丢弃(SolidWorks 导出的非标准 y_dir 得以保留)。 + y_dir = y_vec + else: + # 偏斜 y_dir:正交化并显式警告,避免"静默丢语义"。 + warnings.warn( + f"plane y_dir {list(y_vec)} is not orthogonal to x_dir/normal; re-orthogonalized to {list(generated)}", + UserWarning, + stacklevel=2, + ) + y_dir = generated + return cls(origin_mm=origin, x_dir=x_dir, y_dir=y_dir, normal=normal) + + def as_dict(self) -> dict[str, list[float]]: + return { + "origin_mm": list(self.origin_mm), + "x_dir": list(self.x_dir), + "y_dir": list(self.y_dir), + "normal": list(self.normal), + } + + +@dataclass(frozen=True) +class HoleSpec: + """Runtime-neutral definition of a cylindrical Hole Wizard operation. + + The spec deliberately contains no OCC planes or shapes. The runtime + resolves the host frame and the adapter turns this definition into a + cutting tool, keeping source-contract parsing separate from B-rep work. + """ + + diameter_mm: float + depth_mm: float + end_condition: str + positions_mm: tuple[Vector3, ...] + countersink: tuple[float, float] | None = None + counterbore: tuple[float, float] | None = None + + @classmethod + def from_feature(cls, atomic_id: str, params: dict[str, Any], *, wizard: bool) -> "HoleSpec": + try: + diameter = float(params.get("diameter_mm") or 0.0) + depth = float(params.get("depth_mm") or 0.0) + except (TypeError, ValueError) as error: + raise ValueError("hole dimensions must be numeric") from error + if diameter <= 0 or depth <= 0: + raise ValueError("hole requires positive diameter_mm and depth_mm") + condition = str((params.get("end_condition") or {"type": "blind"}).get("type") or "blind") + if condition not in {"blind", "through_all", "through_all_both"}: + raise ValueError(f"unsupported hole extent {condition!r}") + raw_positions = params.get("positions") or [] + positions = tuple(_vector3(item.get("mm"), field_name="hole position") for item in raw_positions if isinstance(item, dict)) + if len(positions) != len(raw_positions) or not positions: + raise ValueError("hole requires non-empty positions with three-dimensional mm coordinates") + + raw_sink: dict[str, Any] | None = params.get("countersink") if wizard else None + raw_bore: dict[str, Any] | None = params.get("counterbore") if wizard else None + if atomic_id == "hole_countersink": + raw_sink = {"diameter_mm": params.get("countersink_diameter_mm"), "angle_rad": params.get("countersink_angle_rad")} + if atomic_id == "hole_counterbore": + raw_bore = {"diameter_mm": params.get("counterbore_diameter_mm"), "depth_mm": params.get("counterbore_depth_mm")} + + def dimensions(value: dict[str, Any] | None, second: str, label: str) -> tuple[float, float] | None: + if value is None: + return None + try: + first_value = float(value.get("diameter_mm") or 0.0) + second_value = float(value.get(second) or 0.0) + except (AttributeError, TypeError, ValueError) as error: + raise ValueError(f"{label} dimensions must be numeric") from error + if first_value <= diameter or second_value <= 0: + raise ValueError(f"{label} requires a diameter larger than the main hole and a positive {second}") + return first_value, second_value + + return cls( + diameter_mm=diameter, + depth_mm=depth, + end_condition=condition, + positions_mm=positions, + countersink=dimensions(raw_sink, "angle_rad", "countersink"), + counterbore=dimensions(raw_bore, "depth_mm", "counterbore"), + ) + + +@dataclass(frozen=True) +class ThreadSpec: + """Runtime-neutral definition of a parametric screw thread. + + The spec deliberately contains no OCC planes or shapes. The adapter turns + this definition into a threaded solid segment keeping source-contract + parsing separate from B-rep work. + + ``axis.origin_mm`` anchors the leading end face of the threaded segment and + ``axis.direction`` is the outward thread axis; ``angle_deg`` is the full + flank angle (60 for ISO metric V threads, 30 for trapezoidal leadscrews). + ``internal`` selects the cutting form used by later thread_cut support. + """ + + major_diameter_mm: float + minor_diameter_mm: float + pitch_mm: float + length_mm: float + axis: AxisSpec + angle_deg: float = 60.0 + internal: bool = False + lefthand: bool = False + relief_length_mm: float = 0.0 + crest_radius_mm: float = 0.0 + root_radius_mm: float = 0.0 + + @classmethod + def from_feature(cls, atomic_id: str, params: dict[str, Any]) -> "ThreadSpec": + try: + major = float(params.get("major_diameter_mm") or 0.0) + minor = float(params.get("minor_diameter_mm") or 0.0) + pitch = float(params.get("pitch_mm") or 0.0) + length = float(params.get("length_mm") or 0.0) + except (TypeError, ValueError) as error: + raise ValueError("thread dimensions must be numeric") from error + if major <= 0 or minor <= 0 or pitch <= 0 or length <= 0: + raise ValueError("thread requires positive major/minor/pitch/length in millimetres") + if minor >= major: + raise ValueError("thread minor diameter must be smaller than the major diameter") + raw_axis = params.get("axis") + if not isinstance(raw_axis, dict): + raise ValueError("thread requires an axis definition") + angle = 60.0 + raw_angle = params.get("angle_deg") + if raw_angle is not None: + try: + angle = float(raw_angle) + except (TypeError, ValueError) as error: + raise ValueError("thread angle_deg must be numeric") from error + if not 0 < angle < 180: + raise ValueError("thread angle_deg must be between 0 and 180") + relief = float(params.get("relief_length_mm") or 0.0) + crest_r = float(params.get("crest_radius_mm") or 0.0) + root_r = float(params.get("root_radius_mm") or 0.0) + if relief < 0: + raise ValueError("thread relief_length_mm must be non-negative") + if crest_r < 0 or root_r < 0: + raise ValueError("thread crest/root radius must be non-negative") + # 清根退化保护:crest + root 圆角半径之和不能超过可用牙高的一半, + # 否则梯形牙截面会被 fillet 完全吃掉,无法形成封闭截面。 + depth_radius = (major - minor) / 2.0 + if crest_r + root_r > 0.5 * depth_radius: + raise ValueError("thread crest_radius + root_radius exceeds half the thread depth") + # thread_cut 语义上恒为内螺纹:CDSL 输入无需显式传 internal,即使传 + # internal=False 也强制为 True(外螺纹切槽没有任何物理意义,且外轮廓 + # 刀具做布尔差会在 coincident faces 上退化)。 + internal = True if atomic_id == "thread_cut" else bool(params.get("internal", False)) + return cls( + major_diameter_mm=major, + minor_diameter_mm=minor, + pitch_mm=pitch, + length_mm=length, + axis=AxisSpec.from_mapping(raw_axis), + angle_deg=angle, + internal=internal, + lefthand=bool(params.get("lefthand", False)), + relief_length_mm=relief, + crest_radius_mm=crest_r, + root_radius_mm=root_r, + ) + + +@dataclass(frozen=True) +class BendLeg: + """One straight wing of a bent sheet-metal chain (runtime-neutral). + + ``leg_mm`` is the straight mid-plane distance from the previous fold vertex + to the fold vertex leaving this wing (the unfolded chord length between the + two surrounding fold vertices). When the wing has a following wing, + ``bend_angle_deg`` is the required *interior* angle between the two wings + (0 < angle < 180, 90 = a right-angle bend), ``inner_radius_mm`` the inner + bend-surface fillet radius (>= 0; the outer radius is always + ``inner_radius_mm + thickness_mm``) and ``side`` the fold direction + (+1 / -1). A trailing wing carries no fold; stray fold fields on the last + wing are ignored for tolerance. + """ + + leg_mm: float + bend_angle_deg: float | None = None + inner_radius_mm: float = 0.0 + side: int = 1 + + +@dataclass(frozen=True) +class BendSpec: + """Runtime-neutral definition of a sheet-metal bend (``bend_add``). + + The part is described by its mid-plane centreline: ``chain`` lists the + straight wings joined by equal-thickness bend corners. ``thickness_mm`` + and ``width_mm`` are the sheet thickness and the full length along the fold + (width) axis. ``frame.origin_mm`` anchors the start of the first wing's + mid-plane path, ``frame.x_dir`` is the fold/width axis and + ``frame.normal`` is the mid-plane normal of the first wing; the first wing + extends along ``normal x x_dir``. + + The spec deliberately contains no OCC planes or shapes. The adapter turns + this definition into a bent solid keeping source-contract parsing separate + from B-rep work (same pattern as :class:`ThreadSpec`). + """ + + thickness_mm: float + width_mm: float + frame: PlaneSpec + chain: tuple[BendLeg, ...] + + @classmethod + def from_feature(cls, params: dict[str, Any]) -> "BendSpec": + try: + thickness = float(params.get("thickness_mm") or 0.0) + width = float(params.get("width_mm") or 0.0) + except (TypeError, ValueError) as error: + raise ValueError("bend thickness/width must be numeric") from error + if thickness <= 0 or width <= 0: + raise ValueError("bend requires positive thickness_mm and width_mm in millimetres") + raw_chain = params.get("chain") + if not isinstance(raw_chain, (list, tuple)) or not raw_chain: + raise ValueError("bend requires a non-empty chain of wing segments") + chain: list[BendLeg] = [] + for index, raw_leg in enumerate(raw_chain): + if not isinstance(raw_leg, dict): + raise ValueError(f"bend chain[{index}] must be an object with leg_mm") + try: + leg = float(raw_leg.get("leg_mm") or 0.0) + except (TypeError, ValueError) as error: + raise ValueError(f"bend chain[{index}].leg_mm must be numeric") from error + if leg <= 0: + raise ValueError(f"bend chain[{index}].leg_mm must be positive") + bend_angle: float | None = None + if index < len(raw_chain) - 1: + raw_angle = raw_leg.get("bend_angle_deg") + if raw_angle is None: + raise ValueError( + f"bend chain[{index}] needs bend_angle_deg for the fold towards the next wing" + ) + try: + bend_angle = float(raw_angle) + except (TypeError, ValueError) as error: + raise ValueError(f"bend chain[{index}].bend_angle_deg must be numeric") from error + if not 0 < bend_angle < 180: + raise ValueError("bend interior angle must be between 0 and 180 degrees") + radius = float(raw_leg.get("inner_radius_mm") or 0.0) + raw_side = raw_leg.get("side") + side = 1 if raw_side is None else int(raw_side) + if radius < 0: + raise ValueError(f"bend chain[{index}].inner_radius_mm must be non-negative") + if side not in (1, -1): + raise ValueError(f"bend chain[{index}].side must be +1 or -1") + chain.append(BendLeg( + leg_mm=leg, + bend_angle_deg=bend_angle, + inner_radius_mm=radius, + side=side, + )) + raw_frame = params.get("frame") + if isinstance(raw_frame, dict): + frame = PlaneSpec.from_mapping(raw_frame) + else: + # frame 缺省:首翼沿世界 +X 延伸、厚度沿 +Y、折痕沿 +Z(贴 XY 平面)。 + frame = PlaneSpec( + origin_mm=(0.0, 0.0, 0.0), + x_dir=(0.0, 0.0, 1.0), + y_dir=(1.0, 0.0, 0.0), + normal=(0.0, 1.0, 0.0), + ) + return cls(thickness_mm=thickness, width_mm=width, frame=frame, chain=tuple(chain)) + + +@dataclass(frozen=True) +class GearSpec: + """Runtime-neutral definition of an involute spur/helical/herringbone gear. + + ``module_mm``/``teeth_count``/``pressure_angle_rad`` follow the ISO + involute convention: pitch radius ``m*z/2``, base radius + ``m*z/2*cos(alpha)``, addendum ``m`` and dedendum ``1.25*m``. ``width_mm`` + is the axial face width. ``helix_angle_rad`` is the pitch-cylinder helix + angle (0 = spur); ``herringbone=True`` builds a symmetric double-helical + gear whose helix reverses at mid-width (the mid-plane seam is shared + phase, no V-notch). ``axis.origin_mm`` is the centre of the ``z = 0`` end + face and ``axis.direction`` the outward gear axis. + """ + + module_mm: float + teeth_count: int + width_mm: float + axis: AxisSpec + helix_angle_rad: float = 0.0 + pressure_angle_rad: float = pi / 9.0 # 20 degrees + herringbone: bool = False + + @classmethod + def from_feature(cls, params: dict[str, Any]) -> "GearSpec": + try: + module = float(params.get("module_mm") or 0.0) + width = float(params.get("width_mm") or 0.0) + teeth = int(params.get("teeth_count") or 0) + helix_raw = params.get("helix_angle_rad") + helix = float(helix_raw) if helix_raw is not None else 0.0 + pressure_raw = params.get("pressure_angle_rad") + pressure = float(pressure_raw) if pressure_raw is not None else pi / 9.0 + except (TypeError, ValueError) as error: + raise ValueError("gear dimensions must be numeric") from error + if module <= 0 or width <= 0: + raise ValueError("gear requires positive module_mm and width_mm") + if not 8 <= teeth <= 200: + raise ValueError("gear teeth_count must be between 8 and 200") + if not 0.0 <= helix < radians(45.0): + raise ValueError("gear helix_angle_rad must be within [0, 45) degrees") + if not 0.0 < pressure <= radians(35.0): + raise ValueError("gear pressure_angle_rad must be within (0, 35] degrees") + herringbone = bool(params.get("herringbone", False)) + if herringbone and helix <= 0.0: + raise ValueError("herringbone gear requires helix_angle_rad > 0") + raw_axis = params.get("axis") + if not isinstance(raw_axis, dict): + raise ValueError("gear requires an axis definition") + return cls( + module_mm=module, + teeth_count=teeth, + width_mm=width, + axis=AxisSpec.from_mapping(raw_axis), + helix_angle_rad=helix, + pressure_angle_rad=pressure, + herringbone=herringbone, + ) + + @property + def pitch_radius_mm(self) -> float: + return self.module_mm * self.teeth_count / 2.0 + + @property + def base_radius_mm(self) -> float: + return self.pitch_radius_mm * cos(self.pressure_angle_rad) + + @property + def tip_radius_mm(self) -> float: + return self.pitch_radius_mm + self.module_mm + + @property + def root_radius_mm(self) -> float: + return self.pitch_radius_mm - 1.25 * self.module_mm + + +@dataclass(frozen=True) +class RackSpec: + """Runtime-neutral definition of a straight-sided rack (``rack_add``). + + The rack is the linear counterpart of a gear: tooth pitch ``p = pi*m``, + addendum ``m`` above the reference line and dedendum ``1.25*m`` below it, + flanks inclined by ``pressure_angle_rad``. ``teeth_count`` sets the exact + overall length ``teeth_count * pi * m`` (both ends land in tooth valley + centres). ``thickness_mm`` is the width along the tooth crest direction. + ``axis.origin_mm`` is the corner point where the length-start end face, + the thickness-start face and the tooth-valley plane intersect, and + ``axis.direction`` is the direction the teeth point (local +Z). + """ + + module_mm: float + teeth_count: int + thickness_mm: float + axis: AxisSpec + pressure_angle_rad: float = pi / 9.0 # 20 degrees + + @classmethod + def from_feature(cls, params: dict[str, Any]) -> "RackSpec": + try: + module = float(params.get("module_mm") or 0.0) + thickness = float(params.get("thickness_mm") or 0.0) + teeth = int(params.get("teeth_count") or 0) + pressure_raw = params.get("pressure_angle_rad") + pressure = float(pressure_raw) if pressure_raw is not None else pi / 9.0 + except (TypeError, ValueError) as error: + raise ValueError("rack dimensions must be numeric") from error + if module <= 0 or thickness <= 0: + raise ValueError("rack requires positive module_mm and thickness_mm") + if not 1 <= teeth <= 2000: + raise ValueError("rack teeth_count must be between 1 and 2000") + if not 0.0 < pressure <= radians(35.0): + raise ValueError("rack pressure_angle_rad must be within (0, 35] degrees") + raw_axis = params.get("axis") + if not isinstance(raw_axis, dict): + raise ValueError("rack requires an axis definition") + return cls( + module_mm=module, + teeth_count=teeth, + thickness_mm=thickness, + axis=AxisSpec.from_mapping(raw_axis), + pressure_angle_rad=pressure, + ) + + @property + def pitch_mm(self) -> float: + return pi * self.module_mm + + @property + def length_mm(self) -> float: + return self.pitch_mm * self.teeth_count + + @property + def addendum_mm(self) -> float: + return self.module_mm + + @property + def dedendum_mm(self) -> float: + return 1.25 * self.module_mm diff --git a/backend/engine/cdsl_engine/topology.py b/backend/engine/cdsl_engine/topology.py new file mode 100644 index 00000000..6b73e11f --- /dev/null +++ b/backend/engine/cdsl_engine/topology.py @@ -0,0 +1,1010 @@ +"""Runtime-neutral CDSL diagnostics, planning, and topology contracts. + +This module deliberately has no build123d dependency. The planner and +selector resolver can therefore be used by validation, batch reporting, and +any geometry adapter without importing OCC objects. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from math import sqrt +from typing import Any, Iterable + +from .specs import AxisSpec, PlaneSpec, Vector3, _length, _vector3, normalize_selector_geometry + + +@dataclass(frozen=True) +class RuntimeDiagnostic: + code: str + message: str + feature_id: str | None = None + detail: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + output: dict[str, Any] = {"code": self.code, "message": self.message} + if self.feature_id is not None: + output["feature_id"] = self.feature_id + if self.detail: + output["detail"] = self.detail + return output + + +@dataclass(frozen=True) +class CapabilityResult: + feature_id: str + atomic_id: str + resolved_status: str + required_capabilities: tuple[str, ...] = () + blockers: tuple[RuntimeDiagnostic, ...] = () + + @property + def executable(self) -> bool: + return self.resolved_status == "executable" + + def as_dict(self) -> dict[str, Any]: + return { + "feature_id": self.feature_id, + "atomic_id": self.atomic_id, + "resolved_status": self.resolved_status, + "required_capabilities": list(self.required_capabilities), + "blockers": [blocker.as_dict() for blocker in self.blockers], + } + + +@dataclass(frozen=True) +class FeaturePlanNode: + feature_id: str + atomic_id: str + name: str | None + depends_on: tuple[str, ...] + params: dict[str, Any] + selectors: tuple[dict[str, Any], ...] + sketch_id: str | None + declared_status: str | None + source_feature: dict[str, Any] + + +@dataclass +class FeatureResult: + feature_id: str + atomic_id: str + status: str + body_id: str | None = None + surface_id: str | None = None + context: PlaneSpec | AxisSpec | None = None + replay_definition: dict[str, Any] | None = None + diagnostics: list[RuntimeDiagnostic] = field(default_factory=list) + + def as_dict(self) -> dict[str, Any]: + output: dict[str, Any] = { + "feature_id": self.feature_id, + "atomic_id": self.atomic_id, + "status": self.status, + "diagnostics": [diagnostic.as_dict() for diagnostic in self.diagnostics], + } + if self.body_id is not None: + output["body_id"] = self.body_id + if self.surface_id is not None: + output["surface_id"] = self.surface_id + if self.context is not None: + output["context"] = self.context.as_dict() + if self.replay_definition is not None: + output["replay_definition"] = self.replay_definition + return output + + +@dataclass(frozen=True) +class TopologyRecord: + """Runtime-side signature of a topology item or context object. + + ``feature_id`` identifies the feature that produced this *snapshot*. + ``owner_feature_ids`` is durable semantic provenance for an unchanged + current B-rep item. Boolean and dress-up operations replace OCC objects, + so keeping these concepts separate prevents a later mutation from making + every surviving face appear to be owned by that mutation. + """ + + record_id: str + kind: str + feature_id: str + body_id: str | None = None + geometry: dict[str, Any] = field(default_factory=dict) + value: Any = None + owner_feature_ids: tuple[str, ...] = () + # Builder-produced roles describe a particular result subshape. They are + # deliberately separate from the geometric signature: equal geometry does + # not prove that two faces have the same feature-output meaning. + output_roles: tuple[str, ...] = () + # Generated roles may carry the direct feature-output role that the kernel + # operation transformed. This is semantic provenance, not a stable-id + # shortcut: the resolver still requires the exact active result snapshot. + output_role_sources: tuple[tuple[str, str, str], ...] = () + + @property + def owners(self) -> tuple[str, ...]: + """Return durable provenance, retaining compatibility for contexts.""" + return self.owner_feature_ids or (self.feature_id,) + + def public_dict(self) -> dict[str, Any]: + output: dict[str, Any] = { + "record_id": self.record_id, + "kind": self.kind, + "feature_id": self.feature_id, + "geometry": self.geometry, + } + if self.body_id is not None: + output["body_id"] = self.body_id + if self.owner_feature_ids: + output["owner_feature_ids"] = list(self.owner_feature_ids) + if self.output_roles: + output["output_roles"] = list(self.output_roles) + if self.output_role_sources: + output["output_role_sources"] = [ + {"output_role": role, "owner_feature_id": owner, "source_output_role": source_role} + for role, owner, source_role in self.output_role_sources + ] + return output + + +@dataclass(frozen=True) +class TopologyDeltaRelation: + """One opaque kernel-history relationship for a topology subshape. + + Geometry adapters retain ownership of the values in this structure. They + are intentionally opaque to the runtime: a build123d/OCC adapter may use + ``TopoDS_Shape`` values while another adapter can use its native handles. + The registry only asks whether a handle is exactly the same topology item; + it never uses this contract to score nearby geometry. + """ + + event: str + kind: str + source_value: Any + result_values: tuple[Any, ...] = () + output_role: str | None = None + + def __post_init__(self) -> None: + if self.event not in {"preserved", "modified", "generated", "deleted"}: + raise ValueError(f"unsupported topology delta event {self.event!r}") + if self.kind not in {"face", "edge", "vertex"}: + raise ValueError(f"unsupported topology delta kind {self.kind!r}") + if self.event == "deleted" and (self.result_values or self.output_role is not None): + raise ValueError("deleted topology delta relations cannot have result values or an output role") + if self.output_role is not None and (not isinstance(self.output_role, str) or not self.output_role): + raise ValueError("topology delta output_role must be a non-empty string when provided") + + +@dataclass(frozen=True) +class TopologyDelta: + """Kernel-backed topology history for one adapter operation. + + ``operation`` is evidence only. The runtime transfers durable provenance + solely from a unique exact relationship, never from an operation name or a + geometric resemblance. + """ + + operation: str + relations: tuple[TopologyDeltaRelation, ...] = () + + +@dataclass(frozen=True) +class SelectorResolution: + selector: dict[str, Any] + status: str + record: TopologyRecord | None = None + candidates: tuple[dict[str, Any], ...] = () + diagnostic: RuntimeDiagnostic | None = None + + def as_dict(self) -> dict[str, Any]: + output = { + "selector": self.selector, + "status": self.status, + "candidates": list(self.candidates), + } + if self.record is not None: + output["record"] = self.record.public_dict() + output["selected"] = self.record.public_dict() + score = next( + (candidate.get("score") for candidate in self.candidates if candidate.get("record_id") == self.record.record_id), + None, + ) + if score is not None: + output["score"] = score + if self.diagnostic is not None: + output["diagnostic"] = self.diagnostic.as_dict() + return output + + +class TopologyRegistry: + """Feature-scoped context/topology registry with explainable matching.""" + + def __init__(self) -> None: + self._records: list[TopologyRecord] = [] + self._by_feature: dict[str, list[TopologyRecord]] = {} + self._active_body_id: str | None = None + self._topology_deltas: list[dict[str, Any]] = [] + # #8 selector 持久性:old_record_id -> [new_record_id]。fillet/chamfer + # 会把一条直线边拆分为若干段(中间直段 + 两端圆弧),旧边不再与任何 + # 新边几何等价;这里记录"位置轨迹延续"的直段后继,使后续 selector 的 + # stable_id 引用可以解析到 active body 内的新形态。 + self._successors: dict[str, list[str]] = {} + + def register(self, record: TopologyRecord) -> None: + self._records.append(record) + self._by_feature.setdefault(record.feature_id, []).append(record) + + def records_for_feature(self, feature_id: str) -> tuple[TopologyRecord, ...]: + return tuple(self._by_feature.get(feature_id, ())) + + def records(self) -> tuple[TopologyRecord, ...]: + return tuple(self._records) + + def topology_deltas(self) -> tuple[dict[str, Any], ...]: + """Return serializable evidence derived from exact adapter history.""" + return tuple(self._topology_deltas) + + def register_context(self, feature_id: str, context: PlaneSpec | AxisSpec) -> TopologyRecord: + kind = "plane" if isinstance(context, PlaneSpec) else "axis" + record = TopologyRecord( + record_id=f"{feature_id}:{kind}", + kind=kind, + feature_id=feature_id, + geometry=context.as_dict(), + value=context, + ) + self.register(record) + return record + + def replace_body_topology( + self, feature_id: str, body_id: str, records: Iterable[TopologyRecord], + *, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None, + additional_predecessors: Iterable[TopologyRecord] = (), + ) -> None: + self.replace_body_topologies( + feature_id, [(body_id, records)], active_body_id=active_body_id, topology_delta=topology_delta, + additional_predecessors=additional_predecessors, + ) + + def replace_body_topologies( + self, feature_id: str, bodies: Iterable[tuple[str, Iterable[TopologyRecord]]], + *, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None, + additional_predecessors: Iterable[TopologyRecord] = (), + ) -> None: + """Record a fresh B-rep snapshot after a feature mutates the body. + + OCC topology object identity is invalidated by most body mutations. + We therefore keep old objects out of active selector resolution but + carry their semantic owners forward when, and only when, one current + object has one geometrically equivalent predecessor. A changed or + split object intentionally becomes owned by this feature instead of + being guessed as belonging to an older one. + + ``active_body_id`` names the whole-body group when ``body_id`` is a + member of a multi-solid body (issue #7): the group id keeps the next + mutation's predecessor lookup scoped to every solid of the previous + body, while each member keeps its own ``body:{feature}:{index}`` id. + """ + active_previous = [ + record for record in self._records + if self._active_body_id is not None and record.body_id is not None + and ( + record.body_id == self._active_body_id + or record.body_id.startswith(f"{self._active_body_id}:") + ) + ] + # A pattern COPY can carry a chain of exact transform/boolean builder + # histories before its final aggregate snapshot is registered. These + # temporary records are valid predecessors only for that documented + # kernel-history bridge. They are deliberately excluded from geometric + # fallback matching: an equal-looking final face never proves that it + # belongs to one particular copy instance. + transient_previous = list(additional_predecessors) + previous = [*active_previous, *transient_previous] + # 同一 source feature 的 pattern copy 可以产生完全相同的几何面。它们 + # 必须保留为多个实例,不能在跨 body 的全局 predecessor 匹配中互相消费。 + # pattern 的 Compound 成员顺序是稳定的:已有实例以同一 member index + # 延续,新增实例只会出现在末尾。按该 index 限定后继匹配。 + current = [(body_id, list(records)) for body_id, records in bodies] + current_records = [record for _body_id, records in current for record in records] + ( + exact_predecessors, + exact_successors, + kernel_covered_predecessors, + exact_output_roles, + exact_output_role_sources, + delta_evidence, + ) = self._exact_delta_links( + topology_delta, previous, current_records, + ) + previous_member_ids = { + suffix for record in active_previous + for suffix in [str(record.body_id).rsplit(":", 1)[-1]] + if suffix.isdigit() + } + use_member_indexes = len(current) > 1 and previous_member_ids + consumed_predecessors: set[str] = set() + registered: list[TopologyRecord] = [] + for body_id, records in current: + member_id = str(body_id).rsplit(":", 1)[-1] + local_predecessors = [ + record for record in active_previous + if not use_member_indexes or str(record.body_id).rsplit(":", 1)[-1] == member_id + ] + for record in records: + exact_predecessor_id = exact_predecessors.get(record.record_id) + # Member order is a useful isolation boundary for geometric + # fallback matching, especially for coincident pattern + # copies. It is not a provenance boundary when a kernel + # builder explicitly relates one source subshape to one + # result subshape: boolean/delete lifecycle can remove an + # earlier member and shift a surviving source to another + # member index. A unique OCC continuation remains exact + # evidence across that index change. + predecessor = next( + (prior for prior in previous if prior.record_id == exact_predecessor_id), + None, + ) + if predecessor is None and record.record_id not in exact_predecessors: + predecessor = self._unique_equivalent_predecessor(record, local_predecessors, consumed_predecessors) + owners = predecessor.owners if predecessor is not None else (feature_id,) + output_roles = set(record.output_roles) + output_roles.update(exact_output_roles.get(record.record_id, ())) + output_role_sources = set(record.output_role_sources) + output_role_sources.update(exact_output_role_sources.get(record.record_id, ())) + # A feature-output role can survive a later operation only + # through the same unique kernel continuation used for owner + # provenance. Geometry equivalence alone never carries it. + if predecessor is not None and record.record_id in exact_predecessors: + output_roles.update(predecessor.output_roles) + if predecessor is not None and record.record_id not in exact_predecessors: + consumed_predecessors.add(predecessor.record_id) + registered.append(TopologyRecord( + record_id=record.record_id, + kind=record.kind, + feature_id=feature_id, + body_id=body_id, + geometry=dict(record.geometry), + value=record.value, + owner_feature_ids=owners, + output_roles=tuple(sorted(output_roles)), + output_role_sources=tuple(sorted(output_role_sources)), + )) + for record in registered: + self.register(record) + for predecessor_id, successor_ids in exact_successors.items(): + known = self._successors.setdefault(predecessor_id, []) + for successor_id in successor_ids: + if successor_id not in known: + known.append(successor_id) + if delta_evidence is not None: + self._topology_deltas.append({ + "feature_id": feature_id, + "operation": topology_delta.operation, + "relations": delta_evidence, + }) + # #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续 + # selector 的 stable_id 引用解析到 active body 内的新形态。多条演化 + # 候选时只登记"漂移显著最小"的那条(例如底面边圆角后既有缩短的直段 + # 也有圆角过渡带的新边,前者的端点与原边重合、漂移更小);漂移并列 + # (如竖直边被完整消费成两条等距直段)属于本质歧义,保守不登记。 + for prior in previous: + if ( + prior.record_id in consumed_predecessors + or prior.record_id in exact_successors + or prior.record_id in kernel_covered_predecessors + ): + continue + candidates = sorted( + ( + (self._evolved_drift(prior, record), record.record_id) + for record in registered if self._evolved_equivalent(prior, record) + ), + key=lambda item: item[0], + ) + if not candidates: + continue + best, second = candidates[0], (candidates[1] if len(candidates) > 1 else None) + if second is None or (second[0] - best[0]) > max(0.5, 0.2 * best[0]): + self._successors[prior.record_id] = [best[1]] + self._active_body_id = active_body_id or body_id + + @staticmethod + def _same_topology_value(left: Any, right: Any) -> bool: + """Compare adapter handles only through their exact topology identity.""" + left_value = getattr(left, "wrapped", left) + right_value = getattr(right, "wrapped", right) + if left_value is right_value: + return True + for candidate, other in ((left_value, right_value), (right_value, left_value)): + for method_name in ("IsSame", "is_same"): + method = getattr(candidate, method_name, None) + if callable(method): + try: + return bool(method(other)) + except (AttributeError, TypeError, ValueError): + continue + return False + + @classmethod + def _exact_delta_links( + cls, + topology_delta: TopologyDelta | None, + previous: list[TopologyRecord], + current: list[TopologyRecord], + ) -> tuple[ + dict[str, str], + dict[str, list[str]], + set[str], + dict[str, tuple[str, ...]], + dict[str, tuple[tuple[str, str, str], ...]], + list[dict[str, Any]] | None, + ]: + """Bind opaque kernel history to snapshots without geometric guessing. + + Ownership transfer is intentionally limited to a single source item and + a single output item. Split/merge history remains useful evidence, but + has no unique owner continuation until a later operation-specific + contract can express it. + """ + if topology_delta is None: + return {}, {}, set(), {}, {}, None + candidate_sources: dict[str, set[str]] = {} + kernel_covered_predecessors: set[str] = set() + output_roles: dict[str, set[str]] = {} + output_role_sources: dict[str, set[tuple[str, str, str]]] = {} + relation_links: list[tuple[str, str] | None] = [] + evidence: list[dict[str, Any]] = [] + for relation in topology_delta.relations: + sources = [ + record for record in previous + if record.kind == relation.kind and cls._same_topology_value(record.value, relation.source_value) + ] + outputs = [ + record for record in current + if record.kind == relation.kind + and any(cls._same_topology_value(record.value, value) for value in relation.result_values) + ] + item = { + "event": relation.event, + "kind": relation.kind, + "source_record_ids": [record.record_id for record in sources], + "result_record_ids": [record.record_id for record in outputs], + "proof": "kernel_history", + } + if relation.output_role is not None: + item["output_role"] = relation.output_role + role_is_unique = len(relation.result_values) == 1 and len(outputs) == 1 + item["output_role_status"] = ( + "unique_result_snapshot" if role_is_unique else "non_unique_or_missing_result_snapshot" + ) + if role_is_unique: + output_roles.setdefault(outputs[0].record_id, set()).add(relation.output_role) + for source in sources: + for source_role in source.output_roles: + for source_owner in source.owners: + output_role_sources.setdefault(outputs[0].record_id, set()).add( + (relation.output_role, source_owner, source_role) + ) + if len(sources) == 1: + kernel_covered_predecessors.add(sources[0].record_id) + can_transfer = ( + relation.event in {"preserved", "modified"} + and len(relation.result_values) == 1 + and len(sources) == 1 + and len(outputs) == 1 + ) + if can_transfer: + source_id, result_id = sources[0].record_id, outputs[0].record_id + candidate_sources.setdefault(result_id, set()).add(source_id) + relation_links.append((source_id, result_id)) + else: + relation_links.append(None) + evidence.append(item) + predecessors = { + result_id: next(iter(source_ids)) + for result_id, source_ids in candidate_sources.items() + if len(source_ids) == 1 + } + successors: dict[str, list[str]] = {} + for result_id, source_id in predecessors.items(): + successors.setdefault(source_id, []).append(result_id) + for item, relation, link in zip(evidence, topology_delta.relations, relation_links): + if link is not None: + _source_id, result_id = link + item["status"] = ( + "unique_exact_continuation" + if len(candidate_sources[result_id]) == 1 else "ambiguous_exact_continuation" + ) + elif relation.event in {"preserved", "modified"}: + item["status"] = "non_unique_or_incomplete" + else: + item["status"] = "recorded_without_owner_transfer" + return ( + predecessors, + successors, + kernel_covered_predecessors, + {record_id: tuple(sorted(roles)) for record_id, roles in output_roles.items()}, + {record_id: tuple(sorted(sources)) for record_id, sources in output_role_sources.items()}, + evidence, + ) + + @staticmethod + def _numbers_equal(left: Any, right: Any, *, tolerance: float = 1e-6) -> bool: + try: + return abs(float(left) - float(right)) <= tolerance + except (TypeError, ValueError): + return False + + @classmethod + def _vectors_equal(cls, left: Any, right: Any, *, tolerance: float = 1e-6) -> bool: + try: + first = _vector3(left, field_name="prior topology geometry") + second = _vector3(right, field_name="current topology geometry") + except ValueError: + return False + return all(abs(a - b) <= tolerance for a, b in zip(first, second)) + + @classmethod + def _geometry_equivalent(cls, prior: TopologyRecord, current: TopologyRecord) -> bool: + """Check a complete, orientation-aware snapshot signature. + + This is intentionally much stricter than selector scoring. Selector + scoring may compare partial source evidence; provenance transfer must + never manufacture ownership from a merely similar candidate. + """ + if prior.kind != current.kind: + return False + left, right = prior.geometry, current.geometry + for key in ("surface_type", "curve_type"): + if left.get(key) != right.get(key): + return False + for key in ("bbox_mm", "center_mm", "normal", "plane_normal"): + if key in left or key in right: + if key not in left or key not in right: + return False + left_value, right_value = left[key], right[key] + if key == "bbox_mm": + if not isinstance(left_value, (list, tuple)) or not isinstance(right_value, (list, tuple)): + return False + if len(left_value) != 6 or len(right_value) != 6: + return False + if not all(cls._numbers_equal(a, b) for a, b in zip(left_value, right_value)): + return False + elif not cls._vectors_equal(left_value, right_value): + return False + for key in ("area_mm2", "length_mm", "plane_offset_mm"): + if key in left or key in right: + if key not in left or key not in right or not cls._numbers_equal(left[key], right[key]): + return False + for key in ("adjacency_signature", "adjacent_face_count", "incident_edge_count"): + if key in left or key in right: + if key not in left or key not in right or left[key] != right[key]: + return False + left_start, left_end = left.get("start_mm"), left.get("end_mm") + right_start, right_end = right.get("start_mm"), right.get("end_mm") + if any(value is not None for value in (left_start, left_end, right_start, right_end)): + if None in (left_start, left_end, right_start, right_end): + return False + same_direction = cls._vectors_equal(left_start, right_start) and cls._vectors_equal(left_end, right_end) + reverse_direction = cls._vectors_equal(left_start, right_end) and cls._vectors_equal(left_end, right_start) + if not same_direction and not reverse_direction: + return False + return True + + @classmethod + def _unique_equivalent_predecessor( + cls, + current: TopologyRecord, + predecessors: Iterable[TopologyRecord], + consumed_predecessors: set[str], + ) -> TopologyRecord | None: + matches = [ + record for record in predecessors + if record.record_id not in consumed_predecessors and cls._geometry_equivalent(record, current) + ] + return matches[0] if len(matches) == 1 else None + + @staticmethod + def _evolved_drift(prior: TopologyRecord, current: TopologyRecord) -> float | None: + """Endpoint drift between direction-aligned straight edges. + + Returns the minimum total endpoint drift (mm) when the two edges are + collinear straight lines (either orientation), otherwise ``None``. + """ + if prior.kind != current.kind: + return None + left, right = prior.geometry, current.geometry + if left.get("curve_type") != "line" or right.get("curve_type") != "line": + return None + if None in (left.get("start_mm"), left.get("end_mm"), right.get("start_mm"), right.get("end_mm")): + return None + + def _delta(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]: + return (b[0] - a[0], b[1] - a[1], b[2] - a[2]) + + def _dist(a: tuple[float, float, float], b: tuple[float, float, float]) -> float: + return sqrt(sum((a[i] - b[i]) ** 2 for i in range(3))) + + left_dir = _delta(left["start_mm"], left["end_mm"]) + right_dir = _delta(right["start_mm"], right["end_mm"]) + if _length(left_dir) <= 1e-9 or _length(right_dir) <= 1e-9: + return None + cross = ( + left_dir[1] * right_dir[2] - left_dir[2] * right_dir[1], + left_dir[2] * right_dir[0] - left_dir[0] * right_dir[2], + left_dir[0] * right_dir[1] - left_dir[1] * right_dir[0], + ) + if _length(cross) / (_length(left_dir) * _length(right_dir)) > 1e-3: + return None + same_order = _dist(left["start_mm"], right["start_mm"]) + _dist(left["end_mm"], right["end_mm"]) + reversed_order = _dist(left["start_mm"], right["end_mm"]) + _dist(left["end_mm"], right["start_mm"]) + return min(same_order, reversed_order) + + @classmethod + def _evolved_equivalent(cls, prior: TopologyRecord, current: TopologyRecord, *, drift_mm: float = 5.0) -> bool: + """Loose "position trajectory" equivalence used for evolved successors. + + Unlike ``_geometry_equivalent`` (strict, anti-false-positive provenance), + this deliberately tolerates small endpoint drift: fillet/chamfer split a + straight edge into segments (a middle straight run plus end arcs). The + straight run keeps the same direction and stays within ``drift_mm`` of the + original edge, so it can serve as the edge's evolved successor. Uniqueness + is enforced by the caller (only a single best candidate is recorded). + """ + drift = cls._evolved_drift(prior, current) + return drift is not None and drift <= drift_mm + + @staticmethod + def _vector_score(expected: Any, actual: Any, tolerance: float = 1e-4) -> float | None: + try: + left = _vector3(expected, field_name="selector geometry") + right = _vector3(actual, field_name="record geometry") + except ValueError: + return None + error = _length(tuple(a - b for a, b in zip(left, right))) + return max(0.0, 1.0 - error / tolerance) + + @classmethod + def _geometry_score(cls, selector_geometry: dict[str, Any], record_geometry: dict[str, Any]) -> float | None: + if not selector_geometry: + return 0.0 + scores: list[float] = [] + for key in ("center_mm", "circle_center_mm", "normal", "origin_mm", "direction", "plane_normal", "start_mm", "end_mm"): + if key in selector_geometry: + score = cls._vector_score(selector_geometry[key], record_geometry.get(key)) + if score is None: + return None + scores.append(score) + for key in ("surface_type", "curve_type"): + if key in selector_geometry: + if record_geometry.get(key) != selector_geometry[key]: + # #8 selector 持久性:fillet/chamfer 会把直线边演化为圆弧、 + # 平面演化为柱面,但被选中拓扑的位置锚定(bbox/center/端点) + # 不变。曲线/曲面类型变化不再一票否决,而是记低分:位置完全 + # 重合的候选(同一条边的形态演化)仍可胜出;位置不重合的 + # 相邻边会被 0 分项拉低,仍被 minimum_score 挡住。 + scores.append(0.5) + else: + scores.append(1.0) + if "bbox_mm" in selector_geometry: + expected = selector_geometry["bbox_mm"] + actual = record_geometry.get("bbox_mm") + if not isinstance(expected, list) or not isinstance(actual, list) or len(expected) != len(actual): + return None + delta = max(abs(float(a) - float(b)) for a, b in zip(expected, actual)) + scores.append(max(0.0, 1.0 - delta / 1e-4)) + if "plane_offset_mm" in selector_geometry: + try: + delta = abs(float(selector_geometry["plane_offset_mm"]) - float(record_geometry.get("plane_offset_mm"))) + except (TypeError, ValueError): + return None + scores.append(max(0.0, 1.0 - delta / 1e-4)) + if "radius_mm" in selector_geometry: + try: + delta = abs(float(selector_geometry["radius_mm"]) - float(record_geometry.get("radius_mm"))) + except (TypeError, ValueError): + return None + scores.append(max(0.0, 1.0 - delta / 1e-4)) + if "area_mm2" in selector_geometry: + try: + expected_area = float(selector_geometry["area_mm2"]) + actual_area = float(record_geometry.get("area_mm2")) + except (TypeError, ValueError): + return None + relative_delta = abs(expected_area - actual_area) / max(abs(expected_area), 1e-9) + scores.append(max(0.0, 1.0 - relative_delta / 1e-4)) + return sum(scores) / len(scores) if scores else 0.0 + + def resolve( + self, + selector: dict[str, Any], + *, + minimum_score: float = 0.8, + active_body_id: str | None = None, + ) -> SelectorResolution: + kind = selector.get("kind") + owner = selector.get("owner_feature_id") + candidates = [record for record in self._records if record.kind == kind] + if active_body_id and kind in {"face", "edge", "vertex", "body"}: + # #7 multi-body:记录 body_id 可能是 body:{feature}:{index}(多体 + # 成员),用前缀匹配把整个主体的记录纳入候选,同时保证旧 body 的 + # 记录(不同 feature 前缀)不会泄漏进来。 + candidates = [ + record for record in candidates + if record.body_id == active_body_id + or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:")) + ] + if owner: + candidates = [record for record in candidates if owner in record.owners] + if kind == "plane" and selector.get("frame") is not None: + # #6 pattern 引用重解析:pattern 重放 source(pattern_mirror)时, + # mirror_plane 的 plane 引用由运行时随实例变换后内联为显式 frame + # (_translated_node / _mirrored_node),这里直接构造 PlaneSpec, + # 不再走 stable_id / 几何匹配,避免解析到未随实例变换的原始面。 + try: + plane = PlaneSpec.from_mapping(selector.get("frame") or {}) + except (TypeError, ValueError): + plane = None + if plane is not None: + record = TopologyRecord( + record_id=f"inline:{id(plane)}", + kind="plane", + feature_id=str(owner or "inline"), + geometry=plane.as_dict(), + value=plane, + ) + return SelectorResolution( + selector=selector, + status="resolved", + record=record, + candidates=({"score": 1.0, **record.public_dict()},), + ) + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_frame_incomplete", + message="An inline plane frame requires origin_mm, x_dir and normal", + detail={"frame": selector.get("frame")}, + ), + ) + geometry = normalize_selector_geometry(selector.get("geometry")) + if selector.get("snapshot_id") and not owner: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_owner_required", + message="A snapshot selector requires owner_feature_id", + detail={"minimum_score": minimum_score}, + ), + ) + output_role = str(selector.get("output_role") or "").strip() + if output_role: + if not owner: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_owner_required", + message="A feature output role selector requires owner_feature_id", + detail={"output_role": output_role}, + ), + ) + if active_body_id is None: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_active_body_required", + message="A feature output role selector requires an active body snapshot", + detail={"output_role": output_role}, + ), + ) + if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")): + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_mixed_evidence", + message="A feature output role selector cannot mix stable or geometry evidence", + detail={"output_role": output_role}, + ), + ) + role_candidates = [record for record in candidates if output_role in record.output_roles] + role_source = selector.get("output_role_source") + if role_source is not None: + source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None + source_role = role_source.get("output_role") if isinstance(role_source, dict) else None + if not isinstance(source_owner, str) or not isinstance(source_role, str): + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_source_invalid", + message="An output role selector source requires owner_feature_id and output_role", + ), + ) + if output_role != "shell.offset_face" or source_role not in {"extrude.start", "extrude.end"}: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_source_unsupported", + message="Output role sources are currently supported only for shell.offset_face from an extrusion cap", + ), + ) + role_candidates = [ + record for record in role_candidates + if (output_role, source_owner, source_role) in record.output_role_sources + ] + public_candidates = tuple( + {"score": 1.0, **record.public_dict()} for record in role_candidates + ) + if len(role_candidates) == 1: + return SelectorResolution( + selector=selector, + status="resolved", + record=role_candidates[0], + candidates=public_candidates, + ) + if len(role_candidates) > 1: + return SelectorResolution( + selector=selector, + status="ambiguous", + candidates=public_candidates, + diagnostic=RuntimeDiagnostic( + code="selector_output_role_ambiguous", + message="More than one active topology record has the requested output role", + detail={"output_role": output_role, "candidate_count": len(role_candidates)}, + ), + ) + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_not_found", + message="No active topology record has the requested output role", + detail={"output_role": output_role, "candidate_count": 0}, + ), + ) + stable_id = str(selector.get("stable_id") or "").strip() + if stable_id: + # #8 selector 持久性:stable_id 是跨 body 演化的持久标识符,精确 + # 匹配在 active body 过滤之前对整个记录集(kind + owner 过滤)执行。 + # 命中已过期(旧 body)的记录时,经演化后继映射解析到 active body + # 内的新形态(fillet/chamfer 拆段后的直段后继);无后继则回落到 + # 几何打分流程。 + stable_records = [ + record for record in self._records + if record.kind == kind and (not owner or owner in record.owners) + ] + exact = [record for record in stable_records if record.record_id == stable_id] + if len(exact) == 1: + record = exact[0] + is_active = active_body_id is None or ( + record.body_id == active_body_id + or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:")) + ) + if not is_active: + successors = [ + candidate for candidate in stable_records + if candidate.record_id in self._successors.get(record.record_id, ()) + and ( + candidate.body_id == active_body_id + or (candidate.body_id is not None and active_body_id and candidate.body_id.startswith(f"{active_body_id}:")) + ) + ] + if len(successors) == 1: + record = successors[0] + is_active = True + elif len(successors) > 1: + return SelectorResolution( + selector=selector, + status="ambiguous", + candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors), + diagnostic=RuntimeDiagnostic( + code="selector_ambiguous", + message="More than one evolved successor record satisfies the stable_id", + detail={"stable_id": stable_id, "candidate_count": len(successors)}, + ), + ) + if is_active: + # A stable ID is only a lookup accelerator for snapshot-aware + # selectors. It cannot revive a B-rep entity whose geometric + # signature changed after an upstream rebuild. + if selector.get("snapshot_id"): + score = self._geometry_score(geometry, record.geometry) if geometry else None + if score is None or score < minimum_score: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=({"score": round(float(score or 0), 6), **record.public_dict()},), + diagnostic=RuntimeDiagnostic( + code="selector_geometry_mismatch", + message="The stable selector record no longer matches its geometry signature", + detail={"stable_id": stable_id, "score": score, "minimum_score": minimum_score}, + ), + ) + return SelectorResolution( + selector=selector, + status="resolved", + record=record, + candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},), + ) + if not geometry: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_stable_id_inactive", + message="The stable selector record is not active and has no geometry signature for rebinding", + detail={"stable_id": stable_id}, + ), + ) + if len(exact) > 1: + return SelectorResolution( + selector=selector, + status="ambiguous", + candidates=tuple({"score": 1.0, **record.public_dict()} for record in exact), + diagnostic=RuntimeDiagnostic( + code="selector_ambiguous", + message="More than one runtime topology record has the requested stable_id", + detail={"stable_id": stable_id, "candidate_count": len(exact)}, + ), + ) + if selector.get("snapshot_id") and not geometry: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_geometry_mismatch", + message="A snapshot selector requires a geometry signature", + detail={"minimum_score": minimum_score}, + ), + ) + scored: list[tuple[float, TopologyRecord]] = [] + for candidate in candidates: + # An owner-qualified context selector is deterministic when it has + # a single runtime candidate even if its source stable_id cannot + # survive the SolidWorks -> OCC boundary. + score = 1.0 if not geometry else self._geometry_score(geometry, candidate.geometry) + if score is not None: + scored.append((score, candidate)) + scored.sort(key=lambda item: (-item[0], item[1].record_id)) + public_candidates = tuple({"score": round(score, 6), **record.public_dict()} for score, record in scored) + if not scored or scored[0][0] < minimum_score: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=public_candidates, + diagnostic=RuntimeDiagnostic( + code="selector_not_found", + message="No runtime topology record satisfies the selector", + detail={"candidate_count": len(scored), "minimum_score": minimum_score}, + ), + ) + best_score, best_record = scored[0] + if len(scored) > 1 and abs(scored[1][0] - best_score) <= 1e-9: + return SelectorResolution( + selector=selector, + status="ambiguous", + candidates=public_candidates, + diagnostic=RuntimeDiagnostic( + code="selector_ambiguous", + message="More than one runtime topology record has the best selector score", + detail={"best_score": best_score, "candidate_count": len(scored)}, + ), + ) + return SelectorResolution(selector=selector, status="resolved", record=best_record, candidates=public_candidates)