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.
This commit is contained in:
2026-09-09 12:58:58 +08:00
parent f79c28759d
commit 3fb08423da
3 changed files with 1703 additions and 1610 deletions
File diff suppressed because it is too large Load Diff
+619
View File
@@ -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
File diff suppressed because it is too large Load Diff