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