From 2bb8044144a99793d0c8523c9bbbe03a4d2ad963 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 17:04:59 +0300 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/mech/__init__.py | 49 +++++ engineering/mech/primitives.py | 229 ++++++++++++++++++++++++ engineering/mech/standards/__init__.py | 136 ++++++++++++++ engineering/mech/standards/materials.py | 145 +++++++++++++++ engineering/mech/xdata.py | 97 ++++++++++ tests/test_mech_materials.py | 126 +++++++++++++ tests/test_mech_primitives.py | 135 ++++++++++++++ tests/test_mech_standards.py | 94 ++++++++++ tests/test_mech_xdata.py | 85 +++++++++ 9 files changed, 1096 insertions(+) create mode 100644 engineering/mech/__init__.py create mode 100644 engineering/mech/primitives.py create mode 100644 engineering/mech/standards/__init__.py create mode 100644 engineering/mech/standards/materials.py create mode 100644 engineering/mech/xdata.py create mode 100644 tests/test_mech_materials.py create mode 100644 tests/test_mech_primitives.py create mode 100644 tests/test_mech_standards.py create mode 100644 tests/test_mech_xdata.py 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