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
@@ -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)
|
||||
Reference in New Issue
Block a user