diff --git a/README.md b/README.md index 6c4c2b7..805a949 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:** 231 tools · 8 resources · 5 prompt templates · 4005 collected tests. -> 231 is the **registered** count; a default install advertises 226 over `tools/list`, +> **v1.5 release snapshot:** 233 tools · 8 resources · 5 prompt templates · 4005 collected tests. +> 233 is the **registered** count; a default install advertises 228 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 | -231 tools in 25 groups; TOOL_PACKS=core hides the nine pid_* tools, the 22 environment tools and the 14 mechanical tools (TOOL_PACKS=core,mech keeps the last) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. +233 tools in 25 groups; TOOL_PACKS=core hides the nine pid_* tools, the 22 environment tools and the 14 mechanical tools (TOOL_PACKS=core,mech keeps the last) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. **Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy. diff --git a/discovery/aliases.py b/discovery/aliases.py index 2a944e7..67ca41f 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -2264,6 +2264,36 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "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 97fa0bf..db78b61 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -3,7 +3,7 @@ "schema_version": 1, "package_version": "1.5.1", "totals": { - "tools": 231, + "tools": 233, "resources": 8, "prompts": 5, "groups": 25 @@ -16,7 +16,7 @@ "dimensions": 5, "drawing": 11, "engineering": 10, - "entity_creation": 19, + "entity_creation": 21, "entity_modification": 16, "entity_query": 10, "environment": 22, @@ -208,6 +208,34 @@ "readOnlyHint": true } }, + { + "name": "arch_grid", + "group": "entity_creation", + "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_symbol", + "group": "entity_creation", + "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": "balloon_add", "group": "sheet", diff --git a/engineering/arch/__init__.py b/engineering/arch/__init__.py index 22c2b2d..df92a6e 100644 --- a/engineering/arch/__init__.py +++ b/engineering/arch/__init__.py @@ -40,6 +40,15 @@ from .model import ( wall_from_dict, wall_length, ) +from .symbols import ( + SYMBOL_KINDS, + draw_grid, + draw_symbol, + fmt_level, + grid_axes, + grid_prims, + symbol_prims, +) __all__ = [ "APP_ID", @@ -56,6 +65,7 @@ __all__ = [ "Opening", "Room", "SIZE_BASIS", + "SYMBOL_KINDS", "Stair", "WALL_HATCH_TABLE", "WALL_MATERIALS", @@ -64,15 +74,21 @@ __all__ = [ "block_spec", "catalogue", "decode", + "draw_grid", + "draw_symbol", "encode", "fmt_area_m2", + "fmt_level", "fmt_number", "from_payload", + "grid_axes", + "grid_prims", "insert_item", "opening_from_dict", "room_from_dict", "segment_of", "stair_from_dict", + "symbol_prims", "tag_prefix", "to_payload", "to_values", 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 b92c819..70e8893 100644 --- a/server.py +++ b/server.py @@ -9781,7 +9781,7 @@ async def drawing_export_dwg( # --------------------------------------------------------------------------- -# ── SECTION 25: Architecture (2 tools) ────────────────────────────────────── +# ── SECTION 25: Architecture (4 tools) ────────────────────────────────────── # --------------------------------------------------------------------------- # ── arch: catalogue (group C) ── @@ -9888,6 +9888,139 @@ async def arch_catalogue_insert( 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_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 e06fb5b..7c08c13 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 (231 tools, 25 groups). + """Frozen snapshot of the surface (233 tools, 25 groups). Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and @@ -589,6 +589,10 @@ async def test_tool_group_sizes_are_unchanged(): 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. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} assert sizes == { @@ -599,7 +603,7 @@ async def test_tool_group_sizes_are_unchanged(): "dimensions": 5, "drawing": 11, "engineering": 10, - "entity_creation": 19, + "entity_creation": 21, "entity_modification": 16, "entity_query": 10, "environment": 22, @@ -618,4 +622,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 231 + assert sum(sizes.values()) == 233