Build your own house: walls, windows, doors, floors, roof, stairs, furniture
Deploy web Studio / deploy (push) Successful in 3m27s

House parts (stdlib isokit/house): house_wall drawn along a line, with ends
that adapt at corners, T-junctions and straight runs so no two walls
overlap; window and door that sit on a wall and cut its opening (door
styles panel, glass, double, sliding, opening; swing and hinge by
rotation); floor (boards, tiles, carpet, stone); roof (hip, gable, flat,
optional solar); stairs.

Furniture (stdlib isokit/furniture): chair, sofa / armchair, table with
chairs, desk with monitor and chair, TV, bed, cupboard, bookshelf, kitchen
run, bathtub, shower, toilet, washbasin, plant, rug, floor lamp.

Cut-open levels: on gf / 1f the walls in front of a room are cut low
(rooms are found by their floors), the roof becomes an outline on the
floor, and storeys above are left out. A part's storey comes from z
(storey_h, default 3 m).

Scripting: objects_of, objects_at, objects_on, mount_of, mounted, frame,
to_world, to_local, local_of. Prefab meta: shape (line / rect), mount,
storey.

Studio: drag to draw lines and rectangles (walls, roads, fences, hedges,
floors, areas, buildings, roofs). The Room tool draws a floor plus walls,
without doubling walls that already stand on an edge. Windows and doors
snap onto walls with a hover preview, and they move, turn, copy and
delete with their wall in one undo step. New parts land on the storey
the view shows. Palette categories collapse, and the palette scrolls
apart from the object list.

The MCP instructions explain house building, and list_prefabs shows
sits_on. examples/house is a furnished bungalow; the web Studio gets a
House demo button. Tests: wall corners and junctions, zero depth-sort
cycles in every view, cut levels, and the house demo in every view;
check_studio covers the room / window / door / storey flow, check_web
the House demo.
This commit is contained in:
grabowski committed 2026-10-24 17:39:04 +07:00
1 parent 84309a7a08
commit 95ea4a8512
26 files changed
+2137 -64

No files matched your search

+499
View File
@@ -0,0 +1,499 @@
// isokit/house: build a house from parts. Walls run along their local x;
// a window or door placed on a wall line sits on that wall (the wall leaves
// the gap, the window draws the opening); floors tell rooms from outside;
// roofs and stairs. Furniture is in isokit/furniture.
//
// Storeys: a part's storey is floor((z + 0.5) / storey_h), storey_h being
// the project parameter of that name (default 3 m). Level "gf" shows storey
// 0 cut open, "1f" storey 1 and so on: walls in front of a room are cut low,
// roofs show as an outline, parts on the storeys above are left out. Any
// other level ("site", "roof") shows the house closed.
fn storey_h() {
let h = cfg("storey_h");
let t = type_of(h);
let v = if t == "i64" { h.to_float() } else if t == "f64" { h } else { 0.0 };
if v > 0.0 { v } else { 3.0 }
}
fn storey_of(z) { ((z + 0.5) / storey_h()).floor().to_int() }
/// How part o shows on this level: 0 closed (a normal level, or a storey
/// below the one cut open), 1 on the storey cut open, 2 above it (left out).
fn house_view(o) {
let k = floor_of(o.level);
if k < 0 { return 0; }
let s = storey_of(o.z);
if s < k { 0 } else if s == k { 1 } else { 2 }
}
fn wall_cut_h() { 1.0 }
fn wall_types() { ["house_wall"] }
fn opening_types() { ["window", "door"] }
fn room_types() { ["floor"] }
fn house_wall_materials() { ["render", "render2", "brick", "wood", "wall", "wall2", "wallp", "concb", "office", "furnw"] }
fn flooring_materials() { ["f-oak", "f-wood", "f-tile", "f-stone", "f-carpet", "f-conc", "z-floor", "z-living", "z-kitchen", "z-bed", "z-wc"] }
fn house_roof_materials() { ["rooft", "roofr", "roof", "roof2", "roof3", "roofcap", "canopy"] }
/// Is there a room (a floor on wall w's storey) at (u, v) of the current frame?
fn room_at(w, u, v) {
let q = to_world(u, v);
let s = storey_of(w.z);
for f in objects_at(room_types(), q[0], q[1]) {
if storey_of(f.z) == s { return true; }
}
false
}
/// Height wall w is drawn to; call it in the wall's frame. On the storey cut
/// open a wall keeps its full height only when it stands behind a room (a
/// floor on the viewer's side of it, none behind it); the others are cut low
/// so the rooms can be seen into. Walls without floors around are cut low.
fn wall_top(w) {
let p = w.p;
if house_view(w) != 1 { return p.h; }
let near = if vis(0, -1) { -1.0 } else { 1.0 };
let off = p.t / 2.0 + 0.3;
let seen = false;
let behind = false;
for u in [-0.3 * p.w, 0.0, 0.3 * p.w] {
if room_at(w, u, near * off) { seen = true; }
if room_at(w, u, -near * off) { behind = true; }
}
if seen && !behind { p.h } else { fmin(p.h, wall_cut_h()) }
}
/// Span [u0, u1] along wall h of an opening `width` wide centred at u,
/// kept inside the wall's length.
fn opening_span(h, u, width) {
let L = h.p.w / 2.0;
let hw = fmin(width, h.p.w) / 2.0;
let c = clamp(u, -L + hw, L - hw);
[c - hw, c + hw]
}
/// Where opening o sits: #{ wall, u0, u1, rel }, with the frame set to the
/// wall's. rel: o's rotation relative to the wall in quarter turns. A
/// window or door on no wall stands alone as a short wall piece.
fn opening_at(o) {
let h = mount_of(o, wall_types());
if type_of(h) == "()" {
let w = #{ id: o.id, type: "house_wall", x: o.x, y: o.y, z: o.z, rot: o.rot, level: o.level,
p: #{ w: o.p.w, h: 2.8, t: 0.15, mat: "render" } };
return #{ wall: w, u0: -o.p.w / 2.0, u1: o.p.w / 2.0, rel: 0 };
}
frame(h);
let sp = opening_span(h, h.u, o.p.w);
let q = ((o.rot - h.rot) / 90.0).round().to_int() % 4;
if q < 0 { q += 4; }
#{ wall: h, u0: sp[0], u1: sp[1], rel: q }
}
/// A thin upright slab from plan point a to b, th thick (on the left of
/// a -> b), from z0 to z1: door leaves at any angle.
fn slab(a, b, th, z0, z1, mat) {
let dx = b[0] - a[0];
let dy = b[1] - a[1];
let L = fmax((dx * dx + dy * dy).sqrt(), 0.001);
let nx = -dy / L * th;
let ny = dx / L * th;
this.prism([a, b, [b[0] + nx, b[1] + ny], [a[0] + nx, a[1] + ny]], z0, z1, mat);
this
}
// ---------------------------------------------------------------- walls
fn meta_house_wall() {
#{ label: "House wall", category: "House", icon: "▮", shape: "line", storey: true,
help: "A wall along its length (local x); drag on the map to draw one. Windows and doors placed on it cut their openings. GF / 1F cut walls in front of a room low; walls tell rooms by their floors.",
props: [
#{ key: "w", type: "float", def: 4.0, min: 0.3, max: 60.0, step: 0.1, label: "Length (m)" },
#{ key: "h", type: "float", def: 2.8, min: 0.3, max: 8.0, step: 0.1, label: "Height (m)" },
#{ key: "t", type: "float", def: 0.15, min: 0.05, max: 0.6, step: 0.01, label: "Thickness (m)" },
#{ key: "mat", type: "material", def: "render", choices: house_wall_materials(), label: "Material" },
] }
}
fn prefab_house_wall(o) {
let p = o.p;
if house_view(o) == 2 { return; }
let top = wall_top(o);
let cap = if top < p.h { "cut" } else { p.mat };
let t = p.t / 2.0;
// gaps for the windows and doors on this wall, left to right
let gaps = [];
for m in mounted(o, opening_types(), wall_types()) {
gaps.push(opening_span(o, m.u, m.p.w));
}
gaps.sort(|a, b| if a[0] < b[0] { -1 } else if a[0] > b[0] { 1 } else { 0 });
let x = -p.w / 2.0 - wall_ext(o, -1.0);
for g in gaps {
if g[0] > x + 0.01 { wall_run(x, g[0], t, top, p.mat, cap); }
x = fmax(x, g[1]);
}
let end = p.w / 2.0 + wall_ext(o, 1.0);
if end > x + 0.01 { wall_run(x, end, t, top, p.mat, cap); }
}
fn along_world_x(rot) { ((rot / 90.0).round().to_int() % 2) == 0 }
/// How far wall o reaches past end e (-1 or 1) of its centre line. Alone:
/// half its thickness, so a corner closes. Where other walls meet it,
/// the walls must not overlap (the depth sort needs them apart): along a
/// line the two just meet; at a T this wall stops at the other's face; at
/// a corner the wall along world x runs through, the other stops at it.
fn wall_ext(o, e) {
let t = o.p.t / 2.0;
let p = to_world(e * o.p.w / 2.0, 0.0);
let ox = along_world_x(o.rot);
let s = storey_of(o.z);
let ext = t;
for w in objects_on(wall_types(), p[0], p[1], 0.0) {
if w.id == o.id || storey_of(w.z) != s { continue; }
if along_world_x(w.rot) == ox { return 0.0; }
let corner = (w.u.abs() - w.p.w / 2.0).abs() < 0.02;
if corner && ox {
ext = fmax(ext, w.p.t / 2.0);
} else {
// stop at the face of w on this wall's side
let q = to_world(e * (o.p.w / 2.0 - 0.05), 0.0);
let side = if local_of(w, q[0], q[1])[1] >= w.v { 1.0 } else { -1.0 };
return -(w.p.t / 2.0 - side * w.v);
}
}
ext
}
/// A solid stretch of wall from x0 to x1, one sprite per 30 m at most.
fn wall_run(x0, x1, t, top, mat, cap) {
let n = ((x1 - x0) / 30.0).ceiling().to_int();
let L = (x1 - x0) / n;
for i in 0..n {
sprite().box(x0 + i * L, -t, x0 + (i + 1) * L, t, 0.0, top, mat, #{ top: cap });
}
}
// ---------------------------------------------------------------- windows and doors
fn meta_window() {
#{ label: "Window", category: "House", icon: "⊞", mount: wall_types(),
help: "Place it on a house wall: it snaps onto the wall, which leaves the opening for it, and moves with the wall. On no wall it stands as a short wall piece.",
props: [
#{ key: "w", type: "float", def: 1.2, min: 0.3, max: 8.0, step: 0.05, label: "Width (m)" },
#{ key: "h", type: "float", def: 1.3, min: 0.3, max: 3.5, step: 0.05, label: "Height (m)" },
#{ key: "sill", type: "float", def: 0.9, min: 0.0, max: 2.5, step: 0.05, label: "Sill height (m)" },
#{ key: "panes", type: "int", def: 2, min: 1, max: 8, step: 1, label: "Panes" },
#{ key: "lit", type: "bool", def: false, label: "Lit at night" },
] }
}
fn prefab_window(o) {
let c = opening_at(o);
let w = c.wall;
if house_view(w) == 2 { return; }
let H = w.p.h;
let t = w.p.t / 2.0;
let top = wall_top(w);
let cap = if top < H { "cut" } else { w.p.mat };
let u0 = c.u0;
let u1 = c.u1;
let z0 = clamp(o.p.sill, 0.0, H - 0.3);
let z1 = fmin(z0 + o.p.h, H - 0.1);
let s = sprite();
// wall below the sill, the glazing with its frame, the lintel above
if z0 > 0.0 {
s.box(u0, -t, u1, t, 0.0, fmin(z0, top), w.p.mat, #{ top: if top > z0 { "sill" } else { cap } });
}
if top > z0 {
let zt = fmin(z1, top);
let pane = if o.p.lit { "glassl" } else { "glassp" };
s.box(u0, -0.025, u1, 0.025, z0, zt, pane);
let y = if vis(0, -1) { -0.03 } else { 0.03 };
let n = o.p.panes;
for i in 0..(n + 1) {
s.stem(u0 + (u1 - u0) * i / n, y, z0, zt, 0.2, "wframe");
}
s.lines([[u0, y, u1, y]], z0 + 0.01, "wframe");
if zt >= z1 { s.lines([[u0, y, u1, y]], z1, "wframe"); }
if top > z1 { s.box(u0, -t, u1, t, z1, top, w.p.mat, #{ top: cap }); }
}
}
fn meta_door() {
#{ label: "Door", category: "House", icon: "🚪", mount: wall_types(),
help: "Place it on a house wall: it snaps onto the wall and swings to the side you click on. Turning it (R) moves the hinge and then the swing side.",
props: [
#{ key: "w", type: "float", def: 0.9, min: 0.5, max: 4.0, step: 0.05, label: "Width (m)" },
#{ key: "h", type: "float", def: 2.1, min: 1.6, max: 3.5, step: 0.05, label: "Height (m)" },
#{ key: "style", type: "choice", def: "panel", choices: ["panel", "glass", "double", "sliding", "opening"], label: "Style" },
#{ key: "open", type: "bool", def: false, label: "Open" },
#{ key: "mat", type: "material", def: "wood", choices: ["wood", "woodd", "doorw", "doorg", "steel"], label: "Colour" },
] }
}
fn prefab_door(o) {
let c = opening_at(o);
let w = c.wall;
if house_view(w) == 2 { return; }
let p = o.p;
let H = w.p.h;
let t = w.p.t / 2.0;
let top = wall_top(w);
let cap = if top < H { "cut" } else { w.p.mat };
let u0 = c.u0;
let u1 = c.u1;
let zh = fmin(p.h, H - 0.05);
let zt = fmin(zh, top);
let leafcap = if top < zh { "cut" } else { "" };
// rel 0 / 1 swing to +v, 2 / 3 to -v; 0 / 2 hinge at u0, 1 / 3 at u1
let sw = if c.rel < 2 { 1.0 } else { -1.0 };
let lo = c.rel == 0 || c.rel == 2;
let s = sprite();
s.flat(rect(u0, -t, u1, t), 0.008, "sill t");
let st = p.style;
let mat = if st == "glass" { "glassp" } else { p.mat };
let y = if vis(0, -1) { -0.03 } else { 0.03 };
if st == "sliding" {
// two glass panes in a frame; open: the second one slid over the first
let m = (u0 + u1) / 2.0;
let b0 = if p.open { u0 } else { m - 0.03 };
s.box(u0 + 0.02, -0.045, m + 0.03, -0.01, 0.0, zt, "glassp");
s.box(b0, 0.01, b0 + (m + 0.03 - u0 - 0.02), 0.045, 0.0, zt, "glassp");
for u in [u0 + 0.02, m, u1 - 0.02] { s.stem(u, y, 0.0, zt, 0.2, "wframe"); }
} else if st != "opening" {
let leaves = if st == "double" { [[u0, (u0 + u1) / 2.0, true], [(u0 + u1) / 2.0, u1, false]] }
else if lo { [[u0, u1, true]] } else { [[u0, u1, false]] };
for l in leaves {
let a = l[0] + 0.02;
let b = l[1] - 0.02;
if !p.open {
s.box(a, -0.025, b, 0.025, 0.0, zt, mat, door_cap(leafcap, mat));
} else {
// swung open 80 degrees towards the swing side, hinged at a or b
let hu = if l[2] { a } else { b };
let L = b - a;
let du = if l[2] { 0.174 } else { -0.174 };
let hp = [hu, sw * t];
let tip = [hu + du * L, sw * (t + 0.985 * L)];
if l[2] == (sw > 0.0) { s.slab(hp, tip, 0.04, 0.0, zt, mat); } else { s.slab(tip, hp, 0.04, 0.0, zt, mat); }
}
if st == "glass" && !p.open {
for u in [a, b] { s.stem(u, y, 0.0, zt, 0.25, "wframe"); }
}
}
}
if top > zh { s.box(u0, -t, u1, t, zh, top, w.p.mat, #{ top: cap }); }
}
fn door_cap(cap, mat) { if cap == "" { #{ top: mat } } else { #{ top: cap } } }
// ---------------------------------------------------------------- floors, roofs, stairs
fn meta_floor() {
#{ label: "Floor", category: "House", icon: "▦", shape: "rect", storey: true,
help: "Flooring of a room; drag a rectangle on the map. Walls use floors to tell rooms from outside. Above the ground it is a slab whose top is at the object's height.",
props: [
#{ key: "w", type: "float", def: 4.0, min: 0.5, max: 100.0, step: 0.1, label: "Width (m)" },
#{ key: "d", type: "float", def: 3.0, min: 0.5, max: 100.0, step: 0.1, label: "Depth (m)" },
#{ key: "mat", type: "material", def: "f-oak", choices: flooring_materials(), label: "Flooring" },
#{ key: "pattern", type: "bool", def: true, label: "Boards / tiles" },
] }
}
fn prefab_floor(o) {
if house_view(o) == 2 { return; }
let p = o.p;
let w = p.w / 2.0;
let d = p.d / 2.0;
let ground = o.z.abs() < 0.25;
// on the ground: a surface under everything standing on it; higher up: a slab
if ground { layer("floor"); }
let s = sprite();
let z = if ground { 0.005 } else { 0.0 };
if ground {
s.flat(rect(-w, -d, w, d), z, p.mat + " t");
} else {
s.box(-w, -d, w, d, -0.2, 0.0, "plinth", #{ top: p.mat });
}
if p.pattern { floor_pattern(s, p.mat, w, d, z + 0.002); }
}
/// Board or tile joints on a floor of half size w x d at height z.
fn floor_pattern(s, m, w, d, z) {
let boards = m == "f-oak" || m == "f-wood";
let step = if boards { 0.2 } else if m == "f-tile" { 0.6 } else if m == "f-stone" { 0.8 } else { 0.0 };
if step == 0.0 { return; }
let segs = [];
let y = -d + step;
while y < d - 0.01 {
segs.push([-w, y, w, y]);
y += step;
}
if !boards {
let x = -w + step;
while x < w - 0.01 {
segs.push([x, -d, x, d]);
x += step;
}
}
if segs.len() > 0 && segs.len() < 500 { s.lines(segs, z, "grout"); }
}
fn meta_roof() {
#{ label: "Roof", category: "House", icon: "🏠", shape: "rect", storey: true,
help: "A roof over the walls: w x d is the outer size of the walls, the eaves sit h above the object. Levels that cut the house open show only its outline.",
props: [
#{ key: "w", type: "float", def: 8.0, min: 1.0, max: 100.0, step: 0.1, label: "Width (m)" },
#{ key: "d", type: "float", def: 6.0, min: 1.0, max: 100.0, step: 0.1, label: "Depth (m)" },
#{ key: "h", type: "float", def: 2.8, min: 0.0, max: 30.0, step: 0.1, label: "Eaves height (m)" },
#{ key: "style", type: "choice", def: "hip", choices: ["hip", "gable", "flat"], label: "Style" },
#{ key: "ridge", type: "choice", def: "x", choices: ["x", "y"], label: "Gable ridge along" },
#{ key: "rise", type: "float", def: 1.8, min: 0.3, max: 10.0, step: 0.1, label: "Rise (m)" },
#{ key: "over", type: "float", def: 0.4, min: 0.0, max: 2.0, step: 0.05, label: "Overhang (m)" },
#{ key: "mat", type: "material", def: "roofr", choices: house_roof_materials(), label: "Roof colour" },
#{ key: "wall_mat", type: "material", def: "render", choices: house_wall_materials(), label: "Gable ends" },
#{ key: "pv", type: "bool", def: false, label: "Solar panels" },
] }
}
fn prefab_roof(o) {
let p = o.p;
let hv = house_view(o);
if hv == 2 { return; }
if hv == 1 {
// the house is cut open on this storey: the roof's outline on the
// floor, under everything (it shows around the walls)
let w = p.w / 2.0 + p.over;
let d = p.d / 2.0 + p.over;
layer("floor");
sprite().lines(rsegs([-w, -d, w, d]), 0.02, "mark");
return;
}
let w = p.w;
let d = p.d;
// hip ridges run along the longer side; gables along the chosen axis
if (p.style == "hip" && d > w) || (p.style == "gable" && p.ridge == "y") {
frame(o.x, o.y, o.z, o.rot + 90.0);
w = p.d;
d = p.w;
}
let s = sprite();
if p.style == "flat" {
let W = w / 2.0 + p.over;
let D = d / 2.0 + p.over;
s.box(-W, -D, W, D, p.h, p.h + 0.25, p.mat, #{ side: "fascia" });
if p.pv && w > 3.0 && d > 3.0 {
let mods = [];
let y = -D + 0.8;
while y + 1.0 < D - 0.6 {
mods.push([[-W + 0.6, y, p.h + 0.35], [W - 0.6, y, p.h + 0.35], [W - 0.6, y + 1.0, p.h + 0.8], [-W + 0.6, y + 1.0, p.h + 0.8]]);
y += 2.0;
}
if mods.len() > 0 { s.poly(mods, "pv"); }
}
} else if p.style == "gable" {
house_gable(s, p, w, d);
} else {
house_hip(s, p, w, d);
}
}
/// Gable roof, ridge along local x over walls w x d; it starts at the eaves
/// height so it never reaches down beside the walls it sits on.
fn house_gable(s, p, w, d) {
let a = w / 2.0;
let h = d / 2.0;
let W = a + p.over;
let D = h + p.over;
let ze = p.h;
let k = p.rise / D;
let zr = ze + p.rise;
let zw = ze + p.over * k;
// gable ends up to the roof's underside, then the two slabs back to front
s.face([[a, -h, ze], [a, h, ze], [a, h, zw], [a, 0.0, zr], [a, -h, zw]], p.wall_mat);
s.face([[-a, h, ze], [-a, -h, ze], [-a, -h, zw], [-a, 0.0, zr], [-a, h, zw]], p.wall_mat);
let m = p.mat;
s.seq([
[0.0, -D / 2.0, |q| q.prism(rect(-W, -D, W, 0.0), [ze, ze, zr, zr], [ze + 0.15, ze + 0.15, zr + 0.15, zr + 0.15], m, #{ side: "fascia" })],
[0.0, D / 2.0, |q| q.prism(rect(-W, 0.0, W, D), [zr, zr, ze, ze], [zr + 0.15, zr + 0.15, ze + 0.15, ze + 0.15], m, #{ side: "fascia" })],
]);
if p.pv { roof_pv(s, W - 0.4, -1.0, D, k, ze + 0.15); }
}
/// Hip roof over walls w x d (w >= d), ridge along local x: a fascia band
/// at the eaves and four slopes, back to front.
fn house_hip(s, p, w, d) {
let W = w / 2.0 + p.over;
let D = d / 2.0 + p.over;
let ze = p.h;
let zb = ze + 0.15;
let zr = zb + p.rise;
let r = fmax(W - D, 0.0);
let f = 0.05;
let fm = "fascia";
s.box(-W, -D, W, -D + f, ze, zb, fm);
s.box(-W, D - f, W, D, ze, zb, fm);
s.box(-W, -D + f, -W + f, D - f, ze, zb, fm);
s.box(W - f, -D + f, W, D - f, ze, zb, fm);
let m = p.mat;
s.seq([
[0.0, -D / 2.0, |q| q.face([[-W, -D, zb], [W, -D, zb], [r, 0.0, zr], [-r, 0.0, zr]], m)],
[0.0, D / 2.0, |q| q.face([[W, D, zb], [-W, D, zb], [-r, 0.0, zr], [r, 0.0, zr]], m)],
[W - D / 2.0, 0.0, |q| q.face([[W, -D, zb], [W, D, zb], [r, 0.0, zr]], m)],
[-W + D / 2.0, 0.0, |q| q.face([[-W, D, zb], [-W, -D, zb], [-r, 0.0, zr]], m)],
]);
if p.pv { roof_pv(s, 0.0, r, D, p.rise / D, zb); }
}
/// Solar modules on the -y slope of a roof (eaves at y = -D and height ze,
/// slope k): rows L long each side of x = 0, or on a hip roof (hip = half
/// the ridge length) as long as the slope is wide at the row's top.
fn roof_pv(s, L, hip, D, k, ze) {
let cw = 1.13;
let rh = 1.7;
let rows = ((D - 0.6) / rh).floor().to_int();
let mods = [];
for i in 0..rows {
let ya0 = -D + 0.4 + i * rh + 0.03;
let half = if hip >= 0.0 { hip - (ya0 + rh) - 0.35 } else { L };
let cols = ((2.0 * half - 0.2) / cw).floor().to_int();
for j in 0..cols {
let ya = ya0;
let yb = ya + rh - 0.06;
let xa = -cols * cw / 2.0 + j * cw + 0.03;
let xb = xa + cw - 0.06;
let za = ze + (ya + D) * k + 0.08;
let zb = ze + (yb + D) * k + 0.08;
mods.push([[xa, ya, za], [xb, ya, za], [xb, yb, zb], [xa, yb, zb]]);
}
}
if mods.len() > 0 { s.poly(mods, "pv"); }
}
fn meta_stairs() {
#{ label: "Stairs", category: "House", icon: "≣", storey: true,
help: "A straight flight rising towards local +y, one storey high by default.",
props: [
#{ key: "w", type: "float", def: 1.0, min: 0.6, max: 4.0, step: 0.05, label: "Width (m)" },
#{ key: "run", type: "float", def: 3.6, min: 0.6, max: 10.0, step: 0.1, label: "Length (m)" },
#{ key: "rise", type: "float", def: 3.0, min: 0.2, max: 8.0, step: 0.05, label: "Rise (m)" },
#{ key: "mat", type: "material", def: "wood", choices: ["wood", "woodd", "concb", "steel", "furnw", "f-stone"], label: "Material" },
] }
}
fn prefab_stairs(o) {
if house_view(o) == 2 { return; }
let p = o.p;
let w = p.w / 2.0;
let n = fmax(2.0, (p.rise / 0.18).round()).to_int();
let g = p.run / n;
let r = p.rise / n;
let m = p.mat;
let items = [];
for i in 0..n {
let y0 = -p.run / 2.0 + i * g;
let zt = (i + 1) * r;
items.push([0.0, y0 + g / 2.0, |q| q.box(-w, y0, w, y0 + g, 0.0, zt, m)]);
}
sprite().seq(items);
}