Build your own house: walls, windows, doors, floors, roof, stairs, furniture
Deploy web Studio / deploy (push) Successful in 3m27s
Deploy web Studio / deploy (push) Successful in 3m27s
House parts (stdlib isokit/house): house_wall drawn along a line, with ends that adapt at corners, T-junctions and straight runs so no two walls overlap; window and door that sit on a wall and cut its opening (door styles panel, glass, double, sliding, opening; swing and hinge by rotation); floor (boards, tiles, carpet, stone); roof (hip, gable, flat, optional solar); stairs. Furniture (stdlib isokit/furniture): chair, sofa / armchair, table with chairs, desk with monitor and chair, TV, bed, cupboard, bookshelf, kitchen run, bathtub, shower, toilet, washbasin, plant, rug, floor lamp. Cut-open levels: on gf / 1f the walls in front of a room are cut low (rooms are found by their floors), the roof becomes an outline on the floor, and storeys above are left out. A part's storey comes from z (storey_h, default 3 m). Scripting: objects_of, objects_at, objects_on, mount_of, mounted, frame, to_world, to_local, local_of. Prefab meta: shape (line / rect), mount, storey. Studio: drag to draw lines and rectangles (walls, roads, fences, hedges, floors, areas, buildings, roofs). The Room tool draws a floor plus walls, without doubling walls that already stand on an edge. Windows and doors snap onto walls with a hover preview, and they move, turn, copy and delete with their wall in one undo step. New parts land on the storey the view shows. Palette categories collapse, and the palette scrolls apart from the object list. The MCP instructions explain house building, and list_prefabs shows sits_on. examples/house is a furnished bungalow; the web Studio gets a House demo button. Tests: wall corners and junctions, zero depth-sort cycles in every view, cut levels, and the house demo in every view; check_studio covers the room / window / door / storey flow, check_web the House demo.
This commit is contained in:
1 parent
84309a7a08
commit
95ea4a8512
26 files changed
+2137
-64
No files matched your search
@@ -20,6 +20,7 @@ on:
|
|||||||
- "viewer/**"
|
- "viewer/**"
|
||||||
- "web/**"
|
- "web/**"
|
||||||
- "examples/demo/**"
|
- "examples/demo/**"
|
||||||
|
- "examples/house/**"
|
||||||
- "tools/build_web.py"
|
- "tools/build_web.py"
|
||||||
- "Cargo.toml"
|
- "Cargo.toml"
|
||||||
- "Cargo.lock"
|
- "Cargo.lock"
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ click for details. One Rust binary does everything:
|
|||||||
same operations; their changes show up live in an open Studio.
|
same operations; their changes show up live in an open Studio.
|
||||||
- **Build**: a self-contained HTML page with every view prerendered, or
|
- **Build**: a self-contained HTML page with every view prerendered, or
|
||||||
standalone SVGs for documents.
|
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
|
- **Engine and scripting**: everything you can place is a *prefab* written in
|
||||||
[Rhai](https://rhai.rs). Projects add their own prefabs, scripts and colours
|
[Rhai](https://rhai.rs). Projects add their own prefabs, scripts and colours
|
||||||
without touching the engine.
|
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
|
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)
|
### Studio (for everyone)
|
||||||
|
|
||||||
| Do | How |
|
| Do | How |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Add | pick an object on the left, click on the map (Shift-click places several) |
|
| 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 |
|
| 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) |
|
| Move | drag it, or arrow keys by the snap step (0.1 / 0.5 / 1 / 5 m) |
|
||||||
| Turn | R / Shift+R or ⟲ ⟳ |
|
| 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 |
|
| 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 |
|
| 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
|
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
|
(an agent, git pull, another editor), the Studio reloads it, and that change can
|
||||||
be undone like any other.
|
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 <folder>`, an editor, `git pull`) show up within 1.5 s and can be undone |
|
| **Open folder live** | Chrome / Edge: edits are written straight to the folder's files, and changes made there (an agent running `isokit mcp <folder>`, 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 |
|
| **{ } 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`) |
|
| **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)
|
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
|
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
|
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)
|
deploy/Caddyfile the web Studio's vhost on the buildfor.life web server (s1)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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), \
|
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. \
|
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 \
|
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 {
|
fn tool(name: &str, description: &str, props: Value, required: &[&str]) -> Value {
|
||||||
json!({ "name": name, "description": description,
|
json!({ "name": name, "description": description,
|
||||||
@@ -140,7 +145,7 @@ pub fn call_tool<S: Store>(ws: &mut Workspace<S>, name: &str, a: &Map<String, Va
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|m| c.as_ref().is_none_or(|c| m.category.eq_ignore_ascii_case(c)))
|
.filter(|m| c.as_ref().is_none_or(|c| m.category.eq_ignore_ascii_case(c)))
|
||||||
.map(|m| {
|
.map(|m| {
|
||||||
json!({ "type": m.kind, "label": m.label, "category": m.category, "help": m.help,
|
let mut v = json!({ "type": m.kind, "label": m.label, "category": m.category, "help": m.help,
|
||||||
"props": m.props.iter().map(|p| {
|
"props": m.props.iter().map(|p| {
|
||||||
let mut d = json!({ "key": p.key, "type": p.kind, "default": p.default });
|
let mut d = json!({ "key": p.key, "type": p.kind, "default": p.default });
|
||||||
if let Some(x) = p.min { d["min"] = json!(x); }
|
if let Some(x) = p.min { d["min"] = json!(x); }
|
||||||
@@ -148,7 +153,11 @@ pub fn call_tool<S: Store>(ws: &mut Workspace<S>, name: &str, a: &Map<String, Va
|
|||||||
if !p.choices.is_empty() { d["choices"] = json!(p.choices); }
|
if !p.choices.is_empty() { d["choices"] = json!(p.choices); }
|
||||||
if !p.label.is_empty() { d["label"] = json!(p.label); }
|
if !p.label.is_empty() { d["label"] = json!(p.label); }
|
||||||
d
|
d
|
||||||
}).collect::<Vec<_>>() })
|
}).collect::<Vec<_>>() });
|
||||||
|
if !m.mount.is_empty() {
|
||||||
|
v["sits_on"] = json!(m.mount);
|
||||||
|
}
|
||||||
|
v
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
text(json!(list))
|
text(json!(list))
|
||||||
|
|||||||
@@ -72,6 +72,32 @@ fn demo_passes_the_check() {
|
|||||||
assert!(f.is_empty(), "{f:?}");
|
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]
|
#[test]
|
||||||
fn static_page_and_svg_export() {
|
fn static_page_and_svg_export() {
|
||||||
let p = Project::load(demo()).unwrap();
|
let p = Project::load(demo()).unwrap();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use crate::conv::*;
|
use crate::conv::*;
|
||||||
use crate::State;
|
use crate::State;
|
||||||
use isokit_core::fmt::{hash, round1, thousands};
|
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 rhai::{Array, Dynamic, Engine, FnPtr, Map, NativeCallContext};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
@@ -702,6 +702,7 @@ pub(crate) fn register_scene(e: &mut Engine, st: &Shared) {
|
|||||||
});
|
});
|
||||||
let s = st.clone();
|
let s = st.clone();
|
||||||
e.register_fn("objects", move || s.borrow().all_objects.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?
|
// is a face with outward normal (nx, ny) (object coordinates) turned towards the viewer?
|
||||||
let s = st.clone();
|
let s = st.clone();
|
||||||
e.register_fn("vis", move |nx: D, ny: D| -> RR<bool> {
|
e.register_fn("vis", move |nx: D, ny: D| -> RR<bool> {
|
||||||
@@ -783,3 +784,202 @@ pub(crate) fn register_scene(e: &mut Engine, st: &Shared) {
|
|||||||
Ok(m)
|
Ok(m)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- objects and frames
|
||||||
|
|
||||||
|
/// A type name or a list of them.
|
||||||
|
fn kinds(d: &Dynamic) -> Vec<String> {
|
||||||
|
match d.read_lock::<Array>() {
|
||||||
|
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.<k>`).
|
||||||
|
fn prop(m: &Map, k: &str) -> Option<f64> {
|
||||||
|
m.get("p").and_then(|p| {
|
||||||
|
p.read_lock::<Map>()
|
||||||
|
.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::<Map>()
|
||||||
|
.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>().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::<Map>() 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<Array> {
|
||||||
|
let (ks, x, y) = (kinds(&k), n(&x)?, n(&y)?);
|
||||||
|
Ok(s.borrow()
|
||||||
|
.objects
|
||||||
|
.iter()
|
||||||
|
.filter(|d| {
|
||||||
|
of_type(d, &ks)
|
||||||
|
&& d.read_lock::<Map>().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<Array> {
|
||||||
|
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::<Map>()?;
|
||||||
|
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<Dynamic> {
|
||||||
|
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::<Map>()?;
|
||||||
|
let (i, u, v) = host_index(&b.objects, &m, &hs, tol)?;
|
||||||
|
let on = b.objects[i]
|
||||||
|
.read_lock::<Map>()
|
||||||
|
.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<Array> {
|
||||||
|
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<Array> {
|
||||||
|
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<Array> {
|
||||||
|
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<Array> {
|
||||||
|
let p = xf_of(&o).inv(n(&x)?, n(&y)?);
|
||||||
|
Ok(vec![Dynamic::from_float(p[0]), Dynamic::from_float(p[1])])
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -105,9 +105,20 @@ pub struct PrefabMeta {
|
|||||||
pub icon: String,
|
pub icon: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub help: String,
|
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")]
|
#[serde(default = "default_shape")]
|
||||||
pub shape: String,
|
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<String>,
|
||||||
|
/// 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)]
|
#[serde(default)]
|
||||||
pub props: Vec<PropDef>,
|
pub props: Vec<PropDef>,
|
||||||
/// Module the prefab comes from ("isokit/site", "lib/kiosk.rhai").
|
/// Module the prefab comes from ("isokit/site", "lib/kiosk.rhai").
|
||||||
|
|||||||
@@ -13,4 +13,6 @@ pub const MODULES: &[(&str, &str)] = &[
|
|||||||
("isokit/vehicles", include_str!("../stdlib/vehicles.rhai")),
|
("isokit/vehicles", include_str!("../stdlib/vehicles.rhai")),
|
||||||
("isokit/people", include_str!("../stdlib/people.rhai")),
|
("isokit/people", include_str!("../stdlib/people.rhai")),
|
||||||
("isokit/equipment", include_str!("../stdlib/equipment.rhai")),
|
("isokit/equipment", include_str!("../stdlib/equipment.rhai")),
|
||||||
|
("isokit/house", include_str!("../stdlib/house.rhai")),
|
||||||
|
("isokit/furniture", include_str!("../stdlib/furniture.rhai")),
|
||||||
];
|
];
|
||||||
@@ -290,3 +290,204 @@ fn cfg_reaches_the_script() {
|
|||||||
let b = r.build("site", 0, &c, &Doc::default()).unwrap();
|
let b = r.build("site", 0, &c, &Doc::default()).unwrap();
|
||||||
assert_eq!(b.scene.layers[2].sprites[0].world_box().unwrap()[5], 9.0);
|
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<f64> {
|
||||||
|
["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");
|
||||||
|
}
|
||||||
@@ -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 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() {
|
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.",
|
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: [
|
props: [
|
||||||
#{ key: "w", type: "float", def: 20.0, min: 2.0, max: 300.0, step: 0.5, label: "Width (m)" },
|
#{ 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() {
|
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.",
|
help: "A canopy on posts with cars parked under it.",
|
||||||
props: [
|
props: [
|
||||||
#{ key: "w", type: "float", def: 6.0, min: 2.5, max: 60.0, step: 0.5, label: "Width (m)" },
|
#{ 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() {
|
fn meta_shed() {
|
||||||
#{ label: "Shed / kiosk", category: "Buildings", icon: "▣",
|
#{ label: "Shed / kiosk", category: "Buildings", icon: "▣", shape: "rect",
|
||||||
props: [
|
props: [
|
||||||
#{ key: "w", type: "float", def: 4.0, min: 1.0, max: 30.0, step: 0.5, label: "Width (m)" },
|
#{ 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)" },
|
#{ key: "d", type: "float", def: 3.0, min: 1.0, max: 30.0, step: 0.5, label: "Depth (m)" },
|
||||||
|
|||||||
@@ -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"); }
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ fn ground_materials() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn meta_site() {
|
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.",
|
help: "The ground plate: a surface with earth sides. Frames the view.",
|
||||||
props: [
|
props: [
|
||||||
#{ key: "w", type: "float", def: 60.0, min: 4.0, max: 1000.0, step: 1.0, label: "Width (m)" },
|
#{ 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() {
|
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.",
|
help: "A paved or planted area lying on the ground.",
|
||||||
props: [
|
props: [
|
||||||
#{ key: "w", type: "float", def: 10.0, min: 0.5, max: 1000.0, step: 0.5, label: "Width (m)" },
|
#{ 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() {
|
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.",
|
help: "A straight road along its length, with a dashed centre line.",
|
||||||
props: [
|
props: [
|
||||||
#{ key: "w", type: "float", def: 40.0, min: 2.0, max: 2000.0, step: 1.0, label: "Length (m)" },
|
#{ 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() {
|
fn meta_wall() {
|
||||||
#{ label: "Wall", category: "Site", icon: "▬",
|
#{ label: "Wall", category: "Site", icon: "▬", shape: "line",
|
||||||
props: [
|
props: [
|
||||||
#{ key: "w", type: "float", def: 20.0, min: 0.5, max: 500.0, step: 0.5, label: "Length (m)" },
|
#{ 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)" },
|
#{ 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() {
|
fn meta_fence() {
|
||||||
#{ label: "Fence", category: "Site", icon: "┼",
|
#{ label: "Fence", category: "Site", icon: "┼", shape: "line",
|
||||||
props: [
|
props: [
|
||||||
#{ key: "w", type: "float", def: 20.0, min: 1.0, max: 500.0, step: 0.5, label: "Length (m)" },
|
#{ 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)" },
|
#{ key: "h", type: "float", def: 1.8, min: 0.5, max: 4.0, step: 0.1, label: "Height (m)" },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ fn prefab_tree(o) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn meta_tree_row() {
|
fn meta_tree_row() {
|
||||||
#{ label: "Row of trees", category: "Nature", icon: "🌲",
|
#{ label: "Row of trees", category: "Nature", icon: "🌲", shape: "line",
|
||||||
props: [
|
props: [
|
||||||
#{ key: "w", type: "float", def: 30.0, min: 2.0, max: 500.0, step: 1.0, label: "Length (m)" },
|
#{ 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)" },
|
#{ 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() {
|
fn meta_hedge() {
|
||||||
#{ label: "Hedge", category: "Nature", icon: "▬",
|
#{ label: "Hedge", category: "Nature", icon: "▬", shape: "line",
|
||||||
props: [
|
props: [
|
||||||
#{ key: "w", type: "float", def: 10.0, min: 0.5, max: 300.0, step: 0.5, label: "Length (m)" },
|
#{ 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)" },
|
#{ key: "d", type: "float", def: 0.6, min: 0.3, max: 3.0, step: 0.1, label: "Thickness (m)" },
|
||||||
|
|||||||
@@ -224,6 +224,31 @@ lounger = "#b9b3a6"
|
|||||||
tower = "#8fa3bd"
|
tower = "#8fa3bd"
|
||||||
ringl = "rgba(235, 238, 242, .55)"
|
ringl = "rgba(235, 238, 242, .55)"
|
||||||
genset = "#56685c"
|
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]
|
[palette.light]
|
||||||
sky1 = "#a9d4fb"
|
sky1 = "#a9d4fb"
|
||||||
@@ -443,6 +468,31 @@ lounger = "#f1ece0"
|
|||||||
tower = "#d6e2ef"
|
tower = "#d6e2ef"
|
||||||
ringl = "rgba(255, 255, 255, .95)"
|
ringl = "rgba(255, 255, 255, .95)"
|
||||||
genset = "#7f9a86"
|
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]
|
[materials]
|
||||||
warn = "#e0412f"
|
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-bed = "color-mix(in srgb, var(--iso-floor) 70%, #d8b88a)"
|
||||||
z-garage = "color-mix(in srgb, var(--iso-floor) 88%, #8d96a8)"
|
z-garage = "color-mix(in srgb, var(--iso-floor) 88%, #8d96a8)"
|
||||||
genset = "genset"
|
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]
|
[rules]
|
||||||
".iso-ground .iso-dirt" = "stroke: var(--iso-edge); stroke-width: .4"
|
".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-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-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-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"
|
||||||
+33
-2
@@ -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)
|
- [ ] Keep the conversation per project across reloads; attach a rendered view (PNG, Phase 4)
|
||||||
for vision models
|
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
|
## Phase 2: richer editing
|
||||||
|
|
||||||
- [ ] Interior prefabs (desk, table with chairs, shelf, machine, bed, sofa, partition walls) and a
|
- [x] Interior prefabs (see Build your own house); a building `rooms` prop is still open
|
||||||
building `rooms` prop, so the GF/1F levels show something
|
|
||||||
- [ ] Path objects in the Studio: click points for areas, roads (polyline with width), fences, walls,
|
- [ ] Path objects in the Studio: click points for areas, roads (polyline with width), fences, walls,
|
||||||
pipe runs; vertex handles
|
pipe runs; vertex handles
|
||||||
- [ ] Multi-select (box select, Shift-click), group move, align/distribute
|
- [ ] Multi-select (box select, Shift-click), group move, align/distribute
|
||||||
|
|||||||
+44
-2
@@ -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`),
|
- Property types: `float`, `int`, `bool`, `choice` (with `choices`),
|
||||||
`material` (a theme material, optionally with `choices`), `text`.
|
`material` (a theme material, optionally with `choices`), `text`.
|
||||||
Write `def:` for the default (`default` is a reserved word in Rhai).
|
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
|
- `o` holds `id, type, name, x, y, z, rot, p` (the props with defaults
|
||||||
filled in), `pts`, `tag`, `info`, `level`.
|
filled in), `pts`, `tag`, `info`, `level`.
|
||||||
- The engine sets the object's frame before calling the prefab. All
|
- 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 |
|
| `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 |
|
| `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 |
|
| `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? |
|
| `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) |
|
| `dv(dx, dy, dz)` | screen vector of a world vector (CSS motion) |
|
||||||
| `along_x()` | should a mover run along x in this rotation? |
|
| `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)`,
|
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(...)`,
|
`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
|
## Rules that keep the depth sort right
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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}}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Colour overrides on top of the built-in theme (see examples/demo/theme.toml).
|
||||||
+5
-2
@@ -30,8 +30,8 @@ document.documentElement.setAttribute("data-theme",t);})();
|
|||||||
</header>
|
</header>
|
||||||
<main class="st-main">
|
<main class="st-main">
|
||||||
<aside class="st-left">
|
<aside class="st-left">
|
||||||
<section class="st-panel">
|
<section class="st-panel st-pal-panel">
|
||||||
<h2>Add <small>click, then click on the map</small></h2>
|
<h2>Add <small>click, then click or drag on the map</small></h2>
|
||||||
<input type="search" id="psearch" placeholder="Search objects…" autocomplete="off">
|
<input type="search" id="psearch" placeholder="Search objects…" autocomplete="off">
|
||||||
<div id="palette" class="st-palette"></div>
|
<div id="palette" class="st-palette"></div>
|
||||||
</section>
|
</section>
|
||||||
@@ -62,6 +62,9 @@ document.documentElement.setAttribute("data-theme",t);})();
|
|||||||
<h3>isokit Studio</h3>
|
<h3>isokit Studio</h3>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>Add</th><td>pick an object on the left, click on the map (Shift-click to place several)</td></tr>
|
<tr><th>Add</th><td>pick an object on the left, click on the map (Shift-click to place several)</td></tr>
|
||||||
|
<tr><th>Draw</th><td>walls, roads, fences: drag along the map; floors, areas, buildings, roofs: drag a rectangle; Room: drag a rectangle for a floor with walls around it</td></tr>
|
||||||
|
<tr><th>Windows, doors</th><td>click on a wall: they snap onto it, cut their opening and move, turn, copy and delete with the wall; R turns a door to the other hinge and swing side</td></tr>
|
||||||
|
<tr><th>Storeys</th><td>GF, 1F, … cut a house open; on 1F and up, new walls, floors and furniture go onto that storey (3 m each, parameter <code>storey_h</code>)</td></tr>
|
||||||
<tr><th>Select</th><td>click it on the map or in the list</td></tr>
|
<tr><th>Select</th><td>click it on the map or in the list</td></tr>
|
||||||
<tr><th>Move</th><td>drag it, or arrow keys (by the snap step)</td></tr>
|
<tr><th>Move</th><td>drag it, or arrow keys (by the snap step)</td></tr>
|
||||||
<tr><th>Turn</th><td>R / Shift+R, or the ⟲ ⟳ buttons</td></tr>
|
<tr><th>Turn</th><td>R / Shift+R, or the ⟲ ⟳ buttons</td></tr>
|
||||||
|
|||||||
+15
-1
@@ -27,11 +27,20 @@ input[type=checkbox] { accent-color: var(--accent); }
|
|||||||
.st-right { border-right: 0; border-left: 1px solid var(--stroke); padding: 10px; }
|
.st-right { border-right: 0; border-left: 1px solid var(--stroke); padding: 10px; }
|
||||||
.st-panel { padding: 8px 10px; border-bottom: 1px solid var(--stroke); }
|
.st-panel { padding: 8px 10px; border-bottom: 1px solid var(--stroke); }
|
||||||
.st-grow-panel { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 140px; }
|
.st-grow-panel { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 140px; }
|
||||||
|
/* the palette scrolls on its own, so the object list below stays in view */
|
||||||
|
.st-left { overflow: hidden; }
|
||||||
|
.st-pal-panel { flex: 0 1 auto; max-height: 62%; min-height: 120px; display: flex; flex-direction: column; }
|
||||||
|
.st-pal-panel .st-palette { overflow: auto; min-height: 0; padding-right: 2px; }
|
||||||
.st-panel h2, .st-right h2 { margin: 2px 0 6px; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--mute); font-weight: 600; }
|
.st-panel h2, .st-right h2 { margin: 2px 0 6px; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--mute); font-weight: 600; }
|
||||||
.st-panel h2 small { text-transform: none; letter-spacing: 0; font-weight: 400; margin-left: 4px; }
|
.st-panel h2 small { text-transform: none; letter-spacing: 0; font-weight: 400; margin-left: 4px; }
|
||||||
.st-panel input[type=search] { width: 100%; margin-bottom: 6px; }
|
.st-panel input[type=search] { width: 100%; margin-bottom: 6px; }
|
||||||
.st-palette { display: flex; flex-direction: column; gap: 6px; }
|
.st-palette { display: flex; flex-direction: column; gap: 6px; }
|
||||||
.st-cat { font-size: 11px; color: var(--mute); margin: 4px 0 2px; }
|
.st-cat { font-size: 11px; color: var(--mute); margin: 4px 0 2px; cursor: pointer; user-select: none; }
|
||||||
|
.st-cat::before { content: "▾"; display: inline-block; width: 12px; }
|
||||||
|
.st-cat.st-closed::before { content: "▸"; }
|
||||||
|
.st-cat:hover { color: var(--fg); }
|
||||||
|
.st-cat small { opacity: .7; }
|
||||||
|
.st-tiles[hidden] { display: none; }
|
||||||
.st-tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
|
.st-tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
|
||||||
.st-tile { display: flex; align-items: center; gap: 6px; text-align: left; padding: 4px 6px; overflow: hidden; }
|
.st-tile { display: flex; align-items: center; gap: 6px; text-align: left; padding: 4px 6px; overflow: hidden; }
|
||||||
.st-tile i { font-style: normal; width: 18px; text-align: center; flex: 0 0 auto; }
|
.st-tile i { font-style: normal; width: 18px; text-align: center; flex: 0 0 auto; }
|
||||||
@@ -150,6 +159,11 @@ input[type=checkbox] { accent-color: var(--accent); }
|
|||||||
.ag-grip { position: absolute; left: 0; top: 0; width: 14px; height: 14px; cursor: nwse-resize; touch-action: none; background: linear-gradient(135deg, var(--mute) 0 2px, transparent 2px 4px, var(--mute) 4px 5px, transparent 5px); opacity: .5; border-top-left-radius: 8px; }
|
.ag-grip { position: absolute; left: 0; top: 0; width: 14px; height: 14px; cursor: nwse-resize; touch-action: none; background: linear-gradient(135deg, var(--mute) 0 2px, transparent 2px 4px, var(--mute) 4px 5px, transparent 5px); opacity: .5; border-top-left-radius: 8px; }
|
||||||
.ag-grip:hover { opacity: 1; }
|
.ag-grip:hover { opacity: 1; }
|
||||||
|
|
||||||
|
/* previews while placing: footprints, wall planes, where a window lands */
|
||||||
|
.iso-svg .st-ghosts { pointer-events: none; }
|
||||||
|
.iso-svg .st-ghost { fill: color-mix(in srgb, var(--iso-accent) 16%, transparent); stroke: var(--iso-accent); stroke-width: 1.5; stroke-dasharray: 5 3; vector-effect: non-scaling-stroke; stroke-linejoin: round; }
|
||||||
|
.iso-svg .st-ghost.st-plane { fill: color-mix(in srgb, var(--iso-accent) 28%, transparent); stroke-dasharray: none; }
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.st-main { grid-template-columns: 200px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 40vh; }
|
.st-main { grid-template-columns: 200px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 40vh; }
|
||||||
.st-right { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--stroke); }
|
.st-right { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--stroke); }
|
||||||
|
|||||||
+266
-33
@@ -28,6 +28,88 @@
|
|||||||
function obj(id) { return (S.doc.objects || []).filter(function (o) { return o.id === id; })[0]; }
|
function obj(id) { return (S.doc.objects || []).filter(function (o) { return o.id === id; })[0]; }
|
||||||
function label(o) { var m = prefab(o.type); return o.name || (m ? m.label : o.type); }
|
function label(o) { var m = prefab(o.type); return o.name || (m ? m.label : o.type); }
|
||||||
function icon(kind) { var m = prefab(kind); return (m && m.icon) || ICON_DEFAULT; }
|
function icon(kind) { var m = prefab(kind); return (m && m.icon) || ICON_DEFAULT; }
|
||||||
|
function r3(v) { return Math.round(v * 1000) / 1000; }
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ storeys, frames, mounts
|
||||||
|
// The same rules as the scripts (stdlib/house.rhai, mount_of in api.rs).
|
||||||
|
var ROOM = { type: "@room", label: "Room", category: "House", icon: "⬚", shape: "rect", storey: true, props: [],
|
||||||
|
help: "Drag a rectangle: a floor with house walls around it. Walls already standing along an edge are not doubled." };
|
||||||
|
function pmeta(kind) { return kind === ROOM.type ? ROOM : prefab(kind); }
|
||||||
|
function floorOf(lv) { if (lv === "gf") return 0; var m = /^(\d+)f$/.exec(lv || ""); return m ? +m[1] : -1; }
|
||||||
|
function storeyH() {
|
||||||
|
var v = (S.doc.cfg || {}).storey_h;
|
||||||
|
if (typeof v !== "number") { var d = (S.meta.params || []).filter(function (p) { return p.name === "storey_h"; })[0]; v = d ? +d.default : 3; }
|
||||||
|
return v > 0 ? v : 3;
|
||||||
|
}
|
||||||
|
// height new objects go to: the floor of the storey the view shows, for prefabs that live on storeys
|
||||||
|
function placeZ(m) { if (!m || !m.storey) return 0; var k = floorOf(S.view.getLevel()); return k > 0 ? r3(k * storeyH()) : 0; }
|
||||||
|
function worldAt(e, z) { return S.view.toWorld(e.clientX, e.clientY, z || 0); }
|
||||||
|
function qOf(rot) { return ((Math.round((rot || 0) / 90) % 4) + 4) % 4; }
|
||||||
|
function turnV(q, x, y) { return q === 0 ? [x, y] : q === 1 ? [-y, x] : q === 2 ? [-x, -y] : [y, -x]; }
|
||||||
|
function localOf(o, x, y) { return turnV((4 - qOf(o.rot)) % 4, x - o.x, y - o.y); }
|
||||||
|
function worldOf(o, u, v) { var d = turnV(qOf(o.rot), u, v); return [o.x + d[0], o.y + d[1]]; }
|
||||||
|
function defOf(m, k) { var d = m && (m.props || []).filter(function (x) { return x.key === k; })[0]; return d ? d.default : undefined; }
|
||||||
|
function propOf(o, k) { var p = o.props || {}; return p[k] !== undefined ? p[k] : defOf(prefab(o.type), k); }
|
||||||
|
function clampProp(m, k, v) {
|
||||||
|
var d = m && (m.props || []).filter(function (x) { return x.key === k; })[0];
|
||||||
|
if (d && d.min !== undefined) v = Math.max(d.min, v);
|
||||||
|
if (d && d.max !== undefined) v = Math.min(d.max, v);
|
||||||
|
return r3(v);
|
||||||
|
}
|
||||||
|
// the host an object sits on (a window's wall): nearest centre line within half its thickness + 0.3 m
|
||||||
|
function hostOf(o) {
|
||||||
|
var m = prefab(o.type), hosts = (m && m.mount) || [], best = null;
|
||||||
|
if (!hosts.length) return null;
|
||||||
|
S.doc.objects.forEach(function (h) {
|
||||||
|
if (h.hidden || h.id === o.id || hosts.indexOf(h.type) < 0 || Math.abs((o.z || 0) - (h.z || 0)) > 0.5) return;
|
||||||
|
var l = localOf(h, o.x, o.y), half = (+propOf(h, "w") || 1) / 2, lim = (+propOf(h, "t") || 0) / 2 + 0.3;
|
||||||
|
if (Math.abs(l[0]) <= half + 1e-6 && Math.abs(l[1]) <= lim + 1e-6 && (!best || Math.abs(l[1]) < Math.abs(best.v) - 1e-9)) best = { host: h, u: l[0], v: l[1] };
|
||||||
|
});
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
function isHost(kind) { return (S.meta.catalog || []).some(function (m) { return (m.mount || []).indexOf(kind) >= 0; }); }
|
||||||
|
// the objects sitting on h; they move, turn, copy and delete with it
|
||||||
|
function mountedOn(h) {
|
||||||
|
if (!isHost(h.type)) return [];
|
||||||
|
return S.doc.objects.filter(function (o) { var r = o.id !== h.id && hostOf(o); return r && r.host.id === h.id; });
|
||||||
|
}
|
||||||
|
// where a window or door goes when dropped at w: onto the nearest host's centre line (within 1.5 m),
|
||||||
|
// along it by the snap step, kept inside the host's length
|
||||||
|
function snapMount(m, w, z, selfId, width) {
|
||||||
|
var best = null;
|
||||||
|
S.doc.objects.forEach(function (h) {
|
||||||
|
if (h.hidden || h.id === selfId || (m.mount || []).indexOf(h.type) < 0 || Math.abs((h.z || 0) - z) > 0.5) return;
|
||||||
|
var l = localOf(h, w[0], w[1]), half = (+propOf(h, "w") || 1) / 2, u = Math.max(-half, Math.min(half, l[0])), d = Math.hypot(l[0] - u, l[1]);
|
||||||
|
if (d < 1.5 && (!best || d < best.d)) best = { host: h, u: u, side: l[1] < 0 ? -1 : 1, d: d, half: half };
|
||||||
|
});
|
||||||
|
if (!best) return null;
|
||||||
|
var s = snapStep(), u = s ? Math.round(best.u / s) * s : best.u, hw = (+width || 1) / 2;
|
||||||
|
u = hw >= best.half ? 0 : Math.max(-best.half + hw, Math.min(best.half - hw, u));
|
||||||
|
var p = worldOf(best.host, u, 0);
|
||||||
|
return { x: r3(p[0]), y: r3(p[1]), z: best.host.z || 0, host: best.host, u: u, side: best.side, hw: hw };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ ghosts: previews drawn over the view
|
||||||
|
function ghost(polys, text) {
|
||||||
|
var svg = S.view.svg, g = svg.querySelector(".st-ghosts");
|
||||||
|
if (!polys || !polys.length) { if (g) g.parentNode.removeChild(g); return; }
|
||||||
|
if (!g) { g = document.createElementNS("http://www.w3.org/2000/svg", "g"); g.setAttribute("class", "st-ghosts"); svg.appendChild(g); }
|
||||||
|
var rot = S.view.getRotation();
|
||||||
|
g.innerHTML = polys.map(function (p) {
|
||||||
|
return '<path class="st-ghost' + (p.cls ? " " + p.cls : "") + '" d="' + p.pts.map(function (q, i) {
|
||||||
|
var s = IsoKit.project(rot, q[0], q[1], q[2] || 0);
|
||||||
|
return (i ? "L" : "M") + s[0].toFixed(1) + " " + s[1].toFixed(1);
|
||||||
|
}).join("") + 'Z"/>';
|
||||||
|
}).join("");
|
||||||
|
if (text !== undefined) { var h = $("hint"); h.textContent = text; h.hidden = false; }
|
||||||
|
}
|
||||||
|
function rectPts(x0, y0, x1, y1, z) { return [[x0, y0, z], [x1, y0, z], [x1, y1, z], [x0, y1, z]]; }
|
||||||
|
// an upright quad from a to b (plan points), z0 to z1
|
||||||
|
function planePts(a, b, z0, z1) { return [[a[0], a[1], z0], [b[0], b[1], z0], [b[0], b[1], z1], [a[0], a[1], z1]]; }
|
||||||
|
function mountGhost(m, sm, o) {
|
||||||
|
var z0 = +(o ? propOf(o, "sill") : defOf(m, "sill")) || 0, h = +(o ? propOf(o, "h") : defOf(m, "h")) || 2;
|
||||||
|
return [{ pts: planePts(worldOf(sm.host, sm.u - sm.hw, 0), worldOf(sm.host, sm.u + sm.hw, 0), sm.z + z0, sm.z + z0 + h), cls: "st-plane" }];
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------ server state
|
// ------------------------------------------------------------ server state
|
||||||
function takeState(r) {
|
function takeState(r) {
|
||||||
@@ -77,7 +159,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------ view
|
// ------------------------------------------------------------ view
|
||||||
var drag = null;
|
var drag = null, tool = null;
|
||||||
function moveEls(id) { return S.view.svg.querySelectorAll('[data-obj="' + id.replace(/"/g, "") + '"]'); }
|
function moveEls(id) { return S.view.svg.querySelectorAll('[data-obj="' + id.replace(/"/g, "") + '"]'); }
|
||||||
function mountView() {
|
function mountView() {
|
||||||
var src = B ? B.source(toViewerMeta(S.meta)) : IsoKit.httpSource("", toViewerMeta(S.meta));
|
var src = B ? B.source(toViewerMeta(S.meta)) : IsoKit.httpSource("", toViewerMeta(S.meta));
|
||||||
@@ -90,39 +172,61 @@
|
|||||||
var w = $("warnbtn"); w.hidden = !S.warnings.length; w.textContent = S.warnings.length + " warning" + (S.warnings.length === 1 ? "" : "s");
|
var w = $("warnbtn"); w.hidden = !S.warnings.length; w.textContent = S.warnings.length + " warning" + (S.warnings.length === 1 ? "" : "s");
|
||||||
if (S.sel) S.view.selectObj(S.sel);
|
if (S.sel) S.view.selectObj(S.sel);
|
||||||
},
|
},
|
||||||
onHover: function (w) { if (w) $("cursor").textContent = "x " + w[0].toFixed(1) + " y " + w[1].toFixed(1); },
|
onHover: function (w, e) {
|
||||||
|
if (w) $("cursor").textContent = "x " + w[0].toFixed(1) + " y " + w[1].toFixed(1);
|
||||||
|
if (S.placing && !tool && e) hoverPlacing(e);
|
||||||
|
},
|
||||||
onPick: function (i) { select(i && i.obj ? i.obj : null); },
|
onPick: function (i) { select(i && i.obj ? i.obj : null); },
|
||||||
onKey: keyHandler,
|
onKey: keyHandler,
|
||||||
interact: {
|
interact: {
|
||||||
down: function (e, hit) {
|
down: function (e, hit) {
|
||||||
if (S.placing) { return true; }
|
if (S.placing) {
|
||||||
|
var pm = pmeta(S.placing), z = placeZ(pm), a = worldAt(e, z);
|
||||||
|
tool = { m: pm, z: z, a: [snap(a[0]), snap(a[1])], b: null };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (!hit.obj) return false;
|
if (!hit.obj) return false;
|
||||||
var o = obj(hit.obj);
|
var o = obj(hit.obj);
|
||||||
if (!o || o.locked) return false;
|
if (!o || o.locked) return false;
|
||||||
drag = { id: o.id, ox: o.x, oy: o.y, w0: hit.world, moved: false, els: null };
|
var m = prefab(o.type), mount = !!(m && m.mount && m.mount.length), h = mount ? hostOf(o) : null;
|
||||||
|
drag = { id: o.id, ox: o.x, oy: o.y, w0: hit.world, moved: false, els: null, m: m, mount: mount,
|
||||||
|
rel: h ? qOf((o.rot || 0) - (h.host.rot || 0)) : null, carry: mountedOn(o) };
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
move: function (e, m) {
|
move: function (e, mv) {
|
||||||
if (S.placing || !drag || !m.world || !drag.w0) return;
|
if (S.placing) { if (tool && mv.world) drawTool(e); return; }
|
||||||
if (!drag.moved) { drag.moved = true; drag.els = moveEls(drag.id); select(drag.id, true); }
|
if (!drag || !mv.world || !drag.w0) return;
|
||||||
var dx = snap(drag.ox + m.world[0] - drag.w0[0]) - drag.ox, dy = snap(drag.oy + m.world[1] - drag.w0[1]) - drag.oy;
|
if (!drag.moved) {
|
||||||
|
drag.moved = true; select(drag.id, true); drag.els = [];
|
||||||
|
// what sits on the object (a wall's windows) moves along
|
||||||
|
[drag.id].concat(drag.carry.map(function (c) { return c.id; })).forEach(function (id) { Array.prototype.push.apply(drag.els, moveEls(id)); });
|
||||||
|
}
|
||||||
|
var tx = drag.ox + mv.world[0] - drag.w0[0], ty = drag.oy + mv.world[1] - drag.w0[1];
|
||||||
|
var dx = snap(tx) - drag.ox, dy = snap(ty) - drag.oy;
|
||||||
|
drag.snapTo = null;
|
||||||
|
if (drag.mount) {
|
||||||
|
// windows and doors snap onto the nearest wall
|
||||||
|
var o = obj(drag.id), sm = o && snapMount(drag.m, [tx, ty], o.z || 0, o.id, propOf(o, "w"));
|
||||||
|
if (sm) { drag.snapTo = sm; dx = sm.x - drag.ox; dy = sm.y - drag.oy; ghost(mountGhost(drag.m, sm, o)); } else ghost(null);
|
||||||
|
}
|
||||||
var a = S.view.screenOf(dx, dy, 0), b = S.view.screenOf(0, 0, 0);
|
var a = S.view.screenOf(dx, dy, 0), b = S.view.screenOf(0, 0, 0);
|
||||||
drag.dx = dx; drag.dy = dy;
|
drag.dx = dx; drag.dy = dy;
|
||||||
Array.prototype.forEach.call(drag.els, function (g) { g.setAttribute("transform", "translate(" + (a[0] - b[0]).toFixed(1) + " " + (a[1] - b[1]).toFixed(1) + ")"); g.classList.add("iso-moving"); });
|
drag.els.forEach(function (g) { g.setAttribute("transform", "translate(" + (a[0] - b[0]).toFixed(1) + " " + (a[1] - b[1]).toFixed(1) + ")"); g.classList.add("iso-moving"); });
|
||||||
$("cursor").textContent = "x " + (drag.ox + dx).toFixed(2) + " y " + (drag.oy + dy).toFixed(2);
|
$("cursor").textContent = "x " + (drag.ox + dx).toFixed(2) + " y " + (drag.oy + dy).toFixed(2);
|
||||||
},
|
},
|
||||||
up: function (e, m) {
|
up: function (e, mv) {
|
||||||
if (S.placing) {
|
if (S.placing) { var t = tool; tool = null; ghost(null); if (t && !mv.cancel) finishTool(t, e); return; }
|
||||||
if (!m.cancel && m.world) place(S.placing, m.world, e.shiftKey);
|
var d = drag; drag = null; ghost(null);
|
||||||
return;
|
|
||||||
}
|
|
||||||
var d = drag; drag = null;
|
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
if (!d.moved || m.cancel) { select(d.id); return; }
|
if (!d.moved || mv.cancel) { if (d.moved) S.view.refresh(); select(d.id); return; }
|
||||||
if (d.dx || d.dy) update(d.id, { x: +(d.ox + d.dx).toFixed(3), y: +(d.oy + d.dy).toFixed(3) });
|
if (d.snapTo) {
|
||||||
|
// on a wall: keep the turn relative to the wall it came from (door swing), else open to the side dropped on
|
||||||
|
var sm = d.snapTo, rel = d.rel !== null ? d.rel * 90 : (sm.side < 0 ? 180 : 0);
|
||||||
|
update(d.id, { x: sm.x, y: sm.y, z: sm.z, rot: ((sm.host.rot || 0) + rel) % 360 });
|
||||||
|
} else if (d.dx || d.dy) moveWith(d.id, d.dx, d.dy, d.carry);
|
||||||
else S.view.refresh();
|
else S.view.refresh();
|
||||||
},
|
},
|
||||||
cancel: function () { if (drag && drag.els) S.view.refresh(); drag = null; }
|
cancel: function () { if (drag && drag.els) S.view.refresh(); drag = null; tool = null; ghost(null); }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.__studio = { S: S, view: S.view, op: op, select: select, reloadAll: reloadAll, toast: toast, api: api, obj: obj, snapStep: snapStep, ready: true };
|
window.__studio = { S: S, view: S.view, op: op, select: select, reloadAll: reloadAll, toast: toast, api: api, obj: obj, snapStep: snapStep, ready: true };
|
||||||
@@ -137,20 +241,105 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------ actions
|
// ------------------------------------------------------------ actions
|
||||||
function place(kind, w, keep) {
|
// the hover preview while placing a window or door: where on which wall it goes
|
||||||
var m = prefab(kind);
|
function hoverPlacing(e) {
|
||||||
op({ op: "add", type: kind, x: snap(w[0]), y: snap(w[1]) }).then(function (r) {
|
var m = pmeta(S.placing);
|
||||||
if (r) toast((m ? m.label : kind) + " added" + (keep ? "" : " · Shift-click to place several"));
|
if (!m || !m.mount || !m.mount.length) return;
|
||||||
});
|
var z = placeZ(m), sm = snapMount(m, worldAt(e, z), z, null, defOf(m, "w"));
|
||||||
|
ghost(sm ? mountGhost(m, sm) : null);
|
||||||
|
}
|
||||||
|
// a drag while placing: "line" prefabs run from the press to the pointer along the main axis,
|
||||||
|
// "rect" prefabs (and rooms) fill the rectangle
|
||||||
|
function toolGeom(t) {
|
||||||
|
var a = t.a, b = t.b || t.a, dx = b[0] - a[0], dy = b[1] - a[1];
|
||||||
|
if (t.m.shape === "line") {
|
||||||
|
var ax = Math.abs(dx) >= Math.abs(dy), L = Math.abs(ax ? dx : dy);
|
||||||
|
return { line: true, ax: ax, len: L, x: ax ? (a[0] + b[0]) / 2 : a[0], y: ax ? a[1] : (a[1] + b[1]) / 2, ok: L >= 0.25 };
|
||||||
|
}
|
||||||
|
return { w: Math.abs(dx), d: Math.abs(dy), x: (a[0] + b[0]) / 2, y: (a[1] + b[1]) / 2,
|
||||||
|
x0: Math.min(a[0], b[0]), y0: Math.min(a[1], b[1]), x1: Math.max(a[0], b[0]), y1: Math.max(a[1], b[1]), ok: Math.abs(dx) >= 0.25 && Math.abs(dy) >= 0.25 };
|
||||||
|
}
|
||||||
|
function drawTool(e) {
|
||||||
|
var t = tool, m = t.m, w = worldAt(e, t.z), z = t.z, polys = [];
|
||||||
|
t.b = [snap(w[0]), snap(w[1])];
|
||||||
|
if (m.shape !== "line" && m.shape !== "rect") return;
|
||||||
|
var g = toolGeom(t);
|
||||||
|
if (g.line) {
|
||||||
|
var th = +(defOf(m, "t") || defOf(m, "d")) || 0.3, h = +defOf(m, "h") || 0, L = g.len / 2;
|
||||||
|
polys.push({ pts: g.ax ? rectPts(g.x - L, g.y - th / 2, g.x + L, g.y + th / 2, z) : rectPts(g.x - th / 2, g.y - L, g.x + th / 2, g.y + L, z) });
|
||||||
|
if (h > 0) polys.push({ pts: planePts(g.ax ? [g.x - L, g.y] : [g.x, g.y - L], g.ax ? [g.x + L, g.y] : [g.x, g.y + L], z, z + h), cls: "st-plane" });
|
||||||
|
ghost(polys, m.label + " " + g.len.toFixed(2) + " m · release to place");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
polys.push({ pts: rectPts(g.x0, g.y0, g.x1, g.y1, z) });
|
||||||
|
if (m === ROOM) {
|
||||||
|
var wh = +defOf(prefab("house_wall"), "h") || 2.8;
|
||||||
|
[[g.x0, g.y0, g.x1, g.y0], [g.x0, g.y1, g.x1, g.y1], [g.x0, g.y0, g.x0, g.y1], [g.x1, g.y0, g.x1, g.y1]].forEach(function (s) {
|
||||||
|
polys.push({ pts: planePts([s[0], s[1]], [s[2], s[3]], z, z + wh), cls: "st-plane" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ghost(polys, m.label + " " + g.w.toFixed(2) + " × " + g.d.toFixed(2) + " m · release to place");
|
||||||
|
}
|
||||||
|
function finishTool(t, e) {
|
||||||
|
var m = t.m, keep = e.shiftKey, g = t.b ? toolGeom(t) : null, add;
|
||||||
|
var at = worldAt(e, t.z), p = [snap(at[0]), snap(at[1])];
|
||||||
|
if (m === ROOM) {
|
||||||
|
if (!g || !g.ok) { toast("Drag a rectangle on the map to draw a room"); setPlacing(m.type); return; }
|
||||||
|
var fm = prefab("floor"), ops = roomWalls(g.x0, g.y0, g.x1, g.y1, t.z);
|
||||||
|
ops.push({ op: "add", type: "floor", x: r3(g.x), y: r3(g.y), z: t.z, props: { w: clampProp(fm, "w", g.w), d: clampProp(fm, "d", g.d) } });
|
||||||
|
op({ ops: ops }).then(function (r) { if (r) toast("Room " + g.w.toFixed(1) + " × " + g.d.toFixed(1) + " m: a floor and " + (ops.length - 1) + " wall" + (ops.length === 2 ? "" : "s")); });
|
||||||
|
if (!keep) setPlacing(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (g && g.ok && m.shape === "line") add = { type: m.type, x: r3(g.x), y: r3(g.y), rot: g.ax ? 0 : 90, props: { w: clampProp(m, "w", g.len) } };
|
||||||
|
else if (g && g.ok && m.shape === "rect") add = { type: m.type, x: r3(g.x), y: r3(g.y), props: { w: clampProp(m, "w", g.w), d: clampProp(m, "d", g.d) } };
|
||||||
|
else if (m.mount && m.mount.length) {
|
||||||
|
// onto the nearest wall, opening towards the side clicked on; on no wall it stands alone
|
||||||
|
var sm = snapMount(m, at, t.z, null, defOf(m, "w"));
|
||||||
|
add = sm ? { type: m.type, x: sm.x, y: sm.y, z: sm.z, rot: ((sm.host.rot || 0) + (sm.side < 0 ? 180 : 0)) % 360 } : { type: m.type, x: p[0], y: p[1] };
|
||||||
|
} else add = { type: m.type, x: p[0], y: p[1] };
|
||||||
|
add.op = "add";
|
||||||
|
if (add.z === undefined && t.z) add.z = t.z;
|
||||||
|
op(add).then(function (r) { if (r) toast(m.label + " added" + (keep ? "" : " · Shift to place several")); });
|
||||||
if (!keep) setPlacing(null);
|
if (!keep) setPlacing(null);
|
||||||
}
|
}
|
||||||
|
// walls around a room's rectangle, leaving out the stretches a wall already stands on
|
||||||
|
function roomWalls(x0, y0, x1, y1, z) {
|
||||||
|
var walls = S.doc.objects.filter(function (o) { return o.type === "house_wall" && !o.hidden && Math.abs((o.z || 0) - z) < 0.5; }), out = [];
|
||||||
|
[[x0, y0, x1, y0], [x0, y1, x1, y1], [x0, y0, x0, y1], [x1, y0, x1, y1]].forEach(function (e) {
|
||||||
|
var ax = e[1] === e[3], line = ax ? e[1] : e[0], lo = ax ? e[0] : e[1], hi = ax ? e[2] : e[3], cov = [];
|
||||||
|
walls.forEach(function (w) {
|
||||||
|
if ((qOf(w.rot) % 2 === 0) !== ax || Math.abs((ax ? w.y : w.x) - line) > 0.05) return;
|
||||||
|
var c = ax ? w.x : w.y, L = (+propOf(w, "w") || 0) / 2;
|
||||||
|
cov.push([c - L, c + L]);
|
||||||
|
});
|
||||||
|
cov.sort(function (a, b) { return a[0] - b[0]; });
|
||||||
|
var segs = [], cur = lo;
|
||||||
|
cov.forEach(function (c) { if (c[0] > cur + 0.05) segs.push([cur, Math.min(c[0], hi)]); cur = Math.max(cur, c[1]); });
|
||||||
|
if (hi > cur + 0.05) segs.push([cur, hi]);
|
||||||
|
segs.forEach(function (s) {
|
||||||
|
if (s[1] - s[0] < 0.2) return;
|
||||||
|
var mid = r3((s[0] + s[1]) / 2);
|
||||||
|
out.push({ op: "add", type: "house_wall", x: ax ? mid : line, y: ax ? line : mid, z: z, rot: ax ? 0 : 90, props: { w: r3(s[1] - s[0]) } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
function setPlacing(kind) {
|
function setPlacing(kind) {
|
||||||
S.placing = kind;
|
S.placing = kind; tool = null;
|
||||||
|
if (S.view) ghost(null);
|
||||||
document.querySelector(".st-centre").classList.toggle("st-placing", !!kind);
|
document.querySelector(".st-centre").classList.toggle("st-placing", !!kind);
|
||||||
Array.prototype.forEach.call(document.querySelectorAll(".st-tile"), function (b) { b.setAttribute("aria-pressed", String(b.getAttribute("data-type") === kind)); });
|
Array.prototype.forEach.call(document.querySelectorAll(".st-tile"), function (b) { b.setAttribute("aria-pressed", String(b.getAttribute("data-type") === kind)); });
|
||||||
var h = $("hint");
|
var h = $("hint");
|
||||||
if (kind) { var m = prefab(kind); h.textContent = "Click on the map to place: " + (m ? m.label : kind) + " · Esc to stop"; h.hidden = false; }
|
if (kind) {
|
||||||
else h.hidden = true;
|
var m = pmeta(kind), lab = m ? m.label : kind;
|
||||||
|
h.textContent = m === ROOM ? "Drag a rectangle on the map: a room (floor and walls) · Esc to stop"
|
||||||
|
: m && m.mount && m.mount.length ? "Click on a wall to place: " + lab + " · Esc to stop"
|
||||||
|
: m && m.shape === "line" ? "Drag along the map to draw: " + lab + " (a click gives the default length) · Esc to stop"
|
||||||
|
: m && m.shape === "rect" ? "Drag a rectangle on the map: " + lab + " (a click gives the default size) · Esc to stop"
|
||||||
|
: "Click on the map to place: " + lab + " · Esc to stop";
|
||||||
|
h.hidden = false;
|
||||||
|
} else h.hidden = true;
|
||||||
}
|
}
|
||||||
function select(id, quiet) {
|
function select(id, quiet) {
|
||||||
if (id && !obj(id)) id = null;
|
if (id && !obj(id)) id = null;
|
||||||
@@ -159,9 +348,37 @@
|
|||||||
Array.prototype.forEach.call(document.querySelectorAll("#objects li"), function (li) { li.setAttribute("aria-selected", String(li.getAttribute("data-id") === id)); });
|
Array.prototype.forEach.call(document.querySelectorAll("#objects li"), function (li) { li.setAttribute("aria-selected", String(li.getAttribute("data-id") === id)); });
|
||||||
if (!quiet) renderRight();
|
if (!quiet) renderRight();
|
||||||
}
|
}
|
||||||
function remove(id) { if (id) op({ op: "remove", id: id }).then(function (r) { if (r) toast("Deleted · Ctrl+Z brings it back"); }); }
|
function opList(ops, quiet) { return op(ops.length === 1 ? ops[0] : { ops: ops }, quiet); }
|
||||||
function duplicate(id) { if (id) op({ op: "duplicate", id: id, dx: Math.max(snapStep(), 1) * 2, dy: Math.max(snapStep(), 1) * 2 }); }
|
// the object and what sits on it (a wall's windows and doors) as one undo step; the object last, so it stays selected
|
||||||
function turn(id, d) { var o = obj(id); if (o) update(id, { rot: ((o.rot || 0) + d + 360) % 360 }); }
|
function remove(id) {
|
||||||
|
var o = obj(id); if (!o) return;
|
||||||
|
var carry = mountedOn(o);
|
||||||
|
opList(carry.map(function (c) { return { op: "remove", id: c.id }; }).concat([{ op: "remove", id: id }])).then(function (r) {
|
||||||
|
if (r) toast("Deleted" + (carry.length ? " with the " + carry.length + " window" + (carry.length === 1 ? "" : "s") + " / door" + (carry.length === 1 ? "" : "s") + " on it" : "") + " · Ctrl+Z brings it back");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function duplicate(id) {
|
||||||
|
var o = obj(id); if (!o) return;
|
||||||
|
var dd = Math.max(snapStep(), 1) * 2, dx = dd, dy = dd, h = hostOf(o);
|
||||||
|
// a copy of a window or door goes further along its wall
|
||||||
|
if (h) { var v = turnV(qOf(h.host.rot), (+propOf(o, "w") || 1) + 0.5, 0); dx = r3(v[0]); dy = r3(v[1]); }
|
||||||
|
opList(mountedOn(o).map(function (c) { return { op: "duplicate", id: c.id, dx: dx, dy: dy }; }).concat([{ op: "duplicate", id: id, dx: dx, dy: dy }]));
|
||||||
|
}
|
||||||
|
function moveWith(id, dx, dy, carry, quiet) {
|
||||||
|
var o = obj(id); if (!o) return Promise.resolve(null);
|
||||||
|
var ops = (carry || mountedOn(o)).map(function (c) { return { op: "update", id: c.id, patch: { x: r3(c.x + dx), y: r3(c.y + dy) } }; });
|
||||||
|
ops.push({ op: "update", id: id, patch: { x: r3(o.x + dx), y: r3(o.y + dy) } });
|
||||||
|
return opList(ops, quiet);
|
||||||
|
}
|
||||||
|
function turn(id, d) {
|
||||||
|
var o = obj(id); if (!o) return;
|
||||||
|
var q = qOf(d), ops = mountedOn(o).map(function (c) {
|
||||||
|
var v = turnV(q, c.x - o.x, c.y - o.y);
|
||||||
|
return { op: "update", id: c.id, patch: { x: r3(o.x + v[0]), y: r3(o.y + v[1]), rot: ((c.rot || 0) + d + 360) % 360 } };
|
||||||
|
});
|
||||||
|
ops.push({ op: "update", id: id, patch: { rot: ((o.rot || 0) + d + 360) % 360 } });
|
||||||
|
opList(ops);
|
||||||
|
}
|
||||||
function nudge(id, dx, dy) {
|
function nudge(id, dx, dy) {
|
||||||
var o = obj(id); if (!o || o.locked) return;
|
var o = obj(id); if (!o || o.locked) return;
|
||||||
var s = snapStep() || 0.5;
|
var s = snapStep() || 0.5;
|
||||||
@@ -172,7 +389,7 @@
|
|||||||
var wx = dx * r[0] + dy * u[0], wy = dx * r[1] + dy * u[1];
|
var wx = dx * r[0] + dy * u[0], wy = dx * r[1] + dy * u[1];
|
||||||
// pick the dominant world axis so a key press moves along x or y only
|
// pick the dominant world axis so a key press moves along x or y only
|
||||||
if (Math.abs(wx) >= Math.abs(wy)) { wy = 0; wx = Math.sign(wx); } else { wx = 0; wy = Math.sign(wy); }
|
if (Math.abs(wx) >= Math.abs(wy)) { wy = 0; wx = Math.sign(wx); } else { wx = 0; wy = Math.sign(wy); }
|
||||||
update(id, { x: +(o.x + wx * s).toFixed(3), y: +(o.y + wy * s).toFixed(3) }, true).then(function () { renderRight(); });
|
moveWith(id, wx * s, wy * s, null, true).then(function () { renderRight(); });
|
||||||
}
|
}
|
||||||
function keyHandler(e) {
|
function keyHandler(e) {
|
||||||
var t = e.target, typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT");
|
var t = e.target, typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT");
|
||||||
@@ -198,14 +415,20 @@
|
|||||||
|
|
||||||
// ------------------------------------------------------------ left panel
|
// ------------------------------------------------------------ left panel
|
||||||
function renderPalette() {
|
function renderPalette() {
|
||||||
var q = $("psearch").value.trim().toLowerCase(), cats = {}, order = [];
|
var q = $("psearch").value.trim().toLowerCase(), cats = {}, order = [], list = (S.meta.catalog || []).slice();
|
||||||
(S.meta.catalog || []).forEach(function (m) {
|
// the Room tool (a floor with walls) leads the House parts
|
||||||
|
var hi = list.findIndex(function (m) { return m.category === "House"; });
|
||||||
|
if (hi >= 0 && prefab("house_wall") && prefab("floor")) list.splice(hi, 0, ROOM);
|
||||||
|
list.forEach(function (m) {
|
||||||
if (q && (m.label + " " + m.type + " " + m.category + " " + (m.help || "")).toLowerCase().indexOf(q) < 0) return;
|
if (q && (m.label + " " + m.type + " " + m.category + " " + (m.help || "")).toLowerCase().indexOf(q) < 0) return;
|
||||||
if (!cats[m.category]) { cats[m.category] = []; order.push(m.category); }
|
if (!cats[m.category]) { cats[m.category] = []; order.push(m.category); }
|
||||||
cats[m.category].push(m);
|
cats[m.category].push(m);
|
||||||
});
|
});
|
||||||
|
var closed = paletteClosed();
|
||||||
$("palette").innerHTML = order.map(function (c) {
|
$("palette").innerHTML = order.map(function (c) {
|
||||||
return '<div><div class="st-cat">' + esc(c) + '</div><div class="st-tiles">' + cats[c].map(function (m) {
|
var shut = !q && closed.indexOf(c) >= 0;
|
||||||
|
return '<div><div class="st-cat' + (shut ? " st-closed" : "") + '" data-cat="' + esc(c) + '" title="Show / hide">' + esc(c) + " <small>" + cats[c].length + "</small></div>" +
|
||||||
|
'<div class="st-tiles"' + (shut ? " hidden" : "") + ">" + cats[c].map(function (m) {
|
||||||
return '<button type="button" class="st-tile" data-type="' + esc(m.type) + '" title="' + esc(m.help || m.label) + '" aria-pressed="' + (S.placing === m.type) + '"><i>' + esc(m.icon || ICON_DEFAULT) + "</i><span>" + esc(m.label) + "</span></button>";
|
return '<button type="button" class="st-tile" data-type="' + esc(m.type) + '" title="' + esc(m.help || m.label) + '" aria-pressed="' + (S.placing === m.type) + '"><i>' + esc(m.icon || ICON_DEFAULT) + "</i><span>" + esc(m.label) + "</span></button>";
|
||||||
}).join("") + "</div></div>";
|
}).join("") + "</div></div>";
|
||||||
}).join("") || '<div class="st-help">No object matches.</div>';
|
}).join("") || '<div class="st-help">No object matches.</div>';
|
||||||
@@ -219,7 +442,17 @@
|
|||||||
return '<li data-id="' + esc(o.id) + '" aria-selected="' + (o.id === S.sel) + '"' + (o.hidden ? ' class="st-hidden"' : "") + ' title="' + esc(o.id) + '"><i>' + esc(icon(o.type)) + "</i><span>" + esc(label(o)) + "</span><small>" + (o.locked ? "🔒" : "") + "</small></li>";
|
return '<li data-id="' + esc(o.id) + '" aria-selected="' + (o.id === S.sel) + '"' + (o.hidden ? ' class="st-hidden"' : "") + ' title="' + esc(o.id) + '"><i>' + esc(icon(o.type)) + "</i><span>" + esc(label(o)) + "</span><small>" + (o.locked ? "🔒" : "") + "</small></li>";
|
||||||
}).join("");
|
}).join("");
|
||||||
}
|
}
|
||||||
|
// closed palette categories, remembered in this browser
|
||||||
|
function paletteClosed() { try { return JSON.parse(localStorage.getItem("isokit-palette-closed") || "[]"); } catch (e) { return []; } }
|
||||||
$("palette").addEventListener("click", function (e) {
|
$("palette").addEventListener("click", function (e) {
|
||||||
|
var h = e.target.closest(".st-cat");
|
||||||
|
if (h) {
|
||||||
|
var c = h.getAttribute("data-cat"), list = paletteClosed(), i = list.indexOf(c);
|
||||||
|
if (i >= 0) list.splice(i, 1); else list.push(c);
|
||||||
|
try { localStorage.setItem("isokit-palette-closed", JSON.stringify(list)); } catch (er) { /* private mode */ }
|
||||||
|
renderPalette();
|
||||||
|
return;
|
||||||
|
}
|
||||||
var b = e.target.closest(".st-tile"); if (!b) return;
|
var b = e.target.closest(".st-tile"); if (!b) return;
|
||||||
var k = b.getAttribute("data-type");
|
var k = b.getAttribute("data-type");
|
||||||
setPlacing(S.placing === k ? null : k);
|
setPlacing(S.placing === k ? null : k);
|
||||||
|
|||||||
+5
-4
@@ -66,9 +66,9 @@ def lock_version(name):
|
|||||||
return m.group(1) if m else None
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
def demo_files():
|
def demo_files(name="demo"):
|
||||||
"""The demo project as the web Studio stores projects: path -> text."""
|
"""An example project as the web Studio stores projects: path -> text."""
|
||||||
base = ROOT / "examples" / "demo"
|
base = ROOT / "examples" / name
|
||||||
out = {}
|
out = {}
|
||||||
for p in sorted(base.rglob("*")):
|
for p in sorted(base.rglob("*")):
|
||||||
rel = p.relative_to(base).as_posix()
|
rel = p.relative_to(base).as_posix()
|
||||||
@@ -77,7 +77,7 @@ def demo_files():
|
|||||||
continue
|
continue
|
||||||
if p.suffix in (".rhai", ".toml", ".json") or p.name == "README.md":
|
if p.suffix in (".rhai", ".toml", ".json") or p.name == "README.md":
|
||||||
out[rel] = p.read_text(encoding="utf-8")
|
out[rel] = p.read_text(encoding="utf-8")
|
||||||
assert "isokit.toml" in out, "examples/demo has no isokit.toml"
|
assert "isokit.toml" in out, f"examples/{name} has no isokit.toml"
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -147,6 +147,7 @@ def main():
|
|||||||
(out / "assets" / dst).mkdir(parents=True, exist_ok=True)
|
(out / "assets" / dst).mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copy2(ROOT / src, out / "assets" / dst / Path(src).name)
|
shutil.copy2(ROOT / src, out / "assets" / dst / Path(src).name)
|
||||||
(web / "demo.json").write_text(json.dumps(demo_files(), ensure_ascii=False), encoding="utf-8")
|
(web / "demo.json").write_text(json.dumps(demo_files(), ensure_ascii=False), encoding="utf-8")
|
||||||
|
(web / "house.json").write_text(json.dumps(demo_files("house"), ensure_ascii=False), encoding="utf-8")
|
||||||
|
|
||||||
h = hashlib.sha256()
|
h = hashlib.sha256()
|
||||||
for p in sorted((out / "assets").rglob("*")):
|
for p in sorted((out / "assets").rglob("*")):
|
||||||
|
|||||||
@@ -115,6 +115,89 @@ with sync_playwright() as p:
|
|||||||
pg.wait_for_timeout(700)
|
pg.wait_for_timeout(700)
|
||||||
check(obj(new["id"]) is None, "Delete removes the selected object")
|
check(obj(new["id"]) is None, "Delete removes the selected object")
|
||||||
|
|
||||||
|
# build a house: a room (floor + 4 walls in one step), a window and a door snapped onto its walls,
|
||||||
|
# the wall carrying its window when dragged and deleted, furniture on the first floor, the cut-open level
|
||||||
|
to_client = """(w) => { const v = __studio.view, s = v.screenOf(w[0], w[1], w[2] || 0), svg = v.svg, pt = svg.createSVGPoint();
|
||||||
|
pt.x = s[0]; pt.y = s[1]; const q = pt.matrixTransform(svg.getScreenCTM()); return [q.x, q.y]; }"""
|
||||||
|
at = lambda x, y: pg.evaluate(to_client, [x, y, 0])
|
||||||
|
pg.evaluate("__studio.select(null)")
|
||||||
|
pg.evaluate("__studio.view.focus([25.5, 4, 0], 3)")
|
||||||
|
pg.wait_for_timeout(400)
|
||||||
|
n1 = len(doc()["objects"])
|
||||||
|
pg.locator('.st-tile[data-type="@room"]').click()
|
||||||
|
a, b2 = at(23, 2), at(28, 6)
|
||||||
|
pg.mouse.move(*a)
|
||||||
|
pg.mouse.down()
|
||||||
|
for i in range(1, 13):
|
||||||
|
pg.mouse.move(a[0] + (b2[0] - a[0]) * i / 12, a[1] + (b2[1] - a[1]) * i / 12)
|
||||||
|
pg.wait_for_timeout(15)
|
||||||
|
check(pg.locator(".st-ghost").count() == 5 and "5.00 × 4.00" in pg.locator("#hint").inner_text(), "the Room tool previews a 5 x 4 m room while dragging")
|
||||||
|
pg.screenshot(path=str(OUT / "studio-room-drag.jpg"), type="jpeg", quality=80)
|
||||||
|
pg.mouse.up()
|
||||||
|
pg.wait_for_timeout(800)
|
||||||
|
new = doc()["objects"][n1:]
|
||||||
|
kinds = sorted(o["type"] for o in new)
|
||||||
|
check(kinds == ["floor", "house_wall", "house_wall", "house_wall", "house_wall"], f"a room is a floor and four walls: {kinds}")
|
||||||
|
fl = next((o for o in new if o["type"] == "floor"), {})
|
||||||
|
check(fl.get("props") == {"w": 5, "d": 4} or fl.get("props") == {"w": 5.0, "d": 4.0}, f"the floor is 5 x 4 m: {fl.get('props')}")
|
||||||
|
south = next((o for o in new if o["type"] == "house_wall" and abs(o["y"] - 2) < 1e-6), None)
|
||||||
|
pg.locator('.st-tile[data-type="window"]').click()
|
||||||
|
pg.mouse.move(*at(25.1, 1.6))
|
||||||
|
pg.mouse.move(*at(25.2, 1.7))
|
||||||
|
check(pg.locator(".st-ghost").count() == 1, "hovering a wall with a window shows where it goes")
|
||||||
|
pg.mouse.click(*at(25.2, 1.7))
|
||||||
|
pg.wait_for_timeout(800)
|
||||||
|
win = doc()["objects"][-1]
|
||||||
|
check(win["type"] == "window" and abs(win["y"] - 2) < 1e-6 and abs(win["x"] - 25) < 1e-6, f"the window snaps onto the wall: ({win['x']}, {win['y']})")
|
||||||
|
pg.locator('.st-tile[data-type="door"]').click()
|
||||||
|
pg.mouse.click(*at(27.6, 4.2))
|
||||||
|
pg.wait_for_timeout(800)
|
||||||
|
door = doc()["objects"][-1]
|
||||||
|
check(door["type"] == "door" and abs(door["x"] - 28) < 1e-6 and door.get("rot") == 90, f"the door snaps onto the east wall and opens into the room: {door}")
|
||||||
|
# drag the south wall 1 m south: its window comes along, as one undo step
|
||||||
|
pg.evaluate("__studio.select(null)")
|
||||||
|
wb = pg.locator(f'.iso-svg g[data-obj="{south["id"]}"] path.iso-t').first.bounding_box()
|
||||||
|
cx, cy = wb["x"] + wb["width"] / 2, wb["y"] + wb["height"] / 2
|
||||||
|
d0, d1 = at(25, 2), at(25, 1)
|
||||||
|
pg.mouse.move(cx, cy)
|
||||||
|
pg.mouse.down()
|
||||||
|
for i in range(1, 11):
|
||||||
|
pg.mouse.move(cx + (d1[0] - d0[0]) * i / 10, cy + (d1[1] - d0[1]) * i / 10)
|
||||||
|
pg.wait_for_timeout(15)
|
||||||
|
pg.mouse.up()
|
||||||
|
pg.wait_for_timeout(800)
|
||||||
|
check(abs(obj(south["id"])["y"] - 1) < 1e-6 and abs(obj(win["id"])["y"] - 1) < 1e-6, "dragging the wall moves its window along")
|
||||||
|
pg.locator("#undo").click()
|
||||||
|
pg.wait_for_timeout(700)
|
||||||
|
check(abs(obj(south["id"])["y"] - 2) < 1e-6 and abs(obj(win["id"])["y"] - 2) < 1e-6, "one undo puts wall and window back")
|
||||||
|
pg.locator(f'#objects li[data-id="{south["id"]}"]').click()
|
||||||
|
pg.locator(".iso-svg").focus()
|
||||||
|
pg.keyboard.press("Delete")
|
||||||
|
pg.wait_for_timeout(800)
|
||||||
|
check(obj(south["id"]) is None and obj(win["id"]) is None, "deleting the wall deletes its window")
|
||||||
|
pg.keyboard.press("Control+z")
|
||||||
|
pg.wait_for_timeout(800)
|
||||||
|
check(obj(win["id"]) is not None, "undo brings both back")
|
||||||
|
# the cut-open level: walls in front of the room low, the others full height
|
||||||
|
rv = get("/api/render?level=gf&rot=1")
|
||||||
|
tops = sorted(round(rv["boxes"][o["id"]][5], 2) for o in new if o["type"] == "house_wall")
|
||||||
|
check(tops == [1.0, 1.0, 2.8, 2.8], f"GF cuts the two walls in front of the room low: {tops}")
|
||||||
|
# furniture placed while 1F is shown goes onto the first floor
|
||||||
|
pg.evaluate("__studio.select(null)")
|
||||||
|
pg.locator('.iso-seg button[data-level="1f"]').click()
|
||||||
|
pg.wait_for_timeout(600)
|
||||||
|
pg.locator('.st-tile[data-type="chair"]').click()
|
||||||
|
pg.mouse.click(*at(25, 4))
|
||||||
|
pg.wait_for_timeout(800)
|
||||||
|
chair = doc()["objects"][-1]
|
||||||
|
check(chair["type"] == "chair" and chair.get("z") == 3, f"a chair placed on 1F stands at z = 3: {chair.get('z')}")
|
||||||
|
pg.locator('.iso-seg button[data-level="gf"]').click()
|
||||||
|
pg.wait_for_timeout(600)
|
||||||
|
pg.screenshot(path=str(OUT / "studio-house-gf.jpg"), type="jpeg", quality=80)
|
||||||
|
pg.locator('.iso-seg button[data-level="site"]').click()
|
||||||
|
pg.evaluate("__studio.select(null)")
|
||||||
|
pg.wait_for_timeout(400)
|
||||||
|
|
||||||
# an agent edits over MCP: the open Studio follows
|
# an agent edits over MCP: the open Studio follows
|
||||||
init = mcp({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "0"}}})
|
init = mcp({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "0"}}})
|
||||||
check(init["result"]["serverInfo"]["name"] == "isokit", "MCP initialize")
|
check(init["result"]["serverInfo"]["name"] == "isokit", "MCP initialize")
|
||||||
|
|||||||
@@ -289,6 +289,18 @@ try:
|
|||||||
check(pg.locator("#wb-plist li").count() == n_proj - 1, "a project can be deleted")
|
check(pg.locator("#wb-plist li").count() == n_proj - 1, "a project can be deleted")
|
||||||
pg.keyboard.press("Escape")
|
pg.keyboard.press("Escape")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- the house demo: a house built from parts
|
||||||
|
pg.locator("#wb-projects").click()
|
||||||
|
pg.locator("#wb-nhouse").click()
|
||||||
|
pg.wait_for_load_state()
|
||||||
|
ready(pg)
|
||||||
|
pg.wait_for_timeout(500)
|
||||||
|
nh = len(doc(pg)["objects"])
|
||||||
|
check(title(pg) == "isokit house" and nh > 50, f"House demo opens a furnished house ({nh} objects)")
|
||||||
|
check(pg.evaluate("() => __studio.S.warnings.length") == 0 and pg.locator('.st-tile[data-type="@room"]').count() == 1,
|
||||||
|
"it draws without warnings, the palette has the Room tool")
|
||||||
|
shot(pg, "web-house.jpg")
|
||||||
|
|
||||||
# ---------------------------------------------------------------- folder project (OPFS stands in for a picked folder)
|
# ---------------------------------------------------------------- folder project (OPFS stands in for a picked folder)
|
||||||
demo = {f.relative_to(ROOT / "examples" / "demo").as_posix(): f.read_text(encoding="utf-8")
|
demo = {f.relative_to(ROOT / "examples" / "demo").as_posix(): f.read_text(encoding="utf-8")
|
||||||
for f in (ROOT / "examples" / "demo").rglob("*") if f.is_file() and "dist" not in f.parts}
|
for f in (ROOT / "examples" / "demo").rglob("*") if f.is_file() and "dist" not in f.parts}
|
||||||
|
|||||||
+3
-2
@@ -367,7 +367,7 @@ function injectUI() {
|
|||||||
'<h3>Projects</h3>' +
|
'<h3>Projects</h3>' +
|
||||||
'<div class="wb-cur" id="wb-cur"></div>' +
|
'<div class="wb-cur" id="wb-cur"></div>' +
|
||||||
'<ul class="wb-plist" id="wb-plist"></ul>' +
|
'<ul class="wb-plist" id="wb-plist"></ul>' +
|
||||||
'<div class="wb-new"><input id="wb-ntitle" placeholder="Title of a new project" autocomplete="off"><button type="button" id="wb-ncreate">New project</button><button type="button" id="wb-ndemo" title="A fresh copy of the demo scene">Demo copy</button></div>' +
|
'<div class="wb-new"><input id="wb-ntitle" placeholder="Title of a new project" autocomplete="off"><button type="button" id="wb-ncreate">New project</button><button type="button" id="wb-ndemo" title="A fresh copy of the demo scene">Demo copy</button><button type="button" id="wb-nhouse" title="A house built from parts: walls, windows, doors, floors, furniture">House demo</button></div>' +
|
||||||
'<div class="wb-drop" id="wb-drop">Import: drop a project folder or .zip here, or <button type="button" id="wb-izip" title="Import a project .zip into this browser">.zip…</button><button type="button" id="wb-idir" title="Copy a project folder into this browser">folder…</button>' +
|
'<div class="wb-drop" id="wb-drop">Import: drop a project folder or .zip here, or <button type="button" id="wb-izip" title="Import a project .zip into this browser">.zip…</button><button type="button" id="wb-idir" title="Copy a project folder into this browser">folder…</button>' +
|
||||||
(canOpenFolder ? '<span class="st-grow"></span><button type="button" id="wb-ofolder" title="Edit the files in the folder on disk directly; changes made there show up live">Open folder live…</button>' : "") + "</div>" +
|
(canOpenFolder ? '<span class="st-grow"></span><button type="button" id="wb-ofolder" title="Edit the files in the folder on disk directly; changes made there show up live">Open folder live…</button>' : "") + "</div>" +
|
||||||
'<p class="st-help">Projects are kept in this browser. ' + (canOpenFolder ? "A folder opened live is edited on disk, so agents (<b>isokit mcp</b>), editors and git see the same files." : "To edit a folder on disk directly, use Chrome or Edge.") + "</p>" +
|
'<p class="st-help">Projects are kept in this browser. ' + (canOpenFolder ? "A folder opened live is edited on disk, so agents (<b>isokit mcp</b>), editors and git see the same files." : "To edit a folder on disk directly, use Chrome or Edge.") + "</p>" +
|
||||||
@@ -417,6 +417,7 @@ function wireProjects() {
|
|||||||
});
|
});
|
||||||
$("wb-ntitle").addEventListener("keydown", function (e) { if (e.key === "Enter") $("wb-ncreate").click(); });
|
$("wb-ntitle").addEventListener("keydown", function (e) { if (e.key === "Enter") $("wb-ncreate").click(); });
|
||||||
$("wb-ndemo").addEventListener("click", async function () { openProject((await createProject(await demoFiles())).id); });
|
$("wb-ndemo").addEventListener("click", async function () { openProject((await createProject(await demoFiles())).id); });
|
||||||
|
$("wb-nhouse").addEventListener("click", async function () { openProject((await createProject(await demoFiles("house.json"))).id); });
|
||||||
$("wb-izip").addEventListener("click", function () { $("wb-fzip").click(); });
|
$("wb-izip").addEventListener("click", function () { $("wb-fzip").click(); });
|
||||||
$("wb-idir").addEventListener("click", function () { $("wb-fdir").click(); });
|
$("wb-idir").addEventListener("click", function () { $("wb-fdir").click(); });
|
||||||
$("wb-fzip").addEventListener("change", function () { var f = this.files[0]; if (f) importZip(f); this.value = ""; });
|
$("wb-fzip").addEventListener("change", function () { var f = this.files[0]; if (f) importZip(f); this.value = ""; });
|
||||||
@@ -468,7 +469,7 @@ function exportZip() {
|
|||||||
download(name + ".zip", zip(P.files, name + "/"));
|
download(name + ".zip", zip(P.files, name + "/"));
|
||||||
toast("Exported " + name + ".zip");
|
toast("Exported " + name + ".zip");
|
||||||
}
|
}
|
||||||
async function demoFiles() { return (await fetch(asset("demo.json"))).json(); }
|
async function demoFiles(name) { return (await fetch(asset(name || "demo.json"))).json(); }
|
||||||
|
|
||||||
// the code editor: the project's files, applied as one outside edit
|
// the code editor: the project's files, applied as one outside edit
|
||||||
var C = { path: null, dirty: false };
|
var C = { path: null, dirty: false };
|
||||||
|
|||||||
Reference in new issue
Block a user