Files
autocad-mcp-pro/engineering/mech/features.py
T
Umut CANandClaude Opus 5 457c53644f fix(task-3): keyway end-view arc, the machined face, and the chamfer's segment bound
Three defects in the feature catalogue, all faithfully transcribed from the
plan text and all wrong in shipped code.

1. Keyway.prims(view="side") emitted the arc the keyway REMOVES. `Arc` is CCW
   start -> end, and the emitted (right wall -> left wall) is the ~31 degree
   cap over the slot opening, not the ~329 degrees of shaft that survives it.
   Measured live on AutoCAD 2026 (Documents.Add scratch doc, closed in a
   finally): ModelSpace.AddArc(center, radius, start_rad, end_rad) -- angles in
   RADIANS, CCW start -> end -- gives ArcLength 8.098 mm for the emitted
   74.534 -> 105.466 deg and 86.150 mm for the reversed 105.466 -> 74.534 deg,
   against a 94.248 mm full circle at r = 15; StartPoint/EndPoint of the
   emitted arc are exactly the notch walls at x = +/-4. Swapped, so the arc now
   runs left wall -> right wall the long way round, and the side view's bbox
   goes from (-4, 11, 4, 15) to (-15, -15, 15, 14.457).

2. Hole and Pocket cut the plate from OPPOSITE faces in the same view frame:
   Hole ran z = 0 -> depth, Pocket z = thickness -> thickness - depth, and
   neither takes a face parameter. A plate carrying both showed the pocket
   milled from one side and the blind hole drilled from the other, silently --
   omission() returns None for both, so nothing reported it. The frame comment
   now names the machined face (v = thickness, the face the front view looks
   at), and Hole drills from it: the blind floor, the counterbore step and the
   through bore all hang down from there, as Pocket already did.

3. Chamfer.validate bounded the axial leg against the whole part, never against
   the segment the chamfer sits on. A shallow lead-in passed validation and was
   drawn straight through a shoulder at a radius that segment does not have
   (size 30 at 10 deg on a 15 mm end segment: radial 5.29 < 10 so the radial
   guard was happy, yet `before` landed at x = 45, two segments away). New
   helper _anchor_segment names the segment a corner actually cuts into -- at a
   step, the adjacent one carrying the larger radius -- and the leg is bounded
   by its length, refused by name with the step it would have crossed.

Tests: the keyway test asserted only primitive counts and two x coordinates, so
it passed with the arc reversed; it now pins the sweep, the endpoints and the
bbox. The hole test exercised only a through hole, which is symmetric; added a
blind-hole-and-pocket test that pins both to the same face, and a counterbore
test. Added three chamfer tests (two refusals, one that must still be
accepted). Each fix was mutation-checked: reverting it fails its test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 19:48:00 +03:00

2056 lines
81 KiB
Python

"""Every feature of the part model: validation, projected geometry, dimensions.
Pure: no backend, no async, no DXF. A feature answers four questions about the
part it sits on - *is my placement legal*, *what do I contribute to this view*,
*what do I contribute to a cut face*, *what dimension do I want* - and five
structural ones the view engine asks:
``removal_box(part)``
the axis-aligned box of material this feature removes, **in the front
view's plane** (revolved: x along the axis, y radial; prismatic: the
outline's own XY). ``None`` opts out - used by features whose removal is
not a box (an annulus, an axisymmetric bore).
``radial_edit(part)``
``{"kind": "inner"|"outer", "x0", "x1", "radius"}`` records that change the
revolved radial profile, which is what makes both the silhouette and the
section cut face come out of one calculation.
``corner_edit(part)``
``{"corner", "before", "after", "arc"}`` - replace one silhouette corner
with a chamfer line or a tangent fillet arc.
``outline_edit(part)``
the prismatic equivalent, keyed by outline vertex index.
``omission(part, view)``
why this feature has no geometry in that view, for the honesty rule: a
feature that contributes nothing and edits nothing is reported in the
view's ``omitted`` list rather than silently dropped.
Two features reuse the repository rather than restating a table:
``Keyway`` takes its width and depth from ``engineering.keyway`` (DIN 6885-1)
and ``GearTeeth`` takes its outline from ``engineering.gear``'s involute
generator. ``Thread`` takes its pitch from ``standards/threads.py`` (ISO 261)
and its geometry from ISO 6410. ``Undercut``, ``RetainingGroove``,
``CentreHole`` and ``ORingGroove`` take their dimensions from the registered
standards tables, which are not transcribed in this build - so each of them
either receives explicit dimensions or is refused by name. None of them
invents a number.
"""
from __future__ import annotations
import math
from collections.abc import Iterable
from dataclasses import dataclass, fields
from typing import Any, ClassVar, Protocol, runtime_checkable
from engineering.gear import generate_full_gear_outline
from engineering.keyway import keyway_dimensions
from engineering.mech.part import (
PrismaticPart,
RevolvedPart,
inner_radius_at,
outer_radius_at,
outline_bbox,
part_length,
point_in_outline,
segment_bounds,
)
from engineering.mech.primitives import Arc, Circle, DimIntent, HatchArea, Line, Poly, Prim, Text
from engineering.mech.standards.centres import centre_hole_dims
from engineering.mech.standards.orings import KINDS as ORING_KINDS
from engineering.mech.standards.orings import oring_groove_dims
from engineering.mech.standards.rings import ring_groove_dims
from engineering.mech.standards.threads import (
MINOR_RATIO,
parse_designation,
thread_axial_prims,
thread_end_prims,
)
from engineering.mech.standards.undercuts import undercut_profile
_EPS = 1e-9
@runtime_checkable
class Feature(Protocol):
"""The surface the part model and the view engine rely on."""
id: str
def validate(self, part) -> None: ...
def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: ...
def cut_prims(self, part, plane, ctx: dict) -> tuple[Prim, ...]: ...
def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: ...
# -- shared helpers ----------------------------------------------------------
def mirror_about_x(prims: Iterable[Prim]) -> tuple[Prim, ...]:
"""Mirror primitives about the y = 0 axis (the revolved part's axis)."""
out: list[Prim] = []
for prim in prims:
if isinstance(prim, Line):
out.append(Line((prim.p1[0], -prim.p1[1]), (prim.p2[0], -prim.p2[1]), prim.role))
elif isinstance(prim, Arc):
out.append(
Arc(
(prim.center[0], -prim.center[1]),
prim.radius,
(-prim.end_deg) % 360.0,
(-prim.start_deg) % 360.0,
prim.role,
)
)
elif isinstance(prim, Circle):
out.append(Circle((prim.center[0], -prim.center[1]), prim.radius, prim.role))
elif isinstance(prim, Poly):
out.append(Poly(tuple((x, -y) for x, y in prim.points), prim.closed, prim.role))
elif isinstance(prim, HatchArea):
out.append(
HatchArea(
tuple(tuple((x, -y) for x, y in loop) for loop in prim.loops),
prim.material,
)
)
elif isinstance(prim, Text):
out.append(
Text((prim.at[0], -prim.at[1]), prim.text, prim.height, prim.rotation, prim.role)
)
else: # pragma: no cover - the union is closed
raise ValueError(f"mirror_about_x: unknown primitive {type(prim).__name__}.")
return tuple(out)
def _number(value: Any, path: str, *, positive: bool = True) -> float:
try:
number = float(value)
except (TypeError, ValueError):
raise ValueError(f"{path}: expected a number, got {value!r}.") from None
if not math.isfinite(number):
raise ValueError(f"{path}: {value!r} is not a finite number.")
if positive and number <= _EPS:
raise ValueError(f"{path}: must be greater than zero, got {number}.")
return number
def _require_revolved(part, kind: str) -> RevolvedPart:
if not isinstance(part, RevolvedPart):
raise ValueError(f"{kind} applies to a revolved part, not a {type(part).__name__}.")
return part
def _require_prismatic(part, kind: str) -> PrismaticPart:
if not isinstance(part, PrismaticPart):
raise ValueError(f"{kind} applies to a prismatic part, not a {type(part).__name__}.")
return part
def _segment_index(part: RevolvedPart, index: Any, path: str = "segment") -> int:
try:
value = int(index)
except (TypeError, ValueError):
raise ValueError(f"{path}: expected a segment index, got {index!r}.") from None
count = len(part.segments)
if not 0 <= value < count:
raise ValueError(
f"{path} {value} is outside the {count}-segment part (segments 0-{count - 1})."
)
return value
def _parse_at(at: str, part: RevolvedPart, *, allow_step: bool = True) -> tuple[str, int]:
text = str(at or "").strip().lower()
if text in ("start", "end"):
return text, 0
if text.startswith("step:"):
if not allow_step:
raise ValueError(f"at {at!r}: this feature is placed at 'start' or 'end' only.")
try:
index = int(text.split(":", 1)[1])
except ValueError:
raise ValueError(f"at {at!r}: expected 'step:<index>'.") from None
last = len(part.segments) - 2
if last < 0 or not 0 <= index <= last:
raise ValueError(
f"at 'step:{index}': the part has {len(part.segments)} segment(s), so its steps "
f"are step:0-step:{max(last, 0)}."
)
return "step", index
raise ValueError(f"at {at!r}: expected 'start', 'end' or 'step:<index>'.")
def _radius_right_end(part: RevolvedPart, index: int) -> float:
segment = part.segments[index]
return (segment.taper_to if segment.taper_to is not None else segment.d_outer) / 2.0
def _radius_left_end(part: RevolvedPart, index: int) -> float:
return part.segments[index].d_outer / 2.0
def _anchor(feature, part: RevolvedPart) -> tuple[float, float, int]:
"""(x, radius, direction) of the corner a chamfer/fillet/undercut sits on."""
kind, index = _parse_at(feature.at, part)
if kind == "start":
return 0.0, _radius_left_end(part, 0), +1
if kind == "end":
last = len(part.segments) - 1
return part_length(part), _radius_right_end(part, last), -1
x = segment_bounds(part)[index][1]
left = _radius_right_end(part, index)
right = _radius_left_end(part, index + 1)
return (x, left, -1) if left >= right else (x, right, +1)
def _anchor_segment(feature, part: RevolvedPart) -> int:
"""Index of the segment a chamfer at ``feature.at`` actually cuts into.
``_anchor`` gives the corner and the direction into the material; the leg
runs that way, so at a step it belongs to whichever of the two adjacent
segments carries the larger radius.
"""
kind, index = _parse_at(feature.at, part)
if kind == "start":
return 0
if kind == "end":
return len(part.segments) - 1
left = _radius_right_end(part, index)
right = _radius_left_end(part, index + 1)
return index if left >= right else index + 1
def _shoulder(feature, part: RevolvedPart) -> tuple[float, float, int]:
"""(x, radius of the SMALLER cylinder, direction into it) for a relief groove."""
kind, index = _parse_at(feature.at, part)
if kind == "start":
return 0.0, _radius_left_end(part, 0), +1
if kind == "end":
last = len(part.segments) - 1
return part_length(part), _radius_right_end(part, last), -1
x = segment_bounds(part)[index][1]
left = _radius_right_end(part, index)
right = _radius_left_end(part, index + 1)
return (x, right, +1) if left >= right else (x, left, -1)
def _jsonable(value: Any) -> Any:
if isinstance(value, tuple):
return [_jsonable(item) for item in value]
if isinstance(value, list):
return [_jsonable(item) for item in value]
if isinstance(value, dict):
return {key: _jsonable(item) for key, item in value.items()}
return value
def _tuplify(value: Any) -> Any:
if isinstance(value, list):
return tuple(_tuplify(item) for item in value)
return value
# -- the base every feature shares -------------------------------------------
_REGISTRY: dict[str, type] = {}
def _register(cls):
_REGISTRY[cls.KIND] = cls
return cls
@dataclass(frozen=True, kw_only=True)
class _Base:
id: str
KIND: ClassVar[str] = ""
#: ISO 128-3: a rib or web cut longitudinally is not hatched. Features that
#: can be one override this as a real field.
no_section_hatch = False
#: True when the feature's *front* primitives describe only the +Y half of
#: an axisymmetric cut, so the view engine mirrors them about the axis. A
#: keyway is not axisymmetric (it sits on one face); a ring groove is.
axisymmetric: ClassVar[bool] = False
def validate(self, part) -> None:
if not isinstance(self.id, str) or not self.id.strip():
raise ValueError("id: a non-empty feature id is required.")
def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
return ()
def cut_prims(self, part, plane, ctx: dict) -> tuple[Prim, ...]:
return self.prims(part, "front", ctx)
def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
return ()
def removal_box(self, part) -> tuple[float, float, float, float] | None:
return None
def radial_edit(self, part) -> tuple[dict, ...]:
return ()
def corner_edit(self, part) -> dict | None:
return None
def outline_edit(self, part) -> dict | None:
return None
def omission(self, part, view: str) -> str | None:
return f"{type(self).__name__} has no {view} projection"
def to_dict(self) -> dict:
payload = {"kind": self.KIND}
for field_ in fields(self):
payload[field_.name] = _jsonable(getattr(self, field_.name))
return payload
def feature_from_dict(d: dict) -> Feature:
"""Build a feature from its dict form; the ``kind`` key selects the class."""
if not isinstance(d, dict):
raise ValueError(f"expected a dict with a 'kind' key, got {type(d).__name__}.")
kind = str(d.get("kind", "")).strip().lower()
if kind not in _REGISTRY:
raise ValueError(f"unknown feature kind {d.get('kind')!r}. Known kinds: {FEATURE_KINDS}.")
cls = _REGISTRY[kind]
names = {field_.name for field_ in fields(cls)}
payload = {key: value for key, value in d.items() if key != "kind"}
unknown = sorted(set(payload) - names)
if unknown:
raise ValueError(f"{kind}: unknown key(s) {unknown}; it takes {sorted(names)}.")
try:
return cls(**{key: _tuplify(value) for key, value in payload.items()})
except TypeError as exc:
raise ValueError(f"{kind}: {exc}") from exc
# -- revolved features -------------------------------------------------------
@_register
@dataclass(frozen=True, kw_only=True)
class Chamfer(_Base):
"""ISO 128-3 corner chamfer at an end or a step. ``size`` is the axial leg."""
KIND: ClassVar[str] = "chamfer"
at: str
size: float
angle: float = 45.0
def _legs(self) -> tuple[float, float]:
"""(axial leg, radial leg).
45 degrees is special-cased to an exact 1.0 slope:
``math.tan(math.radians(45))`` is 0.9999999999999999, and the
overwhelmingly common chamfer would otherwise carry that error into
every coordinate it produces.
"""
angle = float(self.angle)
slope = 1.0 if abs(angle - 45.0) < 1e-12 else math.tan(math.radians(angle))
return float(self.size), float(self.size) * slope
def validate(self, part) -> None:
super().validate(part)
part = _require_revolved(part, "Chamfer")
_number(self.size, "size")
angle = _number(self.angle, "angle")
if not 0.0 < angle < 90.0:
raise ValueError(f"angle: a chamfer angle is between 0 and 90 degrees, got {angle}.")
x, radius, direction = _anchor(self, part)
axial, radial = self._legs()
if radial >= radius:
raise ValueError(
f"size {self.size} at {self.at!r} cuts {radial:.3f} mm off a {radius:.3f} mm "
"radius: the chamfer is larger than the material it sits on."
)
index = _anchor_segment(self, part)
segment_length = part.segments[index].length
if axial > segment_length + _EPS:
far = x + direction * segment_length
raise ValueError(
f"size {self.size} at {self.at!r} runs {axial:g} mm along the axis, past "
f"segment {index}'s {segment_length:g} mm: the chamfer would cross the step "
f"at x={far:g} and be anchored at a radius that segment does not have."
)
del x
def corner_edit(self, part) -> dict:
x, radius, direction = _anchor(self, _require_revolved(part, "Chamfer"))
axial, radial = self._legs()
if direction > 0:
before, after = (x, radius - radial), (x + axial, radius)
else:
before, after = (x - axial, radius), (x, radius - radial)
return {"corner": (x, radius), "before": before, "after": after, "arc": None}
def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
if view != "side":
return ()
part = _require_revolved(part, "Chamfer")
_, radius, _ = _anchor(self, part)
_, radial = self._legs()
return (Circle((0.0, 0.0), radius - radial, "visible"),)
def removal_box(self, part):
edit = self.corner_edit(part)
xs = (edit["before"][0], edit["after"][0])
ys = (edit["before"][1], edit["after"][1])
return (min(xs), min(ys), max(xs), max(ys))
def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
if view != "front":
return ()
edit = self.corner_edit(part)
return (
DimIntent(
kind="aligned",
p1=edit["before"],
p2=edit["after"],
text_override=f"{float(self.size):g}x{float(self.angle):g}°",
feature=self.id,
),
)
def omission(self, part, view: str) -> str | None:
return None if view in ("front", "side") else f"a chamfer has no {view} projection"
@_register
@dataclass(frozen=True, kw_only=True)
class Fillet(_Base):
"""A tangent fillet radius in the inside corner of a step."""
KIND: ClassVar[str] = "fillet"
at: str
radius: float
def validate(self, part) -> None:
super().validate(part)
part = _require_revolved(part, "Fillet")
radius = _number(self.radius, "radius")
kind, index = _parse_at(self.at, part)
if kind != "step":
raise ValueError(
f"at {self.at!r}: a fillet is defined at a step ('step:<index>'); an end "
"corner takes a Chamfer."
)
left = _radius_right_end(part, index)
right = _radius_left_end(part, index + 1)
if abs(left - right) < _EPS:
raise ValueError(f"at {self.at!r}: the two segments have the same diameter, no step.")
if radius >= abs(left - right):
raise ValueError(
f"radius {radius} is not smaller than the step height {abs(left - right):.3f} mm."
)
shortest = min(part.segments[index].length, part.segments[index + 1].length)
if radius >= shortest:
raise ValueError(
f"radius {radius} is not smaller than the shortest segment {shortest}."
)
def corner_edit(self, part) -> dict:
part = _require_revolved(part, "Fillet")
_, index = _parse_at(self.at, part)
x = segment_bounds(part)[index][1]
left = _radius_right_end(part, index)
right = _radius_left_end(part, index + 1)
radius = float(self.radius)
if left < right: # step up going right: the small cylinder is on the left
corner = (x, left)
before = (x - radius, left)
after = (x, left + radius)
center = (x - radius, left + radius)
arc = Arc(center, radius, 270.0, 360.0, "visible")
else: # step down going right: the small cylinder is on the right
corner = (x, right)
before = (x, right + radius)
after = (x + radius, right)
center = (x + radius, right + radius)
arc = Arc(center, radius, 180.0, 270.0, "visible")
return {"corner": corner, "before": before, "after": after, "arc": arc}
def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
if view != "front":
return ()
edit = self.corner_edit(part)
return (
DimIntent(
kind="radius",
p1=edit["arc"].center,
p2=edit["before"],
text_override=f"R{float(self.radius):g}",
feature=self.id,
),
)
def omission(self, part, view: str) -> str | None:
if view == "front":
return None
return "a step fillet projects onto the end view as a circle that coincides with the step"
@_register
@dataclass(frozen=True, kw_only=True)
class Keyway(_Base):
"""DIN 6885-1 keyway on the +Y face of a segment.
Width and depth come from ``engineering.keyway.keyway_dimensions`` unless
both are given. Because the slot is modelled on the +Y face, the front view
shows its floor and its two end walls as visible lines; the width edges
coincide with the silhouette and are not drawn twice.
"""
KIND: ClassVar[str] = "keyway"
segment: int
length: float
offset: float = 0.0
from_end: str = "left"
width: float | None = None
depth: float | None = None
def _table(self, part) -> dict:
index = _segment_index(part, self.segment)
diameter = part.segments[index].d_outer
if self.width is not None and self.depth is not None:
return {"width": float(self.width), "depth_shaft": float(self.depth)}
try:
table = keyway_dimensions(diameter)
except ValueError as exc:
raise ValueError(f"DIN 6885: {exc}") from exc
return {
"width": float(self.width) if self.width is not None else table["width"],
"depth_shaft": float(self.depth) if self.depth is not None else table["depth_shaft"],
}
def _span(self, part) -> tuple[float, float, float, float, float]:
index = _segment_index(part, self.segment)
x0, x1 = segment_bounds(part)[index]
table = self._table(part)
offset = float(self.offset)
length = float(self.length)
if str(self.from_end).strip().lower() == "right":
xa = x1 - offset - length
else:
xa = x0 + offset
radius = part.segments[index].d_outer / 2.0
return xa, xa + length, radius, table["width"], table["depth_shaft"]
def validate(self, part) -> None:
super().validate(part)
part = _require_revolved(part, "Keyway")
index = _segment_index(part, self.segment)
_number(self.length, "length")
_number(self.offset, "offset", positive=False)
if str(self.from_end).strip().lower() not in ("left", "right"):
raise ValueError(f"from_end: expected 'left' or 'right', got {self.from_end!r}.")
segment_length = part.segments[index].length
if float(self.offset) + float(self.length) > segment_length + _EPS:
raise ValueError(
f"length {self.length} + offset {self.offset} exceeds segment {index}'s "
f"{segment_length} mm."
)
table = self._table(part)
radius = part.segments[index].d_outer / 2.0
if table["depth_shaft"] >= radius:
raise ValueError(
f"depth {table['depth_shaft']} mm is not smaller than the radius {radius} mm."
)
def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
part = _require_revolved(part, "Keyway")
xa, xb, radius, width, depth = self._span(part)
if view == "front":
floor = radius - depth
return (
Line((xa, radius), (xa, floor), "visible"),
Line((xa, floor), (xb, floor), "visible"),
Line((xb, floor), (xb, radius), "visible"),
)
if view == "side":
half = width / 2.0
top = math.sqrt(max(radius * radius - half * half, 0.0))
floor = radius - depth
right = math.degrees(math.atan2(top, half)) % 360.0
left = math.degrees(math.atan2(top, -half)) % 360.0
# `Arc` is CCW from start to end, so the shaft that SURVIVES the slot
# runs from the left notch wall the long way round to the right one.
# Emitting (right -> left) would draw the ~31 degree cap over the
# opening instead of the ~329 degrees of material.
return (
Arc((0.0, 0.0), radius, left, right, "visible"),
Line((-half, top), (-half, floor), "visible"),
Line((-half, floor), (half, floor), "visible"),
Line((half, floor), (half, top), "visible"),
)
return ()
def removal_box(self, part):
xa, xb, radius, _, depth = self._span(part)
return (xa, radius - depth, xb, radius)
def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
xa, xb, radius, width, depth = self._span(part)
if view == "front":
return (DimIntent(kind="linear", p1=(xa, radius), p2=(xb, radius), feature=self.id),)
if view == "side":
floor = radius - depth
return (
DimIntent(
kind="linear",
p1=(-width / 2.0, floor),
p2=(width / 2.0, floor),
feature=self.id,
),
DimIntent(
kind="linear",
p1=(0.0, -radius),
p2=(0.0, floor),
text_override=f"{radius + floor:g}",
feature=self.id,
),
)
return ()
def omission(self, part, view: str) -> str | None:
if view in ("front", "side"):
return None
return "a keyway on the +Y face coincides with the outline in the top view"
@_register
@dataclass(frozen=True, kw_only=True)
class Thread(_Base):
"""An ISO 261 thread drawn with the ISO 6410 representation."""
KIND: ClassVar[str] = "thread"
segment: int
designation: str
length: float
offset: float = 0.0
from_end: str = "left"
internal: bool = False
def _parsed(self) -> dict:
return parse_designation(self.designation)
def _span(self, part) -> tuple[float, float, float]:
index = _segment_index(part, self.segment)
x0, x1 = segment_bounds(part)[index]
length = float(self.length)
if str(self.from_end).strip().lower() == "right":
xa = x1 - float(self.offset) - length
else:
xa = x0 + float(self.offset)
return xa, xa + length, self._parsed()["d"]
def validate(self, part) -> None:
super().validate(part)
part = _require_revolved(part, "Thread")
index = _segment_index(part, self.segment)
parsed = self._parsed()
_number(self.length, "length")
_number(self.offset, "offset", positive=False)
segment = part.segments[index]
if float(self.offset) + float(self.length) > segment.length + _EPS:
raise ValueError(
f"length {self.length} + offset {self.offset} exceeds segment {index}'s "
f"{segment.length} mm."
)
if self.internal:
# ISO 6410: a tapped hole is modelled with the *drilled* bore, i.e.
# the minor diameter at 0.8 x major, and the major is what the
# section hatch then runs to (radial_edit below).
wanted = MINOR_RATIO * parsed["d"]
if abs(wanted - segment.d_inner) > 0.01:
raise ValueError(
f"{parsed['designation']} internal is drawn with its minor diameter at "
f"{wanted:g} mm (ISO 6410, 0.8 x major) but segment {index}'s bore is "
f"{segment.d_inner:g} mm. Drill the segment to the minor diameter, or "
"name the thread that fits it."
)
elif abs(parsed["d"] - segment.d_outer) > 0.01:
raise ValueError(
f"{parsed['designation']} has a major diameter of {parsed['d']:g} mm but "
f"segment {index}'s outside is {segment.d_outer:g} mm. Cut the segment to the "
"thread diameter, or name the thread that fits it."
)
def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
part = _require_revolved(part, "Thread")
xa, xb, d = self._span(part)
if view == "front":
return thread_axial_prims(x0=xa, length=xb - xa, d=d, internal=bool(self.internal))
if view == "side":
return thread_end_prims(center=(0.0, 0.0), d=d, internal=bool(self.internal))
return ()
def radial_edit(self, part) -> tuple[dict, ...]:
if not self.internal:
return ()
# ISO 6410 section convention: the hatch of an internal thread runs to
# the major diameter, so the cut face's bore is the major, not the minor.
xa, xb, d = self._span(part)
return ({"kind": "inner", "x0": xa, "x1": xb, "radius": d / 2.0},)
def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
if view != "front":
return ()
xa, _, d = self._span(part)
return (
DimIntent(
kind="diameter",
p1=(xa, d / 2.0),
p2=(xa, -d / 2.0),
text_override=self._parsed()["designation"],
feature=self.id,
),
)
def omission(self, part, view: str) -> str | None:
return None if view in ("front", "side") else f"a thread has no {view} projection"
@property
def minor_ratio(self) -> float:
return MINOR_RATIO
@_register
@dataclass(frozen=True, kw_only=True)
class Undercut(_Base):
"""A DIN 509 relief groove drawn from the profile the standard publishes.
``profile`` is a sequence of ``(along, depth)`` pairs in millimetres,
measured from the shoulder corner into the smaller cylinder. With no
explicit profile the registered DIN 509 table is asked - and refuses by
name, because no row is transcribed in this build. Nothing is reconstructed.
"""
KIND: ClassVar[str] = "undercut"
axisymmetric: ClassVar[bool] = True
at: str
form: str = "E"
profile: tuple[tuple[float, float], ...] | None = None
def _profile(self, part) -> tuple[tuple[float, float], ...]:
if self.profile is not None:
return tuple((float(a), float(b)) for a, b in self.profile)
_, radius, _ = _shoulder(self, part)
return undercut_profile(self.form, radius * 2.0)
def validate(self, part) -> None:
super().validate(part)
part = _require_revolved(part, "Undercut")
_parse_at(self.at, part)
profile = self._profile(part)
if len(profile) < 2:
raise ValueError("profile: a relief profile needs at least two (along, depth) pairs.")
previous = -math.inf
for index, (along, depth) in enumerate(profile):
_number(along, f"profile[{index}].along", positive=False)
_number(depth, f"profile[{index}].depth", positive=False)
if along < previous - _EPS:
raise ValueError(f"profile[{index}].along: the profile must run monotonically.")
previous = along
_, radius, _ = _shoulder(self, part)
deepest = max(depth for _, depth in profile)
if deepest >= radius:
raise ValueError(
f"profile: the deepest relief {deepest} mm is not smaller than the "
f"{radius} mm radius it is cut into."
)
def _points(self, part) -> tuple[tuple[float, float], ...]:
x, radius, direction = _shoulder(self, part)
return tuple(
(x + direction * along, radius - depth) for along, depth in self._profile(part)
)
def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
if view != "front":
return ()
return (Poly(self._points(_require_revolved(part, "Undercut")), False, "visible"),)
def removal_box(self, part):
points = self._points(part)
xs = [p[0] for p in points]
ys = [p[1] for p in points]
return (min(xs), min(ys), max(xs), max(ys))
def omission(self, part, view: str) -> str | None:
return None if view == "front" else f"a relief groove has no {view} projection"
@_register
@dataclass(frozen=True, kw_only=True)
class RetainingGroove(_Base):
"""A DIN 471 (shaft) or DIN 472 (bore) retaining-ring groove."""
KIND: ClassVar[str] = "retaining_groove"
axisymmetric: ClassVar[bool] = True
x: float
d: float
where: str = "shaft"
m: float | None = None
d2: float | None = None
standard: str | None = None
def _standard(self) -> str:
if self.standard:
return " ".join(str(self.standard).split()).upper()
return "DIN 471" if str(self.where).strip().lower() == "shaft" else "DIN 472"
def _dims(self) -> dict:
if self.m is not None and self.d2 is not None:
return {"m": float(self.m), "d2": float(self.d2)}
return ring_groove_dims(self._standard(), float(self.d))
def validate(self, part) -> None:
super().validate(part)
part = _require_revolved(part, "RetainingGroove")
where = str(self.where).strip().lower()
if where not in ("shaft", "bore"):
raise ValueError(f"where: expected 'shaft' or 'bore', got {self.where!r}.")
x = _number(self.x, "x", positive=False)
_number(self.d, "d")
if not 0.0 <= x <= part_length(part) + _EPS:
raise ValueError(f"x {x} is outside the part (0 .. {part_length(part)} mm).")
dims = self._dims()
_number(dims["m"], "m")
_number(dims["d2"], "d2")
radius = outer_radius_at(part, x)
bore = inner_radius_at(part, x)
if where == "shaft" and dims["d2"] / 2.0 >= radius:
raise ValueError(
f"d2 {dims['d2']} mm does not cut into the {radius * 2.0:g} mm shaft at x={x}."
)
if where == "bore" and dims["d2"] / 2.0 <= bore:
raise ValueError(
f"d2 {dims['d2']} mm does not cut into the {bore * 2.0:g} mm bore at x={x}."
)
def _span(self, part) -> tuple[float, float, float, float]:
dims = self._dims()
x = float(self.x)
half = dims["m"] / 2.0
surface = (
outer_radius_at(part, x)
if str(self.where).strip().lower() == "shaft"
else inner_radius_at(part, x)
)
return x - half, x + half, dims["d2"] / 2.0, surface
def radial_edit(self, part) -> tuple[dict, ...]:
x0, x1, groove_r, _ = self._span(part)
kind = "outer" if str(self.where).strip().lower() == "shaft" else "inner"
return ({"kind": kind, "x0": x0, "x1": x1, "radius": groove_r},)
def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
if view != "front":
return ()
x0, x1, groove_r, surface = self._span(_require_revolved(part, "RetainingGroove"))
return (
Line((x0, surface), (x0, groove_r), "visible"),
Line((x0, groove_r), (x1, groove_r), "visible"),
Line((x1, groove_r), (x1, surface), "visible"),
)
def removal_box(self, part):
x0, x1, groove_r, surface = self._span(part)
return (x0, min(groove_r, surface), x1, max(groove_r, surface))
def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
if view != "front":
return ()
x0, x1, groove_r, _ = self._span(part)
dims = self._dims()
return (
DimIntent(kind="linear", p1=(x0, groove_r), p2=(x1, groove_r), feature=self.id),
DimIntent(
kind="diameter",
p1=(float(self.x), groove_r),
p2=(float(self.x), -groove_r),
text_override=f"⌀{dims['d2']:g} ({self._standard()})",
feature=self.id,
),
)
def omission(self, part, view: str) -> str | None:
return None if view == "front" else f"a ring groove has no {view} projection"
@_register
@dataclass(frozen=True, kw_only=True)
class CentreHole(_Base):
"""A DIN 332-1 centre hole, forms A and B. Form R is refused, not approximated."""
KIND: ClassVar[str] = "centre_hole"
at: str = "end"
form: str = "A"
size: float = 0.0
d1: float | None = None
d2: float | None = None
d3: float | None = None
t: float | None = None
def _dims(self) -> dict:
form = str(self.form).strip().upper()
explicit = self.d1 is not None and self.d2 is not None and self.t is not None
if form == "B":
explicit = explicit and self.d3 is not None
if explicit:
return {
"d1": float(self.d1),
"d2": float(self.d2),
"d3": float(self.d3) if self.d3 is not None else None,
"t": float(self.t),
}
return centre_hole_dims(self.form, float(self.size))
def validate(self, part) -> None:
super().validate(part)
part = _require_revolved(part, "CentreHole")
_parse_at(self.at, part, allow_step=False)
if str(self.form).strip().upper() == "R":
# the table module owns the wording; ask it so there is one message
centre_hole_dims("R", float(self.size))
dims = self._dims()
_number(dims["d1"], "d1")
_number(dims["d2"], "d2")
_number(dims["t"], "t")
if dims["d2"] <= dims["d1"]:
raise ValueError(f"d2 {dims['d2']} must be larger than d1 {dims['d1']}.")
kind, _ = _parse_at(self.at, part, allow_step=False)
x = 0.0 if kind == "start" else part_length(part)
if dims["d2"] / 2.0 >= outer_radius_at(part, x):
raise ValueError(
f"d2 {dims['d2']} mm does not fit the {outer_radius_at(part, x) * 2.0:g} mm face."
)
def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
part = _require_revolved(part, "CentreHole")
dims = self._dims()
kind, _ = _parse_at(self.at, part, allow_step=False)
face = 0.0 if kind == "start" else part_length(part)
step = 1.0 if kind == "start" else -1.0
r1, r2 = dims["d1"] / 2.0, dims["d2"] / 2.0
cone = (r2 - r1) / math.tan(math.radians(30.0)) # 60 deg included angle
if view == "front":
prims: list[Prim] = [
Line((face, r2), (face + step * cone, r1), "hidden"),
Line((face, -r2), (face + step * cone, -r1), "hidden"),
Line((face + step * cone, r1), (face + step * dims["t"], r1), "hidden"),
Line((face + step * cone, -r1), (face + step * dims["t"], -r1), "hidden"),
Line(
(face + step * dims["t"], -r1),
(face + step * dims["t"], r1),
"hidden",
),
]
if str(self.form).strip().upper() == "B" and dims.get("d3"):
r3 = dims["d3"] / 2.0
guard = (r3 - r2) / math.tan(math.radians(60.0)) # 120 deg protective cone
prims.insert(0, Line((face, r3), (face + step * guard, r2), "hidden"))
prims.insert(1, Line((face, -r3), (face + step * guard, -r2), "hidden"))
return tuple(prims)
if view == "side":
circles: list[Prim] = [
Circle((0.0, 0.0), r1, "hidden"),
Circle((0.0, 0.0), r2, "hidden"),
]
if str(self.form).strip().upper() == "B" and dims.get("d3"):
circles.append(Circle((0.0, 0.0), dims["d3"] / 2.0, "hidden"))
return tuple(circles)
return ()
def removal_box(self, part):
dims = self._dims()
kind, _ = _parse_at(self.at, part, allow_step=False)
face = 0.0 if kind == "start" else part_length(part)
step = 1.0 if kind == "start" else -1.0
far = face + step * dims["t"]
outer = max(dims["d2"], dims.get("d3") or 0.0) / 2.0
return (min(face, far), -outer, max(face, far), outer)
def omission(self, part, view: str) -> str | None:
return None if view in ("front", "side") else f"a centre hole has no {view} projection"
@_register
@dataclass(frozen=True, kw_only=True)
class ORingGroove(_Base):
"""An ISO 3601-2 radial O-ring housing. An axial (face) groove is refused."""
KIND: ClassVar[str] = "oring_groove"
axisymmetric: ClassVar[bool] = True
x: float
cord: float
where: str = "shaft"
housing: str = "radial"
b: float | None = None
h: float | None = None
def _dims(self) -> dict:
if self.b is not None and self.h is not None:
return {"b": float(self.b), "h": float(self.h)}
return oring_groove_dims(float(self.cord), str(self.housing))
def validate(self, part) -> None:
super().validate(part)
part = _require_revolved(part, "ORingGroove")
if str(self.housing).strip().lower() not in ORING_KINDS:
raise ValueError(f"housing: expected one of {ORING_KINDS}, got {self.housing!r}.")
if str(self.housing).strip().lower() == "axial":
raise ValueError(
"housing 'axial': a face groove is not on the revolved silhouette and is "
"refused rather than drawn as if it were radial. Model the face as a "
"prismatic pocket."
)
if str(self.where).strip().lower() not in ("shaft", "bore"):
raise ValueError(f"where: expected 'shaft' or 'bore', got {self.where!r}.")
x = _number(self.x, "x", positive=False)
_number(self.cord, "cord")
if not 0.0 <= x <= part_length(part) + _EPS:
raise ValueError(f"x {x} is outside the part (0 .. {part_length(part)} mm).")
dims = self._dims()
_number(dims["b"], "b")
_number(dims["h"], "h")
radius = outer_radius_at(part, x)
if str(self.where).strip().lower() == "shaft" and dims["h"] >= radius:
raise ValueError(f"h {dims['h']} mm is not smaller than the radius {radius} mm.")
def _span(self, part) -> tuple[float, float, float, float]:
dims = self._dims()
x = float(self.x)
half = dims["b"] / 2.0
if str(self.where).strip().lower() == "shaft":
surface = outer_radius_at(part, x)
return x - half, x + half, surface - dims["h"], surface
surface = inner_radius_at(part, x)
return x - half, x + half, surface + dims["h"], surface
def radial_edit(self, part) -> tuple[dict, ...]:
x0, x1, groove_r, _ = self._span(part)
kind = "outer" if str(self.where).strip().lower() == "shaft" else "inner"
return ({"kind": kind, "x0": x0, "x1": x1, "radius": groove_r},)
def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
if view != "front":
return ()
x0, x1, groove_r, surface = self._span(_require_revolved(part, "ORingGroove"))
return (
Line((x0, surface), (x0, groove_r), "visible"),
Line((x0, groove_r), (x1, groove_r), "visible"),
Line((x1, groove_r), (x1, surface), "visible"),
)
def removal_box(self, part):
x0, x1, groove_r, surface = self._span(part)
return (x0, min(groove_r, surface), x1, max(groove_r, surface))
def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
if view != "front":
return ()
x0, x1, groove_r, _ = self._span(part)
return (DimIntent(kind="linear", p1=(x0, groove_r), p2=(x1, groove_r), feature=self.id),)
def omission(self, part, view: str) -> str | None:
return None if view == "front" else f"an O-ring groove has no {view} projection"
@_register
@dataclass(frozen=True, kw_only=True)
class CrossHole(_Base):
"""A radial hole through (or into) a revolved part.
Only a radial hole (``angle`` 90 degrees) is projected. An inclined cross
hole is refused rather than drawn as if it were radial.
"""
KIND: ClassVar[str] = "cross_hole"
x: float
diameter: float
angle: float = 90.0
depth: float | None = None
def validate(self, part) -> None:
super().validate(part)
part = _require_revolved(part, "CrossHole")
x = _number(self.x, "x", positive=False)
diameter = _number(self.diameter, "diameter")
angle = _number(self.angle, "angle", positive=False)
if abs(angle - 90.0) > _EPS:
raise ValueError(
f"angle {angle}: only a radial cross hole (angle=90) is projected by this "
"engine; an inclined hole is refused rather than drawn as if it were radial."
)
if not 0.0 <= x <= part_length(part) + _EPS:
raise ValueError(f"x {x} is outside the part (0 .. {part_length(part)} mm).")
radius = outer_radius_at(part, x)
if diameter >= 2.0 * radius:
raise ValueError(
f"diameter {diameter} mm is not smaller than the {2.0 * radius:g} mm outside "
f"diameter at x={x}."
)
if self.depth is not None:
depth = _number(self.depth, "depth")
if depth > 2.0 * radius + _EPS:
raise ValueError(f"depth {depth} mm is deeper than the part at x={x}.")
def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
part = _require_revolved(part, "CrossHole")
x = float(self.x)
half = float(self.diameter) / 2.0
radius = outer_radius_at(part, x)
if view == "front":
bottom = -radius if self.depth is None else radius - float(self.depth)
prims: list[Prim] = [
Line((x - half, bottom), (x - half, radius), "hidden"),
Line((x + half, bottom), (x + half, radius), "hidden"),
]
if self.depth is not None:
prims.append(Line((x - half, bottom), (x + half, bottom), "hidden"))
return tuple(prims)
if view == "side":
return (
Line((-radius, half), (radius, half), "hidden"),
Line((-radius, -half), (radius, -half), "hidden"),
)
return ()
def removal_box(self, part):
x = float(self.x)
half = float(self.diameter) / 2.0
radius = outer_radius_at(part, x)
bottom = -radius if self.depth is None else radius - float(self.depth)
return (x - half, bottom, x + half, radius)
def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
if view != "front":
return ()
x = float(self.x)
half = float(self.diameter) / 2.0
return (
DimIntent(
kind="diameter",
p1=(x - half, 0.0),
p2=(x + half, 0.0),
text_override=f"⌀{float(self.diameter):g}",
feature=self.id,
),
)
def omission(self, part, view: str) -> str | None:
return None if view in ("front", "side") else f"a cross hole has no {view} projection"
@_register
@dataclass(frozen=True, kw_only=True)
class HoleCircle(_Base):
"""A bolt circle on a face: hidden pairs in the axial view, real holes in the end view."""
KIND: ClassVar[str] = "hole_circle"
x: float
pcd: float
count: int
diameter: float
start_angle: float = 0.0
thread: str | None = None
def validate(self, part) -> None:
super().validate(part)
part = _require_revolved(part, "HoleCircle")
x = _number(self.x, "x", positive=False)
pcd = _number(self.pcd, "pcd")
diameter = _number(self.diameter, "diameter")
_number(self.start_angle, "start_angle", positive=False)
if int(self.count) < 2:
raise ValueError(f"count: a bolt circle needs at least 2 holes, got {self.count}.")
if not 0.0 <= x <= part_length(part) + _EPS:
raise ValueError(f"x {x} is outside the part (0 .. {part_length(part)} mm).")
radius = outer_radius_at(part, x)
bore = inner_radius_at(part, x)
if pcd / 2.0 + diameter / 2.0 > radius + _EPS:
raise ValueError(
f"pcd {pcd} with ⌀{diameter} reaches {pcd / 2.0 + diameter / 2.0:.3f} mm, "
f"past the {radius:.3f} mm outside radius at x={x}."
)
if pcd / 2.0 - diameter / 2.0 < bore - _EPS:
raise ValueError(
f"pcd {pcd} with ⌀{diameter} reaches {pcd / 2.0 - diameter / 2.0:.3f} mm, "
f"inside the {bore:.3f} mm bore at x={x}."
)
if self.thread:
parse_designation(self.thread)
def _centers(self) -> tuple[tuple[float, float], ...]:
count = int(self.count)
radius = float(self.pcd) / 2.0
start = math.radians(float(self.start_angle))
return tuple(
(
radius * math.cos(start + 2.0 * math.pi * k / count),
radius * math.sin(start + 2.0 * math.pi * k / count),
)
for k in range(count)
)
def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
part = _require_revolved(part, "HoleCircle")
pcd_r = float(self.pcd) / 2.0
half = float(self.diameter) / 2.0
if view == "front":
index, _, _, _ = _segment_at_index(part, float(self.x))
x0, x1 = segment_bounds(part)[index]
prims: list[Prim] = []
for sign in (1.0, -1.0):
for offset in (half, -half):
y = sign * (pcd_r + offset)
prims.append(Line((x0, y), (x1, y), "hidden"))
prims.append(Line((x0, sign * pcd_r), (x1, sign * pcd_r), "center"))
return tuple(prims)
if view == "side":
prims = [Circle((0.0, 0.0), pcd_r, "center")]
for cx, cy in self._centers():
prims.append(Circle((cx, cy), half, "visible"))
prims.append(Line((cx - half - 2.0, cy), (cx + half + 2.0, cy), "center"))
prims.append(Line((cx, cy - half - 2.0), (cx, cy + half + 2.0), "center"))
return tuple(prims)
return ()
def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
if view != "side":
return ()
pcd_r = float(self.pcd) / 2.0
half = float(self.diameter) / 2.0
first = self._centers()[0]
label = self.thread or f"⌀{float(self.diameter):g}"
return (
DimIntent(
kind="diameter",
p1=(first[0] - half, first[1]),
p2=(first[0] + half, first[1]),
text_override=f"{int(self.count)}x {label}",
feature=self.id,
),
DimIntent(
kind="diameter",
p1=(-pcd_r, 0.0),
p2=(pcd_r, 0.0),
text_override=f"⌀{float(self.pcd):g}",
feature=self.id,
),
)
def omission(self, part, view: str) -> str | None:
return None if view in ("front", "side") else f"a bolt circle has no {view} projection"
def _segment_at_index(part: RevolvedPart, x: float) -> tuple[int, float, float, Any]:
bounds = zip(segment_bounds(part), part.segments, strict=True)
for index, ((x0, x1), segment) in enumerate(bounds):
if x0 - _EPS <= x <= x1 + _EPS:
return index, x0, x1, segment
raise ValueError(f"x={x} is outside the part (0 .. {part_length(part)} mm).")
@_register
@dataclass(frozen=True, kw_only=True)
class AxialBore(_Base):
"""A bore drilled in from one end. Expressed through the radial profile."""
KIND: ClassVar[str] = "axial_bore"
at: str = "start"
diameter: float
depth: float | None = None
def _span(self, part) -> tuple[float, float]:
kind, _ = _parse_at(self.at, part, allow_step=False)
length = part_length(part)
depth = length if self.depth is None else float(self.depth)
return (0.0, depth) if kind == "start" else (length - depth, length)
def validate(self, part) -> None:
super().validate(part)
part = _require_revolved(part, "AxialBore")
_parse_at(self.at, part, allow_step=False)
diameter = _number(self.diameter, "diameter")
if self.depth is not None:
depth = _number(self.depth, "depth")
if depth > part_length(part) + _EPS:
raise ValueError(
f"depth {depth} mm is longer than the part {part_length(part)} mm."
)
x0, x1 = self._span(part)
for x in (x0 + _EPS, (x0 + x1) / 2.0, x1 - _EPS):
if diameter >= 2.0 * outer_radius_at(part, x):
raise ValueError(
f"diameter {diameter} mm is not smaller than the "
f"{2.0 * outer_radius_at(part, x):g} mm outside diameter at x={x:.3f}."
)
for other in getattr(part, "features", ()):
if other is self or not isinstance(other, AxialBore):
continue
ox0, ox1 = other._span(part)
if min(x1, ox1) - max(x0, ox0) > _EPS:
raise ValueError(
f"bores {self.id!r} and {other.id!r} both remove material over "
f"x {max(x0, ox0):.3f}..{min(x1, ox1):.3f}. A bore that changes diameter "
"is two segments, not two features."
)
def radial_edit(self, part) -> tuple[dict, ...]:
x0, x1 = self._span(part)
return ({"kind": "inner", "x0": x0, "x1": x1, "radius": float(self.diameter) / 2.0},)
def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
part = _require_revolved(part, "AxialBore")
half = float(self.diameter) / 2.0
kind, _ = _parse_at(self.at, part, allow_step=False)
x0, x1 = self._span(part)
if view == "front" and self.depth is not None:
bottom = x1 if kind == "start" else x0
return (Line((bottom, -half), (bottom, half), "hidden"),)
if view == "side":
near = str(ctx.get("side", "right")).strip().lower()
open_here = (near == "left" and kind == "start") or (near == "right" and kind == "end")
return (Circle((0.0, 0.0), half, "visible" if open_here else "hidden"),)
return ()
def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
if view != "side":
return ()
half = float(self.diameter) / 2.0
return (
DimIntent(
kind="diameter",
p1=(-half, 0.0),
p2=(half, 0.0),
text_override=f"⌀{float(self.diameter):g}",
feature=self.id,
),
)
def omission(self, part, view: str) -> str | None:
return None # the bore always reaches the drawing through the radial profile
@_register
@dataclass(frozen=True, kw_only=True)
class GearTeeth(_Base):
"""Involute spur/helical teeth on a segment, from the repository's own generator."""
KIND: ClassVar[str] = "gear_teeth"
segment: int
module: float
teeth: int
pressure_angle: float = 20.0
helix_angle: float = 0.0
hand: str = "RH"
def _radii(self) -> tuple[float, float, float]:
module = float(self.module)
teeth = int(self.teeth)
pitch = module * teeth / 2.0
return pitch + module, pitch, pitch - 1.25 * module
def validate(self, part) -> None:
super().validate(part)
part = _require_revolved(part, "GearTeeth")
index = _segment_index(part, self.segment)
_number(self.module, "module")
angle = _number(self.pressure_angle, "pressure_angle")
if int(self.teeth) < 6:
raise ValueError(
f"teeth: the involute generator needs at least 6 teeth, got {self.teeth}."
)
if not 10.0 <= angle <= 35.0:
raise ValueError(f"pressure_angle {angle}: expected between 10 and 35 degrees.")
if str(self.hand).strip().upper() not in ("RH", "LH"):
raise ValueError(f"hand: expected 'RH' or 'LH', got {self.hand!r}.")
tip, _, _ = self._radii()
outside = part.segments[index].d_outer
if abs(tip * 2.0 - outside) > 0.01:
raise ValueError(
f"m={float(self.module):g} z={int(self.teeth)} gives a tip diameter of "
f"{tip * 2.0:g} mm, but segment {index} is {outside:g} mm. Cut the segment to "
"the tip diameter, or name the module and tooth count that fit it."
)
def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
part = _require_revolved(part, "GearTeeth")
index = _segment_index(part, self.segment)
_, pitch, root = self._radii()
if view == "front":
x0, x1 = segment_bounds(part)[index]
prims: list[Prim] = [
Line((x0, root), (x1, root), "visible"),
Line((x0, -root), (x1, -root), "visible"),
Line((x0, pitch), (x1, pitch), "center"),
Line((x0, -pitch), (x1, -pitch), "center"),
]
if abs(float(self.helix_angle)) > _EPS:
# ISO 2203: three thin lines at the helix angle mark the hand.
mid = (x0 + x1) / 2.0
run = (x1 - x0) / 3.0
rise = run * math.tan(math.radians(float(self.helix_angle)))
sign = 1.0 if str(self.hand).strip().upper() == "RH" else -1.0
for offset in (-run, 0.0, run):
prims.append(
Line(
(mid + offset - run / 2.0, -pitch / 2.0),
(mid + offset + run / 2.0, -pitch / 2.0 + sign * rise),
"phantom",
)
)
return tuple(prims)
if view == "side":
outline = generate_full_gear_outline(
float(self.module),
int(self.teeth),
float(self.pressure_angle),
float(self.helix_angle),
str(self.hand).strip().upper(),
(0.0, 0.0),
)
return (
Poly(tuple(outline), True, "visible"),
Circle((0.0, 0.0), pitch, "center"),
)
return ()
def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
tip, _, _ = self._radii()
if view != "side":
return ()
return (
DimIntent(
kind="diameter",
p1=(-tip, 0.0),
p2=(tip, 0.0),
text_override=(f"⌀{tip * 2.0:g} (m={float(self.module):g}, z={int(self.teeth)})"),
feature=self.id,
),
)
def omission(self, part, view: str) -> str | None:
return None if view in ("front", "side") else f"gear teeth have no {view} projection"
# -- prismatic features ------------------------------------------------------
#
# View frames for a prismatic part:
# "front" : the outline's own XY
# "side" : u = part Y, v = z in [0, thickness] (looking along -X)
# "top" : u = part X, v = z in [0, thickness] (looking along -Y)
#
# THE MACHINED FACE IS v = thickness. The front view looks at the outline from
# +Z, so the face it shows -- the face a feature placed by its (x, y) is cut
# from -- is the top of the side and top views, and every blind feature hangs
# down from there toward v = 0. Hole, Pocket and anything added later must
# agree on this: a drawing whose pocket is milled from one face and whose blind
# hole is drilled from the other is silently wrong, and no validator can tell.
@_register
@dataclass(frozen=True, kw_only=True)
class Hole(_Base):
"""A hole in a prismatic part, optionally counterbored, countersunk or tapped.
Drilled from the machined face (``v = thickness``, the one the front view
looks at), so a blind hole hangs down from there and a counterbore steps
down from the same face -- the convention ``Pocket`` follows too.
"""
KIND: ClassVar[str] = "hole"
x: float
y: float
diameter: float
depth: float | None = None
cbore_d: float | None = None
cbore_depth: float | None = None
csink_d: float | None = None
thread: str | None = None
def validate(self, part) -> None:
super().validate(part)
part = _require_prismatic(part, "Hole")
_number(self.x, "x", positive=False)
_number(self.y, "y", positive=False)
diameter = _number(self.diameter, "diameter")
if not point_in_outline(part, float(self.x), float(self.y)):
raise ValueError(
f"({float(self.x):g}, {float(self.y):g}) is outside the part outline "
f"{outline_bbox(part)}."
)
if self.depth is not None:
depth = _number(self.depth, "depth")
if depth > part.thickness + _EPS:
raise ValueError(f"depth {depth} is deeper than the {part.thickness} mm thickness.")
for name, value in (("cbore_d", self.cbore_d), ("csink_d", self.csink_d)):
if value is not None and _number(value, name) <= diameter:
raise ValueError(f"{name} {value} must be larger than the hole {diameter}.")
if self.cbore_d is not None and self.cbore_depth is None:
raise ValueError("cbore_depth: a counterbore needs its depth.")
if self.thread:
parsed = parse_designation(self.thread)
if abs(parsed["d"] - diameter) > 0.01:
raise ValueError(
f"{parsed['designation']} has a major diameter of {parsed['d']:g} mm but "
f"diameter is {diameter:g} mm."
)
def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
part = _require_prismatic(part, "Hole")
x, y = float(self.x), float(self.y)
half = float(self.diameter) / 2.0
if view == "front":
prims: list[Prim] = []
if self.thread:
minor = MINOR_RATIO * float(self.diameter) / 2.0
prims.append(Circle((x, y), minor, "visible"))
prims.append(Arc((x, y), half, 90.0, 0.0, "visible"))
else:
prims.append(Circle((x, y), half, "visible"))
for extra in (self.cbore_d, self.csink_d):
if extra is not None:
prims.append(Circle((x, y), float(extra) / 2.0, "visible"))
extras = [float(v) / 2.0 for v in (self.cbore_d, self.csink_d) if v is not None]
reach = max([half, *extras]) + 3.0
prims.append(Line((x - reach, y), (x + reach, y), "center"))
prims.append(Line((x, y - reach), (x, y + reach), "center"))
return tuple(prims)
if view in ("side", "top"):
u = y if view == "side" else x
face = part.thickness
depth = part.thickness if self.depth is None else float(self.depth)
floor = face - depth
prims = [
Line((u - half, face), (u - half, floor), "hidden"),
Line((u + half, face), (u + half, floor), "hidden"),
]
if self.depth is not None:
prims.append(Line((u - half, floor), (u + half, floor), "hidden"))
if self.cbore_d is not None:
cb = float(self.cbore_d) / 2.0
cf = face - float(self.cbore_depth)
prims.append(Line((u - cb, face), (u - cb, cf), "hidden"))
prims.append(Line((u + cb, face), (u + cb, cf), "hidden"))
prims.append(Line((u - cb, cf), (u + cb, cf), "hidden"))
return tuple(prims)
return ()
def removal_box(self, part):
x, y = float(self.x), float(self.y)
extras = [float(v) / 2.0 for v in (self.cbore_d, self.csink_d) if v is not None]
outer = max([float(self.diameter) / 2.0, *extras])
return (x - outer, y - outer, x + outer, y + outer)
def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
if view != "front":
return ()
x, y = float(self.x), float(self.y)
half = float(self.diameter) / 2.0
label = self.thread or f"⌀{float(self.diameter):g}"
return (
DimIntent(
kind="diameter",
p1=(x - half, y),
p2=(x + half, y),
text_override=label,
feature=self.id,
),
)
def omission(self, part, view: str) -> str | None:
return None if view in ("front", "side", "top") else f"a hole has no {view} projection"
@_register
@dataclass(frozen=True, kw_only=True)
class Slot(_Base):
"""An obround slot: two parallel flanks closed by two semicircular ends."""
KIND: ClassVar[str] = "slot"
x: float
y: float
length: float
width: float
angle: float = 0.0
def _ends(self) -> tuple[tuple[float, float], tuple[float, float], float]:
half_run = (float(self.length) - float(self.width)) / 2.0
theta = math.radians(float(self.angle))
ux, uy = math.cos(theta), math.sin(theta)
x, y = float(self.x), float(self.y)
return (
(x - half_run * ux, y - half_run * uy),
(x + half_run * ux, y + half_run * uy),
float(self.width) / 2.0,
)
def validate(self, part) -> None:
super().validate(part)
part = _require_prismatic(part, "Slot")
_number(self.x, "x", positive=False)
_number(self.y, "y", positive=False)
length = _number(self.length, "length")
width = _number(self.width, "width")
_number(self.angle, "angle", positive=False)
if length <= width:
raise ValueError(
f"length {length} must be greater than width {width}; a slot as wide as it is "
"long is a hole."
)
for point in self._ends()[:2]:
if not point_in_outline(part, point[0], point[1]):
raise ValueError(
f"slot end ({point[0]:g}, {point[1]:g}) is outside the part outline "
f"{outline_bbox(part)}."
)
def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
part = _require_prismatic(part, "Slot")
(c1, c2, half) = self._ends()
theta = math.radians(float(self.angle))
nx, ny = -math.sin(theta), math.cos(theta)
if view == "front":
return (
Line(
(c1[0] + half * nx, c1[1] + half * ny),
(c2[0] + half * nx, c2[1] + half * ny),
"visible",
),
Line(
(c1[0] - half * nx, c1[1] - half * ny),
(c2[0] - half * nx, c2[1] - half * ny),
"visible",
),
Arc(
c2,
half,
(float(self.angle) - 90.0) % 360.0,
(float(self.angle) + 90.0) % 360.0,
"visible",
),
Arc(
c1,
half,
(float(self.angle) + 90.0) % 360.0,
(float(self.angle) + 270.0) % 360.0,
"visible",
),
Line((c1[0], c1[1]), (c2[0], c2[1]), "center"),
)
if view in ("side", "top"):
box = self.removal_box(part)
u0, u1 = (box[1], box[3]) if view == "side" else (box[0], box[2])
return (
Line((u0, 0.0), (u0, part.thickness), "hidden"),
Line((u1, 0.0), (u1, part.thickness), "hidden"),
)
return ()
def removal_box(self, part):
(c1, c2, half) = self._ends()
xs = (c1[0] - half, c1[0] + half, c2[0] - half, c2[0] + half)
ys = (c1[1] - half, c1[1] + half, c2[1] - half, c2[1] + half)
return (min(xs), min(ys), max(xs), max(ys))
def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
if view != "front":
return ()
(c1, c2, half) = self._ends()
theta = math.radians(float(self.angle))
nx, ny = -math.sin(theta), math.cos(theta)
return (
DimIntent(
kind="aligned",
p1=(c1[0], c1[1]),
p2=(c2[0], c2[1]),
text_override=f"{float(self.length):g}",
feature=self.id,
),
DimIntent(
kind="aligned",
p1=(c2[0] + half * nx, c2[1] + half * ny),
p2=(c2[0] - half * nx, c2[1] - half * ny),
text_override=f"{float(self.width):g}",
feature=self.id,
),
)
def omission(self, part, view: str) -> str | None:
return None if view in ("front", "side", "top") else f"a slot has no {view} projection"
@_register
@dataclass(frozen=True, kw_only=True)
class Pocket(_Base):
"""A milled pocket. ``no_section_hatch`` marks a rib or web (ISO 128-3).
Milled from the machined face (``v = thickness``), the same face ``Hole``
drills from.
"""
KIND: ClassVar[str] = "pocket"
outline: tuple[tuple[float, float], ...]
depth: float
no_section_hatch: bool = False
def validate(self, part) -> None:
super().validate(part)
part = _require_prismatic(part, "Pocket")
depth = _number(self.depth, "depth")
if depth >= part.thickness - _EPS:
raise ValueError(
f"depth {depth} is not smaller than the {part.thickness} mm thickness; "
"a pocket that goes through is a Hole or a Slot."
)
points = tuple((float(x), float(y)) for x, y in self.outline)
if len(points) < 3:
raise ValueError(f"outline: a pocket needs at least 3 vertices, got {len(points)}.")
from engineering.measure import is_self_intersecting
if is_self_intersecting(points) is True:
raise ValueError("outline: the pocket outline crosses itself.")
for index, point in enumerate(points):
if not point_in_outline(part, point[0], point[1]):
raise ValueError(
f"outline[{index}] ({point[0]:g}, {point[1]:g}) is outside the part outline "
f"{outline_bbox(part)}."
)
def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
part = _require_prismatic(part, "Pocket")
points = tuple((float(x), float(y)) for x, y in self.outline)
if view == "front":
return (Poly(points, True, "visible"),)
if view in ("side", "top"):
box = self.removal_box(part)
u0, u1 = (box[1], box[3]) if view == "side" else (box[0], box[2])
face = part.thickness
floor = face - float(self.depth)
return (
Line((u0, face), (u0, floor), "hidden"),
Line((u0, floor), (u1, floor), "hidden"),
Line((u1, floor), (u1, face), "hidden"),
)
return ()
def removal_box(self, part):
xs = [float(x) for x, _ in self.outline]
ys = [float(y) for _, y in self.outline]
return (min(xs), min(ys), max(xs), max(ys))
def omission(self, part, view: str) -> str | None:
return None if view in ("front", "side", "top") else f"a pocket has no {view} projection"
@_register
@dataclass(frozen=True, kw_only=True)
class CornerFillet(_Base):
"""A rounded outline corner: two tangent points and the arc between them."""
KIND: ClassVar[str] = "corner_fillet"
vertex: int
radius: float
def _frame(self, part):
points = _require_prismatic(part, "CornerFillet").outline
count = len(points)
index = int(self.vertex)
if not 0 <= index < count:
raise ValueError(f"vertex {index} is outside the outline (0-{count - 1}).")
v = points[index]
p = points[(index - 1) % count]
n = points[(index + 1) % count]
return v, p, n
def validate(self, part) -> None:
super().validate(part)
radius = _number(self.radius, "radius")
v, p, n = self._frame(part)
edge_p = math.dist(v, p)
edge_n = math.dist(v, n)
u1 = ((p[0] - v[0]) / edge_p, (p[1] - v[1]) / edge_p)
u2 = ((n[0] - v[0]) / edge_n, (n[1] - v[1]) / edge_n)
cosine = max(-1.0, min(1.0, u1[0] * u2[0] + u1[1] * u2[1]))
half = math.acos(cosine) / 2.0
if half < 1e-6 or abs(cosine + 1.0) < 1e-9:
raise ValueError(
f"vertex {self.vertex} is a straight run, there is no corner to round."
)
tangent = radius / math.tan(half)
if tangent > min(edge_p, edge_n) / 2.0 + _EPS:
raise ValueError(
f"radius {radius} needs {tangent:.3f} mm of edge, more than half the shorter "
f"adjacent edge ({min(edge_p, edge_n):.3f} mm)."
)
def outline_edit(self, part) -> dict:
v, p, n = self._frame(part)
radius = float(self.radius)
edge_p = math.dist(v, p)
edge_n = math.dist(v, n)
u1 = ((p[0] - v[0]) / edge_p, (p[1] - v[1]) / edge_p)
u2 = ((n[0] - v[0]) / edge_n, (n[1] - v[1]) / edge_n)
cosine = max(-1.0, min(1.0, u1[0] * u2[0] + u1[1] * u2[1]))
half = math.acos(cosine) / 2.0
tangent = radius / math.tan(half)
before = (v[0] + u1[0] * tangent, v[1] + u1[1] * tangent)
after = (v[0] + u2[0] * tangent, v[1] + u2[1] * tangent)
bisector = (u1[0] + u2[0], u1[1] + u2[1])
norm = math.hypot(*bisector)
center = (
v[0] + bisector[0] / norm * (radius / math.sin(half)),
v[1] + bisector[1] / norm * (radius / math.sin(half)),
)
start = math.degrees(math.atan2(before[1] - center[1], before[0] - center[0])) % 360.0
end = math.degrees(math.atan2(after[1] - center[1], after[0] - center[0])) % 360.0
cross = u1[0] * u2[1] - u1[1] * u2[0]
if cross > 0: # the arc runs the other way round
start, end = end, start
return {
"vertex": int(self.vertex),
"before": before,
"after": after,
"arc": Arc(center, radius, start, end, "visible"),
}
def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
if view != "front":
return ()
edit = self.outline_edit(part)
return (
DimIntent(
kind="radius",
p1=edit["arc"].center,
p2=edit["before"],
text_override=f"R{float(self.radius):g}",
feature=self.id,
),
)
def omission(self, part, view: str) -> str | None:
return None if view == "front" else f"a corner radius has no {view} projection"
@_register
@dataclass(frozen=True, kw_only=True)
class CornerChamfer(_Base):
"""A bevelled outline corner: two points and the straight run between them."""
KIND: ClassVar[str] = "corner_chamfer"
vertex: int
size: float
def _frame(self, part):
points = _require_prismatic(part, "CornerChamfer").outline
count = len(points)
index = int(self.vertex)
if not 0 <= index < count:
raise ValueError(f"vertex {index} is outside the outline (0-{count - 1}).")
return points[index], points[(index - 1) % count], points[(index + 1) % count]
def validate(self, part) -> None:
super().validate(part)
size = _number(self.size, "size")
v, p, n = self._frame(part)
shortest = min(math.dist(v, p), math.dist(v, n))
if size > shortest / 2.0 + _EPS:
raise ValueError(
f"size {size} is more than half the shorter adjacent edge ({shortest:.3f} mm)."
)
def outline_edit(self, part) -> dict:
v, p, n = self._frame(part)
size = float(self.size)
edge_p = math.dist(v, p)
edge_n = math.dist(v, n)
before = (v[0] + (p[0] - v[0]) / edge_p * size, v[1] + (p[1] - v[1]) / edge_p * size)
after = (v[0] + (n[0] - v[0]) / edge_n * size, v[1] + (n[1] - v[1]) / edge_n * size)
return {"vertex": int(self.vertex), "before": before, "after": after, "arc": None}
def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
if view != "front":
return ()
edit = self.outline_edit(part)
return (
DimIntent(
kind="aligned",
p1=edit["before"],
p2=edit["after"],
text_override=f"{float(self.size):g}x45°",
feature=self.id,
),
)
def omission(self, part, view: str) -> str | None:
return None if view == "front" else f"a corner chamfer has no {view} projection"
@_register
@dataclass(frozen=True, kw_only=True)
class BendLine(_Base):
"""A sheet-metal bend line, **representation only**.
Spec section 2 puts flat patterns and bend allowance out of scope: no
developed length is computed here, because a wrong developed length is a
scrapped part. This feature draws the phantom line and its R/angle note and
claims nothing else.
"""
KIND: ClassVar[str] = "bend_line"
p1: tuple[float, float]
p2: tuple[float, float]
angle: float
inside_radius: float
direction: str = "up"
def validate(self, part) -> None:
super().validate(part)
_require_prismatic(part, "BendLine")
for name, point in (("p1", self.p1), ("p2", self.p2)):
if not isinstance(point, (tuple, list)) or len(point) != 2:
raise ValueError(f"{name}: expected [x, y], got {point!r}.")
_number(point[0], f"{name}.x", positive=False)
_number(point[1], f"{name}.y", positive=False)
if math.dist(tuple(self.p1), tuple(self.p2)) < _EPS:
raise ValueError("p1 and p2 are the same point; a bend line needs a direction.")
angle = _number(self.angle, "angle", positive=False)
if not 0.0 < abs(angle) <= 180.0:
raise ValueError(f"angle {angle}: a bend angle is between 0 and 180 degrees.")
_number(self.inside_radius, "inside_radius")
if str(self.direction).strip().lower() not in ("up", "down"):
raise ValueError(f"direction: expected 'up' or 'down', got {self.direction!r}.")
def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
if view != "front":
return ()
p1 = (float(self.p1[0]), float(self.p1[1]))
p2 = (float(self.p2[0]), float(self.p2[1]))
mid = ((p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0)
rotation = math.degrees(math.atan2(p2[1] - p1[1], p2[0] - p1[0]))
note = (
f"R{float(self.inside_radius):g} {float(self.angle):g}° "
f"{str(self.direction).strip().upper()}"
)
return (
Line(p1, p2, "phantom"),
Text((mid[0], mid[1] + 2.0), note, 3.5, rotation, "text"),
)
def omission(self, part, view: str) -> str | None:
return None if view == "front" else f"a bend line has no {view} projection"
@_register
@dataclass(frozen=True, kw_only=True)
class HolePattern(_Base):
"""A linear, grid or polar array of one base hole. Expanded before validation."""
KIND: ClassVar[str] = "hole_pattern"
pattern: str
base: dict
count: int = 2
spacing: float = 0.0
count_y: int = 1
spacing_y: float = 0.0
pcd: float = 0.0
start_angle: float = 0.0
angle: float = 0.0
PATTERNS: ClassVar[tuple[str, ...]] = ("linear", "grid", "polar")
def _base_fields(self) -> dict:
if not isinstance(self.base, dict):
raise ValueError(
f"base: expected a dict of Hole fields, got {type(self.base).__name__}."
)
payload = {key: value for key, value in self.base.items() if key not in ("kind", "id")}
names = {field_.name for field_ in fields(Hole)}
unknown = sorted(set(payload) - names)
if unknown:
raise ValueError(f"base: unknown key(s) {unknown}; a Hole takes {sorted(names)}.")
for required in ("x", "y", "diameter"):
if required not in payload:
raise ValueError(f"base.{required}: a base hole needs x, y and diameter.")
return payload
def expand(self) -> tuple[Hole, ...]:
kind = str(self.pattern).strip().lower()
if kind not in self.PATTERNS:
raise ValueError(f"pattern: expected one of {self.PATTERNS}, got {self.pattern!r}.")
base = self._base_fields()
x0, y0 = float(base["x"]), float(base["y"])
count = int(self.count)
if count < 1:
raise ValueError(f"count: expected at least 1, got {count}.")
holes: list[Hole] = []
if kind == "linear":
if float(self.spacing) <= _EPS:
raise ValueError("spacing: a linear pattern needs a spacing greater than zero.")
theta = math.radians(float(self.angle))
for k in range(count):
holes.append(
Hole(
**{
**base,
"id": f"{self.id}_{k}",
"x": x0 + k * float(self.spacing) * math.cos(theta),
"y": y0 + k * float(self.spacing) * math.sin(theta),
}
)
)
elif kind == "grid":
rows = int(self.count_y)
if rows < 1:
raise ValueError(f"count_y: expected at least 1, got {rows}.")
if count > 1 and float(self.spacing) <= _EPS:
raise ValueError("spacing: a grid with more than one column needs a spacing.")
if rows > 1 and float(self.spacing_y) <= _EPS:
raise ValueError("spacing_y: a grid with more than one row needs a spacing_y.")
for j in range(rows):
for i in range(count):
holes.append(
Hole(
**{
**base,
"id": f"{self.id}_{i}_{j}",
"x": x0 + i * float(self.spacing),
"y": y0 + j * float(self.spacing_y),
}
)
)
else:
if float(self.pcd) <= _EPS:
raise ValueError("pcd: a polar pattern needs a pitch-circle diameter.")
if count < 2:
raise ValueError(f"count: a polar pattern needs at least 2 holes, got {count}.")
radius = float(self.pcd) / 2.0
start = math.radians(float(self.start_angle))
for k in range(count):
theta = start + 2.0 * math.pi * k / count
holes.append(
Hole(
**{
**base,
"id": f"{self.id}_{k}",
"x": x0 + radius * math.cos(theta),
"y": y0 + radius * math.sin(theta),
}
)
)
return tuple(holes)
def validate(self, part) -> None: # pragma: no cover - expanded before it reaches a part
super().validate(part)
for hole in self.expand():
hole.validate(part)
FEATURE_KINDS: tuple[str, ...] = tuple(sorted(_REGISTRY))