diff --git a/engineering/arch/__init__.py b/engineering/arch/__init__.py index 2898cff..2c2bc00 100644 --- a/engineering/arch/__init__.py +++ b/engineering/arch/__init__.py @@ -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", diff --git a/engineering/arch/openings.py b/engineering/arch/openings.py new file mode 100644 index 0000000..f72467b --- /dev/null +++ b/engineering/arch/openings.py @@ -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) diff --git a/engineering/arch/stairs.py b/engineering/arch/stairs.py new file mode 100644 index 0000000..ecdfc7f --- /dev/null +++ b/engineering/arch/stairs.py @@ -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])) diff --git a/tests/test_arch_openings.py b/tests/test_arch_openings.py new file mode 100644 index 0000000..8cee80b --- /dev/null +++ b/tests/test_arch_openings.py @@ -0,0 +1,94 @@ +"""Door and window symbols: hinge, leaf, swing arc, frame and glazing. + +The wall runs along +X from the origin, 200 mm thick and centred, so its left +face is y = +100 and its right face y = -100. The door is 900 wide at offset +1000: its jambs are x = 1000 and x = 1900. For each swing/hand pair the hinge, +the open leaf and the arc angles are written out below by hand. +""" + +from __future__ import annotations + +import pytest + +from engineering.arch.model import Opening, Wall +from engineering.arch.openings import opening_prims +from engineering.mech.primitives import Arc, Line + +WALL = Wall(id="a", axis=((0.0, 0.0), (5000.0, 0.0)), thickness=200.0) + + +def door(swing: str, hand: str) -> Opening: + return Opening( + id="d1", wall="a", kind="door", offset=1000.0, width=900.0, swing=swing, hand=hand + ) + + +def pt(p) -> tuple[float, float]: + return (round(p[0], 9) + 0.0, round(p[1], 9) + 0.0) + + +@pytest.mark.parametrize( + ("swing", "hand", "hinge", "leaf_end", "start_deg", "end_deg"), + [ + # in = the axis's left (+Y). Facing the wall from +Y, the viewer's left is +X. + ("in", "left", (1900.0, 100.0), (1900.0, 1000.0), 90.0, 180.0), + ("in", "right", (1000.0, 100.0), (1000.0, 1000.0), 0.0, 90.0), + # out = the axis's right (-Y). Facing the wall from -Y, the viewer's left is -X. + ("out", "left", (1000.0, -100.0), (1000.0, -1000.0), 270.0, 0.0), + ("out", "right", (1900.0, -100.0), (1900.0, -1000.0), 180.0, 270.0), + ], +) +def test_a_door_hinges_on_its_hand_and_swings_a_quarter_arc_to_its_side( + swing, hand, hinge, leaf_end, start_deg, end_deg +): + leaf, arc = opening_prims(WALL, door(swing, hand)) + assert isinstance(leaf, Line) and leaf.role == "door" + assert (pt(leaf.p1), pt(leaf.p2)) == (hinge, leaf_end) + assert isinstance(arc, Arc) and arc.role == "door" + assert pt(arc.center) == hinge + assert arc.radius == 900.0 + assert (round(arc.start_deg, 9), round(arc.end_deg, 9)) == (start_deg, end_deg) + + +def test_the_leaf_is_as_long_as_the_opening_is_wide_and_square_to_the_wall(): + leaf, _arc = opening_prims(WALL, door("in", "left")) + dx = leaf.p2[0] - leaf.p1[0] + dy = leaf.p2[1] - leaf.p1[1] + assert (dx, dy) == (0.0, 900.0) + + +def test_a_door_on_a_second_segment_is_placed_along_the_whole_axis(): + bent = Wall(id="b", axis=((0.0, 0.0), (4000.0, 0.0), (4000.0, 3000.0)), thickness=200.0) + op = Opening( + id="d2", wall="b", kind="door", offset=4500.0, width=800.0, swing="in", hand="right" + ) + leaf, arc = opening_prims(bent, op) + # segment 1 runs +Y from (4000, 0); its left normal is -X, so "in" is x < 4000 + # and its left face is x = 3900. Facing +X from the left side, the viewer's + # left is +Y, so a right hand hinges at the near jamb, y = 500. + assert pt(leaf.p1) == (3900.0, 500.0) + assert pt(leaf.p2) == (3100.0, 500.0) + assert (round(arc.start_deg, 9), round(arc.end_deg, 9)) == (90.0, 180.0) + + +def test_a_window_is_two_frame_lines_and_two_glazing_lines(): + window = Opening(id="w1", wall="a", kind="window", offset=2500.0, width=1200.0) + prims = opening_prims(WALL, window) + assert len(prims) == 4 + assert all(isinstance(p, Line) and p.role == "window" for p in prims) + assert [(pt(p.p1), pt(p.p2)) for p in prims] == [ + ((2500.0, 100.0), (3700.0, 100.0)), # frame, left face + ((2500.0, -100.0), (3700.0, -100.0)), # frame, right face + ((2500.0, round(-100.0 + 200.0 / 3.0, 9)), (3700.0, round(-100.0 + 200.0 / 3.0, 9))), + ((2500.0, round(-100.0 + 400.0 / 3.0, 9)), (3700.0, round(-100.0 + 400.0 / 3.0, 9))), + ] + + +def test_an_opening_of_another_wall_is_refused(): + with pytest.raises(ValueError, match="hosted in wall 'z'"): + opening_prims(WALL, Opening(id="d1", wall="z", kind="door", offset=0.0, width=900.0)) + + +def test_an_unknown_swing_is_refused_with_the_list(): + with pytest.raises(ValueError, match="swings are in, out"): + opening_prims(WALL, door("sideways", "left")) diff --git a/tests/test_arch_stairs.py b/tests/test_arch_stairs.py new file mode 100644 index 0000000..2b701b6 --- /dev/null +++ b/tests/test_arch_stairs.py @@ -0,0 +1,180 @@ +"""Stairs in plan: tread lines, outline, walking line, label, and the Blondel rule. + +Every expected coordinate is hand-computed from the stair's numbers: a flight +of m risers at going g runs (m - 1) * g, a riser line spans the full width, and +the first flight of an L or U carries (risers + 1) // 2 risers. +""" + +from __future__ import annotations + +import pytest + +from engineering.arch.lang import vocab +from engineering.arch.model import Stair +from engineering.arch.stairs import BLONDEL_RANGE, blondel, stair_prims +from engineering.mech.primitives import Line, Poly, Text + + +def S(x1, y1, x2, y2) -> tuple: + return tuple(sorted(((float(x1), float(y1)), (float(x2), float(y2))))) + + +def segs(prims) -> list: + out = [] + for p in prims: + if isinstance(p, Line): + a = (round(p.p1[0], 9) + 0.0, round(p.p1[1], 9) + 0.0) + b = (round(p.p2[0], 9) + 0.0, round(p.p2[1], 9) + 0.0) + out.append(tuple(sorted((a, b)))) + return out + + +def path(prims) -> list: + (walk,) = [p for p in prims if isinstance(p, Poly)] + return [(round(x, 9) + 0.0, round(y, 9) + 0.0) for x, y in walk.points] + + +def stair(**kw) -> Stair: + base = { + "id": "s1", + "start": (1000.0, 2000.0), + "direction_deg": 0.0, + "width": 1000.0, + "risers": 17, + "riser_height": 170.0, + "going": 290.0, + } + return Stair(**{**base, **kw}) + + +# -- Blondel ----------------------------------------------------------------------- + + +def test_blondel_reports_two_risers_plus_a_going_against_600_to_650(): + assert BLONDEL_RANGE == (600.0, 650.0) + assert blondel(170.0, 290.0) == {"value": 630.0, "ok": True, "range": [600.0, 650.0]} + assert blondel(200.0, 300.0) == {"value": 700.0, "ok": False, "range": [600.0, 650.0]} + assert blondel(150.0, 280.0)["ok"] is False # 580 + assert blondel(175.0, 250.0)["ok"] is True # 600, the lower edge is inside + + +def test_blondel_refuses_a_non_positive_dimension_by_name(): + with pytest.raises(ValueError, match="riser_height"): + blondel(0.0, 290.0) + with pytest.raises(ValueError, match="going"): + blondel(170.0, -1.0) + + +# -- straight ----------------------------------------------------------------------- + + +def test_a_straight_stair_draws_every_riser_once_and_both_stringers(): + prims = stair_prims(stair()) + lines = segs(prims) + risers = [S(1000 + 290 * k, 1500, 1000 + 290 * k, 2500) for k in range(17)] + assert set(risers) <= set(lines) + assert S(1000, 1500, 5640, 1500) in lines # (17 - 1) * 290 = 4640 + assert S(1000, 2500, 5640, 2500) in lines + assert len(lines) == 17 + 2 + 2 # risers, stringers, the two arrow strokes + assert all(p.role == "stair" for p in prims) + + +def test_the_walking_line_runs_up_the_middle_with_an_arrow_at_the_top(): + prims = stair_prims(stair(), scale=50) + assert path(prims) == [(1000.0, 2000.0), (5640.0, 2000.0)] + lines = segs(prims) + # arrow 3 mm x 50 = 150 long, its strokes 50 either side of the line + assert S(5490, 2050, 5640, 2000) in lines + assert S(5490, 1950, 5640, 2000) in lines + + +def test_the_up_label_sits_on_the_first_tread_at_the_plot_scale_height(): + (text,) = [p for p in stair_prims(stair(), scale=50) if isinstance(p, Text)] + assert text.text == vocab("en")["up"] + assert text.height == 125.0 # 2.5 mm x 50 + # (0.15 * 290, -0.25 * 1000 - 0.5 * 125) from the start + assert (round(text.at[0], 9), round(text.at[1], 9)) == (1043.5, 1687.5) + (tr,) = [p for p in stair_prims(stair(), lang="tr") if isinstance(p, Text)] + assert tr.text == vocab("tr")["up"] + + +def test_direction_turns_the_whole_stair_about_its_start(): + prims = stair_prims(stair(direction_deg=90.0)) + lines = segs(prims) + risers = [S(500, 2000 + 290 * k, 1500, 2000 + 290 * k) for k in range(17)] + assert set(risers) <= set(lines) + assert path(prims) == [(1000.0, 2000.0), (1000.0, 6640.0)] + (text,) = [p for p in prims if isinstance(p, Text)] + assert text.rotation == 90.0 + + +# -- L and U ------------------------------------------------------------------------ + + +def test_an_l_stair_turns_left_on_a_square_landing(): + prims = stair_prims(stair(start=(0.0, 0.0), kind="l", risers=16, going=280.0)) + lines = segs(prims) + # 8 + 8 risers; the landing starts at 7 * 280 = 1960, the top riser at 500 + 7 * 280 + first = [S(280 * k, -500, 280 * k, 500) for k in range(8)] + second = [S(1960, 500 + 280 * k, 2960, 500 + 280 * k) for k in range(8)] + assert set(first + second) <= set(lines) + assert { + S(0, -500, 2960, -500), + S(2960, -500, 2960, 2460), + S(0, 500, 1960, 500), + S(1960, 500, 1960, 2460), + } <= set(lines) + assert len(lines) == 16 + 4 + 2 + assert path(prims) == [(0.0, 0.0), (2460.0, 0.0), (2460.0, 2460.0)] + + +def test_an_l_stair_turning_right_is_the_mirror_image(): + prims = stair_prims(stair(start=(0.0, 0.0), kind="l", turn="right", risers=16, going=280.0)) + lines = segs(prims) + assert S(1960, -500, 2960, -500) in lines + assert S(2960, 500, 2960, -2460) in lines + assert path(prims)[-1] == (2460.0, -2460.0) + + +def test_a_u_stair_returns_beside_the_first_flight(): + prims = stair_prims(stair(start=(0.0, 0.0), kind="u", risers=18, going=270.0, width=1100.0)) + lines = segs(prims) + # 9 + 9 risers; landing at 8 * 270 = 2160; the second flight comes back to x = 0 + first = [S(270 * k, -550, 270 * k, 550) for k in range(9)] + second = [S(2160 - 270 * k, 550, 2160 - 270 * k, 1650) for k in range(9)] + assert set(first + second) <= set(lines) + assert { + S(0, -550, 3260, -550), + S(3260, -550, 3260, 1650), + S(3260, 1650, 0, 1650), + S(0, 550, 2160, 550), + } <= set(lines) + assert len(lines) == 18 + 4 + 2 + assert path(prims) == [(0.0, 0.0), (2710.0, 0.0), (2710.0, 1100.0), (0.0, 1100.0)] + + +# -- refusals --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("kw", "match"), + [ + ({"risers": 1}, "at least 2"), + ({"kind": "l", "risers": 3}, "at least 4"), + ({"risers": True}, "whole number"), + ({"kind": "spiral"}, "kinds are straight, l, u"), + ({"turn": "up"}, "turns are left, right"), + ({"width": 0.0}, "width"), + ({"going": float("nan")}, "going"), + ({"direction_deg": "north"}, "direction_deg must be finite"), + ({"start": (0.0, float("inf"))}, r"start\[1\] must be finite"), + ], +) +def test_a_bad_stair_is_refused_before_any_geometry(kw, match): + with pytest.raises(ValueError, match=match): + stair_prims(stair(**kw)) + + +def test_an_unknown_language_is_refused(): + with pytest.raises(ValueError): + stair_prims(stair(), lang="de")