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:
File diff suppressed because it is too large
Load Diff
@@ -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
Reference in New Issue
Block a user