From afd42356b55fe80afc34c5d0e62a9448dafbc4bc Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 23 Sep 2026 22:35:44 +0300 Subject: [PATCH 1/2] feat(task-7): SECTION 25 catalogue - arch_catalogue_list, arch_catalogue_insert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit engineering/arch/catalogue.py is the standard-parts pattern of track B for an architectural plan: twenty authored plan symbols in two families - thirteen furniture items (single and double bed, wardrobe, three-seat sofa, armchair, chair, dining table with four chairs, desk, kitchen counter run, fridge, bookshelf, nightstand, coffee table) and seven sanitary items (WC, bidet, wall basin, shower tray, bathtub, kitchen sink, washing machine). Every size is a nominal catalogue dimension and says so in SIZE_BASIS, in each row and in both tool docstrings; no standard is cited because none fixes these sizes. Each item is typed block_define primitives on layer 0 inside [0, w] x [0, d], origin at the back-left corner with the wall side along +X - a test holds all twenty to their footprint. catalogue() searches name, family and the English and Turkish labels, ignoring case and Turkish diacritics (buzdolabi finds buzdolabı). insert_item() defines ARCH_ once, creates the furniture or sanitary layer from its ARCH_LAYERS row when missing (live ActiveX refuses a missing layer), inserts with an invisible ITEM attribute, and refuses an unknown name with the nearest catalogue names before any write. SECTION 25 opens on this branch with the two tools, alias records (INSERT, ADCENTER, TOOLPALETTES and English/Turkish synonyms), a regenerated inventory, the README snapshot at 231 tools, and the branch-local group snapshot (entity_query 10, entity_creation 19) that the track F merge recomputes. No new ActiveX call: insert_item composes the existing layer_list, layer_create, block_list, block_define and block_insert members. Run once live through ComBackend on AutoCAD 2026 (scratch document, closed after): all twenty items defined and inserted, a second double_bed insert reported defined=false, ITEM read back as double_bed, 20 ARCH_* blocks, 21 INSERTs on A-FURN-E-N / A-SANR-E-N, both layers created at ACI 5 / 0.18 mm. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 6 +- discovery/aliases.py | 34 ++ docs/tool-inventory.json | 34 +- engineering/arch/__init__.py | 18 + engineering/arch/catalogue.py | 525 +++++++++++++++++++++++++++++ server.py | 108 ++++++ tests/test_arch_catalogue.py | 233 +++++++++++++ tests/test_arch_catalogue_tools.py | 54 +++ tests/test_tool_registry.py | 16 +- 9 files changed, 1018 insertions(+), 10 deletions(-) create mode 100644 engineering/arch/catalogue.py create mode 100644 tests/test_arch_catalogue.py create mode 100644 tests/test_arch_catalogue_tools.py diff --git a/README.md b/README.md index 6320f44..6c4c2b7 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 229 tools · 8 resources · 5 prompt templates · 4005 collected tests. -> 229 is the **registered** count; a default install advertises 224 over `tools/list`, +> **v1.5 release snapshot:** 231 tools · 8 resources · 5 prompt templates · 4005 collected tests. +> 231 is the **registered** count; a default install advertises 226 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. ## Why this exists @@ -120,7 +120,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor | Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) | | Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks | -229 tools in 25 groups; TOOL_PACKS=core hides the nine pid_* tools, the 22 environment tools and the 14 mechanical tools (TOOL_PACKS=core,mech keeps the last) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. +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. **Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy. diff --git a/discovery/aliases.py b/discovery/aliases.py index 5099993..2a944e7 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -2230,6 +2230,40 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "punta deligi", ), ), + # ── Architecture: catalogue (SECTION 25, track F group C) ─────────────── + "arch_catalogue_list": ToolAliases( + acad=("ADCENTER", "TOOLPALETTES"), + synonyms=( + "furniture catalogue", + "furniture library", + "sanitary fixtures", + "bathroom fixtures", + "what furniture is there", + "mobilya kutuphanesi", + "vitrifiye listesi", + ), + ), + "arch_catalogue_insert": ToolAliases( + acad=("INSERT",), + synonyms=( + "place furniture", + "put a bed in the room", + "insert a sofa", + "add a wc", + "place a toilet", + "wash basin", + "bathtub", + "kitchen sink", + "wardrobe", + "dining table", + "yatak", + "koltuk", + "klozet", + "lavabo", + "kuvet", + "mobilya yerlestir", + ), + ), } diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 50d7a81..97fa0bf 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": 229, + "tools": 231, "resources": 8, "prompts": 5, "groups": 25 @@ -16,9 +16,9 @@ "dimensions": 5, "drawing": 11, "engineering": 10, - "entity_creation": 18, + "entity_creation": 19, "entity_modification": 16, - "entity_query": 9, + "entity_query": 10, "environment": 22, "layers": 14, "layouts": 12, @@ -180,6 +180,34 @@ "readOnlyHint": true } }, + { + "name": "arch_catalogue_insert", + "group": "entity_creation", + "cost": "mutate", + "summary": "Insert a furniture or sanitary item - bed, sofa, WC, basin, bathtub - as a block.", + "tags": [ + "arch", + "create" + ], + "annotations": { + "title": "Architecture: Catalogue Insert", + "readOnlyHint": false + } + }, + { + "name": "arch_catalogue_list", + "group": "entity_query", + "cost": "read", + "summary": "Search the furniture and sanitary catalogue in English or Turkish.", + "tags": [ + "arch", + "query" + ], + "annotations": { + "title": "Architecture: Catalogue", + "readOnlyHint": true + } + }, { "name": "balloon_add", "group": "sheet", diff --git a/engineering/arch/__init__.py b/engineering/arch/__init__.py index 505a99e..22c2b2d 100644 --- a/engineering/arch/__init__.py +++ b/engineering/arch/__init__.py @@ -6,6 +6,16 @@ and alphabetical - keep every side's names. from __future__ import annotations +from .catalogue import ( + CATALOGUE_NAMES, + FAMILIES, + ITEMS, + SIZE_BASIS, + CatalogueItem, + block_spec, + catalogue, + insert_item, +) from .lang import LANGS, fmt_area_m2, fmt_number, tag_prefix, vocab from .layers import ARCH_LAYER_DEFS, ARCH_LAYER_ROLES, ARCH_LAYERS, ARCH_ROLE_LAYER from .materials import WALL_HATCH_TABLE, WALL_MATERIALS, wall_hatch @@ -37,20 +47,28 @@ __all__ = [ "ARCH_LAYER_DEFS", "ARCH_LAYER_ROLES", "ARCH_ROLE_LAYER", + "CATALOGUE_NAMES", + "CatalogueItem", + "FAMILIES", + "ITEMS", "KINDS", "LANGS", "Opening", "Room", + "SIZE_BASIS", "Stair", "WALL_HATCH_TABLE", "WALL_MATERIALS", "Wall", "axis_segments", + "block_spec", + "catalogue", "decode", "encode", "fmt_area_m2", "fmt_number", "from_payload", + "insert_item", "opening_from_dict", "room_from_dict", "segment_of", diff --git a/engineering/arch/catalogue.py b/engineering/arch/catalogue.py new file mode 100644 index 0000000..33457df --- /dev/null +++ b/engineering/arch/catalogue.py @@ -0,0 +1,525 @@ +"""The furniture and sanitary catalogue: authored outlines in, real blocks out. + +The standard-parts pattern of track B (``engineering/mech/stdparts.py``) +applied to an architectural plan: :func:`catalogue` searches the items by +name, family and their English or Turkish label; :func:`block_spec` turns one +item into the typed primitives and the ATTDEF ``block_define`` takes; +:func:`insert_item` defines the block ``ARCH_`` once, inserts it on the +furniture or sanitary layer and reports what it did. + +**Sizes are nominal catalogue dimensions, not standards values.** A 900 x 2000 +single bed or a 600 x 600 washing machine is what a furniture or appliance +catalogue lists as a common nominal size; no standard fixes it, and every row +says so in ``SIZE_BASIS``. The outlines are plan symbols drawn inside that +footprint, not manufacturer drawings. + +Block-local frame, the same for every item: the origin is the **back-left +corner**, the back (the side that stands against a wall) runs along +X from the +origin, and the item extends towards +Y. So ``rotation=0`` puts an item's back +on a wall that runs along +X below it, and every primitive lies inside +``[0, w] x [0, d]`` - a test holds each item to that. + +Block content is on layer ``0`` (colour and linetype ByBlock, which is how +``block_define`` writes them), so an INSERT takes the look of the layer it is +inserted on: ``ARCH_ROLE_LAYER["furniture"]`` or ``ARCH_ROLE_LAYER["sanitary"]``. +""" + +from __future__ import annotations + +import difflib +import math +from typing import TYPE_CHECKING, NamedTuple + +from engineering.arch.lang import LANGS +from engineering.arch.layers import ARCH_LAYERS, ARCH_ROLE_LAYER + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +FAMILIES: tuple[str, ...] = ("furniture", "sanitary") + +#: What every size in this module is - stated in each row and in the tool docstring. +SIZE_BASIS = "nominal catalogue dimensions (w x d, mm) - not a standards value" + +BLOCK_PREFIX = "ARCH_" + +#: The one ATTDEF every catalogue block carries: the item name, invisible, so a +#: count or a data extract can read what the INSERT is without parsing its name. +ITEM_TAG = "ITEM" +ITEM_TAG_HEIGHT = 100.0 + + +# -- block_define spec helpers (block-local mm, layer 0) --------------------- + + +def _line(x1: float, y1: float, x2: float, y2: float) -> dict: + return { + "type": "line", + "x1": float(x1), + "y1": float(y1), + "x2": float(x2), + "y2": float(y2), + "layer": "0", + } + + +def _circle(cx: float, cy: float, r: float) -> dict: + return {"type": "circle", "cx": float(cx), "cy": float(cy), "r": float(r), "layer": "0"} + + +def _arc(cx: float, cy: float, r: float, start_deg: float, end_deg: float) -> dict: + return { + "type": "arc", + "cx": float(cx), + "cy": float(cy), + "r": float(r), + "start_deg": float(start_deg), + "end_deg": float(end_deg), + "layer": "0", + } + + +def _poly(points, closed: bool) -> dict: + return { + "type": "polyline", + "points": [[float(x), float(y)] for x, y in points], + "closed": bool(closed), + "layer": "0", + } + + +def _rect(x0: float, y0: float, x1: float, y1: float) -> dict: + return _poly(((x0, y0), (x1, y0), (x1, y1), (x0, y1)), True) + + +# -- the items --------------------------------------------------------------- + + +class CatalogueItem(NamedTuple): + name: str + family: str + label_en: str + label_tr: str + w: float + d: float + entities: tuple[dict, ...] + + +def _chair(x0: float, y0: float, back_at_top: bool) -> tuple[dict, ...]: + """A 450 x 450 chair seat with its back rail 60 mm in from the back edge.""" + back_y = y0 + 390.0 if back_at_top else y0 + 60.0 + return (_rect(x0, y0, x0 + 450.0, y0 + 450.0), _line(x0, back_y, x0 + 450.0, back_y)) + + +ITEMS: tuple[CatalogueItem, ...] = ( + # -- furniture ----------------------------------------------------------- + CatalogueItem( + "single_bed", + "furniture", + "single bed", + "tek kişilik yatak", + 900.0, + 2000.0, + (_rect(0, 0, 900, 2000), _rect(100, 50, 800, 450), _line(0, 700, 900, 700)), + ), + CatalogueItem( + "double_bed", + "furniture", + "double bed", + "çift kişilik yatak", + 1600.0, + 2000.0, + ( + _rect(0, 0, 1600, 2000), + _rect(100, 50, 750, 450), + _rect(850, 50, 1500, 450), + _line(0, 700, 1600, 700), + ), + ), + CatalogueItem( + "wardrobe", + "furniture", + "wardrobe", + "gardırop", + 1200.0, + 600.0, + (_rect(0, 0, 1200, 600), _line(50, 300, 1150, 300)), + ), + CatalogueItem( + "sofa_3_seat", + "furniture", + "sofa, three seats", + "üçlü kanepe", + 2200.0, + 900.0, + ( + _rect(0, 0, 2200, 900), + _line(200, 0, 200, 900), + _line(2000, 0, 2000, 900), + _line(200, 200, 2000, 200), + _line(800, 200, 800, 900), + _line(1400, 200, 1400, 900), + ), + ), + CatalogueItem( + "armchair", + "furniture", + "armchair", + "berjer koltuk", + 900.0, + 900.0, + ( + _rect(0, 0, 900, 900), + _line(150, 0, 150, 900), + _line(750, 0, 750, 900), + _line(150, 200, 750, 200), + ), + ), + CatalogueItem( + "chair", + "furniture", + "chair", + "sandalye", + 450.0, + 450.0, + _chair(0.0, 0.0, back_at_top=False), + ), + CatalogueItem( + "dining_table_4", + "furniture", + "dining table with four chairs", + "dört kişilik yemek masası", + 1400.0, + 1700.0, + ( + _rect(0, 450, 1400, 1250), + *_chair(125.0, 0.0, back_at_top=False), + *_chair(825.0, 0.0, back_at_top=False), + *_chair(125.0, 1250.0, back_at_top=True), + *_chair(825.0, 1250.0, back_at_top=True), + ), + ), + CatalogueItem( + "desk", + "furniture", + "desk", + "çalışma masası", + 1400.0, + 700.0, + (_rect(0, 0, 1400, 700), _line(1000, 0, 1000, 700)), + ), + CatalogueItem( + "kitchen_counter", + "furniture", + "kitchen counter run", + "mutfak tezgâhı", + 2400.0, + 600.0, + ( + _rect(0, 0, 2400, 600), + _line(600, 0, 600, 600), + _line(1200, 0, 1200, 600), + _line(1800, 0, 1800, 600), + ), + ), + CatalogueItem( + "fridge", + "furniture", + "fridge", + "buzdolabı", + 600.0, + 650.0, + (_rect(0, 0, 600, 650), _line(0, 600, 600, 600)), + ), + CatalogueItem( + "bookshelf", + "furniture", + "bookshelf", + "kitaplık", + 800.0, + 300.0, + (_rect(0, 0, 800, 300), _line(0, 0, 800, 300)), + ), + CatalogueItem( + "nightstand", + "furniture", + "nightstand", + "komodin", + 500.0, + 400.0, + (_rect(0, 0, 500, 400),), + ), + CatalogueItem( + "coffee_table", + "furniture", + "coffee table", + "orta sehpa", + 1100.0, + 600.0, + (_rect(0, 0, 1100, 600),), + ), + # -- sanitary ------------------------------------------------------------ + CatalogueItem( + "wc", + "sanitary", + "wc with cistern", + "klozet", + 380.0, + 700.0, + ( + _rect(0, 0, 380, 180), + _line(40, 180, 40, 530), + _line(340, 180, 340, 530), + _arc(190, 530, 150, 0, 180), + ), + ), + CatalogueItem( + "bidet", + "sanitary", + "bidet", + "bide", + 360.0, + 560.0, + ( + _line(0, 0, 360, 0), + _line(0, 0, 0, 380), + _line(360, 0, 360, 380), + _arc(180, 380, 180, 0, 180), + _circle(180, 80, 20), + _circle(180, 380, 20), + ), + ), + CatalogueItem( + "wall_basin", + "sanitary", + "wall basin", + "lavabo", + 550.0, + 450.0, + ( + _poly(((0, 175), (0, 0), (550, 0), (550, 175)), False), + _arc(275, 175, 275, 0, 180), + _circle(275, 225, 150), + _circle(275, 225, 20), + _circle(275, 50, 15), + ), + ), + CatalogueItem( + "shower_tray", + "sanitary", + "shower tray", + "duş teknesi", + 900.0, + 900.0, + (_rect(0, 0, 900, 900), _rect(50, 50, 850, 850), _circle(450, 450, 50)), + ), + CatalogueItem( + "bathtub", + "sanitary", + "bathtub", + "küvet", + 1700.0, + 700.0, + (_rect(0, 0, 1700, 700), _rect(60, 60, 1640, 640), _circle(200, 350, 25)), + ), + CatalogueItem( + "kitchen_sink", + "sanitary", + "kitchen sink with drainer", + "eviye", + 1000.0, + 500.0, + ( + _rect(0, 0, 1000, 500), + _rect(50, 50, 450, 450), + _circle(250, 250, 30), + _line(550, 150, 950, 150), + _line(550, 250, 950, 250), + _line(550, 350, 950, 350), + ), + ), + CatalogueItem( + "washing_machine", + "sanitary", + "washing machine", + "çamaşır makinesi", + 600.0, + 600.0, + (_rect(0, 0, 600, 600), _circle(300, 300, 200)), + ), +) + +CATALOGUE_NAMES: tuple[str, ...] = tuple(item.name for item in ITEMS) +_BY_NAME: dict[str, CatalogueItem] = {item.name: item for item in ITEMS} + +#: Turkish letters folded to ASCII so "buzdolabi" finds "buzdolabı" and a +#: query typed on an English keyboard still reaches the Turkish label. +_FOLD = str.maketrans( + {"ç": "c", "ğ": "g", "ı": "i", "ö": "o", "ş": "s", "ü": "u", "â": "a", "î": "i", "û": "u"} +) + + +def _fold(text: str) -> str: + """Lower-case and fold Turkish letters. ``İ`` is mapped before ``lower()``, + which would otherwise turn it into ``i`` plus a combining dot.""" + return " ".join(str(text).replace("İ", "i").lower().translate(_FOLD).split()) + + +def _check_lang(lang: str) -> str: + if lang not in LANGS: + raise ValueError(f"lang must be one of {', '.join(LANGS)}, got {lang!r}.") + return lang + + +def _row(item: CatalogueItem, lang: str) -> dict: + return { + "name": item.name, + "family": item.family, + "label": item.label_tr if lang == "tr" else item.label_en, + "label_en": item.label_en, + "label_tr": item.label_tr, + "size": [item.w, item.d], + "size_basis": SIZE_BASIS, + "block": f"{BLOCK_PREFIX}{item.name.upper()}", + "layer": ARCH_ROLE_LAYER[item.family], + } + + +def catalogue( + query: str | None = None, family: str | None = None, *, lang: str = "en" +) -> tuple[dict, ...]: + """Every catalogue item, optionally filtered. + + ``family`` is ``furniture`` or ``sanitary`` (anything else is refused with + the list). ``query`` is split into words and every word must occur in the + item's name, family, English label or Turkish label; matching ignores case + and Turkish diacritics, so ``'bed'``, ``'yatak'``, ``'KLOZET'`` and + ``'buzdolabi'`` all work. ``lang`` picks the ``label`` column. + """ + _check_lang(lang) + if family is not None and family not in FAMILIES: + raise ValueError(f"family must be one of {', '.join(FAMILIES)}, got {family!r}.") + words = _fold(query).split() if query else [] + rows: list[dict] = [] + for item in ITEMS: + if family is not None and item.family != family: + continue + haystack = _fold( + f"{item.name.replace('_', ' ')} {item.family} {item.label_en} {item.label_tr}" + ) + if all(word in haystack for word in words): + rows.append(_row(item, lang)) + return tuple(rows) + + +def _resolve(name: str) -> CatalogueItem: + key = "_".join(str(name).replace("-", " ").lower().split()) + item = _BY_NAME.get(key) + if item is not None: + return item + close = difflib.get_close_matches(key, CATALOGUE_NAMES, n=3, cutoff=0.4) + hint = ( + f"nearest: {', '.join(close)}" + if close + else f"the catalogue holds {', '.join(CATALOGUE_NAMES)}" + ) + raise ValueError(f"arch catalogue: no item {name!r}; {hint}.") + + +def block_spec(name: str) -> dict: + """The typed primitives and the ITEM ATTDEF ``block_define`` takes for one item. + + Returns ``{"name", "entities", "attdefs", "base_x", "base_y", "item", + "family", "size", "size_basis"}``. An unknown name is refused with the + nearest catalogue names before anything is built. + """ + item = _resolve(name) + return { + "name": f"{BLOCK_PREFIX}{item.name.upper()}", + "entities": [dict(spec) for spec in item.entities], + "attdefs": [ + { + "tag": ITEM_TAG, + "prompt": "Catalogue item", + "default": item.name, + "x": item.w / 2.0, + "y": item.d / 2.0, + "height": ITEM_TAG_HEIGHT, + "align": "middle_center", + "invisible": True, + } + ], + "base_x": 0.0, + "base_y": 0.0, + "item": item.name, + "family": item.family, + "size": [item.w, item.d], + "size_basis": SIZE_BASIS, + } + + +async def _ensure_layer(backend: AutoCADBackend, name: str) -> bool: + """Create the INSERT's layer from its ``ARCH_LAYERS`` row when the drawing lacks it. + + Live ActiveX refuses ``entity.Layer = "X"`` on a missing layer (the + repository rule in CLAUDE.md); the headless engine would create it + silently with no colour or lineweight. Returns whether it was created. + """ + if name.lower() in {layer.name.lower() for layer in await backend.layer_list()}: + return False + row = next((row for row in ARCH_LAYERS if row[0] == name), None) + color, linetype, lineweight = (row[1], row[2], row[3]) if row else (7, "Continuous", 0.25) + await backend.layer_create(name=name, color=color, linetype=linetype, lineweight=lineweight) + return True + + +async def insert_item(backend: AutoCADBackend, name: str, *, at, rotation: float = 0.0) -> dict: + """Define ``ARCH_`` once, insert it at ``at`` turned by ``rotation``. + + ``at`` is the WCS point the item's back-left corner lands on; ``rotation`` + is degrees CCW about it. Refusals - an unknown item (with the nearest + names), a non-finite coordinate or rotation - fire before any write. + """ + spec = block_spec(name) + try: + x, y = float(at[0]), float(at[1]) + except (TypeError, IndexError, ValueError) as exc: + raise ValueError(f"arch catalogue: 'at' must be two numbers, got {at!r}.") from exc + if not (math.isfinite(x) and math.isfinite(y)): + raise ValueError(f"arch catalogue: 'at' must be two finite numbers, got {at!r}.") + angle = float(rotation) + if not math.isfinite(angle): + raise ValueError(f"arch catalogue: rotation must be finite, got {rotation!r}.") + + layer = ARCH_ROLE_LAYER[spec["family"]] + layer_created = await _ensure_layer(backend, layer) + existing = {block.name.upper() for block in await backend.block_list()} + defined = spec["name"].upper() not in existing + if defined: + await backend.block_define( + spec["name"], + spec["entities"], + spec["attdefs"], + spec["base_x"], + spec["base_y"], + False, + False, + ) + ref = await backend.block_insert( + spec["name"], x, y, 1.0, 1.0, angle, {ITEM_TAG: spec["item"]}, layer + ) + return { + "ok": True, + "handle": ref.handle, + "block_name": spec["name"], + "defined": defined, + "item": spec["item"], + "family": spec["family"], + "layer": layer, + "layer_created": layer_created, + "at": [x, y], + "rotation": angle, + "size": spec["size"], + "size_basis": SIZE_BASIS, + "backend": backend.name, + } diff --git a/server.py b/server.py index d6f0808..b92c819 100644 --- a/server.py +++ b/server.py @@ -9780,6 +9780,114 @@ async def drawing_export_dwg( return await _backend(ctx).drawing_export_dwg(str(validated), version) +# --------------------------------------------------------------------------- +# ── SECTION 25: Architecture (2 tools) ────────────────────────────────────── +# --------------------------------------------------------------------------- +# ── arch: catalogue (group C) ── + + +@cad_tool( + summary="Search the furniture and sanitary catalogue in English or Turkish.", + cost="read", +) +@mcp.tool( + annotations={"title": "Architecture: Catalogue", "readOnlyHint": True}, + tags={"arch", "query"}, +) +async def arch_catalogue_list( + query: Annotated[ + str | None, + Field( + default=None, + description=( + "Words matched against the item name, family and its English or Turkish " + "label, ignoring case and Turkish diacritics - 'bed', 'yatak', 'klozet', " + "'buzdolabi'. Every word must match." + ), + ), + ] = None, + family: Annotated[ + str | None, + Field(default=None, description="furniture | sanitary"), + ] = None, + lang: Annotated[ + str, + Field(default="en", description="en | tr - the language of the 'label' column"), + ] = "en", + ctx: Context = None, +) -> dict: + """Catalogue rows: name, family, both labels, nominal size [w, d] in mm, block name, layer. + + Sizes are nominal catalogue dimensions - what a furniture or appliance + catalogue lists as a common size - and every row says so in `size_basis`; + they are not standards values. Twenty items: thirteen furniture, seven + sanitary. Refusals: a `family` other than furniture/sanitary and a `lang` + other than en/tr, each with the list. + """ + from engineering.arch.catalogue import FAMILIES, SIZE_BASIS, catalogue + + try: + rows = catalogue(query, family, lang=lang) + except ValueError as exc: + raise ToolError(str(exc)) from exc + return { + "count": len(rows), + "families": list(FAMILIES), + "size_basis": SIZE_BASIS, + "items": list(rows), + } + + +@cad_tool( + summary="Insert a furniture or sanitary item - bed, sofa, WC, basin, bathtub - as a block.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Architecture: Catalogue Insert", "readOnlyHint": False}, + tags={"arch", "create"}, +) +async def arch_catalogue_insert( + name: Annotated[ + str, + Field( + description=( + "Catalogue name from arch_catalogue_list, e.g. 'double_bed', 'sofa_3_seat', " + "'wc', 'wall_basin', 'kitchen_counter'" + ) + ), + ], + x: Annotated[float, Field(description="X (WCS) of the item's back-left corner")], + y: Annotated[float, Field(description="Y (WCS) of the item's back-left corner")], + rotation: Annotated[ + float, + Field( + default=0.0, + description=( + "Degrees CCW about (x, y); 0 puts the item's back (its wall side) along +X " + "with the item extending towards +Y" + ), + ), + ] = 0.0, + ctx: Context = None, +) -> dict: + """Define the item's block ARCH_ once, insert it on the furniture or sanitary layer. + + The block carries an invisible ITEM attribute with the catalogue name. Its + size is a nominal catalogue dimension (reported with `size_basis`), not a + standards value; the outline is a plan symbol drawn inside that footprint. + The furniture / sanitary layer is created from the `arch` layer set when + the drawing lacks it. Refusals, all before any write: an unknown name + (with the nearest catalogue names) and a non-finite coordinate or rotation. + """ + from engineering.arch.catalogue import insert_item + + await ctx.info(f"arch catalogue: {name} at ({x}, {y})") + try: + return await insert_item(_backend(ctx), name, at=(x, y), rotation=rotation) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_arch_catalogue.py b/tests/test_arch_catalogue.py new file mode 100644 index 0000000..0a49712 --- /dev/null +++ b/tests/test_arch_catalogue.py @@ -0,0 +1,233 @@ +"""The furniture and sanitary catalogue: authored outlines, search, blocks. + +Pure half first (every item's primitives against its declared nominal size, +the search in both languages, the refusal), then the insert through the +headless backend - the `backend` fixture in tests/conftest.py. +""" + +from __future__ import annotations + +import pytest + +from backends.block_specs import validate_attdef_specs, validate_entity_specs +from engineering.arch.catalogue import ( + CATALOGUE_NAMES, + FAMILIES, + ITEMS, + SIZE_BASIS, + block_spec, + catalogue, + insert_item, +) +from engineering.arch.layers import ARCH_LAYERS, ARCH_ROLE_LAYER +from engineering.mech.primitives import Arc, points_of + + +def _spec_points(spec: dict) -> list[tuple[float, float]]: + """Every point that bounds one block_define spec - an arc by its real sweep.""" + kind = spec["type"] + if kind == "line": + return [(spec["x1"], spec["y1"]), (spec["x2"], spec["y2"])] + if kind == "circle": + cx, cy, r = spec["cx"], spec["cy"], spec["r"] + return [(cx - r, cy), (cx + r, cy), (cx, cy - r), (cx, cy + r)] + if kind == "arc": + arc = Arc((spec["cx"], spec["cy"]), spec["r"], spec["start_deg"], spec["end_deg"]) + return list(points_of(arc)) + if kind == "polyline": + return [tuple(p) for p in spec["points"]] + raise AssertionError(f"unexpected spec type {kind!r}") + + +def test_the_catalogue_holds_twenty_items_in_two_families(): + assert len(ITEMS) == 20 + assert len(set(CATALOGUE_NAMES)) == 20 + assert FAMILIES == ("furniture", "sanitary") + assert sum(item.family == "furniture" for item in ITEMS) == 13 + assert sum(item.family == "sanitary" for item in ITEMS) == 7 + assert { + "single_bed", + "double_bed", + "wardrobe", + "sofa_3_seat", + "armchair", + "dining_table_4", + "desk", + "kitchen_counter", + "fridge", + "bookshelf", + "wc", + "wall_basin", + "shower_tray", + "bathtub", + "kitchen_sink", + "washing_machine", + } <= set(CATALOGUE_NAMES) + for name in CATALOGUE_NAMES: + assert name == name.lower() and " " not in name + + +@pytest.mark.parametrize("name", CATALOGUE_NAMES) +def test_every_item_stays_inside_its_declared_nominal_size(name): + spec = block_spec(name) + w, d = spec["size"] + assert w > 0 and d > 0 + for entity in spec["entities"]: + for x, y in _spec_points(entity): + assert -1e-9 <= x <= w + 1e-9, (name, entity) + assert -1e-9 <= y <= d + 1e-9, (name, entity) + for att in spec["attdefs"]: + assert 0.0 <= att["x"] <= w and 0.0 <= att["y"] <= d + + +@pytest.mark.parametrize("name", CATALOGUE_NAMES) +def test_every_block_spec_validates_against_block_define(name): + spec = block_spec(name) + normalised = validate_entity_specs(spec["entities"]) + assert {entity["layer"] for entity in normalised} == {"0"} + (att,) = validate_attdef_specs(spec["attdefs"]) + assert att["tag"] == "ITEM" and att["default"] == name and att["invisible"] is True + assert spec["name"] == "ARCH_" + name.upper() + assert spec["size_basis"] == SIZE_BASIS + + +def test_every_outline_reaches_its_declared_size(): + """The declared size is the item's footprint, not a loose bound.""" + for name in CATALOGUE_NAMES: + spec = block_spec(name) + points = [p for entity in spec["entities"] for p in _spec_points(entity)] + w, d = spec["size"] + assert min(x for x, _ in points) == pytest.approx(0.0, abs=1e-9), name + assert max(x for x, _ in points) == pytest.approx(w, abs=1e-9), name + assert min(y for _, y in points) == pytest.approx(0.0, abs=1e-9), name + # the wc's bowl arc stops at y = 680 inside its 700 mm nominal depth + assert max(y for _, y in points) <= d + 1e-9, name + + +def test_the_wc_bowl_is_a_semicircle_joining_its_two_sides(): + spec = block_spec("wc") + arc = next(e for e in spec["entities"] if e["type"] == "arc") + assert (arc["cx"], arc["cy"], arc["r"], arc["start_deg"], arc["end_deg"]) == ( + 190.0, + 530.0, + 150.0, + 0.0, + 180.0, + ) + sides = [e for e in spec["entities"] if e["type"] == "line"] + assert [(s["x1"], s["y2"]) for s in sides] == [(40.0, 530.0), (340.0, 530.0)] + + +def test_the_sofa_cushions_split_the_seat_into_three_equal_widths(): + spec = block_spec("sofa_3_seat") + verticals = sorted( + e["x1"] for e in spec["entities"] if e["type"] == "line" and e["x1"] == e["x2"] + ) + assert verticals == [200.0, 800.0, 1400.0, 2000.0] + + +def test_search_by_english_word(): + names = [row["name"] for row in catalogue("bed")] + assert names == ["single_bed", "double_bed"] + assert [row["name"] for row in catalogue("washing machine")] == ["washing_machine"] + + +def test_search_by_turkish_word_with_and_without_diacritics(): + assert [row["name"] for row in catalogue("yatak")] == ["single_bed", "double_bed"] + assert [row["name"] for row in catalogue("buzdolabı")] == ["fridge"] + assert [row["name"] for row in catalogue("buzdolabi")] == ["fridge"] + assert [row["name"] for row in catalogue("KLOZET")] == ["wc"] + assert [row["name"] for row in catalogue("çamaşır")] == ["washing_machine"] + assert [row["name"] for row in catalogue("camasir")] == ["washing_machine"] + + +def test_search_every_word_must_match(): + assert [row["name"] for row in catalogue("masa")] == ["dining_table_4", "desk"] + assert [row["name"] for row in catalogue("yemek masa")] == ["dining_table_4"] + assert catalogue("bed sink") == () + + +def test_family_filter_and_the_label_language(): + sanitary = catalogue(family="sanitary") + assert [row["name"] for row in sanitary] == [ + "wc", + "bidet", + "wall_basin", + "shower_tray", + "bathtub", + "kitchen_sink", + "washing_machine", + ] + assert all(row["layer"] == ARCH_ROLE_LAYER["sanitary"] for row in sanitary) + (row,) = catalogue("fridge", lang="tr") + assert row["label"] == "buzdolabı" and row["label_en"] == "fridge" + assert row["size"] == [600.0, 650.0] + assert row["size_basis"] == SIZE_BASIS + assert row["block"] == "ARCH_FRIDGE" + assert len(catalogue()) == 20 + + +def test_unknown_family_and_language_are_refused_with_the_list(): + with pytest.raises(ValueError, match="furniture, sanitary"): + catalogue(family="lighting") + with pytest.raises(ValueError, match="en, tr"): + catalogue(lang="de") + + +def test_an_unknown_name_is_refused_with_the_nearest_names(): + with pytest.raises(ValueError, match=r"no item 'singlebed'; nearest: single_bed"): + block_spec("singlebed") + with pytest.raises(ValueError, match="the catalogue holds single_bed"): + block_spec("zzz") + + +def test_a_label_spelling_resolves_to_the_item(): + assert block_spec("Single Bed")["item"] == "single_bed" + assert block_spec("washing-machine")["item"] == "washing_machine" + + +@pytest.mark.asyncio +async def test_insert_defines_the_block_once_across_two_inserts(backend): + first = await insert_item(backend, "double_bed", at=(1000.0, 2000.0)) + assert first["ok"] is True + assert first["block_name"] == "ARCH_DOUBLE_BED" + assert first["defined"] is True + assert first["layer"] == ARCH_ROLE_LAYER["furniture"] + assert first["layer_created"] is True + assert first["size"] == [1600.0, 2000.0] + assert first["size_basis"] == SIZE_BASIS + + second = await insert_item(backend, "double_bed", at=(4000.0, 2000.0), rotation=90.0) + assert second["defined"] is False + assert second["layer_created"] is False + assert second["rotation"] == 90.0 + + blocks = [b.name for b in await backend.block_list() if b.name == "ARCH_DOUBLE_BED"] + assert blocks == ["ARCH_DOUBLE_BED"] + inserts = await backend.entity_list(type_filter="INSERT", limit=100) + assert sorted(e.handle for e in inserts) == sorted([first["handle"], second["handle"]]) + assert {e.layer for e in inserts} == {ARCH_ROLE_LAYER["furniture"]} + assert await backend.block_get_attributes(first["handle"]) == {"ITEM": "double_bed"} + + +@pytest.mark.asyncio +async def test_insert_creates_the_layer_from_its_arch_layers_row(backend): + result = await insert_item(backend, "wc", at=(0.0, 0.0)) + layer = ARCH_ROLE_LAYER["sanitary"] + assert result["layer"] == layer + row = next(row for row in ARCH_LAYERS if row[0] == layer) + info = next(info for info in await backend.layer_list() if info.name == layer) + assert info.color == row[1] + + +@pytest.mark.asyncio +async def test_insert_refuses_before_writing_anything(backend): + before = len(await backend.entity_list()) + with pytest.raises(ValueError, match="nearest: bathtub"): + await insert_item(backend, "bathtup", at=(0.0, 0.0)) + with pytest.raises(ValueError, match="finite"): + await insert_item(backend, "bathtub", at=(float("nan"), 0.0)) + with pytest.raises(ValueError, match="rotation must be finite"): + await insert_item(backend, "bathtub", at=(0.0, 0.0), rotation=float("inf")) + assert len(await backend.entity_list()) == before + assert "ARCH_BATHTUB" not in {b.name for b in await backend.block_list()} diff --git a/tests/test_arch_catalogue_tools.py b/tests/test_arch_catalogue_tools.py new file mode 100644 index 0000000..e0c74c6 --- /dev/null +++ b/tests/test_arch_catalogue_tools.py @@ -0,0 +1,54 @@ +"""arch_catalogue_list / arch_catalogue_insert through a real MCP client.""" + +from __future__ import annotations + +import pytest +import pytest_asyncio +from fastmcp import Client +from fastmcp.exceptions import ToolError + +import server +from engineering.arch.catalogue import SIZE_BASIS +from engineering.arch.layers import ARCH_ROLE_LAYER + +pytestmark = pytest.mark.asyncio + + +@pytest_asyncio.fixture +async def client(monkeypatch): + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + async with Client(server.mcp) as connected: + await connected.call_tool("drawing_new", {}) + yield connected + + +async def test_list_searches_in_turkish_and_states_the_size_basis(client): + result = ( + await client.call_tool("arch_catalogue_list", {"query": "yatak", "lang": "tr"}) + ).structured_content + assert result["count"] == 2 + assert [row["name"] for row in result["items"]] == ["single_bed", "double_bed"] + assert result["items"][0]["label"] == "tek kişilik yatak" + assert result["size_basis"] == SIZE_BASIS + assert result["families"] == ["furniture", "sanitary"] + + +async def test_insert_twice_defines_once(client): + first = ( + await client.call_tool("arch_catalogue_insert", {"name": "wc", "x": 0.0, "y": 0.0}) + ).structured_content + second = ( + await client.call_tool( + "arch_catalogue_insert", {"name": "wc", "x": 1000.0, "y": 0.0, "rotation": 180.0} + ) + ).structured_content + assert first["defined"] is True and second["defined"] is False + assert first["block_name"] == second["block_name"] == "ARCH_WC" + assert first["layer"] == ARCH_ROLE_LAYER["sanitary"] + + +async def test_an_unknown_item_is_a_tool_error_naming_the_nearest(client): + with pytest.raises(ToolError, match="nearest: wardrobe"): + await client.call_tool("arch_catalogue_insert", {"name": "wardrob", "x": 0.0, "y": 0.0}) + with pytest.raises(ToolError, match="furniture, sanitary"): + await client.call_tool("arch_catalogue_list", {"family": "garden"}) diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index aa37309..e06fb5b 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping(): async def test_tool_group_sizes_are_unchanged(): - """Frozen snapshot of the surface (229 tools, 25 groups). + """Frozen snapshot of the surface (231 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 @@ -581,6 +581,14 @@ async def test_tool_group_sizes_are_unchanged(): `sheet`, so pack, group and SECTION are one list again: `engineering` returned 17 -> 10, `blocks` 11 -> 9, `entity_creation` 19 -> 18 and `drawing` 12 -> 11 — every one of them its pre-SECTION-24 value. + + Track F group C opened SECTION 25 with the furniture and sanitary + catalogue: `arch_catalogue_list` is tagged `arch` and `query`, + `arch_catalogue_insert` `arch` and `create`. `arch` is not in + `_GROUP_TAG_PRIORITY` on this branch, so they file under their secondary + tags: `entity_query` 9 -> 10, `entity_creation` 18 -> 19. 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 == { @@ -591,9 +599,9 @@ async def test_tool_group_sizes_are_unchanged(): "dimensions": 5, "drawing": 11, "engineering": 10, - "entity_creation": 18, + "entity_creation": 19, "entity_modification": 16, - "entity_query": 9, + "entity_query": 10, "environment": 22, "layers": 14, "layouts": 12, @@ -610,4 +618,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 229 + assert sum(sizes.values()) == 231 From f5f11178a952c1240f83328d2e0294558e978d28 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 23 Sep 2026 22:42:42 +0300 Subject: [PATCH 2/2] feat(task-8): SECTION 25 grid and symbols - arch_grid, arch_symbol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit engineering/arch/symbols.py is pure: grid_prims() draws each structural axis on the grid role - its CENTER linetype comes from the A-GRID-E-N row, no primitive sets one - with a 5 mm (paper) bubble and its label touching both ends, on the symbol role so the circle does not print dashed. Numbers run along x and letters along y (A..Z, AA, ... none skipped) unless labels names them; positions are sorted, duplicates and label-count mismatches refused by path. symbol_prims() draws the four plan symbols: the north arrow (a circle, an arrow turned CCW from +Y, the vocab(lang) north letter upright beyond the tip), the section mark (the cut line, a labelled bubble past each end and a viewing arrow at each end, p1/p2 relative to the origin), the level mark (the triangle, its line and the value formatted +3.00 / ±0.00, with a decimal comma in Turkish) and the elevation mark (a circle whose pointers are the tangents from r*sqrt(2) touching it at +/-45 degrees). An unknown kind is refused with the list, a parameter the kind does not take with the ones it does. Every size is a declared paper-mm drafting choice times the plot scale, not a standards value. arch_grid and arch_symbol draw through draw_prims with ARCH_ROLE_LAYER; alias records (COLUMNGRIDADD, English and Turkish synonyms), a regenerated inventory, the README snapshot at 233 tools and the branch-local group snapshot (entity_creation 21) complete the registration. Beyond the plan: grid_axes refuses a labels value that is not a dict and a labels.x / labels.y that is not a list (a string was split into one label per character, a number raised a bare TypeError). No new ActiveX member: a live run on AutoCAD 2026 (scratch document, closed after) drew the grid and all four symbols through draw_prims - A-GRID-E-N created with CENTER, bubbles, texts and symbols on A-SYMB-T-N, the Turkish north letter K and the level "+3,00". Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 6 +- discovery/aliases.py | 30 +++ docs/tool-inventory.json | 32 ++- engineering/arch/__init__.py | 16 ++ engineering/arch/symbols.py | 421 ++++++++++++++++++++++++++++++++ server.py | 135 +++++++++- tests/test_arch_symbol_tools.py | 64 +++++ tests/test_arch_symbols.py | 358 +++++++++++++++++++++++++++ tests/test_tool_registry.py | 10 +- 9 files changed, 1063 insertions(+), 9 deletions(-) create mode 100644 engineering/arch/symbols.py create mode 100644 tests/test_arch_symbol_tools.py create mode 100644 tests/test_arch_symbols.py diff --git a/README.md b/README.md index 6c4c2b7..805a949 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:** 233 tools · 8 resources · 5 prompt templates · 4005 collected tests. +> 233 is the **registered** count; a default install advertises 228 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. ## Why this exists @@ -120,7 +120,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor | Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) | | Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks | -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. +233 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/discovery/aliases.py b/discovery/aliases.py index 2a944e7..67ca41f 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -2264,6 +2264,36 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "mobilya yerlestir", ), ), + "arch_grid": ToolAliases( + acad=("COLUMNGRIDADD",), + synonyms=( + "structural grid", + "column grid", + "axis grid", + "grid bubbles", + "grid lines with bubbles", + "numbered and lettered axes", + "aks sistemi", + "akslar", + "kolon aksi", + ), + ), + "arch_symbol": ToolAliases( + acad=(), + synonyms=( + "north arrow", + "north point", + "section mark", + "section cut symbol", + "level mark", + "floor level symbol", + "elevation mark", + "kuzey oku", + "kot isareti", + "kesit isareti", + "gorunus isareti", + ), + ), } diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 97fa0bf..db78b61 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": 233, "resources": 8, "prompts": 5, "groups": 25 @@ -16,7 +16,7 @@ "dimensions": 5, "drawing": 11, "engineering": 10, - "entity_creation": 19, + "entity_creation": 21, "entity_modification": 16, "entity_query": 10, "environment": 22, @@ -208,6 +208,34 @@ "readOnlyHint": true } }, + { + "name": "arch_grid", + "group": "entity_creation", + "cost": "mutate", + "summary": "Draw a structural grid: axis lines with numbered and lettered bubbles at both ends.", + "tags": [ + "arch", + "create" + ], + "annotations": { + "title": "Architecture: Structural Grid", + "destructiveHint": false + } + }, + { + "name": "arch_symbol", + "group": "entity_creation", + "cost": "mutate", + "summary": "Draw a plan symbol: north arrow, section mark, level mark or elevation mark.", + "tags": [ + "arch", + "create" + ], + "annotations": { + "title": "Architecture: Symbol", + "destructiveHint": false + } + }, { "name": "balloon_add", "group": "sheet", diff --git a/engineering/arch/__init__.py b/engineering/arch/__init__.py index 22c2b2d..df92a6e 100644 --- a/engineering/arch/__init__.py +++ b/engineering/arch/__init__.py @@ -40,6 +40,15 @@ from .model import ( wall_from_dict, wall_length, ) +from .symbols import ( + SYMBOL_KINDS, + draw_grid, + draw_symbol, + fmt_level, + grid_axes, + grid_prims, + symbol_prims, +) __all__ = [ "APP_ID", @@ -56,6 +65,7 @@ __all__ = [ "Opening", "Room", "SIZE_BASIS", + "SYMBOL_KINDS", "Stair", "WALL_HATCH_TABLE", "WALL_MATERIALS", @@ -64,15 +74,21 @@ __all__ = [ "block_spec", "catalogue", "decode", + "draw_grid", + "draw_symbol", "encode", "fmt_area_m2", + "fmt_level", "fmt_number", "from_payload", + "grid_axes", + "grid_prims", "insert_item", "opening_from_dict", "room_from_dict", "segment_of", "stair_from_dict", + "symbol_prims", "tag_prefix", "to_payload", "to_values", diff --git a/engineering/arch/symbols.py b/engineering/arch/symbols.py new file mode 100644 index 0000000..5b5c7cb --- /dev/null +++ b/engineering/arch/symbols.py @@ -0,0 +1,421 @@ +"""The structural grid and the architectural symbols - pure primitives. + +:func:`grid_prims` draws the axis lines of a structural grid on the ``grid`` +role (whose layer carries the CENTER linetype, so no primitive sets one) with a +bubble and its label at both ends: numbers along x, letters along y, unless +``labels`` names them. :func:`symbol_prims` draws the four plan symbols: the +north arrow, the section mark, the level mark and the elevation mark. + +Every size here is **paper millimetres times ``scale``** (the plot-scale +denominator: 50 means 1:50), so a 5 mm bubble is 250 drawing units at 1:50 and +reads 5 mm on the printed sheet. The paper sizes below are this module's own +drafting choices, declared as such - no standard in this repository fixes a grid +bubble's diameter or a north arrow's length. + +Bubbles, labels and symbols go on the ``symbol`` role, not on ``grid``: a +bubble on the grid layer would inherit CENTER and print as a dashed circle. + +Labels are centred with ``engineering.sheet.frames.centred_text`` (the +repository's one centring rule: 0.6 x height advance per character), because +``entity_create_text`` carries no alignment. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +from engineering.arch.lang import fmt_number, vocab +from engineering.arch.layers import ARCH_ROLE_LAYER +from engineering.mech.primitives import Circle, Line, Poly, Prim, Pt, Text +from engineering.sheet.frames import centred_text + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +# -- declared paper sizes (mm on the printed sheet; multiplied by `scale`) ----- + +BUBBLE_RADIUS = 5.0 +LABEL_HEIGHT = 3.5 +NORTH_RADIUS = 8.0 +SECTION_ARROW_LENGTH = 6.0 +SECTION_ARROW_HALF_WIDTH = 2.0 +LEVEL_TRIANGLE = 2.5 +LEVEL_LINE = 15.0 +LEVEL_TEXT_HEIGHT = 2.5 +LEVEL_TEXT_GAP = 1.0 + +SYMBOL_KINDS: tuple[str, ...] = ("north_arrow", "section_mark", "level", "elevation_mark") +SECTION_SIDES: tuple[str, ...] = ("left", "right") +#: An elevation mark's direction in drawing terms, not compass terms: "up" is +#: +Y on the sheet whatever the north arrow says. +ELEVATION_DIRECTIONS: dict[str, float] = {"right": 0.0, "up": 90.0, "left": 180.0, "down": 270.0} + +_PARAMS: dict[str, tuple[str, ...]] = { + "north_arrow": ("rotation",), + "section_mark": ("p1", "p2", "label", "side"), + "level": ("value",), + "elevation_mark": ("label", "directions"), +} + + +def _finite(value, where: str) -> float: + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError(f"{where}: expected a number, got {value!r}.") from None + if not math.isfinite(number): + raise ValueError(f"{where}: must be finite, got {value!r}.") + return number + + +def _positive(value, where: str) -> float: + number = _finite(value, where) + if number <= 0.0: + raise ValueError(f"{where}: must be greater than zero, got {number:g}.") + return number + + +def _point(value, where: str) -> Pt: + try: + x, y = value + except (TypeError, ValueError): + raise ValueError(f"{where}: expected an [x, y] pair, got {value!r}.") from None + return (_finite(x, f"{where}.x"), _finite(y, f"{where}.y")) + + +def _label(value, where: str) -> str: + text = " ".join(str(value).split()) if value is not None else "" + if not text: + raise ValueError(f"{where}: a label must be a non-empty string.") + return text + + +def _centred(cx: float, cy: float, text: str, height: float) -> Text: + return centred_text(cx, cy, text, height)._replace(role="symbol") + + +def _turn(vx: float, vy: float, deg: float) -> Pt: + c = math.cos(math.radians(deg)) + s = math.sin(math.radians(deg)) + return (vx * c - vy * s, vx * s + vy * c) + + +# -- the structural grid ----------------------------------------------------- + + +def _letters(index: int) -> str: + """0 -> A ... 25 -> Z, 26 -> AA: every letter, none skipped.""" + out = "" + n = index + 1 + while n: + n, rest = divmod(n - 1, 26) + out = chr(ord("A") + rest) + out + return out + + +def grid_axes(x_axes, y_axes, *, labels=None) -> dict: + """The grid's axes sorted by position, each with its label. + + ``labels`` is ``{"x": [...], "y": [...]}`` (either key optional), one label + per axis in the order the positions were given. Refused by path: an empty + axis list, a non-finite or duplicate position, a label count that does not + match, a duplicate or empty label, an unknown ``labels`` key. + """ + out: dict[str, list[dict]] = {} + if labels is not None and not isinstance(labels, dict): + raise ValueError(f"labels: expected {{'x': [...], 'y': [...]}}, got {labels!r}.") + given = {} if labels is None else dict(labels) + unknown = sorted(set(given) - {"x", "y"}) + if unknown: + raise ValueError(f"labels: unknown key(s) {', '.join(unknown)}; keys are x, y.") + for key, positions, default in ( + ("x", x_axes, lambda i: str(i + 1)), + ("y", y_axes, _letters), + ): + where = f"{key}_axes" + values = [_finite(v, f"{where}[{i}]") for i, v in enumerate(positions or ())] + if not values: + raise ValueError(f"{where}: a grid needs at least one axis in each direction.") + names = given.get(key) + if names is None: + order = sorted(range(len(values)), key=lambda i: values[i]) + names_sorted = [default(rank) for rank in range(len(values))] + pairs = [(values[i], names_sorted[rank]) for rank, i in enumerate(order)] + else: + if not isinstance(names, (list, tuple)): + raise ValueError(f"labels.{key}: expected a list of labels, got {names!r}.") + if len(names) != len(values): + raise ValueError( + f"labels.{key}: {len(names)} label(s) for {len(values)} axis position(s)." + ) + texts = [_label(n, f"labels.{key}[{i}]") for i, n in enumerate(names)] + if len(set(texts)) != len(texts): + raise ValueError(f"labels.{key}: every axis label must be unique, got {texts}.") + pairs = sorted(zip(values, texts, strict=True), key=lambda pair: pair[0]) + for (a, _), (b, _) in zip(pairs, pairs[1:], strict=False): + if abs(b - a) <= 1e-9: + raise ValueError(f"{where}: two axes at the same position {a:g}.") + out[key] = [{"label": text, "position": value} for value, text in pairs] + return out + + +def grid_prims( + x_axes, y_axes, *, labels=None, extension: float = 1500.0, scale: float = 50 +) -> tuple[Prim, ...]: + """Axis lines on ``grid`` with a labelled bubble at both ends of each. + + ``x_axes`` are the x positions of the vertical axes (numbered 1, 2, 3 ... + from left to right), ``y_axes`` the y positions of the horizontal axes + (lettered A, B, C ... from bottom to top). Every axis runs ``extension`` + drawing units past the outermost crossing axis; the bubble (radius + ``BUBBLE_RADIUS * scale``) sits beyond that, touching the line's end. + Per axis the primitives are: the line, then for each end its bubble and + its label. + """ + ext = _positive(extension, "extension") + k = _positive(scale, "scale") + axes = grid_axes(x_axes, y_axes, labels=labels) + r = BUBBLE_RADIUS * k + h = LABEL_HEIGHT * k + xs = [axis["position"] for axis in axes["x"]] + ys = [axis["position"] for axis in axes["y"]] + y_lo, y_hi = min(ys) - ext, max(ys) + ext + x_lo, x_hi = min(xs) - ext, max(xs) + ext + prims: list[Prim] = [] + for axis in axes["x"]: + x = axis["position"] + prims.append(Line((x, y_lo), (x, y_hi), "grid")) + for cy in (y_lo - r, y_hi + r): + prims.append(Circle((x, cy), r, "symbol")) + prims.append(_centred(x, cy, axis["label"], h)) + for axis in axes["y"]: + y = axis["position"] + prims.append(Line((x_lo, y), (x_hi, y), "grid")) + for cx in (x_lo - r, x_hi + r): + prims.append(Circle((cx, y), r, "symbol")) + prims.append(_centred(cx, y, axis["label"], h)) + return tuple(prims) + + +# -- the four symbols ---------------------------------------------------------- + + +def _north_arrow(at: Pt, k: float, lang: str, rotation=0.0) -> tuple[Prim, ...]: + """A circle, an arrow whose tip is north, the north letter beyond the tip. + + ``rotation`` is degrees CCW from the sheet's +Y: 0 puts north straight up. + The letter stays upright; only its position turns with the arrow. + """ + turn = _finite(rotation, "north_arrow.rotation") + big_r = NORTH_RADIUS * k + h = LABEL_HEIGHT * k + ax, ay = at + local = ( + (0.0, big_r), + (-0.4 * big_r, -0.6 * big_r), + (0.0, -0.3 * big_r), + (0.4 * big_r, -0.6 * big_r), + ) + arrow = tuple((ax + dx, ay + dy) for dx, dy in (_turn(x, y, turn) for x, y in local)) + lx, ly = _turn(0.0, big_r + h, turn) + return ( + Circle((ax, ay), big_r, "symbol"), + Poly(arrow, True, "symbol"), + _centred(ax + lx, ay + ly, vocab(lang)["north"], h), + ) + + +def _section_mark(at: Pt, k: float, lang: str, p1=None, p2=None, label="A", side="left"): + """The cut line, a labelled bubble beyond each end, an arrow at each end. + + ``p1`` and ``p2`` are relative to ``at`` (so ``at=(0, 0)`` makes them WCS). + ``side`` is the side of the line the section looks towards, seen from + ``p1`` looking at ``p2``. + """ + if p1 is None or p2 is None: + raise ValueError("section_mark: p1 and p2 are required (the two ends of the cut line).") + a = _point(p1, "section_mark.p1") + b = _point(p2, "section_mark.p2") + text = _label(label, "section_mark.label") + if side not in SECTION_SIDES: + raise ValueError( + f"section_mark.side: must be one of {', '.join(SECTION_SIDES)}, got {side!r}." + ) + x1, y1 = at[0] + a[0], at[1] + a[1] + x2, y2 = at[0] + b[0], at[1] + b[1] + span = math.hypot(x2 - x1, y2 - y1) + r = BUBBLE_RADIUS * k + h = LABEL_HEIGHT * k + length = SECTION_ARROW_LENGTH * k + half = SECTION_ARROW_HALF_WIDTH * k + if span <= 4.0 * half: + raise ValueError( + f"section_mark: the cut line is {span:g} long; it must be longer than " + f"{4.0 * half:g} ({4.0 * SECTION_ARROW_HALF_WIDTH:g} mm at 1:{k:g}) so the two " + "arrows do not overlap." + ) + ux, uy = (x2 - x1) / span, (y2 - y1) / span + dx, dy = (-uy, ux) if side == "left" else (uy, -ux) + prims: list[Prim] = [Line((x1, y1), (x2, y2), "symbol")] + for cx, cy in ((x1 - ux * r, y1 - uy * r), (x2 + ux * r, y2 + uy * r)): + prims.append(Circle((cx, cy), r, "symbol")) + prims.append(_centred(cx, cy, text, h)) + for ex, ey, sign in ((x1, y1, 1.0), (x2, y2, -1.0)): + inner = (ex + sign * ux * 2.0 * half, ey + sign * uy * 2.0 * half) + mid = (ex + sign * ux * half, ey + sign * uy * half) + apex = (mid[0] + dx * length, mid[1] + dy * length) + prims.append(Poly(((ex, ey), inner, apex), True, "symbol")) + return tuple(prims) + + +def fmt_level(value: float, lang: str) -> str: + """``±0.00`` at zero, ``+3.00`` above, ``-0.45`` below; ``tr`` writes a comma.""" + number = _finite(value, "level.value") + if abs(number) < 0.005: + return "±" + fmt_number(0.0, lang, 2) + sign = "+" if number > 0 else "-" + return sign + fmt_number(abs(number), lang, 2) + + +def _level(at: Pt, k: float, lang: str, value=0.0) -> tuple[Prim, ...]: + """A triangle whose apex is the level point, a line, the value above the line. + + ``value`` is in metres, as a level is written on a plan. + """ + text = fmt_level(value, lang) + t = LEVEL_TRIANGLE * k + ax, ay = at + gap = LEVEL_TEXT_GAP * k + return ( + Poly(((ax, ay), (ax - t, ay + t), (ax + t, ay + t)), True, "symbol"), + Line((ax + t, ay + t), (ax + t + LEVEL_LINE * k, ay + t), "symbol"), + Text((ax + t + gap, ay + t + gap), text, LEVEL_TEXT_HEIGHT * k, 0.0, "symbol"), + ) + + +def _elevation_mark(at: Pt, k: float, lang: str, label="1", directions=("up",)): + """A circle, the label in it, and one pointer per viewing direction. + + Each pointer is the two tangents from an apex at ``r * sqrt(2)`` along the + direction to the circle, touching it at the direction +/- 45 degrees. + """ + text = _label(label, "elevation_mark.label") + names = list(directions) if not isinstance(directions, str) else [directions] + if not names: + raise ValueError("elevation_mark.directions: name at least one direction.") + for i, name in enumerate(names): + if name not in ELEVATION_DIRECTIONS: + raise ValueError( + f"elevation_mark.directions[{i}]: must be one of " + f"{', '.join(ELEVATION_DIRECTIONS)}, got {name!r}." + ) + if len(set(names)) != len(names): + raise ValueError(f"elevation_mark.directions: each direction once, got {names}.") + r = BUBBLE_RADIUS * k + ax, ay = at + prims: list[Prim] = [Circle((ax, ay), r, "symbol"), _centred(ax, ay, text, LABEL_HEIGHT * k)] + for name in names: + theta = ELEVATION_DIRECTIONS[name] + apex_r = r * math.sqrt(2.0) + apex = ( + ax + apex_r * math.cos(math.radians(theta)), + ay + apex_r * math.sin(math.radians(theta)), + ) + side_a = ( + ax + r * math.cos(math.radians(theta - 45.0)), + ay + r * math.sin(math.radians(theta - 45.0)), + ) + side_b = ( + ax + r * math.cos(math.radians(theta + 45.0)), + ay + r * math.sin(math.radians(theta + 45.0)), + ) + prims.append(Poly((side_a, apex, side_b), False, "symbol")) + return tuple(prims) + + +_BUILDERS = { + "north_arrow": _north_arrow, + "section_mark": _section_mark, + "level": _level, + "elevation_mark": _elevation_mark, +} + + +def symbol_prims( + kind: str, at: Pt, *, lang: str = "en", scale: float = 50, **params +) -> tuple[Prim, ...]: + """One architectural symbol at ``at``, sized in paper mm times ``scale``. + + ``kind`` and its ``params``: ``north_arrow`` (``rotation``), + ``section_mark`` (``p1``, ``p2``, ``label``, ``side``), ``level`` + (``value`` in metres), ``elevation_mark`` (``label``, ``directions``). + Refused before anything is built: an unknown kind (with the list), a + parameter the kind does not take (with the ones it does), an unknown + ``lang``, a non-finite or non-positive ``scale``. + """ + if kind not in SYMBOL_KINDS: + raise ValueError(f"unknown symbol kind {kind!r}; kinds are {', '.join(SYMBOL_KINDS)}.") + extra = sorted(set(params) - set(_PARAMS[kind])) + if extra: + raise ValueError( + f"{kind}: unknown parameter(s) {', '.join(extra)}; it takes {', '.join(_PARAMS[kind])}." + ) + vocab(lang) + k = _positive(scale, "scale") + origin = _point(at, "at") + return _BUILDERS[kind](origin, k, lang, **params) + + +# -- the async layer: the prims through draw_prims ------------------------------ + + +async def draw_grid( + backend: AutoCADBackend, + x_axes, + y_axes, + *, + labels=None, + extension: float = 1500.0, + scale: float = 50, +) -> dict: + """Draw :func:`grid_prims` through ``draw_prims`` with the arch role map.""" + from engineering.mech.draw import draw_prims + + axes = grid_axes(x_axes, y_axes, labels=labels) + prims = grid_prims(x_axes, y_axes, labels=labels, extension=extension, scale=scale) + drawn = await draw_prims(backend, prims, role_layer=ARCH_ROLE_LAYER) + return { + "ok": True, + "axes": axes, + "bubble_radius": BUBBLE_RADIUS * float(scale), + "handles": drawn["handles"], + "counts": drawn["counts"], + "layers": sorted({ARCH_ROLE_LAYER["grid"], ARCH_ROLE_LAYER["symbol"]}), + "backend": backend.name, + } + + +async def draw_symbol( + backend: AutoCADBackend, + kind: str, + at: Pt, + *, + lang: str = "en", + scale: float = 50, + **params, +) -> dict: + """Draw :func:`symbol_prims` through ``draw_prims`` with the arch role map.""" + from engineering.mech.draw import draw_prims + + prims = symbol_prims(kind, at, lang=lang, scale=scale, **params) + drawn = await draw_prims(backend, prims, role_layer=ARCH_ROLE_LAYER) + return { + "ok": True, + "kind": kind, + "at": [float(at[0]), float(at[1])], + "handles": drawn["handles"], + "counts": drawn["counts"], + "layer": ARCH_ROLE_LAYER["symbol"], + "backend": backend.name, + } diff --git a/server.py b/server.py index b92c819..70e8893 100644 --- a/server.py +++ b/server.py @@ -9781,7 +9781,7 @@ async def drawing_export_dwg( # --------------------------------------------------------------------------- -# ── SECTION 25: Architecture (2 tools) ────────────────────────────────────── +# ── SECTION 25: Architecture (4 tools) ────────────────────────────────────── # --------------------------------------------------------------------------- # ── arch: catalogue (group C) ── @@ -9888,6 +9888,139 @@ async def arch_catalogue_insert( raise ToolError(str(exc)) from exc +@cad_tool( + summary="Draw a structural grid: axis lines with numbered and lettered bubbles at both ends.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Architecture: Structural Grid", "destructiveHint": False}, + tags={"arch", "create"}, +) +async def arch_grid( + x_axes: Annotated[ + list[float], + Field(description="X positions (WCS) of the vertical axes; numbered 1, 2, 3 left to right"), + ], + y_axes: Annotated[ + list[float], + Field( + description="Y positions (WCS) of the horizontal axes; lettered A, B, C bottom to top" + ), + ], + labels: Annotated[ + dict | None, + Field( + default=None, + description=( + "Override the labels: {'x': [...], 'y': [...]} (either key), one label per " + "position in the order the positions were given" + ), + ), + ] = None, + extension: Annotated[ + float, + Field( + default=1500.0, + gt=0, + description="Drawing units each axis runs past the outermost crossing axis", + ), + ] = 1500.0, + scale: Annotated[ + float, + Field( + default=50.0, + gt=0, + description="Plot-scale denominator (50 = 1:50): bubbles and labels are paper mm x scale", + ), + ] = 50.0, + ctx: Context = None, +) -> dict: + """Axis lines on the arch grid layer (its CENTER linetype comes from the layer) and a + 5 mm bubble with its label at both ends of each axis, on the symbol layer. + + Numbers run along x and letters along y (A..Z, then AA, AB - no letter is + skipped) unless `labels` names them. The bubble and label sizes are this + server's declared drafting sizes in paper millimetres, not a standards value. + Refusals, all before any write: an empty axis list in either direction, a + non-finite or duplicate position, a label list whose length does not match + its positions, a duplicate or empty label, an unknown `labels` key, and a + non-positive `extension` or `scale`. + """ + from engineering.arch.symbols import draw_grid + + await ctx.info(f"arch grid: {len(x_axes)} x {len(y_axes)} axes at 1:{scale:g}") + try: + return await draw_grid( + _backend(ctx), x_axes, y_axes, labels=labels, extension=extension, scale=scale + ) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Draw a plan symbol: north arrow, section mark, level mark or elevation mark.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Architecture: Symbol", "destructiveHint": False}, + tags={"arch", "create"}, +) +async def arch_symbol( + kind: Annotated[ + str, + Field(description="north_arrow | section_mark | level | elevation_mark"), + ], + x: Annotated[float, Field(description="Symbol origin X (WCS)")], + y: Annotated[float, Field(description="Symbol origin Y (WCS)")], + params: Annotated[ + dict | None, + Field( + default=None, + description=( + "north_arrow: {rotation} (degrees CCW from +Y; 0 = north up). " + "section_mark: {p1, p2, label, side} - p1/p2 are [x, y] relative to (x, y), " + "side left|right is where the section looks, seen from p1 towards p2. " + "level: {value} in metres (0 -> '±0.00'). " + "elevation_mark: {label, directions} - directions from up|right|down|left" + ), + ), + ] = None, + lang: Annotated[ + str, + Field(default="en", description="en | tr - the north letter and the decimal separator"), + ] = "en", + scale: Annotated[ + float, + Field( + default=50.0, + gt=0, + description="Plot-scale denominator (50 = 1:50): symbol sizes are paper mm x scale", + ), + ] = 50.0, + ctx: Context = None, +) -> dict: + """Draw one architectural symbol on the arch symbol layer. + + Sizes are this server's declared drafting sizes in paper millimetres times + `scale` (a 16 mm north-arrow circle, 10 mm section and elevation bubbles), + not standards values. Refusals, all before any write: an unknown `kind` + (with the list), a parameter the kind does not take (with the ones it + does), a section mark without p1/p2, with an empty label, an unknown side + or a cut line too short for its two arrows, an elevation direction outside + up/right/down/left or named twice, a non-finite level value, and a `lang` + other than en/tr. + """ + from engineering.arch.symbols import draw_symbol + + await ctx.info(f"arch symbol: {kind} at ({x}, {y})") + try: + return await draw_symbol( + _backend(ctx), kind, (x, y), lang=lang, scale=scale, **(params or {}) + ) + except (TypeError, ValueError) as exc: + raise ToolError(str(exc)) from exc + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_arch_symbol_tools.py b/tests/test_arch_symbol_tools.py new file mode 100644 index 0000000..b938246 --- /dev/null +++ b/tests/test_arch_symbol_tools.py @@ -0,0 +1,64 @@ +"""arch_grid / arch_symbol through a real MCP client.""" + +from __future__ import annotations + +import pytest +import pytest_asyncio +from fastmcp import Client +from fastmcp.exceptions import ToolError + +import server +from engineering.arch.layers import ARCH_ROLE_LAYER + +pytestmark = pytest.mark.asyncio + + +@pytest_asyncio.fixture +async def client(monkeypatch): + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + async with Client(server.mcp) as connected: + await connected.call_tool("drawing_new", {}) + yield connected + + +async def test_grid_reports_its_axes_and_bubble_size(client): + result = ( + await client.call_tool( + "arch_grid", {"x_axes": [0.0, 4000.0, 8000.0], "y_axes": [0.0, 5000.0], "scale": 100} + ) + ).structured_content + assert [axis["label"] for axis in result["axes"]["x"]] == ["1", "2", "3"] + assert [axis["label"] for axis in result["axes"]["y"]] == ["A", "B"] + assert result["bubble_radius"] == 500.0 + assert len(result["handles"]) == 5 * 5 + assert result["layers"] == sorted({ARCH_ROLE_LAYER["grid"], ARCH_ROLE_LAYER["symbol"]}) + + +async def test_symbol_level_and_north_arrow_in_turkish(client): + result = ( + await client.call_tool( + "arch_symbol", + {"kind": "level", "x": 0.0, "y": 0.0, "params": {"value": 3.0}, "lang": "tr"}, + ) + ).structured_content + assert result["counts"] == {"poly": 1, "line": 1, "text": 1} + north = ( + await client.call_tool( + "arch_symbol", {"kind": "north_arrow", "x": 0.0, "y": 0.0, "lang": "tr"} + ) + ).structured_content + assert north["layer"] == ARCH_ROLE_LAYER["symbol"] + assert north["counts"] == {"circle": 1, "poly": 1, "text": 1} + + +async def test_refusals_are_tool_errors(client): + with pytest.raises( + ToolError, match="kinds are north_arrow, section_mark, level, elevation_mark" + ): + await client.call_tool("arch_symbol", {"kind": "compass", "x": 0.0, "y": 0.0}) + with pytest.raises(ToolError, match="p1 and p2 are required"): + await client.call_tool( + "arch_symbol", {"kind": "section_mark", "x": 0.0, "y": 0.0, "params": {"label": "A"}} + ) + with pytest.raises(ToolError, match="y_axes: a grid needs at least one axis"): + await client.call_tool("arch_grid", {"x_axes": [0.0], "y_axes": []}) diff --git a/tests/test_arch_symbols.py b/tests/test_arch_symbols.py new file mode 100644 index 0000000..d67f9f1 --- /dev/null +++ b/tests/test_arch_symbols.py @@ -0,0 +1,358 @@ +"""The structural grid and the architectural symbols. + +Pure geometry: the emitted primitives are asserted against coordinates worked +out by hand in each test (1:50 unless a test says otherwise, so a 5 mm paper +bubble is 250 drawing units). The last tests draw through the headless backend. +""" + +from __future__ import annotations + +import math + +import pytest + +from engineering.arch.lang import vocab +from engineering.arch.layers import ARCH_LAYERS, ARCH_ROLE_LAYER +from engineering.arch.symbols import ( + SYMBOL_KINDS, + draw_grid, + draw_symbol, + fmt_level, + grid_axes, + grid_prims, + symbol_prims, +) +from engineering.mech.primitives import Circle, Line, Poly, Text + +TOL = 1e-9 + + +def close(a, b) -> bool: + return abs(a[0] - b[0]) <= TOL and abs(a[1] - b[1]) <= TOL + + +def of(prims, kind): + return [p for p in prims if isinstance(p, kind)] + + +def centred_at(text: Text, cx: float, cy: float) -> bool: + """The inverse of engineering.sheet.frames.centred_text.""" + h = text.height + return close(text.at, (cx - 0.3 * h * len(text.text), cy - 0.5 * h)) + + +# -- the grid ------------------------------------------------------------------ + + +def test_grid_numbers_along_x_and_letters_along_y(): + axes = grid_axes([0.0, 5000.0, 9000.0], [0.0, 6000.0]) + assert axes == { + "x": [ + {"label": "1", "position": 0.0}, + {"label": "2", "position": 5000.0}, + {"label": "3", "position": 9000.0}, + ], + "y": [{"label": "A", "position": 0.0}, {"label": "B", "position": 6000.0}], + } + + +def test_grid_lines_run_past_the_outer_axes_by_the_extension(): + prims = grid_prims([0.0, 5000.0, 9000.0], [0.0, 6000.0], extension=1500.0, scale=50) + lines = of(prims, Line) + assert len(lines) == 5 + assert all(line.role == "grid" for line in lines) + # vertical axes: y from 0 - 1500 to 6000 + 1500 + assert close(lines[0].p1, (0.0, -1500.0)) and close(lines[0].p2, (0.0, 7500.0)) + assert close(lines[2].p1, (9000.0, -1500.0)) and close(lines[2].p2, (9000.0, 7500.0)) + # horizontal axes: x from 0 - 1500 to 9000 + 1500 + assert close(lines[3].p1, (-1500.0, 0.0)) and close(lines[3].p2, (10500.0, 0.0)) + assert close(lines[4].p1, (-1500.0, 6000.0)) and close(lines[4].p2, (10500.0, 6000.0)) + + +def test_grid_bubbles_touch_both_ends_and_carry_the_labels(): + prims = grid_prims([0.0, 5000.0, 9000.0], [0.0, 6000.0], extension=1500.0, scale=50) + bubbles = of(prims, Circle) + texts = of(prims, Text) + assert len(bubbles) == 10 and len(texts) == 10 + assert all(b.radius == 250.0 and b.role == "symbol" for b in bubbles) + assert all(t.role == "symbol" and t.height == 175.0 for t in texts) + # axis 1 (x = 0): bubbles at y = -1500 - 250 and y = 7500 + 250 + assert close(bubbles[0].center, (0.0, -1750.0)) + assert close(bubbles[1].center, (0.0, 7750.0)) + # axis B (y = 6000): bubbles at x = -1500 - 250 and x = 10500 + 250 + assert close(bubbles[8].center, (-1750.0, 6000.0)) + assert close(bubbles[9].center, (10750.0, 6000.0)) + assert [t.text for t in texts] == ["1", "1", "2", "2", "3", "3", "A", "A", "B", "B"] + # "1" at (0, -1750), height 175: left baseline at (-52.5, -1837.5) + assert close(texts[0].at, (-52.5, -1837.5)) + for bubble, text in zip(bubbles, texts, strict=True): + assert centred_at(text, *bubble.center) + + +def test_grid_prims_come_line_then_bubble_label_pairs_per_axis(): + prims = grid_prims([0.0], [0.0], extension=1000.0, scale=100) + assert [type(p).__name__ for p in prims] == [ + "Line", + "Circle", + "Text", + "Circle", + "Text", + "Line", + "Circle", + "Text", + "Circle", + "Text", + ] + # 1:100 -> a 5 mm bubble is 500 units; the vertical axis spans y -1000 .. 1000 + assert close(prims[1].center, (0.0, -1500.0)) and prims[1].radius == 500.0 + + +def test_grid_unsorted_positions_are_numbered_left_to_right(): + axes = grid_axes([9000.0, 0.0, 5000.0], [6000.0, 0.0]) + assert [(a["label"], a["position"]) for a in axes["x"]] == [ + ("1", 0.0), + ("2", 5000.0), + ("3", 9000.0), + ] + assert [(a["label"], a["position"]) for a in axes["y"]] == [("A", 0.0), ("B", 6000.0)] + + +def test_grid_given_labels_follow_their_positions(): + axes = grid_axes([5000.0, 0.0], [0.0, 6000.0], labels={"x": ["B2", "B1"], "y": ["K", "L"]}) + assert [(a["label"], a["position"]) for a in axes["x"]] == [("B1", 0.0), ("B2", 5000.0)] + assert [a["label"] for a in axes["y"]] == ["K", "L"] + + +def test_grid_letters_continue_past_z(): + axes = grid_axes([0.0], [float(i) * 1000.0 for i in range(28)]) + assert [a["label"] for a in axes["y"]][24:] == ["Y", "Z", "AA", "AB"] + + +def test_grid_refusals_name_their_path(): + with pytest.raises(ValueError, match="y_axes: a grid needs at least one axis"): + grid_prims([0.0, 5000.0], []) + with pytest.raises(ValueError, match=r"x_axes: two axes at the same position 0"): + grid_prims([0.0, 0.0], [0.0]) + with pytest.raises(ValueError, match=r"x_axes\[1\]: must be finite"): + grid_prims([0.0, float("nan")], [0.0]) + with pytest.raises(ValueError, match=r"labels.x: 1 label\(s\) for 2 axis position\(s\)"): + grid_prims([0.0, 5000.0], [0.0], labels={"x": ["1"]}) + with pytest.raises(ValueError, match="labels.y: every axis label must be unique"): + grid_prims([0.0], [0.0, 10.0], labels={"y": ["A", "A"]}) + with pytest.raises(ValueError, match="labels: unknown key"): + grid_prims([0.0], [0.0], labels={"z": ["1"]}) + # a string is not split into one label per character; a number is not a list + with pytest.raises(ValueError, match=r"labels.x: expected a list of labels"): + grid_prims([0.0, 1000.0], [0.0], labels={"x": "AB"}) + with pytest.raises(ValueError, match=r"labels.x: expected a list of labels"): + grid_prims([0.0], [0.0], labels={"x": 5}) + with pytest.raises(ValueError, match=r"labels: expected \{'x'"): + grid_prims([0.0], [0.0], labels=["x"]) + with pytest.raises(ValueError, match="extension: must be greater than zero"): + grid_prims([0.0], [0.0], extension=0.0) + with pytest.raises(ValueError, match="scale: must be greater than zero"): + grid_prims([0.0], [0.0], scale=-50) + + +# -- the north arrow ------------------------------------------------------------- + + +def test_north_arrow_points_up_at_zero_rotation(): + circle, arrow, label = symbol_prims("north_arrow", (1000.0, 2000.0), scale=50) + # radius 8 mm x 50 = 400 + assert isinstance(circle, Circle) and circle.radius == 400.0 + assert close(circle.center, (1000.0, 2000.0)) + assert isinstance(arrow, Poly) and arrow.closed and arrow.role == "symbol" + expected = ((1000.0, 2400.0), (840.0, 1760.0), (1000.0, 1880.0), (1160.0, 1760.0)) + assert all(close(p, q) for p, q in zip(arrow.points, expected, strict=True)) + assert label.text == vocab("en")["north"] + # label centre: 400 + 175 above the centre + assert centred_at(label, 1000.0, 2575.0) + assert label.rotation == 0.0 + + +def test_north_arrow_rotation_turns_the_tip_and_the_label_ccw(): + circle, arrow, label = symbol_prims("north_arrow", (1000.0, 2000.0), scale=50, rotation=90.0) + # CCW 90: +Y becomes -X + assert close(arrow.points[0], (600.0, 2000.0)) + # the left barb (-160, -240) turns to (240, -160) + assert close(arrow.points[1], (1240.0, 1840.0)) + assert centred_at(label, 425.0, 2000.0) + assert label.rotation == 0.0 # the letter stays upright + + +def test_north_arrow_speaks_turkish(): + *_, label = symbol_prims("north_arrow", (0.0, 0.0), lang="tr") + assert label.text == vocab("tr")["north"] + assert vocab("tr")["north"] != vocab("en")["north"] + + +# -- the section mark ------------------------------------------------------------ + + +def test_section_mark_line_bubbles_and_arrows_looking_left(): + prims = symbol_prims( + "section_mark", (0.0, 0.0), scale=50, p1=(0.0, 0.0), p2=(4000.0, 0.0), label="A" + ) + (line,) = of(prims, Line) + assert close(line.p1, (0.0, 0.0)) and close(line.p2, (4000.0, 0.0)) + bubbles = of(prims, Circle) + assert [b.radius for b in bubbles] == [250.0, 250.0] + assert close(bubbles[0].center, (-250.0, 0.0)) and close(bubbles[1].center, (4250.0, 0.0)) + texts = of(prims, Text) + assert [t.text for t in texts] == ["A", "A"] + assert centred_at(texts[0], -250.0, 0.0) and centred_at(texts[1], 4250.0, 0.0) + first, second = of(prims, Poly) + # arrow: 6 mm long, 2 mm half-width at 1:50 -> 300 and 100 + assert all( + close(p, q) + for p, q in zip(first.points, ((0.0, 0.0), (200.0, 0.0), (100.0, 300.0)), strict=True) + ) + assert all( + close(p, q) + for p, q in zip(second.points, ((4000.0, 0.0), (3800.0, 0.0), (3900.0, 300.0)), strict=True) + ) + + +def test_section_mark_looking_right_and_relative_to_at(): + prims = symbol_prims( + "section_mark", + (100.0, 200.0), + scale=50, + p1=(0.0, 0.0), + p2=(0.0, 3000.0), + label="B", + side="right", + ) + (line,) = of(prims, Line) + assert close(line.p1, (100.0, 200.0)) and close(line.p2, (100.0, 3200.0)) + first, _second = of(prims, Poly) + # up the line, looking right is +X + assert close(first.points[2], (100.0 + 300.0, 200.0 + 100.0)) + + +def test_section_mark_refusals(): + with pytest.raises(ValueError, match="p1 and p2 are required"): + symbol_prims("section_mark", (0.0, 0.0), p1=(0.0, 0.0)) + with pytest.raises(ValueError, match="side: must be one of left, right"): + symbol_prims("section_mark", (0.0, 0.0), p1=(0, 0), p2=(1000, 0), side="up") + with pytest.raises(ValueError, match="the cut line is 300 long"): + symbol_prims("section_mark", (0.0, 0.0), p1=(0, 0), p2=(300, 0)) + with pytest.raises(ValueError, match="label must be a non-empty string"): + symbol_prims("section_mark", (0.0, 0.0), p1=(0, 0), p2=(1000, 0), label=" ") + + +# -- the level mark -------------------------------------------------------------- + + +def test_level_formats_the_value_in_both_languages(): + assert fmt_level(0.0, "en") == "±0.00" + assert fmt_level(-0.004, "en") == "±0.00" + assert fmt_level(3.0, "en") == "+3.00" + assert fmt_level(-0.45, "en") == "-0.45" + assert fmt_level(3.0, "tr") == "+3,00" + assert fmt_level(0.0, "tr") == "±0,00" + + +def test_level_triangle_line_and_value(): + triangle, line, text = symbol_prims("level", (1000.0, 1000.0), scale=50, value=2.8) + # 2.5 mm x 50 = 125: apex on the level point, the top edge 125 above it + expected = ((1000.0, 1000.0), (875.0, 1125.0), (1125.0, 1125.0)) + assert triangle.closed and all( + close(p, q) for p, q in zip(triangle.points, expected, strict=True) + ) + # the line runs 15 mm x 50 = 750 to the right from the triangle's right corner + assert close(line.p1, (1125.0, 1125.0)) and close(line.p2, (1875.0, 1125.0)) + # the value sits 1 mm x 50 = 50 right of and above that corner, 2.5 mm high + assert text.text == "+2.80" and text.height == 125.0 + assert close(text.at, (1175.0, 1175.0)) + + +# -- the elevation mark ---------------------------------------------------------- + + +def test_elevation_mark_pointer_is_tangent_at_45_degrees(): + circle, label, pointer = symbol_prims("elevation_mark", (0.0, 0.0), scale=50, label="2") + assert circle.radius == 250.0 + assert label.text == "2" and centred_at(label, 0.0, 0.0) + s = 250.0 * math.sqrt(0.5) # 176.776... + expected = ((s, s), (0.0, 250.0 * math.sqrt(2.0)), (-s, s)) + assert not pointer.closed + assert all(close(p, q) for p, q in zip(pointer.points, expected, strict=True)) + + +def test_elevation_mark_one_pointer_per_direction(): + prims = symbol_prims( + "elevation_mark", (0.0, 0.0), scale=50, label="1", directions=["up", "right", "down"] + ) + pointers = of(prims, Poly) + assert len(pointers) == 3 + apex = 250.0 * math.sqrt(2.0) + assert close(pointers[1].points[1], (apex, 0.0)) + assert close(pointers[2].points[1], (0.0, -apex)) + with pytest.raises(ValueError, match=r"directions\[0\]: must be one of right, up, left, down"): + symbol_prims("elevation_mark", (0.0, 0.0), directions=["north"]) + with pytest.raises(ValueError, match="each direction once"): + symbol_prims("elevation_mark", (0.0, 0.0), directions=["up", "up"]) + + +# -- refusals common to every kind --------------------------------------------- + + +def test_an_unknown_kind_is_refused_with_the_list(): + with pytest.raises(ValueError) as excinfo: + symbol_prims("compass_rose", (0.0, 0.0)) + message = str(excinfo.value) + assert "unknown symbol kind 'compass_rose'" in message + for kind in SYMBOL_KINDS: + assert kind in message + + +def test_a_parameter_the_kind_does_not_take_is_refused(): + with pytest.raises(ValueError, match="level: unknown parameter.*takes value"): + symbol_prims("level", (0.0, 0.0), rotation=45.0) + + +def test_an_unknown_language_is_refused(): + with pytest.raises(ValueError, match="en, tr"): + symbol_prims("north_arrow", (0.0, 0.0), lang="de") + + +# -- drawn through the headless backend ------------------------------------------- + + +@pytest.mark.asyncio +async def test_draw_grid_puts_axes_on_the_grid_layer_and_bubbles_on_symbols(backend): + result = await draw_grid(backend, [0.0, 6000.0], [0.0, 4500.0], scale=50) + assert result["ok"] is True + assert len(result["handles"]) == 4 * 5 + assert result["bubble_radius"] == 250.0 + grid_layer = ARCH_ROLE_LAYER["grid"] + symbol_layer = ARCH_ROLE_LAYER["symbol"] + lines = await backend.entity_list(type_filter="LINE", limit=100) + circles = await backend.entity_list(type_filter="CIRCLE", limit=100) + texts = await backend.entity_list(type_filter="TEXT", limit=100) + assert len(lines) == 4 and {e.layer for e in lines} == {grid_layer} + assert len(circles) == 8 and {e.layer for e in circles} == {symbol_layer} + assert sorted(e.properties["text"] for e in texts) == ["1", "1", "2", "2", "A", "A", "B", "B"] + row = next(row for row in ARCH_LAYERS if row[0] == grid_layer) + info = next(info for info in await backend.layer_list() if info.name == grid_layer) + assert info.linetype.upper() == row[2].upper() + + +@pytest.mark.asyncio +async def test_draw_symbol_writes_the_north_arrow_on_the_symbol_layer(backend): + result = await draw_symbol(backend, "north_arrow", (0.0, 0.0), lang="tr", rotation=30.0) + assert result["ok"] is True and result["kind"] == "north_arrow" + assert result["counts"] == {"circle": 1, "poly": 1, "text": 1} + (text,) = await backend.entity_list(type_filter="TEXT", limit=10) + assert text.layer == ARCH_ROLE_LAYER["symbol"] + assert text.properties["text"] == vocab("tr")["north"] + + +@pytest.mark.asyncio +async def test_draw_symbol_refuses_before_writing_anything(backend): + before = len(await backend.entity_list()) + with pytest.raises(ValueError, match="kinds are north_arrow"): + await draw_symbol(backend, "compass_rose", (0.0, 0.0)) + assert len(await backend.entity_list()) == before diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index e06fb5b..7c08c13 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 (233 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 @@ -589,6 +589,10 @@ async def test_tool_group_sizes_are_unchanged(): tags: `entity_query` 9 -> 10, `entity_creation` 18 -> 19. This snapshot is branch-local: the track F merge task recomputes it once the walls, rooms and catalogue branches have landed. + + Group C's second task added the structural grid and the plan symbols + (`arch_grid` / `arch_symbol`), both tagged `arch` and `create`: + `entity_creation` 19 -> 21, for the same branch-local reason. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} assert sizes == { @@ -599,7 +603,7 @@ async def test_tool_group_sizes_are_unchanged(): "dimensions": 5, "drawing": 11, "engineering": 10, - "entity_creation": 19, + "entity_creation": 21, "entity_modification": 16, "entity_query": 10, "environment": 22, @@ -618,4 +622,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 231 + assert sum(sizes.values()) == 233