merge: track F group W

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-23 23:00:54 +03:00
co-authored by Claude Opus 5.5
18 changed files with 3252 additions and 8 deletions
+3 -3
View File
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
</div>
> **v1.5 release snapshot:** 229 tools · 8 resources · 5 prompt templates · 4005 collected tests.
> 229 is the **registered** count; a default install advertises 224 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 |
<sub>229 tools in 25 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools, the 22 environment tools and the 14 mechanical tools (<code>TOOL_PACKS=core,mech</code> keeps the last) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
<sub>233 tools in 26 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools, the 22 environment tools and the 14 mechanical tools (<code>TOOL_PACKS=core,mech</code> keeps the last) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
**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.
+74
View File
@@ -2230,6 +2230,80 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"punta deligi",
),
),
# ── Architecture (SECTION 25, track F group W) ──────────────────────────
"arch_wall": ToolAliases(
acad=("WALLADD", "MLINE"),
synonyms=(
"draw a wall",
"wall with a thickness",
"exterior wall",
"partition wall",
"floor plan walls",
"clean wall corners",
"t junction of walls",
"wall poche",
"duvar",
"duvar çiz",
"duvar ciz",
"bölme duvar",
"bolme duvar",
"kat planı",
"kat plani",
),
),
"arch_opening": ToolAliases(
acad=("DOORADD", "WINDOWADD"),
synonyms=(
"add a door",
"door in a wall",
"door swing",
"add a window",
"window in a wall",
"cut an opening in the wall",
"kapı",
"kapi",
"kapı ekle",
"pencere",
"pencere ekle",
"doğrama",
"dograma",
),
),
"arch_stair": ToolAliases(
acad=("STAIRADD",),
synonyms=(
"draw a stair",
"staircase",
"flight of stairs",
"l-shaped stair",
"u-shaped stair",
"walking line",
"blondel rule",
"risers and treads",
"merdiven",
"merdiven çiz",
"merdiven ciz",
"basamak",
"rıht",
"riht",
),
),
"arch_dimension_chains": ToolAliases(
acad=(),
synonyms=(
"dimension the facade",
"exterior dimensions",
"dimension chains",
"overall building dimensions",
"opening dimensions on the plan",
"ölçülendir",
"olculendir plan",
"dış ölçü",
"dis olcu",
"aks ölçüsü",
"aks olcusu",
),
),
}
+59 -2
View File
@@ -3,13 +3,14 @@
"schema_version": 1,
"package_version": "1.5.1",
"totals": {
"tools": 229,
"tools": 233,
"resources": 8,
"prompts": 5,
"groups": 25
"groups": 26
},
"groups": {
"analysis": 12,
"architecture": 4,
"batch": 3,
"blocks": 9,
"corner_ops": 4,
@@ -180,6 +181,62 @@
"readOnlyHint": true
}
},
{
"name": "arch_dimension_chains",
"group": "architecture",
"cost": "mutate",
"summary": "Dimension the plan's facades: openings, walls and overall chains outside each side.",
"tags": [
"arch",
"dimension"
],
"annotations": {
"title": "Architecture: Dimension Chains",
"destructiveHint": false
}
},
{
"name": "arch_opening",
"group": "architecture",
"cost": "mutate",
"summary": "Host a door or a window in a drawn wall by distance along it; the wall is cut.",
"tags": [
"arch",
"create"
],
"annotations": {
"title": "Architecture: Door / Window",
"destructiveHint": false
}
},
{
"name": "arch_stair",
"group": "architecture",
"cost": "mutate",
"summary": "Draw a straight, L or U stair in plan and report its Blondel step rule.",
"tags": [
"arch",
"create"
],
"annotations": {
"title": "Architecture: Stair",
"destructiveHint": false
}
},
{
"name": "arch_wall",
"group": "architecture",
"cost": "mutate",
"summary": "Add walls to the plan: clean L/T/X junctions, cut openings, material poché.",
"tags": [
"arch",
"create"
],
"annotations": {
"title": "Architecture: Wall",
"destructiveHint": false
}
},
{
"name": "balloon_add",
"group": "sheet",
+46
View File
@@ -6,6 +6,13 @@ and alphabetical - keep every side's names.
from __future__ import annotations
from .dimension import (
FIRST_OFFSET_PAPER_MM,
ROWS,
SIDES,
STEP_PAPER_MM,
exterior_chains,
)
from .lang import LANGS, fmt_area_m2, fmt_number, tag_prefix, vocab
from .layers import ARCH_LAYER_DEFS, ARCH_LAYER_ROLES, ARCH_LAYERS, ARCH_ROLE_LAYER
from .materials import WALL_HATCH_TABLE, WALL_MATERIALS, wall_hatch
@@ -30,6 +37,25 @@ from .model import (
wall_from_dict,
wall_length,
)
from .openings import opening_prims
from .stairs import (
ARROW_PAPER_MM,
BLONDEL_RANGE,
STAIR_KINDS,
TEXT_PAPER_MM,
TURNS,
blondel,
stair_prims,
)
from .walls import (
MIN_JOIN_ANGLE_DEG,
WallGeometry,
face_offsets,
room_segments,
wall_geometry,
wall_geometry_by_wall,
wall_segments,
)
__all__ = [
"APP_ID",
@@ -37,30 +63,50 @@ __all__ = [
"ARCH_LAYER_DEFS",
"ARCH_LAYER_ROLES",
"ARCH_ROLE_LAYER",
"ARROW_PAPER_MM",
"BLONDEL_RANGE",
"FIRST_OFFSET_PAPER_MM",
"KINDS",
"LANGS",
"MIN_JOIN_ANGLE_DEG",
"Opening",
"ROWS",
"Room",
"SIDES",
"STAIR_KINDS",
"STEP_PAPER_MM",
"Stair",
"TEXT_PAPER_MM",
"TURNS",
"WALL_HATCH_TABLE",
"WALL_MATERIALS",
"Wall",
"WallGeometry",
"axis_segments",
"blondel",
"decode",
"encode",
"exterior_chains",
"face_offsets",
"fmt_area_m2",
"fmt_number",
"from_payload",
"opening_from_dict",
"opening_prims",
"room_from_dict",
"room_segments",
"segment_of",
"stair_from_dict",
"stair_prims",
"tag_prefix",
"to_payload",
"to_values",
"validate_openings",
"vocab",
"wall_from_dict",
"wall_geometry",
"wall_geometry_by_wall",
"wall_hatch",
"wall_length",
"wall_segments",
]
+208
View File
@@ -0,0 +1,208 @@
"""Exterior dimension chains: openings, walls, overall - three rows per side.
An architectural plan carries its dimensions outside the building, in rows
parallel to each facade, nearest first:
1. **openings** - the facade's extremes and both jambs of every opening in the
exterior wall on that side;
2. **walls** - the extremes and both faces of every wall that meets that
exterior wall, so the chain reads wall thickness, clear room width, wall
thickness, ...;
3. **overall** - the building's extreme faces.
The "exterior wall on a side" is every axis segment parallel to that side
whose outer face no other parallel segment overlapping it lies beyond. Points
come from the wall engine's own faces, so a chain can never disagree with the
outline it measures.
ISO 129-1: each measurement appears once. A row whose points are exactly those
of a farther row (a facade with no opening, a side no wall meets) is not
emitted; the rows that are keep their places, so the overall row is always at
the same distance.
Offsets are paper millimetres times the plot-scale denominator: the first row
10 mm from the building and 8 mm between rows. Those two numbers are this
repository's drafting defaults, not standard values; pass ``first_offset`` /
``step`` in drawing units to override them.
"""
from __future__ import annotations
import math
from collections.abc import Sequence
from engineering.arch.model import Wall, segment_of
from engineering.arch.walls import face_offsets, wall_geometry, wall_segments
from engineering.mech.primitives import DimIntent
SIDES: tuple[str, ...] = ("bottom", "top", "left", "right")
ROWS: tuple[str, ...] = ("openings", "walls", "overall")
FIRST_OFFSET_PAPER_MM = 10.0
STEP_PAPER_MM = 8.0
_TOL = 1.0
def _positive(value, where: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{where}: expected a number, got {value!r}")
number = float(value)
if not math.isfinite(number) or number <= 0.0:
raise ValueError(f"{where}: must be a finite number above zero, got {value!r}")
return number
def _sides(sides) -> tuple[str, ...]:
if isinstance(sides, str) or not isinstance(sides, (list, tuple)) or not sides:
raise ValueError(f"sides: name at least one of {', '.join(SIDES)}, got {sides!r}")
chosen: list[str] = []
for i, side in enumerate(sides):
if side not in SIDES:
raise ValueError(f"sides[{i}]: {side!r}; sides are {', '.join(SIDES)}")
if side in chosen:
raise ValueError(f"sides[{i}]: {side!r} is named twice")
chosen.append(side)
return tuple(chosen)
def _unit(a, b) -> tuple[float, float]:
length = math.dist(a, b)
return ((b[0] - a[0]) / length, (b[1] - a[1]) / length)
def _exterior(walls: Sequence[Wall], ai: int, sign: float) -> list[dict]:
"""The segments parallel to a side whose outer face nothing parallel lies beyond."""
ci = 1 - ai
parallel: list[dict] = []
for wall in walls:
left, right = face_offsets(wall)
for index, (a, b) in enumerate(wall_segments(wall)):
u = _unit(a, b)
if abs(u[ci]) > 1e-9:
continue
n = (-u[1], u[0])
faces = (a[ci] + n[ci] * left, a[ci] + n[ci] * right)
parallel.append(
{
"wall": wall,
"index": index,
"a": a,
"b": b,
"span": (min(a[ai], b[ai]), max(a[ai], b[ai])),
"outer": min(faces) if sign < 0 else max(faces),
}
)
exterior = []
for item in parallel:
lo, hi = item["span"]
covered = any(
other is not item
and min(hi, other["span"][1]) - max(lo, other["span"][0]) > _TOL
and (
other["outer"] < item["outer"] - _TOL
if sign < 0
else other["outer"] > item["outer"] + _TOL
)
for other in parallel
)
if not covered:
exterior.append(item)
return exterior
def _inside(point, item: dict) -> bool:
a, b = item["a"], item["b"]
left, right = face_offsets(item["wall"])
u = _unit(a, b)
s = (point[0] - a[0]) * u[0] + (point[1] - a[1]) * u[1]
d = (point[0] - a[0]) * -u[1] + (point[1] - a[1]) * u[0]
return -_TOL <= s <= math.dist(a, b) + _TOL and right - _TOL <= d <= left + _TOL
def _points(values: set[float]) -> list[float]:
out: list[float] = []
for value in sorted(values):
if not out or value - out[-1] > 1e-6:
out.append(value)
return out
def exterior_chains(
walls,
openings=(),
*,
sides=("bottom", "left"),
first_offset=None,
step=None,
scale=50,
) -> tuple[DimIntent, ...]:
"""Linear ``DimIntent`` rows outside each named side, nearest first.
``feature`` names the row as ``"<side>:<row>"`` (``"bottom:openings"``);
``text_at`` is the point the dimension line passes through.
"""
walls = list(walls)
openings = list(openings)
chosen = _sides(sides)
factor = _positive(scale, "scale")
first = (
FIRST_OFFSET_PAPER_MM * factor
if first_offset is None
else _positive(first_offset, "first_offset")
)
gap = STEP_PAPER_MM * factor if step is None else _positive(step, "step")
geometry = wall_geometry(walls, openings, poche=False)
if not geometry.faces:
raise ValueError("walls: there is no wall to dimension")
xs = [p[0] for line in geometry.faces for p in (line.p1, line.p2)]
ys = [p[1] for line in geometry.faces for p in (line.p1, line.p2)]
box = {"bottom": min(ys), "top": max(ys), "left": min(xs), "right": max(xs)}
skipped = {entry["element"] for entry in geometry.omitted}
by_id = {wall.id: wall for wall in walls}
intents: list[DimIntent] = []
for side in chosen:
ai = 0 if side in ("bottom", "top") else 1
sign = -1.0 if side in ("bottom", "left") else 1.0
edge = box[side]
lo, hi = (box["left"], box["right"]) if ai == 0 else (box["bottom"], box["top"])
exterior = _exterior(walls, ai, sign)
hosts = {(item["wall"].id, item["index"]) for item in exterior}
jambs = {lo, hi}
for opening in openings:
if opening.id in skipped:
continue
wall = by_id[opening.wall]
index, along = segment_of(wall, float(opening.offset))
if (wall.id, index) not in hosts:
continue
a, b = wall_segments(wall)[index]
u = _unit(a, b)
jambs.add(a[ai] + u[ai] * along)
jambs.add(a[ai] + u[ai] * (along + float(opening.width)))
faces = {lo, hi}
for wall in walls:
left, right = face_offsets(wall)
for a, b in wall_segments(wall):
u = _unit(a, b)
if abs(u[1 - ai]) <= 1e-9:
continue
n = (-u[1], u[0])
for end in (a, b):
if any(_inside(end, item) for item in exterior):
faces.add(end[ai] + n[ai] * left)
faces.add(end[ai] + n[ai] * right)
rows = [_points(jambs), _points(faces), _points({lo, hi})]
for r, points in enumerate(rows):
if any(points == later for later in rows[r + 1 :]):
continue
offset = edge + sign * (first + r * gap)
for p, q in zip(points, points[1:], strict=False):
if ai == 0:
p1, p2, at = (p, edge), (q, edge), ((p + q) / 2.0, offset)
else:
p1, p2, at = (edge, p), (edge, q), (offset, (p + q) / 2.0)
intents.append(DimIntent("linear", p1, p2, at, feature=f"{side}:{ROWS[r]}"))
return tuple(intents)
+416
View File
@@ -0,0 +1,416 @@
"""The async layer of the architectural track - the only module here that touches a backend.
Everything else under `engineering/arch/` is pure; this module turns its
primitives into entities through the generic contract members (so it runs on
both engines as written) and writes the plan onto the drawing:
* each wall's ``ACADMCP_ARCH`` record on its first face line, each opening's
on its door leaf or its window frame, each stair's on its first riser line;
* next to each record, under ``ACADMCP_ARCH_H``, the handles of every entity
that element owns - faces, jambs, poche, the symbol - so a later call can
take exactly those entities back out and redraw them.
`read_plan` reads the records back. `add_walls` and `add_opening` build on
it: they compute the whole network before and after the change, redraw only
the walls whose outline the change touches, and say which.
Every refusal fires before the first entity is created or erased.
"""
from __future__ import annotations
import logging
import math
from dataclasses import replace
from engineering.arch.dimension import exterior_chains
from engineering.arch.lang import tag_prefix
from engineering.arch.layers import ARCH_LAYERS, ARCH_ROLE_LAYER
from engineering.arch.model import (
APP_ID,
Opening,
Room,
Stair,
Wall,
decode,
from_payload,
opening_from_dict,
stair_from_dict,
to_payload,
to_values,
wall_from_dict,
)
from engineering.arch.openings import opening_prims
from engineering.arch.stairs import blondel, stair_prims
from engineering.arch.walls import WallGeometry, wall_geometry_by_wall
from engineering.mech.draw import draw_prims
log = logging.getLogger(__name__)
#: The application that carries an element's owned handles beside its record.
HANDLES_APP = "ACADMCP_ARCH_H"
#: The layers `read_plan` scans for records - never the poche or the dimensions.
RECORD_ROLES: tuple[str, ...] = ("wall", "door", "window", "stair", "room")
#: Model class -> the record kind, read off the object rather than the payload,
#: so the reader does not depend on how a payload spells its own kind.
_KIND = {Wall: "wall", Opening: "opening", Stair: "stair", Room: "room"}
# -- layers -----------------------------------------------------------------------
async def ensure_arch_layers(backend, names) -> tuple[str, ...]:
"""Create the named layers the drawing lacks, with their ``ARCH_LAYERS`` row.
A live seat refuses ``entity.Layer = name`` on a missing layer (measured
by the mechanical track), so every draw path ensures its layers first.
"""
rows = {row[0]: row for row in ARCH_LAYERS}
existing = {layer.name.lower() for layer in await backend.layer_list()}
created: list[str] = []
for name in dict.fromkeys(names):
if name.lower() in existing:
continue
_name, color, linetype, lineweight, _description = rows.get(
name, (name, 7, "Continuous", 0.25, "")
)
await backend.layer_create(name=name, color=color, linetype=linetype, lineweight=lineweight)
created.append(name)
return tuple(created)
# -- records --------------------------------------------------------------------
async def _write_record(backend, handle: str, element, owned: list[str]) -> None:
await backend.entity_set_xdata(handle, APP_ID, to_values(to_payload(element)))
await backend.entity_set_xdata(handle, HANDLES_APP, list(owned))
async def read_plan(backend) -> dict:
"""Every ``ACADMCP_ARCH`` record on the drawing, rebuilt into model objects.
``carriers`` maps ``"<kind>:<id>"`` to the entity that carries the record.
Reads only; the drawing is never modified.
"""
plan: dict = {"walls": [], "openings": [], "stairs": [], "rooms": [], "carriers": {}}
for role in RECORD_ROLES:
for info in await backend.entity_list(layer_filter=ARCH_ROLE_LAYER[role], limit=100000):
raw = await backend.entity_get_xdata(info.handle, APP_ID)
values = (raw.get("xdata") or {}).get(APP_ID) or []
if not values:
continue
element = from_payload(decode(values))
kind = _KIND[type(element)]
plan[f"{kind}s"].append(element)
plan["carriers"][f"{kind}:{element.id}"] = info.handle
return plan
async def _owned(backend, carrier: str) -> list[str]:
raw = await backend.entity_get_xdata(carrier, HANDLES_APP)
return [str(h) for h in ((raw.get("xdata") or {}).get(HANDLES_APP) or [])]
async def _erase(backend, plan: dict, wall_id: str) -> list[str]:
"""Take a wall, its openings and their poche back out. Returns handles already gone."""
carriers = [plan["carriers"][f"wall:{wall_id}"]]
carriers += [
plan["carriers"][f"opening:{op.id}"] for op in plan["openings"] if op.wall == wall_id
]
missing: list[str] = []
for carrier in carriers:
for handle in await _owned(backend, carrier) or [carrier]:
try:
await backend.entity_delete(handle)
except Exception as exc: # erased by hand since it was drawn: nothing to take back
log.info("arch: %s was already gone (%s)", handle, exc)
missing.append(handle)
return missing
# -- walls ------------------------------------------------------------------------
def _signature(geometry: WallGeometry) -> tuple:
"""What a wall looks like, rounded - equal signatures mean nothing to redraw."""
def seg(line):
a = (round(line.p1[0], 6), round(line.p1[1], 6))
b = (round(line.p2[0], 6), round(line.p2[1], 6))
return tuple(sorted((a, b)))
def ring(points):
pts = [(round(x, 6), round(y, 6)) for x, y in points]
k = pts.index(min(pts))
return tuple(pts[k:] + pts[:k])
return (
frozenset(seg(line) for line in geometry.faces),
frozenset(seg(line) for line in geometry.jambs),
frozenset(ring(region) for region in geometry.regions),
frozenset(entry["element"] for entry in geometry.omitted),
)
async def _draw_wall(backend, wall, geometry: WallGeometry, openings) -> dict:
"""One wall's faces, jambs, poche and hosted symbols, with their records."""
skipped = {entry["element"] for entry in geometry.omitted}
symbols = [(op, opening_prims(wall, op)) for op in openings if op.id not in skipped]
# Faces first, so owned[0] - the record carrier - is the wall's first face line;
# the poche follows on the wall-hatch layer, in each material's pattern.
drawn = await draw_prims(
backend,
[*geometry.faces, *geometry.jambs, *geometry.hatches],
role_layer=ARCH_ROLE_LAYER,
)
owned = list(drawn["handles"])
done: list[dict] = []
for op, prims in symbols:
placed = await draw_prims(backend, prims, role_layer=ARCH_ROLE_LAYER)
await _write_record(backend, placed["handles"][0], op, placed["handles"])
done.append(
{
"id": op.id,
"tag": op.tag,
"record": placed["handles"][0],
"handles": placed["handles"],
}
)
await _write_record(backend, owned[0], wall, owned)
return {
"wall": {"id": wall.id, "record": owned[0], "handles": owned},
"openings": done,
"omitted": list(geometry.omitted),
}
def _collect(results: list[dict]) -> dict:
return {
"walls": [r["wall"] for r in results],
"openings": [op for r in results for op in r["openings"]],
"handles": [
h
for r in results
for h in (*r["wall"]["handles"], *(x for op in r["openings"] for x in op["handles"]))
],
"omitted": [entry for r in results for entry in r["omitted"]],
}
async def draw_walls(backend, walls, openings=(), *, poche=True) -> dict:
"""Draw a wall network and its openings, writing every record. Refusals first."""
walls = list(walls)
openings = list(openings)
geometry = wall_geometry_by_wall(walls, openings, poche=poche)
for wall in walls: # every symbol is validated before the first entity
for op in openings:
if op.wall == wall.id:
opening_prims(wall, op)
results = [
await _draw_wall(
backend, wall, geometry[wall.id], [op for op in openings if op.wall == wall.id]
)
for wall in walls
]
return {**_collect(results), "backend": backend.name}
async def add_walls(backend, walls, *, poche=True) -> dict:
"""Add walls to the plan on the drawing, redrawing the drawn walls they join."""
if isinstance(walls, dict) or not isinstance(walls, (list, tuple)) or not walls:
raise ValueError("walls: give a list of at least one wall {id, axis, thickness, ...}")
new = [wall_from_dict(item, f"walls[{i}]") for i, item in enumerate(walls)]
plan = await read_plan(backend)
drawn = {wall.id for wall in plan["walls"]}
for i, wall in enumerate(new):
if wall.id in drawn:
raise ValueError(
f"walls[{i}].id: {wall.id!r} is already drawn; choose a new id "
f"(drawn: {', '.join(sorted(drawn))})"
)
before = (
wall_geometry_by_wall(plan["walls"], plan["openings"], poche=poche) if plan["walls"] else {}
)
network = [*plan["walls"], *new]
after = wall_geometry_by_wall(network, plan["openings"], poche=poche)
changed = [
wall.id
for wall in plan["walls"]
if _signature(before[wall.id]) != _signature(after[wall.id])
]
for wall_id in changed:
was = {entry["element"] for entry in before[wall_id].omitted}
for entry in after[wall_id].omitted:
if entry["element"] not in was:
raise ValueError(
f"the new walls would leave opening {entry['element']!r} of wall "
f"{wall_id!r} uncuttable ({entry['reason']}); move the opening or the wall"
)
by_id = {wall.id: wall for wall in network}
targets = [*changed, *(wall.id for wall in new)]
for wall_id in targets:
for op in plan["openings"]:
if op.wall == wall_id:
opening_prims(by_id[wall_id], op)
missing: list[str] = []
for wall_id in changed:
missing += await _erase(backend, plan, wall_id)
results = [
await _draw_wall(
backend,
by_id[wall_id],
after[wall_id],
[op for op in plan["openings"] if op.wall == wall_id],
)
for wall_id in targets
]
return {
**_collect(results),
"added": [wall.id for wall in new],
"redrawn": changed,
"already_erased": missing,
"backend": backend.name,
}
# -- openings ---------------------------------------------------------------------
def assign_tags(openings, existing=(), *, lang: str = "en") -> tuple[Opening, ...]:
"""Give every untagged opening the next free tag of its kind: D1, W2 (EN) / K1, P2 (TR)."""
used = {op.tag for op in (*existing, *openings) if op.tag}
out: list[Opening] = []
for op in openings:
if op.tag:
out.append(op)
continue
prefix = tag_prefix(op.kind, lang)
number = 1
while f"{prefix}{number}" in used:
number += 1
used.add(f"{prefix}{number}")
out.append(replace(op, tag=f"{prefix}{number}"))
return tuple(out)
async def add_opening(backend, opening: dict, *, lang: str = "en", poche: bool = True) -> dict:
"""Host a door or a window in a drawn wall, found by id, and redraw that wall cut."""
if not isinstance(opening, dict):
raise ValueError(f"opening: expected a dict, got {type(opening).__name__}")
raw = dict(opening)
auto_id = not raw.get("id")
if auto_id:
raw["id"] = "__new__"
op = opening_from_dict(raw, "opening")
plan = await read_plan(backend)
walls = {wall.id: wall for wall in plan["walls"]}
if op.wall not in walls:
names = ", ".join(sorted(walls)) or "none - draw one with arch_wall"
raise ValueError(f"opening.wall: {op.wall!r} is not a wall on this drawing; walls: {names}")
(op,) = assign_tags([op], plan["openings"], lang=lang)
if auto_id:
op = replace(op, id=op.tag)
if op.id in {other.id for other in plan["openings"]}:
raise ValueError(f"opening.id: {op.id!r} is already drawn; choose a new id")
hosted = [other for other in plan["openings"] if other.wall == op.wall] + [op]
geometry = wall_geometry_by_wall(plan["walls"], [*plan["openings"], op], poche=poche)
for entry in geometry[op.wall].omitted:
if entry["element"] == op.id:
raise ValueError(f"opening {op.id!r} cannot be cut: {entry['reason']}")
wall = walls[op.wall]
for other in hosted:
opening_prims(wall, other)
missing = await _erase(backend, plan, op.wall)
result = await _draw_wall(backend, wall, geometry[op.wall], hosted)
return {
**_collect([result]),
"opening": op.id,
"tag": op.tag,
"redrawn": [op.wall],
"already_erased": missing,
"backend": backend.name,
}
# -- stairs -----------------------------------------------------------------------
async def draw_stair(backend, stair: Stair, *, lang: str = "en", scale=50) -> dict:
"""A stair in plan with its record; the Blondel value is reported, never enforced."""
prims = stair_prims(stair, lang=lang, scale=scale)
check = blondel(stair.riser_height, stair.going)
drawn = await draw_prims(backend, prims, role_layer=ARCH_ROLE_LAYER)
await _write_record(backend, drawn["handles"][0], stair, drawn["handles"])
flights = 1 if stair.kind == "straight" else 2
return {
"stair": stair.id,
"record": drawn["handles"][0],
"handles": drawn["handles"],
"risers": stair.risers,
"treads": stair.risers - flights,
"blondel": check,
"backend": backend.name,
}
async def add_stair(backend, stair: dict, *, lang: str = "en", scale=50) -> dict:
"""`draw_stair` for a stair given as a dict, refusing an id already on the drawing."""
if not isinstance(stair, dict):
raise ValueError(f"stair: expected a dict, got {type(stair).__name__}")
model = stair_from_dict(stair, "stair")
stair_prims(model, lang=lang, scale=scale)
plan = await read_plan(backend)
if model.id in {other.id for other in plan["stairs"]}:
raise ValueError(f"stair.id: {model.id!r} is already drawn; choose a new id")
return await draw_stair(backend, model, lang=lang, scale=scale)
# -- dimensions ---------------------------------------------------------------------
async def draw_chains(
backend, walls, openings, *, sides=("bottom", "left"), scale=50, first_offset=None, step=None
) -> dict:
"""The exterior chains as real linear DIMENSIONs on the dimension layer."""
intents = exterior_chains(
walls, openings, sides=sides, first_offset=first_offset, step=step, scale=scale
)
layer = ARCH_ROLE_LAYER["dim"]
await ensure_arch_layers(backend, [layer])
handles: list[str] = []
rows: list[dict] = []
for intent in intents:
horizontal = abs(intent.p2[1] - intent.p1[1]) <= abs(intent.p2[0] - intent.p1[0])
info = await backend.dimension_linear(
intent.p1[0],
intent.p1[1],
intent.p2[0],
intent.p2[1],
intent.text_at[0],
intent.text_at[1],
0.0 if horizontal else 90.0,
layer,
)
handles.append(info.handle)
rows.append(
{
"row": intent.feature,
"from": list(intent.p1),
"to": list(intent.p2),
"value": math.dist(intent.p1, intent.p2),
}
)
return {
"count": len(handles),
"handles": handles,
"dimensions": rows,
"text_height": (
"set by the current dimension style; at 1:{0:g} set DIMSCALE={0:g} with "
"dimstyle_modify so the text plots at its paper height".format(float(scale))
),
"backend": backend.name,
}
+87
View File
@@ -0,0 +1,87 @@
"""Door and window symbols in plan, hosted in their wall.
The wall engine (`walls.py`) cuts the gap; this module draws what stands in it.
Both read the opening's place from the same numbers - the wall's axis, its face
offsets and ``model.segment_of`` - so the symbol cannot drift off its gap.
Conventions (this repository's, stated because plan symbols vary by office):
* ``swing="in"`` opens to the **left** of the wall's axis, looking along it
from its first point; ``"out"`` to the right. A perimeter drawn
counter-clockwise has its inside on the left, so "in" means into the building.
* ``hand`` names the jamb that carries the hinges, seen by a person standing on
the side the door swings towards and facing the wall: ``"left"`` hinges on
their left-hand jamb.
* A door is drawn **open at 90°**: the leaf is a line as long as the opening is
wide, square to the wall from the hinge, on the swing-side face; the swing is
a quarter arc about the hinge from the closed position to the open one.
* A window is a frame line on each wall face across the opening and two
glazing lines between them at the thirds of the wall thickness (a drafting
convention, not a standard).
The first primitive is the one `draw.py` writes the opening record on: the
door leaf, or the window's frame line on the left face.
"""
from __future__ import annotations
import math
from engineering.arch.model import Opening, Wall, segment_of
from engineering.arch.walls import face_offsets, wall_segments
from engineering.mech.primitives import Arc, Line, Prim, Pt
def _deg(v: Pt) -> float:
return math.degrees(math.atan2(v[1], v[0])) % 360.0
def opening_prims(wall: Wall, opening: Opening) -> tuple[Prim, ...]:
"""The door leaf and swing, or the window frame and glazing, in WCS."""
if opening.wall != wall.id:
raise ValueError(
f"opening {opening.id!r} is hosted in wall {opening.wall!r}, not in {wall.id!r}"
)
width = float(opening.width)
index, along = segment_of(wall, float(opening.offset))
(ax, ay), (bx, by) = wall_segments(wall)[index]
length = math.hypot(bx - ax, by - ay)
u = ((bx - ax) / length, (by - ay) / length)
n = (-u[1], u[0])
left, right = face_offsets(wall)
def at(s: float, offset: float) -> Pt:
return (ax + u[0] * s + n[0] * offset, ay + u[1] * s + n[1] * offset)
s0, s1 = along, along + width
if opening.kind == "window":
third = (left - right) / 3.0
return (
Line(at(s0, left), at(s1, left), "window"),
Line(at(s0, right), at(s1, right), "window"),
Line(at(s0, right + third), at(s1, right + third), "window"),
Line(at(s0, right + 2.0 * third), at(s1, right + 2.0 * third), "window"),
)
if opening.kind != "door":
raise ValueError(f"opening {opening.id!r}: kind {opening.kind!r}; kinds are door, window")
if opening.swing not in ("in", "out"):
raise ValueError(f"opening {opening.id!r}: swing {opening.swing!r}; swings are in, out")
if opening.hand not in ("left", "right"):
raise ValueError(f"opening {opening.id!r}: hand {opening.hand!r}; hands are left, right")
side = 1.0 if opening.swing == "in" else -1.0
face = left if side > 0 else right
# Facing the wall from the swing side, the viewer's left hand points along
# +u when they stand on the axis's left, and along -u when on its right.
toward = side if opening.hand == "left" else -side
hinge_s, free_s = (s1, s0) if toward > 0 else (s0, s1)
hinge = at(hinge_s, face)
closed = at(free_s, face)
opened = (hinge[0] + n[0] * side * width, hinge[1] + n[1] * side * width)
a_closed = _deg((closed[0] - hinge[0], closed[1] - hinge[1]))
a_open = _deg((opened[0] - hinge[0], opened[1] - hinge[1]))
if abs(((a_open - a_closed) % 360.0) - 90.0) < 1e-6:
swing = Arc(hinge, width, a_closed, a_open, "door")
else:
swing = Arc(hinge, width, a_open, a_closed, "door")
return (Line(hinge, opened, "door"), swing)
+164
View File
@@ -0,0 +1,164 @@
"""Stairs in plan: straight, L and U flights, the walking line and the Blondel check.
Geometry, in the stair's own frame (x up the first flight, y to its left):
* ``start`` is the midpoint of the bottom riser; ``direction_deg`` turns the
frame into WCS.
* ``risers`` counts every rise from the lower floor to the upper one. A flight
of ``m`` risers has ``m - 1`` treads of depth ``going``; every riser is drawn
once as a line across the flight, so a straight stair of 17 risers shows 17
lines, the first at the start and the last at the top.
* An L or a U splits the risers into two flights, ``(risers + 1) // 2`` in the
first, and turns ``turn`` on a square landing ``width`` deep. The U's two
flights lie side by side with no stairwell between them.
* The walking line runs up the middle of every flight and across the landing,
with an open arrow at the top; the "up" word of the chosen language sits on
the first tread.
`blondel` reports 2R + G against 600-650 mm. That band is the step-length rule
attributed to François Blondel (Cours d'architecture, 1675), reported as a
design rule: it is not a building code, and no national stair regulation is
checked here.
"""
from __future__ import annotations
import math
from engineering.arch.lang import vocab
from engineering.arch.model import Stair
from engineering.mech.primitives import Line, Poly, Prim, Pt, Text, rotate, translate
BLONDEL_RANGE: tuple[float, float] = (600.0, 650.0)
STAIR_KINDS: tuple[str, ...] = ("straight", "l", "u")
TURNS: tuple[str, ...] = ("left", "right")
#: Paper sizes, multiplied by the plot-scale denominator. Drafting choices of
#: this repository, not standard values.
TEXT_PAPER_MM = 2.5
ARROW_PAPER_MM = 3.0
def _positive(value, where: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{where}: expected a number, got {value!r}")
number = float(value)
if not math.isfinite(number) or number <= 0.0:
raise ValueError(f"{where}: must be a finite number above zero, got {value!r}")
return number
def blondel(riser_height: float, going: float) -> dict:
"""``{"value": 2R + G, "ok": 600 <= value <= 650, "range": [600.0, 650.0]}`` in mm."""
rise = _positive(riser_height, "riser_height")
tread = _positive(going, "going")
value = 2.0 * rise + tread
low, high = BLONDEL_RANGE
return {"value": value, "ok": low <= value <= high, "range": [low, high]}
def _validate(stair: Stair, scale) -> tuple[float, float, int, float]:
width = _positive(stair.width, f"stair {stair.id!r}: width")
going = _positive(stair.going, f"stair {stair.id!r}: going")
_positive(stair.riser_height, f"stair {stair.id!r}: riser_height")
factor = _positive(scale, "scale")
if stair.kind not in STAIR_KINDS:
raise ValueError(
f"stair {stair.id!r}: kind {stair.kind!r}; kinds are {', '.join(STAIR_KINDS)}"
)
if stair.turn not in TURNS:
raise ValueError(f"stair {stair.id!r}: turn {stair.turn!r}; turns are {', '.join(TURNS)}")
risers = stair.risers
if isinstance(risers, bool) or not isinstance(risers, int):
raise ValueError(f"stair {stair.id!r}: risers must be a whole number, got {risers!r}")
least = 2 if stair.kind == "straight" else 4
if risers < least:
raise ValueError(
f"stair {stair.id!r}: risers {risers}; a {stair.kind} stair needs at least {least} "
"(two per flight)"
)
for where, value in (
("direction_deg", stair.direction_deg),
("start[0]", stair.start[0]),
("start[1]", stair.start[1]),
):
if (
isinstance(value, bool)
or not isinstance(value, (int, float))
or not math.isfinite(float(value))
):
raise ValueError(f"stair {stair.id!r}: {where} must be finite, got {value!r}")
return width, going, risers, factor
def _flights(kind: str, width: float, going: float, risers: int):
"""Riser lines, outline lines and the walking-line path, turning left."""
half = width / 2.0
if kind == "straight":
run = (risers - 1) * going
treads = [Line((k * going, -half), (k * going, half), "stair") for k in range(risers)]
outline = [
Line((0.0, -half), (run, -half), "stair"),
Line((0.0, half), (run, half), "stair"),
]
return treads, outline, [(0.0, 0.0), (run, 0.0)]
first = (risers + 1) // 2
second = risers - first
landing = (first - 1) * going
treads = [Line((k * going, -half), (k * going, half), "stair") for k in range(first)]
if kind == "l":
top = half + (second - 1) * going
treads += [
Line((landing, half + k * going), (landing + width, half + k * going), "stair")
for k in range(second)
]
outline = [
Line((0.0, -half), (landing + width, -half), "stair"),
Line((landing + width, -half), (landing + width, top), "stair"),
Line((0.0, half), (landing, half), "stair"),
Line((landing, half), (landing, top), "stair"),
]
return treads, outline, [(0.0, 0.0), (landing + half, 0.0), (landing + half, top)]
back = landing - (second - 1) * going
treads += [
Line((landing - k * going, half), (landing - k * going, 3.0 * half), "stair")
for k in range(second)
]
outline = [
Line((0.0, -half), (landing + width, -half), "stair"),
Line((landing + width, -half), (landing + width, 3.0 * half), "stair"),
Line((landing + width, 3.0 * half), (back, 3.0 * half), "stair"),
Line((min(0.0, back), half), (landing, half), "stair"),
]
path = [(0.0, 0.0), (landing + half, 0.0), (landing + half, width), (back, width)]
return treads, outline, path
def _mirror(p: Pt) -> Pt:
return (p[0], -p[1])
def stair_prims(stair: Stair, *, lang: str = "en", scale=50) -> tuple[Prim, ...]:
"""Treads, outline, walking line with its arrow, and the "up" label - in WCS, role "stair"."""
width, going, risers, factor = _validate(stair, scale)
label = vocab(lang)["up"]
treads, outline, path = _flights(stair.kind, width, going, risers)
if stair.turn == "right":
treads = [line._replace(p1=_mirror(line.p1), p2=_mirror(line.p2)) for line in treads]
outline = [line._replace(p1=_mirror(line.p1), p2=_mirror(line.p2)) for line in outline]
path = [_mirror(p) for p in path]
(px, py), (ex, ey) = path[-2], path[-1]
length = math.hypot(ex - px, ey - py)
d = ((ex - px) / length, (ey - py) / length)
p = (-d[1], d[0])
arrow = ARROW_PAPER_MM * factor
tail = (ex - d[0] * arrow, ey - d[1] * arrow)
head = [
Line((tail[0] + p[0] * arrow / 3.0, tail[1] + p[1] * arrow / 3.0), (ex, ey), "stair"),
Line((tail[0] - p[0] * arrow / 3.0, tail[1] - p[1] * arrow / 3.0), (ex, ey), "stair"),
]
height = TEXT_PAPER_MM * factor
text = Text((0.15 * going, -0.25 * width - 0.5 * height), label, height, 0.0, "stair")
local = (*treads, *outline, Poly(tuple(path), False, "stair"), *head, text)
turned = rotate(local, float(stair.direction_deg), (0.0, 0.0))
return translate(turned, float(stair.start[0]), float(stair.start[1]))
+668
View File
@@ -0,0 +1,668 @@
"""The wall engine: axis polylines in, clean wall outlines out.
A wall is an axis polyline with a thickness and a justification - the side of
the axis the wall lies on, looking along it from its first point. This module
offsets every axis segment into its two faces and resolves every place where
segments meet:
* **L and straight joins** - two segment ends at one point. Each face is met by
the face of its neighbour and both are trimmed or extended to their
intersection, so the corner is a clean mitre. A near-straight join whose
faces are offset from each other (a thickness or justification change, or a
rounding kink between two such walls) would mitre kilometres away; past
``_MITRE_REACH`` thicknesses it steps instead: both faces stop on the line
bisecting the join and a short face closes the step.
* **T** - a segment ends *inside* another wall. Its axis is carried to the
crossing axis, the crossing segment is split there, and the same rule stops
the stem's faces on the crossing wall's near face. That face is cut where
the stem meets it; the far face stays one line.
* **X** - two axes cross in their interiors. Both are split and all four
faces are cut.
All three are one algorithm: at every node the segment ends are taken as
outgoing directions and sorted by angle, and the left face of each end meets
the right face of the next one counter-clockwise. A node of three or more ends
leaves a *hub* - the square an X crosses in, the triangle a centred T leaves -
that no single segment covers; it is a region of its own, so the poche has no
hole in it.
An opening removes its width from both faces and each edge is closed by a jamb;
a free wall end is closed by a jamb too. Every solid piece - one axis segment's
stretch between junctions, openings and free ends, plus each hub - is one CCW
region and, with ``poche=True``, one ``HatchArea`` in the wall's material.
Refused by name before anything is computed: two walls with one id, an unknown
material or justification, a zero-length segment, two segments that meet at
less than ``MIN_JOIN_ANGLE_DEG`` (the mitre would run away), a wall corner that
lies inside another wall off its axis, an end inside a wall whose axis would
have to run past that wall's end to meet it, a segment that lies wholly
inside the wall it joins, and one too short for its junctions (its outline
would cross itself - `engineering.measure.is_self_intersecting`). An opening that cannot be cut because it runs into a
junction is returned in ``omitted``, never dropped.
"""
from __future__ import annotations
import math
from collections.abc import Sequence
from dataclasses import dataclass, field
from engineering.arch.materials import wall_hatch
from engineering.arch.model import Opening, Wall, segment_of, validate_openings
from engineering.measure import is_self_intersecting, polygon_area_perimeter
from engineering.mech.primitives import HatchArea, Line, Pt
#: The shallowest angle two wall axes may meet at. Below it the mitre point of
#: the two faces runs more than eleven wall thicknesses away from the node, so
#: the junction is refused rather than drawn as a spike. A drafting limit of
#: this engine, not a building rule.
MIN_JOIN_ANGLE_DEG = 5.0
#: How far from its node, in thicknesses of the thicker wall, the face mitre of
#: a near-straight join may lie. Two faces at equal offsets meet within 1.42
#: thicknesses of the node at any kink up to 90 degrees; faces at different
#: offsets (a thickness or justification change) meet at offset / sin(kink),
#: kilometres away for a rounding kink, so past this reach the join steps
#: instead of flaring.
_MITRE_REACH = 2.0
_EPS = 1e-9
#: Decimals a point is rounded to when face lines are matched end to end.
_KEY_DECIMALS = 6
@dataclass(frozen=True)
class WallGeometry:
faces: tuple[Line, ...]
jambs: tuple[Line, ...]
regions: tuple[tuple[Pt, ...], ...]
hatches: tuple[HatchArea, ...]
omitted: tuple[dict, ...]
#: Each opening's two face lines across its gap. Never drawn: they are what
#: `room_segments` adds so a doorway does not merge two rooms into one face.
thresholds: tuple[Line, ...] = ()
# -- vectors ------------------------------------------------------------------
def _sub(p: Pt, q: Pt) -> Pt:
return (p[0] - q[0], p[1] - q[1])
def _add(p: Pt, q: Pt) -> Pt:
return (p[0] + q[0], p[1] + q[1])
def _mul(v: Pt, k: float) -> Pt:
return (v[0] * k, v[1] * k)
def _dot(u: Pt, v: Pt) -> float:
return u[0] * v[0] + u[1] * v[1]
def _cross(u: Pt, v: Pt) -> float:
return u[0] * v[1] - u[1] * v[0]
def _left(u: Pt) -> Pt:
return (-u[1], u[0])
def _unit(p: Pt, q: Pt) -> Pt:
length = math.dist(p, q)
return ((q[0] - p[0]) / length, (q[1] - p[1]) / length)
def _meet(p: Pt, u: Pt, q: Pt, v: Pt) -> Pt:
"""Where the line through ``p`` along ``u`` meets the line through ``q`` along ``v``."""
t = _cross(_sub(q, p), v) / _cross(u, v)
return _add(p, _mul(u, t))
def _heading(u: Pt) -> float:
return math.degrees(math.atan2(u[1], u[0])) % 360.0
def _line_angle(u: Pt, v: Pt) -> float:
"""The angle between two lines (not directions), 0-90 degrees."""
return math.degrees(math.asin(min(1.0, abs(_cross(u, v)))))
def _polygon(points: Sequence[Pt]) -> tuple[Pt, ...] | None:
"""Consecutive duplicates removed; None if nothing is enclosed.
Every caller builds its ring counter-clockwise - a piece as right face out,
left face back; a hub in the angular order of its corners - so the ring is
not turned here; a ring that comes out clockwise is inside out, and the
callers refuse it (`_counter_clockwise`). The area is
`engineering.measure`'s, not a second shoelace.
"""
kept: list[Pt] = []
for p in points:
if not kept or math.dist(kept[-1], p) > _EPS:
kept.append(p)
if len(kept) > 1 and math.dist(kept[0], kept[-1]) <= _EPS:
kept.pop()
if len(kept) < 3:
return None
area, _perimeter = polygon_area_perimeter([(x, y, 0.0) for x, y in kept], True)
return tuple(kept) if area > _EPS else None
def _counter_clockwise(ring: Sequence[Pt]) -> bool:
"""The ring's turning sense - the sign `polygon_area_perimeter` folds away."""
twice = sum(_cross(p, q) for p, q in zip(ring, (*ring[1:], ring[0]), strict=True))
return twice > 0.0
def _key(p: Pt) -> tuple[float, float]:
return (round(p[0], _KEY_DECIMALS) + 0.0, round(p[1], _KEY_DECIMALS) + 0.0)
# -- the wall's own numbers -----------------------------------------------------
def face_offsets(wall: Wall) -> tuple[float, float]:
"""``(left, right)``: the signed offsets of a wall's two faces along the axis's left normal."""
thickness = float(wall.thickness)
if wall.justification == "center":
return (thickness / 2.0, -thickness / 2.0)
if wall.justification == "left":
return (thickness, 0.0)
if wall.justification == "right":
return (0.0, -thickness)
raise ValueError(
f"wall {wall.id!r}: justification {wall.justification!r}; "
"justifications are center, left, right"
)
def wall_segments(wall: Wall) -> tuple[tuple[Pt, Pt], ...]:
"""The wall's axis segments in order, the closing one last when ``closed``."""
points = [(float(x), float(y)) for x, y in wall.axis]
pairs = list(zip(points, points[1:], strict=False))
if wall.closed:
pairs.append((points[-1], points[0]))
return tuple(pairs)
# -- the network ----------------------------------------------------------------
@dataclass
class _Seg:
wall: int
index: int
va: tuple[int, int]
vb: tuple[int, int]
left: float
right: float
#: Where other axes stop on or cross this one, as points, never as distances:
#: a distance is measured from the segment's start, and that start may still
#: be carried onto a third wall later in the same pass.
cuts: list[Pt] = field(default_factory=list)
@dataclass
class _Piece:
seg: _Seg
a: Pt
b: Pt
s0: float
corners: dict = field(default_factory=dict) # (end 0|1, "left"|"right") -> Pt
def _validate(walls: Sequence[Wall], openings: Sequence[Opening], join_tol: float) -> None:
if not isinstance(join_tol, (int, float)) or not math.isfinite(join_tol) or join_tol <= 0:
raise ValueError(f"join_tol: must be a finite number above zero, got {join_tol!r}")
seen: dict[str, int] = {}
for i, wall in enumerate(walls):
if not isinstance(wall, Wall):
raise ValueError(f"walls[{i}]: expected a Wall, got {type(wall).__name__}")
if wall.id in seen:
raise ValueError(
f"walls[{i}].id: {wall.id!r} is already used by walls[{seen[wall.id]}]"
)
seen[wall.id] = i
try:
wall_hatch(wall.material)
except ValueError as exc:
# wall_hatch's message already names its field; the path replaces it
reason = str(exc).removeprefix("material: ")
raise ValueError(f"walls[{i}].material: {reason}") from None
try:
face_offsets(wall)
except ValueError as exc:
raise ValueError(f"walls[{i}].justification: {exc}") from None
segments = wall_segments(wall)
if not segments:
raise ValueError(f"walls[{i}].axis: needs at least two points")
for k, (a, b) in enumerate(segments):
if math.dist(a, b) <= _EPS:
raise ValueError(f"walls[{i}].axis[{k}]: zero-length segment at {a}")
validate_openings(list(walls), list(openings))
def _segments(walls: Sequence[Wall]) -> tuple[dict, list[_Seg]]:
pos: dict[tuple[int, int], Pt] = {}
segs: list[_Seg] = []
for wi, wall in enumerate(walls):
left, right = face_offsets(wall)
count = len(wall.axis)
for k, (x, y) in enumerate(wall.axis):
pos[(wi, k)] = (float(x), float(y))
for i in range(count if wall.closed else count - 1):
segs.append(_Seg(wi, i, (wi, i), (wi, (i + 1) % count), left, right))
return pos, segs
def _name(walls: Sequence[Wall], index: int) -> str:
return f"walls[{index}] ({walls[index].id!r})"
def _refuse_shallow(walls, one: _Seg, other: _Seg, u: Pt, v: Pt) -> None:
angle = _line_angle(u, v)
if angle < MIN_JOIN_ANGLE_DEG:
raise ValueError(
f"{_name(walls, one.wall)} meets {_name(walls, other.wall)} at {angle:.1f}°; "
f"a junction below {MIN_JOIN_ANGLE_DEG:g}° cannot be resolved into a clean "
"outline - end the wall at a steeper angle."
)
def _resolve_tees(walls, pos, segs, tol: float) -> None:
"""Carry every axis end that stops inside another wall onto that wall's axis, and split it."""
touching: dict[tuple[int, int], list[_Seg]] = {}
for seg in segs:
touching.setdefault(seg.va, []).append(seg)
touching.setdefault(seg.vb, []).append(seg)
for vertex, mine in touching.items():
for other in segs:
if any(other is seg for seg in mine):
continue
p = pos[vertex]
a, b = pos[other.va], pos[other.vb]
if math.dist(p, a) <= tol or math.dist(p, b) <= tol:
continue
length = math.dist(a, b)
u = _unit(a, b)
s = _dot(_sub(p, a), u)
d = _dot(_sub(p, a), _left(u))
if s < -tol or s > length + tol or d < other.right - tol or d > other.left + tol:
continue
for seg in mine:
_refuse_shallow(walls, seg, other, _unit(pos[seg.va], pos[seg.vb]), u)
if abs(d) <= tol:
x = _add(a, _mul(u, s))
elif len(mine) == 1:
seg = mine[0]
far = pos[seg.vb] if seg.va == vertex else pos[seg.va]
x = _meet(far, _unit(far, p), a, u)
else:
raise ValueError(
f"the corner of {_name(walls, vertex[0])} at ({p[0]:g}, {p[1]:g}) lies inside "
f"{_name(walls, other.wall)} off its axis; end the wall there or move the corner "
"onto the other wall's axis."
)
sx = _dot(_sub(x, a), u)
if sx < -tol or sx > length + tol:
raise ValueError(
f"{_name(walls, vertex[0])} ends inside {_name(walls, other.wall)} at "
f"({p[0]:g}, {p[1]:g}), but its axis meets that wall's axis beyond its end; "
"join the two axes at a common point."
)
if sx <= tol:
pos[vertex] = a
elif sx >= length - tol:
pos[vertex] = b
else:
pos[vertex] = x
other.cuts.append(x)
break
def _resolve_crossings(walls, pos, segs, tol: float) -> None:
"""Split both segments wherever two axes cross in their interiors (an X)."""
for i, one in enumerate(segs):
for other in segs[i + 1 :]:
if {one.va, one.vb} & {other.va, other.vb}:
continue
a1, b1 = pos[one.va], pos[one.vb]
a2, b2 = pos[other.va], pos[other.vb]
r = _sub(b1, a1)
q = _sub(b2, a2)
denom = _cross(r, q)
if abs(denom) <= _EPS:
continue
t = _cross(_sub(a2, a1), q) / denom
w = _cross(_sub(a2, a1), r) / denom
l1 = math.hypot(*r)
l2 = math.hypot(*q)
if tol < t * l1 < l1 - tol and tol < w * l2 < l2 - tol:
_refuse_shallow(walls, one, other, _unit(a1, b1), _unit(a2, b2))
crossing = _add(a1, _mul(r, t))
one.cuts.append(crossing)
other.cuts.append(crossing)
def _pieces(walls, pos, segs, tol: float) -> list[_Piece]:
pieces: list[_Piece] = []
for seg in segs:
a, b = pos[seg.va], pos[seg.vb]
length = math.dist(a, b)
if length <= tol:
raise ValueError(
f"{_name(walls, seg.wall)}: segment {seg.index} lies inside the wall it joins - "
"nothing of it stands clear of that wall; lengthen it or leave it out."
)
u = _unit(a, b)
stops: list[float] = []
for cut in sorted(_dot(_sub(point, a), u) for point in seg.cuts):
if tol < cut < length - tol and (not stops or cut - stops[-1] > tol):
stops.append(cut)
bounds = [0.0, *stops, length]
for s0, s1 in zip(bounds, bounds[1:], strict=False):
start = a if s0 == 0.0 else _add(a, _mul(u, s0))
end = b if s1 == length else _add(a, _mul(u, s1))
pieces.append(_Piece(seg, start, end, s0))
return pieces
def _outgoing(piece: _Piece, end: int):
"""(origin, direction, left normal, left offset, right offset) leaving the node at ``end``."""
u = _unit(piece.a, piece.b)
if end == 0:
return piece.a, u, _left(u), piece.seg.left, piece.seg.right
back = (-u[0], -u[1])
return piece.b, back, _left(back), -piece.seg.right, -piece.seg.left
def _canonical(end: int, side: str) -> str:
"""Leaving through its end, a piece's left face is its canonical right one."""
if end == 0:
return side
return "right" if side == "left" else "left"
def _thickness(piece: _Piece) -> float:
return piece.seg.left - piece.seg.right
def _build(walls: Sequence[Wall], openings: Sequence[Opening], join_tol: float) -> dict:
walls = list(walls)
openings = list(openings)
_validate(walls, openings, join_tol)
pos, segs = _segments(walls)
_resolve_tees(walls, pos, segs, join_tol)
_resolve_crossings(walls, pos, segs, join_tol)
pieces = _pieces(walls, pos, segs, join_tol)
faces: list[tuple[Line, str]] = []
jambs: list[tuple[Line, str]] = []
thresholds: list[tuple[Line, str]] = []
regions: list[tuple[tuple[Pt, ...], str]] = []
omitted: list[tuple[dict, str]] = []
# -- the nodes ------------------------------------------------------------
nodes: list[Pt] = []
ends: list[list[tuple[_Piece, int]]] = []
for piece in pieces:
for end, point in ((0, piece.a), (1, piece.b)):
for k, node in enumerate(nodes):
if math.dist(point, node) <= join_tol:
ends[k].append((piece, end))
break
else:
nodes.append(point)
ends.append([(piece, end)])
for node, members in zip(nodes, ends, strict=True):
if len(members) == 1:
piece, end = members[0]
origin, _u, n, left, right = _outgoing(piece, end)
pl = _add(origin, _mul(n, left))
pr = _add(origin, _mul(n, right))
piece.corners[(end, _canonical(end, "left"))] = pl
piece.corners[(end, _canonical(end, "right"))] = pr
jambs.append((Line(pl, pr, "wall"), walls[piece.seg.wall].id))
continue
ordered = sorted(members, key=lambda m: _heading(_outgoing(*m)[1]))
pairs: list[list] = []
for idx, (pi, ei) in enumerate(ordered):
pj, ej = ordered[(idx + 1) % len(ordered)]
oi, ui, ni, li, _ri = _outgoing(pi, ei)
oj, uj, nj, _lj, rj = _outgoing(pj, ej)
gap = (_heading(uj) - _heading(ui)) % 360.0
if gap < MIN_JOIN_ANGLE_DEG:
raise ValueError(
f"{_name(walls, pi.seg.wall)} and {_name(walls, pj.seg.wall)} meet at "
f"({node[0]:g}, {node[1]:g}) {gap:.1f}° apart; a junction below "
f"{MIN_JOIN_ANGLE_DEG:g}° cannot be resolved into a clean outline."
)
face_i = _add(oi, _mul(ni, li))
face_j = _add(oj, _mul(nj, rj))
mitre = None
if abs(_cross(ui, uj)) > 1e-12:
mitre = _meet(face_i, ui, face_j, uj)
reach = _MITRE_REACH * max(_thickness(pi), _thickness(pj))
if _dot(ui, uj) < 0.0 and math.dist(mitre, node) > reach:
mitre = None # a near-straight join whose faces are offset: a step
pairs.append([pi, ei, pj, ej, face_i, ui, face_j, uj, mitre])
if len(pairs) == 2 and any(pair[8] is None for pair in pairs):
# both sides of one straight-through join step on the same cut line,
# or the two pieces would leave a sliver between their ends
for pair in pairs:
pair[8] = None
hub: list[Pt] = []
for pi, ei, pj, ej, face_i, ui, face_j, uj, mitre in pairs:
if mitre is not None:
ci = cj = mitre
else:
# the step: both faces stop on the line through the node that
# bisects the join - square across a straight join, and the line
# every equal-offset mitre of this node lies on anyway
cut = _left(_sub(ui, uj))
ci = _meet(face_i, ui, node, cut)
cj = _meet(face_j, uj, node, cut)
if math.dist(ci, cj) <= _EPS:
cj = ci
else:
faces.append((Line(ci, cj, "wall"), walls[pi.seg.wall].id))
pi.corners[(ei, _canonical(ei, "left"))] = ci
pj.corners[(ej, _canonical(ej, "right"))] = cj
hub.append(ci)
if cj is not ci:
hub.append(cj)
if len(ordered) >= 3:
polygon = _polygon(hub)
if polygon and not _counter_clockwise(polygon):
raise ValueError(
f"the junction at ({node[0]:g}, {node[1]:g}) cannot be resolved into a "
"clean outline - its middle turns inside out; move the walls' ends apart."
)
if polygon:
owner = min(piece.seg.wall for piece, _end in ordered)
regions.append((polygon, walls[owner].id))
# -- the openings -----------------------------------------------------------
by_id = {wall.id: i for i, wall in enumerate(walls)}
by_seg = {(seg.wall, seg.index): seg for seg in segs}
cut_in: dict[int, list[tuple[float, float]]] = {}
for opening in openings:
wi = by_id[opening.wall]
wall = walls[wi]
si, along = segment_of(wall, float(opening.offset))
a0, b0 = wall_segments(wall)[si]
start = _add(a0, _mul(_unit(a0, b0), along))
seg = by_seg[(wi, si)]
u = _unit(pos[seg.va], pos[seg.vb])
s0 = _dot(_sub(start, pos[seg.va]), u)
s1 = s0 + float(opening.width)
host = None
for piece in pieces:
length = math.dist(piece.a, piece.b)
if piece.seg is seg and piece.s0 - _EPS <= s0 and s1 <= piece.s0 + length + _EPS:
host = piece
break
if host is None:
omitted.append(
(
{
"element": opening.id,
"reason": f"it crosses a junction on wall {wall.id!r} segment {si}; "
"an opening must lie inside one stretch of wall",
},
wall.id,
)
)
continue
d0 = s0 - host.s0
d1 = s1 - host.s0
clear_from = max(
_dot(_sub(host.corners[(0, side)], host.a), u) for side in ("left", "right")
)
clear_to = min(_dot(_sub(host.corners[(1, side)], host.a), u) for side in ("left", "right"))
if d0 < clear_from - _EPS or d1 > clear_to + _EPS:
omitted.append(
(
{
"element": opening.id,
"reason": f"it runs into a junction of wall {wall.id!r}: on segment {si} "
f"the solid wall runs from {host.s0 + clear_from:g} to "
f"{host.s0 + clear_to:g} mm along the axis, the opening from "
f"{s0:g} to {s1:g}",
},
wall.id,
)
)
continue
cut_in.setdefault(id(host), []).append((d0, d1))
# -- faces, jambs and regions, piece by piece ---------------------------------
for piece in pieces:
seg = piece.seg
owner = walls[seg.wall].id
u = _unit(piece.a, piece.b)
n = _left(u)
def at(s: float, offset: float, piece=piece, u=u, n=n) -> Pt:
return _add(_add(piece.a, _mul(u, s)), _mul(n, offset))
cur_l = piece.corners[(0, "left")]
cur_r = piece.corners[(0, "right")]
runs: list[tuple[Pt, Pt, Pt, Pt]] = []
for d0, d1 in sorted(cut_in.get(id(piece), [])):
la, ra = at(d0, seg.left), at(d0, seg.right)
lb, rb = at(d1, seg.left), at(d1, seg.right)
runs.append((cur_l, cur_r, la, ra))
jambs.append((Line(la, ra, "wall"), owner))
jambs.append((Line(lb, rb, "wall"), owner))
thresholds.append((Line(la, lb, "wall"), owner))
thresholds.append((Line(ra, rb, "wall"), owner))
cur_l, cur_r = lb, rb
runs.append((cur_l, cur_r, piece.corners[(1, "left")], piece.corners[(1, "right")]))
for sl, sr, el, er in runs:
if math.dist(sl, el) > _EPS:
faces.append((Line(sl, el, "wall"), owner))
if math.dist(sr, er) > _EPS:
faces.append((Line(sr, er, "wall"), owner))
polygon = _polygon((sr, er, el, sl))
if polygon and (is_self_intersecting(polygon) or not _counter_clockwise(polygon)):
raise ValueError(
f"{_name(walls, seg.wall)}: segment {seg.index} is too short for the "
"junctions at its ends - its outline would cross itself; lengthen it "
"or join it elsewhere."
)
if polygon:
regions.append((polygon, owner))
return {
"walls": walls,
"faces": _merge_collinear(faces, jambs),
"jambs": jambs,
"thresholds": thresholds,
"regions": regions,
"omitted": omitted,
}
def _merge_collinear(faces, jambs):
"""Join two faces of one wall that continue each other in a straight line.
That is what keeps a crossing wall's far face one line past a T, and a
straight axis with an intermediate vertex one face. A point touched by a
third line (a jamb, another face) is a real corner and is left alone.
"""
items = list(faces)
while True:
touching: dict[tuple[float, float], list[int]] = {}
for idx, (line, _owner) in enumerate(items):
for p in (line.p1, line.p2):
touching.setdefault(_key(p), []).append(idx)
for line, _owner in jambs:
for p in (line.p1, line.p2):
touching.setdefault(_key(p), []).append(-1)
for key, idxs in touching.items():
if len(idxs) != 2 or -1 in idxs or idxs[0] == idxs[1]:
continue
i, j = sorted(idxs)
(li, wi), (lj, wj) = items[i], items[j]
if wi != wj:
continue
near_i, far_i = (li.p2, li.p1) if _key(li.p2) == key else (li.p1, li.p2)
far_j = lj.p2 if _key(lj.p1) == key else lj.p1
d1 = _unit(far_i, near_i)
d2 = _unit(near_i, far_j)
if abs(_cross(d1, d2)) > 1e-9 or _dot(d1, d2) <= 0.0:
continue
items[i] = (Line(far_i, far_j, "wall"), wi)
del items[j]
break
else:
return items
def _assemble(parts: dict, poche: bool, owner: str | None) -> WallGeometry:
def mine(rows):
return [item for item, who in rows if owner is None or who == owner]
material = {wall.id: wall.material for wall in parts["walls"]}
regions = [(polygon, who) for polygon, who in parts["regions"] if owner in (None, who)]
hatches = (
tuple(HatchArea((polygon,), material[who]) for polygon, who in regions) if poche else ()
)
return WallGeometry(
faces=tuple(mine(parts["faces"])),
jambs=tuple(mine(parts["jambs"])),
regions=tuple(polygon for polygon, _who in regions),
hatches=hatches,
omitted=tuple(mine(parts["omitted"])),
thresholds=tuple(mine(parts["thresholds"])),
)
def wall_geometry(walls, openings=(), *, poche=True, join_tol=1.0) -> WallGeometry:
"""The whole network's outlines, jambs, regions and poche - see the module docstring."""
return _assemble(_build(walls, openings, join_tol), poche, None)
def wall_geometry_by_wall(
walls, openings=(), *, poche=True, join_tol=1.0
) -> dict[str, WallGeometry]:
"""The same geometry, split by the wall each element belongs to.
A hub (the middle of a T or an X) belongs to the first of its walls in the
order given. `engineering.arch.draw` uses the split to redraw only the walls
whose outline a new wall or opening changes.
"""
parts = _build(walls, openings, join_tol)
return {wall.id: _assemble(parts, poche, wall.id) for wall in parts["walls"]}
def room_segments(geometry: WallGeometry) -> tuple[tuple[Pt, Pt], ...]:
"""Faces, jambs and thresholds as bare segments - the input of the planar-face finder."""
return tuple(
(line.p1, line.p2) for line in (*geometry.faces, *geometry.jambs, *geometry.thresholds)
)
+291
View File
@@ -691,6 +691,7 @@ async def _registered_tool_count() -> int | None:
_GROUP_TAG_PRIORITY = (
"pid",
"mech",
"arch",
"sheet",
"style",
"pagesetup",
@@ -721,6 +722,7 @@ _GROUP_TAG_PRIORITY = (
_GROUP_TAG_LABELS = {
"pid": "pid",
"mech": "mechanical",
"arch": "architecture",
"sheet": "sheet",
"style": "styles",
"pagesetup": "page_setup",
@@ -9780,6 +9782,295 @@ async def drawing_export_dwg(
return await _backend(ctx).drawing_export_dwg(str(validated), version)
# ---------------------------------------------------------------------------
# ── SECTION 25: Architecture (4 tools) ──────────────────────────────────────
# ---------------------------------------------------------------------------
@cad_tool(
summary="Add walls to the plan: clean L/T/X junctions, cut openings, material poché.",
cost="mutate",
)
@mcp.tool(
annotations={"title": "Architecture: Wall", "destructiveHint": False},
tags={"arch", "create"},
)
async def arch_wall(
walls: Annotated[
list[dict],
Field(
description=(
"Walls to add: [{id, axis:[[x,y],...] (2+ points, straight segments, WCS mm), "
"thickness, justification:'center'|'left'|'right' (side of the axis looking "
"along it), material:'brick'|'aac'|'concrete'|'reinforced_concrete'|"
"'gypsum_board'|'stone'|'timber'|'generic', closed:bool}]"
)
),
],
poche: Annotated[
bool,
Field(default=True, description="Hatch the cut wall faces in their material (ISO 128-50)"),
] = True,
ctx: Context = None,
) -> dict:
"""Add walls to the plan network already on the drawing and resolve every junction.
Two ends at one point mitre into a clean L; an end inside another wall is a
T (the stem stops on the crossing wall's near face, which is cut there); two
axes crossing are an X (all four faces cut). A drawn wall whose outline the
new walls change is taken back out and redrawn with its openings - the
result lists it under `redrawn` - and a wall they do not touch is left alone.
Every wall carries an `ACADMCP_ARCH` record, so later calls find it by id.
Refused before anything is drawn or erased, by path: a wall id already on
the drawing or used twice, an axis of fewer than two points or with a
zero-length segment, a non-positive thickness, an unknown justification or
material (the list is named), two walls meeting at less than 5° (the mitre
would run away), a wall corner lying inside another wall off its axis, an
end inside a wall whose axis only meets it past that wall's end, and a new
wall that would leave a drawn opening uncuttable. A curved wall is not
supported: an axis is straight segments only.
"""
from engineering.arch.draw import add_walls
await ctx.info(f"arch_wall: {len(walls)} wall(s)")
try:
return await add_walls(_backend(ctx), walls, poche=poche)
except ValueError as exc:
raise ToolError(str(exc)) from exc
@cad_tool(
summary="Host a door or a window in a drawn wall by distance along it; the wall is cut.",
cost="mutate",
)
@mcp.tool(
annotations={"title": "Architecture: Door / Window", "destructiveHint": False},
tags={"arch", "create"},
)
async def arch_opening(
wall: Annotated[str, Field(description="Id of the drawn wall that hosts the opening")],
offset: Annotated[
float,
Field(
description="Distance along the wall's axis from its first point to the near jamb (mm)"
),
],
width: Annotated[float, Field(description="Clear width of the opening (mm)")],
kind: Annotated[str, Field(default="door", description="door | window")] = "door",
swing: Annotated[
str,
Field(
default="in",
description="Doors: in = to the left of the axis (inside a CCW perimeter) | out",
),
] = "in",
hand: Annotated[
str,
Field(
default="left",
description="Doors: the hinge jamb, seen from the swing side facing the wall: left | right",
),
] = "left",
sill: Annotated[
float | None, Field(default=None, description="Windows: sill height (schedule only, mm)")
] = None,
height: Annotated[
float | None, Field(default=None, description="Opening height (schedule only, mm)")
] = None,
tag: Annotated[
str | None,
Field(default=None, description="Schedule tag; default the next D1/W1 (en) or K1/P1 (tr)"),
] = None,
opening_id: Annotated[
str | None, Field(default=None, description="Opening id; default its tag")
] = None,
lang: Annotated[str, Field(default="en", description="Tag language: en | tr")] = "en",
poche: Annotated[
bool, Field(default=True, description="Hatch the redrawn wall's cut faces")
] = True,
ctx: Context = None,
) -> dict:
"""A door or a window in a wall already drawn, found by its id; the wall is redrawn cut.
The opening removes its width from both wall faces and each edge is closed
by a jamb. A door is drawn open at 90° - its leaf as long as the opening is
wide, hinged on `hand`, with a quarter-arc swing on the `swing` side; a
window is a frame line on each face and two glazing lines between them. The
opening's `ACADMCP_ARCH` record sits on the leaf or the frame, so a schedule
reads it back.
Refused before anything is drawn or erased: a wall id not on the drawing
(the drawn ids are named), an opening id already drawn, an opening that
runs past its wall segment or straddles an axis vertex, one overlapping
another opening on the same wall (both ids named), one that runs into a
junction (the solid stretch it must fit in is named), an unknown kind,
swing, hand or language.
"""
from engineering.arch.draw import add_opening
spec = {
"id": opening_id,
"wall": wall,
"kind": kind,
"offset": offset,
"width": width,
"swing": swing,
"hand": hand,
"sill": sill,
"height": height,
"tag": tag,
}
await ctx.info(f"arch_opening: {kind} in wall {wall}")
try:
return await add_opening(
_backend(ctx), {k: v for k, v in spec.items() if v is not None}, lang=lang, poche=poche
)
except ValueError as exc:
raise ToolError(str(exc)) from exc
@cad_tool(
summary="Draw a straight, L or U stair in plan and report its Blondel step rule.",
cost="mutate",
)
@mcp.tool(
annotations={"title": "Architecture: Stair", "destructiveHint": False},
tags={"arch", "create"},
)
async def arch_stair(
stair_id: Annotated[str, Field(description="Stair id, unique on the drawing")],
start_x: Annotated[float, Field(description="WCS X of the midpoint of the bottom riser")],
start_y: Annotated[float, Field(description="WCS Y of the midpoint of the bottom riser")],
width: Annotated[float, Field(description="Flight width (mm)")],
risers: Annotated[int, Field(description="Number of risers from floor to floor")],
riser_height: Annotated[float, Field(description="Riser height R (mm)")],
going: Annotated[float, Field(description="Tread going G (mm)")],
direction_deg: Annotated[
float, Field(default=0.0, description="Direction up the first flight, degrees CCW from +X")
] = 0.0,
kind: Annotated[str, Field(default="straight", description="straight | l | u")] = "straight",
turn: Annotated[
str, Field(default="left", description="L / U stairs: which way the second flight turns")
] = "left",
lang: Annotated[str, Field(default="en", description="Label language: en | tr")] = "en",
scale: Annotated[
float,
Field(
default=50.0, description="Plot-scale denominator for the label and arrow (50 = 1:50)"
),
] = 50.0,
ctx: Context = None,
) -> dict:
"""A stair in plan: every riser as a line, the outline, the walking line with its arrow.
The label ("UP" / "ÇIKIŞ") sits on the first tread at 2.5 mm × `scale`. The
result reports `blondel` - 2R + G against 600-650 mm, the step-length rule
attributed to François Blondel (1675). It is a design rule reported for the
drafter, not a building code: no national stair regulation is checked and
a value outside the band is reported (`ok: false`), not refused.
Refused before anything is drawn: a stair id already on the drawing, a
non-positive width, riser height or going, fewer than 2 risers (4 for an L
or a U - two per flight), a non-integer riser count, an unknown kind, turn
or language.
"""
from engineering.arch.draw import add_stair
spec = {
"id": stair_id,
"start": [start_x, start_y],
"direction_deg": direction_deg,
"width": width,
"risers": risers,
"riser_height": riser_height,
"going": going,
"kind": kind,
"turn": turn,
}
await ctx.info(f"arch_stair: {kind} stair {stair_id}")
try:
return await add_stair(_backend(ctx), spec, lang=lang, scale=scale)
except ValueError as exc:
raise ToolError(str(exc)) from exc
@cad_tool(
summary="Dimension the plan's facades: openings, walls and overall chains outside each side.",
cost="mutate",
)
@mcp.tool(
annotations={"title": "Architecture: Dimension Chains", "destructiveHint": False},
tags={"arch", "dimension"},
)
async def arch_dimension_chains(
sides: Annotated[
list[str] | None,
Field(
default=None,
description="Sides to dimension: bottom | top | left | right (default bottom, left)",
),
] = None,
scale: Annotated[
float,
Field(
default=50.0,
description="Plot-scale denominator: rows sit 10 mm and 8 mm apart on paper",
),
] = 50.0,
first_offset: Annotated[
float | None,
Field(
default=None, description="Distance of the first row from the building (drawing units)"
),
] = None,
step: Annotated[
float | None, Field(default=None, description="Distance between rows (drawing units)")
] = None,
ctx: Context = None,
) -> dict:
"""The three exterior chains an architectural plan carries, as real DIMENSIONs.
Nearest first: the openings row (every jamb in that facade), the walls row
(both faces of every wall meeting it - wall thickness, clear width, wall
thickness, ...) and the overall row. The points come from the wall engine's
own faces, read back from the plan on the drawing. A row that would only
restate a farther one is not drawn (ISO 129-1: each measurement once).
The row spacing defaults (10 mm, then 8 mm on paper) are this server's
drafting choices, not standard values. Text height follows the current
dimension style: at 1:50 set DIMSCALE=50 with `dimstyle_modify`.
Refused by name: a drawing with no walls, an unknown or repeated side, a
non-positive scale, offset or step.
"""
from engineering.arch.draw import draw_chains, read_plan
backend = _backend(ctx)
try:
plan = await read_plan(backend)
if not plan["walls"]:
raise ValueError(
"this drawing carries no architectural walls; draw them with arch_wall"
)
await ctx.info(f"arch_dimension_chains: {len(plan['walls'])} wall(s)")
return await draw_chains(
backend,
plan["walls"],
plan["openings"],
sides=tuple(sides or ("bottom", "left")),
scale=scale,
first_offset=first_offset,
step=step,
)
except ValueError as exc:
raise ToolError(str(exc)) from exc
# ── arch: rooms (group R) ──
# ── arch: catalogue (group C) ──
# ---------------------------------------------------------------------------
# ── RESOURCES ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
+107
View File
@@ -0,0 +1,107 @@
"""Exterior dimension chains: three rows per side, from the wall engine's own faces.
The plan: a closed 8200 x 6200 axis rectangle of 200 mm walls (outer faces
-100..8300 x -100..6300), a 100 mm partition on x = 4100 teed into the bottom
and top walls (faces x = 4050 / 4150), a 1200 window at 1500 and a 1000 door
at 5500 in the bottom wall. Every point below is hand-computed from those
numbers; at 1:50 the rows sit 500, 900 and 1300 mm outside the building.
"""
from __future__ import annotations
import pytest
from engineering.arch.dimension import (
FIRST_OFFSET_PAPER_MM,
ROWS,
SIDES,
STEP_PAPER_MM,
exterior_chains,
)
from engineering.arch.model import Opening, Wall
from engineering.mech.primitives import DimIntent
SHELL = Wall(
id="shell",
axis=((0.0, 0.0), (8200.0, 0.0), (8200.0, 6200.0), (0.0, 6200.0)),
thickness=200.0,
closed=True,
)
PARTITION = Wall(id="p", axis=((4100.0, 0.0), (4100.0, 6200.0)), thickness=100.0)
WINDOW = Opening(id="w1", wall="shell", kind="window", offset=1500.0, width=1200.0)
DOOR = Opening(id="d1", wall="shell", kind="door", offset=5500.0, width=1000.0)
def row(intents, feature) -> list[tuple]:
out = []
for intent in intents:
if intent.feature == feature:
out.append(
(
tuple(round(v, 9) + 0.0 for v in intent.p1),
tuple(round(v, 9) + 0.0 for v in intent.p2),
tuple(round(v, 9) + 0.0 for v in intent.text_at),
)
)
return out
def test_the_rows_and_the_offsets_are_declared():
assert SIDES == ("bottom", "top", "left", "right")
assert ROWS == ("openings", "walls", "overall")
assert (FIRST_OFFSET_PAPER_MM, STEP_PAPER_MM) == (10.0, 8.0)
def test_the_bottom_side_carries_openings_walls_and_overall():
intents = exterior_chains([SHELL, PARTITION], [WINDOW, DOOR], sides=("bottom",), scale=50)
assert all(isinstance(i, DimIntent) and i.kind == "linear" for i in intents)
assert row(intents, "bottom:openings") == [
((-100.0, -100.0), (1500.0, -100.0), (700.0, -600.0)),
((1500.0, -100.0), (2700.0, -100.0), (2100.0, -600.0)),
((2700.0, -100.0), (5500.0, -100.0), (4100.0, -600.0)),
((5500.0, -100.0), (6500.0, -100.0), (6000.0, -600.0)),
((6500.0, -100.0), (8300.0, -100.0), (7400.0, -600.0)),
]
assert row(intents, "bottom:walls") == [
((-100.0, -100.0), (100.0, -100.0), (0.0, -1000.0)),
((100.0, -100.0), (4050.0, -100.0), (2075.0, -1000.0)),
((4050.0, -100.0), (4150.0, -100.0), (4100.0, -1000.0)),
((4150.0, -100.0), (8100.0, -100.0), (6125.0, -1000.0)),
((8100.0, -100.0), (8300.0, -100.0), (8200.0, -1000.0)),
]
assert row(intents, "bottom:overall") == [
((-100.0, -100.0), (8300.0, -100.0), (4100.0, -1400.0)),
]
assert len(intents) == 11
def test_a_row_that_repeats_a_farther_one_is_not_emitted():
intents = exterior_chains([SHELL, PARTITION], [WINDOW, DOOR], sides=("left",), scale=50)
# no opening in the left wall: that row would only restate the overall one
assert row(intents, "left:openings") == []
assert row(intents, "left:walls") == [
((-100.0, -100.0), (-100.0, 100.0), (-1000.0, 0.0)),
((-100.0, 100.0), (-100.0, 6100.0), (-1000.0, 3100.0)),
((-100.0, 6100.0), (-100.0, 6300.0), (-1000.0, 6200.0)),
]
assert row(intents, "left:overall") == [((-100.0, -100.0), (-100.0, 6300.0), (-1400.0, 3100.0))]
def test_explicit_offsets_override_the_plot_scale_defaults():
intents = exterior_chains(
[SHELL], [], sides=("top",), first_offset=300.0, step=250.0, scale=100
)
# the top: the extremes only, so the walls row is the corner walls' faces
assert row(intents, "top:overall") == [((-100.0, 6300.0), (8300.0, 6300.0), (4100.0, 7100.0))]
assert {i.text_at[1] for i in intents if i.feature == "top:walls"} == {6850.0}
def test_bad_sides_and_scales_are_refused_by_name():
with pytest.raises(ValueError, match=r"sides\[0\]: 'north'"):
exterior_chains([SHELL], [], sides=("north",))
with pytest.raises(ValueError, match="named twice"):
exterior_chains([SHELL], [], sides=("left", "left"))
with pytest.raises(ValueError, match="scale"):
exterior_chains([SHELL], [], scale=0)
with pytest.raises(ValueError, match="sides"):
exterior_chains([SHELL], [], sides="bottom")
+207
View File
@@ -0,0 +1,207 @@
"""The async layer: walls, openings, stairs and chains drawn through the ezdxf backend.
The same code runs on the live engine: `engineering/arch/draw.py` composes only
generic contract members (entity_create_*, dimension_linear, entity_set_xdata,
entity_list, entity_delete), like the mechanical track's draw layer.
"""
from __future__ import annotations
import pytest
from engineering.arch.draw import (
HANDLES_APP,
add_opening,
add_stair,
add_walls,
assign_tags,
draw_chains,
draw_stair,
draw_walls,
read_plan,
)
from engineering.arch.layers import ARCH_ROLE_LAYER
from engineering.arch.model import APP_ID, Opening, Stair, Wall
pytestmark = pytest.mark.asyncio
WALL_A = {"id": "a", "axis": [[0.0, 0.0], [5000.0, 0.0]], "thickness": 200.0}
WALL_B = {"id": "b", "axis": [[2500.0, 0.0], [2500.0, 3000.0]], "thickness": 100.0}
async def on(backend, role, type_filter=None):
return await backend.entity_list(
type_filter=type_filter, layer_filter=ARCH_ROLE_LAYER[role], limit=10000
)
def ends(info) -> tuple:
a = tuple(round(v, 6) + 0.0 for v in info.properties["start"][:2])
b = tuple(round(v, 6) + 0.0 for v in info.properties["end"][:2])
return tuple(sorted((a, b)))
async def test_draw_walls_puts_faces_jambs_and_poche_on_their_layers(backend):
wall = Wall(id="a", axis=((0.0, 0.0), (5000.0, 0.0)), thickness=200.0)
door = Opening(id="d1", wall="a", kind="door", offset=1000.0, width=900.0, tag="D1")
result = await draw_walls(backend, [wall], [door])
lines = await on(backend, "wall", "LINE")
assert {ends(e) for e in lines} == {
((0.0, 100.0), (1000.0, 100.0)),
((1900.0, 100.0), (5000.0, 100.0)),
((0.0, -100.0), (1000.0, -100.0)),
((1900.0, -100.0), (5000.0, -100.0)),
((0.0, -100.0), (0.0, 100.0)),
((5000.0, -100.0), (5000.0, 100.0)),
((1000.0, -100.0), (1000.0, 100.0)),
((1900.0, -100.0), (1900.0, 100.0)),
}
assert len(await on(backend, "wall_hatch", "HATCH")) == 2
assert len(await on(backend, "door", "LINE")) == 1
assert len(await on(backend, "door", "ARC")) == 1
assert [w["id"] for w in result["walls"]] == ["a"]
assert [o["id"] for o in result["openings"]] == ["d1"]
assert result["omitted"] == []
async def test_the_records_round_trip_through_xdata(backend):
wall = Wall(id="a", axis=((0.0, 0.0), (5000.0, 0.0)), thickness=200.0, material="aac")
window = Opening(id="w1", wall="a", kind="window", offset=2000.0, width=1200.0, tag="W1")
drawn = await draw_walls(backend, [wall], [window])
plan = await read_plan(backend)
assert plan["walls"] == [wall]
assert plan["openings"] == [window]
assert plan["carriers"]["wall:a"] == drawn["walls"][0]["record"]
carrier = drawn["openings"][0]["record"]
assert plan["carriers"]["opening:w1"] == carrier
# the window's record sits on its left-face frame line, with its four lines owned
raw = await backend.entity_get_xdata(carrier, HANDLES_APP)
assert len(raw["xdata"][HANDLES_APP]) == 4
assert (await backend.entity_get_xdata(carrier, APP_ID))["xdata"][APP_ID]
async def test_add_walls_redraws_only_the_wall_the_new_one_joins(backend):
await add_walls(
backend,
[WALL_A, {"id": "c", "axis": [[0.0, 2000.0], [5000.0, 2000.0]], "thickness": 200.0}],
)
result = await add_walls(
backend, [{"id": "b", "axis": [[2500.0, 0.0], [2500.0, 1000.0]], "thickness": 100.0}]
)
assert result["added"] == ["b"]
assert result["redrawn"] == ["a"] # c is not touched by b
faces = {ends(e) for e in await on(backend, "wall", "LINE")}
# a's near face is now cut where b meets it; its far face is still one line
assert ((0.0, 100.0), (2450.0, 100.0)) in faces
assert ((2550.0, 100.0), (5000.0, 100.0)) in faces
assert ((0.0, -100.0), (5000.0, -100.0)) in faces
assert ((0.0, 100.0), (5000.0, 100.0)) not in faces # the old, uncut face was erased
plan = await read_plan(backend)
assert sorted(w.id for w in plan["walls"]) == ["a", "b", "c"]
async def test_an_opening_added_later_cuts_its_wall(backend):
await add_walls(backend, [WALL_A])
before = {ends(e) for e in await on(backend, "wall", "LINE")}
assert ((0.0, 100.0), (5000.0, 100.0)) in before
result = await add_opening(
backend, {"wall": "a", "kind": "door", "offset": 1000.0, "width": 900.0}
)
assert result["opening"] == "D1" and result["tag"] == "D1"
assert result["redrawn"] == ["a"]
after = {ends(e) for e in await on(backend, "wall", "LINE")}
assert ((0.0, 100.0), (5000.0, 100.0)) not in after
assert {((0.0, 100.0), (1000.0, 100.0)), ((1900.0, 100.0), (5000.0, 100.0))} <= after
assert len(await on(backend, "door", "ARC")) == 1
second = await add_opening(
backend, {"wall": "a", "kind": "door", "offset": 3000.0, "width": 800.0}
)
assert second["tag"] == "D2"
assert len(await on(backend, "door", "ARC")) == 2 # the first door was redrawn, not lost
plan = await read_plan(backend)
assert sorted(op.tag for op in plan["openings"]) == ["D1", "D2"]
async def test_an_opening_in_an_unknown_wall_or_overlapping_is_refused_untouched(backend):
await add_walls(backend, [WALL_A])
count = len(await backend.entity_list(limit=10000))
with pytest.raises(ValueError, match="'zz' is not a wall"):
await add_opening(backend, {"wall": "zz", "kind": "door", "offset": 0.0, "width": 900.0})
await add_opening(backend, {"wall": "a", "kind": "window", "offset": 1000.0, "width": 1200.0})
count = len(await backend.entity_list(limit=10000))
with pytest.raises(ValueError):
await add_opening(backend, {"wall": "a", "kind": "door", "offset": 1500.0, "width": 900.0})
assert len(await backend.entity_list(limit=10000)) == count
async def test_a_wall_id_already_drawn_is_refused(backend):
await add_walls(backend, [WALL_A])
with pytest.raises(ValueError, match=r"walls\[0\]\.id: 'a' is already drawn"):
await add_walls(backend, [WALL_A])
async def test_a_new_wall_that_would_swallow_an_opening_is_refused(backend):
await add_walls(backend, [WALL_A])
await add_opening(backend, {"wall": "a", "kind": "door", "offset": 2200.0, "width": 900.0})
count = len(await backend.entity_list(limit=10000))
with pytest.raises(ValueError, match="uncuttable"):
await add_walls(backend, [WALL_B])
assert len(await backend.entity_list(limit=10000)) == count
async def test_draw_stair_writes_its_record_and_reports_blondel(backend):
stair = Stair(
id="s1",
start=(0.0, 0.0),
direction_deg=0.0,
width=1000.0,
risers=17,
riser_height=170.0,
going=290.0,
)
result = await draw_stair(backend, stair)
assert result["blondel"] == {"value": 630.0, "ok": True, "range": [600.0, 650.0]}
assert result["treads"] == 16
assert len(await on(backend, "stair", "LINE")) == 17 + 2 + 2
assert (await read_plan(backend))["stairs"] == [stair]
with pytest.raises(ValueError, match="'s1' is already drawn"):
await add_stair(
backend,
{
"id": "s1",
"start": [0, 0],
"direction_deg": 0.0, # required by Task 1's stair_from_dict
"width": 1000.0,
"risers": 17,
"riser_height": 170.0,
"going": 290.0,
},
)
async def test_draw_chains_puts_real_dimensions_on_the_dimension_layer(backend):
shell = Wall(
id="shell",
axis=((0.0, 0.0), (8200.0, 0.0), (8200.0, 6200.0), (0.0, 6200.0)),
thickness=200.0,
closed=True,
)
result = await draw_chains(backend, [shell], [], sides=("bottom", "left"), scale=50)
dims = await on(backend, "dim", "DIMENSION")
assert len(dims) == result["count"] == 8 # per side: walls (3) + overall (1)
assert {row["row"] for row in result["dimensions"]} == {
"bottom:walls",
"bottom:overall",
"left:walls",
"left:overall",
}
async def test_assign_tags_numbers_each_kind_in_the_chosen_language():
ops = [
Opening(id="1", wall="a", kind="door", offset=0.0, width=900.0),
Opening(id="2", wall="a", kind="window", offset=2000.0, width=900.0),
Opening(id="3", wall="a", kind="door", offset=4000.0, width=900.0, tag="D1"),
]
assert [op.tag for op in assign_tags(ops)] == ["D2", "W1", "D1"]
assert [op.tag for op in assign_tags(ops[:2], lang="tr")] == ["K1", "P1"]
+94
View File
@@ -0,0 +1,94 @@
"""Door and window symbols: hinge, leaf, swing arc, frame and glazing.
The wall runs along +X from the origin, 200 mm thick and centred, so its left
face is y = +100 and its right face y = -100. The door is 900 wide at offset
1000: its jambs are x = 1000 and x = 1900. For each swing/hand pair the hinge,
the open leaf and the arc angles are written out below by hand.
"""
from __future__ import annotations
import pytest
from engineering.arch.model import Opening, Wall
from engineering.arch.openings import opening_prims
from engineering.mech.primitives import Arc, Line
WALL = Wall(id="a", axis=((0.0, 0.0), (5000.0, 0.0)), thickness=200.0)
def door(swing: str, hand: str) -> Opening:
return Opening(
id="d1", wall="a", kind="door", offset=1000.0, width=900.0, swing=swing, hand=hand
)
def pt(p) -> tuple[float, float]:
return (round(p[0], 9) + 0.0, round(p[1], 9) + 0.0)
@pytest.mark.parametrize(
("swing", "hand", "hinge", "leaf_end", "start_deg", "end_deg"),
[
# in = the axis's left (+Y). Facing the wall from +Y, the viewer's left is +X.
("in", "left", (1900.0, 100.0), (1900.0, 1000.0), 90.0, 180.0),
("in", "right", (1000.0, 100.0), (1000.0, 1000.0), 0.0, 90.0),
# out = the axis's right (-Y). Facing the wall from -Y, the viewer's left is -X.
("out", "left", (1000.0, -100.0), (1000.0, -1000.0), 270.0, 0.0),
("out", "right", (1900.0, -100.0), (1900.0, -1000.0), 180.0, 270.0),
],
)
def test_a_door_hinges_on_its_hand_and_swings_a_quarter_arc_to_its_side(
swing, hand, hinge, leaf_end, start_deg, end_deg
):
leaf, arc = opening_prims(WALL, door(swing, hand))
assert isinstance(leaf, Line) and leaf.role == "door"
assert (pt(leaf.p1), pt(leaf.p2)) == (hinge, leaf_end)
assert isinstance(arc, Arc) and arc.role == "door"
assert pt(arc.center) == hinge
assert arc.radius == 900.0
assert (round(arc.start_deg, 9), round(arc.end_deg, 9)) == (start_deg, end_deg)
def test_the_leaf_is_as_long_as_the_opening_is_wide_and_square_to_the_wall():
leaf, _arc = opening_prims(WALL, door("in", "left"))
dx = leaf.p2[0] - leaf.p1[0]
dy = leaf.p2[1] - leaf.p1[1]
assert (dx, dy) == (0.0, 900.0)
def test_a_door_on_a_second_segment_is_placed_along_the_whole_axis():
bent = Wall(id="b", axis=((0.0, 0.0), (4000.0, 0.0), (4000.0, 3000.0)), thickness=200.0)
op = Opening(
id="d2", wall="b", kind="door", offset=4500.0, width=800.0, swing="in", hand="right"
)
leaf, arc = opening_prims(bent, op)
# segment 1 runs +Y from (4000, 0); its left normal is -X, so "in" is x < 4000
# and its left face is x = 3900. Facing +X from the left side, the viewer's
# left is +Y, so a right hand hinges at the near jamb, y = 500.
assert pt(leaf.p1) == (3900.0, 500.0)
assert pt(leaf.p2) == (3100.0, 500.0)
assert (round(arc.start_deg, 9), round(arc.end_deg, 9)) == (90.0, 180.0)
def test_a_window_is_two_frame_lines_and_two_glazing_lines():
window = Opening(id="w1", wall="a", kind="window", offset=2500.0, width=1200.0)
prims = opening_prims(WALL, window)
assert len(prims) == 4
assert all(isinstance(p, Line) and p.role == "window" for p in prims)
assert [(pt(p.p1), pt(p.p2)) for p in prims] == [
((2500.0, 100.0), (3700.0, 100.0)), # frame, left face
((2500.0, -100.0), (3700.0, -100.0)), # frame, right face
((2500.0, round(-100.0 + 200.0 / 3.0, 9)), (3700.0, round(-100.0 + 200.0 / 3.0, 9))),
((2500.0, round(-100.0 + 400.0 / 3.0, 9)), (3700.0, round(-100.0 + 400.0 / 3.0, 9))),
]
def test_an_opening_of_another_wall_is_refused():
with pytest.raises(ValueError, match="hosted in wall 'z'"):
opening_prims(WALL, Opening(id="d1", wall="z", kind="door", offset=0.0, width=900.0))
def test_an_unknown_swing_is_refused_with_the_list():
with pytest.raises(ValueError, match="swings are in, out"):
opening_prims(WALL, door("sideways", "left"))
+180
View File
@@ -0,0 +1,180 @@
"""Stairs in plan: tread lines, outline, walking line, label, and the Blondel rule.
Every expected coordinate is hand-computed from the stair's numbers: a flight
of m risers at going g runs (m - 1) * g, a riser line spans the full width, and
the first flight of an L or U carries (risers + 1) // 2 risers.
"""
from __future__ import annotations
import pytest
from engineering.arch.lang import vocab
from engineering.arch.model import Stair
from engineering.arch.stairs import BLONDEL_RANGE, blondel, stair_prims
from engineering.mech.primitives import Line, Poly, Text
def S(x1, y1, x2, y2) -> tuple:
return tuple(sorted(((float(x1), float(y1)), (float(x2), float(y2)))))
def segs(prims) -> list:
out = []
for p in prims:
if isinstance(p, Line):
a = (round(p.p1[0], 9) + 0.0, round(p.p1[1], 9) + 0.0)
b = (round(p.p2[0], 9) + 0.0, round(p.p2[1], 9) + 0.0)
out.append(tuple(sorted((a, b))))
return out
def path(prims) -> list:
(walk,) = [p for p in prims if isinstance(p, Poly)]
return [(round(x, 9) + 0.0, round(y, 9) + 0.0) for x, y in walk.points]
def stair(**kw) -> Stair:
base = {
"id": "s1",
"start": (1000.0, 2000.0),
"direction_deg": 0.0,
"width": 1000.0,
"risers": 17,
"riser_height": 170.0,
"going": 290.0,
}
return Stair(**{**base, **kw})
# -- Blondel -----------------------------------------------------------------------
def test_blondel_reports_two_risers_plus_a_going_against_600_to_650():
assert BLONDEL_RANGE == (600.0, 650.0)
assert blondel(170.0, 290.0) == {"value": 630.0, "ok": True, "range": [600.0, 650.0]}
assert blondel(200.0, 300.0) == {"value": 700.0, "ok": False, "range": [600.0, 650.0]}
assert blondel(150.0, 280.0)["ok"] is False # 580
assert blondel(175.0, 250.0)["ok"] is True # 600, the lower edge is inside
def test_blondel_refuses_a_non_positive_dimension_by_name():
with pytest.raises(ValueError, match="riser_height"):
blondel(0.0, 290.0)
with pytest.raises(ValueError, match="going"):
blondel(170.0, -1.0)
# -- straight -----------------------------------------------------------------------
def test_a_straight_stair_draws_every_riser_once_and_both_stringers():
prims = stair_prims(stair())
lines = segs(prims)
risers = [S(1000 + 290 * k, 1500, 1000 + 290 * k, 2500) for k in range(17)]
assert set(risers) <= set(lines)
assert S(1000, 1500, 5640, 1500) in lines # (17 - 1) * 290 = 4640
assert S(1000, 2500, 5640, 2500) in lines
assert len(lines) == 17 + 2 + 2 # risers, stringers, the two arrow strokes
assert all(p.role == "stair" for p in prims)
def test_the_walking_line_runs_up_the_middle_with_an_arrow_at_the_top():
prims = stair_prims(stair(), scale=50)
assert path(prims) == [(1000.0, 2000.0), (5640.0, 2000.0)]
lines = segs(prims)
# arrow 3 mm x 50 = 150 long, its strokes 50 either side of the line
assert S(5490, 2050, 5640, 2000) in lines
assert S(5490, 1950, 5640, 2000) in lines
def test_the_up_label_sits_on_the_first_tread_at_the_plot_scale_height():
(text,) = [p for p in stair_prims(stair(), scale=50) if isinstance(p, Text)]
assert text.text == vocab("en")["up"]
assert text.height == 125.0 # 2.5 mm x 50
# (0.15 * 290, -0.25 * 1000 - 0.5 * 125) from the start
assert (round(text.at[0], 9), round(text.at[1], 9)) == (1043.5, 1687.5)
(tr,) = [p for p in stair_prims(stair(), lang="tr") if isinstance(p, Text)]
assert tr.text == vocab("tr")["up"]
def test_direction_turns_the_whole_stair_about_its_start():
prims = stair_prims(stair(direction_deg=90.0))
lines = segs(prims)
risers = [S(500, 2000 + 290 * k, 1500, 2000 + 290 * k) for k in range(17)]
assert set(risers) <= set(lines)
assert path(prims) == [(1000.0, 2000.0), (1000.0, 6640.0)]
(text,) = [p for p in prims if isinstance(p, Text)]
assert text.rotation == 90.0
# -- L and U ------------------------------------------------------------------------
def test_an_l_stair_turns_left_on_a_square_landing():
prims = stair_prims(stair(start=(0.0, 0.0), kind="l", risers=16, going=280.0))
lines = segs(prims)
# 8 + 8 risers; the landing starts at 7 * 280 = 1960, the top riser at 500 + 7 * 280
first = [S(280 * k, -500, 280 * k, 500) for k in range(8)]
second = [S(1960, 500 + 280 * k, 2960, 500 + 280 * k) for k in range(8)]
assert set(first + second) <= set(lines)
assert {
S(0, -500, 2960, -500),
S(2960, -500, 2960, 2460),
S(0, 500, 1960, 500),
S(1960, 500, 1960, 2460),
} <= set(lines)
assert len(lines) == 16 + 4 + 2
assert path(prims) == [(0.0, 0.0), (2460.0, 0.0), (2460.0, 2460.0)]
def test_an_l_stair_turning_right_is_the_mirror_image():
prims = stair_prims(stair(start=(0.0, 0.0), kind="l", turn="right", risers=16, going=280.0))
lines = segs(prims)
assert S(1960, -500, 2960, -500) in lines
assert S(2960, 500, 2960, -2460) in lines
assert path(prims)[-1] == (2460.0, -2460.0)
def test_a_u_stair_returns_beside_the_first_flight():
prims = stair_prims(stair(start=(0.0, 0.0), kind="u", risers=18, going=270.0, width=1100.0))
lines = segs(prims)
# 9 + 9 risers; landing at 8 * 270 = 2160; the second flight comes back to x = 0
first = [S(270 * k, -550, 270 * k, 550) for k in range(9)]
second = [S(2160 - 270 * k, 550, 2160 - 270 * k, 1650) for k in range(9)]
assert set(first + second) <= set(lines)
assert {
S(0, -550, 3260, -550),
S(3260, -550, 3260, 1650),
S(3260, 1650, 0, 1650),
S(0, 550, 2160, 550),
} <= set(lines)
assert len(lines) == 18 + 4 + 2
assert path(prims) == [(0.0, 0.0), (2710.0, 0.0), (2710.0, 1100.0), (0.0, 1100.0)]
# -- refusals ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("kw", "match"),
[
({"risers": 1}, "at least 2"),
({"kind": "l", "risers": 3}, "at least 4"),
({"risers": True}, "whole number"),
({"kind": "spiral"}, "kinds are straight, l, u"),
({"turn": "up"}, "turns are left, right"),
({"width": 0.0}, "width"),
({"going": float("nan")}, "going"),
({"direction_deg": "north"}, "direction_deg must be finite"),
({"start": (0.0, float("inf"))}, r"start\[1\] must be finite"),
],
)
def test_a_bad_stair_is_refused_before_any_geometry(kw, match):
with pytest.raises(ValueError, match=match):
stair_prims(stair(**kw))
def test_an_unknown_language_is_refused():
with pytest.raises(ValueError):
stair_prims(stair(), lang="de")
+124
View File
@@ -0,0 +1,124 @@
"""SECTION 25's first four tools, called the way a client calls them.
In-memory client on a fresh headless document; the env var pins ezdxf so no
test here can reach an operator's live drawing (tests/conftest.py).
"""
from __future__ import annotations
import pytest
import pytest_asyncio
from fastmcp import Client
from fastmcp.exceptions import ToolError
import server
pytestmark = pytest.mark.asyncio
ARCH_TOOLS = {"arch_wall", "arch_opening", "arch_stair", "arch_dimension_chains"}
@pytest_asyncio.fixture
async def client(monkeypatch):
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
async with Client(server.mcp) as connected:
status = (await connected.call_tool("system_status", {})).structured_content or {}
assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD"
await connected.call_tool("drawing_new", {})
yield connected
async def call(client, name, args) -> dict:
return (await client.call_tool(name, args)).structured_content
async def test_the_four_tools_are_registered_under_the_architecture_group():
tools = {tool.name: tool for tool in await server._registered_tools()}
assert ARCH_TOOLS <= set(tools)
groups = await server._tool_groups()
assert set(groups["architecture"]) == ARCH_TOOLS
for name in ARCH_TOOLS:
card = (tools[name].meta or {})["cad"]
assert card["cost"] == "mutate"
assert card["synonyms"], f"{name} needs an alias record"
async def test_a_plan_built_tool_by_tool(client):
walls = await call(
client,
"arch_wall",
{
"walls": [
{
"id": "shell",
"axis": [[0, 0], [8200, 0], [8200, 6200], [0, 6200]],
"thickness": 200,
"closed": True,
}
]
},
)
assert walls["added"] == ["shell"] and walls["redrawn"] == []
partition = await call(
client,
"arch_wall",
{"walls": [{"id": "p", "axis": [[4100, 0], [4100, 6200]], "thickness": 100}]},
)
assert partition["redrawn"] == ["shell"]
door = await call(
client, "arch_opening", {"wall": "shell", "offset": 5500, "width": 1000, "swing": "in"}
)
assert door["tag"] == "D1"
window = await call(
client, "arch_opening", {"wall": "shell", "kind": "window", "offset": 1500, "width": 1200}
)
assert window["tag"] == "W1"
stair = await call(
client,
"arch_stair",
{
"stair_id": "s1",
"start_x": 1000,
"start_y": 1500,
"width": 1000,
"risers": 17,
"riser_height": 170,
"going": 290,
"direction_deg": 90,
},
)
assert stair["blondel"]["ok"] is True
chains = await call(client, "arch_dimension_chains", {"sides": ["bottom"]})
# 5 openings + 5 walls + 1 overall, hand-counted in tests/test_arch_dimension.py
assert chains["count"] == 11
async def test_refusals_come_back_as_tool_errors_that_name_the_cause(client):
with pytest.raises(ToolError, match="no architectural walls"):
await call(client, "arch_dimension_chains", {})
with pytest.raises(ToolError, match="'nowhere' is not a wall"):
await call(client, "arch_opening", {"wall": "nowhere", "offset": 0, "width": 900})
await call(
client, "arch_wall", {"walls": [{"id": "a", "axis": [[0, 0], [5000, 0]], "thickness": 200}]}
)
with pytest.raises(ToolError, match="5°"):
await call(
client,
"arch_wall",
{"walls": [{"id": "b", "axis": [[-2000, -150], [2000, 0]], "thickness": 200}]},
)
with pytest.raises(ToolError, match="at least 4"):
await call(
client,
"arch_stair",
{
"stair_id": "s",
"start_x": 0,
"start_y": 0,
"width": 900,
"risers": 3,
"riser_height": 170,
"going": 290,
"kind": "u",
},
)
+514
View File
@@ -0,0 +1,514 @@
"""The wall engine: outlines, L/T/X junctions, opening gaps and poche.
Every corner below is hand-computed and written out: two 200 mm walls centred
on their axes put their faces 100 mm either side, so an L at the origin has
its inner corner at (100, 100) and its outer one at (-100, -100), and a T or
an X leaves the faces of the crossing wall cut exactly 100 mm either side of
the stem's axis.
"""
from __future__ import annotations
import itertools
import math
import pytest
from engineering.arch.model import Opening, Wall
from engineering.arch.walls import (
MIN_JOIN_ANGLE_DEG,
WallGeometry,
face_offsets,
room_segments,
wall_geometry,
wall_geometry_by_wall,
wall_segments,
)
from engineering.mech.primitives import HatchArea, Line
def seg(line) -> tuple:
"""An undirected, rounded segment: comparable as a set member."""
a = (round(line.p1[0], 9) + 0.0, round(line.p1[1], 9) + 0.0)
b = (round(line.p2[0], 9) + 0.0, round(line.p2[1], 9) + 0.0)
return tuple(sorted((a, b)))
def segs(lines) -> set:
return {seg(line) for line in lines}
def S(x1, y1, x2, y2) -> tuple:
return tuple(sorted(((float(x1), float(y1)), (float(x2), float(y2)))))
def ring(points) -> tuple:
"""A polygon, rounded and rotated to start at its smallest vertex (order kept)."""
pts = [(round(x, 9) + 0.0, round(y, 9) + 0.0) for x, y in points]
k = pts.index(min(pts))
return tuple(pts[k:] + pts[:k])
def signed_area(points) -> float:
return (
sum(
x1 * y2 - x2 * y1
for (x1, y1), (x2, y2) in zip(points, list(points[1:]) + [points[0]], strict=True)
)
/ 2.0
)
def wall(id, *axis, thickness=200.0, **kw) -> Wall:
return Wall(id=id, axis=tuple(axis), thickness=thickness, **kw)
# -- the wall's own numbers ------------------------------------------------------
def test_face_offsets_follow_the_justification():
assert face_offsets(wall("c", (0, 0), (1, 0))) == (100.0, -100.0)
assert face_offsets(wall("l", (0, 0), (1, 0), justification="left")) == (200.0, 0.0)
assert face_offsets(wall("r", (0, 0), (1, 0), justification="right")) == (0.0, -200.0)
def test_wall_segments_close_a_closed_axis():
square = wall("s", (0, 0), (4000, 0), (4000, 3000), (0, 3000), closed=True)
assert wall_segments(square) == (
((0.0, 0.0), (4000.0, 0.0)),
((4000.0, 0.0), (4000.0, 3000.0)),
((4000.0, 3000.0), (0.0, 3000.0)),
((0.0, 3000.0), (0.0, 0.0)),
)
# -- a free end ------------------------------------------------------------------
def test_a_single_wall_is_two_faces_closed_by_a_jamb_at_each_free_end():
geo = wall_geometry([wall("a", (0, 0), (3000, 0), thickness=100.0, justification="left")])
assert isinstance(geo, WallGeometry)
assert segs(geo.faces) == {S(0, 100, 3000, 100), S(0, 0, 3000, 0)}
assert segs(geo.jambs) == {S(0, 0, 0, 100), S(3000, 0, 3000, 100)}
assert [ring(r) for r in geo.regions] == [ring([(0, 0), (3000, 0), (3000, 100), (0, 100)])]
assert all(line.role == "wall" for line in (*geo.faces, *geo.jambs))
def test_right_justification_puts_the_wall_on_the_other_side_of_the_axis():
geo = wall_geometry([wall("a", (0, 0), (3000, 0), thickness=100.0, justification="right")])
assert segs(geo.faces) == {S(0, 0, 3000, 0), S(0, -100, 3000, -100)}
# -- L -----------------------------------------------------------------------------
L_FACES = {
S(100, 100, 4000, 100), # wall a, inner face
S(-100, -100, 4000, -100), # wall a, outer face
S(100, 100, 100, 3000), # wall b, inner face
S(-100, -100, -100, 3000), # wall b, outer face
}
L_JAMBS = {S(4000, -100, 4000, 100), S(-100, 3000, 100, 3000)}
L_REGIONS = {
ring([(-100, -100), (4000, -100), (4000, 100), (100, 100)]),
ring([(100, 100), (100, 3000), (-100, 3000), (-100, -100)]),
}
def test_an_l_between_two_walls_mitres_both_faces_to_their_intersections():
geo = wall_geometry([wall("a", (0, 0), (4000, 0)), wall("b", (0, 0), (0, 3000))])
assert segs(geo.faces) == L_FACES
assert segs(geo.jambs) == L_JAMBS
assert {ring(r) for r in geo.regions} == L_REGIONS
assert len(geo.hatches) == 2
def test_an_l_inside_one_wall_is_the_same_corner():
geo = wall_geometry([wall("a", (4000, 0), (0, 0), (0, 3000))])
assert segs(geo.faces) == L_FACES
assert segs(geo.jambs) == L_JAMBS
assert {ring(r) for r in geo.regions} == L_REGIONS
def test_every_region_is_counter_clockwise():
geo = wall_geometry([wall("a", (4000, 0), (0, 0), (0, 3000))])
assert all(signed_area(r) > 0 for r in geo.regions)
# -- straight joins whose faces do not line up ---------------------------------------
def test_a_justification_change_on_a_straight_axis_steps_across_the_node():
geo = wall_geometry(
[
wall("a", (0, 0), (3000, 0), justification="left"),
wall("b", (3000, 0), (6000, 0), justification="right"),
]
)
assert segs(geo.faces) == {
S(0, 0, 3000, 0),
S(0, 200, 3000, 200),
S(3000, 200, 3000, 0), # the step: each face of a stops on the square cut
S(3000, 0, 3000, -200), # through the node, and so does b's
S(3000, 0, 6000, 0),
S(3000, -200, 6000, -200),
}
assert {ring(r) for r in geo.regions} == {
ring([(0, 0), (3000, 0), (3000, 200), (0, 200)]),
ring([(3000, -200), (6000, -200), (6000, 0), (3000, 0)]),
}
def test_a_rounding_kink_steps_like_the_straight_join_instead_of_mitring_kilometres_away():
# 0.5 mm over 3 m: the faces 200 mm apart would meet at x = 1 203 000
geo = wall_geometry(
[
wall("a", (0, 0), (3000, 0), justification="left"),
wall("b", (3000, 0), (6000, 0.5), justification="right"),
]
)
points = [p for line in geo.faces for p in (line.p1, line.p2)]
# b's free end is square to its own axis: 200 * sin(0.5 / 3000) past x = 6000
assert max(abs(x) for x, _y in points) <= 6000.0 + 0.04
assert max(abs(y) for _x, y in points) <= 200.5 + 1e-6
assert all(signed_area(r) > 0 for r in geo.regions)
assert sorted(signed_area(r) for r in geo.regions) == pytest.approx([600000, 600000], abs=50)
def test_a_thickness_change_at_a_small_kink_steps_at_the_node_and_does_not_flare():
kink = math.radians(2.0)
geo = wall_geometry(
[
wall("a", (0, 0), (3000, 0), thickness=100.0),
wall(
"b",
(3000, 0),
(3000 + 3000 * math.cos(kink), 3000 * math.sin(kink)),
thickness=300.0,
),
]
)
a_faces = [line for line in geo.faces if abs(line.p1[1] - line.p2[1]) < 1e-9]
# a's two 100 mm faces stop at the node (the bisector of a 2 degree join), not
# 2865 mm into b where they would meet b's 300 mm faces
assert {round(y, 6) for line in a_faces for y in (line.p1[1], line.p2[1])} == {50.0, -50.0}
assert max(x for line in a_faces for x in (line.p1[0], line.p2[0])) < 3002.0
assert all(signed_area(r) > 0 for r in geo.regions)
assert sorted(signed_area(r) for r in geo.regions) == pytest.approx([300000, 900000], abs=1)
def test_a_rounded_rotated_thickness_change_draws_the_same_step_as_the_exact_one():
c, s = math.cos(math.radians(30)), math.sin(math.radians(30))
def along(d, digits=None):
x, y = d * c, d * s
return (round(x, digits), round(y, digits)) if digits is not None else (x, y)
exact = wall_geometry(
[
wall("a", along(0), along(3000), thickness=100.0),
wall("b", along(3000), along(6000), thickness=300.0),
]
)
rounded = wall_geometry(
[
wall("a", along(0, 2), along(3000, 2), thickness=100.0),
wall("b", along(3000, 2), along(6000, 2), thickness=300.0),
]
)
assert len(rounded.faces) == len(exact.faces) == 6
for mine, theirs in zip(
sorted(seg(line) for line in rounded.faces),
sorted(seg(line) for line in exact.faces),
strict=True,
):
assert math.dist(mine[0], theirs[0]) < 0.05 and math.dist(mine[1], theirs[1]) < 0.05
@pytest.mark.parametrize("kink", [0.0, 0.01, 0.5, 2.0, 8.0, 20.0, 45.0, -2.0, -30.0])
@pytest.mark.parametrize("justs", list(itertools.product(("center", "left", "right"), repeat=2)))
@pytest.mark.parametrize("thicknesses", [(200.0, 200.0), (100.0, 300.0)])
def test_a_straight_or_kinked_join_stays_near_its_node_and_every_region_is_ccw(
kink, justs, thicknesses
):
turn = math.radians(30.0 + kink)
p1 = (round(3000 * math.cos(math.radians(30)), 2), round(3000 * math.sin(math.radians(30)), 2))
p2 = (round(p1[0] + 3000 * math.cos(turn), 2), round(p1[1] + 3000 * math.sin(turn), 2))
geo = wall_geometry(
[
wall("a", (0, 0), p1, thickness=thicknesses[0], justification=justs[0]),
wall("b", p1, p2, thickness=thicknesses[1], justification=justs[1]),
]
)
reach = 6000 + 2 * max(thicknesses)
assert all(math.hypot(*p) < reach for line in geo.faces for p in (line.p1, line.p2))
assert len(geo.regions) == 2
assert all(signed_area(r) > 0 for r in geo.regions)
# -- T -----------------------------------------------------------------------------
def test_a_t_stops_the_stem_on_the_near_face_and_keeps_the_far_face_whole():
geo = wall_geometry([wall("a", (-3000, 0), (3000, 0)), wall("b", (0, 0), (0, 2500))])
assert segs(geo.faces) == {
S(-3000, -100, 3000, -100), # the far face: one line past the stem
S(-3000, 100, -100, 100), # the near face, cut where the stem meets it
S(100, 100, 3000, 100),
S(-100, 100, -100, 2500), # the stem, stopped on the near face
S(100, 100, 100, 2500),
}
assert segs(geo.jambs) == {
S(-3000, -100, -3000, 100),
S(3000, -100, 3000, 100),
S(-100, 2500, 100, 2500),
}
assert {ring(r) for r in geo.regions} == {
ring([(-3000, -100), (0, -100), (-100, 100), (-3000, 100)]),
ring([(0, -100), (3000, -100), (3000, 100), (100, 100)]),
ring([(100, 100), (100, 2500), (-100, 2500), (-100, 100)]),
ring([(100, 100), (-100, 100), (0, -100)]), # the hub the three pieces leave
}
def test_a_stem_drawn_to_the_face_is_carried_to_the_axis():
touching = wall_geometry([wall("a", (-3000, 0), (3000, 0)), wall("b", (0, 100), (0, 2500))])
on_axis = wall_geometry([wall("a", (-3000, 0), (3000, 0)), wall("b", (0, 0), (0, 2500))])
assert segs(touching.faces) == segs(on_axis.faces)
# c runs along y = 0; a is drawn to c's face (its start is carried from y = 100
# down to c's axis) and b is a T into a's middle at y = 2000. The T on a must not
# depend on whether a's start has been carried yet when b is resolved.
T_ON_A_CARRIED_WALL = {
S(-1000, -100, 5000, -100), # c's far face, whole
S(-1000, 100, 1900, 100), # c's near face, cut by a
S(2100, 100, 5000, 100),
S(1900, 100, 1900, 1900), # a's near face to b, cut by b at 1900 / 2100
S(1900, 2100, 1900, 4000),
S(2100, 100, 2100, 4000), # a's far face to b, whole
S(0, 1900, 1900, 1900), # b stops on a's near face
S(0, 2100, 1900, 2100),
}
@pytest.mark.parametrize("order", list(itertools.permutations("cab")))
def test_a_t_into_a_carried_wall_does_not_depend_on_the_order_of_the_walls(order):
walls = {
"c": wall("c", (-1000, 0), (5000, 0)),
"a": wall("a", (2000, 100), (2000, 4000)),
"b": wall("b", (0, 2000), (2000, 2000)),
}
geo = wall_geometry([walls[name] for name in order])
assert segs(geo.faces) == T_ON_A_CARRIED_WALL
assert segs(geo.jambs) == {
S(-1000, -100, -1000, 100),
S(5000, -100, 5000, 100),
S(1900, 4000, 2100, 4000),
S(0, 1900, 0, 2100), # b's free end - and no jamb inside wall a
}
assert {ring(r) for r in geo.regions} == {
ring([(-1000, -100), (2000, -100), (1900, 100), (-1000, 100)]),
ring([(2000, -100), (5000, -100), (5000, 100), (2100, 100)]),
ring([(2100, 100), (1900, 100), (2000, -100)]), # the hub on c
ring([(2100, 100), (2100, 2000), (1900, 1900), (1900, 100)]), # a, split at y = 2000
ring([(2100, 2000), (2100, 4000), (1900, 4000), (1900, 2100)]),
ring([(1900, 2100), (1900, 1900), (2100, 2000)]), # the hub on a
ring([(0, 1900), (1900, 1900), (1900, 2100), (0, 2100)]),
}
# -- X -----------------------------------------------------------------------------
def test_an_x_cuts_all_four_faces():
geo = wall_geometry([wall("a", (-3000, 0), (3000, 0)), wall("b", (0, -2500), (0, 2500))])
assert segs(geo.faces) == {
S(-3000, 100, -100, 100),
S(100, 100, 3000, 100),
S(-3000, -100, -100, -100),
S(100, -100, 3000, -100),
S(-100, -2500, -100, -100),
S(-100, 100, -100, 2500),
S(100, -2500, 100, -100),
S(100, 100, 100, 2500),
}
assert len(geo.jambs) == 4
assert ring([(100, 100), (-100, 100), (-100, -100), (100, -100)]) in {
ring(r) for r in geo.regions
}
assert len(geo.regions) == 5 # four arms and the hub
# -- openings ------------------------------------------------------------------------
def test_a_door_removes_its_width_from_both_faces_and_is_closed_by_two_jambs():
geo = wall_geometry(
[wall("a", (0, 0), (5000, 0))],
[Opening(id="d1", wall="a", kind="door", offset=1000.0, width=900.0)],
)
assert segs(geo.faces) == {
S(0, 100, 1000, 100),
S(1900, 100, 5000, 100),
S(0, -100, 1000, -100),
S(1900, -100, 5000, -100),
}
assert segs(geo.jambs) == {
S(0, -100, 0, 100),
S(5000, -100, 5000, 100),
S(1000, -100, 1000, 100),
S(1900, -100, 1900, 100),
}
assert segs(geo.thresholds) == {S(1000, 100, 1900, 100), S(1000, -100, 1900, -100)}
assert {ring(r) for r in geo.regions} == {
ring([(0, -100), (1000, -100), (1000, 100), (0, 100)]),
ring([(1900, -100), (5000, -100), (5000, 100), (1900, 100)]),
}
assert [h.material for h in geo.hatches] == ["brick", "brick"]
assert geo.omitted == ()
def test_an_opening_is_placed_along_the_whole_axis_not_its_first_segment():
geo = wall_geometry(
[wall("a", (0, 0), (4000, 0), (4000, 3000))],
[Opening(id="w1", wall="a", kind="window", offset=4500.0, width=1200.0)],
)
assert {S(3900, 100, 3900, 500), S(3900, 1700, 3900, 3000)} <= segs(geo.faces)
assert {S(3900, 500, 4100, 500), S(3900, 1700, 4100, 1700)} <= segs(geo.jambs)
def test_an_opening_that_runs_into_a_junction_is_omitted_by_name():
geo = wall_geometry(
[wall("a", (0, 0), (4000, 0), (4000, 3000))],
[Opening(id="d9", wall="a", kind="door", offset=3500.0, width=450.0)],
)
((entry,),) = [geo.omitted]
assert entry["element"] == "d9"
assert "junction" in entry["reason"]
assert segs(geo.faces) == segs(
wall_geometry([wall("a", (0, 0), (4000, 0), (4000, 3000))]).faces
)
# -- poche -----------------------------------------------------------------------------
def test_one_hatch_per_region_in_the_wall_material_and_none_without_poche():
walls = [wall("a", (-3000, 0), (3000, 0), material="concrete"), wall("b", (0, 0), (0, 2500))]
geo = wall_geometry(walls)
assert len(geo.hatches) == len(geo.regions) == 4
assert all(isinstance(h, HatchArea) and len(h.loops) == 1 for h in geo.hatches)
assert sorted(h.material for h in geo.hatches) == ["brick", "concrete", "concrete", "concrete"]
bare = wall_geometry(walls, poche=False)
assert bare.hatches == ()
assert len(bare.regions) == 4
def test_by_wall_splits_every_element_to_its_owner():
walls = [wall("a", (-3000, 0), (3000, 0)), wall("b", (0, 0), (0, 2500))]
split = wall_geometry_by_wall(walls)
whole = wall_geometry(walls)
assert set(split) == {"a", "b"}
assert segs(split["a"].faces) | segs(split["b"].faces) == segs(whole.faces)
assert segs(split["b"].faces) == {S(-100, 100, -100, 2500), S(100, 100, 100, 2500)}
assert len(split["a"].regions) == 3 # two arms and the hub (a is first)
assert len(split["b"].regions) == 1
def test_room_segments_are_faces_jambs_and_thresholds():
geo = wall_geometry(
[wall("a", (0, 0), (5000, 0))],
[Opening(id="d1", wall="a", kind="door", offset=1000.0, width=900.0)],
)
segments = room_segments(geo)
assert len(segments) == len(geo.faces) + len(geo.jambs) + len(geo.thresholds) == 10
assert {S(*p1, *p2) for p1, p2 in segments} == segs(geo.faces) | segs(geo.jambs) | segs(
geo.thresholds
)
# -- refusals -------------------------------------------------------------------------
def test_a_stem_meeting_a_wall_below_five_degrees_is_refused_by_name():
with pytest.raises(ValueError) as excinfo:
wall_geometry([wall("a", (0, 0), (5000, 0)), wall("b", (-2000, -150), (2000, 0))])
message = str(excinfo.value)
assert "'a'" in message and "'b'" in message
assert f"{MIN_JOIN_ANGLE_DEG:g}°" in message
def test_two_ends_meeting_below_five_degrees_are_refused():
with pytest.raises(ValueError, match="apart"):
wall_geometry([wall("a", (0, 0), (5000, 0)), wall("b", (0, 0), (5000, 200))])
def test_duplicate_wall_ids_are_refused_with_both_paths():
with pytest.raises(ValueError, match=r"walls\[1\]\.id: 'a' is already used by walls\[0\]"):
wall_geometry([wall("a", (0, 0), (1000, 0)), wall("a", (0, 500), (1000, 500))])
def test_an_unknown_material_is_refused_by_path():
with pytest.raises(ValueError, match=r"walls\[0\]\.material") as excinfo:
wall_geometry([wall("a", (0, 0), (1000, 0), material="cheese")])
assert "material: material" not in str(excinfo.value)
def test_a_corner_inside_another_wall_is_refused():
with pytest.raises(ValueError, match="corner"):
wall_geometry(
[
wall("a", (-3000, 0), (3000, 0)),
wall("b", (-500, 2000), (0, 50), (500, 2000)),
]
)
def test_an_end_whose_axis_misses_the_other_wall_is_refused():
with pytest.raises(ValueError, match="beyond its end"):
wall_geometry([wall("a", (0, 0), (3000, 0)), wall("b", (2990, 80), (2000, 2000))])
def test_a_stub_lying_inside_the_wall_it_joins_is_refused():
with pytest.raises(ValueError, match="lies inside the wall it joins"):
wall_geometry([wall("a", (-3000, 0), (3000, 0)), wall("b", (0, 0), (0, 80))])
def test_a_segment_too_short_for_its_corner_is_refused():
# a 30 degree corner puts the inner mitre 100 / tan(15 deg) = 373.2 along the
# second segment, which is only 300 long: its outline would cross itself
with pytest.raises(ValueError, match="too short"):
wall_geometry([wall("a", (1000, 0), (0, 0), (259.8, 150.0))])
def test_a_stub_ending_inside_its_own_corner_is_refused():
# 60 long past a 200 mm L: its end lies inside the first segment's body
with pytest.raises(ValueError, match="lies inside the wall it joins"):
wall_geometry([wall("a", (0, 0), (1000, 0), (1000, 60))])
def test_a_bad_join_tolerance_is_refused():
with pytest.raises(ValueError, match="join_tol"):
wall_geometry([wall("a", (0, 0), (1000, 0))], join_tol=0.0)
def test_line_primitives_are_the_mech_line_type():
geo = wall_geometry([wall("a", (0, 0), (1000, 0))])
assert all(isinstance(line, Line) for line in (*geo.faces, *geo.jambs))
def test_a_closed_axis_is_a_ring_with_no_jamb_and_a_4_by_5_m_inner_face():
geo = wall_geometry([wall("r", (0, 0), (4200, 0), (4200, 5200), (0, 5200), closed=True)])
assert geo.jambs == ()
assert segs(geo.faces) == {
S(100, 100, 4100, 100),
S(4100, 100, 4100, 5100),
S(4100, 5100, 100, 5100),
S(100, 5100, 100, 100),
S(-100, -100, 4300, -100),
S(4300, -100, 4300, 5300),
S(4300, 5300, -100, 5300),
S(-100, 5300, -100, -100),
}
assert len(geo.regions) == 4
+1 -1
View File
@@ -246,7 +246,7 @@ async def test_the_three_track_e_sections_file_under_their_own_groups():
assert set(groups["environment"]) == SETTINGS_TOOLS
assert set(groups["mechanical"]) == MECH_TOOLS
assert set(groups["sheet"]) == SHEET_TOOLS
assert len(groups) == 25
assert len(groups) == 26 # + architecture (SECTION 25, track F)
async def test_system_about_reports_packs():
+9 -2
View File
@@ -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 (229 tools, 25 groups).
"""Frozen snapshot of the surface (233 tools, 26 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
@@ -581,10 +581,17 @@ async def test_tool_group_sizes_are_unchanged():
`sheet`, so pack, group and SECTION are one list again: `engineering`
returned 17 -> 10, `blocks` 11 -> 9, `entity_creation` 19 -> 18 and
`drawing` 12 -> 11 — every one of them its pre-SECTION-24 value.
`architecture` appeared with v1.6's track F: SECTION 25 opened with the
plan model's wall engine (`arch_wall` / `arch_opening` / `arch_stair` /
`arch_dimension_chains`); the `arch` tag is ranked directly after `mech` in
`_GROUP_TAG_PRIORITY`, so those tools file under `architecture` rather
than under their secondary `create` / `dimension` tags.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
assert sizes == {
"analysis": 12,
"architecture": 4,
"batch": 3,
"blocks": 9,
"corner_ops": 4,
@@ -610,4 +617,4 @@ async def test_tool_group_sizes_are_unchanged():
"validation": 1,
"view": 4,
}
assert sum(sizes.values()) == 229
assert sum(sizes.values()) == 233