Files
autocad-mcp-pro/engineering/mech/features.py
T
Umut CANandClaude Opus 5 ac6c4075ba feat(task-3): the feature catalogue and its standards tables
engineering/mech/features.py is the nineteen typed features of spec section 5,
each answering the same four questions - is my placement legal, what do I
contribute to this view, to a cut face, and what dimension do I want - plus the
five structural hooks the view engine needs: removal_box, radial_edit,
corner_edit, outline_edit and omission. Pure arithmetic; a test asserts emitted
primitive tuples, not a screenshot.

Nothing restates a table the repository already owns: Keyway takes its width
and depth from engineering.keyway (DIN 6885-1) and is refused by name outside
that table's bore range; GearTeeth takes its outline from engineering.gear's
involute generator and is refused when its tip diameter disagrees with the
segment it sits on.

standards/threads.py transcribes ISO 261 Table 1 and Table 2 - first-choice
M1.6-M64 plus second-choice M14/M18/M22/M27/M33/M39/M45/M52/M60 - and computes
ISO 724's basic minor and pitch diameters from ISO 68-1's own formula, pinned
against the published values (M20x2.5 -> 17.294 / 18.376). The remaining
second-choice diameters, every third-choice diameter and fine pitches below M8
are not transcribed and are refused by name, never interpolated. The drawing
rules are ISO 6410: minor at 0.8 x major, a thread-length limit line across the
major, a three-quarter circle in the end view, and an internal thread whose
section hatch runs to the major diameter.

standards/{undercuts,rings,centres,orings}.py ship DIN 509, DIN 471/472,
DIN 332-1 and ISO 3601-2 with their SOURCE, their row-key contract and an
**empty** row set. That is deliberate and is the track's table rule taken at its
word: a row that could not be verified against the standard is not shipped,
because a wrong relief depth or groove diameter is a wrong workshop drawing
while a narrow table is merely narrow. Every lookup is refused by name and says
what to transcribe and where; the features accept explicit dimensions so the
geometry is fully exercised, and DIN 332 form R is refused outright rather than
approximated by the straight 60 degree cone of forms A and B. Filling any of
these tables later is a data edit with no code change, and a shape test already
fails any row that lands without a column the drawing code reads.

Three deviations from the plan text, each measured rather than assumed
(the merge agent should keep exactly one copy of the first):

1. standards/__init__.py (Task 1, on main) refused a registration with no rows
   ("registered no rows"), which would have made the four not-transcribed
   tables impossible to register at all - and registering them is what keeps a
   later transcription a data edit. register() now refuses a rows argument that
   is not a dict and accepts an empty one; two tests in tests/test_mech_standards.py
   pin both halves. No existing test asserted the old refusal.

2. ORingGroove's housing kind is carried by `housing`, not `kind`. A dataclass
   field named `kind` overwrites the discriminator _Base.to_dict() writes:
   measured, Probe(id='og').to_dict() == {'kind': 'radial', 'id': 'og'}, so
   feature_from_dict would then refuse its own payload as an unknown feature
   kind and no part carrying an O-ring groove could round-trip through XDATA.
   A new test pins the round trip.

3. The plan's end-chamfer test asserted after == (75.0, 10.0) - the corner
   itself - and before == (72.0, 8.268), a point on neither surface. The real
   edit is before (72.0, 10.0) on the cylinder and after (75.0, 8.268) on the
   end face, consistent with the 45-degree start chamfer the same file pins.
   The test was corrected to the measured geometry; the behaviour is unchanged.

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

2009 lines
79 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 _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, _ = _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."
)
if axial > part_length(part):
raise ValueError(f"size {self.size} is longer than the part ({part_length(part)} mm).")
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
start = math.degrees(math.atan2(top, half)) % 360.0
end = math.degrees(math.atan2(top, -half)) % 360.0
return (
Arc((0.0, 0.0), radius, start, end, "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)
@_register
@dataclass(frozen=True, kw_only=True)
class Hole(_Base):
"""A hole in a prismatic part, optionally counterbored, countersunk or tapped."""
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
depth = part.thickness if self.depth is None else float(self.depth)
prims = [
Line((u - half, 0.0), (u - half, depth), "hidden"),
Line((u + half, 0.0), (u + half, depth), "hidden"),
]
if self.depth is not None:
prims.append(Line((u - half, depth), (u + half, depth), "hidden"))
if self.cbore_d is not None:
cb = float(self.cbore_d) / 2.0
cd = float(self.cbore_depth)
prims.append(Line((u - cb, 0.0), (u - cb, cd), "hidden"))
prims.append(Line((u + cb, 0.0), (u + cb, cd), "hidden"))
prims.append(Line((u - cb, cd), (u + cb, cd), "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)."""
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])
floor = part.thickness - float(self.depth)
return (
Line((u0, part.thickness), (u0, floor), "hidden"),
Line((u0, floor), (u1, floor), "hidden"),
Line((u1, floor), (u1, part.thickness), "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))