3fb08423da
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.
620 lines
26 KiB
Python
620 lines
26 KiB
Python
"""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
|