Files
Umut CANandClaude Opus 5.5 b68fe7ef66 feat(arch): door and window symbols, straight/L/U stairs and the Blondel check
openings.py draws what stands in the gap the wall engine cuts, from the same
numbers - the wall axis, its face offsets and model.segment_of - so the symbol
cannot drift off its gap. A door is drawn open at 90 degrees: its leaf as long
as the opening is wide, square to the wall from the hinge on the swing-side
face, and a quarter arc from the closed position to the open one. swing=in is
the left of the axis (the inside of a CCW perimeter); hand names the hinge jamb
seen from the swing side facing the wall. All four swing/hand pairs are tested
against hand-computed hinges and arc angles. A window is a frame line on each
face and two glazing lines at the thirds of the thickness.

stairs.py draws every riser once as a line across its flight, the outline, a
walking line up the middle with an open arrow at the top, and the language's
'up' word on the first tread at 2.5 mm times the plot scale. An L or a U turns
on a square landing; its first flight carries (risers + 1) // 2 risers. The
riser count and every dimension are refused by name before any geometry.

blondel() reports 2R + G against 600-650 mm - the step-length rule attributed
to Francois Blondel (1675), reported as a design rule and never as a building
code: no national stair regulation is checked.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 22:51:41 +03:00

165 lines
7.1 KiB
Python

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