diff --git a/README.md b/README.md
index 6c4c2b7..0b91788 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:** 231 tools · 8 resources · 5 prompt templates · 4005 collected tests.
-> 231 is the **registered** count; a default install advertises 226 over `tools/list`,
+> **v1.5 release snapshot:** 232 tools · 8 resources · 5 prompt templates · 4005 collected tests.
+> 232 is the **registered** count; a default install advertises 227 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 |
-231 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.
+232 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.
**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/backends/com_backend.py b/backends/com_backend.py
index 07b5a1f..f224529 100644
--- a/backends/com_backend.py
+++ b/backends/com_backend.py
@@ -4792,14 +4792,15 @@ class ComBackend(AutoCADBackend):
)
for column, width in enumerate(layout.column_widths):
table.SetColumnWidth(column, width)
+ # MEASURED on AutoCAD 2026: IAcadTable has no TextHeight property
+ # (the old `table.TextHeight = h` raised and was swallowed, leaving
+ # the Standard style's 6.0 / 4.5 - illegible at 1:50). The member
+ # is SetCellTextHeight(row, col, h), set per cell.
for row_index, row in enumerate(layout.cells):
table.SetRowHeight(row_index, layout.row_height)
for column_index, value in enumerate(row):
table.SetText(row_index, column_index, value)
- try:
- table.TextHeight = layout.text_height
- except Exception as exc:
- log.debug("setting table TextHeight failed: %s", exc)
+ table.SetCellTextHeight(row_index, column_index, layout.text_height)
_apply_entity_attrs(table, layer, None, None)
_regen()
info = _entity_info(table)
diff --git a/discovery/aliases.py b/discovery/aliases.py
index fdf95fc..9526b19 100644
--- a/discovery/aliases.py
+++ b/discovery/aliases.py
@@ -94,8 +94,9 @@ class ToolAliases:
# REVCLOUD -- one command draws a bare cloud (entity_create_revcloud) or a
# revision cloud that also files a revision row (revision_add); a
# drafter typing it could mean either.
-# TABLE -- one command makes an empty table (entity_create_table) or the
-# ISO 7573 parts list (bom_table); both are correct destinations.
+# TABLE -- one command makes an empty table (entity_create_table), the
+# ISO 7573 parts list (bom_table) or a door / window / room schedule
+# (arch_schedule); all three are correct destinations.
# SECTIONPLANE -- the section a drafter asks for is either a whole section
# view of a drawn part (mech_view_add) or the cutting-plane line and its
# labels on an existing view (section_line); tracks B+G groups M and A
@@ -2260,6 +2261,18 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"kat plani alanlari",
),
),
+ "arch_schedule": ToolAliases(
+ acad=("TABLE",),
+ synonyms=(
+ "door schedule",
+ "window schedule",
+ "room schedule",
+ "opening schedule",
+ "kapi cizelgesi",
+ "pencere cizelgesi",
+ "mahal listesi",
+ ),
+ ),
}
diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json
index 30bb0a9..7708050 100644
--- a/docs/tool-inventory.json
+++ b/docs/tool-inventory.json
@@ -3,7 +3,7 @@
"schema_version": 1,
"package_version": "1.5.1",
"totals": {
- "tools": 231,
+ "tools": 232,
"resources": 8,
"prompts": 5,
"groups": 25
@@ -16,7 +16,7 @@
"dimensions": 5,
"drawing": 11,
"engineering": 10,
- "entity_creation": 19,
+ "entity_creation": 20,
"entity_modification": 16,
"entity_query": 10,
"environment": 22,
@@ -208,6 +208,20 @@
"readOnlyHint": true
}
},
+ {
+ "name": "arch_schedule",
+ "group": "entity_creation",
+ "cost": "mutate",
+ "summary": "Draw a door, window or room schedule from the plan records.",
+ "tags": [
+ "arch",
+ "create"
+ ],
+ "annotations": {
+ "title": "Architecture: Schedule",
+ "destructiveHint": false
+ }
+ },
{
"name": "balloon_add",
"group": "sheet",
diff --git a/engineering/arch/__init__.py b/engineering/arch/__init__.py
index 5292a46..c7be50f 100644
--- a/engineering/arch/__init__.py
+++ b/engineering/arch/__init__.py
@@ -39,6 +39,7 @@ from .rooms import (
room_label_prims,
rooms_detect,
)
+from .schedule import SCHEDULE_KINDS, draw_schedule, schedule_rows
__all__ = [
"APP_ID",
@@ -52,6 +53,7 @@ __all__ = [
"LANGS",
"Opening",
"Room",
+ "SCHEDULE_KINDS",
"Stair",
"WALL_HATCH_TABLE",
"WALL_MATERIALS",
@@ -59,6 +61,7 @@ __all__ = [
"axis_segments",
"decode",
"detect_rooms",
+ "draw_schedule",
"encode",
"face_containing",
"fmt_area_m2",
@@ -72,6 +75,7 @@ __all__ = [
"room_from_dict",
"room_label_prims",
"rooms_detect",
+ "schedule_rows",
"segment_of",
"stair_from_dict",
"tag_prefix",
diff --git a/engineering/arch/schedule.py b/engineering/arch/schedule.py
new file mode 100644
index 0000000..689c205
--- /dev/null
+++ b/engineering/arch/schedule.py
@@ -0,0 +1,284 @@
+"""Door, window and room schedules - a read of the drawing, drawn as a real TABLE.
+
+`schedule_rows` turns plan data (the shape `read_plan` returns: ``walls``,
+``openings``, ``rooms`` lists of model dataclasses) into rows; `draw_schedule`
+reads that data off the drawing's ``ACADMCP_ARCH`` records and draws it through
+the same `entity_create_table` path the ISO 7573 parts list takes
+(`engineering/sheet/bom.py::draw_bom_table`): insertion at the top-left corner,
+the table growing downwards, and the engine's representation reported rather
+than assumed - a real ACAD_TABLE live, a composite of rules and MTEXT headless.
+
+Rows:
+
+* doors - tag, width, height, swing, hand, wall; windows - tag, width,
+ height, sill, wall. Sorted by tag, numbers compared as numbers (D2 before
+ D10). An opening without a tag gets the next free ```` in wall
+ order (the order of ``walls``, then the offset along the wall); an explicit
+ tag is never renumbered and a generated one never repeats it.
+* rooms - number, name, area (the measured one the label carries), sorted by
+ number with the unnumbered last, plus a total row.
+
+A value the model does not carry (a door without a height) is an empty cell,
+never a default. Headers come from `vocab(lang)`; ``lang="tr"`` also writes
+decimal commas.
+
+DECLARED, not standard: the paper column widths below and the reuse of the
+parts list's 7 mm row and 2.5 mm text (`engineering.sheet.bom.ROW_HEIGHT`,
+`TEXT_HEIGHT`) - no standard fixes a schedule's layout.
+"""
+
+from __future__ import annotations
+
+import math
+import re
+from typing import TYPE_CHECKING
+
+from engineering.arch.lang import LANGS, fmt_area_m2, fmt_number, tag_prefix, vocab
+from engineering.arch.layers import ARCH_LAYERS, ARCH_ROLE_LAYER
+
+if TYPE_CHECKING:
+ from backends.base import AutoCADBackend
+
+__all__ = [
+ "COLUMN_WIDTHS",
+ "MAX_ROWS",
+ "SCHEDULE_COLUMNS",
+ "SCHEDULE_KINDS",
+ "SCHEDULE_TITLES",
+ "draw_schedule",
+ "schedule_rows",
+]
+
+SCHEDULE_KINDS: tuple[str, ...] = ("doors", "windows", "rooms")
+
+SCHEDULE_COLUMNS: dict[str, tuple[str, ...]] = {
+ "doors": ("tag", "width", "height", "swing", "hand", "wall"),
+ "windows": ("tag", "width", "height", "sill", "wall"),
+ "rooms": ("number", "name", "area"),
+}
+
+#: The `vocab` key of each schedule's title row.
+SCHEDULE_TITLES: dict[str, str] = {
+ "doors": "door_schedule",
+ "windows": "window_schedule",
+ "rooms": "room_schedule",
+}
+
+#: Column widths in paper millimetres (x the scale denominator in the drawing).
+COLUMN_WIDTHS: dict[str, float] = {
+ "tag": 15.0,
+ "width": 20.0,
+ "height": 20.0,
+ "swing": 20.0,
+ "hand": 20.0,
+ "sill": 20.0,
+ "wall": 20.0,
+ "number": 15.0,
+ "name": 45.0,
+ "area": 25.0,
+}
+
+#: `entity_create_table` takes at most 200 rows, the title and header included.
+MAX_ROWS = 198
+
+_OPENING_KIND = {"doors": "door", "windows": "window"}
+
+
+def _check(kind: str, lang: str) -> None:
+ if kind not in SCHEDULE_KINDS:
+ raise ValueError(
+ f"kind: {kind!r} is not a schedule; schedules are {', '.join(SCHEDULE_KINDS)}"
+ )
+ if lang not in LANGS:
+ raise ValueError(
+ f"lang: {lang!r} is not a schedule language; languages are {', '.join(LANGS)}"
+ )
+
+
+def _natural(text: str) -> tuple:
+ """``D10`` after ``D2``: digit runs compare as numbers."""
+ return tuple(
+ (0, int(part), "") if part.isdigit() else (1, 0, part)
+ for part in re.findall(r"\d+|\D+", str(text))
+ )
+
+
+def _mm(value, lang: str) -> str:
+ """A length in mm: whole numbers without decimals, others to 0.1 mm; None is blank."""
+ if value is None:
+ return ""
+ number = float(value)
+ decimals = 0 if abs(number - round(number)) < 1e-9 else 1
+ return fmt_number(number, lang, decimals)
+
+
+def _opening_rows(kind: str, plan: dict, lang: str) -> tuple[dict, ...]:
+ words = vocab(lang)
+ wanted = _OPENING_KIND[kind]
+ wall_order = {wall.id: index for index, wall in enumerate(plan.get("walls") or ())}
+ openings = [o for o in plan.get("openings") or () if o.kind == wanted]
+ openings.sort(
+ key=lambda o: (wall_order.get(o.wall, len(wall_order)), o.wall, float(o.offset), o.id)
+ )
+ prefix = tag_prefix(wanted, lang)
+ used = {str(o.tag) for o in openings if o.tag}
+ counter = 0
+ rows: list[dict] = []
+ for opening in openings:
+ tag = str(opening.tag) if opening.tag else None
+ generated = tag is None
+ if generated:
+ counter += 1
+ while f"{prefix}{counter}" in used:
+ counter += 1
+ tag = f"{prefix}{counter}"
+ used.add(tag)
+ row = {
+ "id": opening.id,
+ "tag": tag,
+ "generated": generated,
+ "width": _mm(opening.width, lang),
+ "height": _mm(opening.height, lang),
+ "wall": opening.wall,
+ }
+ if kind == "doors":
+ row["swing"] = words[opening.swing]
+ row["hand"] = words[opening.hand]
+ else:
+ row["sill"] = _mm(opening.sill, lang)
+ rows.append(row)
+ rows.sort(key=lambda row: _natural(row["tag"]))
+ return tuple(rows)
+
+
+def _room_rows(plan: dict, lang: str) -> tuple[dict, ...]:
+ rooms = sorted(
+ plan.get("rooms") or (),
+ key=lambda r: (
+ r.number is None or str(r.number) == "",
+ _natural(r.number or ""),
+ r.name,
+ r.id,
+ ),
+ )
+ if not rooms:
+ return ()
+ rows = [
+ {
+ "id": room.id,
+ "number": "" if room.number is None else str(room.number),
+ "name": room.name,
+ "area": fmt_area_m2(room.area, lang),
+ "area_mm2": float(room.area),
+ "total": False,
+ }
+ for room in rooms
+ ]
+ total = math.fsum(float(room.area) for room in rooms)
+ rows.append(
+ {
+ "id": "",
+ "number": "",
+ "name": vocab(lang)["total"],
+ "area": fmt_area_m2(total, lang),
+ "area_mm2": total,
+ "total": True,
+ }
+ )
+ return tuple(rows)
+
+
+def schedule_rows(kind: str, plan: dict, *, lang: str = "en") -> tuple[dict, ...]:
+ """The rows of one schedule, each a dict holding every column of ``SCHEDULE_COLUMNS[kind]``
+ as display text, plus ``id`` (and ``generated`` for openings, ``area_mm2`` and
+ ``total`` for rooms). Refused: an unknown kind or language, named with the list."""
+ _check(kind, lang)
+ if kind == "rooms":
+ return _room_rows(plan, lang)
+ return _opening_rows(kind, plan, lang)
+
+
+async def _ensure_layer(backend: AutoCADBackend, name: str) -> None:
+ """Create the schedule layer from its `ARCH_LAYERS` row when the drawing lacks it.
+
+ Live ActiveX will not create a layer on assignment (the repository rule),
+ so the layer exists before the table does.
+ """
+ if name.casefold() in {layer.name.casefold() for layer in await backend.layer_list()}:
+ return
+ row = next(r for r in ARCH_LAYERS if r[0] == name)
+ await backend.layer_create(name=row[0], color=row[1], linetype=row[2], lineweight=row[3])
+
+
+async def draw_schedule(
+ backend: AutoCADBackend, kind: str, *, at, lang: str = "en", scale=50
+) -> dict:
+ """Read the drawing's records and draw one schedule as a TABLE whose top-left is ``at``.
+
+ Refused before anything is drawn: an unknown kind or language, a scale that
+ is not a finite positive number, an ``at`` that is not two finite numbers,
+ a drawing with nothing to schedule, and more rows than one TABLE takes.
+ """
+ from engineering.arch.rooms import read_records # the one ACADMCP_ARCH reader
+ from engineering.sheet.bom import ROW_HEIGHT, TEXT_HEIGHT
+
+ _check(kind, lang)
+ try:
+ factor = float(scale)
+ except (TypeError, ValueError):
+ raise ValueError(f"scale: expected a number, got {scale!r}") from None
+ if not math.isfinite(factor) or factor <= 0.0:
+ raise ValueError(f"scale: must be a finite number greater than zero, got {scale!r}")
+ try:
+ x, y = float(at[0]), float(at[1])
+ except (TypeError, ValueError, IndexError):
+ raise ValueError(f"at: expected [x, y], got {at!r}") from None
+ if not (math.isfinite(x) and math.isfinite(y)):
+ raise ValueError(f"at: must be two finite numbers, got {at!r}")
+
+ plan = await read_records(backend)
+ rows = schedule_rows(kind, plan, lang=lang)
+ if not rows:
+ raise ValueError(
+ f"kind: this drawing carries no {kind[:-1]} records to schedule "
+ "(ACADMCP_ARCH on the architectural layers); draw the plan first"
+ )
+ if len(rows) > MAX_ROWS:
+ raise ValueError(
+ f"kind: {len(rows)} {kind} rows do not fit one TABLE ({MAX_ROWS} data rows at most)"
+ )
+ words = vocab(lang)
+ columns = SCHEDULE_COLUMNS[kind]
+ layer = ARCH_ROLE_LAYER["symbol"]
+ await _ensure_layer(backend, layer)
+ entity = await backend.entity_create_table(
+ x,
+ y,
+ [[row[c] for c in columns] for row in rows],
+ headers=[words[c] for c in columns],
+ column_widths=[COLUMN_WIDTHS[c] * factor for c in columns],
+ row_height=ROW_HEIGHT * factor,
+ text_height=TEXT_HEIGHT * factor,
+ title=words[SCHEDULE_TITLES[kind]],
+ layer=layer,
+ )
+ width = sum(COLUMN_WIDTHS[c] for c in columns) * factor
+ height = (len(rows) + 2) * ROW_HEIGHT * factor
+ return {
+ "ok": True,
+ "kind": kind,
+ "lang": lang,
+ "handle": entity.handle,
+ "entity_type": entity.type,
+ "representation": entity.properties.get("representation", "native"),
+ "child_handles": list(entity.properties.get("child_handles") or []),
+ "layer": layer,
+ "columns": list(columns),
+ "headers": [words[c] for c in columns],
+ "title": words[SCHEDULE_TITLES[kind]],
+ "rows": [dict(row) for row in rows],
+ "row_count": len(rows),
+ "table_rows": entity.properties.get("rows"),
+ "insert": [x, y],
+ "bbox": {"min": [x, y - height], "max": [x + width, y]},
+ }
diff --git a/server.py b/server.py
index 6c6524d..059b2a6 100644
--- a/server.py
+++ b/server.py
@@ -9781,7 +9781,7 @@ async def drawing_export_dwg(
# ---------------------------------------------------------------------------
-# ── SECTION 25: Architecture (2 tools) ──────────────────────────────────────
+# ── SECTION 25: Architecture (3 tools) ──────────────────────────────────────
# ---------------------------------------------------------------------------
@@ -9899,6 +9899,49 @@ async def arch_rooms_detect(
raise ToolError(str(exc)) from exc
+@cad_tool(summary="Draw a door, window or room schedule from the plan records.", cost="mutate")
+@mcp.tool(
+ annotations={"title": "Architecture: Schedule", "destructiveHint": False},
+ tags={"arch", "create"},
+)
+async def arch_schedule(
+ kind: Annotated[str, Field(description="doors | windows | rooms")],
+ x: Annotated[float, Field(description="WCS X of the table's top-left corner.")],
+ y: Annotated[float, Field(description="WCS Y of the table's top-left corner.")],
+ lang: Annotated[
+ str, Field(default="en", description="Headers and numbers: en | tr (decimal comma).")
+ ] = "en",
+ scale: Annotated[
+ float,
+ Field(default=50.0, gt=0, description="Plot-scale denominator: 50 is 1:50."),
+ ] = 50.0,
+ ctx: Context = None,
+) -> dict:
+ """Draw a door, window or room schedule as a real TABLE - a read of the drawing.
+
+ The rows come from the `ACADMCP_ARCH` records on the drawing (what
+ `arch_wall` / `arch_opening` / `arch_room` write), never from the caller:
+ doors by tag with width, height, swing, hand and wall; windows by tag with
+ width, height, sill and wall; rooms by number with name and the measured
+ area, plus a total. An opening drawn without a tag gets the next free D1 /
+ W1 (K1 / P1 in Turkish) in wall order; an explicit tag is kept. A value the
+ model does not carry is an empty cell, never a default. `representation`
+ reports what was drawn: `native` (ACAD_TABLE, live) or `composite` (rules
+ and MTEXT, headless).
+
+ Refused before anything is drawn: an unknown `kind` or `lang` (named with
+ the list), nothing of that kind on the drawing, and more rows than one
+ TABLE takes (198).
+ """
+ from engineering.arch.schedule import draw_schedule
+
+ await ctx.info(f"arch_schedule: {kind} at ({x}, {y})")
+ try:
+ return await draw_schedule(_backend(ctx), kind, at=(x, y), lang=lang, scale=scale)
+ except ValueError as exc:
+ raise ToolError(str(exc)) from exc
+
+
# ---------------------------------------------------------------------------
# ── RESOURCES ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
diff --git a/tests/test_arch_schedule.py b/tests/test_arch_schedule.py
new file mode 100644
index 0000000..02076ab
--- /dev/null
+++ b/tests/test_arch_schedule.py
@@ -0,0 +1,200 @@
+"""Door, window and room schedules: rows from the plan model, a TABLE from the drawing.
+
+Records are written with `model.to_values` directly - in production the wall
+engine (Task 4, another branch) and `arch_room` write them; the record shape is
+pinned by the skeleton.
+"""
+
+from __future__ import annotations
+
+import re
+
+import pytest
+
+from engineering.arch.lang import vocab
+from engineering.arch.layers import ARCH_ROLE_LAYER
+from engineering.arch.model import APP_ID, Opening, Room, Wall, to_payload, to_values
+from engineering.arch.schedule import SCHEDULE_COLUMNS, draw_schedule, schedule_rows
+
+WALLS = [
+ Wall(id="w1", axis=((0.0, 0.0), (6000.0, 0.0)), thickness=200.0),
+ Wall(id="w2", axis=((6000.0, 0.0), (6000.0, 4000.0)), thickness=200.0),
+]
+
+
+def plan():
+ return {
+ "walls": list(WALLS),
+ "openings": [
+ Opening(
+ id="da",
+ wall="w2",
+ kind="door",
+ offset=500.0,
+ width=800.0,
+ swing="out",
+ hand="right",
+ ),
+ Opening(id="db", wall="w1", kind="door", offset=3000.0, width=912.5, height=2100.0),
+ Opening(
+ id="dc", wall="w1", kind="door", offset=1000.0, width=900.0, height=2100.0, tag="D2"
+ ),
+ Opening(
+ id="wa",
+ wall="w1",
+ kind="window",
+ offset=4000.0,
+ width=1200.0,
+ height=1200.0,
+ sill=900.0,
+ ),
+ ],
+ "rooms": [
+ Room(id="R1", name="KITCHEN", number="2", at=(0.0, 0.0), area=15_000_000.0),
+ Room(id="R2", name="LIVING", number="10", at=(0.0, 0.0), area=20_000_000.0),
+ Room(id="R3", name="STORE", number=None, at=(0.0, 0.0), area=10_000_000.0),
+ ],
+ }
+
+
+def test_door_rows_generate_missing_tags_in_wall_order_and_sort_by_tag():
+ rows = schedule_rows("doors", plan(), lang="en")
+ en = vocab("en")
+ # wall order: w1 at 1000 (tagged D2), w1 at 3000 -> D1, w2 at 500 -> D3 (D2 is taken)
+ assert [(r["id"], r["tag"], r["generated"]) for r in rows] == [
+ ("db", "D1", True),
+ ("dc", "D2", False),
+ ("da", "D3", True),
+ ]
+ assert rows[0] == {
+ "id": "db",
+ "tag": "D1",
+ "generated": True,
+ "width": "912.5",
+ "height": "2100",
+ "swing": en["in"],
+ "hand": en["left"],
+ "wall": "w1",
+ }
+ # no height on the model: an empty cell, never a default
+ assert rows[2]["height"] == ""
+ assert (rows[2]["swing"], rows[2]["hand"]) == (en["out"], en["right"])
+ assert set(SCHEDULE_COLUMNS["doors"]) <= set(rows[0])
+
+
+def test_turkish_door_rows_use_k_tags_decimal_commas_and_turkish_words():
+ rows = schedule_rows("doors", plan(), lang="tr")
+ tr = vocab("tr")
+ # explicit "D2" is never renamed; generated tags take the Turkish prefix
+ assert [r["tag"] for r in rows] == ["D2", "K1", "K2"]
+ k1 = next(r for r in rows if r["tag"] == "K1")
+ assert k1["width"] == "912,5"
+ assert (k1["swing"], k1["hand"]) == (tr["in"], tr["left"])
+
+
+def test_window_rows_carry_sill_and_no_swing():
+ (row,) = schedule_rows("windows", plan(), lang="en")
+ assert row == {
+ "id": "wa",
+ "tag": "W1",
+ "generated": True,
+ "width": "1200",
+ "height": "1200",
+ "sill": "900",
+ "wall": "w1",
+ }
+ assert [r["tag"] for r in schedule_rows("windows", plan(), lang="tr")] == ["P1"]
+
+
+def test_room_rows_sort_by_number_with_the_unnumbered_last_and_end_in_a_total():
+ rows = schedule_rows("rooms", plan(), lang="en")
+ assert [(r["number"], r["name"], r["area"]) for r in rows] == [
+ ("2", "KITCHEN", "15.00 m²"),
+ ("10", "LIVING", "20.00 m²"),
+ ("", "STORE", "10.00 m²"),
+ ("", vocab("en")["total"], "45.00 m²"), # 15 + 20 + 10
+ ]
+ assert rows[-1]["total"] is True
+ assert rows[-1]["area_mm2"] == 45_000_000.0
+
+
+def test_turkish_room_rows_write_the_total_with_a_decimal_comma():
+ rows = schedule_rows("rooms", plan(), lang="tr")
+ assert (rows[-1]["name"], rows[-1]["area"]) == (vocab("tr")["total"], "45,00 m²")
+ assert rows[0]["area"] == "15,00 m²"
+
+
+def test_an_empty_plan_has_no_rows_and_no_total():
+ assert schedule_rows("rooms", {"walls": [], "openings": [], "rooms": []}) == ()
+ assert schedule_rows("doors", {"walls": [], "openings": [], "rooms": []}) == ()
+
+
+@pytest.mark.parametrize(
+ ("kind", "lang", "message"),
+ [
+ ("stairs", "en", "kind: 'stairs' is not a schedule; schedules are doors, windows, rooms"),
+ ("doors", "de", "lang: 'de' is not a schedule language; languages are en, tr"),
+ ],
+)
+def test_an_unknown_kind_or_language_is_refused_with_the_list(kind, lang, message):
+ with pytest.raises(ValueError, match=re.escape(message)):
+ schedule_rows(kind, plan(), lang=lang)
+
+
+# ── async: the TABLE ────────────────────────────────────────────────────────
+
+
+async def write_record(backend, obj, role):
+ carrier = await backend.entity_create_line(0.0, 0.0, 1.0, 0.0, layer=ARCH_ROLE_LAYER[role])
+ await backend.entity_set_xdata(carrier.handle, APP_ID, to_values(to_payload(obj)))
+
+
+async def write_plan(backend):
+ data = plan()
+ for wall in data["walls"]:
+ await write_record(backend, wall, "wall")
+ for opening in data["openings"]:
+ await write_record(backend, opening, opening.kind)
+ for room in data["rooms"]:
+ await write_record(backend, room, "room")
+
+
+@pytest.mark.asyncio
+async def test_a_door_schedule_is_a_table_with_title_header_and_one_row_per_door(backend):
+ await write_plan(backend)
+ result = await draw_schedule(backend, "doors", at=(10_000.0, 5_000.0), lang="en", scale=50)
+ assert result["row_count"] == 3
+ assert result["table_rows"] == 5 # title + header + 3 doors
+ assert result["representation"] == "composite"
+ assert result["layer"] == ARCH_ROLE_LAYER["symbol"]
+ assert result["title"] == vocab("en")["door_schedule"]
+ # 1:50: widths 15+20+20+20+20+20 = 115 mm -> 5750; rows 5 x 7 mm x 50 = 1750
+ assert result["bbox"] == {"min": [10_000.0, 3_250.0], "max": [15_750.0, 5_000.0]}
+ cells = {
+ e.properties["text"]
+ for e in await backend.entity_list(
+ type_filter="MTEXT", layer_filter=ARCH_ROLE_LAYER["symbol"], limit=1000
+ )
+ }
+ assert {"D1", "D2", "D3", "912.5", "w2"} <= cells
+
+
+@pytest.mark.asyncio
+async def test_a_room_schedule_carries_the_total_row(backend):
+ await write_plan(backend)
+ result = await draw_schedule(backend, "rooms", at=(0.0, 0.0), lang="tr")
+ assert result["row_count"] == 4 # three rooms + the total
+ assert result["table_rows"] == 6
+ assert result["rows"][-1]["area"] == "45,00 m²"
+
+
+@pytest.mark.asyncio
+async def test_nothing_to_schedule_is_refused_before_anything_is_drawn(backend):
+ before = await backend.entity_count()
+ with pytest.raises(ValueError, match=re.escape("kind: this drawing carries no window records")):
+ await draw_schedule(backend, "windows", at=(0.0, 0.0))
+ with pytest.raises(
+ ValueError, match=re.escape("scale: must be a finite number greater than zero")
+ ):
+ await draw_schedule(backend, "doors", at=(0.0, 0.0), scale=-1)
+ assert await backend.entity_count() == before
diff --git a/tests/test_arch_schedule_tools.py b/tests/test_arch_schedule_tools.py
new file mode 100644
index 0000000..2c52713
--- /dev/null
+++ b/tests/test_arch_schedule_tools.py
@@ -0,0 +1,65 @@
+"""arch_schedule over the MCP wire: rooms labelled by arch_room, scheduled as a TABLE."""
+
+from __future__ import annotations
+
+import pytest
+import pytest_asyncio
+from fastmcp import Client
+
+import server
+from engineering.arch.layers import ARCH_ROLE_LAYER
+
+pytestmark = pytest.mark.asyncio
+
+WALL = ARCH_ROLE_LAYER["wall"]
+
+
+def rect(x0, y0, x1, y1):
+ return [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x1, y1), (x0, y1)), ((x0, y1), (x0, y0))]
+
+
+@pytest_asyncio.fixture
+async def client(monkeypatch):
+ """In-memory client on a fresh headless document - never the operator's AutoCAD."""
+ 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 draw_two_rooms(client):
+ """Rooms (200,200)-(4200,5200) and (4400,200)-(8400,5200) between 200 mm walls."""
+ for (x1, y1), (x2, y2) in [
+ *rect(0.0, 0.0, 8600.0, 5400.0),
+ *rect(200.0, 200.0, 8400.0, 5200.0),
+ ((4200.0, 200.0), (4200.0, 5200.0)),
+ ((4400.0, 200.0), (4400.0, 5200.0)),
+ ]:
+ await client.call_tool(
+ "entity_create_line", {"x1": x1, "y1": y1, "x2": x2, "y2": y2, "layer": WALL}
+ )
+
+
+async def test_a_room_schedule_reads_the_labels_arch_room_wrote(client):
+ await draw_two_rooms(client)
+ await client.call_tool("arch_room", {"name": "LIVING", "number": "1", "x": 2200.0, "y": 2700.0})
+ await client.call_tool("arch_room", {"name": "BED", "number": "2", "x": 6400.0, "y": 2700.0})
+ result = (
+ await client.call_tool("arch_schedule", {"kind": "rooms", "x": 9000.0, "y": 5400.0})
+ ).structured_content
+ assert result["table_rows"] == 5 # title + header + two rooms + total
+ assert [(r["number"], r["area"]) for r in result["rows"]] == [
+ ("1", "20.00 m²"),
+ ("2", "20.00 m²"),
+ ("", "40.00 m²"),
+ ]
+
+
+async def test_nothing_to_schedule_is_refused_over_the_wire(client):
+ result = await client.call_tool(
+ "arch_schedule", {"kind": "doors", "x": 0.0, "y": 0.0}, raise_on_error=False
+ )
+ assert result.is_error is True
+ assert "kind: this drawing carries no door records" in result.content[0].text
diff --git a/tests/test_table_leader.py b/tests/test_table_leader.py
index d43d8e7..687eb68 100644
--- a/tests/test_table_leader.py
+++ b/tests/test_table_leader.py
@@ -135,3 +135,77 @@ async def test_server_registers_table_and_mleader_tools():
names = {tool.name for tool in await server._registered_tools()}
assert "entity_create_table" in names
assert "leader_create_mleader" in names
+
+
+class _MeasuredTable:
+ """The IAcadTable members a live AutoCAD 2026 seat exposes (measured).
+
+ MEASURED on AutoCAD 2026 (track F task 6): the table has no ``TextHeight``
+ property - pywin32 answers ``table.TextHeight = h`` with AttributeError -
+ and a table left alone keeps the Standard style's heights (title 6.0, header
+ and data 4.5). ``SetCellTextHeight(row, col, h)`` is the member that sets it.
+ A fake that accepted ``TextHeight`` would hide exactly that defect.
+ """
+
+ def __init__(self, rows, columns):
+ self.Handle = "A1"
+ self.Rows, self.Columns = rows, columns
+ self._heights = {
+ (r, c): (6.0 if r == 0 else 4.5) for r in range(rows) for c in range(columns)
+ }
+
+ def __setattr__(self, name, value):
+ if name == "TextHeight":
+ raise AttributeError(f"object has no attribute {name!r}")
+ object.__setattr__(self, name, value)
+
+ def SetColumnWidth(self, column, width):
+ pass
+
+ def SetRowHeight(self, row, height):
+ pass
+
+ def SetText(self, row, column, value):
+ pass
+
+ def SetCellTextHeight(self, row, column, height):
+ self._heights[(row, column)] = float(height)
+
+ def GetCellTextHeight(self, row, column):
+ return self._heights[(row, column)]
+
+
+async def test_com_table_sets_every_cell_text_height_through_the_measured_member():
+ backend = ComBackend()
+
+ async def run_inline(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ backend._run = run_inline
+ mspace = MagicMock()
+ made: list[_MeasuredTable] = []
+
+ def add_table(_point, rows, columns, _row_height, _column_width):
+ made.append(_MeasuredTable(rows, columns))
+ return made[-1]
+
+ mspace.AddTable.side_effect = add_table
+ info = EntityInfo("A1", "ACAD_TABLE", "TEXT", 256, "ByLayer", True, {})
+
+ with (
+ patch("backends.com_backend._msp", return_value=mspace),
+ patch("backends.com_backend._apoint", side_effect=lambda *p: tuple(p)),
+ patch("backends.com_backend._entity_info", return_value=info),
+ patch("backends.com_backend._apply_entity_attrs"),
+ patch("backends.com_backend._regen"),
+ ):
+ await backend.entity_create_table(
+ 0, 50, [["A", "1"]], headers=["Name", "Qty"], title="BOM", text_height=125.0
+ )
+
+ (table,) = made
+ assert {
+ (row, column): table.GetCellTextHeight(row, column)
+ for row in range(table.Rows)
+ for column in range(table.Columns)
+ } == {(row, column): 125.0 for row in range(3) for column in range(2)}
diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py
index 12ed93e..e7b774c 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 (231 tools, 25 groups).
+ """Frozen snapshot of the surface (232 tools, 25 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
@@ -582,12 +582,12 @@ async def test_tool_group_sizes_are_unchanged():
returned 17 -> 10, `blocks` 11 -> 9, `entity_creation` 19 -> 18 and
`drawing` 12 -> 11 — every one of them its pre-SECTION-24 value.
- Track F group R opened SECTION 25 with the rooms: `arch_room` is tagged
- `arch` and `create`, `arch_rooms_detect` `arch` and `query`. `arch` is not
- in `_GROUP_TAG_PRIORITY` on this branch, so they file under their
- secondary tags: `entity_creation` 18 -> 19, `entity_query` 9 -> 10. This
- snapshot is branch-local: the track F merge task recomputes it once the
- walls, rooms and catalogue branches have landed.
+ Track F group R opened SECTION 25 with the rooms: `arch_room` and
+ `arch_schedule` are tagged `arch` and `create`, `arch_rooms_detect` `arch`
+ and `query`. `arch` is not in `_GROUP_TAG_PRIORITY` on this branch, so they
+ file under their secondary tags: `entity_creation` 18 -> 20,
+ `entity_query` 9 -> 10. This snapshot is branch-local: the track F merge
+ task recomputes it once the walls, rooms and catalogue branches have landed.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
assert sizes == {
@@ -598,7 +598,7 @@ async def test_tool_group_sizes_are_unchanged():
"dimensions": 5,
"drawing": 11,
"engineering": 10,
- "entity_creation": 19,
+ "entity_creation": 20,
"entity_modification": 16,
"entity_query": 10,
"environment": 22,
@@ -617,4 +617,4 @@ async def test_tool_group_sizes_are_unchanged():
"validation": 1,
"view": 4,
}
- assert sum(sizes.values()) == 231
+ assert sum(sizes.values()) == 232