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