feat(task-7): SECTION 25 catalogue - arch_catalogue_list, arch_catalogue_insert
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_<NAME> 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d5c5e90406
commit
afd42356b5
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
||||
|
||||
</div>
|
||||
|
||||
> **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 |
|
||||
|
||||
<sub>229 tools in 25 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools, the 22 environment tools and the 14 mechanical tools (<code>TOOL_PACKS=core,mech</code> keeps the last) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
|
||||
<sub>231 tools in 25 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools, the 22 environment tools and the 14 mechanical tools (<code>TOOL_PACKS=core,mech</code> keeps the last) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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_<NAME>`` 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_<NAME>`` 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,
|
||||
}
|
||||
@@ -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_<NAME> 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 ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()}
|
||||
@@ -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"})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user