diff --git a/README.md b/README.md index 9f44eef..b305a80 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:** 236 tools · 8 resources · 5 prompt templates · 4005 collected tests. -> 236 is the **registered** count; a default install advertises 231 over `tools/list`, +> **v1.5 release snapshot:** 240 tools · 8 resources · 5 prompt templates · 4005 collected tests. +> 240 is the **registered** count; a default install advertises 235 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 | -236 tools in 26 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. +240 tools in 26 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 5738f41..be6377a 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -2347,6 +2347,70 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "mahal listesi", ), ), + # ── 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", + ), + ), + "arch_grid": ToolAliases( + acad=("COLUMNGRIDADD",), + synonyms=( + "structural grid", + "column grid", + "axis grid", + "grid bubbles", + "grid lines with bubbles", + "numbered and lettered axes", + "aks sistemi", + "akslar", + "kolon aksi", + ), + ), + "arch_symbol": ToolAliases( + acad=(), + synonyms=( + "north arrow", + "north point", + "section mark", + "section cut symbol", + "level mark", + "floor level symbol", + "elevation mark", + "kuzey oku", + "kot isareti", + "kesit isareti", + "gorunus isareti", + ), + ), } diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 4a1ade3..a8e489d 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -3,14 +3,14 @@ "schema_version": 1, "package_version": "1.5.1", "totals": { - "tools": 236, + "tools": 240, "resources": 8, "prompts": 5, "groups": 26 }, "groups": { "analysis": 12, - "architecture": 7, + "architecture": 11, "batch": 3, "blocks": 9, "corner_ops": 4, @@ -181,6 +181,34 @@ "readOnlyHint": true } }, + { + "name": "arch_catalogue_insert", + "group": "architecture", + "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": "architecture", + "cost": "read", + "summary": "Search the furniture and sanitary catalogue in English or Turkish.", + "tags": [ + "arch", + "query" + ], + "annotations": { + "title": "Architecture: Catalogue", + "readOnlyHint": true + } + }, { "name": "arch_dimension_chains", "group": "architecture", @@ -195,6 +223,20 @@ "destructiveHint": false } }, + { + "name": "arch_grid", + "group": "architecture", + "cost": "mutate", + "summary": "Draw a structural grid: axis lines with numbered and lettered bubbles at both ends.", + "tags": [ + "arch", + "create" + ], + "annotations": { + "title": "Architecture: Structural Grid", + "destructiveHint": false + } + }, { "name": "arch_opening", "group": "architecture", @@ -265,6 +307,20 @@ "destructiveHint": false } }, + { + "name": "arch_symbol", + "group": "architecture", + "cost": "mutate", + "summary": "Draw a plan symbol: north arrow, section mark, level mark or elevation mark.", + "tags": [ + "arch", + "create" + ], + "annotations": { + "title": "Architecture: Symbol", + "destructiveHint": false + } + }, { "name": "arch_wall", "group": "architecture", diff --git a/engineering/arch/__init__.py b/engineering/arch/__init__.py index f5f53c3..4a3678d 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 .dimension import ( FIRST_OFFSET_PAPER_MM, ROWS, @@ -57,6 +67,15 @@ from .stairs import ( blondel, stair_prims, ) +from .symbols import ( + SYMBOL_KINDS, + draw_grid, + draw_symbol, + fmt_level, + grid_axes, + grid_prims, + symbol_prims, +) from .walls import ( MIN_JOIN_ANGLE_DEG, WallGeometry, @@ -75,9 +94,13 @@ __all__ = [ "ARCH_ROLE_LAYER", "ARROW_PAPER_MM", "BLONDEL_RANGE", + "CATALOGUE_NAMES", + "CatalogueItem", "DEFAULT_TOL", + "FAMILIES", "FIRST_OFFSET_PAPER_MM", "Face", + "ITEMS", "KINDS", "LANGS", "MIN_JOIN_ANGLE_DEG", @@ -86,8 +109,10 @@ __all__ = [ "Room", "SCHEDULE_KINDS", "SIDES", + "SIZE_BASIS", "STAIR_KINDS", "STEP_PAPER_MM", + "SYMBOL_KINDS", "Stair", "TEXT_PAPER_MM", "TURNS", @@ -96,17 +121,25 @@ __all__ = [ "Wall", "WallGeometry", "axis_segments", + "block_spec", "blondel", + "catalogue", "decode", "detect_rooms", + "draw_grid", "draw_schedule", + "draw_symbol", "encode", "exterior_chains", "face_containing", "face_offsets", "fmt_area_m2", + "fmt_level", "fmt_number", "from_payload", + "grid_axes", + "grid_prims", + "insert_item", "label_room", "opening_closures", "opening_from_dict", @@ -121,6 +154,7 @@ __all__ = [ "segment_of", "stair_from_dict", "stair_prims", + "symbol_prims", "tag_prefix", "tagged_faces", "to_payload", 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/engineering/arch/symbols.py b/engineering/arch/symbols.py new file mode 100644 index 0000000..5b5c7cb --- /dev/null +++ b/engineering/arch/symbols.py @@ -0,0 +1,421 @@ +"""The structural grid and the architectural symbols - pure primitives. + +:func:`grid_prims` draws the axis lines of a structural grid on the ``grid`` +role (whose layer carries the CENTER linetype, so no primitive sets one) with a +bubble and its label at both ends: numbers along x, letters along y, unless +``labels`` names them. :func:`symbol_prims` draws the four plan symbols: the +north arrow, the section mark, the level mark and the elevation mark. + +Every size here is **paper millimetres times ``scale``** (the plot-scale +denominator: 50 means 1:50), so a 5 mm bubble is 250 drawing units at 1:50 and +reads 5 mm on the printed sheet. The paper sizes below are this module's own +drafting choices, declared as such - no standard in this repository fixes a grid +bubble's diameter or a north arrow's length. + +Bubbles, labels and symbols go on the ``symbol`` role, not on ``grid``: a +bubble on the grid layer would inherit CENTER and print as a dashed circle. + +Labels are centred with ``engineering.sheet.frames.centred_text`` (the +repository's one centring rule: 0.6 x height advance per character), because +``entity_create_text`` carries no alignment. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +from engineering.arch.lang import fmt_number, vocab +from engineering.arch.layers import ARCH_ROLE_LAYER +from engineering.mech.primitives import Circle, Line, Poly, Prim, Pt, Text +from engineering.sheet.frames import centred_text + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +# -- declared paper sizes (mm on the printed sheet; multiplied by `scale`) ----- + +BUBBLE_RADIUS = 5.0 +LABEL_HEIGHT = 3.5 +NORTH_RADIUS = 8.0 +SECTION_ARROW_LENGTH = 6.0 +SECTION_ARROW_HALF_WIDTH = 2.0 +LEVEL_TRIANGLE = 2.5 +LEVEL_LINE = 15.0 +LEVEL_TEXT_HEIGHT = 2.5 +LEVEL_TEXT_GAP = 1.0 + +SYMBOL_KINDS: tuple[str, ...] = ("north_arrow", "section_mark", "level", "elevation_mark") +SECTION_SIDES: tuple[str, ...] = ("left", "right") +#: An elevation mark's direction in drawing terms, not compass terms: "up" is +#: +Y on the sheet whatever the north arrow says. +ELEVATION_DIRECTIONS: dict[str, float] = {"right": 0.0, "up": 90.0, "left": 180.0, "down": 270.0} + +_PARAMS: dict[str, tuple[str, ...]] = { + "north_arrow": ("rotation",), + "section_mark": ("p1", "p2", "label", "side"), + "level": ("value",), + "elevation_mark": ("label", "directions"), +} + + +def _finite(value, where: str) -> float: + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError(f"{where}: expected a number, got {value!r}.") from None + if not math.isfinite(number): + raise ValueError(f"{where}: must be finite, got {value!r}.") + return number + + +def _positive(value, where: str) -> float: + number = _finite(value, where) + if number <= 0.0: + raise ValueError(f"{where}: must be greater than zero, got {number:g}.") + return number + + +def _point(value, where: str) -> Pt: + try: + x, y = value + except (TypeError, ValueError): + raise ValueError(f"{where}: expected an [x, y] pair, got {value!r}.") from None + return (_finite(x, f"{where}.x"), _finite(y, f"{where}.y")) + + +def _label(value, where: str) -> str: + text = " ".join(str(value).split()) if value is not None else "" + if not text: + raise ValueError(f"{where}: a label must be a non-empty string.") + return text + + +def _centred(cx: float, cy: float, text: str, height: float) -> Text: + return centred_text(cx, cy, text, height)._replace(role="symbol") + + +def _turn(vx: float, vy: float, deg: float) -> Pt: + c = math.cos(math.radians(deg)) + s = math.sin(math.radians(deg)) + return (vx * c - vy * s, vx * s + vy * c) + + +# -- the structural grid ----------------------------------------------------- + + +def _letters(index: int) -> str: + """0 -> A ... 25 -> Z, 26 -> AA: every letter, none skipped.""" + out = "" + n = index + 1 + while n: + n, rest = divmod(n - 1, 26) + out = chr(ord("A") + rest) + out + return out + + +def grid_axes(x_axes, y_axes, *, labels=None) -> dict: + """The grid's axes sorted by position, each with its label. + + ``labels`` is ``{"x": [...], "y": [...]}`` (either key optional), one label + per axis in the order the positions were given. Refused by path: an empty + axis list, a non-finite or duplicate position, a label count that does not + match, a duplicate or empty label, an unknown ``labels`` key. + """ + out: dict[str, list[dict]] = {} + if labels is not None and not isinstance(labels, dict): + raise ValueError(f"labels: expected {{'x': [...], 'y': [...]}}, got {labels!r}.") + given = {} if labels is None else dict(labels) + unknown = sorted(set(given) - {"x", "y"}) + if unknown: + raise ValueError(f"labels: unknown key(s) {', '.join(unknown)}; keys are x, y.") + for key, positions, default in ( + ("x", x_axes, lambda i: str(i + 1)), + ("y", y_axes, _letters), + ): + where = f"{key}_axes" + values = [_finite(v, f"{where}[{i}]") for i, v in enumerate(positions or ())] + if not values: + raise ValueError(f"{where}: a grid needs at least one axis in each direction.") + names = given.get(key) + if names is None: + order = sorted(range(len(values)), key=lambda i: values[i]) + names_sorted = [default(rank) for rank in range(len(values))] + pairs = [(values[i], names_sorted[rank]) for rank, i in enumerate(order)] + else: + if not isinstance(names, (list, tuple)): + raise ValueError(f"labels.{key}: expected a list of labels, got {names!r}.") + if len(names) != len(values): + raise ValueError( + f"labels.{key}: {len(names)} label(s) for {len(values)} axis position(s)." + ) + texts = [_label(n, f"labels.{key}[{i}]") for i, n in enumerate(names)] + if len(set(texts)) != len(texts): + raise ValueError(f"labels.{key}: every axis label must be unique, got {texts}.") + pairs = sorted(zip(values, texts, strict=True), key=lambda pair: pair[0]) + for (a, _), (b, _) in zip(pairs, pairs[1:], strict=False): + if abs(b - a) <= 1e-9: + raise ValueError(f"{where}: two axes at the same position {a:g}.") + out[key] = [{"label": text, "position": value} for value, text in pairs] + return out + + +def grid_prims( + x_axes, y_axes, *, labels=None, extension: float = 1500.0, scale: float = 50 +) -> tuple[Prim, ...]: + """Axis lines on ``grid`` with a labelled bubble at both ends of each. + + ``x_axes`` are the x positions of the vertical axes (numbered 1, 2, 3 ... + from left to right), ``y_axes`` the y positions of the horizontal axes + (lettered A, B, C ... from bottom to top). Every axis runs ``extension`` + drawing units past the outermost crossing axis; the bubble (radius + ``BUBBLE_RADIUS * scale``) sits beyond that, touching the line's end. + Per axis the primitives are: the line, then for each end its bubble and + its label. + """ + ext = _positive(extension, "extension") + k = _positive(scale, "scale") + axes = grid_axes(x_axes, y_axes, labels=labels) + r = BUBBLE_RADIUS * k + h = LABEL_HEIGHT * k + xs = [axis["position"] for axis in axes["x"]] + ys = [axis["position"] for axis in axes["y"]] + y_lo, y_hi = min(ys) - ext, max(ys) + ext + x_lo, x_hi = min(xs) - ext, max(xs) + ext + prims: list[Prim] = [] + for axis in axes["x"]: + x = axis["position"] + prims.append(Line((x, y_lo), (x, y_hi), "grid")) + for cy in (y_lo - r, y_hi + r): + prims.append(Circle((x, cy), r, "symbol")) + prims.append(_centred(x, cy, axis["label"], h)) + for axis in axes["y"]: + y = axis["position"] + prims.append(Line((x_lo, y), (x_hi, y), "grid")) + for cx in (x_lo - r, x_hi + r): + prims.append(Circle((cx, y), r, "symbol")) + prims.append(_centred(cx, y, axis["label"], h)) + return tuple(prims) + + +# -- the four symbols ---------------------------------------------------------- + + +def _north_arrow(at: Pt, k: float, lang: str, rotation=0.0) -> tuple[Prim, ...]: + """A circle, an arrow whose tip is north, the north letter beyond the tip. + + ``rotation`` is degrees CCW from the sheet's +Y: 0 puts north straight up. + The letter stays upright; only its position turns with the arrow. + """ + turn = _finite(rotation, "north_arrow.rotation") + big_r = NORTH_RADIUS * k + h = LABEL_HEIGHT * k + ax, ay = at + local = ( + (0.0, big_r), + (-0.4 * big_r, -0.6 * big_r), + (0.0, -0.3 * big_r), + (0.4 * big_r, -0.6 * big_r), + ) + arrow = tuple((ax + dx, ay + dy) for dx, dy in (_turn(x, y, turn) for x, y in local)) + lx, ly = _turn(0.0, big_r + h, turn) + return ( + Circle((ax, ay), big_r, "symbol"), + Poly(arrow, True, "symbol"), + _centred(ax + lx, ay + ly, vocab(lang)["north"], h), + ) + + +def _section_mark(at: Pt, k: float, lang: str, p1=None, p2=None, label="A", side="left"): + """The cut line, a labelled bubble beyond each end, an arrow at each end. + + ``p1`` and ``p2`` are relative to ``at`` (so ``at=(0, 0)`` makes them WCS). + ``side`` is the side of the line the section looks towards, seen from + ``p1`` looking at ``p2``. + """ + if p1 is None or p2 is None: + raise ValueError("section_mark: p1 and p2 are required (the two ends of the cut line).") + a = _point(p1, "section_mark.p1") + b = _point(p2, "section_mark.p2") + text = _label(label, "section_mark.label") + if side not in SECTION_SIDES: + raise ValueError( + f"section_mark.side: must be one of {', '.join(SECTION_SIDES)}, got {side!r}." + ) + x1, y1 = at[0] + a[0], at[1] + a[1] + x2, y2 = at[0] + b[0], at[1] + b[1] + span = math.hypot(x2 - x1, y2 - y1) + r = BUBBLE_RADIUS * k + h = LABEL_HEIGHT * k + length = SECTION_ARROW_LENGTH * k + half = SECTION_ARROW_HALF_WIDTH * k + if span <= 4.0 * half: + raise ValueError( + f"section_mark: the cut line is {span:g} long; it must be longer than " + f"{4.0 * half:g} ({4.0 * SECTION_ARROW_HALF_WIDTH:g} mm at 1:{k:g}) so the two " + "arrows do not overlap." + ) + ux, uy = (x2 - x1) / span, (y2 - y1) / span + dx, dy = (-uy, ux) if side == "left" else (uy, -ux) + prims: list[Prim] = [Line((x1, y1), (x2, y2), "symbol")] + for cx, cy in ((x1 - ux * r, y1 - uy * r), (x2 + ux * r, y2 + uy * r)): + prims.append(Circle((cx, cy), r, "symbol")) + prims.append(_centred(cx, cy, text, h)) + for ex, ey, sign in ((x1, y1, 1.0), (x2, y2, -1.0)): + inner = (ex + sign * ux * 2.0 * half, ey + sign * uy * 2.0 * half) + mid = (ex + sign * ux * half, ey + sign * uy * half) + apex = (mid[0] + dx * length, mid[1] + dy * length) + prims.append(Poly(((ex, ey), inner, apex), True, "symbol")) + return tuple(prims) + + +def fmt_level(value: float, lang: str) -> str: + """``±0.00`` at zero, ``+3.00`` above, ``-0.45`` below; ``tr`` writes a comma.""" + number = _finite(value, "level.value") + if abs(number) < 0.005: + return "±" + fmt_number(0.0, lang, 2) + sign = "+" if number > 0 else "-" + return sign + fmt_number(abs(number), lang, 2) + + +def _level(at: Pt, k: float, lang: str, value=0.0) -> tuple[Prim, ...]: + """A triangle whose apex is the level point, a line, the value above the line. + + ``value`` is in metres, as a level is written on a plan. + """ + text = fmt_level(value, lang) + t = LEVEL_TRIANGLE * k + ax, ay = at + gap = LEVEL_TEXT_GAP * k + return ( + Poly(((ax, ay), (ax - t, ay + t), (ax + t, ay + t)), True, "symbol"), + Line((ax + t, ay + t), (ax + t + LEVEL_LINE * k, ay + t), "symbol"), + Text((ax + t + gap, ay + t + gap), text, LEVEL_TEXT_HEIGHT * k, 0.0, "symbol"), + ) + + +def _elevation_mark(at: Pt, k: float, lang: str, label="1", directions=("up",)): + """A circle, the label in it, and one pointer per viewing direction. + + Each pointer is the two tangents from an apex at ``r * sqrt(2)`` along the + direction to the circle, touching it at the direction +/- 45 degrees. + """ + text = _label(label, "elevation_mark.label") + names = list(directions) if not isinstance(directions, str) else [directions] + if not names: + raise ValueError("elevation_mark.directions: name at least one direction.") + for i, name in enumerate(names): + if name not in ELEVATION_DIRECTIONS: + raise ValueError( + f"elevation_mark.directions[{i}]: must be one of " + f"{', '.join(ELEVATION_DIRECTIONS)}, got {name!r}." + ) + if len(set(names)) != len(names): + raise ValueError(f"elevation_mark.directions: each direction once, got {names}.") + r = BUBBLE_RADIUS * k + ax, ay = at + prims: list[Prim] = [Circle((ax, ay), r, "symbol"), _centred(ax, ay, text, LABEL_HEIGHT * k)] + for name in names: + theta = ELEVATION_DIRECTIONS[name] + apex_r = r * math.sqrt(2.0) + apex = ( + ax + apex_r * math.cos(math.radians(theta)), + ay + apex_r * math.sin(math.radians(theta)), + ) + side_a = ( + ax + r * math.cos(math.radians(theta - 45.0)), + ay + r * math.sin(math.radians(theta - 45.0)), + ) + side_b = ( + ax + r * math.cos(math.radians(theta + 45.0)), + ay + r * math.sin(math.radians(theta + 45.0)), + ) + prims.append(Poly((side_a, apex, side_b), False, "symbol")) + return tuple(prims) + + +_BUILDERS = { + "north_arrow": _north_arrow, + "section_mark": _section_mark, + "level": _level, + "elevation_mark": _elevation_mark, +} + + +def symbol_prims( + kind: str, at: Pt, *, lang: str = "en", scale: float = 50, **params +) -> tuple[Prim, ...]: + """One architectural symbol at ``at``, sized in paper mm times ``scale``. + + ``kind`` and its ``params``: ``north_arrow`` (``rotation``), + ``section_mark`` (``p1``, ``p2``, ``label``, ``side``), ``level`` + (``value`` in metres), ``elevation_mark`` (``label``, ``directions``). + Refused before anything is built: an unknown kind (with the list), a + parameter the kind does not take (with the ones it does), an unknown + ``lang``, a non-finite or non-positive ``scale``. + """ + if kind not in SYMBOL_KINDS: + raise ValueError(f"unknown symbol kind {kind!r}; kinds are {', '.join(SYMBOL_KINDS)}.") + extra = sorted(set(params) - set(_PARAMS[kind])) + if extra: + raise ValueError( + f"{kind}: unknown parameter(s) {', '.join(extra)}; it takes {', '.join(_PARAMS[kind])}." + ) + vocab(lang) + k = _positive(scale, "scale") + origin = _point(at, "at") + return _BUILDERS[kind](origin, k, lang, **params) + + +# -- the async layer: the prims through draw_prims ------------------------------ + + +async def draw_grid( + backend: AutoCADBackend, + x_axes, + y_axes, + *, + labels=None, + extension: float = 1500.0, + scale: float = 50, +) -> dict: + """Draw :func:`grid_prims` through ``draw_prims`` with the arch role map.""" + from engineering.mech.draw import draw_prims + + axes = grid_axes(x_axes, y_axes, labels=labels) + prims = grid_prims(x_axes, y_axes, labels=labels, extension=extension, scale=scale) + drawn = await draw_prims(backend, prims, role_layer=ARCH_ROLE_LAYER) + return { + "ok": True, + "axes": axes, + "bubble_radius": BUBBLE_RADIUS * float(scale), + "handles": drawn["handles"], + "counts": drawn["counts"], + "layers": sorted({ARCH_ROLE_LAYER["grid"], ARCH_ROLE_LAYER["symbol"]}), + "backend": backend.name, + } + + +async def draw_symbol( + backend: AutoCADBackend, + kind: str, + at: Pt, + *, + lang: str = "en", + scale: float = 50, + **params, +) -> dict: + """Draw :func:`symbol_prims` through ``draw_prims`` with the arch role map.""" + from engineering.mech.draw import draw_prims + + prims = symbol_prims(kind, at, lang=lang, scale=scale, **params) + drawn = await draw_prims(backend, prims, role_layer=ARCH_ROLE_LAYER) + return { + "ok": True, + "kind": kind, + "at": [float(at[0]), float(at[1])], + "handles": drawn["handles"], + "counts": drawn["counts"], + "layer": ARCH_ROLE_LAYER["symbol"], + "backend": backend.name, + } diff --git a/server.py b/server.py index 6ac2627..6b3c3ff 100644 --- a/server.py +++ b/server.py @@ -9783,7 +9783,7 @@ async def drawing_export_dwg( # --------------------------------------------------------------------------- -# ── SECTION 25: Architecture (7 tools) ────────────────────────────────────── +# ── SECTION 25: Architecture (11 tools) ───────────────────────────────────── # --------------------------------------------------------------------------- @@ -10226,6 +10226,241 @@ async def arch_schedule( # ── 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 + + +@cad_tool( + summary="Draw a structural grid: axis lines with numbered and lettered bubbles at both ends.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Architecture: Structural Grid", "destructiveHint": False}, + tags={"arch", "create"}, +) +async def arch_grid( + x_axes: Annotated[ + list[float], + Field(description="X positions (WCS) of the vertical axes; numbered 1, 2, 3 left to right"), + ], + y_axes: Annotated[ + list[float], + Field( + description="Y positions (WCS) of the horizontal axes; lettered A, B, C bottom to top" + ), + ], + labels: Annotated[ + dict | None, + Field( + default=None, + description=( + "Override the labels: {'x': [...], 'y': [...]} (either key), one label per " + "position in the order the positions were given" + ), + ), + ] = None, + extension: Annotated[ + float, + Field( + default=1500.0, + gt=0, + description="Drawing units each axis runs past the outermost crossing axis", + ), + ] = 1500.0, + scale: Annotated[ + float, + Field( + default=50.0, + gt=0, + description="Plot-scale denominator (50 = 1:50): bubbles and labels are paper mm x scale", + ), + ] = 50.0, + ctx: Context = None, +) -> dict: + """Axis lines on the arch grid layer (its CENTER linetype comes from the layer) and a + 5 mm bubble with its label at both ends of each axis, on the symbol layer. + + Numbers run along x and letters along y (A..Z, then AA, AB - no letter is + skipped) unless `labels` names them. The bubble and label sizes are this + server's declared drafting sizes in paper millimetres, not a standards value. + Refusals, all before any write: an empty axis list in either direction, a + non-finite or duplicate position, a label list whose length does not match + its positions, a duplicate or empty label, an unknown `labels` key, and a + non-positive `extension` or `scale`. + """ + from engineering.arch.symbols import draw_grid + + await ctx.info(f"arch grid: {len(x_axes)} x {len(y_axes)} axes at 1:{scale:g}") + try: + return await draw_grid( + _backend(ctx), x_axes, y_axes, labels=labels, extension=extension, scale=scale + ) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Draw a plan symbol: north arrow, section mark, level mark or elevation mark.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Architecture: Symbol", "destructiveHint": False}, + tags={"arch", "create"}, +) +async def arch_symbol( + kind: Annotated[ + str, + Field(description="north_arrow | section_mark | level | elevation_mark"), + ], + x: Annotated[float, Field(description="Symbol origin X (WCS)")], + y: Annotated[float, Field(description="Symbol origin Y (WCS)")], + params: Annotated[ + dict | None, + Field( + default=None, + description=( + "north_arrow: {rotation} (degrees CCW from +Y; 0 = north up). " + "section_mark: {p1, p2, label, side} - p1/p2 are [x, y] relative to (x, y), " + "side left|right is where the section looks, seen from p1 towards p2. " + "level: {value} in metres (0 -> '±0.00'). " + "elevation_mark: {label, directions} - directions from up|right|down|left" + ), + ), + ] = None, + lang: Annotated[ + str, + Field(default="en", description="en | tr - the north letter and the decimal separator"), + ] = "en", + scale: Annotated[ + float, + Field( + default=50.0, + gt=0, + description="Plot-scale denominator (50 = 1:50): symbol sizes are paper mm x scale", + ), + ] = 50.0, + ctx: Context = None, +) -> dict: + """Draw one architectural symbol on the arch symbol layer. + + Sizes are this server's declared drafting sizes in paper millimetres times + `scale` (a 16 mm north-arrow circle, 10 mm section and elevation bubbles), + not standards values. Refusals, all before any write: an unknown `kind` + (with the list), a parameter the kind does not take (with the ones it + does), a section mark without p1/p2, with an empty label, an unknown side + or a cut line too short for its two arrows, an elevation direction outside + up/right/down/left or named twice, a non-finite level value, and a `lang` + other than en/tr. + """ + from engineering.arch.symbols import draw_symbol + + await ctx.info(f"arch symbol: {kind} at ({x}, {y})") + try: + return await draw_symbol( + _backend(ctx), kind, (x, y), lang=lang, scale=scale, **(params or {}) + ) + except (TypeError, ValueError) as exc: + raise ToolError(str(exc)) from exc + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_arch_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_arch_symbol_tools.py b/tests/test_arch_symbol_tools.py new file mode 100644 index 0000000..b938246 --- /dev/null +++ b/tests/test_arch_symbol_tools.py @@ -0,0 +1,64 @@ +"""arch_grid / arch_symbol through a real MCP client.""" + +from __future__ import annotations + +import pytest +import pytest_asyncio +from fastmcp import Client +from fastmcp.exceptions import ToolError + +import server +from engineering.arch.layers import ARCH_ROLE_LAYER + +pytestmark = pytest.mark.asyncio + + +@pytest_asyncio.fixture +async def client(monkeypatch): + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + async with Client(server.mcp) as connected: + await connected.call_tool("drawing_new", {}) + yield connected + + +async def test_grid_reports_its_axes_and_bubble_size(client): + result = ( + await client.call_tool( + "arch_grid", {"x_axes": [0.0, 4000.0, 8000.0], "y_axes": [0.0, 5000.0], "scale": 100} + ) + ).structured_content + assert [axis["label"] for axis in result["axes"]["x"]] == ["1", "2", "3"] + assert [axis["label"] for axis in result["axes"]["y"]] == ["A", "B"] + assert result["bubble_radius"] == 500.0 + assert len(result["handles"]) == 5 * 5 + assert result["layers"] == sorted({ARCH_ROLE_LAYER["grid"], ARCH_ROLE_LAYER["symbol"]}) + + +async def test_symbol_level_and_north_arrow_in_turkish(client): + result = ( + await client.call_tool( + "arch_symbol", + {"kind": "level", "x": 0.0, "y": 0.0, "params": {"value": 3.0}, "lang": "tr"}, + ) + ).structured_content + assert result["counts"] == {"poly": 1, "line": 1, "text": 1} + north = ( + await client.call_tool( + "arch_symbol", {"kind": "north_arrow", "x": 0.0, "y": 0.0, "lang": "tr"} + ) + ).structured_content + assert north["layer"] == ARCH_ROLE_LAYER["symbol"] + assert north["counts"] == {"circle": 1, "poly": 1, "text": 1} + + +async def test_refusals_are_tool_errors(client): + with pytest.raises( + ToolError, match="kinds are north_arrow, section_mark, level, elevation_mark" + ): + await client.call_tool("arch_symbol", {"kind": "compass", "x": 0.0, "y": 0.0}) + with pytest.raises(ToolError, match="p1 and p2 are required"): + await client.call_tool( + "arch_symbol", {"kind": "section_mark", "x": 0.0, "y": 0.0, "params": {"label": "A"}} + ) + with pytest.raises(ToolError, match="y_axes: a grid needs at least one axis"): + await client.call_tool("arch_grid", {"x_axes": [0.0], "y_axes": []}) diff --git a/tests/test_arch_symbols.py b/tests/test_arch_symbols.py new file mode 100644 index 0000000..d67f9f1 --- /dev/null +++ b/tests/test_arch_symbols.py @@ -0,0 +1,358 @@ +"""The structural grid and the architectural symbols. + +Pure geometry: the emitted primitives are asserted against coordinates worked +out by hand in each test (1:50 unless a test says otherwise, so a 5 mm paper +bubble is 250 drawing units). The last tests draw through the headless backend. +""" + +from __future__ import annotations + +import math + +import pytest + +from engineering.arch.lang import vocab +from engineering.arch.layers import ARCH_LAYERS, ARCH_ROLE_LAYER +from engineering.arch.symbols import ( + SYMBOL_KINDS, + draw_grid, + draw_symbol, + fmt_level, + grid_axes, + grid_prims, + symbol_prims, +) +from engineering.mech.primitives import Circle, Line, Poly, Text + +TOL = 1e-9 + + +def close(a, b) -> bool: + return abs(a[0] - b[0]) <= TOL and abs(a[1] - b[1]) <= TOL + + +def of(prims, kind): + return [p for p in prims if isinstance(p, kind)] + + +def centred_at(text: Text, cx: float, cy: float) -> bool: + """The inverse of engineering.sheet.frames.centred_text.""" + h = text.height + return close(text.at, (cx - 0.3 * h * len(text.text), cy - 0.5 * h)) + + +# -- the grid ------------------------------------------------------------------ + + +def test_grid_numbers_along_x_and_letters_along_y(): + axes = grid_axes([0.0, 5000.0, 9000.0], [0.0, 6000.0]) + assert axes == { + "x": [ + {"label": "1", "position": 0.0}, + {"label": "2", "position": 5000.0}, + {"label": "3", "position": 9000.0}, + ], + "y": [{"label": "A", "position": 0.0}, {"label": "B", "position": 6000.0}], + } + + +def test_grid_lines_run_past_the_outer_axes_by_the_extension(): + prims = grid_prims([0.0, 5000.0, 9000.0], [0.0, 6000.0], extension=1500.0, scale=50) + lines = of(prims, Line) + assert len(lines) == 5 + assert all(line.role == "grid" for line in lines) + # vertical axes: y from 0 - 1500 to 6000 + 1500 + assert close(lines[0].p1, (0.0, -1500.0)) and close(lines[0].p2, (0.0, 7500.0)) + assert close(lines[2].p1, (9000.0, -1500.0)) and close(lines[2].p2, (9000.0, 7500.0)) + # horizontal axes: x from 0 - 1500 to 9000 + 1500 + assert close(lines[3].p1, (-1500.0, 0.0)) and close(lines[3].p2, (10500.0, 0.0)) + assert close(lines[4].p1, (-1500.0, 6000.0)) and close(lines[4].p2, (10500.0, 6000.0)) + + +def test_grid_bubbles_touch_both_ends_and_carry_the_labels(): + prims = grid_prims([0.0, 5000.0, 9000.0], [0.0, 6000.0], extension=1500.0, scale=50) + bubbles = of(prims, Circle) + texts = of(prims, Text) + assert len(bubbles) == 10 and len(texts) == 10 + assert all(b.radius == 250.0 and b.role == "symbol" for b in bubbles) + assert all(t.role == "symbol" and t.height == 175.0 for t in texts) + # axis 1 (x = 0): bubbles at y = -1500 - 250 and y = 7500 + 250 + assert close(bubbles[0].center, (0.0, -1750.0)) + assert close(bubbles[1].center, (0.0, 7750.0)) + # axis B (y = 6000): bubbles at x = -1500 - 250 and x = 10500 + 250 + assert close(bubbles[8].center, (-1750.0, 6000.0)) + assert close(bubbles[9].center, (10750.0, 6000.0)) + assert [t.text for t in texts] == ["1", "1", "2", "2", "3", "3", "A", "A", "B", "B"] + # "1" at (0, -1750), height 175: left baseline at (-52.5, -1837.5) + assert close(texts[0].at, (-52.5, -1837.5)) + for bubble, text in zip(bubbles, texts, strict=True): + assert centred_at(text, *bubble.center) + + +def test_grid_prims_come_line_then_bubble_label_pairs_per_axis(): + prims = grid_prims([0.0], [0.0], extension=1000.0, scale=100) + assert [type(p).__name__ for p in prims] == [ + "Line", + "Circle", + "Text", + "Circle", + "Text", + "Line", + "Circle", + "Text", + "Circle", + "Text", + ] + # 1:100 -> a 5 mm bubble is 500 units; the vertical axis spans y -1000 .. 1000 + assert close(prims[1].center, (0.0, -1500.0)) and prims[1].radius == 500.0 + + +def test_grid_unsorted_positions_are_numbered_left_to_right(): + axes = grid_axes([9000.0, 0.0, 5000.0], [6000.0, 0.0]) + assert [(a["label"], a["position"]) for a in axes["x"]] == [ + ("1", 0.0), + ("2", 5000.0), + ("3", 9000.0), + ] + assert [(a["label"], a["position"]) for a in axes["y"]] == [("A", 0.0), ("B", 6000.0)] + + +def test_grid_given_labels_follow_their_positions(): + axes = grid_axes([5000.0, 0.0], [0.0, 6000.0], labels={"x": ["B2", "B1"], "y": ["K", "L"]}) + assert [(a["label"], a["position"]) for a in axes["x"]] == [("B1", 0.0), ("B2", 5000.0)] + assert [a["label"] for a in axes["y"]] == ["K", "L"] + + +def test_grid_letters_continue_past_z(): + axes = grid_axes([0.0], [float(i) * 1000.0 for i in range(28)]) + assert [a["label"] for a in axes["y"]][24:] == ["Y", "Z", "AA", "AB"] + + +def test_grid_refusals_name_their_path(): + with pytest.raises(ValueError, match="y_axes: a grid needs at least one axis"): + grid_prims([0.0, 5000.0], []) + with pytest.raises(ValueError, match=r"x_axes: two axes at the same position 0"): + grid_prims([0.0, 0.0], [0.0]) + with pytest.raises(ValueError, match=r"x_axes\[1\]: must be finite"): + grid_prims([0.0, float("nan")], [0.0]) + with pytest.raises(ValueError, match=r"labels.x: 1 label\(s\) for 2 axis position\(s\)"): + grid_prims([0.0, 5000.0], [0.0], labels={"x": ["1"]}) + with pytest.raises(ValueError, match="labels.y: every axis label must be unique"): + grid_prims([0.0], [0.0, 10.0], labels={"y": ["A", "A"]}) + with pytest.raises(ValueError, match="labels: unknown key"): + grid_prims([0.0], [0.0], labels={"z": ["1"]}) + # a string is not split into one label per character; a number is not a list + with pytest.raises(ValueError, match=r"labels.x: expected a list of labels"): + grid_prims([0.0, 1000.0], [0.0], labels={"x": "AB"}) + with pytest.raises(ValueError, match=r"labels.x: expected a list of labels"): + grid_prims([0.0], [0.0], labels={"x": 5}) + with pytest.raises(ValueError, match=r"labels: expected \{'x'"): + grid_prims([0.0], [0.0], labels=["x"]) + with pytest.raises(ValueError, match="extension: must be greater than zero"): + grid_prims([0.0], [0.0], extension=0.0) + with pytest.raises(ValueError, match="scale: must be greater than zero"): + grid_prims([0.0], [0.0], scale=-50) + + +# -- the north arrow ------------------------------------------------------------- + + +def test_north_arrow_points_up_at_zero_rotation(): + circle, arrow, label = symbol_prims("north_arrow", (1000.0, 2000.0), scale=50) + # radius 8 mm x 50 = 400 + assert isinstance(circle, Circle) and circle.radius == 400.0 + assert close(circle.center, (1000.0, 2000.0)) + assert isinstance(arrow, Poly) and arrow.closed and arrow.role == "symbol" + expected = ((1000.0, 2400.0), (840.0, 1760.0), (1000.0, 1880.0), (1160.0, 1760.0)) + assert all(close(p, q) for p, q in zip(arrow.points, expected, strict=True)) + assert label.text == vocab("en")["north"] + # label centre: 400 + 175 above the centre + assert centred_at(label, 1000.0, 2575.0) + assert label.rotation == 0.0 + + +def test_north_arrow_rotation_turns_the_tip_and_the_label_ccw(): + circle, arrow, label = symbol_prims("north_arrow", (1000.0, 2000.0), scale=50, rotation=90.0) + # CCW 90: +Y becomes -X + assert close(arrow.points[0], (600.0, 2000.0)) + # the left barb (-160, -240) turns to (240, -160) + assert close(arrow.points[1], (1240.0, 1840.0)) + assert centred_at(label, 425.0, 2000.0) + assert label.rotation == 0.0 # the letter stays upright + + +def test_north_arrow_speaks_turkish(): + *_, label = symbol_prims("north_arrow", (0.0, 0.0), lang="tr") + assert label.text == vocab("tr")["north"] + assert vocab("tr")["north"] != vocab("en")["north"] + + +# -- the section mark ------------------------------------------------------------ + + +def test_section_mark_line_bubbles_and_arrows_looking_left(): + prims = symbol_prims( + "section_mark", (0.0, 0.0), scale=50, p1=(0.0, 0.0), p2=(4000.0, 0.0), label="A" + ) + (line,) = of(prims, Line) + assert close(line.p1, (0.0, 0.0)) and close(line.p2, (4000.0, 0.0)) + bubbles = of(prims, Circle) + assert [b.radius for b in bubbles] == [250.0, 250.0] + assert close(bubbles[0].center, (-250.0, 0.0)) and close(bubbles[1].center, (4250.0, 0.0)) + texts = of(prims, Text) + assert [t.text for t in texts] == ["A", "A"] + assert centred_at(texts[0], -250.0, 0.0) and centred_at(texts[1], 4250.0, 0.0) + first, second = of(prims, Poly) + # arrow: 6 mm long, 2 mm half-width at 1:50 -> 300 and 100 + assert all( + close(p, q) + for p, q in zip(first.points, ((0.0, 0.0), (200.0, 0.0), (100.0, 300.0)), strict=True) + ) + assert all( + close(p, q) + for p, q in zip(second.points, ((4000.0, 0.0), (3800.0, 0.0), (3900.0, 300.0)), strict=True) + ) + + +def test_section_mark_looking_right_and_relative_to_at(): + prims = symbol_prims( + "section_mark", + (100.0, 200.0), + scale=50, + p1=(0.0, 0.0), + p2=(0.0, 3000.0), + label="B", + side="right", + ) + (line,) = of(prims, Line) + assert close(line.p1, (100.0, 200.0)) and close(line.p2, (100.0, 3200.0)) + first, _second = of(prims, Poly) + # up the line, looking right is +X + assert close(first.points[2], (100.0 + 300.0, 200.0 + 100.0)) + + +def test_section_mark_refusals(): + with pytest.raises(ValueError, match="p1 and p2 are required"): + symbol_prims("section_mark", (0.0, 0.0), p1=(0.0, 0.0)) + with pytest.raises(ValueError, match="side: must be one of left, right"): + symbol_prims("section_mark", (0.0, 0.0), p1=(0, 0), p2=(1000, 0), side="up") + with pytest.raises(ValueError, match="the cut line is 300 long"): + symbol_prims("section_mark", (0.0, 0.0), p1=(0, 0), p2=(300, 0)) + with pytest.raises(ValueError, match="label must be a non-empty string"): + symbol_prims("section_mark", (0.0, 0.0), p1=(0, 0), p2=(1000, 0), label=" ") + + +# -- the level mark -------------------------------------------------------------- + + +def test_level_formats_the_value_in_both_languages(): + assert fmt_level(0.0, "en") == "±0.00" + assert fmt_level(-0.004, "en") == "±0.00" + assert fmt_level(3.0, "en") == "+3.00" + assert fmt_level(-0.45, "en") == "-0.45" + assert fmt_level(3.0, "tr") == "+3,00" + assert fmt_level(0.0, "tr") == "±0,00" + + +def test_level_triangle_line_and_value(): + triangle, line, text = symbol_prims("level", (1000.0, 1000.0), scale=50, value=2.8) + # 2.5 mm x 50 = 125: apex on the level point, the top edge 125 above it + expected = ((1000.0, 1000.0), (875.0, 1125.0), (1125.0, 1125.0)) + assert triangle.closed and all( + close(p, q) for p, q in zip(triangle.points, expected, strict=True) + ) + # the line runs 15 mm x 50 = 750 to the right from the triangle's right corner + assert close(line.p1, (1125.0, 1125.0)) and close(line.p2, (1875.0, 1125.0)) + # the value sits 1 mm x 50 = 50 right of and above that corner, 2.5 mm high + assert text.text == "+2.80" and text.height == 125.0 + assert close(text.at, (1175.0, 1175.0)) + + +# -- the elevation mark ---------------------------------------------------------- + + +def test_elevation_mark_pointer_is_tangent_at_45_degrees(): + circle, label, pointer = symbol_prims("elevation_mark", (0.0, 0.0), scale=50, label="2") + assert circle.radius == 250.0 + assert label.text == "2" and centred_at(label, 0.0, 0.0) + s = 250.0 * math.sqrt(0.5) # 176.776... + expected = ((s, s), (0.0, 250.0 * math.sqrt(2.0)), (-s, s)) + assert not pointer.closed + assert all(close(p, q) for p, q in zip(pointer.points, expected, strict=True)) + + +def test_elevation_mark_one_pointer_per_direction(): + prims = symbol_prims( + "elevation_mark", (0.0, 0.0), scale=50, label="1", directions=["up", "right", "down"] + ) + pointers = of(prims, Poly) + assert len(pointers) == 3 + apex = 250.0 * math.sqrt(2.0) + assert close(pointers[1].points[1], (apex, 0.0)) + assert close(pointers[2].points[1], (0.0, -apex)) + with pytest.raises(ValueError, match=r"directions\[0\]: must be one of right, up, left, down"): + symbol_prims("elevation_mark", (0.0, 0.0), directions=["north"]) + with pytest.raises(ValueError, match="each direction once"): + symbol_prims("elevation_mark", (0.0, 0.0), directions=["up", "up"]) + + +# -- refusals common to every kind --------------------------------------------- + + +def test_an_unknown_kind_is_refused_with_the_list(): + with pytest.raises(ValueError) as excinfo: + symbol_prims("compass_rose", (0.0, 0.0)) + message = str(excinfo.value) + assert "unknown symbol kind 'compass_rose'" in message + for kind in SYMBOL_KINDS: + assert kind in message + + +def test_a_parameter_the_kind_does_not_take_is_refused(): + with pytest.raises(ValueError, match="level: unknown parameter.*takes value"): + symbol_prims("level", (0.0, 0.0), rotation=45.0) + + +def test_an_unknown_language_is_refused(): + with pytest.raises(ValueError, match="en, tr"): + symbol_prims("north_arrow", (0.0, 0.0), lang="de") + + +# -- drawn through the headless backend ------------------------------------------- + + +@pytest.mark.asyncio +async def test_draw_grid_puts_axes_on_the_grid_layer_and_bubbles_on_symbols(backend): + result = await draw_grid(backend, [0.0, 6000.0], [0.0, 4500.0], scale=50) + assert result["ok"] is True + assert len(result["handles"]) == 4 * 5 + assert result["bubble_radius"] == 250.0 + grid_layer = ARCH_ROLE_LAYER["grid"] + symbol_layer = ARCH_ROLE_LAYER["symbol"] + lines = await backend.entity_list(type_filter="LINE", limit=100) + circles = await backend.entity_list(type_filter="CIRCLE", limit=100) + texts = await backend.entity_list(type_filter="TEXT", limit=100) + assert len(lines) == 4 and {e.layer for e in lines} == {grid_layer} + assert len(circles) == 8 and {e.layer for e in circles} == {symbol_layer} + assert sorted(e.properties["text"] for e in texts) == ["1", "1", "2", "2", "A", "A", "B", "B"] + row = next(row for row in ARCH_LAYERS if row[0] == grid_layer) + info = next(info for info in await backend.layer_list() if info.name == grid_layer) + assert info.linetype.upper() == row[2].upper() + + +@pytest.mark.asyncio +async def test_draw_symbol_writes_the_north_arrow_on_the_symbol_layer(backend): + result = await draw_symbol(backend, "north_arrow", (0.0, 0.0), lang="tr", rotation=30.0) + assert result["ok"] is True and result["kind"] == "north_arrow" + assert result["counts"] == {"circle": 1, "poly": 1, "text": 1} + (text,) = await backend.entity_list(type_filter="TEXT", limit=10) + assert text.layer == ARCH_ROLE_LAYER["symbol"] + assert text.properties["text"] == vocab("tr")["north"] + + +@pytest.mark.asyncio +async def test_draw_symbol_refuses_before_writing_anything(backend): + before = len(await backend.entity_list()) + with pytest.raises(ValueError, match="kinds are north_arrow"): + await draw_symbol(backend, "compass_rose", (0.0, 0.0)) + assert len(await backend.entity_list()) == before diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index c1125be..5aa42bc 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 (236 tools, 26 groups). + """Frozen snapshot of the surface (240 tools, 26 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 @@ -594,11 +594,27 @@ async def test_tool_group_sizes_are_unchanged(): file under their secondary tags: `entity_creation` 18 -> 20, `entity_query` 9 -> 10. This snapshot is branch-local: the track F merge task recomputes it once the walls, rooms and catalogue branches have landed. + + 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. + + Group C's second task added the structural grid and the plan symbols + (`arch_grid` / `arch_symbol`), both tagged `arch` and `create`: + `entity_creation` 19 -> 21, for the same branch-local reason. + + After the track F merge all eleven SECTION 25 tools file under + `architecture` (the `arch` tag outranks `create` / `query` / `dimension`), + so `entity_creation` and `entity_query` are back at 18 and 9. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} assert sizes == { "analysis": 12, - "architecture": 7, + "architecture": 11, "batch": 3, "blocks": 9, "corner_ops": 4, @@ -624,4 +640,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 236 + assert sum(sizes.values()) == 240