Files
autocad-mcp-pro/tests/test_arch_foundation.py
T
Umut CANandClaude Opus 5.5 d5c5e90406 feat(arch): the plan model and its foundation - layers, materials, EN/TR, two generalisations
Track F starts here; the three group branches are cut from this commit.

- engineering/arch/model.py: Wall, Opening, Stair and Room as frozen
  dataclasses, and every refusal of spec 4 before anything is drawn, by path
  (walls[2].thickness): finite positive numbers, an axis of at least two
  distinct points with no zero-length segment, an arc segment refused by name
  rather than approximated, an opening on an unknown wall, past its wall,
  across an axis vertex or overlapping another (both ids named), an unknown
  material, and a typed room area - areas are measured, never typed. The
  plan travels as ACADMCP_ARCH XDATA; an opening's door/window kind and a
  stair's straight/l/u kind ride beside the payload kind as opening_kind /
  stair_kind, because one key cannot hold both.
- engineering/arch/layers.py: the arch set - thirteen ISO 13567-style layers
  with ISO 128 lineweights only (0.05 mm on the construction scratch layer,
  as every set in this repository) - and ARCH_ROLE_LAYER, the one place a
  role becomes a layer. Registered in engineering/layers.py last, which is
  what keeps the engineering.layers <-> engineering.arch import cycle closed.
- engineering/arch/materials.py: eight wall materials, one pattern each,
  every row citing its source: ANSI31 for brick (ISO 128-50 general hatching,
  acadiso.pat's own "Iron, Brick, Stone masonry"), AAC as ANSI31 at twice the
  spacing (a repository convention, and it says so), AR-CONC / AR-SAND as
  AutoCAD patterns, and the JIS A 0150 RC / stone / wood symbols. Reinforced
  concrete cannot be ANSI31 and AR-CONC in one HATCH, so it takes the one
  symbol that names it. Periods on a 1:50 sheet are recomputed from ezdxf's
  own pattern definitions by the tests. normalise_material is the one reading
  of a material name, shared by the model and wall_hatch, so the model never
  refuses "Reinforced Concrete" that the hatch table resolves.
- engineering/arch/lang.py: EN / TR vocabulary, D/W and K/P tag prefixes, and
  the decimal comma for lang="tr" (24,50 m2).
- draw_prims(role_layer=) and the XDATA codec's app_id= / kinds=: both
  generalised with the mechanical defaults, and the mechanical suite passes
  unchanged against them - that is the regression gate.

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

368 lines
13 KiB
Python

"""Track F's shared foundation: the arch layer set, the wall-material hatches,
EN/TR vocabulary, and the two generalisations of the mechanical layer
(`draw_prims(role_layer=)` and the XDATA codec's `app_id=` / `kinds=`).
The mechanical callers are the regression gate for the two generalisations:
`tests/test_mech_draw.py`, `tests/test_mech_xdata.py` and the rest of the
mechanical suite run unchanged against the new signatures.
"""
from __future__ import annotations
import math
import pytest
from ezdxf.tools import pattern as ezpattern
from engineering.arch.lang import LANGS, fmt_area_m2, fmt_number, tag_prefix, vocab
from engineering.arch.layers import ARCH_LAYERS, ARCH_ROLE_LAYER
from engineering.arch.materials import WALL_HATCH_TABLE, WALL_MATERIALS, wall_hatch
from engineering.layers import LAYER_ROLES, LAYER_SET_REGISTRY, apply_layer_set, resolve_role_layer
from engineering.mech import xdata as codec
from engineering.mech.draw import draw_prims
from engineering.mech.primitives import HatchArea, Line, Text
from engineering.plan_spec import ISO_128_LINEWEIGHTS_MM
# -- the layer set ---------------------------------------------------------------
EXPECTED_LAYERS = {
"A-WALL-E-N": ("Continuous", 0.50),
"A-WALL-H-N": ("Continuous", 0.13),
"A-DOOR-E-N": ("Continuous", 0.25),
"A-WIND-E-N": ("Continuous", 0.25),
"A-STAIR-E-N": ("Continuous", 0.25),
"A-FURN-E-N": ("Continuous", 0.18),
"A-SANR-E-N": ("Continuous", 0.18),
"A-GRID-E-N": ("CENTER", 0.18),
"A-ROOM-T-N": ("Continuous", 0.25),
"A-DIMEN-T-N": ("Continuous", 0.18),
"A-SYMB-T-N": ("Continuous", 0.25),
"A-OVHD-E-N": ("HIDDEN", 0.18),
"A-CONST-E-N": ("Continuous", 0.05),
}
def test_the_arch_set_is_the_thirteen_pinned_layers_plus_zero():
rows = {name: (linetype, lw) for name, _aci, linetype, lw, _d in ARCH_LAYERS}
assert rows.pop("0") == ("Continuous", 0.25)
assert rows == EXPECTED_LAYERS
def test_every_lineweight_but_the_construction_scratch_is_iso_128():
for name, _aci, _lt, lineweight, _d in ARCH_LAYERS:
if "CONST" in name:
assert lineweight == 0.05 # skipped by the iso128 critique, cleared before finalize
continue
assert lineweight in ISO_128_LINEWEIGHTS_MM, name
def test_the_construction_layer_is_colour_250_like_every_other_set():
colours = {name: aci for name, aci, _lt, _lw, _d in ARCH_LAYERS}
assert colours["A-CONST-E-N"] == 250
def test_every_role_maps_onto_a_layer_of_the_set():
names = {row[0] for row in ARCH_LAYERS}
assert set(ARCH_ROLE_LAYER.values()) <= names
assert ARCH_ROLE_LAYER == {
"wall": "A-WALL-E-N",
"wall_hatch": "A-WALL-H-N",
"door": "A-DOOR-E-N",
"window": "A-WIND-E-N",
"stair": "A-STAIR-E-N",
"furniture": "A-FURN-E-N",
"sanitary": "A-SANR-E-N",
"grid": "A-GRID-E-N",
"room": "A-ROOM-T-N",
"dim": "A-DIMEN-T-N",
"symbol": "A-SYMB-T-N",
"overhead": "A-OVHD-E-N",
}
def test_the_set_is_registered_with_its_dimension_and_construction_roles():
assert LAYER_SET_REGISTRY["arch"] is ARCH_LAYERS
assert LAYER_ROLES["arch"] == {"dim": "A-DIMEN-T-N", "construction": "A-CONST-E-N"}
assert resolve_role_layer("arch", "dim") == "A-DIMEN-T-N"
assert resolve_role_layer("arch", "construction") == "A-CONST-E-N"
@pytest.mark.asyncio
async def test_apply_layer_set_arch_creates_every_layer_with_its_row(backend):
result = await apply_layer_set(backend, "arch")
assert all(result[name] == "created" for name in EXPECTED_LAYERS)
layers = {lyr.name: lyr for lyr in await backend.layer_list()}
assert layers["A-GRID-E-N"].linetype.upper() == "CENTER"
assert layers["A-OVHD-E-N"].linetype.upper() == "HIDDEN"
assert layers["A-CONST-E-N"].color == 250
# -- wall materials --------------------------------------------------------------
def _period(name: str) -> float:
"""The finest perpendicular line period of a pattern at scale 1 (ezdxf ISO set)."""
periods = []
for angle, _base, offset, _dashes in ezpattern.load(measurement=1)[name]:
rad = math.radians(angle)
period = abs(-offset[0] * math.sin(rad) + offset[1] * math.cos(rad))
if period > 1e-9:
periods.append(period)
return min(periods)
def test_the_eight_pinned_materials_each_have_one_row():
assert WALL_MATERIALS == (
"brick",
"aac",
"concrete",
"reinforced_concrete",
"gypsum_board",
"stone",
"timber",
"generic",
)
assert set(WALL_HATCH_TABLE) == set(WALL_MATERIALS)
def test_wall_hatch_returns_exactly_pattern_angle_scale():
assert wall_hatch("brick") == {"pattern": "ANSI31", "angle": 0.0, "scale": 20.0}
assert wall_hatch("Reinforced Concrete")["pattern"] == "JIS_RC_15"
def test_an_unknown_material_is_refused_with_the_list():
with pytest.raises(
ValueError, match=r"unknown wall material 'adobe'; wall materials are brick, aac"
):
wall_hatch("adobe")
def test_every_pattern_exists_in_both_ezdxf_measurement_sets():
"""ezdxf silently falls back to ANSI31 for a pattern it does not know."""
iso = ezpattern.load(measurement=1)
imperial = ezpattern.load(measurement=0)
for material in WALL_MATERIALS:
name = wall_hatch(material)["pattern"]
assert name in iso and name in imperial, material
def test_the_periods_on_a_1_to_50_sheet_are_the_documented_ones():
"""Recomputed from ezdxf's own definitions: period x scale / 50 = paper mm."""
expected_paper_mm = {
"brick": 1.27,
"aac": 2.54,
"reinforced_concrete": 3.0,
"stone": 1.5,
"timber": 1.273,
"generic": 1.27,
}
for material, paper in expected_paper_mm.items():
row = wall_hatch(material)
assert round(_period(row["pattern"]) * row["scale"] / 50.0, 3) == paper, material
def test_aac_is_the_lighter_diagonal_of_brick():
assert wall_hatch("aac")["pattern"] == wall_hatch("brick")["pattern"]
assert wall_hatch("aac")["scale"] == 2 * wall_hatch("brick")["scale"]
def test_every_row_states_where_its_symbol_comes_from():
for material in WALL_MATERIALS:
source = WALL_HATCH_TABLE[material]["source"]
assert any(
token in source for token in ("ISO 128-50", "JIS A 0150", "acadiso.pat", "convention")
)
# -- language --------------------------------------------------------------------
def test_both_languages_carry_the_same_keys():
assert LANGS == ("en", "tr")
assert (
set(vocab("en"))
== set(vocab("tr"))
== {
"door_schedule",
"window_schedule",
"room_schedule",
"tag",
"width",
"height",
"sill",
"swing",
"hand",
"wall",
"number",
"name",
"area",
"total",
"up",
"down",
"north",
"level",
"in",
"out",
"left",
"right",
}
)
assert vocab("tr")["up"] == "ÇIKIŞ"
assert vocab("en")["up"] == "UP"
def test_tag_prefixes():
assert (tag_prefix("door", "en"), tag_prefix("window", "en")) == ("D", "W")
assert (tag_prefix("door", "tr"), tag_prefix("window", "tr")) == ("K", "P")
def test_turkish_switches_the_decimal_separator_to_a_comma():
assert fmt_number(24.5, "en") == "24.50"
assert fmt_number(24.5, "tr") == "24,50"
assert fmt_number(-0.001, "en") == "0.00"
assert fmt_area_m2(24_500_000.0, "en") == "24.50 m²"
assert fmt_area_m2(24_500_000.0, "tr") == "24,50 m²"
assert fmt_area_m2(20_000_000.0, "en") == "20.00 m²"
def test_an_unknown_language_is_refused_with_the_list():
for call in (
lambda: vocab("de"),
lambda: fmt_number(1.0, "de"),
lambda: tag_prefix("door", "fr"),
):
with pytest.raises(
ValueError, match=r"lang: unknown language '(de|fr)'; languages are en, tr"
):
call()
def test_a_negative_area_is_refused():
with pytest.raises(ValueError, match=r"area: must be finite and not negative"):
fmt_area_m2(-1.0, "en")
# -- draw_prims(role_layer=) ---------------------------------------------------------
@pytest.mark.asyncio
async def test_a_role_map_puts_each_role_on_its_architectural_layer(backend):
square = ((0.0, 0.0), (200.0, 0.0), (200.0, 1000.0), (0.0, 1000.0))
result = await draw_prims(
backend,
[
Line((0.0, 0.0), (200.0, 0.0), "wall"),
Line((0.0, 0.0), (0.0, 900.0), "door"),
Text((500.0, 500.0), "LIVING", 125.0, 0.0, "room"),
HatchArea((square,), "brick"),
],
role_layer=ARCH_ROLE_LAYER,
)
assert len(result["handles"]) == 4
placed = {(e.type, e.layer) for e in await backend.entity_list(limit=100)}
assert placed == {
("LINE", "A-WALL-E-N"),
("LINE", "A-DOOR-E-N"),
("TEXT", "A-ROOM-T-N"),
("HATCH", "A-WALL-H-N"),
}
@pytest.mark.asyncio
async def test_a_wall_hatch_takes_the_wall_material_pattern(backend):
square = ((0.0, 0.0), (200.0, 0.0), (200.0, 1000.0), (0.0, 1000.0))
await draw_prims(
backend, [HatchArea((square,), "reinforced_concrete")], role_layer=ARCH_ROLE_LAYER
)
(hatch,) = await backend.entity_list(type_filter="HATCH", limit=10)
entity = backend._doc.entitydb[hatch.handle]
assert entity.dxf.pattern_name == "JIS_RC_15"
assert math.isclose(entity.dxf.pattern_scale, 10.0)
@pytest.mark.asyncio
async def test_missing_architectural_layers_are_created_with_their_row(backend):
await draw_prims(backend, [Line((0.0, 0.0), (1000.0, 0.0), "grid")], role_layer=ARCH_ROLE_LAYER)
layers = {lyr.name: lyr for lyr in await backend.layer_list()}
grid = layers["A-GRID-E-N"]
assert grid.linetype.upper() == "CENTER"
assert grid.color == 1
@pytest.mark.asyncio
async def test_an_unknown_role_is_refused_before_anything_is_drawn(backend):
with pytest.raises(
ValueError, match=r"draw_prims: unknown role 'visible'; this role map has wall"
):
await draw_prims(
backend,
[Line((0.0, 0.0), (1.0, 0.0), "wall"), Line((0.0, 0.0), (1.0, 0.0), "visible")],
role_layer=ARCH_ROLE_LAYER,
)
assert await backend.entity_list(limit=10) == []
@pytest.mark.asyncio
async def test_an_unknown_wall_material_is_refused_before_anything_is_drawn(backend):
square = ((0.0, 0.0), (200.0, 0.0), (200.0, 1000.0), (0.0, 1000.0))
with pytest.raises(ValueError, match=r"unknown wall material 'steel'"):
await draw_prims(
backend,
[Line((0.0, 0.0), (1.0, 0.0), "wall"), HatchArea((square,), "steel")],
role_layer=ARCH_ROLE_LAYER,
)
assert await backend.entity_list(limit=10) == []
@pytest.mark.asyncio
async def test_without_a_role_map_the_mechanical_layers_and_materials_are_unchanged(backend):
square = ((0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0))
await draw_prims(backend, [Line((0.0, 0.0), (1.0, 0.0)), HatchArea((square,), "steel")])
layers = {e.layer for e in await backend.entity_list(limit=10)}
assert layers == {"GEOMETRY", "HATCH"}
(hatch,) = await backend.entity_list(type_filter="HATCH", limit=10)
assert backend._doc.entitydb[hatch.handle].dxf.pattern_name == "ANSI31"
# -- the XDATA codec's app_id / kinds ----------------------------------------------
def test_the_codec_defaults_are_still_the_mechanical_ones():
part = {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": {}}
assert codec.encode(part)[0] == (1001, "ACADMCP_MECH")
assert codec.decode(codec.to_values(part)) == part
def test_the_codec_writes_and_reads_another_application():
payload = {"v": 1, "kind": "thing", "n": 3}
rows = codec.encode(payload, app_id="ACADMCP_TEST", kinds=("thing",))
assert rows[0] == (1001, "ACADMCP_TEST")
assert codec.decode(rows, app_id="ACADMCP_TEST", kinds=("thing",)) == payload
with pytest.raises(
ValueError, match=r"ACADMCP_TEST: unknown payload kind 'part'; kinds are thing"
):
codec.encode({"v": 1, "kind": "part"}, app_id="ACADMCP_TEST", kinds=("thing",))
with pytest.raises(
ValueError, match=r"ACADMCP_MECH: this XDATA belongs to application 'ACADMCP_TEST'"
):
codec.decode(rows)
# -- the tool ------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_drawing_apply_iso_layers_takes_arch():
from fastmcp import Client
import server
async with Client(server.mcp) as client:
await client.call_tool("drawing_new", {})
result = (
await client.call_tool("drawing_apply_iso_layers", {"standard": "arch"})
).structured_content
assert result["standard"] == "arch"
assert result["layers"]["A-WALL-E-N"] == "created"
assert result["layers"]["A-GRID-E-N"] == "created"