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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9e9138f22d
commit
b68fe7ef66
@@ -30,6 +30,16 @@ from .model import (
|
||||
wall_from_dict,
|
||||
wall_length,
|
||||
)
|
||||
from .openings import opening_prims
|
||||
from .stairs import (
|
||||
ARROW_PAPER_MM,
|
||||
BLONDEL_RANGE,
|
||||
STAIR_KINDS,
|
||||
TEXT_PAPER_MM,
|
||||
TURNS,
|
||||
blondel,
|
||||
stair_prims,
|
||||
)
|
||||
from .walls import (
|
||||
MIN_JOIN_ANGLE_DEG,
|
||||
WallGeometry,
|
||||
@@ -46,17 +56,23 @@ __all__ = [
|
||||
"ARCH_LAYER_DEFS",
|
||||
"ARCH_LAYER_ROLES",
|
||||
"ARCH_ROLE_LAYER",
|
||||
"ARROW_PAPER_MM",
|
||||
"BLONDEL_RANGE",
|
||||
"KINDS",
|
||||
"LANGS",
|
||||
"MIN_JOIN_ANGLE_DEG",
|
||||
"Opening",
|
||||
"Room",
|
||||
"STAIR_KINDS",
|
||||
"Stair",
|
||||
"TEXT_PAPER_MM",
|
||||
"TURNS",
|
||||
"WALL_HATCH_TABLE",
|
||||
"WALL_MATERIALS",
|
||||
"Wall",
|
||||
"WallGeometry",
|
||||
"axis_segments",
|
||||
"blondel",
|
||||
"decode",
|
||||
"encode",
|
||||
"face_offsets",
|
||||
@@ -64,10 +80,12 @@ __all__ = [
|
||||
"fmt_number",
|
||||
"from_payload",
|
||||
"opening_from_dict",
|
||||
"opening_prims",
|
||||
"room_from_dict",
|
||||
"room_segments",
|
||||
"segment_of",
|
||||
"stair_from_dict",
|
||||
"stair_prims",
|
||||
"tag_prefix",
|
||||
"to_payload",
|
||||
"to_values",
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Door and window symbols in plan, hosted in their wall.
|
||||
|
||||
The wall engine (`walls.py`) cuts the gap; this module draws what stands in it.
|
||||
Both read the opening's place from the same numbers - the wall's axis, its face
|
||||
offsets and ``model.segment_of`` - so the symbol cannot drift off its gap.
|
||||
|
||||
Conventions (this repository's, stated because plan symbols vary by office):
|
||||
|
||||
* ``swing="in"`` opens to the **left** of the wall's axis, looking along it
|
||||
from its first point; ``"out"`` to the right. A perimeter drawn
|
||||
counter-clockwise has its inside on the left, so "in" means into the building.
|
||||
* ``hand`` names the jamb that carries the hinges, seen by a person standing on
|
||||
the side the door swings towards and facing the wall: ``"left"`` hinges on
|
||||
their left-hand jamb.
|
||||
* A door is drawn **open at 90°**: the leaf is a line as long as the opening is
|
||||
wide, square to the wall from the hinge, on the swing-side face; the swing is
|
||||
a quarter arc about the hinge from the closed position to the open one.
|
||||
* A window is a frame line on each wall face across the opening and two
|
||||
glazing lines between them at the thirds of the wall thickness (a drafting
|
||||
convention, not a standard).
|
||||
|
||||
The first primitive is the one `draw.py` writes the opening record on: the
|
||||
door leaf, or the window's frame line on the left face.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from engineering.arch.model import Opening, Wall, segment_of
|
||||
from engineering.arch.walls import face_offsets, wall_segments
|
||||
from engineering.mech.primitives import Arc, Line, Prim, Pt
|
||||
|
||||
|
||||
def _deg(v: Pt) -> float:
|
||||
return math.degrees(math.atan2(v[1], v[0])) % 360.0
|
||||
|
||||
|
||||
def opening_prims(wall: Wall, opening: Opening) -> tuple[Prim, ...]:
|
||||
"""The door leaf and swing, or the window frame and glazing, in WCS."""
|
||||
if opening.wall != wall.id:
|
||||
raise ValueError(
|
||||
f"opening {opening.id!r} is hosted in wall {opening.wall!r}, not in {wall.id!r}"
|
||||
)
|
||||
width = float(opening.width)
|
||||
index, along = segment_of(wall, float(opening.offset))
|
||||
(ax, ay), (bx, by) = wall_segments(wall)[index]
|
||||
length = math.hypot(bx - ax, by - ay)
|
||||
u = ((bx - ax) / length, (by - ay) / length)
|
||||
n = (-u[1], u[0])
|
||||
left, right = face_offsets(wall)
|
||||
|
||||
def at(s: float, offset: float) -> Pt:
|
||||
return (ax + u[0] * s + n[0] * offset, ay + u[1] * s + n[1] * offset)
|
||||
|
||||
s0, s1 = along, along + width
|
||||
if opening.kind == "window":
|
||||
third = (left - right) / 3.0
|
||||
return (
|
||||
Line(at(s0, left), at(s1, left), "window"),
|
||||
Line(at(s0, right), at(s1, right), "window"),
|
||||
Line(at(s0, right + third), at(s1, right + third), "window"),
|
||||
Line(at(s0, right + 2.0 * third), at(s1, right + 2.0 * third), "window"),
|
||||
)
|
||||
if opening.kind != "door":
|
||||
raise ValueError(f"opening {opening.id!r}: kind {opening.kind!r}; kinds are door, window")
|
||||
if opening.swing not in ("in", "out"):
|
||||
raise ValueError(f"opening {opening.id!r}: swing {opening.swing!r}; swings are in, out")
|
||||
if opening.hand not in ("left", "right"):
|
||||
raise ValueError(f"opening {opening.id!r}: hand {opening.hand!r}; hands are left, right")
|
||||
|
||||
side = 1.0 if opening.swing == "in" else -1.0
|
||||
face = left if side > 0 else right
|
||||
# Facing the wall from the swing side, the viewer's left hand points along
|
||||
# +u when they stand on the axis's left, and along -u when on its right.
|
||||
toward = side if opening.hand == "left" else -side
|
||||
hinge_s, free_s = (s1, s0) if toward > 0 else (s0, s1)
|
||||
hinge = at(hinge_s, face)
|
||||
closed = at(free_s, face)
|
||||
opened = (hinge[0] + n[0] * side * width, hinge[1] + n[1] * side * width)
|
||||
a_closed = _deg((closed[0] - hinge[0], closed[1] - hinge[1]))
|
||||
a_open = _deg((opened[0] - hinge[0], opened[1] - hinge[1]))
|
||||
if abs(((a_open - a_closed) % 360.0) - 90.0) < 1e-6:
|
||||
swing = Arc(hinge, width, a_closed, a_open, "door")
|
||||
else:
|
||||
swing = Arc(hinge, width, a_open, a_closed, "door")
|
||||
return (Line(hinge, opened, "door"), swing)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""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]))
|
||||
Reference in New Issue
Block a user