From d5c5e90406529e4ba4c4b2f5c49d283f483bff83 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 23 Sep 2026 22:27:00 +0300 Subject: [PATCH] feat(arch): the plan model and its foundation - layers, materials, EN/TR, two generalisations Track F starts here; the three group branches are cut from this commit. - engineering/arch/model.py: Wall, Opening, Stair and Room as frozen dataclasses, and every refusal of spec 4 before anything is drawn, by path (walls[2].thickness): finite positive numbers, an axis of at least two distinct points with no zero-length segment, an arc segment refused by name rather than approximated, an opening on an unknown wall, past its wall, across an axis vertex or overlapping another (both ids named), an unknown material, and a typed room area - areas are measured, never typed. The plan travels as ACADMCP_ARCH XDATA; an opening's door/window kind and a stair's straight/l/u kind ride beside the payload kind as opening_kind / stair_kind, because one key cannot hold both. - engineering/arch/layers.py: the arch set - thirteen ISO 13567-style layers with ISO 128 lineweights only (0.05 mm on the construction scratch layer, as every set in this repository) - and ARCH_ROLE_LAYER, the one place a role becomes a layer. Registered in engineering/layers.py last, which is what keeps the engineering.layers <-> engineering.arch import cycle closed. - engineering/arch/materials.py: eight wall materials, one pattern each, every row citing its source: ANSI31 for brick (ISO 128-50 general hatching, acadiso.pat's own "Iron, Brick, Stone masonry"), AAC as ANSI31 at twice the spacing (a repository convention, and it says so), AR-CONC / AR-SAND as AutoCAD patterns, and the JIS A 0150 RC / stone / wood symbols. Reinforced concrete cannot be ANSI31 and AR-CONC in one HATCH, so it takes the one symbol that names it. Periods on a 1:50 sheet are recomputed from ezdxf's own pattern definitions by the tests. normalise_material is the one reading of a material name, shared by the model and wall_hatch, so the model never refuses "Reinforced Concrete" that the hatch table resolves. - engineering/arch/lang.py: EN / TR vocabulary, D/W and K/P tag prefixes, and the decimal comma for lang="tr" (24,50 m2). - draw_prims(role_layer=) and the XDATA codec's app_id= / kinds=: both generalised with the mechanical defaults, and the mechanical suite passes unchanged against them - that is the regression gate. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/arch/__init__.py | 66 +++++ engineering/arch/lang.py | 120 +++++++++ engineering/arch/layers.py | 66 +++++ engineering/arch/materials.py | 125 +++++++++ engineering/arch/model.py | 466 ++++++++++++++++++++++++++++++++++ engineering/layers.py | 15 +- engineering/mech/draw.py | 88 ++++++- engineering/mech/xdata.py | 54 ++-- engineering/plan_spec.py | 2 +- server.py | 8 +- tests/test_arch_foundation.py | 367 ++++++++++++++++++++++++++ tests/test_arch_model.py | 312 +++++++++++++++++++++++ 12 files changed, 1657 insertions(+), 32 deletions(-) create mode 100644 engineering/arch/__init__.py create mode 100644 engineering/arch/lang.py create mode 100644 engineering/arch/layers.py create mode 100644 engineering/arch/materials.py create mode 100644 engineering/arch/model.py create mode 100644 tests/test_arch_foundation.py create mode 100644 tests/test_arch_model.py diff --git a/engineering/arch/__init__.py b/engineering/arch/__init__.py new file mode 100644 index 0000000..505a99e --- /dev/null +++ b/engineering/arch/__init__.py @@ -0,0 +1,66 @@ +"""Architectural 2D: the plan model and the engine that draws it (track F). + +Merge rule for the three track-F branches: the re-exports below are additive +and alphabetical - keep every side's names. +""" + +from __future__ import annotations + +from .lang import LANGS, fmt_area_m2, fmt_number, tag_prefix, vocab +from .layers import ARCH_LAYER_DEFS, ARCH_LAYER_ROLES, ARCH_LAYERS, ARCH_ROLE_LAYER +from .materials import WALL_HATCH_TABLE, WALL_MATERIALS, wall_hatch +from .model import ( + APP_ID, + KINDS, + Opening, + Room, + Stair, + Wall, + axis_segments, + decode, + encode, + from_payload, + opening_from_dict, + room_from_dict, + segment_of, + stair_from_dict, + to_payload, + to_values, + validate_openings, + wall_from_dict, + wall_length, +) + +__all__ = [ + "APP_ID", + "ARCH_LAYERS", + "ARCH_LAYER_DEFS", + "ARCH_LAYER_ROLES", + "ARCH_ROLE_LAYER", + "KINDS", + "LANGS", + "Opening", + "Room", + "Stair", + "WALL_HATCH_TABLE", + "WALL_MATERIALS", + "Wall", + "axis_segments", + "decode", + "encode", + "fmt_area_m2", + "fmt_number", + "from_payload", + "opening_from_dict", + "room_from_dict", + "segment_of", + "stair_from_dict", + "tag_prefix", + "to_payload", + "to_values", + "validate_openings", + "vocab", + "wall_from_dict", + "wall_hatch", + "wall_length", +] diff --git a/engineering/arch/lang.py b/engineering/arch/lang.py new file mode 100644 index 0000000..85150a4 --- /dev/null +++ b/engineering/arch/lang.py @@ -0,0 +1,120 @@ +"""English / Turkish vocabulary for labels and schedules, and the number format. + +``lang="en"`` is the default; ``lang="tr"`` switches the words *and* the +decimal separator to a comma (``24,50 m²``), the user's decision of +2026-09-23. Any other value is refused with the list, never silently +rendered in English. + +The Turkish words are the ones a Turkish drawing office writes on a plan +(``MAHAL LİSTESİ`` for a room schedule, ``KOT`` for a level, ``ÇIKIŞ`` for +the up-arrow of a stair); they are vocabulary, not a standard. +""" + +from __future__ import annotations + +import math + +LANGS: tuple[str, ...] = ("en", "tr") + +_VOCAB: dict[str, dict[str, str]] = { + "en": { + "door_schedule": "DOOR SCHEDULE", + "window_schedule": "WINDOW SCHEDULE", + "room_schedule": "ROOM SCHEDULE", + "tag": "TAG", + "width": "WIDTH", + "height": "HEIGHT", + "sill": "SILL", + "swing": "SWING", + "hand": "HAND", + "wall": "WALL", + "number": "NO.", + "name": "NAME", + "area": "AREA", + "total": "TOTAL", + "up": "UP", + "down": "DN", + "north": "N", + "level": "LEVEL", + "in": "IN", + "out": "OUT", + "left": "LEFT", + "right": "RIGHT", + }, + "tr": { + "door_schedule": "KAPI LİSTESİ", + "window_schedule": "PENCERE LİSTESİ", + "room_schedule": "MAHAL LİSTESİ", + "tag": "KOD", + "width": "GENİŞLİK", + "height": "YÜKSEKLİK", + "sill": "PARAPET", + "swing": "AÇILIŞ", + "hand": "YÖN", + "wall": "DUVAR", + "number": "NO", + "name": "MAHAL ADI", + "area": "ALAN", + "total": "TOPLAM", + "up": "ÇIKIŞ", + "down": "İNİŞ", + "north": "K", + "level": "KOT", + "in": "İÇE", + "out": "DIŞA", + "left": "SOL", + "right": "SAĞ", + }, +} + +_TAG_PREFIX: dict[str, dict[str, str]] = { + "en": {"door": "D", "window": "W"}, + "tr": {"door": "K", "window": "P"}, +} + + +def _lang(lang: str) -> str: + if not isinstance(lang, str) or lang.strip().lower() not in LANGS: + raise ValueError(f"lang: unknown language {lang!r}; languages are {', '.join(LANGS)}") + return lang.strip().lower() + + +def vocab(lang: str) -> dict[str, str]: + """Every word a label or schedule writes, in ``lang``. A copy: callers may not edit it.""" + return dict(_VOCAB[_lang(lang)]) + + +def tag_prefix(kind: str, lang: str) -> str: + """``door`` -> ``D`` / ``K`` (kapı), ``window`` -> ``W`` / ``P`` (pencere).""" + table = _TAG_PREFIX[_lang(lang)] + if kind not in table: + raise ValueError(f"kind: unknown opening kind {kind!r}; kinds are door, window") + return table[kind] + + +def fmt_number(value: float, lang: str, decimals: int = 2) -> str: + """``24.5`` -> ``"24.50"`` (en) / ``"24,50"`` (tr). No thousands separator.""" + code = _lang(lang) + if isinstance(decimals, bool) or not isinstance(decimals, int) or decimals < 0: + raise ValueError(f"decimals: expected a whole number >= 0, got {decimals!r}") + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError(f"value: expected a number, got {value!r}") from None + if not math.isfinite(number): + raise ValueError(f"value: must be finite, got {value!r}") + text = f"{number:.{decimals}f}" + if text.startswith("-") and float(text) == 0.0: + text = text[1:] # never print "-0.00" + return text.replace(".", ",") if code == "tr" else text + + +def fmt_area_m2(area_mm2: float, lang: str) -> str: + """An area measured in mm² written in m² with two decimals: ``"24.50 m²"`` / ``"24,50 m²"``.""" + try: + area = float(area_mm2) + except (TypeError, ValueError): + raise ValueError(f"area: expected a number, got {area_mm2!r}") from None + if not math.isfinite(area) or area < 0.0: + raise ValueError(f"area: must be finite and not negative, got {area_mm2!r}") + return f"{fmt_number(area / 1.0e6, lang, 2)} m²" diff --git a/engineering/arch/layers.py b/engineering/arch/layers.py new file mode 100644 index 0000000..971a23e --- /dev/null +++ b/engineering/arch/layers.py @@ -0,0 +1,66 @@ +"""The ``arch`` layer set and the role -> layer map every architectural module uses. + +Names follow the ISO 13567 field order the repository's ``iso13567`` set +already uses — agent (``A`` = architect), element, presentation (``E`` element +graphics, ``H`` hatching, ``T`` text/annotation), status (``N`` new) — with the +element field in its common short spelling (``WALL``, ``DOOR``, ``WIND``); the +fields are not padded to ISO 13567-2's fixed widths, exactly as the existing +``M-GEOMET-E-N`` rows are not. + +Lineweights are ISO 128 values only (0.13 / 0.18 / 0.25 / 0.50). The one +exception is ``A-CONST-E-N`` at 0.05 mm, the scratch weight every construction +layer in this repository carries: the ``iso128`` critique skips a layer whose +name contains ``CONST``, and ``construction_left`` requires it to be empty +before finalize. Colours are a repository convention — ISO 13567 fixes names, +not colours. + +``ARCH_ROLE_LAYER`` is the one place a role becomes a layer name, so no tool +hardcodes one. +""" + +from __future__ import annotations + +#: (name, ACI colour, linetype, lineweight mm, description) — the row shape of +#: every set in ``engineering/layers.py``. +ARCH_LAYERS: list[tuple[str, int, str, float, str]] = [ + ("0", 7, "Continuous", 0.25, "default"), + ("A-WALL-E-N", 7, "Continuous", 0.50, "Architecture / walls, cut outline / new"), + ("A-WALL-H-N", 8, "Continuous", 0.13, "Architecture / walls, poche hatch / new"), + ("A-DOOR-E-N", 3, "Continuous", 0.25, "Architecture / doors, leaf and swing / new"), + ("A-WIND-E-N", 4, "Continuous", 0.25, "Architecture / windows, frame and glazing / new"), + ("A-STAIR-E-N", 6, "Continuous", 0.25, "Architecture / stairs / new"), + ("A-FURN-E-N", 5, "Continuous", 0.18, "Architecture / furniture / new"), + ("A-SANR-E-N", 5, "Continuous", 0.18, "Architecture / sanitary fixtures / new"), + ("A-GRID-E-N", 1, "CENTER", 0.18, "Architecture / structural grid / new"), + ("A-ROOM-T-N", 7, "Continuous", 0.25, "Architecture / room labels / new"), + ("A-DIMEN-T-N", 2, "Continuous", 0.18, "Architecture / dimensions / new"), + ("A-SYMB-T-N", 7, "Continuous", 0.25, "Architecture / symbols and marks / new"), + ("A-OVHD-E-N", 3, "HIDDEN", 0.18, "Architecture / overhead, above the cut plane / new"), + ("A-CONST-E-N", 250, "Continuous", 0.05, "Architecture / construction (scratch)"), +] + +#: Architectural role -> layer. ``wall_hatch`` is where a ``HatchArea`` lands +#: when ``draw_prims`` is given this map (a HatchArea carries no role of its own). +ARCH_ROLE_LAYER: dict[str, str] = { + "wall": "A-WALL-E-N", + "wall_hatch": "A-WALL-H-N", + "door": "A-DOOR-E-N", + "window": "A-WIND-E-N", + "stair": "A-STAIR-E-N", + "furniture": "A-FURN-E-N", + "sanitary": "A-SANR-E-N", + "grid": "A-GRID-E-N", + "room": "A-ROOM-T-N", + "dim": "A-DIMEN-T-N", + "symbol": "A-SYMB-T-N", + "overhead": "A-OVHD-E-N", +} + +#: The two roles ``engineering/layers.py::resolve_role_layer`` answers for. +ARCH_LAYER_ROLES: dict[str, str] = {"dim": "A-DIMEN-T-N", "construction": "A-CONST-E-N"} + +#: name -> (colour, linetype, lineweight): what ``draw_prims`` creates a missing +#: architectural layer with. +ARCH_LAYER_DEFS: dict[str, tuple[int, str, float]] = { + name: (color, linetype, lineweight) for name, color, linetype, lineweight, _d in ARCH_LAYERS +} diff --git a/engineering/arch/materials.py b/engineering/arch/materials.py new file mode 100644 index 0000000..f575aef --- /dev/null +++ b/engineering/arch/materials.py @@ -0,0 +1,125 @@ +"""Wall material -> the area hatch (poche) of its cut face. + +SOURCE, stated row by row because it is not one document: + +* ISO 128-50 (representation of areas on cuts and sections) standardises only + the *general* hatching — narrow continuous lines at 45 degrees — and leaves + distinguishing materials to other conventions. ``ANSI31`` in AutoCAD 2026's + ``acadiso.pat`` is that line set, and its own description in the installed + file (``R25.1/enu/Support/acadiso.pat``) reads ``*ANSI31,ANSI Iron, Brick, + Stone masonry``. ``brick`` and ``generic`` take it. +* ``reinforced_concrete``, ``stone`` and ``timber`` take the JIS A 0150 + (Japanese architectural drawing conventions) symbols AutoCAD ships in the + same file: ``*JIS_RC_15, RC JIS A 0150(@15)``, ``*JIS_STN_2.5, STONE JIS A + 0150(@2.5)`` and ``*JIS_WOOD, WOOD JIS A 0150``. A reinforced-concrete + section cannot be ANSI31 *and* AR-CONC in one HATCH entity, so it takes the + one symbol that names reinforced concrete. +* ``concrete`` and ``gypsum_board`` take AutoCAD's own ``*AR-CONC, random dot + and stone pattern`` and ``*AR-SAND, random dot pattern`` — declared as + AutoCAD patterns, not standard symbols. +* ``aac`` (autoclaved aerated concrete) has no symbol in any of these. It is + ANSI31 at twice the brick spacing — a lighter diagonal — which is a + repository convention and says so. +* ``timber`` adds 90 degrees so its single line family runs on the opposite + diagonal to brick: JIS_WOOD is one 45-degree family, and unrotated it would + be indistinguishable from brick on the sheet. + +``scale`` is chosen for a 1:50 plan, the default plot scale of every ``arch`` +tool (``wall_hatch`` takes no scale, as pinned): brick lines 3.175 x 20 = +63.5 mm apart, 1.27 mm on a 1:50 sheet; aac 2.54 mm; reinforced concrete one +triple-line group every 150 mm (3.0 mm on the sheet); stone 75 mm (1.5 mm); +timber 0.7071 x 90 = 63.6 mm (1.27 mm). AR-CONC and AR-SAND are defined in +building millimetres and ship at 1.0. The periods are recomputed from ezdxf's +own pattern definitions by ``tests/test_arch_foundation.py``, not trusted here. +""" + +from __future__ import annotations + +WALL_MATERIALS: tuple[str, ...] = ( + "brick", + "aac", + "concrete", + "reinforced_concrete", + "gypsum_board", + "stone", + "timber", + "generic", +) + +WALL_HATCH_TABLE: dict[str, dict] = { + "brick": { + "pattern": "ANSI31", + "angle": 0.0, + "scale": 20.0, + "source": "ISO 128-50 general hatching = acadiso.pat *ANSI31 (Iron, Brick, Stone masonry)", + }, + "aac": { + "pattern": "ANSI31", + "angle": 0.0, + "scale": 40.0, + "source": "repository convention: ANSI31 at twice the brick spacing; no standard AAC symbol", + }, + "concrete": { + "pattern": "AR-CONC", + "angle": 0.0, + "scale": 1.0, + "source": "acadiso.pat *AR-CONC, random dot and stone pattern (AutoCAD, not a standard)", + }, + "reinforced_concrete": { + "pattern": "JIS_RC_15", + "angle": 0.0, + "scale": 10.0, + "source": "acadiso.pat *JIS_RC_15, RC JIS A 0150(@15): the JIS A 0150 RC symbol", + }, + "gypsum_board": { + "pattern": "AR-SAND", + "angle": 0.0, + "scale": 1.0, + "source": "acadiso.pat *AR-SAND, random dot pattern (AutoCAD, not a standard)", + }, + "stone": { + "pattern": "JIS_STN_2.5", + "angle": 0.0, + "scale": 30.0, + "source": "acadiso.pat *JIS_STN_2.5, STONE JIS A 0150(@2.5): the JIS A 0150 stone symbol", + }, + "timber": { + "pattern": "JIS_WOOD", + "angle": 90.0, + "scale": 90.0, + "source": "acadiso.pat *JIS_WOOD, WOOD JIS A 0150; turned 90 deg so it cannot read as brick", + }, + "generic": { + "pattern": "ANSI31", + "angle": 0.0, + "scale": 20.0, + "source": "ISO 128-50 general hatching = acadiso.pat *ANSI31", + }, +} + + +def normalise_material(material: str) -> str: + """The table key a material name is read as: case, spaces and hyphens folded. + + ``"Reinforced Concrete"`` and ``"reinforced-concrete"`` both read as + ``reinforced_concrete``. The plan model and ``wall_hatch`` share this one + reading, so the model never refuses a name the hatch table resolves. The + result may still be unknown; callers refuse it. + """ + return material.strip().lower().replace("-", "_").replace(" ", "_") + + +def wall_hatch(material: str) -> dict: + """``{"pattern", "angle", "scale"}`` for a wall material. Unknown names are refused.""" + if not isinstance(material, str): + raise ValueError( + f"material: expected a name, got {type(material).__name__}; " + f"wall materials are {', '.join(WALL_MATERIALS)}" + ) + row = WALL_HATCH_TABLE.get(normalise_material(material)) + if row is None: + raise ValueError( + f"material: unknown wall material {material!r}; " + f"wall materials are {', '.join(WALL_MATERIALS)}" + ) + return {"pattern": row["pattern"], "angle": row["angle"], "scale": row["scale"]} diff --git a/engineering/arch/model.py b/engineering/arch/model.py new file mode 100644 index 0000000..fa3fe2f --- /dev/null +++ b/engineering/arch/model.py @@ -0,0 +1,466 @@ +"""The plan model: walls with hosted openings, stairs and rooms. + +A wall is an axis polyline of straight segments, a thickness, a justification +(the side of the axis the wall lies on, *looking along the axis from its first +point*) and a material. An opening is hosted in a wall by distance along that +axis: ``offset`` runs from the axis start to the opening's near jamb, so +``offset + width`` is its far jamb. A room is a name at a point; its area is +measured from the face the walls enclose and is never accepted from a caller. + +Validation happens before anything is written and names its path +(``walls[2].thickness: must be greater than zero, got -200``), the repository +rule. The plan travels on the drawing as ``ACADMCP_ARCH`` XDATA through the +mechanical codec generalised in track F (``engineering/mech/xdata.py`` with +``app_id=`` / ``kinds=``), so a schedule is a *read* of the drawing. +""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from dataclasses import asdict, dataclass + +from engineering.mech import xdata as _codec + +from .materials import WALL_HATCH_TABLE, WALL_MATERIALS, normalise_material + +Pt = tuple[float, float] + +APP_ID = "ACADMCP_ARCH" +KINDS: tuple[str, ...] = ("wall", "opening", "stair", "room") +PAYLOAD_VERSION = _codec.PAYLOAD_VERSION + +JUSTIFICATIONS: tuple[str, ...] = ("center", "left", "right") +OPENING_KINDS: tuple[str, ...] = ("door", "window") +SWINGS: tuple[str, ...] = ("in", "out") +HANDS: tuple[str, ...] = ("left", "right") +STAIR_KINDS: tuple[str, ...] = ("straight", "l", "u") +TURNS: tuple[str, ...] = ("left", "right") + +#: Below this, two lengths along a wall are the same length (mm). +EPS = 1e-9 + + +@dataclass(frozen=True) +class Wall: + id: str + axis: tuple[Pt, ...] + thickness: float + justification: str = "center" + material: str = "brick" + closed: bool = False + + +@dataclass(frozen=True) +class Opening: + id: str + wall: str + kind: str + offset: float + width: float + swing: str = "in" + hand: str = "left" + sill: float | None = None + height: float | None = None + tag: str | None = None + + +@dataclass(frozen=True) +class Stair: + id: str + start: Pt + direction_deg: float + width: float + risers: int + riser_height: float + going: float + kind: str = "straight" + turn: str = "left" + + +@dataclass(frozen=True) +class Room: + id: str + name: str + number: str | None + at: Pt + area: float = 0.0 + + +# -- field readers ------------------------------------------------------------- + + +def _number(value, where: str, *, positive: bool = True, allow_zero: bool = False) -> float: + if isinstance(value, bool): + raise ValueError(f"{where}: expected a number, got {value!r}") + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError(f"{where}: expected a number, got {value!r}") from None + if not math.isfinite(number): + raise ValueError(f"{where}: must be finite, got {value!r}") + if positive and not allow_zero and number <= 0.0: + raise ValueError(f"{where}: must be greater than zero, got {number:g}") + if positive and allow_zero and number < 0.0: + raise ValueError(f"{where}: must not be negative, got {number:g}") + return number + + +def _text(value, where: str, *, optional: bool = False) -> str | None: + if value is None and optional: + return None + if isinstance(value, bool) or not isinstance(value, (str, int)): + raise ValueError(f"{where}: expected a name, got {value!r}") + text = str(value).strip() + if not text: + raise ValueError(f"{where}: must not be empty") + return text + + +def _choice(value, where: str, allowed: Sequence[str]) -> str: + if not isinstance(value, str) or value.strip().lower() not in allowed: + raise ValueError(f"{where}: {value!r} is not one of {', '.join(allowed)}") + return value.strip().lower() + + +def _point(value, where: str) -> Pt: + if isinstance(value, dict): + if {"bulge", "arc", "center", "radius"} & set(value): + raise ValueError( + f"{where}: an arc segment is refused - a wall axis is straight segments in " + "this build (curved walls are out of 1.6); split the curve into straight walls" + ) + raise ValueError(f"{where}: expected [x, y], got {value!r}") + if isinstance(value, (str, bytes)) or not isinstance(value, (list, tuple)): + raise ValueError(f"{where}: expected [x, y], got {value!r}") + if len(value) == 3: + raise ValueError( + f"{where}: a third value is a bulge, and an arc segment is refused - a wall axis " + "is straight segments in this build (curved walls are out of 1.6)" + ) + if len(value) != 2: + raise ValueError(f"{where}: expected [x, y], got {value!r}") + return ( + _number(value[0], f"{where}[0]", positive=False), + _number(value[1], f"{where}[1]", positive=False), + ) + + +def _known(d, path: str, allowed: Sequence[str]) -> dict: + if not isinstance(d, dict): + raise ValueError(f"{path}: expected a dict, got {type(d).__name__}") + unknown = sorted(set(d) - set(allowed)) + if unknown: + raise ValueError( + f"{path}: unknown field(s) {', '.join(unknown)}; fields are {', '.join(allowed)}" + ) + return d + + +def _required(d: dict, key: str, path: str): + if key not in d or d[key] is None: + raise ValueError(f"{path}.{key}: required") + return d[key] + + +# -- builders ----------------------------------------------------------------- + +_WALL_FIELDS = ("id", "axis", "thickness", "justification", "material", "closed") +_OPENING_FIELDS = ( + "id", + "wall", + "kind", + "offset", + "width", + "swing", + "hand", + "sill", + "height", + "tag", +) +_STAIR_FIELDS = ( + "id", + "start", + "direction_deg", + "width", + "risers", + "riser_height", + "going", + "kind", + "turn", +) +_ROOM_FIELDS = ("id", "name", "number", "at") + + +def wall_from_dict(d: dict, path: str = "wall") -> Wall: + """A validated Wall. Refusals are ``ValueError(".: ")``.""" + _known(d, path, _WALL_FIELDS) + wall_id = _text(_required(d, "id", path), f"{path}.id") + raw_axis = _required(d, "axis", path) + if isinstance(raw_axis, (str, bytes, dict)) or not isinstance(raw_axis, (list, tuple)): + raise ValueError(f"{path}.axis: expected a list of [x, y] points, got {raw_axis!r}") + axis = tuple(_point(p, f"{path}.axis[{i}]") for i, p in enumerate(raw_axis)) + closed = d.get("closed", False) + if not isinstance(closed, bool): + raise ValueError(f"{path}.closed: expected true or false, got {closed!r}") + need = 3 if closed else 2 + if len(axis) < need: + raise ValueError( + f"{path}.axis: a {'closed ' if closed else ''}wall needs at least {need} points, " + f"got {len(axis)}" + ) + for i in range(1, len(axis)): + if math.dist(axis[i - 1], axis[i]) <= EPS: + raise ValueError( + f"{path}.axis[{i}]: repeats axis[{i - 1}] - a zero-length segment has no " + "direction to offset" + ) + if closed and math.dist(axis[-1], axis[0]) <= EPS: + raise ValueError( + f"{path}.axis[{len(axis) - 1}]: repeats axis[0] - closed=true already closes " + "the loop; drop the repeated point" + ) + thickness = _number(_required(d, "thickness", path), f"{path}.thickness") + justification = _choice( + d.get("justification", "center"), f"{path}.justification", JUSTIFICATIONS + ) + material = d.get("material", "brick") + if not isinstance(material, str) or normalise_material(material) not in WALL_HATCH_TABLE: + raise ValueError( + f"{path}.material: unknown wall material {material!r}; " + f"wall materials are {', '.join(WALL_MATERIALS)}" + ) + return Wall( + id=wall_id, + axis=axis, + thickness=thickness, + justification=justification, + material=normalise_material(material), + closed=closed, + ) + + +def opening_from_dict(d: dict, path: str = "opening") -> Opening: + _known(d, path, _OPENING_FIELDS) + sill = d.get("sill") + height = d.get("height") + tag = d.get("tag") + return Opening( + id=_text(_required(d, "id", path), f"{path}.id"), + wall=_text(_required(d, "wall", path), f"{path}.wall"), + kind=_choice(_required(d, "kind", path), f"{path}.kind", OPENING_KINDS), + offset=_number(_required(d, "offset", path), f"{path}.offset", allow_zero=True), + width=_number(_required(d, "width", path), f"{path}.width"), + swing=_choice(d.get("swing", "in"), f"{path}.swing", SWINGS), + hand=_choice(d.get("hand", "left"), f"{path}.hand", HANDS), + sill=None if sill is None else _number(sill, f"{path}.sill", allow_zero=True), + height=None if height is None else _number(height, f"{path}.height"), + tag=_text(tag, f"{path}.tag", optional=True), + ) + + +def stair_from_dict(d: dict, path: str = "stair") -> Stair: + _known(d, path, _STAIR_FIELDS) + risers = _required(d, "risers", path) + if isinstance(risers, bool) or not isinstance(risers, int): + if isinstance(risers, float) and risers.is_integer(): + risers = int(risers) + else: + raise ValueError(f"{path}.risers: expected a whole number of risers, got {risers!r}") + if risers < 2: + raise ValueError(f"{path}.risers: a stair needs at least 2 risers, got {risers}") + return Stair( + id=_text(_required(d, "id", path), f"{path}.id"), + start=_point(_required(d, "start", path), f"{path}.start"), + direction_deg=_number( + _required(d, "direction_deg", path), f"{path}.direction_deg", positive=False + ), + width=_number(_required(d, "width", path), f"{path}.width"), + risers=risers, + riser_height=_number(_required(d, "riser_height", path), f"{path}.riser_height"), + going=_number(_required(d, "going", path), f"{path}.going"), + kind=_choice(d.get("kind", "straight"), f"{path}.kind", STAIR_KINDS), + turn=_choice(d.get("turn", "left"), f"{path}.turn", TURNS), + ) + + +def room_from_dict(d: dict, path: str = "room") -> Room: + """A validated Room with ``area`` 0.0: the area is measured, never accepted.""" + if isinstance(d, dict) and "area" in d: + raise ValueError( + f"{path}.area: an area is measured from the face the walls enclose, never typed; " + "remove it" + ) + _known(d, path, _ROOM_FIELDS) + return Room( + id=_text(_required(d, "id", path), f"{path}.id"), + name=_text(_required(d, "name", path), f"{path}.name"), + number=_text(d.get("number"), f"{path}.number", optional=True), + at=_point(_required(d, "at", path), f"{path}.at"), + ) + + +# -- walls and hosted openings ------------------------------------------------- + + +def axis_segments(wall: Wall) -> list[tuple[Pt, Pt]]: + """The straight axis segments, the closing one included when the wall is closed.""" + pts = list(wall.axis) + segs = [(pts[i], pts[i + 1]) for i in range(len(pts) - 1)] + if wall.closed: + segs.append((pts[-1], pts[0])) + return segs + + +def wall_length(wall: Wall) -> float: + """The length of the axis, closing segment included when the wall is closed.""" + return sum(math.dist(a, b) for a, b in axis_segments(wall)) + + +def segment_of(wall: Wall, offset: float) -> tuple[int, float]: + """``(segment index, distance along it)`` for a distance along the axis. + + A distance that lands exactly on an axis vertex belongs to the segment that + *starts* there; the very end of the axis belongs to the last segment. + """ + where = float(offset) + total = wall_length(wall) + if not math.isfinite(where) or where < -EPS or where > total + EPS: + raise ValueError(f"offset {offset!r} is outside wall {wall.id} (axis length {total:g})") + run = 0.0 + segs = axis_segments(wall) + for index, (a, b) in enumerate(segs): + length = math.dist(a, b) + if where < run + length - EPS: + return index, max(0.0, where - run) + run += length + last = len(segs) - 1 + return last, math.dist(*segs[last]) + + +def _vertex_runs(wall: Wall) -> list[float]: + """Axis distances of the interior vertices (where one segment turns into the next).""" + runs: list[float] = [] + run = 0.0 + segs = axis_segments(wall) + for a, b in segs[:-1]: + run += math.dist(a, b) + runs.append(run) + return runs + + +def validate_openings(walls: Sequence[Wall], openings: Sequence[Opening]) -> None: + """Refuse an opening that its wall cannot host - before anything is drawn. + + Refused: two walls or two openings with one id; an opening on a wall that + is not in the plan; one that runs past the end of its wall's axis; one + that straddles an axis vertex (a jamb on each side of a corner); two + openings on one wall whose spans overlap - naming both ids. + """ + by_id: dict[str, Wall] = {} + for wall in walls: + if wall.id in by_id: + raise ValueError(f"walls: two walls share the id {wall.id!r}") + by_id[wall.id] = wall + seen: set[str] = set() + spans: dict[str, list[tuple[float, float, str]]] = {} + for opening in openings: + if opening.id in seen: + raise ValueError(f"openings: two openings share the id {opening.id!r}") + seen.add(opening.id) + wall = by_id.get(opening.wall) + if wall is None: + known = ", ".join(sorted(by_id)) or "none" + raise ValueError( + f"opening {opening.id}: wall {opening.wall!r} is not in the plan; walls are {known}" + ) + start = float(opening.offset) + end = start + float(opening.width) + total = wall_length(wall) + if end > total + EPS: + raise ValueError( + f"opening {opening.id}: runs past the end of wall {wall.id} - " + f"{start:g} + {float(opening.width):g} = {end:g} > axis length {total:g}" + ) + for run in _vertex_runs(wall): + if start + EPS < run < end - EPS: + raise ValueError( + f"opening {opening.id}: straddles the axis vertex of wall {wall.id} at " + f"{run:g} ({start:g}-{end:g}); an opening sits inside one straight segment" + ) + for other_start, other_end, other_id in spans.get(wall.id, ()): + if start < other_end - EPS and other_start < end - EPS: + raise ValueError( + f"openings {other_id} and {opening.id} overlap on wall {wall.id}: " + f"{other_start:g}-{other_end:g} and {start:g}-{end:g}" + ) + spans.setdefault(wall.id, []).append((start, end, opening.id)) + + +# -- the ACADMCP_ARCH payload -------------------------------------------------- + +_BY_KIND = {Wall: "wall", Opening: "opening", Stair: "stair", Room: "room"} + + +def _jsonable(value): + if isinstance(value, tuple): + return [_jsonable(v) for v in value] + return value + + +def to_payload(obj) -> dict: + """``{"v": 1, "kind": "wall"|"opening"|"stair"|"room", **fields}`` - JSON-ready. + + ``Opening.kind`` (door / window) and ``Stair.kind`` (straight / l / u) would + overwrite the payload's own ``kind``, so they travel as ``opening_kind`` and + ``stair_kind``; ``from_payload`` puts them back. + """ + kind = _BY_KIND.get(type(obj)) + if kind is None: + raise TypeError(f"to_payload: not a plan object: {type(obj).__name__}") + fields = {key: _jsonable(value) for key, value in asdict(obj).items()} + if "kind" in fields: + fields[f"{kind}_kind"] = fields.pop("kind") + return {"v": PAYLOAD_VERSION, "kind": kind, **fields} + + +def from_payload(payload: dict): + """The Wall / Opening / Stair / Room a payload describes, validated again.""" + if not isinstance(payload, dict): + raise ValueError(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 reads version {PAYLOAD_VERSION}" + ) + kind = payload.get("kind") + fields = {k: v for k, v in payload.items() if k not in ("v", "kind")} + if f"{kind}_kind" in fields: + fields["kind"] = fields.pop(f"{kind}_kind") + where = f"{APP_ID} {kind}" + if kind == "wall": + return wall_from_dict(fields, where) + if kind == "opening": + return opening_from_dict(fields, where) + if kind == "stair": + return stair_from_dict(fields, where) + if kind == "room": + area = _number(fields.pop("area", 0.0), f"{where}.area", allow_zero=True) + room = room_from_dict(fields, where) + return Room(id=room.id, name=room.name, number=room.number, at=room.at, area=area) + raise ValueError(f"{APP_ID}: unknown payload kind {kind!r}; kinds are {', '.join(KINDS)}") + + +def encode(payload: dict) -> list[tuple[int, str]]: + """The mechanical codec with ``app_id=ACADMCP_ARCH`` and the four plan kinds.""" + return _codec.encode(payload, app_id=APP_ID, kinds=KINDS) + + +def to_values(payload: dict) -> list[str]: + """Just the chunk strings - what ``entity_set_xdata(handle, APP_ID, values)`` takes.""" + return _codec.to_values(payload, app_id=APP_ID, kinds=KINDS) + + +def decode(rows: Sequence) -> dict: + """A payload read back from ``entity_get_xdata``; anything foreign is refused.""" + return _codec.decode(rows, app_id=APP_ID, kinds=KINDS) diff --git a/engineering/layers.py b/engineering/layers.py index 667066f..3185de2 100644 --- a/engineering/layers.py +++ b/engineering/layers.py @@ -120,7 +120,7 @@ async def apply_layer_set( backend: AutoCADBackend, standard: str = "mech", ) -> dict[str, str]: - """Idempotently apply a named layer set ('mech', 'pid', 'iso13567').""" + """Idempotently apply a named layer set ('mech', 'pid', 'iso13567', 'arch').""" layer_set = LAYER_SET_REGISTRY.get(standard) if layer_set is None: raise RuntimeError( @@ -150,3 +150,16 @@ async def apply_layer_set( except Exception as exc: results[name] = f"failed: {exc}" return results + + +# ── the architectural set (track F) ───────────────────────────────────────── +# The rows live with the plan model (engineering/arch/layers.py), where +# ARCH_ROLE_LAYER maps each role onto them. Registered last, after every name +# above exists: importing engineering.arch runs its package __init__, which +# pulls in the mechanical codec - and, once track F is merged, the wall engine +# - and those import from this module. A partially initialised +# engineering.layers must already hold every name they ask for. +from engineering.arch.layers import ARCH_LAYER_ROLES, ARCH_LAYERS # noqa: E402 + +LAYER_SET_REGISTRY["arch"] = ARCH_LAYERS +LAYER_ROLES["arch"] = dict(ARCH_LAYER_ROLES) diff --git a/engineering/mech/draw.py b/engineering/mech/draw.py index 7687234..81b1a1c 100644 --- a/engineering/mech/draw.py +++ b/engineering/mech/draw.py @@ -17,6 +17,7 @@ from __future__ import annotations import logging import math +from collections.abc import Mapping from typing import Any import anyio @@ -98,6 +99,49 @@ def _number(value, where: str, *, positive: bool = True) -> float: # -- primitives to entities -------------------------------------------------- +#: The keys a role map may carry for a HatchArea, which has no role of its own: +#: ``hatch`` (a general map) or ``wall_hatch`` (the architectural map, whose +#: only hatch is wall poche). The first one the map carries wins. +_HATCH_ROLES: tuple[str, ...] = ("hatch", "wall_hatch") + + +def _layer_of(prim, role_layer: Mapping[str, str] | None) -> str: + """The layer a primitive lands on: the mechanical ROLE_LAYER, or ``role_layer``.""" + if role_layer is None: + return layer_for(prim) + if isinstance(prim, HatchArea): + for key in _HATCH_ROLES: + if key in role_layer: + return role_layer[key] + raise ValueError( + "draw_prims: this role map has no hatch layer (a 'hatch' or 'wall_hatch' key); " + f"its roles are {', '.join(role_layer)}." + ) + role = getattr(prim, "role", "visible") + try: + return role_layer[role] + except KeyError: + raise ValueError( + f"draw_prims: unknown role {role!r}; this role map has {', '.join(role_layer)}." + ) from None + + +def _hatch_style(material: str, role_layer: Mapping[str, str] | None) -> dict: + """The pattern a HatchArea is filled with. + + The architectural map (the one that carries ``wall_hatch``) resolves wall + materials through ``engineering/arch/materials.py::wall_hatch`` - brick, + aac, reinforced concrete ... at plan scale. Every other map, and no map, + keeps the mechanical ISO 128-50 section map, so a mechanical caller is + unchanged. + """ + if role_layer is not None and "wall_hatch" in role_layer: + from engineering.arch.materials import wall_hatch + + return wall_hatch(material) + return hatch_for(material) + + async def draw_prims( backend, prims, @@ -105,11 +149,22 @@ async def draw_prims( at: tuple[float, float] = (0.0, 0.0), rotation: float = 0.0, layer_prefix: str = "", + role_layer: Mapping[str, str] | None = None, ) -> dict: - """Draw primitive descriptions, turned by ``rotation`` about their origin, then placed at ``at``.""" + """Draw primitive descriptions, turned by ``rotation`` about their origin, then placed at ``at``. + + ``role_layer`` maps ``Prim.role`` to a layer; ``None`` keeps the mechanical + ``ROLE_LAYER``. Every role and every hatch material is resolved - and every + target layer ensured - before the first entity, so an unknown role or + material refuses with nothing drawn. + """ placed = tuple(prims) _refuse_islands_without_edge_paths(backend, placed) - await _ensure_layers(backend, sorted({f"{layer_prefix}{layer_for(p)}" for p in placed})) + targets = [f"{layer_prefix}{_layer_of(p, role_layer)}" for p in placed] + for prim in placed: + if isinstance(prim, HatchArea): + _hatch_style(prim.material, role_layer) + await _ensure_layers(backend, sorted(set(targets))) if abs(float(rotation)) > 1e-12: placed = rotate(placed, float(rotation), (0.0, 0.0)) if abs(float(at[0])) > 1e-12 or abs(float(at[1])) > 1e-12: @@ -118,8 +173,7 @@ async def draw_prims( handles: list[str] = [] counts: dict[str, int] = {} - for prim in placed: - layer = f"{layer_prefix}{layer_for(prim)}" + for prim, layer in zip(placed, targets, strict=True): if isinstance(prim, Line): info = await backend.entity_create_line( prim.p1[0], prim.p1[1], prim.p2[0], prim.p2[1], layer=layer @@ -151,7 +205,7 @@ async def draw_prims( layer=layer, ) elif isinstance(prim, HatchArea): - style = hatch_for(prim.material) + style = _hatch_style(prim.material, role_layer) info = await backend.entity_create_hatch( style["pattern"], [[x, y] for x, y in prim.loops[0]], @@ -207,10 +261,32 @@ async def _ensure_layers(backend, names) -> tuple[str, ...]: Group A measured the same refusal for annotation layers; this is the same cure (``ensure_annotation_layers``) at the places every mechanical drawing path passes through. + + A layer of the ``arch`` set is created with its own ``ARCH_LAYERS`` row - + a grid on CENTER at 0.18 mm, a wall outline at 0.50 mm - rather than as a + bare white Continuous layer; every other name goes through + ``ensure_annotation_layers`` exactly as before. """ + from engineering.arch.layers import ARCH_LAYER_DEFS from engineering.mech.annotate import ensure_annotation_layers - return await ensure_annotation_layers(backend, [str(name) for name in names if name]) + wanted = [str(name) for name in names if name] + architectural = [name for name in wanted if name in ARCH_LAYER_DEFS] + created: list[str] = [] + if architectural: + existing = {lyr.name.lower() for lyr in await backend.layer_list()} + for name in architectural: + if name.lower() in existing: + continue + color, linetype, lineweight = ARCH_LAYER_DEFS[name] + await backend.layer_create( + name=name, color=color, linetype=linetype, lineweight=lineweight + ) + existing.add(name.lower()) + created.append(name) + rest = [name for name in wanted if name not in ARCH_LAYER_DEFS] + created.extend(await ensure_annotation_layers(backend, rest)) + return tuple(created) def _refuse_islands_without_edge_paths(backend, prims) -> None: diff --git a/engineering/mech/xdata.py b/engineering/mech/xdata.py index 915e572..58078de 100644 --- a/engineering/mech/xdata.py +++ b/engineering/mech/xdata.py @@ -15,6 +15,12 @@ written, and both the writer and every reader test them literally: {"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", ...]} + +The codec is shared: ``app_id=`` / ``kinds=`` let another application write +its own payloads through the same chunking and the same refusals (track F +writes ``ACADMCP_ARCH`` with the kinds wall / opening / stair / room through +`engineering/arch/model.py`). The defaults are the mechanical ones, so every +mechanical caller is unchanged. """ from __future__ import annotations @@ -30,46 +36,48 @@ CHUNK = 255 KINDS: tuple[str, ...] = ("part", "std_part", "balloon") -def _validate(payload: dict) -> dict: +def _validate(payload: dict, app_id: str, kinds: Sequence[str]) -> dict: if not isinstance(payload, dict): - raise TypeError(f"{APP_ID}: a payload must be a dict, got {type(payload).__name__}") + 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"{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)}") + 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) +def encode( + payload: dict, *, app_id: str = APP_ID, kinds: Sequence[str] = KINDS +) -> list[tuple[int, str]]: + """``[(1001, app_id), (1000, chunk), ...]`` — refused before anything is written.""" + checked = _validate(payload, app_id, kinds) text = json.dumps(checked, separators=(",", ":"), sort_keys=True, ensure_ascii=True) - rows: list[tuple[int, str]] = [(1001, APP_ID)] + 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]) + 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 " + 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 to_values(payload: dict, *, app_id: str = APP_ID, kinds: Sequence[str] = KINDS) -> list[str]: + """Just the chunk strings — what ``entity_set_xdata(handle, app_id, values)`` takes.""" + return [value for code, value in encode(payload, app_id=app_id, kinds=kinds) if code == 1000] -def decode(rows: Sequence) -> dict: +def decode(rows: Sequence, *, app_id: str = APP_ID, kinds: Sequence[str] = KINDS) -> 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") + raise ValueError(f"{app_id}: empty XDATA") parts: list[str] = [] for row in rows: if isinstance(row, str): @@ -78,20 +86,20 @@ def decode(rows: Sequence) -> dict: try: code, value = row except (TypeError, ValueError): - raise ValueError(f"{APP_ID}: {row!r} is not an XDATA (code, value) row") from None + raise ValueError(f"{app_id}: {row!r} is not an XDATA (code, value) row") from None if code == 1001: - if str(value) != APP_ID: + if str(value) != app_id: raise ValueError( - f"{APP_ID}: this XDATA belongs to application {value!r}, not to us" + 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") + 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 + 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) + raise ValueError(f"{app_id}: the payload decoded to {type(payload).__name__}, not a dict") + return _validate(payload, app_id, kinds) diff --git a/engineering/plan_spec.py b/engineering/plan_spec.py index b4253c1..f1d959c 100644 --- a/engineering/plan_spec.py +++ b/engineering/plan_spec.py @@ -11,7 +11,7 @@ from dataclasses import dataclass, field from typing import Literal SheetSize = Literal["A4", "A3", "A2", "A1", "A0"] -LayerSetId = Literal["iso13567", "mech", "pid"] +LayerSetId = Literal["iso13567", "mech", "pid", "arch"] DimStyle = Literal["chain", "baseline", "ordinate", "mixed"] CritiqueFocus = Literal[ "iso128", diff --git a/server.py b/server.py index 2778a5e..d6f0808 100644 --- a/server.py +++ b/server.py @@ -6138,13 +6138,19 @@ async def drawing_apply_iso_layers( str, Field( default="mech", - description="Layer set: mech (DIN/ISO mechanical), pid (P&ID), iso13567 (CAD layer naming).", + description=( + "Layer set: mech (DIN/ISO mechanical), pid (P&ID), iso13567 (CAD layer naming), " + "arch (ISO 13567-style architectural: walls, poche, doors, windows, stairs, " + "furniture, sanitary, grid, rooms, dimensions, symbols, overhead)." + ), ), ] = "mech", ctx: Context = None, ) -> dict: """Bootstrap a full ISO-conformant layer set with correct colors and lineweights. Idempotent — existing layers are not modified. + + Refused by name: a standard that is not one of mech, pid, iso13567, arch. """ return await _backend(ctx).drawing_apply_iso_layers(standard) diff --git a/tests/test_arch_foundation.py b/tests/test_arch_foundation.py new file mode 100644 index 0000000..451266e --- /dev/null +++ b/tests/test_arch_foundation.py @@ -0,0 +1,367 @@ +"""Track F's shared foundation: the arch layer set, the wall-material hatches, +EN/TR vocabulary, and the two generalisations of the mechanical layer +(`draw_prims(role_layer=)` and the XDATA codec's `app_id=` / `kinds=`). + +The mechanical callers are the regression gate for the two generalisations: +`tests/test_mech_draw.py`, `tests/test_mech_xdata.py` and the rest of the +mechanical suite run unchanged against the new signatures. +""" + +from __future__ import annotations + +import math + +import pytest +from ezdxf.tools import pattern as ezpattern + +from engineering.arch.lang import LANGS, fmt_area_m2, fmt_number, tag_prefix, vocab +from engineering.arch.layers import ARCH_LAYERS, ARCH_ROLE_LAYER +from engineering.arch.materials import WALL_HATCH_TABLE, WALL_MATERIALS, wall_hatch +from engineering.layers import LAYER_ROLES, LAYER_SET_REGISTRY, apply_layer_set, resolve_role_layer +from engineering.mech import xdata as codec +from engineering.mech.draw import draw_prims +from engineering.mech.primitives import HatchArea, Line, Text +from engineering.plan_spec import ISO_128_LINEWEIGHTS_MM + +# -- the layer set --------------------------------------------------------------- + +EXPECTED_LAYERS = { + "A-WALL-E-N": ("Continuous", 0.50), + "A-WALL-H-N": ("Continuous", 0.13), + "A-DOOR-E-N": ("Continuous", 0.25), + "A-WIND-E-N": ("Continuous", 0.25), + "A-STAIR-E-N": ("Continuous", 0.25), + "A-FURN-E-N": ("Continuous", 0.18), + "A-SANR-E-N": ("Continuous", 0.18), + "A-GRID-E-N": ("CENTER", 0.18), + "A-ROOM-T-N": ("Continuous", 0.25), + "A-DIMEN-T-N": ("Continuous", 0.18), + "A-SYMB-T-N": ("Continuous", 0.25), + "A-OVHD-E-N": ("HIDDEN", 0.18), + "A-CONST-E-N": ("Continuous", 0.05), +} + + +def test_the_arch_set_is_the_thirteen_pinned_layers_plus_zero(): + rows = {name: (linetype, lw) for name, _aci, linetype, lw, _d in ARCH_LAYERS} + assert rows.pop("0") == ("Continuous", 0.25) + assert rows == EXPECTED_LAYERS + + +def test_every_lineweight_but_the_construction_scratch_is_iso_128(): + for name, _aci, _lt, lineweight, _d in ARCH_LAYERS: + if "CONST" in name: + assert lineweight == 0.05 # skipped by the iso128 critique, cleared before finalize + continue + assert lineweight in ISO_128_LINEWEIGHTS_MM, name + + +def test_the_construction_layer_is_colour_250_like_every_other_set(): + colours = {name: aci for name, aci, _lt, _lw, _d in ARCH_LAYERS} + assert colours["A-CONST-E-N"] == 250 + + +def test_every_role_maps_onto_a_layer_of_the_set(): + names = {row[0] for row in ARCH_LAYERS} + assert set(ARCH_ROLE_LAYER.values()) <= names + assert ARCH_ROLE_LAYER == { + "wall": "A-WALL-E-N", + "wall_hatch": "A-WALL-H-N", + "door": "A-DOOR-E-N", + "window": "A-WIND-E-N", + "stair": "A-STAIR-E-N", + "furniture": "A-FURN-E-N", + "sanitary": "A-SANR-E-N", + "grid": "A-GRID-E-N", + "room": "A-ROOM-T-N", + "dim": "A-DIMEN-T-N", + "symbol": "A-SYMB-T-N", + "overhead": "A-OVHD-E-N", + } + + +def test_the_set_is_registered_with_its_dimension_and_construction_roles(): + assert LAYER_SET_REGISTRY["arch"] is ARCH_LAYERS + assert LAYER_ROLES["arch"] == {"dim": "A-DIMEN-T-N", "construction": "A-CONST-E-N"} + assert resolve_role_layer("arch", "dim") == "A-DIMEN-T-N" + assert resolve_role_layer("arch", "construction") == "A-CONST-E-N" + + +@pytest.mark.asyncio +async def test_apply_layer_set_arch_creates_every_layer_with_its_row(backend): + result = await apply_layer_set(backend, "arch") + assert all(result[name] == "created" for name in EXPECTED_LAYERS) + layers = {lyr.name: lyr for lyr in await backend.layer_list()} + assert layers["A-GRID-E-N"].linetype.upper() == "CENTER" + assert layers["A-OVHD-E-N"].linetype.upper() == "HIDDEN" + assert layers["A-CONST-E-N"].color == 250 + + +# -- wall materials -------------------------------------------------------------- + + +def _period(name: str) -> float: + """The finest perpendicular line period of a pattern at scale 1 (ezdxf ISO set).""" + periods = [] + for angle, _base, offset, _dashes in ezpattern.load(measurement=1)[name]: + rad = math.radians(angle) + period = abs(-offset[0] * math.sin(rad) + offset[1] * math.cos(rad)) + if period > 1e-9: + periods.append(period) + return min(periods) + + +def test_the_eight_pinned_materials_each_have_one_row(): + assert WALL_MATERIALS == ( + "brick", + "aac", + "concrete", + "reinforced_concrete", + "gypsum_board", + "stone", + "timber", + "generic", + ) + assert set(WALL_HATCH_TABLE) == set(WALL_MATERIALS) + + +def test_wall_hatch_returns_exactly_pattern_angle_scale(): + assert wall_hatch("brick") == {"pattern": "ANSI31", "angle": 0.0, "scale": 20.0} + assert wall_hatch("Reinforced Concrete")["pattern"] == "JIS_RC_15" + + +def test_an_unknown_material_is_refused_with_the_list(): + with pytest.raises( + ValueError, match=r"unknown wall material 'adobe'; wall materials are brick, aac" + ): + wall_hatch("adobe") + + +def test_every_pattern_exists_in_both_ezdxf_measurement_sets(): + """ezdxf silently falls back to ANSI31 for a pattern it does not know.""" + iso = ezpattern.load(measurement=1) + imperial = ezpattern.load(measurement=0) + for material in WALL_MATERIALS: + name = wall_hatch(material)["pattern"] + assert name in iso and name in imperial, material + + +def test_the_periods_on_a_1_to_50_sheet_are_the_documented_ones(): + """Recomputed from ezdxf's own definitions: period x scale / 50 = paper mm.""" + expected_paper_mm = { + "brick": 1.27, + "aac": 2.54, + "reinforced_concrete": 3.0, + "stone": 1.5, + "timber": 1.273, + "generic": 1.27, + } + for material, paper in expected_paper_mm.items(): + row = wall_hatch(material) + assert round(_period(row["pattern"]) * row["scale"] / 50.0, 3) == paper, material + + +def test_aac_is_the_lighter_diagonal_of_brick(): + assert wall_hatch("aac")["pattern"] == wall_hatch("brick")["pattern"] + assert wall_hatch("aac")["scale"] == 2 * wall_hatch("brick")["scale"] + + +def test_every_row_states_where_its_symbol_comes_from(): + for material in WALL_MATERIALS: + source = WALL_HATCH_TABLE[material]["source"] + assert any( + token in source for token in ("ISO 128-50", "JIS A 0150", "acadiso.pat", "convention") + ) + + +# -- language -------------------------------------------------------------------- + + +def test_both_languages_carry_the_same_keys(): + assert LANGS == ("en", "tr") + assert ( + set(vocab("en")) + == set(vocab("tr")) + == { + "door_schedule", + "window_schedule", + "room_schedule", + "tag", + "width", + "height", + "sill", + "swing", + "hand", + "wall", + "number", + "name", + "area", + "total", + "up", + "down", + "north", + "level", + "in", + "out", + "left", + "right", + } + ) + assert vocab("tr")["up"] == "ÇIKIŞ" + assert vocab("en")["up"] == "UP" + + +def test_tag_prefixes(): + assert (tag_prefix("door", "en"), tag_prefix("window", "en")) == ("D", "W") + assert (tag_prefix("door", "tr"), tag_prefix("window", "tr")) == ("K", "P") + + +def test_turkish_switches_the_decimal_separator_to_a_comma(): + assert fmt_number(24.5, "en") == "24.50" + assert fmt_number(24.5, "tr") == "24,50" + assert fmt_number(-0.001, "en") == "0.00" + assert fmt_area_m2(24_500_000.0, "en") == "24.50 m²" + assert fmt_area_m2(24_500_000.0, "tr") == "24,50 m²" + assert fmt_area_m2(20_000_000.0, "en") == "20.00 m²" + + +def test_an_unknown_language_is_refused_with_the_list(): + for call in ( + lambda: vocab("de"), + lambda: fmt_number(1.0, "de"), + lambda: tag_prefix("door", "fr"), + ): + with pytest.raises( + ValueError, match=r"lang: unknown language '(de|fr)'; languages are en, tr" + ): + call() + + +def test_a_negative_area_is_refused(): + with pytest.raises(ValueError, match=r"area: must be finite and not negative"): + fmt_area_m2(-1.0, "en") + + +# -- draw_prims(role_layer=) --------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_a_role_map_puts_each_role_on_its_architectural_layer(backend): + square = ((0.0, 0.0), (200.0, 0.0), (200.0, 1000.0), (0.0, 1000.0)) + result = await draw_prims( + backend, + [ + Line((0.0, 0.0), (200.0, 0.0), "wall"), + Line((0.0, 0.0), (0.0, 900.0), "door"), + Text((500.0, 500.0), "LIVING", 125.0, 0.0, "room"), + HatchArea((square,), "brick"), + ], + role_layer=ARCH_ROLE_LAYER, + ) + assert len(result["handles"]) == 4 + placed = {(e.type, e.layer) for e in await backend.entity_list(limit=100)} + assert placed == { + ("LINE", "A-WALL-E-N"), + ("LINE", "A-DOOR-E-N"), + ("TEXT", "A-ROOM-T-N"), + ("HATCH", "A-WALL-H-N"), + } + + +@pytest.mark.asyncio +async def test_a_wall_hatch_takes_the_wall_material_pattern(backend): + square = ((0.0, 0.0), (200.0, 0.0), (200.0, 1000.0), (0.0, 1000.0)) + await draw_prims( + backend, [HatchArea((square,), "reinforced_concrete")], role_layer=ARCH_ROLE_LAYER + ) + (hatch,) = await backend.entity_list(type_filter="HATCH", limit=10) + entity = backend._doc.entitydb[hatch.handle] + assert entity.dxf.pattern_name == "JIS_RC_15" + assert math.isclose(entity.dxf.pattern_scale, 10.0) + + +@pytest.mark.asyncio +async def test_missing_architectural_layers_are_created_with_their_row(backend): + await draw_prims(backend, [Line((0.0, 0.0), (1000.0, 0.0), "grid")], role_layer=ARCH_ROLE_LAYER) + layers = {lyr.name: lyr for lyr in await backend.layer_list()} + grid = layers["A-GRID-E-N"] + assert grid.linetype.upper() == "CENTER" + assert grid.color == 1 + + +@pytest.mark.asyncio +async def test_an_unknown_role_is_refused_before_anything_is_drawn(backend): + with pytest.raises( + ValueError, match=r"draw_prims: unknown role 'visible'; this role map has wall" + ): + await draw_prims( + backend, + [Line((0.0, 0.0), (1.0, 0.0), "wall"), Line((0.0, 0.0), (1.0, 0.0), "visible")], + role_layer=ARCH_ROLE_LAYER, + ) + assert await backend.entity_list(limit=10) == [] + + +@pytest.mark.asyncio +async def test_an_unknown_wall_material_is_refused_before_anything_is_drawn(backend): + square = ((0.0, 0.0), (200.0, 0.0), (200.0, 1000.0), (0.0, 1000.0)) + with pytest.raises(ValueError, match=r"unknown wall material 'steel'"): + await draw_prims( + backend, + [Line((0.0, 0.0), (1.0, 0.0), "wall"), HatchArea((square,), "steel")], + role_layer=ARCH_ROLE_LAYER, + ) + assert await backend.entity_list(limit=10) == [] + + +@pytest.mark.asyncio +async def test_without_a_role_map_the_mechanical_layers_and_materials_are_unchanged(backend): + square = ((0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)) + await draw_prims(backend, [Line((0.0, 0.0), (1.0, 0.0)), HatchArea((square,), "steel")]) + layers = {e.layer for e in await backend.entity_list(limit=10)} + assert layers == {"GEOMETRY", "HATCH"} + (hatch,) = await backend.entity_list(type_filter="HATCH", limit=10) + assert backend._doc.entitydb[hatch.handle].dxf.pattern_name == "ANSI31" + + +# -- the XDATA codec's app_id / kinds ---------------------------------------------- + + +def test_the_codec_defaults_are_still_the_mechanical_ones(): + part = {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": {}} + assert codec.encode(part)[0] == (1001, "ACADMCP_MECH") + assert codec.decode(codec.to_values(part)) == part + + +def test_the_codec_writes_and_reads_another_application(): + payload = {"v": 1, "kind": "thing", "n": 3} + rows = codec.encode(payload, app_id="ACADMCP_TEST", kinds=("thing",)) + assert rows[0] == (1001, "ACADMCP_TEST") + assert codec.decode(rows, app_id="ACADMCP_TEST", kinds=("thing",)) == payload + with pytest.raises( + ValueError, match=r"ACADMCP_TEST: unknown payload kind 'part'; kinds are thing" + ): + codec.encode({"v": 1, "kind": "part"}, app_id="ACADMCP_TEST", kinds=("thing",)) + with pytest.raises( + ValueError, match=r"ACADMCP_MECH: this XDATA belongs to application 'ACADMCP_TEST'" + ): + codec.decode(rows) + + +# -- the tool ------------------------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_drawing_apply_iso_layers_takes_arch(): + from fastmcp import Client + + import server + + async with Client(server.mcp) as client: + await client.call_tool("drawing_new", {}) + result = ( + await client.call_tool("drawing_apply_iso_layers", {"standard": "arch"}) + ).structured_content + assert result["standard"] == "arch" + assert result["layers"]["A-WALL-E-N"] == "created" + assert result["layers"]["A-GRID-E-N"] == "created" diff --git a/tests/test_arch_model.py b/tests/test_arch_model.py new file mode 100644 index 0000000..64555fb --- /dev/null +++ b/tests/test_arch_model.py @@ -0,0 +1,312 @@ +"""The plan model: every refusal of spec §4, by path, and the ACADMCP_ARCH round trip. + +Pure: no backend. A refusal is a ``ValueError`` whose message starts with the +path of the offending field, so a caller of ``arch_plan_from_spec`` is told +``walls[2].thickness`` and not "invalid wall". +""" + +from __future__ import annotations + +import pytest + +from engineering.arch.model import ( + APP_ID, + KINDS, + Opening, + Room, + Stair, + Wall, + decode, + encode, + from_payload, + opening_from_dict, + room_from_dict, + segment_of, + stair_from_dict, + to_payload, + to_values, + validate_openings, + wall_from_dict, + wall_length, +) + +L_WALL = {"id": "W1", "axis": [[0, 0], [5000, 0], [5000, 4000]], "thickness": 200} + + +def _wall(**over) -> Wall: + return wall_from_dict({**L_WALL, **over}, "walls[0]") + + +def _door(**over) -> Opening: + base = {"id": "D1", "wall": "W1", "kind": "door", "offset": 1000, "width": 900} + return opening_from_dict({**base, **over}, "openings[0]") + + +# -- walls ----------------------------------------------------------------------- + + +def test_a_wall_reads_its_defaults_and_normalises_its_axis(): + wall = _wall() + assert wall == Wall( + id="W1", + axis=((0.0, 0.0), (5000.0, 0.0), (5000.0, 4000.0)), + thickness=200.0, + justification="center", + material="brick", + closed=False, + ) + assert wall_length(wall) == 9000.0 + + +@pytest.mark.parametrize( + ("value", "reason"), + [ + (-200, "must be greater than zero"), + (0, "must be greater than zero"), + (float("nan"), "must be finite"), + (float("inf"), "must be finite"), + ("thick", "expected a number"), + (True, "expected a number"), + ], +) +def test_a_thickness_that_is_not_a_finite_positive_number_is_refused_by_path(value, reason): + with pytest.raises(ValueError, match=rf"^walls\[2\]\.thickness: {reason}"): + wall_from_dict({**L_WALL, "thickness": value}, "walls[2]") + + +def test_an_axis_needs_two_points(): + with pytest.raises( + ValueError, match=r"^walls\[0\]\.axis: a wall needs at least 2 points, got 1" + ): + _wall(axis=[[0, 0]]) + + +def test_a_closed_axis_needs_three_points(): + with pytest.raises(ValueError, match=r"^walls\[0\]\.axis: a closed wall needs at least 3"): + _wall(axis=[[0, 0], [10, 0]], closed=True) + + +def test_a_zero_length_segment_is_refused_at_the_repeated_point(): + with pytest.raises(ValueError, match=r"^walls\[0\]\.axis\[2\]: repeats axis\[1\]"): + _wall(axis=[[0, 0], [5000, 0], [5000, 0], [5000, 4000]]) + + +def test_a_closed_axis_that_repeats_its_first_point_is_refused(): + with pytest.raises(ValueError, match=r"^walls\[0\]\.axis\[3\]: repeats axis\[0\]"): + _wall(axis=[[0, 0], [10, 0], [10, 10], [0, 0]], closed=True) + + +def test_an_arc_segment_is_refused_by_name_not_approximated(): + with pytest.raises(ValueError, match=r"^walls\[0\]\.axis\[1\]: .*arc segment is refused"): + _wall(axis=[[0, 0], [5000, 0, 0.4142], [5000, 4000]]) + with pytest.raises(ValueError, match=r"^walls\[0\]\.axis\[1\]: an arc segment is refused"): + _wall(axis=[[0, 0], {"center": [2500, 0], "radius": 2500}, [5000, 4000]]) + + +def test_an_unknown_material_is_refused_with_the_list(): + with pytest.raises( + ValueError, match=r"^walls\[0\]\.material: unknown wall material 'adobe'" + ) as err: + _wall(material="adobe") + assert "brick, aac, concrete, reinforced_concrete" in str(err.value) + + +def test_a_material_is_read_with_the_spelling_wall_hatch_accepts(): + """The model must not refuse a name the hatch table resolves (spaces, hyphens, case).""" + assert _wall(material="Reinforced Concrete").material == "reinforced_concrete" + assert _wall(material="gypsum-board").material == "gypsum_board" + + +def test_an_unknown_justification_and_an_unknown_field_are_refused(): + with pytest.raises(ValueError, match=r"^walls\[0\]\.justification: 'middle' is not one of"): + _wall(justification="middle") + with pytest.raises(ValueError, match=r"^walls\[0\]: unknown field\(s\) thicknes"): + wall_from_dict({**L_WALL, "thicknes": 200}, "walls[0]") + + +# -- openings -------------------------------------------------------------------- + + +def test_an_opening_reads_its_defaults(): + assert _door() == Opening( + id="D1", wall="W1", kind="door", offset=1000.0, width=900.0, swing="in", hand="left" + ) + + +def test_an_opening_needs_a_positive_width_and_a_non_negative_offset(): + with pytest.raises(ValueError, match=r"^openings\[0\]\.width: must be greater than zero"): + _door(width=0) + with pytest.raises(ValueError, match=r"^openings\[0\]\.offset: must not be negative"): + _door(offset=-1) + assert _door(offset=0).offset == 0.0 + + +def test_an_opening_on_a_wall_that_is_not_in_the_plan_is_refused(): + with pytest.raises(ValueError, match=r"opening D1: wall 'W9' is not in the plan; walls are W1"): + validate_openings([_wall()], [_door(wall="W9")]) + + +def test_an_opening_that_runs_past_its_wall_is_refused(): + with pytest.raises( + ValueError, match=r"opening D1: runs past the end of wall W1 .* 9500 > axis length 9000" + ): + validate_openings([_wall()], [_door(offset=8600)]) + + +def test_an_opening_that_straddles_an_axis_vertex_is_refused(): + # The axis turns at 5000 mm; 4500-5400 has a jamb on each side of the corner. + with pytest.raises( + ValueError, match=r"opening D1: straddles the axis vertex of wall W1 at 5000" + ): + validate_openings([_wall()], [_door(offset=4500)]) + + +def test_an_opening_may_end_exactly_at_a_vertex(): + validate_openings([_wall()], [_door(offset=4100, width=900)]) + + +def test_two_overlapping_openings_are_refused_naming_both_ids(): + door = _door() + window = opening_from_dict( + {"id": "W2", "wall": "W1", "kind": "window", "offset": 1500, "width": 1200}, "openings[1]" + ) + with pytest.raises( + ValueError, match=r"openings D1 and W2 overlap on wall W1: 1000-1900 and 1500-2700" + ): + validate_openings([_wall()], [door, window]) + + +def test_two_openings_that_only_touch_are_accepted(): + window = opening_from_dict( + {"id": "W2", "wall": "W1", "kind": "window", "offset": 1900, "width": 1200}, "openings[1]" + ) + validate_openings([_wall()], [_door(), window]) + + +def test_duplicate_ids_are_refused(): + with pytest.raises(ValueError, match=r"walls: two walls share the id 'W1'"): + validate_openings([_wall(), _wall()], []) + with pytest.raises(ValueError, match=r"openings: two openings share the id 'D1'"): + validate_openings([_wall()], [_door(), _door(offset=3000)]) + + +def test_segment_of_finds_the_segment_and_the_distance_along_it(): + wall = _wall() + assert segment_of(wall, 0.0) == (0, 0.0) + assert segment_of(wall, 1000.0) == (0, 1000.0) + assert segment_of(wall, 5000.0) == (1, 0.0) # a vertex belongs to the segment it starts + assert segment_of(wall, 6500.0) == (1, 1500.0) + assert segment_of(wall, 9000.0) == (1, 4000.0) # the very end belongs to the last one + with pytest.raises(ValueError, match=r"offset 9001.0 is outside wall W1 \(axis length 9000\)"): + segment_of(wall, 9001.0) + + +def test_a_closed_wall_counts_its_closing_segment(): + ring = wall_from_dict( + { + "id": "R", + "axis": [[0, 0], [4000, 0], [4000, 3000], [0, 3000]], + "thickness": 200, + "closed": True, + }, + "walls[0]", + ) + assert wall_length(ring) == 14000.0 + assert segment_of(ring, 12000.0) == (3, 1000.0) + + +# -- stairs and rooms ------------------------------------------------------------ + + +STAIR = { + "id": "S1", + "start": [1000, 500], + "direction_deg": 90, + "width": 1000, + "risers": 16, + "riser_height": 175, + "going": 280, +} + + +def test_a_stair_reads_its_defaults(): + assert stair_from_dict(STAIR, "stairs[0]") == Stair( + id="S1", + start=(1000.0, 500.0), + direction_deg=90.0, + width=1000.0, + risers=16, + riser_height=175.0, + going=280.0, + kind="straight", + turn="left", + ) + + +@pytest.mark.parametrize( + ("over", "message"), + [ + ({"risers": 1}, r"^stairs\[0\]\.risers: a stair needs at least 2 risers, got 1"), + ({"risers": 15.5}, r"^stairs\[0\]\.risers: expected a whole number of risers"), + ({"going": -280}, r"^stairs\[0\]\.going: must be greater than zero"), + ({"riser_height": float("nan")}, r"^stairs\[0\]\.riser_height: must be finite"), + ({"kind": "spiral"}, r"^stairs\[0\]\.kind: 'spiral' is not one of straight, l, u"), + ], +) +def test_a_stair_refuses_by_path(over, message): + with pytest.raises(ValueError, match=message): + stair_from_dict({**STAIR, **over}, "stairs[0]") + + +def test_a_room_refuses_a_typed_area(): + with pytest.raises(ValueError, match=r"^rooms\[0\]\.area: an area is measured .* never typed"): + room_from_dict( + {"id": "R1", "name": "LIVING", "at": [2000, 2000], "area": 20.0e6}, "rooms[0]" + ) + + +def test_a_room_reads_a_number_given_as_an_integer(): + room = room_from_dict({"id": "R1", "name": "LIVING", "number": 101, "at": [2000, 2000]}) + assert room == Room(id="R1", name="LIVING", number="101", at=(2000.0, 2000.0), area=0.0) + + +# -- the ACADMCP_ARCH payload ---------------------------------------------------- + + +def test_the_codec_is_the_mechanical_one_under_our_own_application_id(): + assert APP_ID == "ACADMCP_ARCH" + assert KINDS == ("wall", "opening", "stair", "room") + rows = encode(to_payload(_wall())) + assert rows[0] == (1001, "ACADMCP_ARCH") + assert all(code == 1000 and len(value) <= 255 for code, value in rows[1:]) + + +@pytest.mark.parametrize( + "obj", + [ + _wall(material="aac", justification="left"), + _door(tag="D1", height=2100), + stair_from_dict(STAIR), + Room(id="R1", name="MUTFAK", number="2", at=(1.0, 2.0), area=12.5e6), + ], +) +def test_every_plan_object_survives_the_drawing(obj): + payload = decode(to_values(to_payload(obj))) + assert payload["kind"] in KINDS and payload["v"] == 1 + assert from_payload(payload) == obj + + +def test_an_element_kind_travels_beside_the_payload_kind(): + payload = to_payload(_door()) + assert payload["kind"] == "opening" and payload["opening_kind"] == "door" + assert to_payload(stair_from_dict(STAIR))["stair_kind"] == "straight" + + +def test_a_mechanical_payload_is_not_ours(): + from engineering.mech.xdata import to_values as mech_values + + part = {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": {}} + with pytest.raises(ValueError, match=r"ACADMCP_ARCH: unknown payload kind 'part'"): + decode(mech_values(part)) + with pytest.raises(ValueError, match=r"belongs to application 'ACADMCP_MECH'"): + decode([(1001, "ACADMCP_MECH"), (1000, "{}")])