Files
Umut CANandClaude Opus 5 2bb8044144 feat(mech): shared foundation - primitives, XDATA codec, standards registry, ISO 128-50 materials
engineering/mech/ opens with the four modules every track-B/G branch imports,
so the four worktrees can be cut from this commit and none of them has to
invent a name the others will conflict with.

- primitives.py: the typed descriptions the pure modules emit (Line, Arc,
  Circle, Poly, HatchArea, Text, DimIntent) with bbox / translate / rotate /
  scale, and ROLE_LAYER - the one place an ISO 128 line role becomes a layer
  name, so no module hardcodes a layer. The arc bbox sweeps its quadrants: a
  0-180 arc reports y=+r, not its endpoints, which is what keeps the view
  layout from stacking two views on top of each other.
- xdata.py: the ACADMCP_MECH codec in Track A's shape - a 1001 application
  marker, 1000 chunks of at most 255 ASCII characters, the 16 KB per-entity
  ceiling enforced before anything is written, and a foreign application, a
  corrupt chunk stream, a wrong version or an unknown kind refused with
  ValueError instead of decoded into something plausible.
- standards/__init__.py: the table registry. A size outside the coverage is
  refused against the coverage; a size inside it that is not a listed row is
  refused against the rows. Nothing is interpolated, an ambiguous variant is
  never resolved by taking the first row, and a registration whose SOURCE does
  not name the table it was transcribed from is refused.
- standards/materials.py: the section-hatch map for nine materials. ISO
  128-50 standardises only the general hatching (narrow continuous lines at 45
  degrees) - that is acadiso.pat ANSI31 and what engineering/gear.py already
  draws for steel. The distinguishing symbols are ASME Y14.2M's section lining
  as shipped in AutoCAD 2026's acadiso.pat, read off the installed file; cast
  iron's departure from ASME and the absence of any mechanical symbol for
  concrete and wood are stated in the rows themselves. Angles are 0.0 because
  the patterns already carry 45 degrees, and the two architectural patterns'
  scales are derived from the ANSI31 line spacing and recomputed by the test
  rather than chosen.

No tool is registered here.

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

230 lines
7.1 KiB
Python

"""Typed primitive descriptions emitted by the pure mechanical modules.
`part.py`, `features.py`, `views.py`, `dimension.py` and everything under
`standards/` never touch a backend: they return these tuples, and
`engineering/mech/draw.py` is the only module that turns them into entities.
That is what makes the view engine testable without a drawing — a test asserts
the emitted primitives, not a screenshot.
Coordinates here are part-local; `draw.py` maps them to WCS through the part's
placement. `role` is the ISO 128 line role, and `ROLE_LAYER` is the one place a
role becomes a layer name, so no module hardcodes a layer.
"""
from __future__ import annotations
import math
from collections.abc import Iterable
from typing import Literal, NamedTuple
Role = Literal["visible", "hidden", "center", "phantom", "hatch", "dim", "text"]
Pt = tuple[float, float]
#: ISO 128 line role -> the engineering layer `drawing_new` already bootstraps.
ROLE_LAYER: dict[str, str] = {
"visible": "GEOMETRY",
"hidden": "HIDDEN",
"center": "CENTER",
"phantom": "PHANTOM",
"hatch": "HATCH",
"dim": "DIM",
"text": "TEXT",
}
ROLES: tuple[str, ...] = tuple(ROLE_LAYER)
class Line(NamedTuple):
p1: Pt
p2: Pt
role: Role = "visible"
class Arc(NamedTuple):
"""Counter-clockwise from ``start_deg`` to ``end_deg``, degrees from +X."""
center: Pt
radius: float
start_deg: float
end_deg: float
role: Role = "visible"
class Circle(NamedTuple):
center: Pt
radius: float
role: Role = "visible"
class Poly(NamedTuple):
points: tuple[Pt, ...]
closed: bool = False
role: Role = "visible"
class HatchArea(NamedTuple):
"""``loops[0]`` is the outer boundary; ``loops[1:]`` are islands.
It carries no ``role``: a hatch is always on ``ROLE_LAYER["hatch"]`` and its
pattern comes from ``standards.materials.hatch_for(material)``.
"""
loops: tuple[tuple[Pt, ...], ...]
material: str = "steel"
class Text(NamedTuple):
at: Pt
text: str
height: float = 3.5
rotation: float = 0.0
role: Role = "text"
class DimIntent(NamedTuple):
"""What a feature wants dimensioned, before any placement decision."""
kind: Literal["linear", "aligned", "diameter", "radius", "angular"]
p1: Pt
p2: Pt
text_at: Pt | None = None
text_override: str | None = None
fit: str | None = None
tol: dict | None = None
feature: str = ""
Prim = Line | Arc | Circle | Poly | HatchArea | Text
def layer_for(prim: Prim) -> str:
"""The layer a primitive belongs on. A HATCH has no role of its own."""
if isinstance(prim, HatchArea):
return ROLE_LAYER["hatch"]
role = getattr(prim, "role", "visible")
try:
return ROLE_LAYER[role]
except KeyError:
raise ValueError(f"unknown line role {role!r}; roles are {', '.join(ROLES)}") from None
def _on_circle(center: Pt, radius: float, deg: float) -> Pt:
rad = math.radians(deg)
return (center[0] + radius * math.cos(rad), center[1] + radius * math.sin(rad))
def _arc_points(arc: Arc) -> tuple[Pt, ...]:
"""Endpoints plus every axis extreme the sweep actually passes through.
Without this a 0-180 arc reports a bbox that stops at its endpoints, and
the view layout then puts the next view on top of it.
"""
start = arc.start_deg % 360.0
sweep = (arc.end_deg - arc.start_deg) % 360.0
if sweep == 0.0:
sweep = 360.0
points = [
_on_circle(arc.center, arc.radius, arc.start_deg),
_on_circle(arc.center, arc.radius, arc.end_deg),
]
for quadrant in (0.0, 90.0, 180.0, 270.0):
if ((quadrant - start) % 360.0) <= sweep:
points.append(_on_circle(arc.center, arc.radius, quadrant))
return tuple(points)
def points_of(prim: Prim) -> tuple[Pt, ...]:
"""Every point that bounds a primitive.
A ``Text`` reports only its anchor: the rendered width depends on the style
and the engine, and a guessed box is a silent wrong number.
"""
if isinstance(prim, Line):
return (prim.p1, prim.p2)
if isinstance(prim, Arc):
return _arc_points(prim)
if isinstance(prim, Circle):
cx, cy = prim.center
r = prim.radius
return ((cx - r, cy), (cx + r, cy), (cx, cy - r), (cx, cy + r))
if isinstance(prim, Poly):
return tuple(prim.points)
if isinstance(prim, HatchArea):
return tuple(pt for loop in prim.loops for pt in loop)
if isinstance(prim, Text):
return (prim.at,)
raise TypeError(f"not a primitive: {prim!r}")
def bbox(prims: Iterable[Prim]) -> tuple[float, float, float, float]:
"""``(minx, miny, maxx, maxy)`` over every primitive. Empty is refused."""
xs: list[float] = []
ys: list[float] = []
for prim in prims:
for x, y in points_of(prim):
xs.append(float(x))
ys.append(float(y))
if not xs:
raise ValueError("bbox: no primitives to bound")
return (min(xs), min(ys), max(xs), max(ys))
def _map_points(prim: Prim, fn) -> Prim:
if isinstance(prim, Line):
return prim._replace(p1=fn(prim.p1), p2=fn(prim.p2))
if isinstance(prim, (Arc, Circle)):
return prim._replace(center=fn(prim.center))
if isinstance(prim, Poly):
return prim._replace(points=tuple(fn(p) for p in prim.points))
if isinstance(prim, HatchArea):
return prim._replace(loops=tuple(tuple(fn(p) for p in loop) for loop in prim.loops))
if isinstance(prim, Text):
return prim._replace(at=fn(prim.at))
raise TypeError(f"not a primitive: {prim!r}")
def translate(prims: Iterable[Prim], dx: float, dy: float) -> tuple[Prim, ...]:
def move(p: Pt) -> Pt:
return (p[0] + dx, p[1] + dy)
return tuple(_map_points(prim, move) for prim in prims)
def rotate(prims: Iterable[Prim], deg: float, about: Pt = (0.0, 0.0)) -> tuple[Prim, ...]:
cos = math.cos(math.radians(deg))
sin = math.sin(math.radians(deg))
ox, oy = about
def turn(p: Pt) -> Pt:
dx = p[0] - ox
dy = p[1] - oy
return (ox + dx * cos - dy * sin, oy + dx * sin + dy * cos)
out: list[Prim] = []
for prim in prims:
moved = _map_points(prim, turn)
if isinstance(moved, Arc):
moved = moved._replace(start_deg=moved.start_deg + deg, end_deg=moved.end_deg + deg)
elif isinstance(moved, Text):
moved = moved._replace(rotation=moved.rotation + deg)
out.append(moved)
return tuple(out)
def scale(prims: Iterable[Prim], factor: float, about: Pt = (0.0, 0.0)) -> tuple[Prim, ...]:
if not math.isfinite(factor) or factor <= 0.0:
raise ValueError(f"scale factor must be finite and positive, got {factor!r}")
ox, oy = about
def grow(p: Pt) -> Pt:
return (ox + (p[0] - ox) * factor, oy + (p[1] - oy) * factor)
out: list[Prim] = []
for prim in prims:
moved = _map_points(prim, grow)
if isinstance(moved, (Arc, Circle)):
moved = moved._replace(radius=moved.radius * factor)
elif isinstance(moved, Text):
moved = moved._replace(height=moved.height * factor)
out.append(moved)
return tuple(out)