diff --git a/engineering/mech/__init__.py b/engineering/mech/__init__.py new file mode 100644 index 0000000..157ce19 --- /dev/null +++ b/engineering/mech/__init__.py @@ -0,0 +1,49 @@ +"""The mechanical part model, view engine and standards tables. + +Merge rule for the four track-B/G branches: the re-exports below are additive +and alphabetical — keep every side's names. +""" + +from __future__ import annotations + +from .primitives import ( + ROLE_LAYER, + ROLES, + Arc, + Circle, + DimIntent, + HatchArea, + Line, + Poly, + Prim, + Pt, + Role, + Text, + bbox, + layer_for, + points_of, + rotate, + scale, + translate, +) + +__all__ = [ + "ROLES", + "ROLE_LAYER", + "Arc", + "Circle", + "DimIntent", + "HatchArea", + "Line", + "Poly", + "Prim", + "Pt", + "Role", + "Text", + "bbox", + "layer_for", + "points_of", + "rotate", + "scale", + "translate", +] diff --git a/engineering/mech/primitives.py b/engineering/mech/primitives.py new file mode 100644 index 0000000..6d10e16 --- /dev/null +++ b/engineering/mech/primitives.py @@ -0,0 +1,229 @@ +"""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) diff --git a/engineering/mech/standards/__init__.py b/engineering/mech/standards/__init__.py new file mode 100644 index 0000000..b550ada --- /dev/null +++ b/engineering/mech/standards/__init__.py @@ -0,0 +1,136 @@ +"""Registry for the authored standards tables under ``engineering/mech/standards``. + +Every table registers itself at import with the standard's name, its rows, the +coverage it was transcribed for and a SOURCE line naming standard, edition and +table. ``lookup`` returns a row or refuses by name: a size outside the coverage +is refused against the coverage, and a size *inside* it that is not a listed row +is refused against the rows. Nothing is ever interpolated — the rule +`engineering/fits.py` (ISO 286) already follows, applied to every table here. A +wrong row ships a wrong workshop drawing; a narrow table is merely narrow. + +``rows`` maps a size key (a number in millimetres, or a designation like +``"M12"``) to a row dict, or to a tuple of row dicts when the standard has +variants at the same size (DIN 509 forms E and F, DIN 332 forms A/B/R). ``**kw`` +selects among those variants and is refused when it selects none or more than +one — an ambiguous lookup never quietly picks the first row. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import NamedTuple + + +class Coverage(NamedTuple): + low: float + high: float + unit: str = "mm" + + +@dataclass(frozen=True) +class _Table: + standard: str + rows: dict + coverage: Coverage + source: str + + +_REGISTRY: dict[str, _Table] = {} + + +def _key(value): + if isinstance(value, bool): + raise TypeError("standards: a size key cannot be a bool") + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + return value.strip().upper() + raise TypeError(f"standards: a size key must be a number or a designation, got {value!r}") + + +def _variants(value) -> tuple[dict, ...]: + if isinstance(value, dict): + return (dict(value),) + if isinstance(value, (list, tuple)) and value and all(isinstance(v, dict) for v in value): + return tuple(dict(v) for v in value) + raise TypeError("standards: a row must be a dict, or a non-empty tuple of dicts for variants") + + +def register(standard: str, rows: dict, coverage: Coverage, source: str) -> None: + """Add a table. Refuses a duplicate name and a SOURCE that names no table.""" + if not isinstance(standard, str) or not standard.strip(): + raise ValueError("standards.register: standard must be a non-empty name") + if standard in _REGISTRY: + raise ValueError(f"standards.register: {standard!r} is already registered") + if not rows: + raise ValueError(f"standards.register: {standard!r} registered no rows") + if not isinstance(source, str) or "table" not in source.lower(): + raise ValueError( + f"standards.register: {standard!r} needs a SOURCE naming the standard, its " + "edition and the table its rows were transcribed from" + ) + _REGISTRY[standard] = _Table( + standard, {_key(k): _variants(v) for k, v in rows.items()}, coverage, source + ) + + +def standards() -> tuple[str, ...]: + return tuple(sorted(_REGISTRY)) + + +def _table(standard: str) -> _Table: + table = _REGISTRY.get(standard) + if table is None: + known = ", ".join(standards()) or "(none registered)" + raise ValueError(f"standards: {standard!r} is not registered; known: {known}") + return table + + +def coverage_of(standard: str) -> Coverage: + return _table(standard).coverage + + +def source_of(standard: str) -> str: + return _table(standard).source + + +def _num(value: float) -> str: + return str(int(value)) if float(value).is_integer() else str(value) + + +def _listing(table: _Table) -> str: + keys = sorted(table.rows, key=lambda k: (isinstance(k, str), k)) + shown = [k if isinstance(k, str) else _num(k) for k in keys[:12]] + return ", ".join(shown) + (" ..." if len(keys) > 12 else "") + + +def _kw_text(kw: dict) -> str: + return ", ".join(f"{k}={v!r}" for k, v in sorted(kw.items())) + + +def lookup(standard: str, size, **kw) -> dict: + """One row, or a ValueError naming the standard and what it does cover.""" + table = _table(standard) + key = _key(size) + rows = table.rows.get(key) + if rows is None: + if isinstance(key, float) and not (table.coverage.low <= key <= table.coverage.high): + unit = table.coverage.unit + raise ValueError( + f"{standard} covers {_num(table.coverage.low)}-{_num(table.coverage.high)} " + f"{unit}; {_num(key)} {unit} is outside the table" + ) + raise ValueError(f"{standard} has no row for {size!r}; the table lists {_listing(table)}") + matches = [row for row in rows if all(row.get(k) == v for k, v in kw.items())] + if not matches: + offered = sorted({str(row.get(k)) for row in rows for k in kw}) + raise ValueError( + f"{standard} has no {_kw_text(kw)} row for {size!r}; it lists {', '.join(offered)}" + ) + if len(matches) > 1: + discriminators = sorted({k for row in rows for k in row} - set(kw)) + raise ValueError( + f"{standard} row for {size!r} is ambiguous: {len(matches)} variants match; " + f"narrow it with one of {', '.join(discriminators)}" + ) + return dict(matches[0]) diff --git a/engineering/mech/standards/materials.py b/engineering/mech/standards/materials.py new file mode 100644 index 0000000..919674d --- /dev/null +++ b/engineering/mech/standards/materials.py @@ -0,0 +1,145 @@ +"""Material -> section hatch (pattern, angle, scale). + +SOURCE, stated exactly, because it is not one document: + +* ISO 128-50:2020, clause 5 (representation of areas on cuts and sections) + standardises the *general* hatching only — narrow continuous lines, drawn + preferably at 45 degrees to the principal outline — and leaves distinguishing + materials to other conventions. ``ANSI31`` in AutoCAD 2026's ``acadiso.pat`` + is exactly that line set (45 deg, 2.245 mm apart), and it is what + ``engineering/gear.py`` already draws for a steel section, so `steel` keeps + it. +* The distinguishing symbols are ASME Y14.2M's section-lining symbols as + shipped in ``acadiso.pat``, whose own descriptions name the material — read + off the installed file on this machine (AutoCAD 2026, + ``R25.1/enu/Support/acadiso.pat``): ``*ANSI32,ANSI Steel``, + ``*ANSI33,ANSI Bronze, Brass, Copper``, ``*ANSI34,ANSI Plastic, Rubber``, + ``*ANSI37,ANSI Lead, Zinc, Magnesium, Sound/Heat/Elec Insulation``, + ``*ANSI38,ANSI Aluminum``. +* ``cast_iron`` is the one deliberate departure and says so in its own row: + ASME's cast-iron symbol is ANSI31, which this map reserves for the ISO + general hatching, so cast iron takes ANSI32 to stay *distinguishable* on the + sheet. That is a repository convention, not a standard symbol — the material + is identified by the parts-list entry, never by the pattern alone. +* ``concrete`` and ``wood`` have no mechanical section symbol in either ISO + 128-50 or ASME Y14.2M. They use AutoCAD's own ``AR-CONC`` and ``JIS_WOOD`` + (``*JIS_WOOD, WOOD JIS A 0150``), declared as such in their source line. + +``angle`` is the rotation *added* to the pattern, and it is 0.0 everywhere: the +ANSI3x definitions already carry 45 degrees, so an angle of 45 would draw steel +hatching vertically. ``scale`` is 1.0 for the ANSI family (one scaled set) and, +for the two architectural patterns, is derived so the finest line in the +pattern matches ANSI31's 2.245 mm spacing — AR-CONC is drawn at building scale +and JIS_WOOD at 0.5 mm, so 1.0 would be unreadable on a 40 mm part. Both +derived numbers are recomputed from ezdxf's own pattern definitions by +``tests/test_mech_materials.py`` rather than trusted here. +""" + +from __future__ import annotations + +MATERIAL_TABLE: dict[str, dict] = { + "steel": { + "pattern": "ANSI31", + "angle": 0.0, + "scale": 1.0, + "source": ( + "ISO 128-50:2020 general hatching (45 deg narrow continuous lines) = acadiso.pat ANSI31" + ), + }, + "cast_iron": { + "pattern": "ANSI32", + "angle": 0.0, + "scale": 1.0, + "source": ( + "acadiso.pat ANSI32 (ASME Y14.2M steel symbol); repository convention so cast " + "iron stays distinguishable from the ISO general hatching reserved for steel" + ), + }, + "aluminium": { + "pattern": "ANSI38", + "angle": 0.0, + "scale": 1.0, + "source": "ASME Y14.2M section lining = acadiso.pat *ANSI38,ANSI Aluminum", + }, + "copper_alloy": { + "pattern": "ANSI33", + "angle": 0.0, + "scale": 1.0, + "source": "ASME Y14.2M section lining = acadiso.pat *ANSI33,ANSI Bronze, Brass, Copper", + }, + "plastic": { + "pattern": "ANSI34", + "angle": 0.0, + "scale": 1.0, + "source": "ASME Y14.2M section lining = acadiso.pat *ANSI34,ANSI Plastic, Rubber", + }, + "rubber": { + "pattern": "ANSI34", + "angle": 0.0, + "scale": 1.0, + "source": "ASME Y14.2M section lining = acadiso.pat *ANSI34,ANSI Plastic, Rubber", + }, + "concrete": { + "pattern": "AR-CONC", + "angle": 0.0, + "scale": 0.392, + "source": ( + "no mechanical section symbol in ISO 128-50 or ASME Y14.2M; acadiso.pat " + "*AR-CONC, random dot and stone pattern, scaled to the ANSI31 line spacing" + ), + }, + "wood": { + "pattern": "JIS_WOOD", + "angle": 0.0, + "scale": 4.49, + "source": ( + "no mechanical section symbol in ISO 128-50 or ASME Y14.2M; acadiso.pat " + "*JIS_WOOD, WOOD JIS A 0150, scaled to the ANSI31 line spacing" + ), + }, + "insulation": { + "pattern": "ANSI37", + "angle": 0.0, + "scale": 1.0, + "source": ( + "ASME Y14.2M section lining = acadiso.pat *ANSI37,ANSI Lead, Zinc, Magnesium, " + "Sound/Heat/Elec Insulation" + ), + }, +} + +MATERIALS: tuple[str, ...] = tuple(MATERIAL_TABLE) + +#: Spellings a caller will reasonably type, mapped to a canonical name. +ALIASES: dict[str, str] = { + "aluminum": "aluminium", + "alu": "aluminium", + "brass": "copper_alloy", + "bronze": "copper_alloy", + "copper": "copper_alloy", + "cast_steel": "steel", + "grey_cast_iron": "cast_iron", + "gg": "cast_iron", + "ductile_iron": "cast_iron", + "polymer": "plastic", + "elastomer": "rubber", +} + + +def _canonical(material: str) -> str: + if not isinstance(material, str): + raise TypeError(f"material must be a name, got {type(material).__name__}") + name = material.strip().lower().replace("-", "_").replace(" ", "_") + name = ALIASES.get(name, name) + if name not in MATERIAL_TABLE: + raise ValueError( + f"unknown material {material!r}; the ISO 128-50 section-hatch map covers " + f"{', '.join(MATERIALS)}" + ) + return name + + +def hatch_for(material: str) -> dict: + """``{"pattern", "angle", "scale"}`` for a material. Unknown names are refused.""" + row = MATERIAL_TABLE[_canonical(material)] + return {"pattern": row["pattern"], "angle": row["angle"], "scale": row["scale"]} diff --git a/engineering/mech/xdata.py b/engineering/mech/xdata.py new file mode 100644 index 0000000..915e572 --- /dev/null +++ b/engineering/mech/xdata.py @@ -0,0 +1,97 @@ +"""``ACADMCP_MECH`` XDATA: the part model written onto the drawing. + +The Track A codec (`engineering/pid/xdata.py`) with its limits made explicit. +XDATA strings cap at 255 characters and an entity carries at most 16 KB across +every application, so a payload is JSON-serialised ASCII-only and split into +1000 chunks under one 1001 application marker. `json.dumps(ensure_ascii=True)` +escapes every newline, so no chunk can carry a line separator into the DXF. + +A payload that is not ours is *refused*, never decoded into something +plausible: a foreign application marker, a chunk stream that is not JSON, a +version this build does not read, or a kind outside `KINDS`. Three shapes are +written, and both the writer and every reader test them literally: + + {"v": 1, "kind": "part", "id": "", "view": "front", "part": {...}} + {"v": 1, "kind": "std_part", "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", "size": "M12x60", "qty": 1, "material": "8.8"} + {"v": 1, "kind": "balloon", "item": 3, "targets": ["1A2B", ...]} +""" + +from __future__ import annotations + +import json +from collections.abc import Sequence + +from backends.xdata_specs import MAX_BYTES, app_size + +APP_ID = "ACADMCP_MECH" +PAYLOAD_VERSION = 1 +CHUNK = 255 +KINDS: tuple[str, ...] = ("part", "std_part", "balloon") + + +def _validate(payload: dict) -> dict: + if not isinstance(payload, dict): + raise TypeError(f"{APP_ID}: a payload must be a dict, got {type(payload).__name__}") + version = payload.get("v", PAYLOAD_VERSION) + if version != PAYLOAD_VERSION: + raise ValueError( + f"{APP_ID}: payload version {version!r}; this build writes and reads " + f"version {PAYLOAD_VERSION}" + ) + kind = payload.get("kind") + if kind not in KINDS: + raise ValueError(f"{APP_ID}: unknown payload kind {kind!r}; kinds are {', '.join(KINDS)}") + return {**payload, "v": PAYLOAD_VERSION} + + +def encode(payload: dict) -> list[tuple[int, str]]: + """``[(1001, APP_ID), (1000, chunk), ...]`` — refused before anything is written.""" + checked = _validate(payload) + text = json.dumps(checked, separators=(",", ":"), sort_keys=True, ensure_ascii=True) + rows: list[tuple[int, str]] = [(1001, APP_ID)] + rows.extend((1000, text[i : i + CHUNK]) for i in range(0, len(text), CHUNK)) + size = app_size(APP_ID, [(code, value) for code, value in rows if code != 1001]) + if size > MAX_BYTES: + raise ValueError( + f"{APP_ID}: payload encodes to {size} bytes; the limit is 16 KB per entity " + "across every application" + ) + return rows + + +def to_values(payload: dict) -> list[str]: + """Just the chunk strings — what ``entity_set_xdata(handle, APP_ID, values)`` takes.""" + return [value for code, value in encode(payload) if code == 1000] + + +def decode(rows: Sequence) -> dict: + """``rows`` may be ``(code, value)`` pairs or the bare value list a backend + returns from ``entity_get_xdata``. Raises ValueError on anything foreign.""" + if not rows: + raise ValueError(f"{APP_ID}: empty XDATA") + parts: list[str] = [] + for row in rows: + if isinstance(row, str): + parts.append(row) + continue + try: + code, value = row + except (TypeError, ValueError): + raise ValueError(f"{APP_ID}: {row!r} is not an XDATA (code, value) row") from None + if code == 1001: + if str(value) != APP_ID: + raise ValueError( + f"{APP_ID}: this XDATA belongs to application {value!r}, not to us" + ) + continue + if code != 1000: + raise ValueError(f"{APP_ID}: group code {code} is not a payload chunk") + parts.append(str(value)) + try: + payload = json.loads("".join(parts)) + except json.JSONDecodeError as exc: + raise ValueError(f"{APP_ID}: the chunk stream is not JSON ({exc.msg})") from exc + if not isinstance(payload, dict): + raise ValueError(f"{APP_ID}: the payload decoded to {type(payload).__name__}, not a dict") + return _validate(payload) diff --git a/tests/test_mech_materials.py b/tests/test_mech_materials.py new file mode 100644 index 0000000..85f689d --- /dev/null +++ b/tests/test_mech_materials.py @@ -0,0 +1,126 @@ +"""The section-hatch map: every pattern exists in both engines, every row cites. + +The ezdxf failure this guards is silent: ``Hatch.set_pattern_fill`` falls back +to ANSI31 for a name it does not know (``ezdxf/entities/polygon.py``), so a +typo'd pattern draws steel hatching on a cast-iron part and says nothing. +""" + +from __future__ import annotations + +import pytest +from ezdxf.tools import pattern as ezpattern + +from engineering.mech.standards.materials import ( + ALIASES, + MATERIAL_TABLE, + MATERIALS, + hatch_for, +) + +ANSI31_SPACING = 2.2450640303 # acadiso.pat ANSI31, the ISO 128-50 general hatching + + +def _spacing(name: str, defs: dict) -> float: + """The finest perpendicular line spacing in a pattern definition.""" + return min(abs(line[2][1]) for line in defs[name] if abs(line[2][1]) > 1e-9) + + +def test_the_map_covers_the_nine_named_materials(): + assert MATERIALS == ( + "steel", + "cast_iron", + "aluminium", + "copper_alloy", + "plastic", + "rubber", + "concrete", + "wood", + "insulation", + ) + + +def test_hatch_for_returns_exactly_pattern_angle_scale(): + assert set(hatch_for("steel")) == {"pattern", "angle", "scale"} + + +@pytest.mark.parametrize( + ("material", "expected"), + [ + ("steel", "ANSI31"), + ("cast_iron", "ANSI32"), + ("aluminium", "ANSI38"), + ("copper_alloy", "ANSI33"), + ("plastic", "ANSI34"), + ("rubber", "ANSI34"), + ("insulation", "ANSI37"), + ("concrete", "AR-CONC"), + ("wood", "JIS_WOOD"), + ], +) +def test_each_material_keeps_its_pattern(material, expected): + assert hatch_for(material)["pattern"] == expected + + +def test_aliases_resolve_and_are_case_and_separator_insensitive(): + assert hatch_for("Cast Iron")["pattern"] == "ANSI32" + assert hatch_for("cast-iron")["pattern"] == "ANSI32" + assert hatch_for("aluminum")["pattern"] == "ANSI38" + assert hatch_for("BRASS")["pattern"] == "ANSI33" + assert hatch_for("bronze")["pattern"] == "ANSI33" + assert hatch_for("copper")["pattern"] == "ANSI33" + assert set(ALIASES.values()) <= set(MATERIALS) + + +def test_an_unknown_material_is_refused_with_the_list(): + with pytest.raises(ValueError, match="unobtainium.*cast_iron"): + hatch_for("unobtainium") + + +def test_every_pattern_exists_in_both_measurement_sets(): + """Guards ezdxf's silent ANSI31 fallback and AutoCAD's unknown-pattern error.""" + iso = ezpattern.load(measurement=1) + imperial = ezpattern.load(measurement=0) + for material in MATERIALS: + name = hatch_for(material)["pattern"] + assert name in iso, f"{material}: {name} is not an ezdxf ISO pattern" + assert name in imperial, f"{material}: {name} is not an ezdxf imperial pattern" + + +def test_the_map_adds_no_rotation_because_the_patterns_already_carry_45_degrees(): + defs = ezpattern.load(measurement=1) + for material in MATERIALS: + row = hatch_for(material) + assert row["angle"] == 0.0 + if row["pattern"].startswith("ANSI3"): + assert defs[row["pattern"]][0][0] == 45.0 + + +def test_the_ansi_rows_ship_at_the_pattern_definition_scale(): + for material in ( + "steel", + "cast_iron", + "aluminium", + "copper_alloy", + "plastic", + "rubber", + "insulation", + ): + assert hatch_for(material)["scale"] == 1.0 + + +def test_the_two_non_ansi_patterns_are_scaled_to_the_iso_line_spacing(): + """Concrete and wood are architectural patterns: AR-CONC is defined at + building scale and JIS_WOOD at 0.5 mm. Their scale is derived, not chosen — + the finest line in the pattern is brought to ANSI31's spacing.""" + defs = ezpattern.load(measurement=1) + assert abs(_spacing("ANSI31", defs) - ANSI31_SPACING) < 1e-6 + for material in ("concrete", "wood"): + row = hatch_for(material) + derived = round(ANSI31_SPACING / _spacing(row["pattern"], defs), 3) + assert row["scale"] == derived + + +def test_every_row_states_where_its_symbol_comes_from(): + for material in MATERIALS: + source = MATERIAL_TABLE[material]["source"] + assert any(token in source for token in ("ISO 128-50", "ASME Y14.2M", "acadiso.pat")) diff --git a/tests/test_mech_primitives.py b/tests/test_mech_primitives.py new file mode 100644 index 0000000..4d36513 --- /dev/null +++ b/tests/test_mech_primitives.py @@ -0,0 +1,135 @@ +"""The typed primitive descriptions every pure mechanical module emits. + +Geometry is asserted on the emitted tuples to 1e-9 — never on a rendered +drawing. That is what lets the view engine be tested without a backend. +""" + +from __future__ import annotations + +import pytest + +from engineering.mech.primitives import ( + ROLE_LAYER, + ROLES, + Arc, + Circle, + DimIntent, + HatchArea, + Line, + Poly, + Text, + bbox, + layer_for, + points_of, + rotate, + scale, + translate, +) + +EPS = 1e-9 + + +def test_role_layer_maps_every_role_to_an_engineering_layer(): + assert ROLE_LAYER == { + "visible": "GEOMETRY", + "hidden": "HIDDEN", + "center": "CENTER", + "phantom": "PHANTOM", + "hatch": "HATCH", + "dim": "DIM", + "text": "TEXT", + } + assert ROLES == tuple(ROLE_LAYER) + + +def test_defaults_match_the_pinned_interface(): + assert Line((0.0, 0.0), (1.0, 0.0)).role == "visible" + assert Arc((0.0, 0.0), 5.0, 0.0, 90.0).role == "visible" + assert Circle((0.0, 0.0), 5.0).role == "visible" + poly = Poly(((0.0, 0.0), (1.0, 0.0))) + assert poly.closed is False and poly.role == "visible" + assert HatchArea((((0.0, 0.0), (1.0, 0.0), (1.0, 1.0)),)).material == "steel" + label = Text((0.0, 0.0), "M20") + assert (label.height, label.rotation, label.role) == (3.5, 0.0, "text") + intent = DimIntent("linear", (0.0, 0.0), (10.0, 0.0)) + assert intent.text_at is None + assert intent.text_override is None + assert intent.fit is None + assert intent.tol is None + assert intent.feature == "" + + +def test_layer_for_uses_the_role_map_and_a_hatch_has_no_role(): + assert layer_for(Line((0.0, 0.0), (1.0, 0.0), "hidden")) == "HIDDEN" + assert layer_for(Text((0.0, 0.0), "A")) == "TEXT" + assert layer_for(HatchArea((((0.0, 0.0), (1.0, 0.0), (1.0, 1.0)),))) == "HATCH" + + +def test_arc_bbox_includes_the_quadrant_it_sweeps_through(): + """A 0-180 arc of r=5 reaches y=+5 at 90 deg; an endpoint-only bbox misses it.""" + x0, y0, x1, y1 = bbox([Arc((0.0, 0.0), 5.0, 0.0, 180.0)]) + assert abs(x0 + 5.0) < EPS + assert abs(x1 - 5.0) < EPS + assert abs(y0) < EPS + assert abs(y1 - 5.0) < EPS + + +def test_circle_bbox_is_the_full_square(): + assert bbox([Circle((10.0, 0.0), 2.0)]) == (8.0, -2.0, 12.0, 2.0) + + +def test_bbox_of_nothing_is_refused_rather_than_reported_as_zero(): + with pytest.raises(ValueError, match="no primitives"): + bbox([]) + + +def test_translate_moves_every_primitive_kind(): + prims = ( + Line((0.0, 0.0), (10.0, 0.0)), + Arc((0.0, 0.0), 5.0, 0.0, 90.0), + Circle((0.0, 0.0), 5.0), + Poly(((0.0, 0.0), (1.0, 1.0)), closed=True, role="hidden"), + HatchArea((((0.0, 0.0), (1.0, 0.0), (1.0, 1.0)),), material="cast_iron"), + Text((0.0, 0.0), "A-A"), + ) + moved = translate(prims, 3.0, -2.0) + assert moved[0] == Line((3.0, -2.0), (13.0, -2.0)) + assert moved[1] == Arc((3.0, -2.0), 5.0, 0.0, 90.0) + assert moved[2] == Circle((3.0, -2.0), 5.0) + assert moved[3] == Poly(((3.0, -2.0), (4.0, -1.0)), closed=True, role="hidden") + assert moved[4] == HatchArea((((3.0, -2.0), (4.0, -2.0), (4.0, -1.0)),), material="cast_iron") + assert moved[5] == Text((3.0, -2.0), "A-A") + + +def test_rotate_turns_arc_angles_with_the_geometry(): + turned = rotate([Arc((10.0, 0.0), 5.0, 0.0, 90.0)], 90.0)[0] + assert abs(turned.center[0]) < EPS + assert abs(turned.center[1] - 10.0) < EPS + assert abs(turned.start_deg - 90.0) < EPS + assert abs(turned.end_deg - 180.0) < EPS + + +def test_rotate_adds_to_a_text_rotation(): + label = rotate([Text((1.0, 0.0), "N", rotation=10.0)], 90.0)[0] + assert abs(label.at[0]) < EPS + assert abs(label.at[1] - 1.0) < EPS + assert abs(label.rotation - 100.0) < EPS + + +def test_scale_scales_radius_height_and_position_about_a_point(): + prims = scale( + [Circle((10.0, 0.0), 2.0), Text((10.0, 0.0), "x", height=3.5)], + 2.0, + about=(0.0, 0.0), + ) + assert prims[0] == Circle((20.0, 0.0), 4.0) + assert prims[1].height == 7.0 + + +def test_scale_refuses_a_non_positive_factor(): + with pytest.raises(ValueError, match="scale factor"): + scale([Circle((0.0, 0.0), 1.0)], 0.0) + + +def test_points_of_reports_the_text_anchor_only(): + assert points_of(Text((4.0, 5.0), "long label")) == ((4.0, 5.0),) diff --git a/tests/test_mech_standards.py b/tests/test_mech_standards.py new file mode 100644 index 0000000..84ec877 --- /dev/null +++ b/tests/test_mech_standards.py @@ -0,0 +1,94 @@ +"""The standards registry: one row or a named refusal, never an interpolation.""" + +from __future__ import annotations + +import pytest + +import engineering.mech.standards as standards_mod +from engineering.mech.standards import ( + Coverage, + coverage_of, + lookup, + register, + source_of, + standards, +) + +SOURCE = "TEST 1 (2026), table 1 — fixture rows, not a real standard" + + +@pytest.fixture(autouse=True) +def _fixture_tables(): + """Swap the process-wide registry for three fixture tables, then restore it.""" + saved = dict(standards_mod._REGISTRY) + standards_mod._REGISTRY.clear() + register( + "TEST 1", + {10: {"d": 10.0, "w": 3.0}, 20: {"d": 20.0, "w": 6.0}}, + Coverage(10.0, 20.0), + SOURCE, + ) + register( + "TEST 2", + {18: ({"form": "E", "r": 0.6}, {"form": "F", "r": 0.8})}, + Coverage(18.0, 18.0), + SOURCE, + ) + register("TEST 3", {"M12": {"pitch": 1.75}}, Coverage(12.0, 12.0), SOURCE) + yield + standards_mod._REGISTRY.clear() + standards_mod._REGISTRY.update(saved) + + +def test_registered_standards_are_listed_and_carry_their_source(): + assert standards() == ("TEST 1", "TEST 2", "TEST 3") + assert source_of("TEST 1") == SOURCE + assert coverage_of("TEST 1") == Coverage(10.0, 20.0, "mm") + + +def test_a_listed_size_returns_a_copy_of_its_row(): + row = lookup("TEST 1", 10) + assert row == {"d": 10.0, "w": 3.0} + row["w"] = 99.0 + assert lookup("TEST 1", 10)["w"] == 3.0 + + +def test_a_size_outside_the_coverage_is_refused_by_name(): + with pytest.raises(ValueError, match="TEST 1 covers 10-20 mm; 95 mm is outside the table"): + lookup("TEST 1", 95) + + +def test_a_size_inside_the_coverage_but_not_in_the_table_is_never_interpolated(): + with pytest.raises(ValueError, match="no row for 15"): + lookup("TEST 1", 15) + + +def test_a_variant_is_selected_by_keyword(): + assert lookup("TEST 2", 18, form="E")["r"] == 0.6 + assert lookup("TEST 2", 18, form="F")["r"] == 0.8 + + +def test_an_unknown_variant_is_refused_and_an_ambiguous_one_too(): + with pytest.raises(ValueError, match="no form='G' row"): + lookup("TEST 2", 18, form="G") + with pytest.raises(ValueError, match="ambiguous"): + lookup("TEST 2", 18) + + +def test_a_designation_key_is_case_insensitive(): + assert lookup("TEST 3", "m12")["pitch"] == 1.75 + + +def test_an_unregistered_standard_names_the_ones_that_exist(): + with pytest.raises(ValueError, match="DIN 9999.*TEST 1"): + lookup("DIN 9999", 10) + + +def test_a_source_that_does_not_name_its_table_is_refused(): + with pytest.raises(ValueError, match="SOURCE"): + register("TEST 4", {1: {"a": 1}}, Coverage(1.0, 1.0), "made up") + + +def test_registering_the_same_standard_twice_is_refused(): + with pytest.raises(ValueError, match="already registered"): + register("TEST 1", {10: {"d": 10.0}}, Coverage(10.0, 10.0), SOURCE) diff --git a/tests/test_mech_xdata.py b/tests/test_mech_xdata.py new file mode 100644 index 0000000..76a7fee --- /dev/null +++ b/tests/test_mech_xdata.py @@ -0,0 +1,85 @@ +"""The ACADMCP_MECH chunking codec: Track A's shape, one app, 255-char chunks.""" + +from __future__ import annotations + +import pytest + +from backends.xdata_specs import encode_values +from engineering.mech.xdata import APP_ID, KINDS, PAYLOAD_VERSION, decode, encode, to_values + +PART = { + "v": 1, + "kind": "part", + "id": "1A2B", + "view": "front", + "part": {"name": "shaft", "segments": [{"length": 60.0, "d_outer": 40.0}]}, +} + + +def test_app_id_version_and_kinds_are_the_pinned_ones(): + assert APP_ID == "ACADMCP_MECH" + assert PAYLOAD_VERSION == 1 + assert KINDS == ("part", "std_part", "balloon") + + +def test_encode_opens_with_the_app_id_and_chunks_at_255(): + rows = encode({"v": 1, "kind": "balloon", "item": 3, "targets": ["1A2B"] * 60}) + assert rows[0] == (1001, APP_ID) + assert all(code == 1000 for code, _value in rows[1:]) + assert len(rows) > 2, "the payload must actually have been split" + assert all(len(value) <= 255 for _code, value in rows[1:]) + + +def test_roundtrip_is_exact(): + assert decode(encode(PART)) == PART + + +def test_decode_reads_the_bare_value_list_a_backend_returns(): + """`entity_get_xdata` hands back decoded values, not (code, value) rows.""" + assert decode(to_values(PART)) == PART + + +def test_the_version_is_injected_when_absent_and_a_wrong_one_is_refused(): + payload = { + "kind": "std_part", + "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", + "size": "M12x60", + "qty": 1, + "material": "8.8", + } + assert decode(encode(payload))["v"] == PAYLOAD_VERSION + with pytest.raises(ValueError, match="version"): + encode({**payload, "v": 99}) + + +def test_a_foreign_application_is_refused(): + with pytest.raises(ValueError, match="ACADMCP_PID"): + decode([(1001, "ACADMCP_PID"), (1000, '{"v":1,"kind":"part"}')]) + + +def test_a_corrupt_payload_is_refused_not_silently_dropped(): + with pytest.raises(ValueError, match="not JSON"): + decode([(1001, APP_ID), (1000, '{"v":1,"kind":')]) + + +def test_an_unknown_kind_is_refused_on_both_sides(): + with pytest.raises(ValueError, match="kind"): + encode({"v": 1, "kind": "sheet_frame"}) + with pytest.raises(ValueError, match="kind"): + decode([(1001, APP_ID), (1000, '{"v":1,"kind":"sheet_frame"}')]) + + +def test_the_16_kb_entity_ceiling_is_enforced_before_writing(): + huge = {"v": 1, "kind": "part", "id": "1", "view": "front", "part": {"n": "x" * 20000}} + with pytest.raises(ValueError, match="16 KB"): + encode(huge) + + +def test_every_chunk_is_a_value_the_backend_will_accept(): + """`entity_set_xdata` runs the values through `encode_values`; ASCII-only + JSON means no line separator can reach a 1000 tag and split the DXF.""" + rows = encode(PART) + tags = encode_values(to_values(PART)) + assert all(code == 1000 for code, _value in tags) + assert len(tags) == len(rows) - 1