merge: track F group C
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:
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
||||
|
||||
</div>
|
||||
|
||||
> **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 |
|
||||
|
||||
<sub>236 tools in 26 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>240 tools in 26 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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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_<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
|
||||
|
||||
|
||||
@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 ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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"})
|
||||
@@ -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": []})
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user