From 9168d613977909263d6390bc05dfd9ddc8b78bfd Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 23 Sep 2026 22:56:05 +0300 Subject: [PATCH] feat(task-6): door, window and room schedules read from the drawing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit engineering/arch/schedule.py turns the plan records into schedule rows: 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 the label carries, plus a total. Tags compare as numbers (D2 before D10). An opening without a tag gets the next free D1 / W1 - K1 / P1 in Turkish - in wall order; an explicit tag is never renumbered and a generated one never repeats it. A value the model does not carry is an empty cell, never a default, and Turkish output writes decimal commas. draw_schedule reads the ACADMCP_ARCH records off the drawing through the one reader rooms.py already has and draws a real TABLE the way the ISO 7573 parts list does - top-left insertion, the table growing down, the parts list's 7 mm rows and 2.5 mm text at the plot scale, the engine's representation reported. Nothing to schedule, an unknown kind or language and more rows than one TABLE takes are refused before anything is drawn. arch_schedule joins arch_room and arch_rooms_detect under SECTION 25's rooms marker (banner 3 tools), shares the TABLE command with entity_create_table and bom_table, and the counts follow: 232 tools in 25 groups. Live fix in com_backend.entity_create_table. MEASURED on AutoCAD 2026: IAcadTable has no TextHeight property. pywin32 raised AttributeError on `table.TextHeight = h`, the old code swallowed it, and every live table kept the Standard style's heights (title 6.0, header and data 4.5), which is 0.1 mm on paper at 1:50. The member that works is SetCellTextHeight(row, col, h), probed on a scratch document: every cell read back 125.0 via GetCellTextHeight. The row heights stayed 350.0 and GetBoundingBox matched the reported bbox (10000,3250)-(15750,5000). Then draw_schedule ran end to end through ComBackend on a scratch document: a native TABLE on A-SYMB-T-N for doors (5 rows) and rooms/tr (6 rows, total 45,00 m²), and the document was closed afterwards. The new fake in tests/test_table_leader.py has only the measured members. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 6 +- backends/com_backend.py | 9 +- discovery/aliases.py | 17 +- docs/tool-inventory.json | 18 +- engineering/arch/__init__.py | 4 + engineering/arch/schedule.py | 284 ++++++++++++++++++++++++++++++ server.py | 45 ++++- tests/test_arch_schedule.py | 200 +++++++++++++++++++++ tests/test_arch_schedule_tools.py | 65 +++++++ tests/test_table_leader.py | 74 ++++++++ tests/test_tool_registry.py | 18 +- 11 files changed, 719 insertions(+), 21 deletions(-) create mode 100644 engineering/arch/schedule.py create mode 100644 tests/test_arch_schedule.py create mode 100644 tests/test_arch_schedule_tools.py 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