From 95ea4a85129d95acb7744fb22ee1bb9032871678 Mon Sep 17 00:00:00 2001 From: grabowski Date: Sat, 24 Oct 2026 17:39:04 +0700 Subject: [PATCH] Build your own house: walls, windows, doors, floors, roof, stairs, furniture 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. --- .gitea/workflows/deploy-web.yml | 1 + README.md | 37 +- crates/isokit-project/src/mcp.rs | 15 +- crates/isokit-project/tests/demo.rs | 26 ++ crates/isokit-script/src/api.rs | 202 ++++++++- crates/isokit-script/src/doc.rs | 13 +- crates/isokit-script/src/stdlib.rs | 2 + crates/isokit-script/src/tests.rs | 201 +++++++++ crates/isokit-script/stdlib/building.rhai | 6 +- crates/isokit-script/stdlib/furniture.rhai | 476 ++++++++++++++++++++ crates/isokit-script/stdlib/ground.rhai | 10 +- crates/isokit-script/stdlib/house.rhai | 499 +++++++++++++++++++++ crates/isokit-script/stdlib/nature.rhai | 4 +- crates/isokit-theme/themes/default.toml | 90 ++++ docs/ROADMAP.md | 35 +- docs/SCRIPTING.md | 46 +- examples/house/isokit.toml | 34 ++ examples/house/scene.json | 72 +++ examples/house/theme.toml | 1 + studio/index.html | 7 +- studio/studio.css | 16 +- studio/studio.js | 299 ++++++++++-- tools/build_web.py | 9 +- tools/check_studio.py | 83 ++++ tools/check_web.py | 12 + web/web.js | 5 +- 26 files changed, 2137 insertions(+), 64 deletions(-) create mode 100644 crates/isokit-script/stdlib/furniture.rhai create mode 100644 crates/isokit-script/stdlib/house.rhai create mode 100644 examples/house/isokit.toml create mode 100644 examples/house/scene.json create mode 100644 examples/house/theme.toml diff --git a/.gitea/workflows/deploy-web.yml b/.gitea/workflows/deploy-web.yml index f11397e..77c25c9 100644 --- a/.gitea/workflows/deploy-web.yml +++ b/.gitea/workflows/deploy-web.yml @@ -20,6 +20,7 @@ on: - "viewer/**" - "web/**" - "examples/demo/**" + - "examples/house/**" - "tools/build_web.py" - "Cargo.toml" - "Cargo.lock" diff --git a/README.md b/README.md index 9322ecd..12ace5d 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,10 @@ click for details. One Rust binary does everything: same operations; their changes show up live in an open Studio. - **Build**: a self-contained HTML page with every view prerendered, or standalone SVGs for documents. +- **Build your own house**: walls, windows, doors, floors, roofs and stairs, + plus furniture (sofa, TV, bed, kitchen, table and chairs, desk, bath, …). + Draw rooms by dragging; windows and doors snap onto walls; the GF / 1F levels + cut the house open like a doll's house. - **Engine and scripting**: everything you can place is a *prefab* written in [Rhai](https://rhai.rs). Projects add their own prefabs, scripts and colours without touching the engine. @@ -34,13 +38,15 @@ isokit studio C:/dev/myproject/site-view # opens the editor in the browser isokit build C:/dev/myproject/site-view # dist/index.html, no server needed ``` -Try the demo: `isokit studio examples/demo`. +Try the demos: `isokit studio examples/demo` (a works site) and +`isokit studio examples/house` (a furnished house built from parts). ### Studio (for everyone) | Do | How | |---|---| | Add | pick an object on the left, click on the map (Shift-click places several) | +| Draw | walls, roads, fences, hedges: drag along the map; floors, areas, buildings, roofs, rugs: drag a rectangle | | Select | click it on the map or in the object list | | Move | drag it, or arrow keys by the snap step (0.1 / 0.5 / 1 / 5 m) | | Turn | R / Shift+R or ⟲ ⟳ | @@ -48,6 +54,30 @@ Try the demo: `isokit studio examples/demo`. | Copy / delete | Ctrl+D / Delete; Ctrl+Z / Ctrl+Y undo and redo | | Look | drag the ground to pan, Ctrl+wheel or pinch to zoom, ↺ ↻ rotate, Site / GF / 1F levels | +### Building a house + +The **House** palette has the parts, **Furniture** the interior: + +1. **Room**: drag a rectangle; you get a floor and a house wall on each edge + (walls already standing along an edge are not doubled). Or draw single + **House walls** by dragging along their line, and **Floors** as rectangles. +2. **Window** / **Door**: click on a wall. They snap onto its line, the wall + leaves the opening, and they move, turn, copy and delete with the wall. A + door opens to the side you clicked on; R turns it to the other hinge, then + to the other side. Styles: panel, glass, double, sliding, plain opening. +3. **Furniture**: chair, sofa / armchair, table with chairs, desk with monitor + and chair, TV, bed, cupboard, bookshelf, kitchen run (sink, hob, wall + cabinets, fridge), bathtub, shower, toilet, washbasin, plant, rug, floor + lamp. Fronts face local -y, so the back goes against a wall; R turns them. +4. **Roof** (hip, gable, flat, optional solar) as a rectangle over the walls, + and **Stairs**. + +Level **GF** shows the house cut open: walls in front of a room are cut low +(walls tell inside from outside by the floors next to them), the roof becomes an +outline on the ground. For more storeys, view **1F** (2F, …): new walls, floors +and furniture go onto that storey (3 m apart, or the project parameter +`storey_h`), and the storeys below show closed. + Every change is saved to `scene.json` at once. When the file changes on disk (an agent, git pull, another editor), the Studio reloads it, and that change can be undone like any other. @@ -70,7 +100,7 @@ requests. In addition: | | | |---|---| -| **Projects ▾** | list, open, delete; new project; demo copy; export as `.zip`; import a `.zip` or a project folder (drop it on the strip, or pick it) | +| **Projects ▾** | list, open, delete; new project; demo copy; house demo; export as `.zip`; import a `.zip` or a project folder (drop it on the strip, or pick it) | | **Open folder live** | Chrome / Edge: edits are written straight to the folder's files, and changes made there (an agent running `isokit mcp `, an editor, `git pull`) show up within 1.5 s and can be undone | | **{ } Code** | edit `scene.rhai`, `lib/*.rhai` prefabs, `theme.toml`, `isokit.toml`; Ctrl+S applies, script errors show with file and line | | **Build page** | downloads the self-contained HTML page (a folder project gets `dist/index.html` like `isokit build`) | @@ -175,7 +205,8 @@ crates/isokit-wasm the engine for the browser (wasm-bindgen): a project in m viewer/ isokit-view.js/.css: pan, zoom, levels, rotation, tooltips (any page can embed it) studio/ the Studio GUI (served by `isokit studio`, and by the web Studio); agent.js = agent window web/ web Studio shell: WASM backend for studio.js, projects, folders, zip, code editor -examples/demo demo project +examples/demo demo project (works site) +examples/house house built from parts (walls, windows, doors, floors, furniture, roof) deploy/Caddyfile the web Studio's vhost on the buildfor.life web server (s1) ``` diff --git a/crates/isokit-project/src/mcp.rs b/crates/isokit-project/src/mcp.rs index 5fea8e8..f0101b7 100644 --- a/crates/isokit-project/src/mcp.rs +++ b/crates/isokit-project/src/mcp.rs @@ -16,7 +16,12 @@ const INSTRUCTIONS: &str = "isokit edits an isometric scene (a site plan drawn a World coordinates are metres: x east, y north, z up. The scene is a list of objects; each has a type (a prefab), \ a position (x, y = centre of its footprint), a rotation in 90 degree steps and props from the prefab's schema. \ Start with project_info and list_prefabs, then add_object / update_object. Use render_view (summary) to see where \ -everything ended up and check_project to find problems. Edits are saved at once and show live in an open Studio."; +everything ended up and check_project to find problems. Edits are saved at once and show live in an open Studio. \ +Houses are built from parts (categories House and Furniture): a floor per room (w x d), a house_wall along each edge \ +(x, y = middle of the wall line, rot 0 runs along x, rot 90 along y, w = length; walls meet at their centre lines), \ +then windows and doors with x, y on a wall line (they sit on that wall and cut their opening; a door swings towards \ +the wall's +y side, rot + 180 swings it the other way), a roof (w x d = outer size of the walls) and furniture \ +(fronts face local -y, backs go against a wall). Level gf shows the house cut open; a part's storey is its z / 3 m."; fn tool(name: &str, description: &str, props: Value, required: &[&str]) -> Value { json!({ "name": name, "description": description, @@ -140,7 +145,7 @@ pub fn call_tool(ws: &mut Workspace, name: &str, a: &Map(ws: &mut Workspace, name: &str, a: &Map>() }) + }).collect::>() }); + if !m.mount.is_empty() { + v["sits_on"] = json!(m.mount); + } + v }) .collect(); text(json!(list)) diff --git a/crates/isokit-project/tests/demo.rs b/crates/isokit-project/tests/demo.rs index 698c247..6e3aa6e 100644 --- a/crates/isokit-project/tests/demo.rs +++ b/crates/isokit-project/tests/demo.rs @@ -72,6 +72,32 @@ fn demo_passes_the_check() { assert!(f.is_empty(), "{f:?}"); } +/// The house demo: every view draws every object without warnings or depth-sort cycles +/// (walls must not overlap), and the project passes the check. +#[test] +fn house_demo_renders_every_view_cleanly() { + let p = + Project::load(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/house")).unwrap(); + assert!(p.runtime.is_ok(), "{:?}", p.runtime.as_ref().err()); + for l in p.manifest.levels() { + for rot in 0..4 { + let v = p.render(&l.id, rot, None, false).unwrap(); + assert!(v.warnings.is_empty(), "{}/{rot}: {:?}", l.id, v.warnings); + assert_eq!(v.cycles, 0, "{}/{rot}: depth-sort cycles", l.id); + assert!(!v.html.contains("NaN")); + for o in &p.doc.objects { + assert!( + v.boxes.contains_key(&o.id), + "{} not drawn in {}/{rot}", + o.id, + l.id + ); + } + } + } + assert!(check::check(&p).is_empty(), "{:?}", check::check(&p)); +} + #[test] fn static_page_and_svg_export() { let p = Project::load(demo()).unwrap(); diff --git a/crates/isokit-script/src/api.rs b/crates/isokit-script/src/api.rs index edfe79f..3ccf65e 100644 --- a/crates/isokit-script/src/api.rs +++ b/crates/isokit-script/src/api.rs @@ -3,7 +3,7 @@ use crate::conv::*; use crate::State; use isokit_core::fmt::{hash, round1, thousands}; -use isokit_core::{classes, LayerOpts, Sprite, C}; +use isokit_core::{classes, LayerOpts, Sprite, Xf, C}; use rhai::{Array, Dynamic, Engine, FnPtr, Map, NativeCallContext}; use std::cell::RefCell; use std::rc::Rc; @@ -702,6 +702,7 @@ pub(crate) fn register_scene(e: &mut Engine, st: &Shared) { }); let s = st.clone(); e.register_fn("objects", move || s.borrow().all_objects.clone()); + register_objects(e, st); // is a face with outward normal (nx, ny) (object coordinates) turned towards the viewer? let s = st.clone(); e.register_fn("vis", move |nx: D, ny: D| -> RR { @@ -783,3 +784,202 @@ pub(crate) fn register_scene(e: &mut Engine, st: &Shared) { Ok(m) }); } + +// ---------------------------------------------------------------- objects and frames + +/// A type name or a list of them. +fn kinds(d: &Dynamic) -> Vec { + match d.read_lock::() { + Some(a) => a.iter().map(|v| v.to_string()).collect(), + None => vec![d.to_string()], + } +} + +fn fnum(m: &Map, k: &str) -> f64 { + m.get(k).and_then(|v| n(v).ok()).unwrap_or(0.0) +} + +fn sget(m: &Map, k: &str) -> String { + m.get(k).map(|v| v.to_string()).unwrap_or_default() +} + +/// A number prop of a document object (`o.p.`). +fn prop(m: &Map, k: &str) -> Option { + m.get("p").and_then(|p| { + p.read_lock::() + .and_then(|p| p.get(k).and_then(|v| n(v).ok())) + }) +} + +/// The placement frame of a document object. +fn xf_of(m: &Map) -> Xf { + Xf::new(fnum(m, "x"), fnum(m, "y"), fnum(m, "z"), fnum(m, "rot")) +} + +fn of_type(d: &Dynamic, ks: &[String]) -> bool { + d.read_lock::() + .is_some_and(|m| ks.iter().any(|k| *k == sget(&m, "type"))) +} + +/// The object with (u, v), its position in another object's frame. +fn with_uv(d: &Dynamic, u: f64, v: f64) -> Dynamic { + let mut m = d.read_lock::().map(|m| m.clone()).unwrap_or_default(); + m.insert("u".into(), Dynamic::from_float(u)); + m.insert("v".into(), Dynamic::from_float(v)); + Dynamic::from_map(m) +} + +/// The host `o` sits on: of the objects of the `hosts` types at the same +/// height, the one whose centre line (local x, length `p.w`) passes within +/// `p.t / 2 + tol` of o's position; the nearest wins, then the first. +/// Returns its index and o's position (u, v) in the host's frame. +fn host_index(objs: &[Dynamic], o: &Map, hosts: &[String], tol: f64) -> Option<(usize, f64, f64)> { + let (x, y, z) = (fnum(o, "x"), fnum(o, "y"), fnum(o, "z")); + let id = sget(o, "id"); + let mut best: Option<(usize, f64, f64)> = None; + for (i, d) in objs.iter().enumerate() { + let Some(h) = d.read_lock::() else { + continue; + }; + if !hosts.iter().any(|k| *k == sget(&h, "type")) || sget(&h, "id") == id { + continue; + } + let xf = xf_of(&h); + if (z - xf.z).abs() > 0.5 { + continue; + } + let l = xf.inv(x, y); + let half = prop(&h, "w").unwrap_or(1.0) / 2.0; + let lim = prop(&h, "t").unwrap_or(0.0) / 2.0 + tol; + if l[0].abs() <= half + 1e-6 + && l[1].abs() <= lim + 1e-6 + && best.is_none_or(|b| l[1].abs() < b.2.abs() - 1e-9) + { + best = Some((i, l[0], l[1])); + } + } + best +} + +/// Queries over the document objects shown on this level, and frames. +fn register_objects(e: &mut Engine, st: &Shared) { + // objects of a type (or a list of types) shown on this level + let s = st.clone(); + e.register_fn("objects_of", move |k: D| -> Array { + let ks = kinds(&k); + s.borrow() + .objects + .iter() + .filter(|d| of_type(d, &ks)) + .cloned() + .collect() + }); + // ... whose footprint (p.w x p.d around the origin) holds the world point + let s = st.clone(); + e.register_fn("objects_at", move |k: D, x: D, y: D| -> RR { + let (ks, x, y) = (kinds(&k), n(&x)?, n(&y)?); + Ok(s.borrow() + .objects + .iter() + .filter(|d| { + of_type(d, &ks) + && d.read_lock::().is_some_and(|m| { + let (Some(w), Some(dd)) = (prop(&m, "w"), prop(&m, "d")) else { + return false; + }; + let l = xf_of(&m).inv(x, y); + l[0].abs() <= w / 2.0 + 1e-6 && l[1].abs() <= dd / 2.0 + 1e-6 + }) + }) + .cloned() + .collect()) + }); + // ... whose centre line (local x, length p.w) passes within p.t / 2 + tol + // of the world point, each with (u, v): the point in its frame + let s = st.clone(); + e.register_fn("objects_on", move |k: D, x: D, y: D, tol: D| -> RR { + let (ks, x, y, tol) = (kinds(&k), n(&x)?, n(&y)?, n(&tol)?); + Ok(s.borrow() + .objects + .iter() + .filter(|d| of_type(d, &ks)) + .filter_map(|d| { + let m = d.read_lock::()?; + let l = xf_of(&m).inv(x, y); + let half = prop(&m, "w").unwrap_or(1.0) / 2.0; + let lim = prop(&m, "t").unwrap_or(0.0) / 2.0 + tol; + (l[0].abs() <= half + 1e-6 && l[1].abs() <= lim + 1e-6) + .then(|| with_uv(d, l[0], l[1])) + }) + .collect()) + }); + // the host an object sits on (with u, v: the object's position in the + // host's frame), or () + fn mount_of(s: &Shared, o: &Map, hosts: &D, tol: f64) -> Dynamic { + let b = s.borrow(); + match host_index(&b.objects, o, &kinds(hosts), tol) { + Some((i, u, v)) => with_uv(&b.objects[i], u, v), + None => Dynamic::UNIT, + } + } + let s = st.clone(); + e.register_fn("mount_of", move |o: Map, hosts: D| { + mount_of(&s, &o, &hosts, 0.3) + }); + let s = st.clone(); + e.register_fn("mount_of", move |o: Map, hosts: D, tol: D| -> RR { + Ok(mount_of(&s, &o, &hosts, n(&tol)?)) + }); + // the objects of the given types that sit on host h (each with u, v) + fn mounted(s: &Shared, h: &Map, k: &D, hosts: &D, tol: f64) -> Array { + let b = s.borrow(); + let (ks, hs, hid) = (kinds(k), kinds(hosts), sget(h, "id")); + b.objects + .iter() + .filter(|d| of_type(d, &ks)) + .filter_map(|d| { + let m = d.read_lock::()?; + let (i, u, v) = host_index(&b.objects, &m, &hs, tol)?; + let on = b.objects[i] + .read_lock::() + .is_some_and(|x| sget(&x, "id") == hid); + on.then(|| with_uv(d, u, v)) + }) + .collect() + } + let s = st.clone(); + e.register_fn("mounted", move |h: Map, k: D, hosts: D| { + mounted(&s, &h, &k, &hosts, 0.3) + }); + let s = st.clone(); + e.register_fn( + "mounted", + move |h: Map, k: D, hosts: D, tol: D| -> RR { + Ok(mounted(&s, &h, &k, &hosts, n(&tol)?)) + }, + ); + // draw in another frame from here on (sprites made later, vis, tags) + let s = st.clone(); + e.register_fn("frame", move |o: Map| { + s.borrow_mut().xf = xf_of(&o); + }); + let s = st.clone(); + e.register_fn("frame", move |x: D, y: D, z: D, rot: D| -> RR<()> { + s.borrow_mut().xf = Xf::new(n(&x)?, n(&y)?, n(&z)?, n(&rot)?); + Ok(()) + }); + let s = st.clone(); + e.register_fn("to_world", move |x: D, y: D| -> RR { + let p = s.borrow().xf.p(n(&x)?, n(&y)?); + Ok(vec![Dynamic::from_float(p[0]), Dynamic::from_float(p[1])]) + }); + let s = st.clone(); + e.register_fn("to_local", move |x: D, y: D| -> RR { + let p = s.borrow().xf.inv(n(&x)?, n(&y)?); + Ok(vec![Dynamic::from_float(p[0]), Dynamic::from_float(p[1])]) + }); + e.register_fn("local_of", |o: Map, x: D, y: D| -> RR { + let p = xf_of(&o).inv(n(&x)?, n(&y)?); + Ok(vec![Dynamic::from_float(p[0]), Dynamic::from_float(p[1])]) + }); +} diff --git a/crates/isokit-script/src/doc.rs b/crates/isokit-script/src/doc.rs index ca6b209..fb1cadd 100644 --- a/crates/isokit-script/src/doc.rs +++ b/crates/isokit-script/src/doc.rs @@ -105,9 +105,20 @@ pub struct PrefabMeta { pub icon: String, #[serde(default)] pub help: String, - /// How the Studio places it: "point" (click), "path" (click points). + /// How the Studio places it: "point" (click), "line" (drag along: sets + /// `rot` and the length `w`), "rect" (drag a rectangle: sets `w` and + /// `d`), "path" (click points). #[serde(default = "default_shape")] pub shape: String, + /// Types this prefab sits on (windows on walls): the Studio snaps it + /// onto the nearest one and moves, turns, copies and deletes it with + /// that host. The prefab finds its host with `mount_of`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mount: Vec, + /// Placed on the storey the view shows: on level "1f" the Studio puts + /// it at z = 1 x storey height (parameter `storey_h`, default 3 m). + #[serde(default, skip_serializing_if = "is_false")] + pub storey: bool, #[serde(default)] pub props: Vec, /// Module the prefab comes from ("isokit/site", "lib/kiosk.rhai"). diff --git a/crates/isokit-script/src/stdlib.rs b/crates/isokit-script/src/stdlib.rs index a14f17b..85096f5 100644 --- a/crates/isokit-script/src/stdlib.rs +++ b/crates/isokit-script/src/stdlib.rs @@ -13,4 +13,6 @@ pub const MODULES: &[(&str, &str)] = &[ ("isokit/vehicles", include_str!("../stdlib/vehicles.rhai")), ("isokit/people", include_str!("../stdlib/people.rhai")), ("isokit/equipment", include_str!("../stdlib/equipment.rhai")), + ("isokit/house", include_str!("../stdlib/house.rhai")), + ("isokit/furniture", include_str!("../stdlib/furniture.rhai")), ]; diff --git a/crates/isokit-script/src/tests.rs b/crates/isokit-script/src/tests.rs index efc1fbc..f19ff3f 100644 --- a/crates/isokit-script/src/tests.rs +++ b/crates/isokit-script/src/tests.rs @@ -290,3 +290,204 @@ fn cfg_reaches_the_script() { let b = r.build("site", 0, &c, &Doc::default()).unwrap(); assert_eq!(b.scene.layers[2].sprites[0].world_box().unwrap()[5], 9.0); } + +/// A 4 x 3 m room: floor, four walls on its edges, a window and a door. +fn room_doc() -> Doc { + let mut doc = Doc::default(); + doc.objects + .push(obj("floor", "floor", 0.0, 0.0, json!({"w": 4, "d": 3}))); + for (id, x, y, rot, w) in [ + ("south", 0.0, -1.5, 0.0, 4.0), + ("north", 0.0, 1.5, 0.0, 4.0), + ("west", -2.0, 0.0, 90.0, 3.0), + ("east", 2.0, 0.0, 90.0, 3.0), + ] { + let mut o = obj(id, "house_wall", x, y, json!({ "w": w })); + o.rot = rot; + doc.objects.push(o); + } + // a little off the wall line: still on the south wall + doc.objects + .push(obj("win", "window", 0.5, -1.4, json!({"w": 1.2}))); + let mut door = obj("door", "door", 2.05, 0.4, json!({"open": true})); + door.rot = 90.0; + doc.objects.push(door); + doc +} + +fn sprites_of(b: &crate::Built, id: &str) -> usize { + b.scene + .layers + .iter() + .flat_map(|l| l.sprites.iter()) + .filter(|s| s.obj.as_deref() == Some(id)) + .count() +} + +#[test] +fn windows_and_doors_sit_on_walls() { + let r = Runtime::new(&Bundle::default()).unwrap(); + let doc = room_doc(); + let b = r.build("site", 0, &cfg(), &doc).unwrap(); + assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings); + // the window snaps onto the south wall's line and the wall leaves a gap + let w = b.boxes["win"]; + assert!( + (w[1] + 1.575).abs() < 1e-6 && (w[4] + 1.425).abs() < 1e-6, + "{w:?}" + ); + assert!( + (w[0] + 0.1).abs() < 1e-6 && (w[3] - 1.1).abs() < 1e-6, + "{w:?}" + ); + assert_eq!(sprites_of(&b, "south"), 2, "wall split around the window"); + assert_eq!(sprites_of(&b, "north"), 1); + assert_eq!(sprites_of(&b, "east"), 2, "wall split around the door"); + // the open door swings into the room (rot 90 on a rot 90 wall: towards -x) + let d = b.boxes["door"]; + assert!(d[0] < 1.5 && d[3] <= 2.08, "{d:?}"); + let html = render(&b.scene, 0, &RenderOpts::default()).html; + assert!(html.contains("iso-glassp")); + // corners: the walls along x run through, the others stop at their faces + let (sw, ww) = (b.boxes["south"], b.boxes["west"]); + assert!( + (sw[0] + 2.075).abs() < 1e-6 && (sw[3] - 2.075).abs() < 1e-6, + "{sw:?}" + ); + assert!( + (ww[1] + 1.425).abs() < 1e-6 && (ww[4] - 1.425).abs() < 1e-6, + "{ww:?}" + ); + for rot in 0..4u8 { + for lv in ["site", "gf"] { + let b = r.build(lv, rot, &cfg(), &doc).unwrap(); + let out = render(&b.scene, rot, &RenderOpts::default()); + assert_eq!(out.cycles, 0, "{lv}/{rot}: depth-sort cycles"); + } + } + // moved off every wall, the window stands alone as a wall piece + let mut alone = doc.clone(); + alone.get_mut("win").unwrap().y = -5.0; + let b = r.build("site", 0, &cfg(), &alone).unwrap(); + assert_eq!(sprites_of(&b, "south"), 1); + assert!((b.boxes["win"][5] - 2.8).abs() < 1e-6); +} + +#[test] +fn walls_meeting_at_a_t_or_in_line_do_not_overlap() { + let r = Runtime::new(&Bundle::default()).unwrap(); + let mut doc = room_doc(); + let mut inner = obj("inner", "house_wall", 0.0, 0.0, json!({"w": 3.0, "t": 0.1})); + inner.rot = 90.0; + doc.objects.push(inner); + // the north wall in two pieces meeting at x = 1 + doc.get_mut("north").unwrap().props = json!({"w": 3.0}).as_object().cloned().unwrap(); + doc.get_mut("north").unwrap().x = -0.5; + doc.objects + .push(obj("north2", "house_wall", 1.5, 1.5, json!({"w": 1.0}))); + let b = r.build("site", 1, &cfg(), &doc).unwrap(); + let i = b.boxes["inner"]; + assert!( + (i[1] + 1.425).abs() < 1e-6 && (i[4] - 1.425).abs() < 1e-6, + "{i:?}" + ); + let (n1, n2) = (b.boxes["north"], b.boxes["north2"]); + assert!( + (n1[3] - 1.0).abs() < 1e-6 && (n2[0] - 1.0).abs() < 1e-6, + "{n1:?} {n2:?}" + ); + assert!((n1[0] + 2.075).abs() < 1e-6 && (n2[3] - 2.075).abs() < 1e-6); +} + +#[test] +fn cut_levels_lower_the_walls_in_front_of_rooms() { + let r = Runtime::new(&Bundle::default()).unwrap(); + let mut doc = room_doc(); + doc.objects + .push(obj("roof", "roof", 0.0, 0.0, json!({"w": 4.15, "d": 3.15}))); + let mut up = obj("chair", "chair", 0.0, 0.0, json!({})); + up.z = 3.0; + doc.objects.push(up); + for rot in 0..4u8 { + let site = r.build("site", rot, &cfg(), &doc).unwrap(); + let gf = r.build("gf", rot, &cfg(), &doc).unwrap(); + let tops = |b: &crate::Built| -> Vec { + ["south", "north", "west", "east"] + .iter() + .map(|id| b.boxes[*id][5]) + .collect() + }; + assert!(tops(&site).iter().all(|z| (z - 2.8).abs() < 1e-6)); + // two walls face the viewer: cut low; the two behind the room stay full + let t = tops(&gf); + let low = t.iter().filter(|z| (*z - 1.0).abs() < 1e-6).count(); + let full = t.iter().filter(|z| (*z - 2.8).abs() < 1e-6).count(); + assert_eq!((low, full), (2, 2), "rotation {rot}: {t:?}"); + // the roof is a full roof on the site level, an outline when cut open + assert!(site.boxes["roof"][5] > 4.0); + assert!( + (gf.boxes["roof"][5] - 0.02).abs() < 1e-6, + "outline on the floor" + ); + // the chair on the first floor: left out on GF, there on 1F + assert!(!gf.boxes.contains_key("chair")); + let f1 = r.build("1f", rot, &cfg(), &doc).unwrap(); + assert!(f1.boxes.contains_key("chair")); + assert!(f1.boxes["south"][5] > 2.7, "GF closed below the cut storey"); + assert!(gf.scene.warnings.is_empty(), "{:?}", gf.scene.warnings); + } +} + +#[test] +fn object_queries_and_frames() { + let r = rt(r#" + let s = objects_of("floor"); + let a = objects_at(["floor"], 1.9, 1.4); + let b = objects_at(["floor"], 2.1, 0.0); + let w = objects_of("house_wall")[3]; + frame(w); + let p = to_world(1.0, 0.5); + let q = to_local(p[0], p[1]); + let m = mount_of(objects_of("door")[0], ["house_wall"]); + let on = mounted(w, ["window", "door"], ["house_wall"]); + frame(0, 0, 0, 0); + sprite("probe").box(0, 0, s.len(), a.len() + b.len() * 10, 0, 1, "wall"); + sprite("frame").box(p[0], p[1], p[0] + q[0], p[1] + q[1], 0, 1, "wall"); + sprite("mount").box(0, 0, m.u + 10.0, on.len(), 0, m.v + 10.0, "wall"); + "#); + let b = r.build("site", 0, &cfg(), &room_doc()).unwrap(); + let probe = b.scene.layers[2] + .sprites + .iter() + .find(|s| s.id == "probe") + .unwrap() + .world_box() + .unwrap(); + assert_eq!( + (probe[3], probe[4]), + (1.0, 1.0), + "one floor, holding one point" + ); + let f = b.scene.layers[2] + .sprites + .iter() + .find(|s| s.id == "frame") + .unwrap() + .world_box() + .unwrap(); + // east wall at (2, 0) turned 90: local (1, 0.5) is world (1.5, 1) + assert_eq!([f[0], f[1], f[3], f[4]], [1.5, 1.0, 2.5, 1.5]); + let m = b.scene.layers[2] + .sprites + .iter() + .find(|s| s.id == "mount") + .unwrap() + .world_box() + .unwrap(); + // the door at (2.05, 0.4) is 0.4 along the east wall, 0.05 off it + assert!( + (m[3] - 10.4).abs() < 1e-9 && (m[5] - 9.95).abs() < 1e-9, + "{m:?}" + ); + assert_eq!(m[4], 1.0, "one opening on the east wall"); +} diff --git a/crates/isokit-script/stdlib/building.rhai b/crates/isokit-script/stdlib/building.rhai index 70fc911..c8d0703 100644 --- a/crates/isokit-script/stdlib/building.rhai +++ b/crates/isokit-script/stdlib/building.rhai @@ -11,7 +11,7 @@ fn roof_materials() { ["roof", "roof2", "roof3", "rooft", "roofcap", "canopy"] } fn floor_materials() { ["z-floor", "z-living", "z-offices", "z-kitchen", "z-shop", "z-lab", "z-wh", "z-plant", "z-garage", "z-hall", "z-sport"] } fn meta_building() { - #{ label: "Building", category: "Buildings", icon: "🏢", + #{ label: "Building", category: "Buildings", icon: "🏢", shape: "rect", help: "Storeys with windows, a door on the front (local -y) face, a flat or gable roof and optional solar. The GF / 1F levels cut it open.", props: [ #{ key: "w", type: "float", def: 20.0, min: 2.0, max: 300.0, step: 0.5, label: "Width (m)" }, @@ -165,7 +165,7 @@ fn flat_roof_kit(s, p, zt) { } fn meta_carport() { - #{ label: "Carport", category: "Buildings", icon: "⌂", + #{ label: "Carport", category: "Buildings", icon: "⌂", shape: "rect", help: "A canopy on posts with cars parked under it.", props: [ #{ key: "w", type: "float", def: 6.0, min: 2.5, max: 60.0, step: 0.5, label: "Width (m)" }, @@ -203,7 +203,7 @@ fn prefab_carport(o) { } fn meta_shed() { - #{ label: "Shed / kiosk", category: "Buildings", icon: "▣", + #{ label: "Shed / kiosk", category: "Buildings", icon: "▣", shape: "rect", props: [ #{ key: "w", type: "float", def: 4.0, min: 1.0, max: 30.0, step: 0.5, label: "Width (m)" }, #{ key: "d", type: "float", def: 3.0, min: 1.0, max: 30.0, step: 0.5, label: "Depth (m)" }, diff --git a/crates/isokit-script/stdlib/furniture.rhai b/crates/isokit-script/stdlib/furniture.rhai new file mode 100644 index 0000000..9e37018 --- /dev/null +++ b/crates/isokit-script/stdlib/furniture.rhai @@ -0,0 +1,476 @@ +// isokit/furniture: interior pieces for houses and offices. Every piece +// faces local -y (its front; the back goes against a wall at +y) and stands +// on its object's z, so it can sit on any storey (see isokit/house). Pieces +// on storeys above the one a level cuts open are left out. + +fn furniture_hidden(o) { house_view(o) == 2 } +fn wood_choices() { ["wood", "woodd", "furnw", "steel", "kitchenc"] } +fn fabric_choices() { ["uph1", "uph2", "uph3", "uph4", "sofa", "bed", "cloth"] } + +/// A chair at (x, y) with its back towards (bx, by) (one of the axes). +fn chair_at(x, y, bx, by, mat) { + let h = 0.22; + for c in [[-1, -1], [1, -1], [1, 1], [-1, 1]] { + let lx = x + c[0] * 0.19; + let ly = y + c[1] * 0.19; + this.box(lx - 0.02, ly - 0.02, lx + 0.02, ly + 0.02, 0.02, 0.42, mat); + } + this.box(x - h, y - h, x + h, y + h, 0.42, 0.47, mat); + if bx != 0 { + let a = x + bx * 0.18; + let b = x + bx * 0.22; + this.box(fmin(a, b), y - h, fmax(a, b), y + h, 0.47, 0.92, mat); + } else { + let a = y + by * 0.18; + let b = y + by * 0.22; + this.box(x - h, fmin(a, b), x + h, fmax(a, b), 0.47, 0.92, mat); + } + this +} + +/// An office chair at (x, y) with its back towards -y. +fn office_chair(x, y, mat) { + this.disc(x, y, 0.03, 0.27, "keyb"); + this.stem(x, y, 0.03, 0.45, 0.25, "metal"); + this.box(x - 0.24, y - 0.22, x + 0.24, y + 0.24, 0.45, 0.52, mat); + this.box(x - 0.22, y - 0.26, x + 0.22, y - 0.21, 0.52, 1.0, mat); + this +} + +fn meta_chair() { + #{ label: "Chair", category: "Furniture", icon: "🪑", storey: true, + help: "A dining chair; its back is on the +y side.", + props: [ #{ key: "mat", type: "material", def: "wood", choices: wood_choices(), label: "Colour" } ] } +} +fn prefab_chair(o) { + if furniture_hidden(o) { return; } + sprite().chair_at(0.0, 0.0, 0, 1, o.p.mat); +} + +fn meta_sofa() { + #{ label: "Sofa", category: "Furniture", icon: "🛋", storey: true, + help: "Seats face -y; one seat makes an armchair.", + props: [ + #{ key: "seats", type: "int", def: 3, min: 1, max: 5, step: 1, label: "Seats" }, + #{ key: "mat", type: "material", def: "uph1", choices: fabric_choices(), label: "Fabric" }, + ] } +} +fn prefab_sofa(o) { + if furniture_hidden(o) { return; } + let m = o.p.mat; + let n = o.p.seats; + let W = n * 0.7 / 2.0 + 0.18; + let s = sprite(); + s.box(-W, -0.45, W, 0.45, 0.02, 0.4, m); + s.seq([ + [0.0, 0.35, |q| q.box(-W, 0.25, W, 0.45, 0.4, 0.85, m)], + [0.0, -0.1, |q| { + for i in 0..n { + let x0 = -W + 0.18 + i * 0.7; + q.box(x0 + 0.01, -0.44, x0 + 0.69, 0.25, 0.4, 0.5, m); + } + }], + [-W + 0.09, -0.1, |q| q.box(-W, -0.45, -W + 0.18, 0.25, 0.4, 0.64, m)], + [W - 0.09, -0.1, |q| q.box(W - 0.18, -0.45, W, 0.25, 0.4, 0.64, m)], + ]); +} + +fn meta_table() { + #{ label: "Table", category: "Furniture", icon: "🍽", storey: true, + help: "A table with chairs pulled out along its long sides (and its ends from six chairs on).", + props: [ + #{ key: "w", type: "float", def: 1.6, min: 0.6, max: 6.0, step: 0.05, label: "Length (m)" }, + #{ key: "d", type: "float", def: 0.9, min: 0.5, max: 2.0, step: 0.05, label: "Width (m)" }, + #{ key: "h", type: "float", def: 0.75, min: 0.4, max: 1.1, step: 0.01, label: "Height (m)" }, + #{ key: "chairs", type: "int", def: 4, min: 0, max: 16, step: 1, label: "Chairs" }, + #{ key: "mat", type: "material", def: "wood", choices: wood_choices(), label: "Colour" }, + #{ key: "chair_mat", type: "material", def: "woodd", choices: wood_choices(), label: "Chairs" }, + ] } +} +fn prefab_table(o) { + if furniture_hidden(o) { return; } + let p = o.p; + let w = p.w / 2.0; + let d = p.d / 2.0; + let s = sprite(); + for c in [[-1, -1], [1, -1], [1, 1], [-1, 1]] { + let x = c[0] * (w - 0.07); + let y = c[1] * (d - 0.07); + s.box(x - 0.03, y - 0.03, x + 0.03, y + 0.03, 0.02, p.h - 0.04, p.mat); + } + s.box(-w, -d, w, d, p.h - 0.04, p.h, p.mat); + // each chair its own sprite, clear of the table so the depth sort can order them + let n = p.chairs; + let ends = if n >= 6 { 2 } else { 0 }; + let front = (n - ends + 1) / 2; + let back = n - ends - front; + for i in 0..front { sprite().chair_at(-w + (i + 0.5) * p.w / front, -d - 0.24, 0, -1, p.chair_mat); } + for i in 0..back { sprite().chair_at(-w + (i + 0.5) * p.w / back, d + 0.24, 0, 1, p.chair_mat); } + if ends > 0 { + sprite().chair_at(-w - 0.24, 0.0, -1, 0, p.chair_mat); + sprite().chair_at(w + 0.24, 0.0, 1, 0, p.chair_mat); + } +} + +fn meta_desk() { + #{ label: "Desk", category: "Furniture", icon: "🖥", storey: true, + help: "A desk with a monitor and an office chair on its -y side.", + props: [ + #{ key: "w", type: "float", def: 1.4, min: 0.8, max: 4.0, step: 0.05, label: "Width (m)" }, + #{ key: "d", type: "float", def: 0.7, min: 0.5, max: 1.2, step: 0.05, label: "Depth (m)" }, + #{ key: "mat", type: "material", def: "furnw", choices: wood_choices(), label: "Colour" }, + #{ key: "monitor", type: "bool", def: true, label: "Monitor" }, + #{ key: "chair", type: "bool", def: true, label: "Chair" }, + #{ key: "chair_mat", type: "material", def: "uph1", choices: fabric_choices(), label: "Chair colour" }, + ] } +} +fn prefab_desk(o) { + if furniture_hidden(o) { return; } + let p = o.p; + let w = p.w / 2.0; + let d = p.d / 2.0; + let h = 0.75; + let s = sprite(); + s.box(-w, -d, -w + 0.04, d, 0.02, h - 0.03, p.mat); + s.box(w - 0.04, -d, w, d, 0.02, h - 0.03, p.mat); + s.box(-w + 0.04, d - 0.04, w - 0.04, d, 0.3, h - 0.03, p.mat); + s.box(-w, -d, w, d, h - 0.03, h, p.mat); + if p.monitor { + s.flat(rect(-0.22, -d + 0.1, 0.22, -d + 0.25), h + 0.004, "keyb"); + s.box(-0.04, d - 0.2, 0.04, d - 0.12, h, h + 0.12, "tvbody"); + s.box(-0.3, d - 0.18, 0.3, d - 0.15, h + 0.1, h + 0.45, "tvbody"); + s.open([-0.3, d - 0.18], [0.3, d - 0.18], [[0.02, 0.58, h + 0.12, h + 0.43]], "tvs on"); + } + if p.chair { sprite().office_chair(0.0, -d - 0.3, p.chair_mat); } +} + +fn meta_tv() { + #{ label: "TV", category: "Furniture", icon: "📺", storey: true, + help: "A TV on a low cabinet (or on the wall), screen towards -y.", + props: [ + #{ key: "size", type: "choice", def: "55", choices: ["32", "43", "55", "65", "75", "85"], label: "Size (inch)" }, + #{ key: "stand", type: "bool", def: true, label: "On a cabinet" }, + #{ key: "on", type: "bool", def: true, label: "Switched on" }, + #{ key: "mat", type: "material", def: "woodd", choices: wood_choices(), label: "Cabinet" }, + ] } +} +fn prefab_tv(o) { + if furniture_hidden(o) { return; } + let p = o.p; + let sw = parse_float(p.size) * 0.0254 * 0.8716; + let sh = sw * 9.0 / 16.0; + let s = sprite(); + let zb = 1.0; + let y0 = 0.15; + if p.stand { + let cw = fmax(1.6, sw + 0.3) / 2.0; + s.box(-cw, -0.22, cw, 0.22, 0.02, 0.5, p.mat); + s.open([-cw, -0.22], [cw, -0.22], [[0.04, cw - 0.02, 0.08, 0.44], [cw + 0.02, 2.0 * cw - 0.04, 0.08, 0.44]], "seam"); + s.box(-0.15, -0.08, 0.15, 0.08, 0.5, 0.53, "tvbody"); + s.box(-0.03, 0.0, 0.03, 0.04, 0.53, 0.62, "tvbody"); + zb = 0.6; + y0 = 0.0; + } + s.box(-sw / 2.0, y0, sw / 2.0, y0 + 0.05, zb, zb + sh, "tvbody"); + s.open([-sw / 2.0, y0], [sw / 2.0, y0], [[0.02, sw - 0.02, zb + 0.02, zb + sh - 0.02]], if p.on { "tvs on" } else { "tvs" }); +} + +fn meta_bed() { + #{ label: "Bed", category: "Furniture", icon: "🛏", storey: true, + help: "Head (headboard, pillows) at +y.", + props: [ + #{ key: "size", type: "choice", def: "double", choices: ["single", "double", "king"], label: "Size" }, + #{ key: "mat", type: "material", def: "uph2", choices: fabric_choices(), label: "Duvet" }, + #{ key: "frame", type: "material", def: "woodd", choices: wood_choices(), label: "Frame" }, + #{ key: "nightstands", type: "bool", def: true, label: "Nightstands" }, + ] } +} +fn prefab_bed(o) { + if furniture_hidden(o) { return; } + let p = o.p; + let w = (if p.size == "single" { 0.9 } else if p.size == "king" { 1.8 } else { 1.6 }) / 2.0; + let L = 1.0; + let fr = p.frame; + let duvet = p.mat; + let two = w > 0.6; + sprite().seq([ + [0.0, L + 0.04, |q| q.box(-w - 0.04, L, w + 0.04, L + 0.08, 0.02, 0.95, fr)], + [0.0, 0.0, |q| { + q.box(-w - 0.04, -L, w + 0.04, L, 0.02, 0.3, fr); + q.box(-w, -L + 0.02, w, L - 0.02, 0.3, 0.5, "cloth"); + if two { + q.box(-w + 0.06, L - 0.45, -0.03, L - 0.08, 0.5, 0.62, "cloth"); + q.box(0.03, L - 0.45, w - 0.06, L - 0.08, 0.5, 0.62, "cloth"); + } else { + q.box(-w + 0.06, L - 0.45, w - 0.06, L - 0.08, 0.5, 0.62, "cloth"); + } + q.box(-w - 0.02, -L, w + 0.02, L - 0.55, 0.5, 0.58, duvet); + }], + ]); + if p.nightstands { + for side in [-1, 1] { + let x = side * (w + 0.32); + let n = sprite(); + n.box(x - 0.22, L - 0.42, x + 0.22, L - 0.02, 0.02, 0.5, fr); + n.cyl(x, L - 0.2, 0.08, 0.5, 0.75, "lshade"); + } + } +} + +fn meta_cupboard() { + #{ label: "Cupboard", category: "Furniture", icon: "▯", storey: true, + help: "A wardrobe or cabinet, doors on the -y side.", + props: [ + #{ key: "w", type: "float", def: 1.2, min: 0.3, max: 6.0, step: 0.05, label: "Width (m)" }, + #{ key: "d", type: "float", def: 0.6, min: 0.3, max: 1.0, step: 0.05, label: "Depth (m)" }, + #{ key: "h", type: "float", def: 2.0, min: 0.4, max: 3.0, step: 0.05, label: "Height (m)" }, + #{ key: "mat", type: "material", def: "wardrobe", choices: ["wardrobe", "wood", "woodd", "furnw", "kitchenc"], label: "Colour" }, + ] } +} +fn prefab_cupboard(o) { + if furniture_hidden(o) { return; } + let p = o.p; + let w = p.w / 2.0; + let d = p.d / 2.0; + let s = sprite(); + s.box(-w, -d, w, d, 0.02, p.h, p.mat); + let n = fmax(1.0, (p.w / 0.5).round()).to_int(); + let dw = p.w / n; + let doors = []; + for i in 0..n { doors.push([i * dw + 0.015, (i + 1) * dw - 0.015, 0.06, p.h - 0.04]); } + s.open([-w, -d], [w, -d], doors, "seam"); +} + +fn meta_shelf() { + #{ label: "Bookshelf", category: "Furniture", icon: "📚", storey: true, + help: "Open shelves facing -y.", + props: [ + #{ key: "w", type: "float", def: 1.0, min: 0.3, max: 6.0, step: 0.05, label: "Width (m)" }, + #{ key: "d", type: "float", def: 0.35, min: 0.2, max: 0.8, step: 0.05, label: "Depth (m)" }, + #{ key: "h", type: "float", def: 1.9, min: 0.4, max: 3.0, step: 0.05, label: "Height (m)" }, + #{ key: "books", type: "bool", def: true, label: "Books" }, + #{ key: "mat", type: "material", def: "wood", choices: wood_choices(), label: "Colour" }, + ] } +} +fn prefab_shelf(o) { + if furniture_hidden(o) { return; } + let p = o.p; + let w = p.w / 2.0; + let d = p.d / 2.0; + let s = sprite(); + s.box(-w, -d, w, d, 0.02, p.h, p.mat); + let n = fmax(1.0, ((p.h - 0.1) / 0.36).floor()).to_int(); + let sh = (p.h - 0.08) / n; + let comp = []; + let bks = [[], [], []]; + for i in 0..n { + let z0 = 0.06 + i * sh; + let z1 = z0 + sh - 0.03; + comp.push([0.03, p.w - 0.03, z0, z1]); + if p.books { + let u = 0.05; + let j = 0; + while u < p.w - 0.12 { + let bw = 0.04 + 0.08 * hash(i * 13.1 + j * 3.7 + p.w); + if hash(j * 7.9 + i * 2.3) > 0.88 { u += bw + 0.06; j += 1; continue; } + let bh = (z1 - z0) * (0.62 + 0.3 * hash(j * 5.3 + i)); + bks[j % 3].push([u, fmin(u + bw, p.w - 0.05), z0, z0 + bh]); + u += bw + 0.006; + j += 1; + } + } + } + let a = [-w, -d]; + let b = [w, -d]; + s.open(a, b, comp, "open"); + for k in 0..3 { + if bks[k].len() > 0 { s.open(a, b, bks[k], "bk" + (k + 1)); } + } +} + +fn meta_kitchen() { + #{ label: "Kitchen", category: "Furniture", icon: "🍳", storey: true, + help: "A kitchen run: base cabinets and worktop with sink and hob, wall cabinets and a fridge at the +x end. Front towards -y.", + props: [ + #{ key: "w", type: "float", def: 3.0, min: 0.6, max: 10.0, step: 0.05, label: "Length (m)" }, + #{ key: "mat", type: "material", def: "kitchenc", choices: ["kitchenc", "furnw", "wood", "woodd", "doorg"], label: "Cabinets" }, + #{ key: "top", type: "material", def: "worktop", choices: ["worktop", "wood", "steel", "furnw"], label: "Worktop" }, + #{ key: "sink", type: "bool", def: true, label: "Sink" }, + #{ key: "hob", type: "bool", def: true, label: "Hob" }, + #{ key: "upper", type: "bool", def: true, label: "Wall cabinets" }, + #{ key: "fridge", type: "bool", def: true, label: "Fridge" }, + ] } +} +fn prefab_kitchen(o) { + if furniture_hidden(o) { return; } + let p = o.p; + let w = p.w / 2.0; + let fr = p.fridge && p.w >= 1.3; + let x1 = if fr { w - 0.64 } else { w }; + let L = x1 + w; + let m = p.mat; + let tp = p.top; + let items = [[(x1 - w) / 2.0, 0.0, |q| { + q.box(-w, -0.28, x1, 0.3, 0.02, 0.86, m); + let n = fmax(1.0, (L / 0.6).round()).to_int(); + let dw = L / n; + let fronts = []; + let uppers = []; + for i in 0..n { + fronts.push([i * dw + 0.01, (i + 1) * dw - 0.01, 0.1, 0.84]); + uppers.push([i * dw + 0.01, (i + 1) * dw - 0.01, 1.47, 2.13]); + } + q.open([-w, -0.28], [x1, -0.28], fronts, "seam"); + q.box(-w, -0.32, x1, 0.3, 0.86, 0.9, tp); + if p.sink && L >= 1.0 { + let sx = -w + fmin(0.5, L * 0.3); + q.flat(rect(sx - 0.24, -0.2, sx + 0.24, 0.14), 0.904, "sink"); + q.stem(sx, 0.22, 0.9, 1.18, 0.16, "metal"); + } + if p.hob && L >= 1.8 { + let hx = x1 - 0.5; + q.flat(rect(hx - 0.3, -0.24, hx + 0.3, 0.22), 0.904, "hob"); + for c in [[-1, -1], [1, -1], [1, 1], [-1, 1]] { q.disc(hx + c[0] * 0.14, c[1] * 0.11, 0.906, 0.08, "hobr"); } + } + if p.upper { + q.box(-w, -0.05, x1, 0.3, 1.45, 2.15, m); + q.open([-w, -0.05], [x1, -0.05], uppers, "seam"); + } + }]]; + if fr { + items.push([x1 + 0.33, 0.0, |q| { + q.box(x1 + 0.02, -0.32, w, 0.3, 0.02, 1.9, "fridge"); + q.open([x1 + 0.02, -0.32], [w, -0.32], [[0.03, w - x1 - 0.05, 0.06, 1.12], [0.03, w - x1 - 0.05, 1.16, 1.86]], "seam"); + }]); + } + sprite().seq(items); +} + +fn meta_bath() { + #{ label: "Bathtub", category: "Furniture", icon: "🛁", storey: true, + props: [ + #{ key: "w", type: "float", def: 1.7, min: 1.0, max: 2.4, step: 0.05, label: "Length (m)" }, + #{ key: "d", type: "float", def: 0.75, min: 0.6, max: 1.4, step: 0.05, label: "Width (m)" }, + ] } +} +fn prefab_bath(o) { + if furniture_hidden(o) { return; } + let w = o.p.w / 2.0; + let d = o.p.d / 2.0; + let s = sprite(); + s.box(-w, -d, w, d, 0.02, 0.56, "sanit"); + s.flat(rect(-w + 0.08, -d + 0.08, w - 0.08, d - 0.08), 0.562, "poolw t"); + s.lines(rsegs([-w + 0.08, -d + 0.08, w - 0.08, d - 0.08]), 0.563, "seam"); + s.stem(w - 0.1, 0.0, 0.56, 0.76, 0.15, "metal"); +} + +fn meta_shower() { + #{ label: "Shower", category: "Furniture", icon: "🚿", storey: true, + help: "A tray with glass on its -y and +x sides; the riser is in the back left corner.", + props: [ + #{ key: "w", type: "float", def: 0.9, min: 0.7, max: 2.0, step: 0.05, label: "Width (m)" }, + #{ key: "d", type: "float", def: 0.9, min: 0.7, max: 2.0, step: 0.05, label: "Depth (m)" }, + ] } +} +fn prefab_shower(o) { + if furniture_hidden(o) { return; } + let w = o.p.w / 2.0; + let d = o.p.d / 2.0; + let s = sprite(); + s.box(-w, -d, w, d, 0.02, 0.08, "sanit"); + s.flat(rect(-w + 0.05, -d + 0.05, w - 0.05, d - 0.05), 0.082, "shower t"); + s.disc(0.0, 0.0, 0.083, 0.04, "keyb"); + s.stem(-w + 0.06, d - 0.06, 0.08, 2.05, 0.14, "metal"); + s.disc(-w + 0.2, d - 0.2, 2.0, 0.1, "metal"); + s.box(-w, -d, w, -d + 0.02, 0.08, 2.0, "glassp"); + s.box(w - 0.02, -d + 0.02, w, d, 0.08, 2.0, "glassp"); +} + +fn meta_toilet() { + #{ label: "Toilet", category: "Furniture", icon: "🚽", storey: true, help: "Cistern at +y.", props: [] } +} +fn prefab_toilet(o) { + if furniture_hidden(o) { return; } + sprite().seq([ + [0.0, 0.25, |q| q.box(-0.19, 0.17, 0.19, 0.33, 0.02, 0.8, "sanit")], + [0.0, -0.05, |q| { + q.frust(0.0, -0.05, 0.13, 0.19, 0.02, 0.42, "sanit", 12); + q.disc(0.0, -0.05, 0.422, 0.14, "seam"); + }], + ]); +} + +fn meta_basin() { + #{ label: "Washbasin", category: "Furniture", icon: "🚰", storey: true, + help: "A vanity unit with a basin and a mirror on the wall behind (+y).", + props: [ + #{ key: "w", type: "float", def: 0.6, min: 0.4, max: 2.4, step: 0.05, label: "Width (m)" }, + #{ key: "mirror", type: "bool", def: true, label: "Mirror" }, + #{ key: "mat", type: "material", def: "furnw", choices: wood_choices(), label: "Cabinet" }, + ] } +} +fn prefab_basin(o) { + if furniture_hidden(o) { return; } + let w = o.p.w / 2.0; + let s = sprite(); + s.box(-w, -0.22, w, 0.23, 0.02, 0.82, o.p.mat); + s.open([-w, -0.22], [w, -0.22], [[0.02, w - 0.01, 0.1, 0.8], [w + 0.01, 2.0 * w - 0.02, 0.1, 0.8]], "seam"); + s.box(-w, -0.24, w, 0.23, 0.82, 0.86, "sanit"); + s.flat(rect(-w + 0.08, -0.17, w - 0.08, 0.1), 0.862, "sink"); + s.stem(0.0, 0.17, 0.86, 1.02, 0.14, "metal"); + if o.p.mirror { s.box(-w + 0.04, 0.21, w - 0.04, 0.23, 1.05, 1.75, "glassp"); } +} + +fn meta_plant() { + #{ label: "Plant", category: "Furniture", icon: "🪴", storey: true, + props: [ + #{ key: "h", type: "float", def: 1.0, min: 0.3, max: 2.5, step: 0.05, label: "Height (m)" }, + #{ key: "pot", type: "material", def: "terra", choices: ["terra", "furnw", "concb", "woodd"], label: "Pot" }, + ] } +} +fn prefab_plant(o) { + if furniture_hidden(o) { return; } + let h = o.p.h; + let r = 0.1 + h * 0.08; + let zp = 0.02 + r * 1.5; + let cr = 0.15 + h * 0.2; + let s = sprite(); + s.frust(0.0, 0.0, r * 0.75, r, 0.02, zp, o.p.pot, 10); + s.ext_box(-cr, -cr, cr, cr, zp, zp + h); + s.stem(0.0, 0.0, zp, zp + h * 0.5, 0.25, "trunk"); + let R = cr * px() * 1.18; + s.circle(0.0, 0.0, zp + h * 0.62, R, "leaf l"); + s.circle(0.0, 0.0, zp + h * 0.62, R * 0.58, "leaf t", -R * 0.28, -R * 0.3); +} + +fn meta_rug() { + #{ label: "Rug", category: "Furniture", icon: "▭", shape: "rect", storey: true, + props: [ + #{ key: "w", type: "float", def: 2.0, min: 0.4, max: 10.0, step: 0.05, label: "Width (m)" }, + #{ key: "d", type: "float", def: 1.4, min: 0.4, max: 10.0, step: 0.05, label: "Depth (m)" }, + #{ key: "mat", type: "material", def: "rug1", choices: ["rug1", "rug2", "rug3", "uph1", "uph3", "cloth"], label: "Colour" }, + ] } +} +fn prefab_rug(o) { + if furniture_hidden(o) { return; } + let w = o.p.w / 2.0; + let d = o.p.d / 2.0; + if o.z.abs() < 0.25 { layer("floor"); } + let s = sprite(); + s.flat(rect(-w, -d, w, d), 0.012, o.p.mat + " t"); + let i = fmin(0.1, fmin(w, d) * 0.2); + s.lines(rsegs([-w + i, -d + i, w - i, d - i]), 0.013, "rugl"); +} + +fn meta_floor_lamp() { + #{ label: "Floor lamp", category: "Furniture", icon: "💡", storey: true, + props: [ #{ key: "on", type: "bool", def: true, label: "Lit" } ] } +} +fn prefab_floor_lamp(o) { + if furniture_hidden(o) { return; } + let s = sprite(); + s.disc(0.0, 0.0, 0.025, 0.16, "keyb"); + s.stem(0.0, 0.0, 0.02, 1.4, 0.12, "metal"); + s.frust(0.0, 0.0, 0.22, 0.14, 1.32, 1.62, "lshade", 10); + if o.p.on { s.circle(0.0, 0.0, 1.5, 1.1, "lamp"); } +} diff --git a/crates/isokit-script/stdlib/ground.rhai b/crates/isokit-script/stdlib/ground.rhai index 53d63bc..c80a676 100644 --- a/crates/isokit-script/stdlib/ground.rhai +++ b/crates/isokit-script/stdlib/ground.rhai @@ -7,7 +7,7 @@ fn ground_materials() { } fn meta_site() { - #{ label: "Site ground", category: "Ground", icon: "▭", + #{ label: "Site ground", category: "Ground", icon: "▭", shape: "rect", help: "The ground plate: a surface with earth sides. Frames the view.", props: [ #{ key: "w", type: "float", def: 60.0, min: 4.0, max: 1000.0, step: 1.0, label: "Width (m)" }, @@ -26,7 +26,7 @@ fn prefab_site(o) { } fn meta_area() { - #{ label: "Area", category: "Ground", icon: "▱", + #{ label: "Area", category: "Ground", icon: "▱", shape: "rect", help: "A paved or planted area lying on the ground.", props: [ #{ key: "w", type: "float", def: 10.0, min: 0.5, max: 1000.0, step: 0.5, label: "Width (m)" }, @@ -46,7 +46,7 @@ fn prefab_area(o) { } fn meta_road() { - #{ label: "Road", category: "Ground", icon: "═", + #{ label: "Road", category: "Ground", icon: "═", shape: "line", help: "A straight road along its length, with a dashed centre line.", props: [ #{ key: "w", type: "float", def: 40.0, min: 2.0, max: 2000.0, step: 1.0, label: "Length (m)" }, @@ -90,7 +90,7 @@ fn prefab_parking(o) { } fn meta_wall() { - #{ label: "Wall", category: "Site", icon: "▬", + #{ label: "Wall", category: "Site", icon: "▬", shape: "line", props: [ #{ key: "w", type: "float", def: 20.0, min: 0.5, max: 500.0, step: 0.5, label: "Length (m)" }, #{ key: "h", type: "float", def: 2.2, min: 0.3, max: 8.0, step: 0.1, label: "Height (m)" }, @@ -109,7 +109,7 @@ fn prefab_wall(o) { } fn meta_fence() { - #{ label: "Fence", category: "Site", icon: "┼", + #{ label: "Fence", category: "Site", icon: "┼", shape: "line", props: [ #{ key: "w", type: "float", def: 20.0, min: 1.0, max: 500.0, step: 0.5, label: "Length (m)" }, #{ key: "h", type: "float", def: 1.8, min: 0.5, max: 4.0, step: 0.1, label: "Height (m)" }, diff --git a/crates/isokit-script/stdlib/house.rhai b/crates/isokit-script/stdlib/house.rhai new file mode 100644 index 0000000..9472d08 --- /dev/null +++ b/crates/isokit-script/stdlib/house.rhai @@ -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); +} diff --git a/crates/isokit-script/stdlib/nature.rhai b/crates/isokit-script/stdlib/nature.rhai index e37b34f..b6f6267 100644 --- a/crates/isokit-script/stdlib/nature.rhai +++ b/crates/isokit-script/stdlib/nature.rhai @@ -20,7 +20,7 @@ fn prefab_tree(o) { } fn meta_tree_row() { - #{ label: "Row of trees", category: "Nature", icon: "🌲", + #{ label: "Row of trees", category: "Nature", icon: "🌲", shape: "line", props: [ #{ key: "w", type: "float", def: 30.0, min: 2.0, max: 500.0, step: 1.0, label: "Length (m)" }, #{ key: "spacing", type: "float", def: 6.0, min: 2.0, max: 30.0, step: 0.5, label: "Spacing (m)" }, @@ -38,7 +38,7 @@ fn prefab_tree_row(o) { } fn meta_hedge() { - #{ label: "Hedge", category: "Nature", icon: "▬", + #{ label: "Hedge", category: "Nature", icon: "▬", shape: "line", props: [ #{ key: "w", type: "float", def: 10.0, min: 0.5, max: 300.0, step: 0.5, label: "Length (m)" }, #{ key: "d", type: "float", def: 0.6, min: 0.3, max: 3.0, step: 0.1, label: "Thickness (m)" }, diff --git a/crates/isokit-theme/themes/default.toml b/crates/isokit-theme/themes/default.toml index 0c9fc70..7efdc0e 100644 --- a/crates/isokit-theme/themes/default.toml +++ b/crates/isokit-theme/themes/default.toml @@ -224,6 +224,31 @@ lounger = "#b9b3a6" tower = "#8fa3bd" ringl = "rgba(235, 238, 242, .55)" genset = "#56685c" +# house parts and furniture +brick = "#8a4f3e" +woodd = "#5a4231" +oak = "#9c7b52" +furnw = "#b9bcc2" +uph1 = "#6a7080" +uph2 = "#3f5f8f" +uph3 = "#4f7a5a" +uph4 = "#9a4a3c" +worktop = "#3a3f48" +fridge = "#aab2bd" +tvbody = "#15181e" +tvs = "#0d1016" +tvon = "#4f8fd8" +ftile = "#5f6c78" +fcarpet = "#5a5470" +fstone = "#6d6a66" +terra = "#9a5a3c" +rug1 = "#7a3f3a" +rug2 = "#2f4f6f" +rug3 = "#6b5a3a" +wframe = "#c8ccd2" +sill = "#9aa0a8" +lshade = "#d9c9a0" +roofr = "#8a4a3a" [palette.light] sky1 = "#a9d4fb" @@ -443,6 +468,31 @@ lounger = "#f1ece0" tower = "#d6e2ef" ringl = "rgba(255, 255, 255, .95)" genset = "#7f9a86" +# house parts and furniture +brick = "#b8664c" +woodd = "#7a5a40" +oak = "#d2ad7c" +furnw = "#f4f4f2" +uph1 = "#9aa1ad" +uph2 = "#5b7fbf" +uph3 = "#6fa47a" +uph4 = "#c76a55" +worktop = "#5b616b" +fridge = "#e8ecf0" +tvbody = "#22262e" +tvs = "#1c2129" +tvon = "#6fb0f0" +ftile = "#d9dde2" +fcarpet = "#a99fc4" +fstone = "#cfc8bd" +terra = "#c4774f" +rug1 = "#b75a4f" +rug2 = "#5d86b0" +rug3 = "#c9b07a" +wframe = "#ffffff" +sill = "#e6e2da" +lshade = "#f2e6c8" +roofr = "#b8613f" [materials] warn = "#e0412f" @@ -648,6 +698,33 @@ z-living = "color-mix(in srgb, var(--iso-floor) 64%, #c8a070)" z-bed = "color-mix(in srgb, var(--iso-floor) 70%, #d8b88a)" z-garage = "color-mix(in srgb, var(--iso-floor) 88%, #8d96a8)" genset = "genset" +# house parts and furniture +brick = "brick" +woodd = "woodd" +furnw = "furnw" +uph1 = "uph1" +uph2 = "uph2" +uph3 = "uph3" +uph4 = "uph4" +worktop = "worktop" +fridge = "fridge" +tvbody = "tvbody" +cloth = "cloth" +terra = "terra" +rug1 = "rug1" +rug2 = "rug2" +rug3 = "rug3" +sill = "sill" +lshade = "lshade" +roofr = "roofr" +doorw = "furnw" +doorg = "door" +f-oak = "oak" +f-wood = "wood" +f-tile = "ftile" +f-carpet = "fcarpet" +f-stone = "fstone" +f-conc = "conc" [rules] ".iso-ground .iso-dirt" = "stroke: var(--iso-edge); stroke-width: .4" @@ -730,3 +807,16 @@ genset = "genset" ".iso-svg .iso-netw" = "fill: none; stroke: var(--iso-netw); stroke-width: 1.4; stroke-dasharray: 5 3" ".iso-svg .iso-nete" = "fill: none; stroke: var(--iso-nete); stroke-width: 1.4; stroke-dasharray: 2 2" ".iso-svg .iso-cable" = "fill: none; stroke: var(--iso-cable); stroke-width: .5" +".iso-svg .iso-wframe" = "fill: var(--iso-wframe); stroke: var(--iso-wframe); stroke-width: .3" +".iso-svg .iso-glassl, .iso-svg .iso-glassl.iso-t" = "fill-opacity: .6; --b: var(--iso-winlit)" +".iso-svg .iso-tvs" = "fill: var(--iso-tvs); stroke: none" +".iso-svg .iso-tvs.iso-on" = "fill: var(--iso-tvon)" +".iso-svg .iso-grout" = "fill: none; stroke: var(--iso-edge); stroke-width: .25; opacity: .4" +".iso-svg .iso-rugl" = "fill: none; stroke: rgba(255, 255, 255, .4); stroke-width: .4" +".iso-svg .iso-bk1" = "fill: #8c3b33; stroke: none" +".iso-svg .iso-bk2" = "fill: #2f5d8a; stroke: none" +".iso-svg .iso-bk3" = "fill: #c9a23a; stroke: none" +".iso-svg .iso-hob" = "fill: #16191f; stroke: none" +".iso-svg .iso-hobr" = "fill: none; stroke: #6a707b; stroke-width: .3" +".iso-svg .iso-metal" = "fill: var(--iso-steel); stroke: none" +".iso-svg .iso-keyb" = "fill: #2b2f37; stroke: none" diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index dd9fc36..b7af77f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -49,10 +49,41 @@ Each phase ends with tests, a Studio/viewer check in Edge, commit and push. - [ ] Keep the conversation per project across reloads; attach a rendered view (PNG, Phase 4) for vision models +## Build your own house (done) + +- [x] House parts: `house_wall` (drawn along a line, ends adapt at corners, T-junctions and + straight runs), `window` and `door` that sit on a wall and cut its opening (door styles: + panel, glass, double, sliding, opening; open / closed, hinge and swing by rotation), + `floor` (boards / tiles / carpet / stone), `roof` (hip, gable, flat, solar), `stairs` +- [x] Furniture: chair, sofa / armchair, table with chairs, desk with monitor and chair, TV, + bed with nightstands, cupboard, bookshelf, kitchen run (sink, hob, wall cabinets, fridge), + bathtub, shower, toilet, washbasin, plant, rug, floor lamp +- [x] Cut-open levels for houses: walls in front of rooms cut low (rooms found by their floors), + roof as an outline, storeys from z (`storey_h`), upper storeys left out +- [x] Scripting: `objects_of`, `objects_at`, `objects_on`, `mount_of`, `mounted`, `frame`, + `to_world`, `to_local`, `local_of`; prefab meta `shape` (line / rect), `mount`, `storey` +- [x] Studio: drag to draw lines and rectangles (walls, roads, fences, floors, areas, buildings), + Room tool (floor + walls, shared walls not doubled), windows and doors snap onto walls + (hover preview) and move, turn, copy and delete with them, new parts land on the storey + shown, collapsible palette that scrolls on its own +- [x] `examples/house` (a furnished house built from parts), House demo in the web Studio +- [ ] Openings across two walls; diagonal walls; floor openings for stairs + +## Backlog (2026-10) + +1. Web performance: the web build runs smoothly on low-power machines +2. Conveyors: placeable conveyors +3. Producers with adjustable production speed +4. Robotic arms +5. Rounded streets: curved road segments +6. Sidewalks along streets +7. Roundabouts +8. Waypoint animation: a person or car moving along a path of waypoints +9. ~~Build your own house~~ (done, see above) + ## Phase 2: richer editing -- [ ] Interior prefabs (desk, table with chairs, shelf, machine, bed, sofa, partition walls) and a - building `rooms` prop, so the GF/1F levels show something +- [x] Interior prefabs (see Build your own house); a building `rooms` prop is still open - [ ] Path objects in the Studio: click points for areas, roads (polyline with width), fences, walls, pipe runs; vertex handles - [ ] Multi-select (box select, Shift-click), group move, align/distribute diff --git a/docs/SCRIPTING.md b/docs/SCRIPTING.md index 43f6537..234173d 100644 --- a/docs/SCRIPTING.md +++ b/docs/SCRIPTING.md @@ -27,6 +27,12 @@ fn prefab_kiosk(o) { // draw around (0, 0) in the object's own - Property types: `float`, `int`, `bool`, `choice` (with `choices`), `material` (a theme material, optionally with `choices`), `text`. Write `def:` for the default (`default` is a reserved word in Rhai). +- How the Studio places it: `shape: "line"` (drag along the map: sets `rot` + and the length prop `w`), `shape: "rect"` (drag a rectangle: sets `w` and + `d`), default a click. `mount: ["house_wall"]`: the prefab sits on objects of + those types; the Studio snaps it onto the nearest one and moves, turns, + copies and deletes it with its host. `storey: true`: placed on the storey the + view shows (level `1f` puts it at z = 1 × `storey_h`). - `o` holds `id, type, name, x, y, z, rot, p` (the props with defaults filled in), `pts`, `tag`, `info`, `level`. - The engine sets the object's frame before calling the prefab. All @@ -51,7 +57,14 @@ shaded automatically by face direction. | `tag(x, y, z, text[, prio[, id[, kind]]])` | label; `kind` "signtag" / "warntag" always shows | | `info(id, name[, detail])`, `info(id, #{name, detail, rows: [[k, v], …]})` | tooltip | | `focus(x, y, z)`, `bounds(x0, y0, x1, y1[, depth])`, `defs(svg)`, `warn(msg)` | framing and extras | -| `level()`, `rot()`, `cfg()`, `cfg(name)`, `objects()` | inputs | +| `level()`, `rot()`, `cfg()`, `cfg(name)`, `objects()` | inputs (`objects()`: every document object, props resolved) | +| `objects_of(types)` | objects of a type or a list of types shown on this level | +| `objects_at(types, x, y)` | … whose footprint (`p.w` × `p.d` around the origin) holds the world point | +| `objects_on(types, x, y, tol)` | … whose centre line (local x, length `p.w`) passes within `p.t / 2 + tol` of the point, with `u, v` = the point in their frame | +| `mount_of(o, hosts[, tol])` | the host o sits on (nearest centre line within `p.t / 2 + tol`, default 0.3 m, same height), with `u, v` = o's position in the host's frame; `()` if none | +| `mounted(h, types, hosts[, tol])` | the objects of `types` whose host is h, each with `u, v` | +| `frame(o)`, `frame(x, y, z, rot)` | draw in another frame from here on (a window draws in its wall's frame) | +| `to_world(x, y)`, `to_local(x, y)`, `local_of(o, x, y)` | between the current frame (or o's) and world plan coordinates | | `vis(nx, ny)` | is a face with that outward normal turned to the viewer? | | `dv(dx, dy, dz)` | screen vector of a world vector (CSS motion) | | `along_x()` | should a mover run along x in this rotation? | @@ -81,7 +94,36 @@ shaded automatically by face direction. Library helpers (`stdlib/*.rhai`): `s.tree_at(x, y, r)`, `s.car(x0, y0, x1, y1, cls)`, `s.drive(dx, dy, dur, delay)` … `s.end()`, `cut_walls(...)`, `win_list(...)`, -`floor_of(level)`, `building_windows`, `gable_roof`, `flat_roof_kit`. +`floor_of(level)`, `building_windows`, `gable_roof`, `flat_roof_kit`; for house +parts (`stdlib/house.rhai`, `stdlib/furniture.rhai`): `storey_of(z)`, +`house_view(o)` (0 closed, 1 on the storey cut open, 2 above it: leave out), +`wall_top(w)`, `opening_at(o)`, `s.slab(a, b, th, z0, z1, mat)`, +`s.chair_at(x, y, bx, by, mat)`, `s.office_chair(x, y, mat)`. + +## House parts + +`house_wall`, `window`, `door`, `floor`, `roof`, `stairs` and the furniture +prefabs work together through the document, not through a parent object: + +- A window or door at a point on a wall's centre line (within the wall's half + thickness + 0.3 m, same height) sits on that wall (`mount_of`). The wall + leaves a gap for it (`mounted`), the window draws the opening in the wall's + frame with the wall's height, thickness and material. On no wall it stands + alone as a short wall piece. Its rotation relative to the wall picks a + door's swing side (0, 90: towards the wall's +y side) and hinge (0, 180: + at the low end). +- Wall ends adapt to the walls they meet, so no two walls overlap (the depth + sort needs them apart): along a line two walls just meet, at a T the stem + stops at the other wall's face, at a corner the wall along world x runs + through and the other stops at it. +- A part's storey is `floor((z + 0.5) / storey_h)` (`storey_h`: project + parameter, default 3 m). On level `gf` (`1f`, …) the parts on that storey + are cut open: a wall stays full height only when a floor lies on the + viewer's side of it and none behind it (it is a back wall), all others are + cut to 1 m. Roofs on that storey become an outline on the floor, parts on + storeys above are left out, the storeys below stay closed. +- Ground-level floors and rugs go to the `floor` layer; upper floors are slabs + whose top is at the object's z. Furniture starts 0.02 m above its z. ## Rules that keep the depth sort right diff --git a/examples/house/isokit.toml b/examples/house/isokit.toml new file mode 100644 index 0000000..dbe0484 --- /dev/null +++ b/examples/house/isokit.toml @@ -0,0 +1,34 @@ +# isokit house demo: a house built from parts (walls, windows, doors, floors, +# furniture, roof). Open it with `isokit studio examples/house`. +title = "isokit house" +note = "A house built from parts. GF cuts it open: walls in front of a room are cut low, the roof is lifted off. Drag to pan, Ctrl + wheel or pinch to zoom, tap anything for details." +default_level = "gf" +default_rotation = 1 +prelude = [] +theme = ["theme.toml"] + +[[level]] +id = "site" +label = "Site" +title = "The house closed, with its roof" + +[[level]] +id = "gf" +label = "GF" +title = "Ground floor, cut open" + +[[param]] +name = "labels" +type = "bool" +default = true +label = "Labels" + +[[param]] +name = "running" +type = "bool" +default = true +label = "Animation" + +[build] +out = "dist/index.html" +budget = 6000 diff --git a/examples/house/scene.json b/examples/house/scene.json new file mode 100644 index 0000000..ac5203a --- /dev/null +++ b/examples/house/scene.json @@ -0,0 +1,72 @@ +{ + "version": 1, + "objects": [ + {"id": "site", "type": "site", "x": 2, "y": 0.5, "props": {"w": 38, "d": 28, "mat": "g-grass"}, "name": "Plot", "info": "38 x 28 m plot", "locked": true}, + {"id": "road", "type": "road", "x": 2, "y": -10.5, "props": {"w": 38, "d": 6}, "name": "Street"}, + {"id": "drive", "type": "area", "x": -9.5, "y": -4.0, "props": {"w": 3.5, "d": 7.0, "mat": "g-conc"}, "name": "Driveway"}, + {"id": "path", "type": "area", "x": -1.0, "y": -6.05, "props": {"w": 1.4, "d": 2.9, "mat": "g-path"}, "name": "Front path"}, + {"id": "terrace", "type": "area", "x": 8.6, "y": -2.25, "props": {"w": 5.0, "d": 4.5, "mat": "g-pad"}, "name": "Terrace"}, + {"id": "garden", "type": "area", "x": 2, "y": 9.2, "props": {"w": 30, "d": 7.5, "mat": "g-garden"}, "name": "Back garden"}, + {"id": "floor-living", "type": "floor", "x": -3.0, "y": -2.0, "props": {"w": 6, "d": 5, "mat": "f-oak"}, "name": "Living room"}, + {"id": "floor-kitchen", "type": "floor", "x": 3.0, "y": -2.0, "props": {"w": 6, "d": 5, "mat": "f-tile"}, "name": "Kitchen and dining"}, + {"id": "floor-bedroom", "type": "floor", "x": -3.5, "y": 2.5, "props": {"w": 5, "d": 4, "mat": "f-carpet"}, "name": "Bedroom"}, + {"id": "floor-bathroom", "type": "floor", "x": 0.5, "y": 2.5, "props": {"w": 3, "d": 4, "mat": "f-stone"}, "name": "Bathroom"}, + {"id": "floor-study", "type": "floor", "x": 4.0, "y": 2.5, "props": {"w": 4, "d": 4, "mat": "f-wood"}, "name": "Study"}, + {"id": "wall-s", "type": "house_wall", "x": 0, "y": -4.5, "props": {"w": 12, "t": 0.2, "mat": "render"}, "name": "South wall"}, + {"id": "wall-n", "type": "house_wall", "x": 0, "y": 4.5, "props": {"w": 12, "t": 0.2, "mat": "render"}, "name": "North wall"}, + {"id": "wall-w", "type": "house_wall", "x": -6, "y": 0, "rot": 90, "props": {"w": 9, "t": 0.2, "mat": "render"}, "name": "West wall"}, + {"id": "wall-e", "type": "house_wall", "x": 6, "y": 0, "rot": 90, "props": {"w": 9, "t": 0.2, "mat": "render"}, "name": "East wall"}, + {"id": "wall-mid", "type": "house_wall", "x": 0, "y": 0.5, "props": {"w": 12, "t": 0.12, "mat": "render2"}, "name": "Middle wall"}, + {"id": "wall-lk", "type": "house_wall", "x": 0, "y": -2.0, "rot": 90, "props": {"w": 5, "t": 0.12, "mat": "render2"}, "name": "Living / kitchen"}, + {"id": "wall-bb", "type": "house_wall", "x": -1, "y": 2.5, "rot": 90, "props": {"w": 4, "t": 0.12, "mat": "render2"}, "name": "Bedroom / bathroom"}, + {"id": "wall-bs", "type": "house_wall", "x": 2, "y": 2.5, "rot": 90, "props": {"w": 4, "t": 0.12, "mat": "render2"}, "name": "Bathroom / study"}, + {"id": "door-front", "type": "door", "x": -1.0, "y": -4.5, "props": {"w": 1.0, "mat": "woodd"}, "name": "Front door"}, + {"id": "door-lk", "type": "door", "x": 0, "y": -1.2, "rot": 90, "props": {"w": 2.0, "style": "opening"}, "name": "Opening to the kitchen"}, + {"id": "door-bed", "type": "door", "x": -1.7, "y": 0.5, "props": {"w": 0.85, "mat": "doorw"}, "name": "Bedroom door"}, + {"id": "door-bath", "type": "door", "x": -0.5, "y": 0.5, "rot": 90, "props": {"w": 0.8, "mat": "doorw"}, "name": "Bathroom door"}, + {"id": "door-study", "type": "door", "x": 2.8, "y": 0.5, "props": {"w": 0.85, "mat": "doorw", "open": true}, "name": "Study door"}, + {"id": "door-terrace", "type": "door", "x": 6, "y": -3.6, "rot": 180, "props": {"w": 1.4, "style": "sliding"}, "name": "Terrace door"}, + {"id": "win-living-s", "type": "window", "x": -3.6, "y": -4.5, "props": {"w": 2.0, "panes": 3}}, + {"id": "win-living-w", "type": "window", "x": -6, "y": -2.2, "rot": 90, "props": {"w": 1.6}}, + {"id": "win-kitchen-s", "type": "window", "x": 3.0, "y": -4.5, "props": {"w": 1.8, "panes": 3}}, + {"id": "win-bed-n", "type": "window", "x": -5.0, "y": 4.5, "props": {"w": 1.0, "panes": 1}}, + {"id": "win-bed-w", "type": "window", "x": -6, "y": 3.2, "rot": 90, "props": {"w": 1.4, "lit": true}}, + {"id": "win-bath-n", "type": "window", "x": 0.5, "y": 4.5, "props": {"w": 0.8, "h": 0.6, "sill": 1.5, "panes": 1}}, + {"id": "win-study-n", "type": "window", "x": 4.0, "y": 4.5, "props": {"w": 1.4}}, + {"id": "win-study-e", "type": "window", "x": 6, "y": 3.2, "rot": 90, "props": {"w": 1.2, "lit": true}}, + {"id": "sofa", "type": "sofa", "x": -3.0, "y": -2.6, "rot": 180, "props": {"seats": 3, "mat": "uph1"}, "name": "Sofa"}, + {"id": "armchair", "type": "sofa", "x": -5.2, "y": -1.4, "rot": 90, "props": {"seats": 1, "mat": "uph2"}, "name": "Armchair"}, + {"id": "rug", "type": "rug", "x": -3.0, "y": -1.2, "props": {"w": 2.4, "d": 1.6, "mat": "rug1"}}, + {"id": "coffee", "type": "table", "x": -3.0, "y": -1.3, "props": {"w": 1.0, "d": 0.55, "h": 0.42, "chairs": 0, "mat": "woodd"}, "name": "Coffee table"}, + {"id": "tv", "type": "tv", "x": -3.0, "y": 0.2, "props": {"size": "65"}, "name": "TV"}, + {"id": "plant-living", "type": "plant", "x": -5.4, "y": -4.0, "props": {"h": 1.3}}, + {"id": "lamp-living", "type": "floor_lamp", "x": -4.6, "y": -3.7}, + {"id": "shelf-living", "type": "shelf", "x": -0.235, "y": -3.2, "rot": 270, "props": {"w": 1.4, "h": 1.9}, "name": "Bookshelf"}, + {"id": "kitchen-run", "type": "kitchen", "x": 5.6, "y": -1.5, "rot": 270, "props": {"w": 2.8}, "name": "Kitchen"}, + {"id": "dining", "type": "table", "x": 2.8, "y": -2.2, "props": {"w": 1.8, "d": 0.9, "chairs": 6}, "name": "Dining table"}, + {"id": "plant-kitchen", "type": "plant", "x": 0.5, "y": -4.0, "props": {"h": 0.8, "pot": "furnw"}}, + {"id": "cook", "type": "person", "x": 4.6, "y": -1.0, "props": {"cls": "guest2"}, "name": "Cook"}, + {"id": "bed", "type": "bed", "x": -3.5, "y": 3.3, "props": {"size": "double", "mat": "uph2"}, "name": "Double bed"}, + {"id": "wardrobe", "type": "cupboard", "x": -5.6, "y": 1.6, "rot": 90, "props": {"w": 1.6}, "name": "Wardrobe"}, + {"id": "rug-bed", "type": "rug", "x": -3.5, "y": 1.5, "props": {"w": 1.8, "d": 1.0, "mat": "rug2"}}, + {"id": "bathtub", "type": "bath", "x": 0.5, "y": 4.0, "name": "Bath"}, + {"id": "toilet", "type": "toilet", "x": 1.6, "y": 1.6, "rot": 270}, + {"id": "basin", "type": "basin", "x": -0.7, "y": 2.6, "rot": 90, "name": "Washbasin"}, + {"id": "desk", "type": "desk", "x": 4.0, "y": 4.05, "props": {"w": 1.6}, "name": "Desk"}, + {"id": "shelf-study", "type": "shelf", "x": 5.72, "y": 2.0, "rot": 270, "props": {"w": 1.2}, "name": "Bookshelf"}, + {"id": "plant-study", "type": "plant", "x": 2.5, "y": 4.0, "props": {"h": 0.9}}, + {"id": "roof", "type": "roof", "x": 0, "y": 0, "props": {"w": 12.2, "d": 9.2, "style": "hip", "rise": 2.2, "over": 0.5, "pv": true}, "name": "Roof", "info": "Hip roof with solar on the south slope"}, + {"id": "car", "type": "car", "x": -9.5, "y": -3.5, "rot": 90, "props": {"colour": "car2"}, "name": "Family car"}, + {"id": "terrace-table", "type": "table", "x": 8.8, "y": -2.2, "props": {"w": 1.4, "d": 0.8, "chairs": 4, "mat": "furnw", "chair_mat": "furnw"}, "name": "Terrace table"}, + {"id": "guest", "type": "person", "x": 10.4, "y": -1.0, "props": {"cls": "guest4"}}, + {"id": "plant-terrace", "type": "plant", "x": 10.6, "y": -4.0, "props": {"h": 1.4, "pot": "concb"}}, + {"id": "lamp-path", "type": "lamp", "x": 0.3, "y": -6.8, "props": {"h": 3.5}}, + {"id": "hedge-n", "type": "hedge", "x": 2, "y": 13.6, "props": {"w": 34, "d": 0.8, "h": 1.6}, "name": "Hedge"}, + {"id": "fence-e", "type": "fence", "x": 20.6, "y": 0.5, "rot": 90, "props": {"w": 27.5, "h": 1.4}, "name": "Fence"}, + {"id": "tree-1", "type": "tree", "x": -12.5, "y": 9.0, "props": {"r": 2.2}}, + {"id": "tree-2", "type": "tree", "x": 13.5, "y": 7.5, "props": {"r": 2.4}}, + {"id": "tree-3", "type": "tree", "x": 16.0, "y": -4.5, "props": {"r": 1.8}}, + {"id": "tree-4", "type": "tree", "x": -14.5, "y": -1.0, "props": {"r": 1.6}}, + {"id": "tree-5", "type": "tree", "x": 9.0, "y": 10.5, "props": {"r": 1.5}} + ] +} diff --git a/examples/house/theme.toml b/examples/house/theme.toml new file mode 100644 index 0000000..bc4cd0b --- /dev/null +++ b/examples/house/theme.toml @@ -0,0 +1 @@ +# Colour overrides on top of the built-in theme (see examples/demo/theme.toml). diff --git a/studio/index.html b/studio/index.html index 9cf0502..c319511 100644 --- a/studio/index.html +++ b/studio/index.html @@ -30,8 +30,8 @@ document.documentElement.setAttribute("data-theme",t);})();