engineering/mech/part.py is the model the whole track draws from: a revolved stack of segments along +X, or a closed prismatic outline plus a thickness, each carrying a list of typed features. Pure arithmetic - no backend, no async, no DXF - which is what lets the view engine be tested without a drawing. Every refusal fires before anything is drawn and names the offending path the way pid_from_spec does: a non-finite or non-positive number as segments[2].d_outer, a bore that is not smaller than its outside (a bore that changes diameter is two segments), an outline with fewer than three distinct vertices, an outline that crosses itself (engineering.measure.is_self_intersecting - the shoelace cancels crossed lobes rather than failing, so a bowtie would otherwise measure 0.0), an unknown material, an unknown segment key, a duplicate feature id. Two gates are the reason this is a model rather than a dict: a feature whose placement is off the part is refused by its own name and by the range it missed, and two features that would remove the same material are refused with both names and the overlapping box. Neither is clamped. part_to_dict round-trips through build_part, which is what lets mech_part_draw write the model onto the drawing as ACADMCP_MECH XDATA and mech_view_add read it back months later without the caller re-describing the part. Two fixes to the plan's own code and test list, both recorded in deviations: _features_from imported engineering.mech.features unconditionally, so a featureless part - every fixture in this module, and both round-trips - died on ModuleNotFoundError until Task 3 lands; the import now happens only when there is a feature dict to build. And the plan's test file omitted test_a_duplicate_feature_id_is_refused while its own gate deselects it by name, so the duplicate-id refusal the commit message claims went unpinned; the test is written here and Task 3 turns it green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
360 lines
13 KiB
Python
360 lines
13 KiB
Python
"""The mechanical part model: a profile plus a list of typed features.
|
|
|
|
Pure: no backend, no async, no DXF. A part is either **revolved** (a stack of
|
|
segments along +X, the axis at y = 0, the left end at x = 0) or **prismatic**
|
|
(a closed outline in part-local XY plus a thickness along +Z). Every coordinate
|
|
here is part-local; `engineering/mech/draw.py` is the only module that maps
|
|
part-local to WCS.
|
|
|
|
Everything is validated before anything is drawn (the repository's
|
|
validate-first rule), and every refusal names the offending path the way
|
|
`pid_from_spec` does: ``segments[2].d_outer: ...``.
|
|
|
|
The feature surface is duck-typed on purpose, so this module does not import
|
|
`features.py` at module scope and the two are testable independently:
|
|
|
|
feature.id -> str
|
|
feature.validate(part) -> None, ValueError naming what is wrong
|
|
feature.to_dict() -> dict accepted by feature_from_dict
|
|
feature.removal_box(part) -> (x0, y0, x1, y1) | None (optional)
|
|
feature.expand() -> tuple[Feature, ...] (optional)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from dataclasses import dataclass, replace
|
|
from typing import Any, NamedTuple
|
|
|
|
from engineering.measure import is_self_intersecting
|
|
from engineering.mech.primitives import Pt
|
|
from engineering.mech.standards.materials import hatch_for
|
|
|
|
_EPS = 1e-9
|
|
|
|
|
|
class Segment(NamedTuple):
|
|
"""One turned step of a revolved part, left to right along +X."""
|
|
|
|
length: float
|
|
d_outer: float
|
|
d_inner: float = 0.0
|
|
taper_to: float | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RevolvedPart:
|
|
name: str
|
|
segments: tuple[Segment, ...]
|
|
features: tuple[Any, ...] = ()
|
|
material: str = "steel"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PrismaticPart:
|
|
name: str
|
|
outline: tuple[Pt, ...]
|
|
thickness: float
|
|
features: tuple[Any, ...] = ()
|
|
material: str = "steel"
|
|
|
|
|
|
Part = RevolvedPart | PrismaticPart
|
|
|
|
PART_KINDS: tuple[str, ...] = ("revolved", "prismatic")
|
|
|
|
_SEGMENT_KEYS = frozenset({"length", "d_outer", "d_inner", "taper_to"})
|
|
|
|
|
|
# -- number guards -----------------------------------------------------------
|
|
|
|
|
|
def _finite(value: Any, path: str) -> 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.")
|
|
return number
|
|
|
|
|
|
def _positive(value: Any, path: str) -> float:
|
|
number = _finite(value, path)
|
|
if number <= _EPS:
|
|
raise ValueError(f"{path}: must be greater than zero, got {number}.")
|
|
return number
|
|
|
|
|
|
def _non_negative(value: Any, path: str) -> float:
|
|
number = _finite(value, path)
|
|
if number < -_EPS:
|
|
raise ValueError(f"{path}: must not be negative, got {number}.")
|
|
return max(number, 0.0)
|
|
|
|
|
|
# -- geometry helpers (Tasks 3-7 read the part through these) ----------------
|
|
|
|
|
|
def segment_bounds(part: RevolvedPart) -> tuple[tuple[float, float], ...]:
|
|
"""``((x0, x1), ...)`` per segment, left to right from x = 0."""
|
|
bounds: list[tuple[float, float]] = []
|
|
x = 0.0
|
|
for segment in part.segments:
|
|
bounds.append((x, x + segment.length))
|
|
x += segment.length
|
|
return tuple(bounds)
|
|
|
|
|
|
def part_length(part: Part) -> float:
|
|
if isinstance(part, RevolvedPart):
|
|
return float(sum(segment.length for segment in part.segments))
|
|
x0, _, x1, _ = outline_bbox(part)
|
|
return x1 - x0
|
|
|
|
|
|
def part_max_diameter(part: RevolvedPart) -> float:
|
|
diameters = [segment.d_outer for segment in part.segments]
|
|
diameters += [s.taper_to for s in part.segments if s.taper_to is not None]
|
|
return float(max(diameters))
|
|
|
|
|
|
def _segment_at(part: RevolvedPart, x: float) -> tuple[int, float, float, Segment]:
|
|
bounds = zip(segment_bounds(part), part.segments, strict=True) # one bound per segment
|
|
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).")
|
|
|
|
|
|
def outer_radius_at(part: RevolvedPart, x: float) -> float:
|
|
"""Outer radius at an axial station, honouring a segment's taper."""
|
|
_, x0, x1, segment = _segment_at(part, float(x))
|
|
if segment.taper_to is None:
|
|
return segment.d_outer / 2.0
|
|
span = x1 - x0
|
|
t = 0.0 if span <= _EPS else (float(x) - x0) / span
|
|
return (segment.d_outer + t * (segment.taper_to - segment.d_outer)) / 2.0
|
|
|
|
|
|
def inner_radius_at(part: RevolvedPart, x: float) -> float:
|
|
"""Bore radius at an axial station; 0.0 where the part is solid."""
|
|
_, _, _, segment = _segment_at(part, float(x))
|
|
return segment.d_inner / 2.0
|
|
|
|
|
|
def outline_bbox(part: PrismaticPart) -> tuple[float, float, float, float]:
|
|
xs = [p[0] for p in part.outline]
|
|
ys = [p[1] for p in part.outline]
|
|
return (min(xs), min(ys), max(xs), max(ys))
|
|
|
|
|
|
def point_in_outline(part: PrismaticPart, x: float, y: float) -> bool:
|
|
"""Even-odd ray cast against the closed outline; a point on an edge counts as in."""
|
|
points = part.outline
|
|
count = len(points)
|
|
inside = False
|
|
for i in range(count):
|
|
ax, ay = points[i]
|
|
bx, by = points[(i + 1) % count]
|
|
cross = (bx - ax) * (y - ay) - (by - ay) * (x - ax)
|
|
on_span = min(ax, bx) - _EPS <= x <= max(ax, bx) + _EPS
|
|
on_span = on_span and min(ay, by) - _EPS <= y <= max(ay, by) + _EPS
|
|
if abs(cross) < 1e-9 and on_span:
|
|
return True
|
|
if (ay > y) != (by > y):
|
|
t = (y - ay) / (by - ay)
|
|
if x < ax + t * (bx - ax):
|
|
inside = not inside
|
|
return inside
|
|
|
|
|
|
# -- construction ------------------------------------------------------------
|
|
|
|
|
|
def _segment_from(index: int, raw: Any) -> Segment:
|
|
if not isinstance(raw, dict):
|
|
raise ValueError(f"segments[{index}]: expected a dict, got {type(raw).__name__}.")
|
|
unknown = set(raw) - _SEGMENT_KEYS
|
|
if unknown:
|
|
raise ValueError(
|
|
f"segments[{index}]: unknown key(s) {sorted(unknown)}; "
|
|
f"a segment takes {sorted(_SEGMENT_KEYS)}."
|
|
)
|
|
length = _positive(raw.get("length"), f"segments[{index}].length")
|
|
d_outer = _positive(raw.get("d_outer"), f"segments[{index}].d_outer")
|
|
d_inner = _non_negative(raw.get("d_inner") or 0.0, f"segments[{index}].d_inner")
|
|
if d_inner >= d_outer - _EPS:
|
|
raise ValueError(
|
|
f"segments[{index}].d_inner: {d_inner} mm is not smaller than d_outer "
|
|
f"{d_outer} mm. A bore that changes diameter is two segments."
|
|
)
|
|
taper_to = raw.get("taper_to")
|
|
if taper_to is not None:
|
|
taper_to = _positive(taper_to, f"segments[{index}].taper_to")
|
|
if taper_to <= d_inner + _EPS:
|
|
raise ValueError(
|
|
f"segments[{index}].taper_to: {taper_to} mm is not larger than the bore "
|
|
f"{d_inner} mm."
|
|
)
|
|
return Segment(length, d_outer, d_inner, taper_to)
|
|
|
|
|
|
def _outline_from(raw: Any) -> tuple[Pt, ...]:
|
|
if not isinstance(raw, (list, tuple)) or not raw:
|
|
raise ValueError("outline: expected a non-empty list of [x, y] points.")
|
|
points: list[Pt] = []
|
|
for index, item in enumerate(raw):
|
|
if not isinstance(item, (list, tuple)) or len(item) != 2:
|
|
raise ValueError(f"outline[{index}]: expected [x, y], got {item!r}.")
|
|
points.append(
|
|
(
|
|
_finite(item[0], f"outline[{index}].x"),
|
|
_finite(item[1], f"outline[{index}].y"),
|
|
)
|
|
)
|
|
if len(points) > 1 and math.dist(points[0], points[-1]) < _EPS:
|
|
points = points[:-1]
|
|
distinct: list[Pt] = []
|
|
for point in points:
|
|
if not distinct or math.dist(distinct[-1], point) > _EPS:
|
|
distinct.append(point)
|
|
if len(distinct) < 3:
|
|
raise ValueError(
|
|
f"outline: a closed outline needs at least 3 distinct vertices, got {len(distinct)}."
|
|
)
|
|
if is_self_intersecting(distinct) is True:
|
|
raise ValueError(
|
|
"outline: the closed outline crosses itself. A crossed loop has no single "
|
|
"enclosed area, so neither the view engine nor the section hatch can be right."
|
|
)
|
|
return tuple(distinct)
|
|
|
|
|
|
def _features_from(raw: Any) -> tuple[Any, ...]:
|
|
if raw is None:
|
|
return ()
|
|
if not isinstance(raw, (list, tuple)):
|
|
raise ValueError("features: expected a list of feature dicts.")
|
|
if not raw:
|
|
# No feature dicts to build, so `features.py` is not imported at all:
|
|
# a featureless part stays usable while Task 3 is still unwritten, and
|
|
# the import stays local either way (features.py imports this module).
|
|
return ()
|
|
from engineering.mech.features import feature_from_dict # local: avoids an import cycle
|
|
|
|
built: list[Any] = []
|
|
seen: set[str] = set()
|
|
for index, item in enumerate(raw):
|
|
if not isinstance(item, dict):
|
|
raise ValueError(f"features[{index}]: expected a dict with a 'kind' key.")
|
|
try:
|
|
feature = feature_from_dict(item)
|
|
expand = getattr(feature, "expand", None)
|
|
expanded = tuple(expand()) if callable(expand) else (feature,)
|
|
except ValueError as exc:
|
|
raise ValueError(f"features[{index}]: {exc}") from exc
|
|
for one in expanded:
|
|
if one.id in seen:
|
|
raise ValueError(f"features[{index}]: duplicate feature id {one.id!r}.")
|
|
seen.add(one.id)
|
|
built.append(one)
|
|
return tuple(built)
|
|
|
|
|
|
def validate_part(part: Part) -> None:
|
|
"""Every feature validates against the part, and no two remove the same material."""
|
|
for index, feature in enumerate(part.features):
|
|
try:
|
|
feature.validate(part)
|
|
except ValueError as exc:
|
|
raise ValueError(f"features[{index}] ({feature.id}): {exc}") from exc
|
|
|
|
boxes: list[tuple[str, tuple[float, float, float, float]]] = []
|
|
for feature in part.features:
|
|
getter = getattr(feature, "removal_box", None)
|
|
box = getter(part) if callable(getter) else None
|
|
if box is not None:
|
|
x0, y0, x1, y1 = (float(v) for v in box)
|
|
boxes.append((feature.id, (x0, y0, x1, y1)))
|
|
|
|
for a in range(len(boxes)):
|
|
id_a, (ax0, ay0, ax1, ay1) = boxes[a]
|
|
for b in range(a + 1, len(boxes)):
|
|
id_b, (bx0, by0, bx1, by1) = boxes[b]
|
|
ox0, ox1 = max(ax0, bx0), min(ax1, bx1)
|
|
oy0, oy1 = max(ay0, by0), min(ay1, by1)
|
|
if ox1 - ox0 > _EPS and oy1 - oy0 > _EPS:
|
|
raise ValueError(
|
|
f"features {id_a!r} and {id_b!r} remove the same material "
|
|
f"(overlap x {ox0:.3f}..{ox1:.3f}, y {oy0:.3f}..{oy1:.3f}). "
|
|
"Move one of them, or merge them into a single feature."
|
|
)
|
|
|
|
|
|
def build_part(spec: dict) -> Part:
|
|
"""Validate a part spec and return the typed model. ValueError names the path."""
|
|
if not isinstance(spec, dict):
|
|
raise ValueError(f"spec: expected a dict describing the part, got {type(spec).__name__}.")
|
|
kind = str(spec.get("kind", "")).strip().lower()
|
|
if kind not in PART_KINDS:
|
|
raise ValueError(f"kind: expected one of {PART_KINDS}, got {spec.get('kind')!r}.")
|
|
name = spec.get("name")
|
|
if not isinstance(name, str) or not name.strip():
|
|
raise ValueError("name: a non-empty part name is required.")
|
|
material = str(spec.get("material", "steel"))
|
|
try:
|
|
hatch_for(material)
|
|
except ValueError as exc:
|
|
raise ValueError(f"material: {exc}") from exc
|
|
|
|
if kind == "revolved":
|
|
raw_segments = spec.get("segments")
|
|
if not isinstance(raw_segments, (list, tuple)) or not raw_segments:
|
|
raise ValueError("segments: a revolved part needs at least one segment.")
|
|
part: Part = RevolvedPart(
|
|
name=name.strip(),
|
|
segments=tuple(_segment_from(i, s) for i, s in enumerate(raw_segments)),
|
|
material=material,
|
|
)
|
|
else:
|
|
part = PrismaticPart(
|
|
name=name.strip(),
|
|
outline=_outline_from(spec.get("outline")),
|
|
thickness=_positive(spec.get("thickness"), "thickness"),
|
|
material=material,
|
|
)
|
|
|
|
part = replace(part, features=_features_from(spec.get("features")))
|
|
validate_part(part)
|
|
return part
|
|
|
|
|
|
def part_to_dict(part: Part) -> dict:
|
|
"""The dict `build_part` accepts back, unchanged."""
|
|
common = {
|
|
"name": part.name,
|
|
"material": part.material,
|
|
"features": [feature.to_dict() for feature in part.features],
|
|
}
|
|
if isinstance(part, RevolvedPart):
|
|
return {
|
|
"kind": "revolved",
|
|
**common,
|
|
"segments": [
|
|
{
|
|
"length": s.length,
|
|
"d_outer": s.d_outer,
|
|
"d_inner": s.d_inner,
|
|
"taper_to": s.taper_to,
|
|
}
|
|
for s in part.segments
|
|
],
|
|
}
|
|
return {
|
|
"kind": "prismatic",
|
|
**common,
|
|
"outline": [[x, y] for x, y in part.outline],
|
|
"thickness": part.thickness,
|
|
}
|