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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-23 22:27:00 +03:00
co-authored by Claude Opus 5.5
parent b012fdf1be
commit d5c5e90406
12 changed files with 1657 additions and 32 deletions
+66
View File
@@ -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",
]
+120
View File
@@ -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²"
+66
View File
@@ -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
}
+125
View File
@@ -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"]}
+466
View File
@@ -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("<path>.<field>: <reason>")``."""
_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)