Files
Umut CANandClaude Opus 5.5 cd1ae3529c 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
2026-09-23 23:00:05 +03:00

209 lines
7.8 KiB
Python

"""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)