feat(arch): SECTION 25 opens - walls, openings, stairs and exterior chains as tools

engineering/arch/draw.py is the only module of the track that touches a
backend. It draws through draw_prims(role_layer=ARCH_ROLE_LAYER) and the
generic contract members, so it runs on both engines as written, and it writes
the plan onto the drawing: each element's ACADMCP_ARCH record on one of its own
entities (a wall's first face line, a door's leaf, a window's frame, a stair's
first riser) and, beside it under ACADMCP_ARCH_H, the handles that element
owns. read_plan reads the records back and never modifies the drawing.

arch_wall computes the whole network before and after the new walls and
redraws only the drawn walls whose outline changes - erasing exactly the
entities they own - and says which under redrawn. arch_opening hosts a door or
a window in a drawn wall found by id and redraws that wall cut; its tag
defaults to the next D1/W1 (K1/P1 in Turkish). A new wall that would leave a
drawn opening uncuttable, and an opening that runs into a junction, are refused
before anything is erased. arch_stair reports the Blondel value; it does not
enforce it.

exterior_chains gives each side up to three rows, nearest first: every jamb in
that facade, both faces of every wall meeting it, and the overall - from the
wall engine's own faces. A row that would only restate a farther one is not
emitted (ISO 129-1: each measurement once). The 10 mm / 8 mm row spacing is a
declared drafting default times the plot scale, not a standard value.

The arch tag is ranked after mech so the four tools file under a new
architecture group; alias records carry WALLADD/MLINE, DOORADD/WINDOWADD and
STAIRADD with English and Turkish shop words. Inventory regenerated; README
snapshot 233 tools (228 advertised) in 26 groups; registry snapshot
architecture: 4.

No new ActiveX member is called. Measured on AutoCAD 2026 in a scratch
document through ComBackend: shell + teed partition, D1 + W1, a straight 17-riser stair
and the bottom chains gave the same 20 wall LINEs (identical endpoints),
9 poche HATCHes, 1+1 door, 4 window lines, 11 dimensions and a read_plan
round trip as the headless engine.

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 23:00:05 +03:00
co-authored by Claude Opus 5.5
parent b68fe7ef66
commit cd1ae3529c
12 changed files with 1511 additions and 8 deletions
+3 -3
View File
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
</div>
> **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 |
<sub>229 tools in 25 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools, the 22 environment tools and the 14 mechanical tools (<code>TOOL_PACKS=core,mech</code> keeps the last) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
<sub>233 tools in 26 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools, the 22 environment tools and the 14 mechanical tools (<code>TOOL_PACKS=core,mech</code> keeps the last) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
**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.
+74
View File
@@ -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",
),
),
}
+59 -2
View File
@@ -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",
+12
View File
@@ -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",
+208
View File
@@ -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 ``"<side>:<row>"`` (``"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)
+416
View File
@@ -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 ``"<kind>:<id>"`` 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,
}
+291
View File
@@ -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 ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
+107
View File
@@ -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")
+207
View File
@@ -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"]
+124
View File
@@ -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",
},
)
+1 -1
View File
@@ -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():
+9 -2
View File
@@ -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