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