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