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, "{}")])