diff --git a/README.md b/README.md
index 6320f44..2020760 100644
--- a/README.md
+++ b/README.md
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
-> **v1.5 release snapshot:** 229 tools · 8 resources · 5 prompt templates · 4005 collected tests.
-> 229 is the **registered** count; a default install advertises 224 over `tools/list`,
+> **v1.5 release snapshot:** 233 tools · 8 resources · 5 prompt templates · 4005 collected tests.
+> 233 is the **registered** count; a default install advertises 228 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
## Why this exists
@@ -120,7 +120,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
-229 tools in 25 groups; TOOL_PACKS=core hides the nine pid_* tools, the 22 environment tools and the 14 mechanical tools (TOOL_PACKS=core,mech keeps the last) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates.
+233 tools in 26 groups; TOOL_PACKS=core hides the nine pid_* tools, the 22 environment tools and the 14 mechanical tools (TOOL_PACKS=core,mech keeps the last) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates.
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
diff --git a/discovery/aliases.py b/discovery/aliases.py
index 5099993..e07703f 100644
--- a/discovery/aliases.py
+++ b/discovery/aliases.py
@@ -2230,6 +2230,80 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"punta deligi",
),
),
+ # ── Architecture (SECTION 25, track F group W) ──────────────────────────
+ "arch_wall": ToolAliases(
+ acad=("WALLADD", "MLINE"),
+ synonyms=(
+ "draw a wall",
+ "wall with a thickness",
+ "exterior wall",
+ "partition wall",
+ "floor plan walls",
+ "clean wall corners",
+ "t junction of walls",
+ "wall poche",
+ "duvar",
+ "duvar çiz",
+ "duvar ciz",
+ "bölme duvar",
+ "bolme duvar",
+ "kat planı",
+ "kat plani",
+ ),
+ ),
+ "arch_opening": ToolAliases(
+ acad=("DOORADD", "WINDOWADD"),
+ synonyms=(
+ "add a door",
+ "door in a wall",
+ "door swing",
+ "add a window",
+ "window in a wall",
+ "cut an opening in the wall",
+ "kapı",
+ "kapi",
+ "kapı ekle",
+ "pencere",
+ "pencere ekle",
+ "doğrama",
+ "dograma",
+ ),
+ ),
+ "arch_stair": ToolAliases(
+ acad=("STAIRADD",),
+ synonyms=(
+ "draw a stair",
+ "staircase",
+ "flight of stairs",
+ "l-shaped stair",
+ "u-shaped stair",
+ "walking line",
+ "blondel rule",
+ "risers and treads",
+ "merdiven",
+ "merdiven çiz",
+ "merdiven ciz",
+ "basamak",
+ "rıht",
+ "riht",
+ ),
+ ),
+ "arch_dimension_chains": ToolAliases(
+ acad=(),
+ synonyms=(
+ "dimension the facade",
+ "exterior dimensions",
+ "dimension chains",
+ "overall building dimensions",
+ "opening dimensions on the plan",
+ "ölçülendir",
+ "olculendir plan",
+ "dış ölçü",
+ "dis olcu",
+ "aks ölçüsü",
+ "aks olcusu",
+ ),
+ ),
}
diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json
index 50d7a81..47a999b 100644
--- a/docs/tool-inventory.json
+++ b/docs/tool-inventory.json
@@ -3,13 +3,14 @@
"schema_version": 1,
"package_version": "1.5.1",
"totals": {
- "tools": 229,
+ "tools": 233,
"resources": 8,
"prompts": 5,
- "groups": 25
+ "groups": 26
},
"groups": {
"analysis": 12,
+ "architecture": 4,
"batch": 3,
"blocks": 9,
"corner_ops": 4,
@@ -180,6 +181,62 @@
"readOnlyHint": true
}
},
+ {
+ "name": "arch_dimension_chains",
+ "group": "architecture",
+ "cost": "mutate",
+ "summary": "Dimension the plan's facades: openings, walls and overall chains outside each side.",
+ "tags": [
+ "arch",
+ "dimension"
+ ],
+ "annotations": {
+ "title": "Architecture: Dimension Chains",
+ "destructiveHint": false
+ }
+ },
+ {
+ "name": "arch_opening",
+ "group": "architecture",
+ "cost": "mutate",
+ "summary": "Host a door or a window in a drawn wall by distance along it; the wall is cut.",
+ "tags": [
+ "arch",
+ "create"
+ ],
+ "annotations": {
+ "title": "Architecture: Door / Window",
+ "destructiveHint": false
+ }
+ },
+ {
+ "name": "arch_stair",
+ "group": "architecture",
+ "cost": "mutate",
+ "summary": "Draw a straight, L or U stair in plan and report its Blondel step rule.",
+ "tags": [
+ "arch",
+ "create"
+ ],
+ "annotations": {
+ "title": "Architecture: Stair",
+ "destructiveHint": false
+ }
+ },
+ {
+ "name": "arch_wall",
+ "group": "architecture",
+ "cost": "mutate",
+ "summary": "Add walls to the plan: clean L/T/X junctions, cut openings, material poché.",
+ "tags": [
+ "arch",
+ "create"
+ ],
+ "annotations": {
+ "title": "Architecture: Wall",
+ "destructiveHint": false
+ }
+ },
{
"name": "balloon_add",
"group": "sheet",
diff --git a/engineering/arch/__init__.py b/engineering/arch/__init__.py
index 2c2bc00..54238a8 100644
--- a/engineering/arch/__init__.py
+++ b/engineering/arch/__init__.py
@@ -6,6 +6,13 @@ and alphabetical - keep every side's names.
from __future__ import annotations
+from .dimension import (
+ FIRST_OFFSET_PAPER_MM,
+ ROWS,
+ SIDES,
+ STEP_PAPER_MM,
+ exterior_chains,
+)
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
@@ -58,12 +65,16 @@ __all__ = [
"ARCH_ROLE_LAYER",
"ARROW_PAPER_MM",
"BLONDEL_RANGE",
+ "FIRST_OFFSET_PAPER_MM",
"KINDS",
"LANGS",
"MIN_JOIN_ANGLE_DEG",
"Opening",
+ "ROWS",
"Room",
+ "SIDES",
"STAIR_KINDS",
+ "STEP_PAPER_MM",
"Stair",
"TEXT_PAPER_MM",
"TURNS",
@@ -75,6 +86,7 @@ __all__ = [
"blondel",
"decode",
"encode",
+ "exterior_chains",
"face_offsets",
"fmt_area_m2",
"fmt_number",
diff --git a/engineering/arch/dimension.py b/engineering/arch/dimension.py
new file mode 100644
index 0000000..fbd8b05
--- /dev/null
+++ b/engineering/arch/dimension.py
@@ -0,0 +1,208 @@
+"""Exterior dimension chains: openings, walls, overall - three rows per side.
+
+An architectural plan carries its dimensions outside the building, in rows
+parallel to each facade, nearest first:
+
+1. **openings** - the facade's extremes and both jambs of every opening in the
+ exterior wall on that side;
+2. **walls** - the extremes and both faces of every wall that meets that
+ exterior wall, so the chain reads wall thickness, clear room width, wall
+ thickness, ...;
+3. **overall** - the building's extreme faces.
+
+The "exterior wall on a side" is every axis segment parallel to that side
+whose outer face no other parallel segment overlapping it lies beyond. Points
+come from the wall engine's own faces, so a chain can never disagree with the
+outline it measures.
+
+ISO 129-1: each measurement appears once. A row whose points are exactly those
+of a farther row (a facade with no opening, a side no wall meets) is not
+emitted; the rows that are keep their places, so the overall row is always at
+the same distance.
+
+Offsets are paper millimetres times the plot-scale denominator: the first row
+10 mm from the building and 8 mm between rows. Those two numbers are this
+repository's drafting defaults, not standard values; pass ``first_offset`` /
+``step`` in drawing units to override them.
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Sequence
+
+from engineering.arch.model import Wall, segment_of
+from engineering.arch.walls import face_offsets, wall_geometry, wall_segments
+from engineering.mech.primitives import DimIntent
+
+SIDES: tuple[str, ...] = ("bottom", "top", "left", "right")
+ROWS: tuple[str, ...] = ("openings", "walls", "overall")
+FIRST_OFFSET_PAPER_MM = 10.0
+STEP_PAPER_MM = 8.0
+_TOL = 1.0
+
+
+def _positive(value, where: str) -> float:
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ raise ValueError(f"{where}: expected a number, got {value!r}")
+ number = float(value)
+ if not math.isfinite(number) or number <= 0.0:
+ raise ValueError(f"{where}: must be a finite number above zero, got {value!r}")
+ return number
+
+
+def _sides(sides) -> tuple[str, ...]:
+ if isinstance(sides, str) or not isinstance(sides, (list, tuple)) or not sides:
+ raise ValueError(f"sides: name at least one of {', '.join(SIDES)}, got {sides!r}")
+ chosen: list[str] = []
+ for i, side in enumerate(sides):
+ if side not in SIDES:
+ raise ValueError(f"sides[{i}]: {side!r}; sides are {', '.join(SIDES)}")
+ if side in chosen:
+ raise ValueError(f"sides[{i}]: {side!r} is named twice")
+ chosen.append(side)
+ return tuple(chosen)
+
+
+def _unit(a, b) -> tuple[float, float]:
+ length = math.dist(a, b)
+ return ((b[0] - a[0]) / length, (b[1] - a[1]) / length)
+
+
+def _exterior(walls: Sequence[Wall], ai: int, sign: float) -> list[dict]:
+ """The segments parallel to a side whose outer face nothing parallel lies beyond."""
+ ci = 1 - ai
+ parallel: list[dict] = []
+ for wall in walls:
+ left, right = face_offsets(wall)
+ for index, (a, b) in enumerate(wall_segments(wall)):
+ u = _unit(a, b)
+ if abs(u[ci]) > 1e-9:
+ continue
+ n = (-u[1], u[0])
+ faces = (a[ci] + n[ci] * left, a[ci] + n[ci] * right)
+ parallel.append(
+ {
+ "wall": wall,
+ "index": index,
+ "a": a,
+ "b": b,
+ "span": (min(a[ai], b[ai]), max(a[ai], b[ai])),
+ "outer": min(faces) if sign < 0 else max(faces),
+ }
+ )
+ exterior = []
+ for item in parallel:
+ lo, hi = item["span"]
+ covered = any(
+ other is not item
+ and min(hi, other["span"][1]) - max(lo, other["span"][0]) > _TOL
+ and (
+ other["outer"] < item["outer"] - _TOL
+ if sign < 0
+ else other["outer"] > item["outer"] + _TOL
+ )
+ for other in parallel
+ )
+ if not covered:
+ exterior.append(item)
+ return exterior
+
+
+def _inside(point, item: dict) -> bool:
+ a, b = item["a"], item["b"]
+ left, right = face_offsets(item["wall"])
+ u = _unit(a, b)
+ s = (point[0] - a[0]) * u[0] + (point[1] - a[1]) * u[1]
+ d = (point[0] - a[0]) * -u[1] + (point[1] - a[1]) * u[0]
+ return -_TOL <= s <= math.dist(a, b) + _TOL and right - _TOL <= d <= left + _TOL
+
+
+def _points(values: set[float]) -> list[float]:
+ out: list[float] = []
+ for value in sorted(values):
+ if not out or value - out[-1] > 1e-6:
+ out.append(value)
+ return out
+
+
+def exterior_chains(
+ walls,
+ openings=(),
+ *,
+ sides=("bottom", "left"),
+ first_offset=None,
+ step=None,
+ scale=50,
+) -> tuple[DimIntent, ...]:
+ """Linear ``DimIntent`` rows outside each named side, nearest first.
+
+ ``feature`` names the row as ``":"`` (``"bottom:openings"``);
+ ``text_at`` is the point the dimension line passes through.
+ """
+ walls = list(walls)
+ openings = list(openings)
+ chosen = _sides(sides)
+ factor = _positive(scale, "scale")
+ first = (
+ FIRST_OFFSET_PAPER_MM * factor
+ if first_offset is None
+ else _positive(first_offset, "first_offset")
+ )
+ gap = STEP_PAPER_MM * factor if step is None else _positive(step, "step")
+ geometry = wall_geometry(walls, openings, poche=False)
+ if not geometry.faces:
+ raise ValueError("walls: there is no wall to dimension")
+ xs = [p[0] for line in geometry.faces for p in (line.p1, line.p2)]
+ ys = [p[1] for line in geometry.faces for p in (line.p1, line.p2)]
+ box = {"bottom": min(ys), "top": max(ys), "left": min(xs), "right": max(xs)}
+ skipped = {entry["element"] for entry in geometry.omitted}
+ by_id = {wall.id: wall for wall in walls}
+
+ intents: list[DimIntent] = []
+ for side in chosen:
+ ai = 0 if side in ("bottom", "top") else 1
+ sign = -1.0 if side in ("bottom", "left") else 1.0
+ edge = box[side]
+ lo, hi = (box["left"], box["right"]) if ai == 0 else (box["bottom"], box["top"])
+ exterior = _exterior(walls, ai, sign)
+ hosts = {(item["wall"].id, item["index"]) for item in exterior}
+
+ jambs = {lo, hi}
+ for opening in openings:
+ if opening.id in skipped:
+ continue
+ wall = by_id[opening.wall]
+ index, along = segment_of(wall, float(opening.offset))
+ if (wall.id, index) not in hosts:
+ continue
+ a, b = wall_segments(wall)[index]
+ u = _unit(a, b)
+ jambs.add(a[ai] + u[ai] * along)
+ jambs.add(a[ai] + u[ai] * (along + float(opening.width)))
+
+ faces = {lo, hi}
+ for wall in walls:
+ left, right = face_offsets(wall)
+ for a, b in wall_segments(wall):
+ u = _unit(a, b)
+ if abs(u[1 - ai]) <= 1e-9:
+ continue
+ n = (-u[1], u[0])
+ for end in (a, b):
+ if any(_inside(end, item) for item in exterior):
+ faces.add(end[ai] + n[ai] * left)
+ faces.add(end[ai] + n[ai] * right)
+
+ rows = [_points(jambs), _points(faces), _points({lo, hi})]
+ for r, points in enumerate(rows):
+ if any(points == later for later in rows[r + 1 :]):
+ continue
+ offset = edge + sign * (first + r * gap)
+ for p, q in zip(points, points[1:], strict=False):
+ if ai == 0:
+ p1, p2, at = (p, edge), (q, edge), ((p + q) / 2.0, offset)
+ else:
+ p1, p2, at = (edge, p), (edge, q), (offset, (p + q) / 2.0)
+ intents.append(DimIntent("linear", p1, p2, at, feature=f"{side}:{ROWS[r]}"))
+ return tuple(intents)
diff --git a/engineering/arch/draw.py b/engineering/arch/draw.py
new file mode 100644
index 0000000..cbf273a
--- /dev/null
+++ b/engineering/arch/draw.py
@@ -0,0 +1,416 @@
+"""The async layer of the architectural track - the only module here that touches a backend.
+
+Everything else under `engineering/arch/` is pure; this module turns its
+primitives into entities through the generic contract members (so it runs on
+both engines as written) and writes the plan onto the drawing:
+
+* each wall's ``ACADMCP_ARCH`` record on its first face line, each opening's
+ on its door leaf or its window frame, each stair's on its first riser line;
+* next to each record, under ``ACADMCP_ARCH_H``, the handles of every entity
+ that element owns - faces, jambs, poche, the symbol - so a later call can
+ take exactly those entities back out and redraw them.
+
+`read_plan` reads the records back. `add_walls` and `add_opening` build on
+it: they compute the whole network before and after the change, redraw only
+the walls whose outline the change touches, and say which.
+
+Every refusal fires before the first entity is created or erased.
+"""
+
+from __future__ import annotations
+
+import logging
+import math
+from dataclasses import replace
+
+from engineering.arch.dimension import exterior_chains
+from engineering.arch.lang import tag_prefix
+from engineering.arch.layers import ARCH_LAYERS, ARCH_ROLE_LAYER
+from engineering.arch.model import (
+ APP_ID,
+ Opening,
+ Room,
+ Stair,
+ Wall,
+ decode,
+ from_payload,
+ opening_from_dict,
+ stair_from_dict,
+ to_payload,
+ to_values,
+ wall_from_dict,
+)
+from engineering.arch.openings import opening_prims
+from engineering.arch.stairs import blondel, stair_prims
+from engineering.arch.walls import WallGeometry, wall_geometry_by_wall
+from engineering.mech.draw import draw_prims
+
+log = logging.getLogger(__name__)
+
+#: The application that carries an element's owned handles beside its record.
+HANDLES_APP = "ACADMCP_ARCH_H"
+
+#: The layers `read_plan` scans for records - never the poche or the dimensions.
+RECORD_ROLES: tuple[str, ...] = ("wall", "door", "window", "stair", "room")
+
+#: Model class -> the record kind, read off the object rather than the payload,
+#: so the reader does not depend on how a payload spells its own kind.
+_KIND = {Wall: "wall", Opening: "opening", Stair: "stair", Room: "room"}
+
+
+# -- layers -----------------------------------------------------------------------
+
+
+async def ensure_arch_layers(backend, names) -> tuple[str, ...]:
+ """Create the named layers the drawing lacks, with their ``ARCH_LAYERS`` row.
+
+ A live seat refuses ``entity.Layer = name`` on a missing layer (measured
+ by the mechanical track), so every draw path ensures its layers first.
+ """
+ rows = {row[0]: row for row in ARCH_LAYERS}
+ existing = {layer.name.lower() for layer in await backend.layer_list()}
+ created: list[str] = []
+ for name in dict.fromkeys(names):
+ if name.lower() in existing:
+ continue
+ _name, color, linetype, lineweight, _description = rows.get(
+ name, (name, 7, "Continuous", 0.25, "")
+ )
+ await backend.layer_create(name=name, color=color, linetype=linetype, lineweight=lineweight)
+ created.append(name)
+ return tuple(created)
+
+
+# -- records --------------------------------------------------------------------
+
+
+async def _write_record(backend, handle: str, element, owned: list[str]) -> None:
+ await backend.entity_set_xdata(handle, APP_ID, to_values(to_payload(element)))
+ await backend.entity_set_xdata(handle, HANDLES_APP, list(owned))
+
+
+async def read_plan(backend) -> dict:
+ """Every ``ACADMCP_ARCH`` record on the drawing, rebuilt into model objects.
+
+ ``carriers`` maps ``":"`` to the entity that carries the record.
+ Reads only; the drawing is never modified.
+ """
+ plan: dict = {"walls": [], "openings": [], "stairs": [], "rooms": [], "carriers": {}}
+ for role in RECORD_ROLES:
+ for info in await backend.entity_list(layer_filter=ARCH_ROLE_LAYER[role], limit=100000):
+ raw = await backend.entity_get_xdata(info.handle, APP_ID)
+ values = (raw.get("xdata") or {}).get(APP_ID) or []
+ if not values:
+ continue
+ element = from_payload(decode(values))
+ kind = _KIND[type(element)]
+ plan[f"{kind}s"].append(element)
+ plan["carriers"][f"{kind}:{element.id}"] = info.handle
+ return plan
+
+
+async def _owned(backend, carrier: str) -> list[str]:
+ raw = await backend.entity_get_xdata(carrier, HANDLES_APP)
+ return [str(h) for h in ((raw.get("xdata") or {}).get(HANDLES_APP) or [])]
+
+
+async def _erase(backend, plan: dict, wall_id: str) -> list[str]:
+ """Take a wall, its openings and their poche back out. Returns handles already gone."""
+ carriers = [plan["carriers"][f"wall:{wall_id}"]]
+ carriers += [
+ plan["carriers"][f"opening:{op.id}"] for op in plan["openings"] if op.wall == wall_id
+ ]
+ missing: list[str] = []
+ for carrier in carriers:
+ for handle in await _owned(backend, carrier) or [carrier]:
+ try:
+ await backend.entity_delete(handle)
+ except Exception as exc: # erased by hand since it was drawn: nothing to take back
+ log.info("arch: %s was already gone (%s)", handle, exc)
+ missing.append(handle)
+ return missing
+
+
+# -- walls ------------------------------------------------------------------------
+
+
+def _signature(geometry: WallGeometry) -> tuple:
+ """What a wall looks like, rounded - equal signatures mean nothing to redraw."""
+
+ def seg(line):
+ a = (round(line.p1[0], 6), round(line.p1[1], 6))
+ b = (round(line.p2[0], 6), round(line.p2[1], 6))
+ return tuple(sorted((a, b)))
+
+ def ring(points):
+ pts = [(round(x, 6), round(y, 6)) for x, y in points]
+ k = pts.index(min(pts))
+ return tuple(pts[k:] + pts[:k])
+
+ return (
+ frozenset(seg(line) for line in geometry.faces),
+ frozenset(seg(line) for line in geometry.jambs),
+ frozenset(ring(region) for region in geometry.regions),
+ frozenset(entry["element"] for entry in geometry.omitted),
+ )
+
+
+async def _draw_wall(backend, wall, geometry: WallGeometry, openings) -> dict:
+ """One wall's faces, jambs, poche and hosted symbols, with their records."""
+ skipped = {entry["element"] for entry in geometry.omitted}
+ symbols = [(op, opening_prims(wall, op)) for op in openings if op.id not in skipped]
+ # Faces first, so owned[0] - the record carrier - is the wall's first face line;
+ # the poche follows on the wall-hatch layer, in each material's pattern.
+ drawn = await draw_prims(
+ backend,
+ [*geometry.faces, *geometry.jambs, *geometry.hatches],
+ role_layer=ARCH_ROLE_LAYER,
+ )
+ owned = list(drawn["handles"])
+ done: list[dict] = []
+ for op, prims in symbols:
+ placed = await draw_prims(backend, prims, role_layer=ARCH_ROLE_LAYER)
+ await _write_record(backend, placed["handles"][0], op, placed["handles"])
+ done.append(
+ {
+ "id": op.id,
+ "tag": op.tag,
+ "record": placed["handles"][0],
+ "handles": placed["handles"],
+ }
+ )
+ await _write_record(backend, owned[0], wall, owned)
+ return {
+ "wall": {"id": wall.id, "record": owned[0], "handles": owned},
+ "openings": done,
+ "omitted": list(geometry.omitted),
+ }
+
+
+def _collect(results: list[dict]) -> dict:
+ return {
+ "walls": [r["wall"] for r in results],
+ "openings": [op for r in results for op in r["openings"]],
+ "handles": [
+ h
+ for r in results
+ for h in (*r["wall"]["handles"], *(x for op in r["openings"] for x in op["handles"]))
+ ],
+ "omitted": [entry for r in results for entry in r["omitted"]],
+ }
+
+
+async def draw_walls(backend, walls, openings=(), *, poche=True) -> dict:
+ """Draw a wall network and its openings, writing every record. Refusals first."""
+ walls = list(walls)
+ openings = list(openings)
+ geometry = wall_geometry_by_wall(walls, openings, poche=poche)
+ for wall in walls: # every symbol is validated before the first entity
+ for op in openings:
+ if op.wall == wall.id:
+ opening_prims(wall, op)
+ results = [
+ await _draw_wall(
+ backend, wall, geometry[wall.id], [op for op in openings if op.wall == wall.id]
+ )
+ for wall in walls
+ ]
+ return {**_collect(results), "backend": backend.name}
+
+
+async def add_walls(backend, walls, *, poche=True) -> dict:
+ """Add walls to the plan on the drawing, redrawing the drawn walls they join."""
+ if isinstance(walls, dict) or not isinstance(walls, (list, tuple)) or not walls:
+ raise ValueError("walls: give a list of at least one wall {id, axis, thickness, ...}")
+ new = [wall_from_dict(item, f"walls[{i}]") for i, item in enumerate(walls)]
+ plan = await read_plan(backend)
+ drawn = {wall.id for wall in plan["walls"]}
+ for i, wall in enumerate(new):
+ if wall.id in drawn:
+ raise ValueError(
+ f"walls[{i}].id: {wall.id!r} is already drawn; choose a new id "
+ f"(drawn: {', '.join(sorted(drawn))})"
+ )
+ before = (
+ wall_geometry_by_wall(plan["walls"], plan["openings"], poche=poche) if plan["walls"] else {}
+ )
+ network = [*plan["walls"], *new]
+ after = wall_geometry_by_wall(network, plan["openings"], poche=poche)
+ changed = [
+ wall.id
+ for wall in plan["walls"]
+ if _signature(before[wall.id]) != _signature(after[wall.id])
+ ]
+ for wall_id in changed:
+ was = {entry["element"] for entry in before[wall_id].omitted}
+ for entry in after[wall_id].omitted:
+ if entry["element"] not in was:
+ raise ValueError(
+ f"the new walls would leave opening {entry['element']!r} of wall "
+ f"{wall_id!r} uncuttable ({entry['reason']}); move the opening or the wall"
+ )
+ by_id = {wall.id: wall for wall in network}
+ targets = [*changed, *(wall.id for wall in new)]
+ for wall_id in targets:
+ for op in plan["openings"]:
+ if op.wall == wall_id:
+ opening_prims(by_id[wall_id], op)
+
+ missing: list[str] = []
+ for wall_id in changed:
+ missing += await _erase(backend, plan, wall_id)
+ results = [
+ await _draw_wall(
+ backend,
+ by_id[wall_id],
+ after[wall_id],
+ [op for op in plan["openings"] if op.wall == wall_id],
+ )
+ for wall_id in targets
+ ]
+ return {
+ **_collect(results),
+ "added": [wall.id for wall in new],
+ "redrawn": changed,
+ "already_erased": missing,
+ "backend": backend.name,
+ }
+
+
+# -- openings ---------------------------------------------------------------------
+
+
+def assign_tags(openings, existing=(), *, lang: str = "en") -> tuple[Opening, ...]:
+ """Give every untagged opening the next free tag of its kind: D1, W2 (EN) / K1, P2 (TR)."""
+ used = {op.tag for op in (*existing, *openings) if op.tag}
+ out: list[Opening] = []
+ for op in openings:
+ if op.tag:
+ out.append(op)
+ continue
+ prefix = tag_prefix(op.kind, lang)
+ number = 1
+ while f"{prefix}{number}" in used:
+ number += 1
+ used.add(f"{prefix}{number}")
+ out.append(replace(op, tag=f"{prefix}{number}"))
+ return tuple(out)
+
+
+async def add_opening(backend, opening: dict, *, lang: str = "en", poche: bool = True) -> dict:
+ """Host a door or a window in a drawn wall, found by id, and redraw that wall cut."""
+ if not isinstance(opening, dict):
+ raise ValueError(f"opening: expected a dict, got {type(opening).__name__}")
+ raw = dict(opening)
+ auto_id = not raw.get("id")
+ if auto_id:
+ raw["id"] = "__new__"
+ op = opening_from_dict(raw, "opening")
+ plan = await read_plan(backend)
+ walls = {wall.id: wall for wall in plan["walls"]}
+ if op.wall not in walls:
+ names = ", ".join(sorted(walls)) or "none - draw one with arch_wall"
+ raise ValueError(f"opening.wall: {op.wall!r} is not a wall on this drawing; walls: {names}")
+ (op,) = assign_tags([op], plan["openings"], lang=lang)
+ if auto_id:
+ op = replace(op, id=op.tag)
+ if op.id in {other.id for other in plan["openings"]}:
+ raise ValueError(f"opening.id: {op.id!r} is already drawn; choose a new id")
+ hosted = [other for other in plan["openings"] if other.wall == op.wall] + [op]
+ geometry = wall_geometry_by_wall(plan["walls"], [*plan["openings"], op], poche=poche)
+ for entry in geometry[op.wall].omitted:
+ if entry["element"] == op.id:
+ raise ValueError(f"opening {op.id!r} cannot be cut: {entry['reason']}")
+ wall = walls[op.wall]
+ for other in hosted:
+ opening_prims(wall, other)
+ missing = await _erase(backend, plan, op.wall)
+ result = await _draw_wall(backend, wall, geometry[op.wall], hosted)
+ return {
+ **_collect([result]),
+ "opening": op.id,
+ "tag": op.tag,
+ "redrawn": [op.wall],
+ "already_erased": missing,
+ "backend": backend.name,
+ }
+
+
+# -- stairs -----------------------------------------------------------------------
+
+
+async def draw_stair(backend, stair: Stair, *, lang: str = "en", scale=50) -> dict:
+ """A stair in plan with its record; the Blondel value is reported, never enforced."""
+ prims = stair_prims(stair, lang=lang, scale=scale)
+ check = blondel(stair.riser_height, stair.going)
+ drawn = await draw_prims(backend, prims, role_layer=ARCH_ROLE_LAYER)
+ await _write_record(backend, drawn["handles"][0], stair, drawn["handles"])
+ flights = 1 if stair.kind == "straight" else 2
+ return {
+ "stair": stair.id,
+ "record": drawn["handles"][0],
+ "handles": drawn["handles"],
+ "risers": stair.risers,
+ "treads": stair.risers - flights,
+ "blondel": check,
+ "backend": backend.name,
+ }
+
+
+async def add_stair(backend, stair: dict, *, lang: str = "en", scale=50) -> dict:
+ """`draw_stair` for a stair given as a dict, refusing an id already on the drawing."""
+ if not isinstance(stair, dict):
+ raise ValueError(f"stair: expected a dict, got {type(stair).__name__}")
+ model = stair_from_dict(stair, "stair")
+ stair_prims(model, lang=lang, scale=scale)
+ plan = await read_plan(backend)
+ if model.id in {other.id for other in plan["stairs"]}:
+ raise ValueError(f"stair.id: {model.id!r} is already drawn; choose a new id")
+ return await draw_stair(backend, model, lang=lang, scale=scale)
+
+
+# -- dimensions ---------------------------------------------------------------------
+
+
+async def draw_chains(
+ backend, walls, openings, *, sides=("bottom", "left"), scale=50, first_offset=None, step=None
+) -> dict:
+ """The exterior chains as real linear DIMENSIONs on the dimension layer."""
+ intents = exterior_chains(
+ walls, openings, sides=sides, first_offset=first_offset, step=step, scale=scale
+ )
+ layer = ARCH_ROLE_LAYER["dim"]
+ await ensure_arch_layers(backend, [layer])
+ handles: list[str] = []
+ rows: list[dict] = []
+ for intent in intents:
+ horizontal = abs(intent.p2[1] - intent.p1[1]) <= abs(intent.p2[0] - intent.p1[0])
+ info = await backend.dimension_linear(
+ intent.p1[0],
+ intent.p1[1],
+ intent.p2[0],
+ intent.p2[1],
+ intent.text_at[0],
+ intent.text_at[1],
+ 0.0 if horizontal else 90.0,
+ layer,
+ )
+ handles.append(info.handle)
+ rows.append(
+ {
+ "row": intent.feature,
+ "from": list(intent.p1),
+ "to": list(intent.p2),
+ "value": math.dist(intent.p1, intent.p2),
+ }
+ )
+ return {
+ "count": len(handles),
+ "handles": handles,
+ "dimensions": rows,
+ "text_height": (
+ "set by the current dimension style; at 1:{0:g} set DIMSCALE={0:g} with "
+ "dimstyle_modify so the text plots at its paper height".format(float(scale))
+ ),
+ "backend": backend.name,
+ }
diff --git a/server.py b/server.py
index d6f0808..a1ac54e 100644
--- a/server.py
+++ b/server.py
@@ -691,6 +691,7 @@ async def _registered_tool_count() -> int | None:
_GROUP_TAG_PRIORITY = (
"pid",
"mech",
+ "arch",
"sheet",
"style",
"pagesetup",
@@ -721,6 +722,7 @@ _GROUP_TAG_PRIORITY = (
_GROUP_TAG_LABELS = {
"pid": "pid",
"mech": "mechanical",
+ "arch": "architecture",
"sheet": "sheet",
"style": "styles",
"pagesetup": "page_setup",
@@ -9780,6 +9782,295 @@ async def drawing_export_dwg(
return await _backend(ctx).drawing_export_dwg(str(validated), version)
+# ---------------------------------------------------------------------------
+# ── SECTION 25: Architecture (4 tools) ──────────────────────────────────────
+# ---------------------------------------------------------------------------
+
+
+@cad_tool(
+ summary="Add walls to the plan: clean L/T/X junctions, cut openings, material poché.",
+ cost="mutate",
+)
+@mcp.tool(
+ annotations={"title": "Architecture: Wall", "destructiveHint": False},
+ tags={"arch", "create"},
+)
+async def arch_wall(
+ walls: Annotated[
+ list[dict],
+ Field(
+ description=(
+ "Walls to add: [{id, axis:[[x,y],...] (2+ points, straight segments, WCS mm), "
+ "thickness, justification:'center'|'left'|'right' (side of the axis looking "
+ "along it), material:'brick'|'aac'|'concrete'|'reinforced_concrete'|"
+ "'gypsum_board'|'stone'|'timber'|'generic', closed:bool}]"
+ )
+ ),
+ ],
+ poche: Annotated[
+ bool,
+ Field(default=True, description="Hatch the cut wall faces in their material (ISO 128-50)"),
+ ] = True,
+ ctx: Context = None,
+) -> dict:
+ """Add walls to the plan network already on the drawing and resolve every junction.
+
+ Two ends at one point mitre into a clean L; an end inside another wall is a
+ T (the stem stops on the crossing wall's near face, which is cut there); two
+ axes crossing are an X (all four faces cut). A drawn wall whose outline the
+ new walls change is taken back out and redrawn with its openings - the
+ result lists it under `redrawn` - and a wall they do not touch is left alone.
+ Every wall carries an `ACADMCP_ARCH` record, so later calls find it by id.
+
+ Refused before anything is drawn or erased, by path: a wall id already on
+ the drawing or used twice, an axis of fewer than two points or with a
+ zero-length segment, a non-positive thickness, an unknown justification or
+ material (the list is named), two walls meeting at less than 5° (the mitre
+ would run away), a wall corner lying inside another wall off its axis, an
+ end inside a wall whose axis only meets it past that wall's end, and a new
+ wall that would leave a drawn opening uncuttable. A curved wall is not
+ supported: an axis is straight segments only.
+ """
+ from engineering.arch.draw import add_walls
+
+ await ctx.info(f"arch_wall: {len(walls)} wall(s)")
+ try:
+ return await add_walls(_backend(ctx), walls, poche=poche)
+ except ValueError as exc:
+ raise ToolError(str(exc)) from exc
+
+
+@cad_tool(
+ summary="Host a door or a window in a drawn wall by distance along it; the wall is cut.",
+ cost="mutate",
+)
+@mcp.tool(
+ annotations={"title": "Architecture: Door / Window", "destructiveHint": False},
+ tags={"arch", "create"},
+)
+async def arch_opening(
+ wall: Annotated[str, Field(description="Id of the drawn wall that hosts the opening")],
+ offset: Annotated[
+ float,
+ Field(
+ description="Distance along the wall's axis from its first point to the near jamb (mm)"
+ ),
+ ],
+ width: Annotated[float, Field(description="Clear width of the opening (mm)")],
+ kind: Annotated[str, Field(default="door", description="door | window")] = "door",
+ swing: Annotated[
+ str,
+ Field(
+ default="in",
+ description="Doors: in = to the left of the axis (inside a CCW perimeter) | out",
+ ),
+ ] = "in",
+ hand: Annotated[
+ str,
+ Field(
+ default="left",
+ description="Doors: the hinge jamb, seen from the swing side facing the wall: left | right",
+ ),
+ ] = "left",
+ sill: Annotated[
+ float | None, Field(default=None, description="Windows: sill height (schedule only, mm)")
+ ] = None,
+ height: Annotated[
+ float | None, Field(default=None, description="Opening height (schedule only, mm)")
+ ] = None,
+ tag: Annotated[
+ str | None,
+ Field(default=None, description="Schedule tag; default the next D1/W1 (en) or K1/P1 (tr)"),
+ ] = None,
+ opening_id: Annotated[
+ str | None, Field(default=None, description="Opening id; default its tag")
+ ] = None,
+ lang: Annotated[str, Field(default="en", description="Tag language: en | tr")] = "en",
+ poche: Annotated[
+ bool, Field(default=True, description="Hatch the redrawn wall's cut faces")
+ ] = True,
+ ctx: Context = None,
+) -> dict:
+ """A door or a window in a wall already drawn, found by its id; the wall is redrawn cut.
+
+ The opening removes its width from both wall faces and each edge is closed
+ by a jamb. A door is drawn open at 90° - its leaf as long as the opening is
+ wide, hinged on `hand`, with a quarter-arc swing on the `swing` side; a
+ window is a frame line on each face and two glazing lines between them. The
+ opening's `ACADMCP_ARCH` record sits on the leaf or the frame, so a schedule
+ reads it back.
+
+ Refused before anything is drawn or erased: a wall id not on the drawing
+ (the drawn ids are named), an opening id already drawn, an opening that
+ runs past its wall segment or straddles an axis vertex, one overlapping
+ another opening on the same wall (both ids named), one that runs into a
+ junction (the solid stretch it must fit in is named), an unknown kind,
+ swing, hand or language.
+ """
+ from engineering.arch.draw import add_opening
+
+ spec = {
+ "id": opening_id,
+ "wall": wall,
+ "kind": kind,
+ "offset": offset,
+ "width": width,
+ "swing": swing,
+ "hand": hand,
+ "sill": sill,
+ "height": height,
+ "tag": tag,
+ }
+ await ctx.info(f"arch_opening: {kind} in wall {wall}")
+ try:
+ return await add_opening(
+ _backend(ctx), {k: v for k, v in spec.items() if v is not None}, lang=lang, poche=poche
+ )
+ except ValueError as exc:
+ raise ToolError(str(exc)) from exc
+
+
+@cad_tool(
+ summary="Draw a straight, L or U stair in plan and report its Blondel step rule.",
+ cost="mutate",
+)
+@mcp.tool(
+ annotations={"title": "Architecture: Stair", "destructiveHint": False},
+ tags={"arch", "create"},
+)
+async def arch_stair(
+ stair_id: Annotated[str, Field(description="Stair id, unique on the drawing")],
+ start_x: Annotated[float, Field(description="WCS X of the midpoint of the bottom riser")],
+ start_y: Annotated[float, Field(description="WCS Y of the midpoint of the bottom riser")],
+ width: Annotated[float, Field(description="Flight width (mm)")],
+ risers: Annotated[int, Field(description="Number of risers from floor to floor")],
+ riser_height: Annotated[float, Field(description="Riser height R (mm)")],
+ going: Annotated[float, Field(description="Tread going G (mm)")],
+ direction_deg: Annotated[
+ float, Field(default=0.0, description="Direction up the first flight, degrees CCW from +X")
+ ] = 0.0,
+ kind: Annotated[str, Field(default="straight", description="straight | l | u")] = "straight",
+ turn: Annotated[
+ str, Field(default="left", description="L / U stairs: which way the second flight turns")
+ ] = "left",
+ lang: Annotated[str, Field(default="en", description="Label language: en | tr")] = "en",
+ scale: Annotated[
+ float,
+ Field(
+ default=50.0, description="Plot-scale denominator for the label and arrow (50 = 1:50)"
+ ),
+ ] = 50.0,
+ ctx: Context = None,
+) -> dict:
+ """A stair in plan: every riser as a line, the outline, the walking line with its arrow.
+
+ The label ("UP" / "ÇIKIŞ") sits on the first tread at 2.5 mm × `scale`. The
+ result reports `blondel` - 2R + G against 600-650 mm, the step-length rule
+ attributed to François Blondel (1675). It is a design rule reported for the
+ drafter, not a building code: no national stair regulation is checked and
+ a value outside the band is reported (`ok: false`), not refused.
+
+ Refused before anything is drawn: a stair id already on the drawing, a
+ non-positive width, riser height or going, fewer than 2 risers (4 for an L
+ or a U - two per flight), a non-integer riser count, an unknown kind, turn
+ or language.
+ """
+ from engineering.arch.draw import add_stair
+
+ spec = {
+ "id": stair_id,
+ "start": [start_x, start_y],
+ "direction_deg": direction_deg,
+ "width": width,
+ "risers": risers,
+ "riser_height": riser_height,
+ "going": going,
+ "kind": kind,
+ "turn": turn,
+ }
+ await ctx.info(f"arch_stair: {kind} stair {stair_id}")
+ try:
+ return await add_stair(_backend(ctx), spec, lang=lang, scale=scale)
+ except ValueError as exc:
+ raise ToolError(str(exc)) from exc
+
+
+@cad_tool(
+ summary="Dimension the plan's facades: openings, walls and overall chains outside each side.",
+ cost="mutate",
+)
+@mcp.tool(
+ annotations={"title": "Architecture: Dimension Chains", "destructiveHint": False},
+ tags={"arch", "dimension"},
+)
+async def arch_dimension_chains(
+ sides: Annotated[
+ list[str] | None,
+ Field(
+ default=None,
+ description="Sides to dimension: bottom | top | left | right (default bottom, left)",
+ ),
+ ] = None,
+ scale: Annotated[
+ float,
+ Field(
+ default=50.0,
+ description="Plot-scale denominator: rows sit 10 mm and 8 mm apart on paper",
+ ),
+ ] = 50.0,
+ first_offset: Annotated[
+ float | None,
+ Field(
+ default=None, description="Distance of the first row from the building (drawing units)"
+ ),
+ ] = None,
+ step: Annotated[
+ float | None, Field(default=None, description="Distance between rows (drawing units)")
+ ] = None,
+ ctx: Context = None,
+) -> dict:
+ """The three exterior chains an architectural plan carries, as real DIMENSIONs.
+
+ Nearest first: the openings row (every jamb in that facade), the walls row
+ (both faces of every wall meeting it - wall thickness, clear width, wall
+ thickness, ...) and the overall row. The points come from the wall engine's
+ own faces, read back from the plan on the drawing. A row that would only
+ restate a farther one is not drawn (ISO 129-1: each measurement once).
+ The row spacing defaults (10 mm, then 8 mm on paper) are this server's
+ drafting choices, not standard values. Text height follows the current
+ dimension style: at 1:50 set DIMSCALE=50 with `dimstyle_modify`.
+
+ Refused by name: a drawing with no walls, an unknown or repeated side, a
+ non-positive scale, offset or step.
+ """
+ from engineering.arch.draw import draw_chains, read_plan
+
+ backend = _backend(ctx)
+ try:
+ plan = await read_plan(backend)
+ if not plan["walls"]:
+ raise ValueError(
+ "this drawing carries no architectural walls; draw them with arch_wall"
+ )
+ await ctx.info(f"arch_dimension_chains: {len(plan['walls'])} wall(s)")
+ return await draw_chains(
+ backend,
+ plan["walls"],
+ plan["openings"],
+ sides=tuple(sides or ("bottom", "left")),
+ scale=scale,
+ first_offset=first_offset,
+ step=step,
+ )
+ except ValueError as exc:
+ raise ToolError(str(exc)) from exc
+
+
+# ── arch: rooms (group R) ──
+
+# ── arch: catalogue (group C) ──
+
+
# ---------------------------------------------------------------------------
# ── RESOURCES ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
diff --git a/tests/test_arch_dimension.py b/tests/test_arch_dimension.py
new file mode 100644
index 0000000..3075507
--- /dev/null
+++ b/tests/test_arch_dimension.py
@@ -0,0 +1,107 @@
+"""Exterior dimension chains: three rows per side, from the wall engine's own faces.
+
+The plan: a closed 8200 x 6200 axis rectangle of 200 mm walls (outer faces
+-100..8300 x -100..6300), a 100 mm partition on x = 4100 teed into the bottom
+and top walls (faces x = 4050 / 4150), a 1200 window at 1500 and a 1000 door
+at 5500 in the bottom wall. Every point below is hand-computed from those
+numbers; at 1:50 the rows sit 500, 900 and 1300 mm outside the building.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from engineering.arch.dimension import (
+ FIRST_OFFSET_PAPER_MM,
+ ROWS,
+ SIDES,
+ STEP_PAPER_MM,
+ exterior_chains,
+)
+from engineering.arch.model import Opening, Wall
+from engineering.mech.primitives import DimIntent
+
+SHELL = Wall(
+ id="shell",
+ axis=((0.0, 0.0), (8200.0, 0.0), (8200.0, 6200.0), (0.0, 6200.0)),
+ thickness=200.0,
+ closed=True,
+)
+PARTITION = Wall(id="p", axis=((4100.0, 0.0), (4100.0, 6200.0)), thickness=100.0)
+WINDOW = Opening(id="w1", wall="shell", kind="window", offset=1500.0, width=1200.0)
+DOOR = Opening(id="d1", wall="shell", kind="door", offset=5500.0, width=1000.0)
+
+
+def row(intents, feature) -> list[tuple]:
+ out = []
+ for intent in intents:
+ if intent.feature == feature:
+ out.append(
+ (
+ tuple(round(v, 9) + 0.0 for v in intent.p1),
+ tuple(round(v, 9) + 0.0 for v in intent.p2),
+ tuple(round(v, 9) + 0.0 for v in intent.text_at),
+ )
+ )
+ return out
+
+
+def test_the_rows_and_the_offsets_are_declared():
+ assert SIDES == ("bottom", "top", "left", "right")
+ assert ROWS == ("openings", "walls", "overall")
+ assert (FIRST_OFFSET_PAPER_MM, STEP_PAPER_MM) == (10.0, 8.0)
+
+
+def test_the_bottom_side_carries_openings_walls_and_overall():
+ intents = exterior_chains([SHELL, PARTITION], [WINDOW, DOOR], sides=("bottom",), scale=50)
+ assert all(isinstance(i, DimIntent) and i.kind == "linear" for i in intents)
+ assert row(intents, "bottom:openings") == [
+ ((-100.0, -100.0), (1500.0, -100.0), (700.0, -600.0)),
+ ((1500.0, -100.0), (2700.0, -100.0), (2100.0, -600.0)),
+ ((2700.0, -100.0), (5500.0, -100.0), (4100.0, -600.0)),
+ ((5500.0, -100.0), (6500.0, -100.0), (6000.0, -600.0)),
+ ((6500.0, -100.0), (8300.0, -100.0), (7400.0, -600.0)),
+ ]
+ assert row(intents, "bottom:walls") == [
+ ((-100.0, -100.0), (100.0, -100.0), (0.0, -1000.0)),
+ ((100.0, -100.0), (4050.0, -100.0), (2075.0, -1000.0)),
+ ((4050.0, -100.0), (4150.0, -100.0), (4100.0, -1000.0)),
+ ((4150.0, -100.0), (8100.0, -100.0), (6125.0, -1000.0)),
+ ((8100.0, -100.0), (8300.0, -100.0), (8200.0, -1000.0)),
+ ]
+ assert row(intents, "bottom:overall") == [
+ ((-100.0, -100.0), (8300.0, -100.0), (4100.0, -1400.0)),
+ ]
+ assert len(intents) == 11
+
+
+def test_a_row_that_repeats_a_farther_one_is_not_emitted():
+ intents = exterior_chains([SHELL, PARTITION], [WINDOW, DOOR], sides=("left",), scale=50)
+ # no opening in the left wall: that row would only restate the overall one
+ assert row(intents, "left:openings") == []
+ assert row(intents, "left:walls") == [
+ ((-100.0, -100.0), (-100.0, 100.0), (-1000.0, 0.0)),
+ ((-100.0, 100.0), (-100.0, 6100.0), (-1000.0, 3100.0)),
+ ((-100.0, 6100.0), (-100.0, 6300.0), (-1000.0, 6200.0)),
+ ]
+ assert row(intents, "left:overall") == [((-100.0, -100.0), (-100.0, 6300.0), (-1400.0, 3100.0))]
+
+
+def test_explicit_offsets_override_the_plot_scale_defaults():
+ intents = exterior_chains(
+ [SHELL], [], sides=("top",), first_offset=300.0, step=250.0, scale=100
+ )
+ # the top: the extremes only, so the walls row is the corner walls' faces
+ assert row(intents, "top:overall") == [((-100.0, 6300.0), (8300.0, 6300.0), (4100.0, 7100.0))]
+ assert {i.text_at[1] for i in intents if i.feature == "top:walls"} == {6850.0}
+
+
+def test_bad_sides_and_scales_are_refused_by_name():
+ with pytest.raises(ValueError, match=r"sides\[0\]: 'north'"):
+ exterior_chains([SHELL], [], sides=("north",))
+ with pytest.raises(ValueError, match="named twice"):
+ exterior_chains([SHELL], [], sides=("left", "left"))
+ with pytest.raises(ValueError, match="scale"):
+ exterior_chains([SHELL], [], scale=0)
+ with pytest.raises(ValueError, match="sides"):
+ exterior_chains([SHELL], [], sides="bottom")
diff --git a/tests/test_arch_draw.py b/tests/test_arch_draw.py
new file mode 100644
index 0000000..c687135
--- /dev/null
+++ b/tests/test_arch_draw.py
@@ -0,0 +1,207 @@
+"""The async layer: walls, openings, stairs and chains drawn through the ezdxf backend.
+
+The same code runs on the live engine: `engineering/arch/draw.py` composes only
+generic contract members (entity_create_*, dimension_linear, entity_set_xdata,
+entity_list, entity_delete), like the mechanical track's draw layer.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from engineering.arch.draw import (
+ HANDLES_APP,
+ add_opening,
+ add_stair,
+ add_walls,
+ assign_tags,
+ draw_chains,
+ draw_stair,
+ draw_walls,
+ read_plan,
+)
+from engineering.arch.layers import ARCH_ROLE_LAYER
+from engineering.arch.model import APP_ID, Opening, Stair, Wall
+
+pytestmark = pytest.mark.asyncio
+
+WALL_A = {"id": "a", "axis": [[0.0, 0.0], [5000.0, 0.0]], "thickness": 200.0}
+WALL_B = {"id": "b", "axis": [[2500.0, 0.0], [2500.0, 3000.0]], "thickness": 100.0}
+
+
+async def on(backend, role, type_filter=None):
+ return await backend.entity_list(
+ type_filter=type_filter, layer_filter=ARCH_ROLE_LAYER[role], limit=10000
+ )
+
+
+def ends(info) -> tuple:
+ a = tuple(round(v, 6) + 0.0 for v in info.properties["start"][:2])
+ b = tuple(round(v, 6) + 0.0 for v in info.properties["end"][:2])
+ return tuple(sorted((a, b)))
+
+
+async def test_draw_walls_puts_faces_jambs_and_poche_on_their_layers(backend):
+ wall = Wall(id="a", axis=((0.0, 0.0), (5000.0, 0.0)), thickness=200.0)
+ door = Opening(id="d1", wall="a", kind="door", offset=1000.0, width=900.0, tag="D1")
+ result = await draw_walls(backend, [wall], [door])
+ lines = await on(backend, "wall", "LINE")
+ assert {ends(e) for e in lines} == {
+ ((0.0, 100.0), (1000.0, 100.0)),
+ ((1900.0, 100.0), (5000.0, 100.0)),
+ ((0.0, -100.0), (1000.0, -100.0)),
+ ((1900.0, -100.0), (5000.0, -100.0)),
+ ((0.0, -100.0), (0.0, 100.0)),
+ ((5000.0, -100.0), (5000.0, 100.0)),
+ ((1000.0, -100.0), (1000.0, 100.0)),
+ ((1900.0, -100.0), (1900.0, 100.0)),
+ }
+ assert len(await on(backend, "wall_hatch", "HATCH")) == 2
+ assert len(await on(backend, "door", "LINE")) == 1
+ assert len(await on(backend, "door", "ARC")) == 1
+ assert [w["id"] for w in result["walls"]] == ["a"]
+ assert [o["id"] for o in result["openings"]] == ["d1"]
+ assert result["omitted"] == []
+
+
+async def test_the_records_round_trip_through_xdata(backend):
+ wall = Wall(id="a", axis=((0.0, 0.0), (5000.0, 0.0)), thickness=200.0, material="aac")
+ window = Opening(id="w1", wall="a", kind="window", offset=2000.0, width=1200.0, tag="W1")
+ drawn = await draw_walls(backend, [wall], [window])
+ plan = await read_plan(backend)
+ assert plan["walls"] == [wall]
+ assert plan["openings"] == [window]
+ assert plan["carriers"]["wall:a"] == drawn["walls"][0]["record"]
+ carrier = drawn["openings"][0]["record"]
+ assert plan["carriers"]["opening:w1"] == carrier
+ # the window's record sits on its left-face frame line, with its four lines owned
+ raw = await backend.entity_get_xdata(carrier, HANDLES_APP)
+ assert len(raw["xdata"][HANDLES_APP]) == 4
+ assert (await backend.entity_get_xdata(carrier, APP_ID))["xdata"][APP_ID]
+
+
+async def test_add_walls_redraws_only_the_wall_the_new_one_joins(backend):
+ await add_walls(
+ backend,
+ [WALL_A, {"id": "c", "axis": [[0.0, 2000.0], [5000.0, 2000.0]], "thickness": 200.0}],
+ )
+ result = await add_walls(
+ backend, [{"id": "b", "axis": [[2500.0, 0.0], [2500.0, 1000.0]], "thickness": 100.0}]
+ )
+ assert result["added"] == ["b"]
+ assert result["redrawn"] == ["a"] # c is not touched by b
+ faces = {ends(e) for e in await on(backend, "wall", "LINE")}
+ # a's near face is now cut where b meets it; its far face is still one line
+ assert ((0.0, 100.0), (2450.0, 100.0)) in faces
+ assert ((2550.0, 100.0), (5000.0, 100.0)) in faces
+ assert ((0.0, -100.0), (5000.0, -100.0)) in faces
+ assert ((0.0, 100.0), (5000.0, 100.0)) not in faces # the old, uncut face was erased
+ plan = await read_plan(backend)
+ assert sorted(w.id for w in plan["walls"]) == ["a", "b", "c"]
+
+
+async def test_an_opening_added_later_cuts_its_wall(backend):
+ await add_walls(backend, [WALL_A])
+ before = {ends(e) for e in await on(backend, "wall", "LINE")}
+ assert ((0.0, 100.0), (5000.0, 100.0)) in before
+ result = await add_opening(
+ backend, {"wall": "a", "kind": "door", "offset": 1000.0, "width": 900.0}
+ )
+ assert result["opening"] == "D1" and result["tag"] == "D1"
+ assert result["redrawn"] == ["a"]
+ after = {ends(e) for e in await on(backend, "wall", "LINE")}
+ assert ((0.0, 100.0), (5000.0, 100.0)) not in after
+ assert {((0.0, 100.0), (1000.0, 100.0)), ((1900.0, 100.0), (5000.0, 100.0))} <= after
+ assert len(await on(backend, "door", "ARC")) == 1
+ second = await add_opening(
+ backend, {"wall": "a", "kind": "door", "offset": 3000.0, "width": 800.0}
+ )
+ assert second["tag"] == "D2"
+ assert len(await on(backend, "door", "ARC")) == 2 # the first door was redrawn, not lost
+ plan = await read_plan(backend)
+ assert sorted(op.tag for op in plan["openings"]) == ["D1", "D2"]
+
+
+async def test_an_opening_in_an_unknown_wall_or_overlapping_is_refused_untouched(backend):
+ await add_walls(backend, [WALL_A])
+ count = len(await backend.entity_list(limit=10000))
+ with pytest.raises(ValueError, match="'zz' is not a wall"):
+ await add_opening(backend, {"wall": "zz", "kind": "door", "offset": 0.0, "width": 900.0})
+ await add_opening(backend, {"wall": "a", "kind": "window", "offset": 1000.0, "width": 1200.0})
+ count = len(await backend.entity_list(limit=10000))
+ with pytest.raises(ValueError):
+ await add_opening(backend, {"wall": "a", "kind": "door", "offset": 1500.0, "width": 900.0})
+ assert len(await backend.entity_list(limit=10000)) == count
+
+
+async def test_a_wall_id_already_drawn_is_refused(backend):
+ await add_walls(backend, [WALL_A])
+ with pytest.raises(ValueError, match=r"walls\[0\]\.id: 'a' is already drawn"):
+ await add_walls(backend, [WALL_A])
+
+
+async def test_a_new_wall_that_would_swallow_an_opening_is_refused(backend):
+ await add_walls(backend, [WALL_A])
+ await add_opening(backend, {"wall": "a", "kind": "door", "offset": 2200.0, "width": 900.0})
+ count = len(await backend.entity_list(limit=10000))
+ with pytest.raises(ValueError, match="uncuttable"):
+ await add_walls(backend, [WALL_B])
+ assert len(await backend.entity_list(limit=10000)) == count
+
+
+async def test_draw_stair_writes_its_record_and_reports_blondel(backend):
+ stair = Stair(
+ id="s1",
+ start=(0.0, 0.0),
+ direction_deg=0.0,
+ width=1000.0,
+ risers=17,
+ riser_height=170.0,
+ going=290.0,
+ )
+ result = await draw_stair(backend, stair)
+ assert result["blondel"] == {"value": 630.0, "ok": True, "range": [600.0, 650.0]}
+ assert result["treads"] == 16
+ assert len(await on(backend, "stair", "LINE")) == 17 + 2 + 2
+ assert (await read_plan(backend))["stairs"] == [stair]
+ with pytest.raises(ValueError, match="'s1' is already drawn"):
+ await add_stair(
+ backend,
+ {
+ "id": "s1",
+ "start": [0, 0],
+ "direction_deg": 0.0, # required by Task 1's stair_from_dict
+ "width": 1000.0,
+ "risers": 17,
+ "riser_height": 170.0,
+ "going": 290.0,
+ },
+ )
+
+
+async def test_draw_chains_puts_real_dimensions_on_the_dimension_layer(backend):
+ shell = Wall(
+ id="shell",
+ axis=((0.0, 0.0), (8200.0, 0.0), (8200.0, 6200.0), (0.0, 6200.0)),
+ thickness=200.0,
+ closed=True,
+ )
+ result = await draw_chains(backend, [shell], [], sides=("bottom", "left"), scale=50)
+ dims = await on(backend, "dim", "DIMENSION")
+ assert len(dims) == result["count"] == 8 # per side: walls (3) + overall (1)
+ assert {row["row"] for row in result["dimensions"]} == {
+ "bottom:walls",
+ "bottom:overall",
+ "left:walls",
+ "left:overall",
+ }
+
+
+async def test_assign_tags_numbers_each_kind_in_the_chosen_language():
+ ops = [
+ Opening(id="1", wall="a", kind="door", offset=0.0, width=900.0),
+ Opening(id="2", wall="a", kind="window", offset=2000.0, width=900.0),
+ Opening(id="3", wall="a", kind="door", offset=4000.0, width=900.0, tag="D1"),
+ ]
+ assert [op.tag for op in assign_tags(ops)] == ["D2", "W1", "D1"]
+ assert [op.tag for op in assign_tags(ops[:2], lang="tr")] == ["K1", "P1"]
diff --git a/tests/test_arch_tools.py b/tests/test_arch_tools.py
new file mode 100644
index 0000000..042a0d3
--- /dev/null
+++ b/tests/test_arch_tools.py
@@ -0,0 +1,124 @@
+"""SECTION 25's first four tools, called the way a client calls them.
+
+In-memory client on a fresh headless document; the env var pins ezdxf so no
+test here can reach an operator's live drawing (tests/conftest.py).
+"""
+
+from __future__ import annotations
+
+import pytest
+import pytest_asyncio
+from fastmcp import Client
+from fastmcp.exceptions import ToolError
+
+import server
+
+pytestmark = pytest.mark.asyncio
+
+ARCH_TOOLS = {"arch_wall", "arch_opening", "arch_stair", "arch_dimension_chains"}
+
+
+@pytest_asyncio.fixture
+async def client(monkeypatch):
+ monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
+ async with Client(server.mcp) as connected:
+ status = (await connected.call_tool("system_status", {})).structured_content or {}
+ assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD"
+ await connected.call_tool("drawing_new", {})
+ yield connected
+
+
+async def call(client, name, args) -> dict:
+ return (await client.call_tool(name, args)).structured_content
+
+
+async def test_the_four_tools_are_registered_under_the_architecture_group():
+ tools = {tool.name: tool for tool in await server._registered_tools()}
+ assert ARCH_TOOLS <= set(tools)
+ groups = await server._tool_groups()
+ assert set(groups["architecture"]) == ARCH_TOOLS
+ for name in ARCH_TOOLS:
+ card = (tools[name].meta or {})["cad"]
+ assert card["cost"] == "mutate"
+ assert card["synonyms"], f"{name} needs an alias record"
+
+
+async def test_a_plan_built_tool_by_tool(client):
+ walls = await call(
+ client,
+ "arch_wall",
+ {
+ "walls": [
+ {
+ "id": "shell",
+ "axis": [[0, 0], [8200, 0], [8200, 6200], [0, 6200]],
+ "thickness": 200,
+ "closed": True,
+ }
+ ]
+ },
+ )
+ assert walls["added"] == ["shell"] and walls["redrawn"] == []
+ partition = await call(
+ client,
+ "arch_wall",
+ {"walls": [{"id": "p", "axis": [[4100, 0], [4100, 6200]], "thickness": 100}]},
+ )
+ assert partition["redrawn"] == ["shell"]
+ door = await call(
+ client, "arch_opening", {"wall": "shell", "offset": 5500, "width": 1000, "swing": "in"}
+ )
+ assert door["tag"] == "D1"
+ window = await call(
+ client, "arch_opening", {"wall": "shell", "kind": "window", "offset": 1500, "width": 1200}
+ )
+ assert window["tag"] == "W1"
+ stair = await call(
+ client,
+ "arch_stair",
+ {
+ "stair_id": "s1",
+ "start_x": 1000,
+ "start_y": 1500,
+ "width": 1000,
+ "risers": 17,
+ "riser_height": 170,
+ "going": 290,
+ "direction_deg": 90,
+ },
+ )
+ assert stair["blondel"]["ok"] is True
+ chains = await call(client, "arch_dimension_chains", {"sides": ["bottom"]})
+ # 5 openings + 5 walls + 1 overall, hand-counted in tests/test_arch_dimension.py
+ assert chains["count"] == 11
+
+
+async def test_refusals_come_back_as_tool_errors_that_name_the_cause(client):
+ with pytest.raises(ToolError, match="no architectural walls"):
+ await call(client, "arch_dimension_chains", {})
+ with pytest.raises(ToolError, match="'nowhere' is not a wall"):
+ await call(client, "arch_opening", {"wall": "nowhere", "offset": 0, "width": 900})
+ await call(
+ client, "arch_wall", {"walls": [{"id": "a", "axis": [[0, 0], [5000, 0]], "thickness": 200}]}
+ )
+ with pytest.raises(ToolError, match="5°"):
+ await call(
+ client,
+ "arch_wall",
+ {"walls": [{"id": "b", "axis": [[-2000, -150], [2000, 0]], "thickness": 200}]},
+ )
+ with pytest.raises(ToolError, match="at least 4"):
+ await call(
+ client,
+ "arch_stair",
+ {
+ "stair_id": "s",
+ "start_x": 0,
+ "start_y": 0,
+ "width": 900,
+ "risers": 3,
+ "riser_height": 170,
+ "going": 290,
+ "kind": "u",
+ },
+ )
diff --git a/tests/test_tool_packs.py b/tests/test_tool_packs.py
index 601afae..42769cb 100644
--- a/tests/test_tool_packs.py
+++ b/tests/test_tool_packs.py
@@ -246,7 +246,7 @@ async def test_the_three_track_e_sections_file_under_their_own_groups():
assert set(groups["environment"]) == SETTINGS_TOOLS
assert set(groups["mechanical"]) == MECH_TOOLS
assert set(groups["sheet"]) == SHEET_TOOLS
- assert len(groups) == 25
+ assert len(groups) == 26 # + architecture (SECTION 25, track F)
async def test_system_about_reports_packs():
diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py
index aa37309..c6aad53 100644
--- a/tests/test_tool_registry.py
+++ b/tests/test_tool_registry.py
@@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
async def test_tool_group_sizes_are_unchanged():
- """Frozen snapshot of the surface (229 tools, 25 groups).
+ """Frozen snapshot of the surface (233 tools, 26 groups).
Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when
v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and
@@ -581,10 +581,17 @@ async def test_tool_group_sizes_are_unchanged():
`sheet`, so pack, group and SECTION are one list again: `engineering`
returned 17 -> 10, `blocks` 11 -> 9, `entity_creation` 19 -> 18 and
`drawing` 12 -> 11 — every one of them its pre-SECTION-24 value.
+
+ `architecture` appeared with v1.6's track F: SECTION 25 opened with the
+ plan model's wall engine (`arch_wall` / `arch_opening` / `arch_stair` /
+ `arch_dimension_chains`); the `arch` tag is ranked directly after `mech` in
+ `_GROUP_TAG_PRIORITY`, so those tools file under `architecture` rather
+ than under their secondary `create` / `dimension` tags.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
assert sizes == {
"analysis": 12,
+ "architecture": 4,
"batch": 3,
"blocks": 9,
"corner_ops": 4,
@@ -610,4 +617,4 @@ async def test_tool_group_sizes_are_unchanged():
"validation": 1,
"view": 4,
}
- assert sum(sizes.values()) == 229
+ assert sum(sizes.values()) == 233