The three track-F branches are merged; this is the integration that makes the
twelve SECTION 25 tools one surface.
- engineering/arch/spec.py: a whole plan from one document - walls with their
openings, stairs, room labels measured off the walls just drawn, the
exterior chains and the schedules - inside one transaction. Everything the
model, the wall engine or the stair symbol can refuse is refused before the
transaction opens, by path; a room point in no closed face rolls it back,
and so does a cancelled request, under the anyio shield of pid/spec.py.
EXAMPLE_SPEC's rooms measure 4825 x 5750 and 3825 x 5750 - the net floor,
computed by hand in the tests.
- engineering/arch/critique.py: arch_room_unlabelled, arch_wall_gap (50 mm,
one issue per pair of walls, measured to the wall body so an L corner and a
T stem both read as joined) and arch_opening_clash. One index per critique
run; a drawing without an ACADMCP_ARCH record yields nothing. Each focus
has a positive and a negative fixture.
- TOOL_PACK_NAMES gains "arch" (the twelve SECTION 25 tools); lean gains
arch_wall, arch_opening, arch_room and arch_plan_from_spec and reaches 64;
the arch tag ranks after mech, so SECTION 25 files as one `architecture`
group - 241 tools in 26 groups.
- Six golden tuning cases, two of them Turkish, the arch_plan_from_spec alias
record, the shared-command allowlist reconciled with what the merged records
claim, the inventory regenerated, the registry snapshot retaken, and the
README figures taken from check_doc_numbers rather than typed.
- Two more Turkish golden cases close the reconciliation's alias gaps
("vaziyet planı çiz" -> arch_plan_from_spec, "oda alanlarını hesapla" ->
arch_rooms_detect); the arch records carry vaziyet / alan / oda / kat planı
in diacritic and ASCII-folded spellings, because the search tokenizer splits
on a non-ASCII letter. BOUNDARY and AREA stay with boundary_trace and
analysis_measure_entity on purpose, recorded in the arch alias comment.
Tuning top-1 73/84, top-3 83/84; holdout top-3 15/15.
- tests/test_token_suite.py: TOOL_PACKS=core,mech now drops the arch pack
too (measured 193 = 236 - 9 - 22 - 12).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
266 lines
10 KiB
Python
266 lines
10 KiB
Python
"""Three architectural focuses, each with a positive and a negative fixture.
|
|
|
|
All three are silent on a drawing with no ACADMCP_ARCH record, so
|
|
`focus=None` stays cheap - and honest - on a mechanical sheet, a P&ID or a
|
|
foreign plan of plain lines.
|
|
|
|
A fixture writes the records onto entities of the layers they live on in a
|
|
real plan (a wall record on a wall line, an opening record on a door line, a
|
|
room record on its label), then asks the index. The geometry of the gaps and
|
|
clashes is computed by hand in each test.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from engineering.arch.critique import (
|
|
ARCH_FOCUSES,
|
|
GAP_MM,
|
|
body_distance,
|
|
build_index,
|
|
has_arch_content,
|
|
issues_for,
|
|
opening_clashes,
|
|
point_in_loop,
|
|
wall_gaps,
|
|
)
|
|
from engineering.arch.layers import ARCH_ROLE_LAYER
|
|
from engineering.arch.model import (
|
|
APP_ID,
|
|
Room,
|
|
opening_from_dict,
|
|
to_payload,
|
|
to_values,
|
|
wall_from_dict,
|
|
)
|
|
from engineering.plan_spec import ALL_CRITIQUE_FOCUSES
|
|
|
|
WALL_LAYER = ARCH_ROLE_LAYER["wall"]
|
|
|
|
|
|
def _wall(wall_id, axis, thickness=200.0, **kw):
|
|
return wall_from_dict({"id": wall_id, "axis": axis, "thickness": thickness, **kw})
|
|
|
|
|
|
def _opening(opening_id, wall, offset, width, kind="door"):
|
|
return opening_from_dict(
|
|
{"id": opening_id, "wall": wall, "kind": kind, "offset": offset, "width": width}
|
|
)
|
|
|
|
|
|
async def _record(backend, obj, layer, at=(0.0, 0.0)):
|
|
"""A short line on ``layer`` carrying ``obj``'s ACADMCP_ARCH record."""
|
|
ent = await backend.entity_create_line(at[0], at[1], at[0] + 10.0, at[1], layer=layer)
|
|
await backend.entity_set_xdata(ent.handle, APP_ID, to_values(to_payload(obj)))
|
|
return ent.handle
|
|
|
|
|
|
async def _square(backend, x0, y0, x1, y1, layer=WALL_LAYER):
|
|
corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
|
|
handles = []
|
|
for a, b in zip(corners, corners[1:] + corners[:1], strict=True):
|
|
ent = await backend.entity_create_line(a[0], a[1], b[0], b[1], layer=layer)
|
|
handles.append(ent.handle)
|
|
return handles
|
|
|
|
|
|
# -- the closed enum and the silence rule ---------------------------------------
|
|
|
|
|
|
def test_the_three_focuses_are_the_spec_ones_and_are_in_the_closed_enum():
|
|
assert ARCH_FOCUSES == ("arch_room_unlabelled", "arch_wall_gap", "arch_opening_clash")
|
|
for focus in ARCH_FOCUSES:
|
|
assert focus in ALL_CRITIQUE_FOCUSES
|
|
assert GAP_MM == 50.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_every_focus_is_silent_on_a_drawing_with_no_arch_record(backend):
|
|
"""A closed room of plain lines on a WALL layer, nothing labelled, two
|
|
wall-like lines 30 mm apart - every trigger present, and not one issue,
|
|
because nothing on the drawing claims to be our plan."""
|
|
await _square(backend, 0.0, 0.0, 4000.0, 4000.0, layer="WALLS")
|
|
await backend.entity_create_line(5000.0, 0.0, 9000.0, 0.0, layer="WALLS")
|
|
await backend.entity_create_line(9030.0, 0.0, 12000.0, 0.0, layer="WALLS")
|
|
index = await build_index(backend)
|
|
assert has_arch_content(index) is False
|
|
for focus in ARCH_FOCUSES:
|
|
assert issues_for(focus, index) == []
|
|
|
|
|
|
# -- arch_wall_gap -----------------------------------------------------------------
|
|
|
|
|
|
def test_body_distance_is_zero_inside_and_on_the_edge():
|
|
# a 200 mm centre wall from (0, 0) to (4000, 0): body x 0..4000, y -100..100
|
|
band = (-100.0, 100.0)
|
|
assert body_distance((2000.0, 50.0), (0.0, 0.0), (4000.0, 0.0), band) == 0.0
|
|
assert body_distance((4000.0, 100.0), (0.0, 0.0), (4000.0, 0.0), band) == 0.0
|
|
assert body_distance((4040.0, 0.0), (0.0, 0.0), (4000.0, 0.0), band) == pytest.approx(40.0)
|
|
# 30 past the end and 40 past the face: the corner distance, 50
|
|
assert body_distance((4030.0, 140.0), (0.0, 0.0), (4000.0, 0.0), band) == pytest.approx(50.0)
|
|
|
|
|
|
def test_two_collinear_walls_40_mm_apart_are_one_gap():
|
|
walls = [_wall("W1", [[0, 0], [4000, 0]]), _wall("W2", [[4040, 0], [8000, 0]])]
|
|
(row,) = wall_gaps(walls)
|
|
assert row["walls"] == ("W1", "W2")
|
|
assert row["gap_mm"] == pytest.approx(40.0, abs=1e-9)
|
|
|
|
|
|
def test_an_l_corner_and_a_t_junction_are_joined():
|
|
l_corner = [_wall("W1", [[0, 0], [4000, 0]]), _wall("W2", [[4000, 0], [4000, 3000]])]
|
|
# the stem ends on the crossing wall's axis, inside its body
|
|
t_junction = [_wall("W1", [[0, 0], [6000, 0]]), _wall("W2", [[3000, 0], [3000, 3000]], 100)]
|
|
assert wall_gaps(l_corner) == []
|
|
assert wall_gaps(t_junction) == []
|
|
|
|
|
|
def test_a_stem_stopping_30_mm_short_of_the_crossing_face_is_a_gap():
|
|
# crossing wall body y -100..100; the stem starts at y = 130
|
|
walls = [_wall("W1", [[0, 0], [6000, 0]]), _wall("W2", [[3000, 130], [3000, 3000]], 100)]
|
|
(row,) = wall_gaps(walls)
|
|
assert row["end"] == "W2.start" and row["other"] == "W1"
|
|
assert row["gap_mm"] == pytest.approx(30.0, abs=1e-9)
|
|
|
|
|
|
def test_a_wall_further_than_50_mm_away_is_not_a_gap():
|
|
walls = [_wall("W1", [[0, 0], [4000, 0]]), _wall("W2", [[4051, 0], [8000, 0]])]
|
|
assert wall_gaps(walls) == []
|
|
|
|
|
|
def test_a_wall_that_nearly_closes_on_itself_leaks():
|
|
# the last point stops 40 mm above the first segment's face (y = 100)
|
|
ring = _wall("W1", [[0, 0], [4000, 0], [4000, 3000], [0, 3000], [0, 140]])
|
|
(row,) = wall_gaps([ring])
|
|
assert row["walls"] == ("W1", "W1") and row["end"] == "W1.end"
|
|
assert row["gap_mm"] == pytest.approx(40.0, abs=1e-9)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wall_gap_fires_on_the_drawing(backend):
|
|
await _record(backend, _wall("W1", [[0, 0], [4000, 0]]), WALL_LAYER)
|
|
await _record(backend, _wall("W2", [[4040, 0], [8000, 0]]), WALL_LAYER, at=(4040.0, 0.0))
|
|
issues = issues_for("arch_wall_gap", await build_index(backend))
|
|
assert len(issues) == 1
|
|
assert issues[0].severity == "error"
|
|
assert issues[0].detail["gap_mm"] == pytest.approx(40.0)
|
|
assert len(issues[0].handles) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wall_gap_is_quiet_on_a_joined_pair(backend):
|
|
await _record(backend, _wall("W1", [[0, 0], [4000, 0]]), WALL_LAYER)
|
|
await _record(backend, _wall("W2", [[4000, 0], [4000, 3000]]), WALL_LAYER, at=(4000.0, 0.0))
|
|
assert issues_for("arch_wall_gap", await build_index(backend)) == []
|
|
|
|
|
|
# -- arch_opening_clash ------------------------------------------------------------
|
|
|
|
|
|
def test_overlap_and_overrun_are_both_reported():
|
|
wall = _wall("W1", [[0, 0], [5000, 0]])
|
|
rows = opening_clashes(
|
|
[wall],
|
|
[
|
|
_opening("D1", "W1", 1000, 900),
|
|
_opening("W2", "W1", 1500, 1200, "window"),
|
|
_opening("D3", "W1", 4500, 900),
|
|
],
|
|
)
|
|
assert [row["openings"] for row in rows] == [("D1", "W2"), ("D3",)]
|
|
assert "overlap (1000-1900 and 1500-2700)" in rows[0]["reason"]
|
|
assert "runs 400 mm past the end" in rows[1]["reason"]
|
|
|
|
|
|
def test_an_opening_across_an_axis_vertex_and_one_on_a_missing_wall():
|
|
wall = _wall("W1", [[0, 0], [5000, 0], [5000, 4000]])
|
|
rows = opening_clashes([wall], [_opening("D1", "W1", 4500, 900), _opening("D2", "W9", 0, 900)])
|
|
assert "across the axis vertex at 5000" in rows[0]["reason"]
|
|
assert "'W9' has no record" in rows[1]["reason"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_opening_clash_fires_on_the_drawing(backend):
|
|
await _record(backend, _wall("W1", [[0, 0], [5000, 0]]), WALL_LAYER)
|
|
await _record(backend, _opening("D1", "W1", 1000, 900), ARCH_ROLE_LAYER["door"])
|
|
await _record(backend, _opening("D2", "W1", 1500, 900), ARCH_ROLE_LAYER["door"], (1500.0, 0.0))
|
|
(issue,) = issues_for("arch_opening_clash", await build_index(backend))
|
|
assert issue.severity == "error"
|
|
assert issue.detail == {"openings": ["D1", "D2"], "wall": "W1"}
|
|
assert len(issue.handles) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_opening_clash_is_quiet_on_two_openings_that_only_touch(backend):
|
|
await _record(backend, _wall("W1", [[0, 0], [5000, 0]]), WALL_LAYER)
|
|
await _record(backend, _opening("D1", "W1", 1000, 900), ARCH_ROLE_LAYER["door"])
|
|
await _record(backend, _opening("D2", "W1", 1900, 900), ARCH_ROLE_LAYER["door"], (1900.0, 0.0))
|
|
assert issues_for("arch_opening_clash", await build_index(backend)) == []
|
|
|
|
|
|
# -- arch_room_unlabelled --------------------------------------------------------------
|
|
|
|
|
|
def test_point_in_loop():
|
|
loop = [[0, 0], [4000, 0], [4000, 3000], [0, 3000]]
|
|
assert point_in_loop((2000.0, 1500.0), loop) is True
|
|
assert point_in_loop((4000.0, 1500.0), loop) is True # on an edge
|
|
assert point_in_loop((4100.0, 1500.0), loop) is False
|
|
|
|
|
|
async def _ring(backend):
|
|
"""A 200 mm ring on axis (0,0)-(4000,4000): its faces drawn as lines on the
|
|
wall layer, its record carried by the first outer face line - where a real
|
|
plan carries it. Interior 3800 x 3800."""
|
|
outer = await _square(backend, -100.0, -100.0, 4100.0, 4100.0)
|
|
await _square(backend, 100.0, 100.0, 3900.0, 3900.0)
|
|
ring = _wall("W1", [[0, 0], [4000, 0], [4000, 4000], [0, 4000]], closed=True)
|
|
await backend.entity_set_xdata(outer[0], APP_ID, to_values(to_payload(ring)))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_room_unlabelled_fires_on_a_closed_room_with_no_label(backend):
|
|
await _ring(backend)
|
|
index = await build_index(backend)
|
|
(issue,) = issues_for("arch_room_unlabelled", index)
|
|
assert issue.severity == "warning"
|
|
assert issue.detail["area_mm2"] == pytest.approx(3800.0 * 3800.0)
|
|
assert "14.44 m²" in issue.message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_room_unlabelled_is_quiet_once_the_room_carries_a_label(backend):
|
|
await _ring(backend)
|
|
room = Room(id="R1", name="ROOM", number="01", at=(2000.0, 2000.0), area=3800.0 * 3800.0)
|
|
await _record(backend, room, ARCH_ROLE_LAYER["room"], at=(2000.0, 2000.0))
|
|
assert issues_for("arch_room_unlabelled", await build_index(backend)) == []
|
|
|
|
|
|
# -- the dispatch --------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_dispatch_entries_share_one_index_per_run(backend):
|
|
from engineering.arch.critique import ARCH_DISPATCH
|
|
|
|
await _record(backend, _wall("W1", [[0, 0], [4000, 0]]), WALL_LAYER)
|
|
shared: dict = {}
|
|
for focus in ARCH_FOCUSES:
|
|
check = ARCH_DISPATCH[focus]
|
|
assert check.needs_shared is True
|
|
await check(backend, shared)
|
|
assert list(shared) == ["arch_index"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_critique_reaches_the_arch_focuses(backend):
|
|
from engineering.critique import run_critique
|
|
|
|
await _record(backend, _wall("W1", [[0, 0], [4000, 0]]), WALL_LAYER)
|
|
await _record(backend, _wall("W2", [[4040, 0], [8000, 0]]), WALL_LAYER, at=(4040.0, 0.0))
|
|
(issue,) = await run_critique(backend, ["arch_wall_gap"])
|
|
assert issue.focus == "arch_wall_gap" and issue.severity == "error"
|
|
assert "stops 40 mm short of wall" in issue.message
|