feat(arch): SECTION 25 opens - walls, openings, stairs and exterior chains as tools
engineering/arch/draw.py is the only module of the track that touches a backend. It draws through draw_prims(role_layer=ARCH_ROLE_LAYER) and the generic contract members, so it runs on both engines as written, and it writes the plan onto the drawing: each element's ACADMCP_ARCH record on one of its own entities (a wall's first face line, a door's leaf, a window's frame, a stair's first riser) and, beside it under ACADMCP_ARCH_H, the handles that element owns. read_plan reads the records back and never modifies the drawing. arch_wall computes the whole network before and after the new walls and redraws only the drawn walls whose outline changes - erasing exactly the entities they own - and says which under redrawn. arch_opening hosts a door or a window in a drawn wall found by id and redraws that wall cut; its tag defaults to the next D1/W1 (K1/P1 in Turkish). A new wall that would leave a drawn opening uncuttable, and an opening that runs into a junction, are refused before anything is erased. arch_stair reports the Blondel value; it does not enforce it. exterior_chains gives each side up to three rows, nearest first: every jamb in that facade, both faces of every wall meeting it, and the overall - from the wall engine's own faces. A row that would only restate a farther one is not emitted (ISO 129-1: each measurement once). The 10 mm / 8 mm row spacing is a declared drafting default times the plot scale, not a standard value. The arch tag is ranked after mech so the four tools file under a new architecture group; alias records carry WALLADD/MLINE, DOORADD/WINDOWADD and STAIRADD with English and Turkish shop words. Inventory regenerated; README snapshot 233 tools (228 advertised) in 26 groups; registry snapshot architecture: 4. No new ActiveX member is called. Measured on AutoCAD 2026 in a scratch document through ComBackend: shell + teed partition, D1 + W1, a straight 17-riser stair and the bottom chains gave the same 20 wall LINEs (identical endpoints), 9 poche HATCHes, 1+1 door, 4 window lines, 11 dimensions and a read_plan round trip as the headless engine. 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:
co-authored by
Claude Opus 5.5
parent
b68fe7ef66
commit
cd1ae3529c
@@ -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
|
||||
@@ -58,12 +65,16 @@ __all__ = [
|
||||
"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",
|
||||
@@ -75,6 +86,7 @@ __all__ = [
|
||||
"blondel",
|
||||
"decode",
|
||||
"encode",
|
||||
"exterior_chains",
|
||||
"face_offsets",
|
||||
"fmt_area_m2",
|
||||
"fmt_number",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user