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);})();