Files
autocad-mcp-pro/tests/test_arch_symbols.py
T
Umut CANandClaude Opus 5.5 f5f11178a9 feat(task-8): SECTION 25 grid and symbols - arch_grid, arch_symbol
engineering/arch/symbols.py is pure: grid_prims() draws each structural axis on
the grid role - its CENTER linetype comes from the A-GRID-E-N row, no primitive
sets one - with a 5 mm (paper) bubble and its label touching both ends, on the
symbol role so the circle does not print dashed. Numbers run along x and
letters along y (A..Z, AA, ... none skipped) unless labels names them;
positions are sorted, duplicates and label-count mismatches refused by path.

symbol_prims() draws the four plan symbols: the north arrow (a circle, an
arrow turned CCW from +Y, the vocab(lang) north letter upright beyond the tip),
the section mark (the cut line, a labelled bubble past each end and a viewing
arrow at each end, p1/p2 relative to the origin), the level mark (the triangle,
its line and the value formatted +3.00 / ±0.00, with a decimal comma in
Turkish) and the elevation mark (a circle whose pointers are the tangents from
r*sqrt(2) touching it at +/-45 degrees). An unknown kind is refused with the
list, a parameter the kind does not take with the ones it does. Every size is
a declared paper-mm drafting choice times the plot scale, not a standards value.

arch_grid and arch_symbol draw through draw_prims with ARCH_ROLE_LAYER; alias
records (COLUMNGRIDADD, English and Turkish synonyms), a regenerated inventory,
the README snapshot at 233 tools and the branch-local group snapshot
(entity_creation 21) complete the registration.

Beyond the plan: grid_axes refuses a labels value that is not a dict and a
labels.x / labels.y that is not a list (a string was split into one label per
character, a number raised a bare TypeError). No new ActiveX member: a live run
on AutoCAD 2026 (scratch document, closed after) drew the grid and all four
symbols through draw_prims - A-GRID-E-N created with CENTER, bubbles, texts and
symbols on A-SYMB-T-N, the Turkish north letter K and the level "+3,00".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 22:42:42 +03:00

359 lines
15 KiB
Python

"""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