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
209 lines
7.8 KiB
Python
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)
|