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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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]))
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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 ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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"))
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user