isokit: engine core, theme and Rhai scene runtime with prefab library

This commit is contained in:
grabowski committed 2026-10-22 15:09:46 +07:00
commit 95c80ec6b1
32 files changed
+5896

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
[package]
name = "isokit-script"
description = "isokit scene runtime: Rhai scripts, prefab library, scene documents"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
isokit-core = { workspace = true }
rhai = { workspace = true, features = ["serde"] }
serde = { workspace = true }
serde_json = { workspace = true }
+486
View File
@@ -0,0 +1,486 @@
//! Functions and the `Sprite` type registered into the Rhai engine.
use crate::conv::*;
use crate::State;
use isokit_core::fmt::{hash, round1, thousands};
use isokit_core::{classes, LayerOpts, Sprite, C};
use rhai::{Array, Dynamic, Engine, FnPtr, Map, NativeCallContext};
use std::cell::RefCell;
use std::rc::Rc;
/// Script handle to a sprite: copies share the same sprite.
#[derive(Clone)]
pub struct SpriteH(pub Rc<RefCell<Sprite>>);
type Shared = Rc<RefCell<State>>;
type D = Dynamic;
fn ok(s: &SpriteH) -> RR<SpriteH> {
Ok(s.clone())
}
fn opt_id(d: &str) -> Option<String> {
if d.is_empty() {
None
} else {
Some(d.to_string())
}
}
pub(crate) fn new_sprite(st: &Shared, ctx: Option<&NativeCallContext>, id: Option<&str>, cls: &str) -> SpriteH {
let mut s = st.borrow_mut();
let id = id.map(str::to_string).or_else(|| s.obj.clone()).unwrap_or_default();
let mut sp = Sprite::new(id, classes(cls)).with_frame(s.xf);
sp.obj = s.obj.clone();
if let Some(ctx) = ctx {
if let Some(line) = ctx.call_position().line() {
let src = ctx.call_source().unwrap_or(&s.main).to_string();
sp.src = Some(format!("{src}:{line}"));
}
}
let h = SpriteH(Rc::new(RefCell::new(sp)));
let layer = s.layer.clone();
s.sprites.push((layer, h.clone()));
h
}
/// Sprite methods (no scene state needed).
pub fn register_sprite(e: &mut Engine) {
e.register_type_with_name::<SpriteH>("Sprite");
e.register_get("id", |s: &mut SpriteH| s.0.borrow().id.clone());
// solids
e.register_fn("box", |s: &mut SpriteH, x0: D, y0: D, x1: D, y1: D, z0: D, z1: D, mat: &str| -> RR<SpriteH> {
s.0.borrow_mut().cuboid(n(&x0)?, n(&y0)?, n(&x1)?, n(&y1)?, n(&z0)?, n(&z1)?, style(mat, None));
ok(s)
});
e.register_fn(
"box",
|s: &mut SpriteH, x0: D, y0: D, x1: D, y1: D, z0: D, z1: D, mat: &str, o: Map| -> RR<SpriteH> {
s.0.borrow_mut().cuboid(n(&x0)?, n(&y0)?, n(&x1)?, n(&y1)?, n(&z0)?, n(&z1)?, style(mat, Some(&o)));
ok(s)
},
);
e.register_fn("prism", |s: &mut SpriteH, p: D, z0: D, z1: D, mat: &str| -> RR<SpriteH> {
s.0.borrow_mut().prism(pts(&p)?, zs(&z0)?, zs(&z1)?, style(mat, None));
ok(s)
});
e.register_fn("prism", |s: &mut SpriteH, p: D, z0: D, z1: D, mat: &str, o: Map| -> RR<SpriteH> {
s.0.borrow_mut().prism(pts(&p)?, zs(&z0)?, zs(&z1)?, style(mat, Some(&o)));
ok(s)
});
e.register_fn("cyl", |s: &mut SpriteH, cx: D, cy: D, r: D, z0: D, z1: D, mat: &str| -> RR<SpriteH> {
s.0.borrow_mut().cyl(n(&cx)?, n(&cy)?, n(&r)?, n(&z0)?, n(&z1)?, style(mat, None));
ok(s)
});
e.register_fn("cyl", |s: &mut SpriteH, cx: D, cy: D, r: D, z0: D, z1: D, mat: &str, o: Map| -> RR<SpriteH> {
s.0.borrow_mut().cyl(n(&cx)?, n(&cy)?, n(&r)?, n(&z0)?, n(&z1)?, style(mat, Some(&o)));
ok(s)
});
e.register_fn(
"frust",
|s: &mut SpriteH, cx: D, cy: D, r0: D, r1: D, z0: D, z1: D, mat: &str| -> RR<SpriteH> {
s.0.borrow_mut().frust(n(&cx)?, n(&cy)?, n(&r0)?, n(&r1)?, n(&z0)?, n(&z1)?, 10, style(mat, None));
ok(s)
},
);
e.register_fn(
"frust",
|s: &mut SpriteH, cx: D, cy: D, r0: D, r1: D, z0: D, z1: D, mat: &str, sides: D| -> RR<SpriteH> {
let k = n(&sides)? as u32;
s.0.borrow_mut().frust(n(&cx)?, n(&cy)?, n(&r0)?, n(&r1)?, n(&z0)?, n(&z1)?, k, style(mat, None));
ok(s)
},
);
e.register_fn(
"frust",
|s: &mut SpriteH, cx: D, cy: D, r0: D, r1: D, z0: D, z1: D, mat: &str, sides: D, o: Map| -> RR<SpriteH> {
let k = n(&sides)? as u32;
s.0.borrow_mut().frust(n(&cx)?, n(&cy)?, n(&r0)?, n(&r1)?, n(&z0)?, n(&z1)?, k, style(mat, Some(&o)));
ok(s)
},
);
e.register_fn(
"slope",
|s: &mut SpriteH, x0: D, x1: D, y0: D, y1: D, za: D, zb: D, th: D, mat: &str| -> RR<SpriteH> {
s.0.borrow_mut().slope(n(&x0)?, n(&x1)?, n(&y0)?, n(&y1)?, n(&za)?, n(&zb)?, n(&th)?, &classes(mat));
ok(s)
},
);
// surfaces
e.register_fn("open", |s: &mut SpriteH, a: D, b: D, list: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().open(p2(&a)?, p2(&b)?, quads(&list, "openings")?, &classes(cls), None);
ok(s)
});
e.register_fn("open", |s: &mut SpriteH, a: D, b: D, list: D, cls: &str, id: &str| -> RR<SpriteH> {
s.0.borrow_mut().open(p2(&a)?, p2(&b)?, quads(&list, "openings")?, &classes(cls), opt_id(id));
ok(s)
});
e.register_fn("flat", |s: &mut SpriteH, p: D, z: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().flat(polys(&p)?, n(&z)?, &classes(cls), None);
ok(s)
});
e.register_fn("flat", |s: &mut SpriteH, p: D, z: D, cls: &str, id: &str| -> RR<SpriteH> {
s.0.borrow_mut().flat(polys(&p)?, n(&z)?, &classes(cls), opt_id(id));
ok(s)
});
e.register_fn("lines", |s: &mut SpriteH, segs: D, z: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().lines(quads(&segs, "segments")?, n(&z)?, &classes(cls));
ok(s)
});
e.register_fn("disc", |s: &mut SpriteH, cx: D, cy: D, z: D, r: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().disc(n(&cx)?, n(&cy)?, n(&z)?, n(&r)?, &classes(cls), None);
ok(s)
});
e.register_fn("disc", |s: &mut SpriteH, cx: D, cy: D, z: D, r: D, cls: &str, id: &str| -> RR<SpriteH> {
s.0.borrow_mut().disc(n(&cx)?, n(&cy)?, n(&z)?, n(&r)?, &classes(cls), opt_id(id));
ok(s)
});
e.register_fn("face", |s: &mut SpriteH, p: D, mat: &str| -> RR<SpriteH> {
s.0.borrow_mut().face(pts3(&p)?, &classes(mat), None, false);
ok(s)
});
e.register_fn("face", |s: &mut SpriteH, p: D, mat: &str, o: Map| -> RR<SpriteH> {
let id = o.get("id").map(|v| v.to_string());
let two = o.get("two_sided").and_then(|v| v.as_bool().ok()).unwrap_or(false);
s.0.borrow_mut().face(pts3(&p)?, &classes(mat), id, two);
ok(s)
});
e.register_fn("poly", |s: &mut SpriteH, p: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().poly(polys3(&p)?, &classes(cls), None);
ok(s)
});
e.register_fn("poly", |s: &mut SpriteH, p: D, cls: &str, id: &str| -> RR<SpriteH> {
s.0.borrow_mut().poly(polys3(&p)?, &classes(cls), opt_id(id));
ok(s)
});
// screen-space details
e.register_fn("circle", |s: &mut SpriteH, x: D, y: D, z: D, r: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().circle([n(&x)?, n(&y)?, n(&z)?], n(&r)?, [0.0, 0.0], &classes(cls));
ok(s)
});
e.register_fn(
"circle",
|s: &mut SpriteH, x: D, y: D, z: D, r: D, cls: &str, dx: D, dy: D| -> RR<SpriteH> {
s.0.borrow_mut().circle([n(&x)?, n(&y)?, n(&z)?], n(&r)?, [n(&dx)?, n(&dy)?], &classes(cls));
ok(s)
},
);
e.register_fn("stem", |s: &mut SpriteH, x: D, y: D, z0: D, z1: D, w: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().stem(n(&x)?, n(&y)?, n(&z0)?, n(&z1)?, n(&w)?, &classes(cls));
ok(s)
});
e.register_fn("head", |s: &mut SpriteH, x: D, y: D, z: D, r: D| -> RR<SpriteH> {
s.0.borrow_mut().head(n(&x)?, n(&y)?, n(&z)?, n(&r)?);
ok(s)
});
e.register_fn("person", |s: &mut SpriteH, x: D, y: D, z: D| -> RR<SpriteH> {
s.0.borrow_mut().person(n(&x)?, n(&y)?, n(&z)?, "iso-hivis");
ok(s)
});
e.register_fn("person", |s: &mut SpriteH, x: D, y: D, z: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().person(n(&x)?, n(&y)?, n(&z)?, &classes(cls));
ok(s)
});
e.register_fn("sit", |s: &mut SpriteH, x: D, y: D, z: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().sit(n(&x)?, n(&y)?, n(&z)?, &classes(cls));
ok(s)
});
e.register_fn("led", |s: &mut SpriteH, x: D, y: D, z: D| -> RR<SpriteH> {
s.0.borrow_mut().led(n(&x)?, n(&y)?, n(&z)?, "");
ok(s)
});
e.register_fn("led", |s: &mut SpriteH, x: D, y: D, z: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().led(n(&x)?, n(&y)?, n(&z)?, &classes(cls));
ok(s)
});
e.register_fn("puffs", |s: &mut SpriteH, x: D, y: D, z: D| -> RR<SpriteH> {
s.0.borrow_mut().puffs(n(&x)?, n(&y)?, n(&z)?, "iso-puff", 3, 3.2);
ok(s)
});
e.register_fn("puffs", |s: &mut SpriteH, x: D, y: D, z: D, kind: &str| -> RR<SpriteH> {
s.0.borrow_mut().puffs(n(&x)?, n(&y)?, n(&z)?, &classes(kind), 3, 3.2);
ok(s)
});
e.register_fn(
"puffs",
|s: &mut SpriteH, x: D, y: D, z: D, kind: &str, k: D, dur: D| -> RR<SpriteH> {
s.0.borrow_mut().puffs(n(&x)?, n(&y)?, n(&z)?, &classes(kind), n(&k)? as u32, n(&dur)?);
ok(s)
},
);
e.register_fn("fan", |s: &mut SpriteH, x: D, y: D, z: D, r: D| -> RR<SpriteH> {
s.0.borrow_mut().fan(n(&x)?, n(&y)?, n(&z)?, n(&r)?);
ok(s)
});
e.register_fn("raw", |s: &mut SpriteH, svg: &str| -> RR<SpriteH> {
s.0.borrow_mut().raw(None, svg);
ok(s)
});
e.register_fn("raw", |s: &mut SpriteH, x: D, y: D, z: D, svg: &str| -> RR<SpriteH> {
s.0.borrow_mut().raw(Some([n(&x)?, n(&y)?, n(&z)?]), svg);
ok(s)
});
// extents
e.register_fn("ext", |s: &mut SpriteH, x: D, y: D, z: D| -> RR<SpriteH> {
s.0.borrow_mut().ext(n(&x)?, n(&y)?, n(&z)?);
ok(s)
});
e.register_fn("ext_box", |s: &mut SpriteH, x0: D, y0: D, x1: D, y1: D, z0: D, z1: D| -> RR<SpriteH> {
s.0.borrow_mut().ext_box(n(&x0)?, n(&y0)?, n(&x1)?, n(&y1)?, n(&z0)?, n(&z1)?);
ok(s)
});
// structure
e.register_fn("begin", |s: &mut SpriteH, cls: &str| -> SpriteH {
s.0.borrow_mut().begin_group(&classes(cls), "");
s.clone()
});
e.register_fn("begin", |s: &mut SpriteH, cls: &str, css: &str| -> SpriteH {
s.0.borrow_mut().begin_group(&classes(cls), css);
s.clone()
});
e.register_fn("end", |s: &mut SpriteH| -> SpriteH {
s.0.borrow_mut().end_group();
s.clone()
});
e.register_fn("group", |ctx: NativeCallContext, s: &mut SpriteH, cls: &str, f: FnPtr| -> RR<SpriteH> {
s.0.borrow_mut().begin_group(&classes(cls), "");
let r = f.call_within_context::<Dynamic>(&ctx, (s.clone(),));
s.0.borrow_mut().end_group();
let _ = r?;
ok(s)
});
e.register_fn(
"group",
|ctx: NativeCallContext, s: &mut SpriteH, cls: &str, css: &str, f: FnPtr| -> RR<SpriteH> {
s.0.borrow_mut().begin_group(&classes(cls), css);
let r = f.call_within_context::<Dynamic>(&ctx, (s.clone(),));
s.0.borrow_mut().end_group();
let _ = r?;
ok(s)
},
);
// items: [[x, y, |s| { ... }], ...] drawn back to front by the anchor's depth
e.register_fn("seq", |ctx: NativeCallContext, s: &mut SpriteH, items: Array| -> RR<SpriteH> {
s.0.borrow_mut().begin_seq();
for it in items {
let a = it.into_array().map_err(|t| format!("seq: expected [x, y, fn], got {t}"))?;
if a.len() < 3 {
s.0.borrow_mut().end_seq();
return err("seq: expected [x, y, fn]");
}
let f = a[2].clone().try_cast::<FnPtr>().ok_or("seq: the third entry must be a function")?;
s.0.borrow_mut().seq_item([n(&a[0])?, n(&a[1])?]);
if let Err(e) = f.call_within_context::<Dynamic>(&ctx, (s.clone(),)) {
s.0.borrow_mut().end_seq();
return Err(e);
}
}
s.0.borrow_mut().end_seq();
ok(s)
});
}
/// Scene-level functions (they read and write the shared build state).
pub(crate) fn register_scene(e: &mut Engine, st: &Shared) {
let s = st.clone();
e.register_fn("sprite", move |ctx: NativeCallContext| new_sprite(&s, Some(&ctx), None, ""));
let s = st.clone();
e.register_fn("sprite", move |ctx: NativeCallContext, id: &str| new_sprite(&s, Some(&ctx), Some(id), ""));
let s = st.clone();
e.register_fn("sprite", move |ctx: NativeCallContext, id: &str, cls: &str| {
new_sprite(&s, Some(&ctx), Some(id), cls)
});
// the shared ground sprite (unsorted layer "ground", no pick id)
let s = st.clone();
e.register_fn("ground", move || {
if let Some(g) = s.borrow().ground.clone() {
return g;
}
let saved = std::mem::replace(&mut s.borrow_mut().layer, "ground".into());
let (obj, xf) = {
let mut b = s.borrow_mut();
(b.obj.take(), std::mem::take(&mut b.xf))
};
let g = new_sprite(&s, None, Some(""), "");
let mut b = s.borrow_mut();
b.layer = saved;
b.obj = obj;
b.xf = xf;
b.ground = Some(g.clone());
g
});
let s = st.clone();
e.register_fn("layer", move |name: &str| {
let mut b = s.borrow_mut();
b.scene.layer_mut(name);
b.layer = name.to_string();
});
let s = st.clone();
e.register_fn("layer", move |name: &str, o: Map| {
let mut b = s.borrow_mut();
let opts = LayerOpts {
sort: o.get("sort").and_then(|v| v.as_bool().ok()).unwrap_or(true),
zfirst: o.get("zfirst").and_then(|v| v.as_bool().ok()).unwrap_or(false),
cls: o.get("cls").map(|v| classes(&v.to_string())).unwrap_or_else(|| "iso-sprites".into()),
};
let before = o.get("before").map(|v| v.to_string());
b.scene.add_layer(name, opts, before.as_deref());
b.layer = name.to_string();
});
let s = st.clone();
e.register_fn("current_layer", move || s.borrow().layer.clone());
// tags and info
fn tag(s: &Shared, x: D, y: D, z: D, text: &str, prio: f64, id: Option<&str>, kind: &str) -> RR<()> {
let mut b = s.borrow_mut();
let p = b.xf.p3([n(&x)?, n(&y)?, n(&z)?]);
let id = id.map(str::to_string).or_else(|| b.obj.clone()).unwrap_or_default();
b.scene.tag(p, text, prio, id, classes(kind));
Ok(())
}
let s = st.clone();
e.register_fn("tag", move |x: D, y: D, z: D, t: &str| tag(&s, x, y, z, t, 1.0, None, ""));
let s = st.clone();
e.register_fn("tag", move |x: D, y: D, z: D, t: &str, p: D| tag(&s, x, y, z, t, n(&p)?, None, ""));
let s = st.clone();
e.register_fn("tag", move |x: D, y: D, z: D, t: &str, p: D, id: &str| tag(&s, x, y, z, t, n(&p)?, Some(id), ""));
let s = st.clone();
e.register_fn("tag", move |x: D, y: D, z: D, t: &str, p: D, id: &str, k: &str| {
tag(&s, x, y, z, t, n(&p)?, Some(id), k)
});
let s = st.clone();
e.register_fn("info", move |id: &str, name: &str| s.borrow_mut().scene.info(id, name, ""));
let s = st.clone();
e.register_fn("info", move |id: &str, name: &str, detail: &str| s.borrow_mut().scene.info(id, name, detail));
let s = st.clone();
e.register_fn("info", move |id: &str, m: Map| {
let g = |k: &str| m.get(k).map(|v| v.to_string()).unwrap_or_default();
let mut b = s.borrow_mut();
b.scene.info(id, g("name"), g("detail"));
if let Some(rows) = m.get("rows").and_then(|r| r.read_lock::<Array>().map(|a| a.clone())) {
let rows: Vec<(String, String)> = rows
.iter()
.filter_map(|r| r.read_lock::<Array>().map(|a| a.clone()))
.filter(|a| a.len() >= 2)
.map(|a| (a[0].to_string(), a[1].to_string()))
.collect();
if let Some(i) = b.scene.info.get_mut(id) {
i.rows = rows;
}
}
});
// framing
let s = st.clone();
e.register_fn("focus", move |x: D, y: D, z: D| -> RR<()> {
let mut b = s.borrow_mut();
let p = b.xf.p3([n(&x)?, n(&y)?, n(&z)?]);
b.scene.focus = Some(p);
Ok(())
});
let s = st.clone();
e.register_fn("bounds", move |x0: D, y0: D, x1: D, y1: D| -> RR<()> {
let mut b = s.borrow_mut();
let r = b.xf.rect(n(&x0)?, n(&y0)?, n(&x1)?, n(&y1)?);
b.scene.bounds = Some((r, -1.6));
Ok(())
});
let s = st.clone();
e.register_fn("bounds", move |x0: D, y0: D, x1: D, y1: D, zb: D| -> RR<()> {
let mut b = s.borrow_mut();
let r = b.xf.rect(n(&x0)?, n(&y0)?, n(&x1)?, n(&y1)?);
b.scene.bounds = Some((r, -n(&zb)?.abs()));
Ok(())
});
let s = st.clone();
e.register_fn("defs", move |svg: &str| s.borrow_mut().scene.defs.push(svg.to_string()));
let s = st.clone();
e.register_fn("warn", move |msg: &str| s.borrow_mut().scene.warnings.push(msg.to_string()));
// view and inputs
let s = st.clone();
e.register_fn("level", move || s.borrow().level.clone());
let s = st.clone();
e.register_fn("rot", move || s.borrow().proj.rot as i64);
let s = st.clone();
e.register_fn("cfg", move || s.borrow().cfg.clone());
let s = st.clone();
e.register_fn("cfg", move |k: &str| s.borrow().cfg.get(k).cloned().unwrap_or(Dynamic::UNIT));
let s = st.clone();
e.register_fn("objects", move || s.borrow().all_objects.clone());
// is a face with outward normal (nx, ny) (object coordinates) turned towards the viewer?
let s = st.clone();
e.register_fn("vis", move |nx: D, ny: D| -> RR<bool> {
let b = s.borrow();
let w = b.xf.v(n(&nx)?, n(&ny)?);
Ok(b.proj.vis(w[0], w[1]).is_some())
});
let s = st.clone();
e.register_fn("dv", move |dx: D, dy: D, dz: D| -> RR<Array> {
let b = s.borrow();
let w = b.xf.v(n(&dx)?, n(&dy)?);
let v = b.proj.dv(w[0], w[1], n(&dz)?);
Ok(vec![Dynamic::from_float(round1(v[0])), Dynamic::from_float(round1(v[1]))])
});
// should a mover run along its x axis? (x is then the axis the depth sort compares second)
let s = st.clone();
e.register_fn("along_x", move || {
let b = s.borrow();
let w = b.xf.v(1.0, 0.0);
b.proj.r(w[0], w[1])[0].abs() < 1e-9
});
let s = st.clone();
e.register_fn("depth", move |x: D, y: D| -> RR<f64> {
let b = s.borrow();
let p = b.xf.p(n(&x)?, n(&y)?);
Ok(b.proj.depth(p[0], p[1]))
});
// helpers
e.register_fn("px", || C);
e.register_fn("hash", |x: D| -> RR<f64> { Ok(hash(n(&x)?)) });
e.register_fn("fmt", |x: D| -> RR<String> { Ok(thousands(n(&x)?)) });
e.register_fn("round1", |x: D| -> RR<f64> { Ok(round1(n(&x)?)) });
e.register_fn("num", |x: D| -> RR<f64> { n(&x) });
e.register_fn("fmin", |a: D, b: D| -> RR<f64> { Ok(n(&a)?.min(n(&b)?)) });
e.register_fn("fmax", |a: D, b: D| -> RR<f64> { Ok(n(&a)?.max(n(&b)?)) });
e.register_fn("clamp", |x: D, lo: D, hi: D| -> RR<f64> { Ok(n(&x)?.max(n(&lo)?).min(n(&hi)?)) });
e.register_fn("rect", |x0: D, y0: D, x1: D, y1: D| -> RR<Array> {
let (x0, y0, x1, y1) = (n(&x0)?, n(&y0)?, n(&x1)?, n(&y1)?);
Ok(vec![arr_f(&[x0, y0]), arr_f(&[x1, y0]), arr_f(&[x1, y1]), arr_f(&[x0, y1])])
});
e.register_fn("ellipse", |cx: D, cy: D, a: D, b: D, k: D| -> RR<Array> {
let (cx, cy, a, b, k) = (n(&cx)?, n(&cy)?, n(&a)?, n(&b)?, n(&k)?.max(3.0) as usize);
Ok((0..k)
.map(|i| {
let t = i as f64 / k as f64 * std::f64::consts::TAU;
arr_f(&[cx + a * t.cos(), cy + b * t.sin()])
})
.collect())
});
// outline segments of a rectangle [x0, y0, x1, y1]
e.register_fn("rsegs", |r: D| -> RR<Array> {
let r = rect4(&r)?;
Ok(vec![
arr_f(&[r[0], r[1], r[2], r[1]]),
arr_f(&[r[2], r[1], r[2], r[3]]),
arr_f(&[r[2], r[3], r[0], r[3]]),
arr_f(&[r[0], r[3], r[0], r[1]]),
])
});
// the four faces of a rectangle as [from, to] with the outward normal on the right
e.register_fn("sides", |x0: D, y0: D, x1: D, y1: D| -> RR<Map> {
let (x0, y0, x1, y1) = (n(&x0)?, n(&y0)?, n(&x1)?, n(&y1)?);
let mut m = Map::new();
m.insert("S".into(), arr_pts(&[[x0, y0], [x1, y0]]));
m.insert("E".into(), arr_pts(&[[x1, y0], [x1, y1]]));
m.insert("N".into(), arr_pts(&[[x1, y1], [x0, y1]]));
m.insert("W".into(), arr_pts(&[[x0, y1], [x0, y0]]));
Ok(m)
});
}
+151
View File
@@ -0,0 +1,151 @@
//! Conversions from script values (Rhai `Dynamic`) to engine types.
//!
//! Scripts mix integers and floats freely (`s.box(0, 0, 10, 6, 0, 3.5, ...)`),
//! so every number argument is taken as `Dynamic` and read with [`n`].
use isokit_core::{classes, Style, Zs, P2, P3};
use rhai::{Array, Dynamic, EvalAltResult, Map};
pub type RR<T> = Result<T, Box<EvalAltResult>>;
pub fn err<T>(msg: impl Into<String>) -> RR<T> {
Err(msg.into().into())
}
/// A number (int or float).
pub fn n(d: &Dynamic) -> RR<f64> {
if let Ok(f) = d.as_float() {
return Ok(f);
}
if let Ok(i) = d.as_int() {
return Ok(i as f64);
}
err(format!("expected a number, got {}", d.type_name()))
}
fn arr(d: &Dynamic, what: &str) -> RR<Array> {
match d.read_lock::<Array>() {
Some(a) => Ok(a.clone()),
None => err(format!("{what}: expected an array, got {}", d.type_name())),
}
}
/// `[x, y]`
pub fn p2(d: &Dynamic) -> RR<P2> {
let a = arr(d, "point")?;
if a.len() < 2 {
return err("point: expected [x, y]");
}
Ok([n(&a[0])?, n(&a[1])?])
}
/// `[x, y, z]`
pub fn p3(d: &Dynamic) -> RR<P3> {
let a = arr(d, "point")?;
if a.len() < 3 {
return err("point: expected [x, y, z]");
}
Ok([n(&a[0])?, n(&a[1])?, n(&a[2])?])
}
/// `[[x, y], ...]`
pub fn pts(d: &Dynamic) -> RR<Vec<P2>> {
arr(d, "points")?.iter().map(p2).collect()
}
/// `[[x, y, z], ...]`
pub fn pts3(d: &Dynamic) -> RR<Vec<P3>> {
arr(d, "points")?.iter().map(p3).collect()
}
fn is_point(d: &Dynamic) -> bool {
d.read_lock::<Array>().map(|a| !a.is_empty() && (a[0].is_int() || a[0].is_float())).unwrap_or(false)
}
/// One polygon `[[x, y], ...]` or several `[[[x, y], ...], ...]`.
pub fn polys(d: &Dynamic) -> RR<Vec<Vec<P2>>> {
let a = arr(d, "polygon")?;
if a.is_empty() {
return Ok(vec![]);
}
if is_point(&a[0]) {
Ok(vec![pts(d)?])
} else {
a.iter().map(pts).collect()
}
}
/// One 3D polygon or several.
pub fn polys3(d: &Dynamic) -> RR<Vec<Vec<P3>>> {
let a = arr(d, "polygon")?;
if a.is_empty() {
return Ok(vec![]);
}
if is_point(&a[0]) {
Ok(vec![pts3(d)?])
} else {
a.iter().map(pts3).collect()
}
}
/// `[[a, b, c, d], ...]` (segments, opening lists)
pub fn quads(d: &Dynamic, what: &str) -> RR<Vec<[f64; 4]>> {
arr(d, what)?
.iter()
.map(|q| {
let a = arr(q, what)?;
if a.len() < 4 {
return err(format!("{what}: expected 4 numbers per entry"));
}
Ok([n(&a[0])?, n(&a[1])?, n(&a[2])?, n(&a[3])?])
})
.collect()
}
/// `[x0, y0, x1, y1]`
pub fn rect4(d: &Dynamic) -> RR<[f64; 4]> {
let a = arr(d, "rect")?;
if a.len() < 4 {
return err("rect: expected [x0, y0, x1, y1]");
}
Ok([n(&a[0])?, n(&a[1])?, n(&a[2])?, n(&a[3])?])
}
/// A height: one number or one per vertex.
pub fn zs(d: &Dynamic) -> RR<Zs> {
if d.is_array() {
Ok(Zs::Each(arr(d, "heights")?.iter().map(n).collect::<RR<Vec<f64>>>()?))
} else {
Ok(Zs::One(n(d)?))
}
}
fn get_str(m: &Map, k: &str) -> Option<String> {
m.get(k).and_then(|v| if v.is_unit() { None } else { Some(v.to_string()) })
}
fn get_bool(m: &Map, keys: &[&str]) -> bool {
keys.iter().any(|k| m.get(*k).and_then(|v| v.as_bool().ok()).unwrap_or(false))
}
/// Style from a material and an optional options map
/// `#{ id, top, side, no_top, no_side }`.
pub fn style(mat: &str, o: Option<&Map>) -> Style {
let mut st = Style::mat(classes(mat));
if let Some(m) = o {
st.id = get_str(m, "id");
st.top = get_str(m, "top").map(|s| classes(&s));
st.side = get_str(m, "side").map(|s| classes(&s));
st.no_top = get_bool(m, &["no_top", "noTop"]);
st.no_side = get_bool(m, &["no_side", "noSide"]);
}
st
}
pub fn arr_f(v: &[f64]) -> Dynamic {
Dynamic::from_array(v.iter().map(|x| Dynamic::from_float(*x)).collect())
}
pub fn arr_pts(v: &[P2]) -> Dynamic {
Dynamic::from_array(v.iter().map(|p| arr_f(p)).collect())
}
+206
View File
@@ -0,0 +1,206 @@
//! The scene document: what the Studio GUI and agents edit (scene.json),
//! and the prefab catalog that describes each object type.
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
fn one() -> u32 {
1
}
fn is_zero(v: &f64) -> bool {
*v == 0.0
}
fn is_false(v: &bool) -> bool {
!*v
}
/// A scene document: placed objects plus parameter values.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Doc {
#[serde(default = "one")]
pub version: u32,
/// Values for the project parameters (override the manifest defaults).
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub cfg: Map<String, Value>,
#[serde(default)]
pub objects: Vec<Object>,
}
/// One placed object. Its prefab (`kind`) draws it around (x, y, z), turned
/// by `rot` degrees (multiples of 90), in its own coordinates.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Object {
pub id: String,
#[serde(rename = "type")]
pub kind: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub name: String,
#[serde(default)]
pub x: f64,
#[serde(default)]
pub y: f64,
#[serde(default, skip_serializing_if = "is_zero")]
pub z: f64,
#[serde(default, skip_serializing_if = "is_zero")]
pub rot: f64,
/// Prefab properties; missing ones take the prefab defaults.
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub props: Map<String, Value>,
/// Tooltip text.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub info: String,
/// Label pinned above the object.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub tag: String,
/// Levels the object shows on (empty: all).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub levels: Vec<String>,
/// Outline or path in the object's own coordinates (areas, roads, fences).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pts: Option<Vec<[f64; 2]>>,
#[serde(default, skip_serializing_if = "is_false")]
pub hidden: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub locked: bool,
}
/// One editable property of a prefab.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct PropDef {
pub key: String,
/// float | int | bool | choice | text | material
#[serde(rename = "type", default = "default_kind")]
pub kind: String,
/// Default value (`def` in Rhai, where `default` is a reserved word).
#[serde(default, alias = "def")]
pub default: Value,
#[serde(default)]
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step: Option<f64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub choices: Vec<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub help: String,
}
fn default_kind() -> String {
"float".into()
}
/// An object type, read from the prefab's `meta_<type>()` function.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct PrefabMeta {
#[serde(rename = "type", default)]
pub kind: String,
#[serde(default)]
pub label: String,
#[serde(default)]
pub category: String,
#[serde(default)]
pub icon: String,
#[serde(default)]
pub help: String,
/// How the Studio places it: "point" (click), "path" (click points).
#[serde(default = "default_shape")]
pub shape: String,
#[serde(default)]
pub props: Vec<PropDef>,
/// Module the prefab comes from ("isokit/site", "lib/kiosk.rhai").
#[serde(default)]
pub source: String,
}
fn default_shape() -> String {
"point".into()
}
impl PrefabMeta {
pub fn prop(&self, key: &str) -> Option<&PropDef> {
self.props.iter().find(|p| p.key == key)
}
/// Object props with the defaults filled in and numbers coerced to the
/// declared types.
pub fn resolve(&self, props: &Map<String, Value>) -> Map<String, Value> {
let mut out = props.clone();
for d in &self.props {
let v = out.get(&d.key).cloned().unwrap_or_else(|| d.default.clone());
let v = match (d.kind.as_str(), &v) {
("float", Value::Number(n)) => n.as_f64().map(Value::from).unwrap_or(v),
("float", Value::String(s)) => s.parse::<f64>().map(Value::from).unwrap_or(d.default.clone()),
("int", Value::Number(n)) => n.as_f64().map(|f| Value::from(f.round() as i64)).unwrap_or(v),
("int", Value::String(s)) => s.parse::<f64>().map(|f| Value::from(f.round() as i64)).unwrap_or(d.default.clone()),
("bool", Value::String(s)) => Value::Bool(matches!(s.as_str(), "true" | "1" | "yes" | "on")),
("bool", Value::Number(n)) => Value::Bool(n.as_f64().unwrap_or(0.0) != 0.0),
_ => v,
};
out.insert(d.key.clone(), v);
}
out
}
}
impl Doc {
pub fn get(&self, id: &str) -> Option<&Object> {
self.objects.iter().find(|o| o.id == id)
}
pub fn get_mut(&mut self, id: &str) -> Option<&mut Object> {
self.objects.iter_mut().find(|o| o.id == id)
}
/// A free id: `base`, `base-2`, `base-3`, ...
pub fn free_id(&self, base: &str) -> String {
let base: String = base
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c.to_ascii_lowercase() } else { '-' })
.collect();
let base = if base.is_empty() { "obj".to_string() } else { base };
if self.get(&base).is_none() {
return base;
}
(2..).map(|i| format!("{base}-{i}")).find(|id| self.get(id).is_none()).unwrap()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn object_round_trips_compactly() {
let o: Object = serde_json::from_value(json!({"id": "t1", "type": "tree", "x": 3, "y": 4.5})).unwrap();
assert_eq!(o.kind, "tree");
let back = serde_json::to_value(&o).unwrap();
assert_eq!(back, json!({"id": "t1", "type": "tree", "x": 3.0, "y": 4.5}));
}
#[test]
fn defaults_and_coercion() {
let m = PrefabMeta {
props: vec![
PropDef { key: "r".into(), kind: "float".into(), default: json!(1.5), ..Default::default() },
PropDef { key: "n".into(), kind: "int".into(), default: json!(2), ..Default::default() },
PropDef { key: "on".into(), kind: "bool".into(), default: json!(false), ..Default::default() },
],
..Default::default()
};
let p = m.resolve(json!({"n": "3.6", "on": "yes"}).as_object().unwrap());
assert_eq!(p["r"], json!(1.5));
assert_eq!(p["n"], json!(4));
assert_eq!(p["on"], json!(true));
}
#[test]
fn free_ids() {
let mut d = Doc::default();
assert_eq!(d.free_id("Tree"), "tree");
d.objects.push(Object { id: "tree".into(), kind: "tree".into(), ..Default::default() });
d.objects.push(Object { id: "tree-2".into(), kind: "tree".into(), ..Default::default() });
assert_eq!(d.free_id("tree"), "tree-3");
}
}
+457
View File
@@ -0,0 +1,457 @@
//! isokit-script: the scene runtime.
//!
//! A project is a set of Rhai sources ([`Bundle`]) plus a scene document
//! ([`doc::Doc`]). [`Runtime::build`] runs, for one level and rotation:
//!
//! 1. the optional main script (`scene.rhai`): free drawing with the full API;
//! 2. every document object through its prefab: `prefab_<type>(o)` is called
//! with the object's frame set, so the prefab draws around its own origin.
//!
//! Prefabs come from the built-in library (`isokit/*`, embedded), the
//! project's prelude modules and the main script. Each prefab may have a
//! `meta_<type>()` function returning its label, category and property
//! schema; [`Runtime::catalog`] collects them for the Studio and agents.
pub mod api;
pub mod conv;
pub mod doc;
pub mod stdlib;
use crate::api::SpriteH;
use crate::doc::{Doc, PrefabMeta};
use isokit_core::{Proj, Scene, Xf};
use rhai::module_resolvers::ModuleResolver;
use rhai::{Array, Dynamic, Engine, EvalAltResult, Map, Module, Position, Scope, AST};
use serde::{Deserialize, Serialize};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;
/// Script sources of a project, keyed by relative path ("scene.rhai",
/// "lib/kiosk.rhai").
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Bundle {
/// Main script (optional).
pub main: Option<String>,
/// Modules whose functions are global (project prefab libraries).
#[serde(default)]
pub prelude: Vec<String>,
#[serde(default)]
pub sources: BTreeMap<String, String>,
}
/// A script error with its location.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct ScriptError {
pub message: String,
pub file: String,
pub line: Option<usize>,
pub col: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub trace: Vec<String>,
}
impl std::fmt::Display for ScriptError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.file)?;
if let Some(l) = self.line {
write!(f, ":{l}")?;
if let Some(c) = self.col {
write!(f, ":{c}")?;
}
}
write!(f, ": {}", self.message)?;
for t in &self.trace {
write!(f, "\n {t}")?;
}
Ok(())
}
}
impl std::error::Error for ScriptError {}
fn to_error(e: &EvalAltResult, file: &str) -> ScriptError {
let mut trace = vec![];
let mut cur = e;
let mut file = file.to_string();
loop {
match cur {
EvalAltResult::ErrorInFunctionCall(name, src, inner, p) => {
trace.push(format!("in {name}() called at {file}:{}", p.line().unwrap_or(0)));
if !src.is_empty() {
file = src.clone();
}
cur = inner;
}
EvalAltResult::ErrorInModule(path, inner, p) => {
trace.push(format!("in module {path} imported at {file}:{}", p.line().unwrap_or(0)));
file = path.clone();
cur = inner;
}
_ => break,
}
}
let pos = cur.position();
let mut message = cur.to_string();
if let Some(i) = message.rfind(" (line ") {
message.truncate(i);
}
ScriptError { message, file, line: pos.line(), col: pos.position(), trace }
}
/// Shared state of one build, read and written by the registered functions.
pub(crate) struct State {
pub scene: Scene,
pub layer: String,
pub sprites: Vec<(String, SpriteH)>,
pub ground: Option<SpriteH>,
pub proj: Proj,
pub level: String,
pub cfg: Map,
pub xf: Xf,
pub obj: Option<String>,
pub obj_start: usize,
pub objects: Array,
pub all_objects: Array,
pub boxes: BTreeMap<String, [f64; 6]>,
pub main: String,
pub catalog: BTreeMap<String, PrefabMeta>,
}
impl State {
fn new() -> Self {
State {
scene: Scene::new(),
layer: "main".into(),
sprites: vec![],
ground: None,
proj: Proj::new(0),
level: String::new(),
cfg: Map::new(),
xf: Xf::default(),
obj: None,
obj_start: 0,
objects: vec![],
all_objects: vec![],
boxes: BTreeMap::new(),
main: "scene.rhai".into(),
catalog: BTreeMap::new(),
}
}
}
/// Resolves `import "lib/x"` against the bundle's sources (no disk access,
/// so the same runtime works in the browser later).
struct SourceResolver {
sources: Rc<BTreeMap<String, String>>,
}
fn norm_path(base: Option<&str>, path: &str) -> String {
let mut parts: Vec<&str> = vec![];
if path.starts_with("./") || path.starts_with("../") {
if let Some(b) = base {
parts.extend(b.split('/'));
parts.pop();
}
}
for seg in path.split(['/', '\\']) {
match seg {
"" | "." => {}
".." => {
parts.pop();
}
s => parts.push(s),
}
}
let mut p = parts.join("/");
if !p.ends_with(".rhai") {
p.push_str(".rhai");
}
p
}
impl ModuleResolver for SourceResolver {
fn resolve(
&self,
engine: &Engine,
source: Option<&str>,
path: &str,
pos: Position,
) -> Result<rhai::Shared<Module>, Box<EvalAltResult>> {
let key = norm_path(source, path);
let src = self
.sources
.get(&key)
.ok_or_else(|| Box::new(EvalAltResult::ErrorModuleNotFound(key.clone(), pos)))?;
let mut ast = engine
.compile(src)
.map_err(|e| Box::new(EvalAltResult::ErrorInModule(key.clone(), e.into(), pos)))?;
ast.set_source(key.as_str());
let m = Module::eval_ast_as_new(Scope::new(), &ast, engine)
.map_err(|e| Box::new(EvalAltResult::ErrorInModule(key.clone(), e, pos)))?;
Ok(m.into())
}
}
/// Runs over the document objects after the main script.
const DRIVER: &str = r#"
for o in __objects() {
if __begin(o) {
try {
Fn(o.__fn).call(o);
} catch (e) {
__fail(o, e);
}
__end(o);
}
}
"#;
/// Result of a build: the scene and the world box of every object.
pub struct Built {
pub scene: Scene,
/// Object id -> [x0, y0, z0, x1, y1, z1] of what its prefab drew.
pub boxes: BTreeMap<String, [f64; 6]>,
}
pub struct Runtime {
engine: Engine,
ast: AST,
state: Rc<RefCell<State>>,
catalog: Vec<PrefabMeta>,
main: String,
}
fn dyn_json(v: &serde_json::Value) -> Dynamic {
rhai::serde::to_dynamic(v).unwrap_or(Dynamic::UNIT)
}
fn register_driver(e: &mut Engine, st: &Rc<RefCell<State>>) {
let s = st.clone();
e.register_fn("__objects", move || s.borrow().objects.clone());
let s = st.clone();
e.register_fn("__begin", move |o: Map| -> bool {
let g = |k: &str| o.get(k).cloned().unwrap_or(Dynamic::UNIT);
let num = |k: &str| conv::n(&g(k)).unwrap_or(0.0);
let id = g("id").to_string();
let mut b = s.borrow_mut();
b.xf = Xf::new(num("x"), num("y"), num("z"), num("rot"));
b.obj = Some(id.clone());
b.layer = "main".into();
b.obj_start = b.sprites.len();
let kind = g("type").to_string();
let name = g("name").to_string();
let label = b.catalog.get(&kind).map(|m| m.label.clone()).unwrap_or_else(|| kind.clone());
let name = if name.is_empty() || g("name").is_unit() { label } else { name };
let detail = if g("info").is_unit() { String::new() } else { g("info").to_string() };
b.scene.info(id, name, detail);
if !b.catalog.contains_key(&kind) {
b.scene.warnings.push(format!("object {}: unknown type \"{kind}\"", g("id")));
drop(b);
// a placeholder so the object can still be found and fixed
let sp = api::new_sprite(&s, None, None, "");
sp.0.borrow_mut().cuboid(-0.5, -0.5, 0.5, 0.5, 0.0, 1.0, isokit_core::Style::mat("iso-warn"));
s.borrow_mut().obj = None;
s.borrow_mut().xf = Xf::default();
return false;
}
true
});
let s = st.clone();
e.register_fn("__end", move |o: Map| {
let mut b = s.borrow_mut();
let id = o.get("id").map(|v| v.to_string()).unwrap_or_default();
let mut bx = [f64::MAX, f64::MAX, f64::MAX, f64::MIN, f64::MIN, f64::MIN];
for (_, h) in &b.sprites[b.obj_start..] {
if let Some(w) = h.0.borrow().world_box() {
for k in 0..3 {
bx[k] = bx[k].min(w[k]);
bx[k + 3] = bx[k + 3].max(w[k + 3]);
}
}
}
if bx[0] <= bx[3] {
let tag = o.get("tag").map(|v| v.to_string()).unwrap_or_default();
if !tag.is_empty() && o.get("tag").is_some_and(|v| !v.is_unit()) {
b.scene.tag([(bx[0] + bx[3]) / 2.0, (bx[1] + bx[4]) / 2.0, bx[5] + 0.6], tag, 6.0, id.clone(), "");
}
b.boxes.insert(id, bx);
}
b.obj = None;
b.xf = Xf::default();
b.layer = "main".into();
});
let s = st.clone();
e.register_fn("__fail", move |o: Map, e: Dynamic| {
let msg = match e.read_lock::<Map>() {
Some(m) => {
let g = |k: &str| m.get(k).map(|v| v.to_string()).unwrap_or_default();
format!("{} ({}:{})", g("message"), g("source"), g("line"))
}
None => e.to_string(),
};
let id = o.get("id").map(|v| v.to_string()).unwrap_or_default();
s.borrow_mut().scene.warnings.push(format!("object {id}: {msg}"));
});
}
impl Runtime {
/// Compile the library, the project's prelude modules and main script.
pub fn new(bundle: &Bundle) -> Result<Runtime, ScriptError> {
let state = Rc::new(RefCell::new(State::new()));
let main = bundle.main.clone().unwrap_or_else(|| "scene.rhai".into());
state.borrow_mut().main = main.clone();
let mut engine = Engine::new();
engine.set_max_operations(80_000_000);
engine.set_max_call_levels(64);
engine.set_max_expr_depths(512, 512);
engine.set_module_resolver(SourceResolver { sources: Rc::new(bundle.sources.clone()) });
api::register_sprite(&mut engine);
api::register_scene(&mut engine, &state);
register_driver(&mut engine, &state);
// global modules: built-in library, then the project's prelude
let mut asts: Vec<(String, AST)> = vec![];
let mut mods: Vec<(String, String)> = stdlib::MODULES.iter().map(|(n, s)| (n.to_string(), s.to_string())).collect();
for p in &bundle.prelude {
let src = bundle.sources.get(p).ok_or_else(|| ScriptError {
message: format!("prelude module {p} not found"),
file: p.clone(),
..Default::default()
})?;
mods.push((p.clone(), src.clone()));
}
for (name, src) in &mods {
let mut ast = engine.compile(src).map_err(|e| to_error(&e.into(), name))?;
ast.set_source(name.as_str());
let m = Module::eval_ast_as_new(Scope::new(), &ast, &engine).map_err(|e| to_error(&e, name))?;
engine.register_global_module(m.into());
asts.push((name.clone(), ast));
}
// main script (optional) followed by the object driver
let mut ast = match &bundle.main {
Some(m) => {
let src = bundle.sources.get(m).ok_or_else(|| ScriptError {
message: "main script not found".into(),
file: m.clone(),
..Default::default()
})?;
let mut a = engine.compile(src).map_err(|e| to_error(&e.into(), m))?;
a.set_source(m.as_str());
a
}
None => AST::empty(),
};
asts.push((main.clone(), ast.clone()));
let driver = engine.compile(DRIVER).map_err(|e| to_error(&e.into(), "driver"))?;
ast = ast.merge(&driver);
ast.set_source(main.as_str());
// prefab catalog: prefab_<type>(o) with an optional meta_<type>()
let mut catalog = vec![];
for (name, a) in &asts {
for f in a.iter_functions() {
let Some(kind) = f.name.strip_prefix("prefab_") else { continue };
if f.params.len() != 1 {
continue;
}
let meta_fn = format!("meta_{kind}");
let mut meta = if a.iter_functions().any(|g| g.name == meta_fn && g.params.is_empty()) {
let v: Dynamic = engine
.call_fn(&mut Scope::new(), a, &meta_fn, ())
.map_err(|e| to_error(&e, name))?;
let j: serde_json::Value = rhai::serde::from_dynamic(&v).map_err(|e| to_error(&e, name))?;
serde_json::from_value::<PrefabMeta>(j).map_err(|e| ScriptError {
message: format!("{meta_fn}(): {e}"),
file: name.clone(),
..Default::default()
})?
} else {
PrefabMeta::default()
};
meta.kind = kind.to_string();
if meta.label.is_empty() {
meta.label = kind.replace('_', " ");
}
if meta.category.is_empty() {
meta.category = "Other".into();
}
meta.source = name.clone();
catalog.retain(|m: &PrefabMeta| m.kind != meta.kind); // later modules override
catalog.push(meta);
}
}
state.borrow_mut().catalog = catalog.iter().map(|m| (m.kind.clone(), m.clone())).collect();
Ok(Runtime { engine, ast, state, catalog, main })
}
/// Object types available to the document.
pub fn catalog(&self) -> &[PrefabMeta] {
&self.catalog
}
pub fn prefab(&self, kind: &str) -> Option<&PrefabMeta> {
self.catalog.iter().find(|m| m.kind == kind)
}
/// Build the scene for a level and rotation. `cfg` holds the parameter
/// values (already merged with the defaults).
pub fn build(&self, level: &str, rot: u8, cfg: &serde_json::Map<String, serde_json::Value>, doc: &Doc) -> Result<Built, ScriptError> {
{
let mut b = self.state.borrow_mut();
let catalog = std::mem::take(&mut b.catalog);
let main = std::mem::take(&mut b.main);
*b = State::new();
b.catalog = catalog;
b.main = main;
b.level = level.to_string();
b.proj = Proj::new(rot as i64);
b.cfg = match dyn_json(&serde_json::Value::Object(cfg.clone())).try_cast::<Map>() {
Some(m) => m,
None => Map::new(),
};
for o in &doc.objects {
let mut j = serde_json::to_value(o).unwrap_or_default();
let meta = b.catalog.get(&o.kind);
let props = meta.map(|m| m.resolve(&o.props)).unwrap_or_else(|| o.props.clone());
if let Some(m) = j.as_object_mut() {
for k in ["name", "info", "tag"] {
m.entry(k).or_insert(serde_json::Value::String(String::new()));
}
for k in ["z", "rot"] {
m.entry(k).or_insert(serde_json::Value::from(0.0));
}
m.insert("p".into(), serde_json::Value::Object(props));
m.insert("__fn".into(), serde_json::Value::String(format!("prefab_{}", o.kind)));
m.insert("level".into(), serde_json::Value::String(level.into()));
}
let d = dyn_json(&j);
b.all_objects.push(d.clone());
if !o.hidden && (o.levels.is_empty() || o.levels.iter().any(|l| l == level)) {
b.objects.push(d);
}
}
}
let r = self.engine.run_ast(&self.ast);
let mut b = self.state.borrow_mut();
if let Err(e) = r {
return Err(to_error(&e, &self.main));
}
let mut scene = std::mem::take(&mut b.scene);
let sprites = std::mem::take(&mut b.sprites);
b.ground = None;
for (layer, h) in sprites {
let mut sp = std::mem::take(&mut *h.0.borrow_mut());
let open = sp.finish();
if open > 0 {
scene.warnings.push(format!("sprite {}: {open} group(s) left open", sp.id));
}
scene.add_sprite(&layer, sp);
}
Ok(Built { scene, boxes: std::mem::take(&mut b.boxes) })
}
}
#[cfg(test)]
mod tests;
+16
View File
@@ -0,0 +1,16 @@
//! The built-in prefab library (Rhai sources embedded in the binary).
//!
//! Every module is registered as a global module, so its functions are
//! callable from any script without `import`, and functions that use `this`
//! work as sprite methods (`s.car(...)`). A project overrides a prefab by
//! defining `prefab_<type>` again in its prelude or main script.
pub const MODULES: &[(&str, &str)] = &[
("isokit/util", include_str!("../stdlib/util.rhai")),
("isokit/ground", include_str!("../stdlib/ground.rhai")),
("isokit/nature", include_str!("../stdlib/nature.rhai")),
("isokit/building", include_str!("../stdlib/building.rhai")),
("isokit/vehicles", include_str!("../stdlib/vehicles.rhai")),
("isokit/people", include_str!("../stdlib/people.rhai")),
("isokit/equipment", include_str!("../stdlib/equipment.rhai")),
];
+210
View File
@@ -0,0 +1,210 @@
//! Runtime tests: the scripting API, prefabs, the document driver, errors.
use crate::doc::{Doc, Object};
use crate::{Bundle, Runtime};
use isokit_core::{render, RenderOpts};
use serde_json::{json, Map, Value};
fn rt(main: &str) -> Runtime {
let mut b = Bundle { main: Some("scene.rhai".into()), ..Default::default() };
b.sources.insert("scene.rhai".into(), main.into());
Runtime::new(&b).unwrap_or_else(|e| panic!("{e}"))
}
fn cfg() -> Map<String, Value> {
Map::new()
}
fn obj(id: &str, kind: &str, x: f64, y: f64, props: Value) -> Object {
Object { id: id.into(), kind: kind.into(), x, y, props: props.as_object().cloned().unwrap_or_default(), ..Default::default() }
}
#[test]
fn script_draws_with_mixed_numbers() {
let r = rt(r#"
let s = sprite("hall");
s.box(0, 0, 10, 6.5, 0, 3, "wall", #{ top: "roof" });
info("hall", "Hall", "10 x 6.5 m");
tag(5, 3, 4, "Hall", 5);
"#);
let b = r.build("roof", 0, &cfg(), &Doc::default()).unwrap();
let out = render(&b.scene, 0, &RenderOpts::default());
assert!(out.html.contains("iso-roof iso-t"), "{}", out.html);
assert!(out.html.contains("iso-wall iso-l"));
assert!(out.html.contains(">Hall<"));
assert_eq!(out.info["hall"].detail, "10 x 6.5 m");
}
#[test]
fn methods_from_the_library_work_on_sprites() {
let r = rt(r#"
let s = sprite("t");
s.tree_at(0, 0, 1.5);
sprite("c").car(0, 0, 4.4, 1.8, "car2");
"#);
let b = r.build("site", 1, &cfg(), &Doc::default()).unwrap();
let out = render(&b.scene, 1, &RenderOpts::default());
assert!(out.html.contains("iso-leaf iso-l"));
assert!(out.html.contains("iso-glassd"));
}
#[test]
fn seq_group_and_closures() {
let r = rt(r#"
let s = sprite("x");
let h = 2.0;
s.seq([[0, 0, |q| q.box(0, 0, 1, 1, 0, h, "wall")], [5, 0, |q| q.box(5, 0, 6, 1, 0, h, "roof")]]);
s.group("a drive", |q| q.box(0, 3, 1, 4, 0, 1, "car1"));
"#);
let b = r.build("site", 0, &cfg(), &Doc::default()).unwrap();
let out = render(&b.scene, 0, &RenderOpts::default());
// from the south-east the box at x = 5 is nearer, so it is drawn after the one at x = 0
let (a, c) = (out.html.find("iso-wall").unwrap(), out.html.find("iso-roof").unwrap());
assert!(a < c);
assert!(out.html.contains("<g class=\"iso-a iso-drive\">"));
}
#[test]
fn errors_point_at_the_line() {
let mut b = Bundle { main: Some("scene.rhai".into()), ..Default::default() };
b.sources.insert("scene.rhai".into(), "let s = sprite(\"a\");\n\ns.box(0, 0, 1, 1, 0, \"high\", \"wall\");\n".into());
let r = Runtime::new(&b).unwrap();
let e = r.build("site", 0, &cfg(), &Doc::default()).err().expect("error");
assert_eq!(e.file, "scene.rhai");
assert_eq!(e.line, Some(3), "{e}");
assert!(e.message.contains("number"), "{e}");
b.sources.insert("scene.rhai".into(), "let x = ;".into());
let e = Runtime::new(&b).err().expect("parse error");
assert_eq!(e.line, Some(1), "{e}");
}
#[test]
fn imports_resolve_from_the_bundle() {
let mut b = Bundle { main: Some("scene.rhai".into()), ..Default::default() };
b.sources.insert("lib/kiosk.rhai".into(), "fn kiosk(x, y) { sprite(\"kiosk\").box(x, y, x + 2, y + 2, 0, 2.5, \"wall\"); }".into());
b.sources.insert("scene.rhai".into(), "import \"lib/kiosk\" as k;\nk::kiosk(1, 2);".into());
let r = Runtime::new(&b).unwrap();
let s = r.build("site", 0, &cfg(), &Doc::default()).unwrap().scene;
assert_eq!(s.sprite_count(), 1);
}
#[test]
fn catalog_lists_library_and_project_prefabs() {
let mut b = Bundle { main: None, prelude: vec!["lib/kiosk.rhai".into()], ..Default::default() };
b.sources.insert(
"lib/kiosk.rhai".into(),
r#"
fn meta_kiosk() { #{ label: "Kiosk", category: "Shop", props: [ #{ key: "h", type: "float", def: 2.5 } ] } }
fn prefab_kiosk(o) { sprite().box(-1, -1, 1, 1, 0, o.p.h, "wall"); }
"#
.into(),
);
let r = Runtime::new(&b).unwrap();
let kinds: Vec<&str> = r.catalog().iter().map(|m| m.kind.as_str()).collect();
for k in ["site", "area", "road", "building", "tree", "car", "person", "tank", "kiosk"] {
assert!(kinds.contains(&k), "{k} missing from {kinds:?}");
}
let k = r.prefab("kiosk").unwrap();
assert_eq!(k.label, "Kiosk");
assert_eq!(k.source, "lib/kiosk.rhai");
assert_eq!(k.props[0].default, json!(2.5));
}
#[test]
fn document_objects_are_placed_in_their_frame() {
let r = Runtime::new(&Bundle::default()).unwrap();
let mut doc = Doc::default();
let mut b = obj("hall", "building", 30.0, 20.0, json!({"w": 10, "d": 6, "floors": 1}));
b.rot = 90.0;
b.name = "Main hall".into();
b.tag = "Hall".into();
doc.objects.push(b);
doc.objects.push(obj("t1", "tree", 5.0, 5.0, json!({})));
let built = r.build("roof", 1, &cfg(), &doc).unwrap();
// turned a quarter: 6 m along x, 10 m along y, centred on (30, 20)
let bx = *built.boxes.get("hall").unwrap_or_else(|| panic!("{:?}", built.scene.warnings));
assert!((bx[0] - 27.0).abs() < 1e-9 && (bx[3] - 33.0).abs() < 1e-9, "{bx:?}");
assert!((bx[1] - 15.0).abs() < 1e-9 && (bx[4] - 25.0).abs() < 1e-9, "{bx:?}");
let out = render(&built.scene, 1, &RenderOpts::default());
assert!(out.html.contains("data-obj=\"hall\""));
assert!(out.html.contains("data-obj=\"t1\""));
assert_eq!(out.info["hall"].name, "Main hall");
assert_eq!(out.info["t1"].name, "Tree");
assert!(out.html.contains(">Hall<"));
assert!(built.scene.warnings.is_empty(), "{:?}", built.scene.warnings);
}
#[test]
fn levels_cut_buildings_open_and_filter_objects() {
let r = Runtime::new(&Bundle::default()).unwrap();
let mut doc = Doc::default();
doc.objects.push(obj("b", "building", 0.0, 0.0, json!({"floors": 2})));
let mut desk = obj("desk", "tree", 0.0, 0.0, json!({}));
desk.levels = vec!["gf".into()];
doc.objects.push(desk);
let closed = r.build("roof", 0, &cfg(), &doc).unwrap();
let gf = r.build("gf", 0, &cfg(), &doc).unwrap();
let up = r.build("3f", 0, &cfg(), &doc).unwrap();
assert!(!closed.boxes.contains_key("desk"));
assert!(gf.boxes.contains_key("desk"));
// cut open: a floor sprite plus four wall sprites
assert!(gf.scene.sprite_count() > closed.scene.sprite_count());
let html = render(&gf.scene, 0, &RenderOpts::default()).html;
assert!(html.contains("iso-z-floor iso-t"));
let html = render(&up.scene, 0, &RenderOpts::default()).html;
assert!(html.contains("class=\"iso-dim\""), "ghosted above the top storey");
}
#[test]
fn every_library_prefab_renders_cleanly_in_every_rotation() {
let r = Runtime::new(&Bundle::default()).unwrap();
let mut doc = Doc::default();
for (i, m) in r.catalog().iter().enumerate() {
let mut o = obj(&format!("o{i}"), &m.kind, i as f64 * 15.0, 0.0, json!({}));
// switch every bool prop on to exercise all branches
for p in &m.props {
if p.kind == "bool" {
o.props.insert(p.key.clone(), json!(true));
}
}
doc.objects.push(o);
}
for level in ["site", "gf", "1f"] {
for rot in 0..4 {
let b = r.build(level, rot, &cfg(), &doc).unwrap_or_else(|e| panic!("{level}/{rot}: {e}"));
assert!(b.scene.warnings.is_empty(), "{level}/{rot}: {:?}", b.scene.warnings);
let out = render(&b.scene, rot, &RenderOpts::default());
assert!(!out.html.contains("NaN"), "{level}/{rot}");
for m in r.catalog() {
let id = format!("o{}", r.catalog().iter().position(|q| q.kind == m.kind).unwrap());
assert!(b.boxes.contains_key(&id), "{} drew nothing in {level}/{rot}", m.kind);
}
}
}
}
#[test]
fn unknown_types_and_failing_prefabs_warn_but_render() {
let mut b = Bundle { main: None, prelude: vec!["lib/bad.rhai".into()], ..Default::default() };
b.sources.insert("lib/bad.rhai".into(), "fn prefab_bad(o) { sprite().box(0, 0, 1, 1, 0, o.p.nope, \"wall\"); }".into());
let r = Runtime::new(&b).unwrap();
let mut doc = Doc::default();
doc.objects.push(obj("u", "ufo", 0.0, 0.0, json!({})));
doc.objects.push(obj("x", "bad", 0.0, 0.0, json!({})));
doc.objects.push(obj("t", "tree", 5.0, 0.0, json!({})));
let built = r.build("site", 0, &cfg(), &doc).unwrap();
let w = built.scene.warnings.join("\n");
assert!(w.contains("object u: unknown type"), "{w}");
assert!(w.contains("object x:"), "{w}");
assert!(built.boxes.contains_key("t"));
}
#[test]
fn cfg_reaches_the_script() {
let r = rt(r#"if cfg("big") { sprite("a").box(0, 0, 9, 9, 0, 9, "wall"); } else { sprite("a").box(0, 0, 1, 1, 0, 1, "wall"); }"#);
let mut c = Map::new();
c.insert("big".into(), json!(true));
let b = r.build("site", 0, &c, &Doc::default()).unwrap();
assert_eq!(b.scene.layers[2].sprites[0].world_box().unwrap()[5], 9.0);
}
+224
View File
@@ -0,0 +1,224 @@
// isokit/building: buildings with storeys, windows, roofs and cut-open
// levels, plus carports and sheds.
//
// Levels: "gf", "1f", "2f", ... cut a building open at that storey (lower
// storeys closed, the storey's floor shown, walls facing the viewer cut low);
// any other level (e.g. "roof", "site") shows it closed. A level above the
// building's top storey shows it ghosted.
fn wall_materials() { ["render", "render2", "wall", "wall2", "office", "yardb", "plinth", "wallp", "concb", "firewall"] }
fn roof_materials() { ["roof", "roof2", "roof3", "rooft", "roofcap", "canopy"] }
fn floor_materials() { ["z-floor", "z-living", "z-offices", "z-kitchen", "z-shop", "z-lab", "z-wh", "z-plant", "z-garage", "z-hall", "z-sport"] }
fn meta_building() {
#{ label: "Building", category: "Buildings", icon: "🏢",
help: "Storeys with windows, a door on the front (local -y) face, a flat or gable roof and optional solar. The GF / 1F levels cut it open.",
props: [
#{ key: "w", type: "float", def: 20.0, min: 2.0, max: 300.0, step: 0.5, label: "Width (m)" },
#{ key: "d", type: "float", def: 12.0, min: 2.0, max: 300.0, step: 0.5, label: "Depth (m)" },
#{ key: "floors", type: "int", def: 2, min: 1, max: 20, step: 1, label: "Storeys" },
#{ key: "floor_h", type: "float", def: 3.4, min: 2.4, max: 12.0, step: 0.1, label: "Storey height (m)" },
#{ key: "plinth", type: "float", def: 0.4, min: 0.0, max: 1.5, step: 0.05, label: "Plinth (m)" },
#{ key: "mat", type: "material", def: "render", choices: wall_materials(), label: "Walls" },
#{ key: "roof", type: "choice", def: "flat", choices: ["flat", "gable"], label: "Roof" },
#{ key: "roof_mat", type: "material", def: "roof2", choices: roof_materials(), label: "Roof colour" },
#{ key: "rise", type: "float", def: 2.2, min: 0.5, max: 8.0, step: 0.1, label: "Gable rise (m)" },
#{ key: "windows", type: "bool", def: true, label: "Windows" },
#{ key: "lit", type: "bool", def: true, label: "Lit windows" },
#{ key: "door", type: "bool", def: true, label: "Front door" },
#{ key: "pv", type: "bool", def: false, label: "Solar panels" },
#{ key: "ac", type: "bool", def: false, label: "Roof AC units (flat roof)" },
#{ key: "floor_mat", type: "material", def: "z-floor", choices: floor_materials(), label: "Floor (cut-open levels)" },
] }
}
fn prefab_building(o) {
let p = o.p;
let w = p.w / 2.0;
let d = p.d / 2.0;
let n = p.floors;
let H = p.floor_h;
let z0 = p.plinth;
let k = floor_of(o.level);
if k < 0 || k >= n {
let s = if k >= n { sprite(o.id, "dim") } else { sprite() };
s.box(-w, -d, w, d, 0.0, z0, "plinth");
s.box(-w, -d, w, d, z0, z0 + n * H, p.mat, #{ top: p.roof_mat });
building_windows(s, p, 0, n);
if p.roof == "gable" { gable_roof(s, p, z0 + n * H); } else { flat_roof_kit(s, p, z0 + n * H); }
return;
}
// cut open at storey k: lower storeys closed, the floor of storey k, cut walls
let zf = z0 + k * H;
if k == 0 { layer("floor"); }
let f = sprite();
f.box(-w, -d, w, d, 0.0, z0, "plinth");
if k > 0 {
f.box(-w, -d, w, d, z0, zf, p.mat, #{ top: p.floor_mat });
building_windows(f, p, 0, k);
}
f.flat(rect(-w, -d, w, d), zf + 0.005, p.floor_mat + " t");
layer("main");
// walls start just above the floor so the depth sort draws them over it
cut_walls(-w, -d, w, d, zf + 0.02, 0.9, fmin(H - 0.4, 2.6), 0.25, p.mat, []);
}
/// Windows (and the front door) on all four faces for storeys from..to.
fn building_windows(s, p, from, to) {
let w = p.w / 2.0;
let d = p.d / 2.0;
let sd = sides(-w, -d, w, d);
for f in ["S", "E", "N", "W"] {
let e = sd[f];
let L = if f == "S" || f == "N" { p.w } else { p.d };
let dark = [];
let lit = [];
if p.windows {
for k in from..to {
let z = p.plinth + k * p.floor_h;
let gap = if f == "S" && k == 0 && p.door { L / 2.0 } else { -1.0 };
let i = 0;
for q in win_list(L, z, 0.9, fmin(1.5, p.floor_h - 1.3), 1.6, 3.4, gap) {
if p.lit && hash(i * 7.3 + k * 3.1 + L + f.len()) > 0.55 { lit.push(q); } else { dark.push(q); }
i += 1;
}
}
}
if dark.len() > 0 { s.open(e[0], e[1], dark, "win"); }
if lit.len() > 0 { s.open(e[0], e[1], lit, "win lit"); }
if f == "S" && p.door && from == 0 {
s.open(e[0], e[1], [[L / 2.0 - 0.9, L / 2.0 + 0.9, p.plinth, p.plinth + 2.3]], "door");
}
}
}
/// Gable roof with the ridge along local x, eaves on the -y and +y sides;
/// solar modules on the -y slope.
fn gable_roof(s, p, ze) {
let a = p.w / 2.0;
let w = a + 0.4;
let d = p.d / 2.0;
let e = 0.6;
let r = p.rise;
let k = r / d;
let za = ze - e * k;
let zr = ze + r;
// gable ends first, then the two roof slabs over them
s.face([[a, -d, ze], [a, d, ze], [a, 0.0, zr - 0.05]], p.mat);
s.face([[-a, d, ze], [-a, -d, ze], [-a, 0.0, zr - 0.05]], p.mat);
s.prism(rect(-w, -d - e, w, 0.0), [za - 0.2, za - 0.2, zr - 0.2, zr - 0.2], [za, za, zr, zr], p.roof_mat, #{ side: "fascia" });
s.prism(rect(-w, 0.0, w, d + e), [zr - 0.2, zr - 0.2, za - 0.2, za - 0.2], [zr, zr, za, za], p.roof_mat, #{ side: "fascia" });
if p.pv {
let cw = 1.13;
let rh = 1.7;
let cols = ((p.w - 1.0) / cw).floor().to_int();
let rows = ((d - 0.4) / rh).floor().to_int();
let u0 = -cols * cw / 2.0;
let mods = [];
for i in 0..rows {
for j in 0..cols {
let va = -(0.3 + i * rh + 0.03);
let vb = -(0.3 + (i + 1) * rh - 0.03);
let ua = u0 + j * cw + 0.03;
let ub = u0 + (j + 1) * cw - 0.03;
let zA = zr + va * k + 0.08;
let zB = zr + vb * k + 0.08;
mods.push([[ua, va, zA], [ub, va, zA], [ub, vb, zB], [ua, vb, zB]]);
}
}
if mods.len() > 0 { s.poly(mods, "pv"); }
}
}
/// Parapet, solar rows and AC units on a flat roof at height zt, drawn back
/// to front.
fn flat_roof_kit(s, p, zt) {
let w = p.w / 2.0;
let d = p.d / 2.0;
let t = 0.2;
let ph = 0.5;
let mat = p.mat;
let items = [
[0.0, d, |q| q.box(-w, d - t, w, d, zt, zt + ph, mat, #{ top: "cut" })],
[-w, 0.0, |q| q.box(-w, -d + t, -w + t, d - t, zt, zt + ph, mat, #{ top: "cut" })],
[w, 0.0, |q| q.box(w - t, -d + t, w, d - t, zt, zt + ph, mat, #{ top: "cut" })],
[0.0, -d, |q| q.box(-w, -d, w, -d + t, zt, zt + ph, mat, #{ top: "cut" })],
];
if p.pv && p.w > 4.0 && p.d > 4.0 {
let y = -d + 1.2;
while y + 1.0 < d - 1.2 {
let y0 = y;
let x0 = -w + 1.0;
let x1 = w - 1.0;
items.push([0.0, y0 + 0.5, |q| q.poly([[[x0, y0, zt + 0.35], [x1, y0, zt + 0.35], [x1, y0 + 1.0, zt + 0.85], [x0, y0 + 1.0, zt + 0.85]]], "pv")]);
y += 2.2;
}
}
if p.ac && p.w > 6.0 && p.d > 4.0 {
for i in 0..2 {
let x = w - 2.2 - i * 2.0;
let y = d - 1.4;
items.push([x, y, |q| { q.box(x - 0.7, y - 0.5, x + 0.7, y + 0.5, zt, zt + 0.9, "ac"); q.fan(x, y, zt + 0.9, 0.38); }]);
}
}
s.seq(items);
}
fn meta_carport() {
#{ label: "Carport", category: "Buildings", icon: "⌂",
help: "A canopy on posts with cars parked under it.",
props: [
#{ key: "w", type: "float", def: 6.0, min: 2.5, max: 60.0, step: 0.5, label: "Width (m)" },
#{ key: "d", type: "float", def: 5.5, min: 2.5, max: 12.0, step: 0.5, label: "Depth (m)" },
#{ key: "h", type: "float", def: 2.6, min: 2.2, max: 5.0, step: 0.1, label: "Height (m)" },
#{ key: "cars", type: "int", def: 2, min: 0, max: 20, step: 1, label: "Cars" },
#{ key: "pv", type: "bool", def: false, label: "Solar on the canopy" },
] }
}
fn prefab_carport(o) {
let p = o.p;
let w = p.w / 2.0;
let d = p.d / 2.0;
let h = p.h;
let s = sprite();
// cars and posts back to front inside the canopy's sprite, then the roof over all of them
let items = [];
for c in [[-w + 0.1, -d + 0.1], [w - 0.1, -d + 0.1], [w - 0.1, d - 0.1], [-w + 0.1, d - 0.1]] {
let x = c[0];
let y = c[1];
items.push([x, y, |q| q.box(x - 0.08, y - 0.08, x + 0.08, y + 0.08, 0.0, h, "steel")]);
}
let cols = ["car1", "car2", "car3", "car4"];
let n = p.cars;
for i in 0..n {
let x = -w + (i + 0.5) * p.w / n;
let col = cols[(i + (hash(o.x + o.y) * 4.0).floor().to_int()) % 4];
items.push([x, 0.0, |q| q.car(x - 0.9, -2.2, x + 0.9, 2.2, col)]);
}
s.seq(items);
s.box(-w, -d, w, d, h, h + 0.15, "canopy");
if p.pv {
s.poly([[[-w + 0.2, -d + 0.2, h + 0.2], [w - 0.2, -d + 0.2, h + 0.2], [w - 0.2, d - 0.2, h + 0.2], [-w + 0.2, d - 0.2, h + 0.2]]], "pv");
}
}
fn meta_shed() {
#{ label: "Shed / kiosk", category: "Buildings", icon: "▣",
props: [
#{ key: "w", type: "float", def: 4.0, min: 1.0, max: 30.0, step: 0.5, label: "Width (m)" },
#{ key: "d", type: "float", def: 3.0, min: 1.0, max: 30.0, step: 0.5, label: "Depth (m)" },
#{ key: "h", type: "float", def: 2.8, min: 1.8, max: 8.0, step: 0.1, label: "Height (m)" },
#{ key: "mat", type: "material", def: "render2", choices: wall_materials(), label: "Walls" },
#{ key: "roof_mat", type: "material", def: "roof3", choices: roof_materials(), label: "Roof" },
] }
}
fn prefab_shed(o) {
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.0, 0.15, "plinth");
s.box(-w, -d, w, d, 0.15, p.h, p.mat, #{ top: p.roof_mat });
let sd = sides(-w, -d, w, d);
s.open(sd.S[0], sd.S[1], [[p.w / 2.0 - 0.5, p.w / 2.0 + 0.5, 0.15, 2.2]], "door2");
}
@@ -0,0 +1,91 @@
// isokit/equipment: tanks, containers, generators, ground-mounted solar,
// chimneys.
fn meta_tank() {
#{ label: "Tank", category: "Equipment", icon: "◯",
props: [
#{ key: "r", type: "float", def: 2.0, min: 0.3, max: 30.0, step: 0.1, label: "Radius (m)" },
#{ key: "h", type: "float", def: 5.0, min: 0.5, max: 60.0, step: 0.1, label: "Height (m)" },
#{ key: "mat", type: "material", def: "tank", choices: ["tank", "water", "firetank", "steel", "silok", "siloy", "silor", "chiller"], label: "Colour" },
#{ key: "cap", type: "bool", def: true, label: "Cone roof" },
] }
}
fn prefab_tank(o) {
let p = o.p;
let s = sprite();
s.cyl(0.0, 0.0, p.r, 0.0, p.h, p.mat);
s.disc(0.0, 0.0, p.h * 0.6, p.r + 0.01, "tankring");
if p.cap { s.frust(0.0, 0.0, p.r, 0.25, p.h, p.h + p.r * 0.3, p.mat, 14); }
}
fn meta_container() {
#{ label: "Shipping container", category: "Equipment", icon: "▭",
props: [
#{ key: "size", type: "choice", def: "20ft", choices: ["10ft", "20ft", "40ft"], label: "Size" },
#{ key: "colour", type: "choice", def: "rack", choices: ["rack", "truckbox", "classic", "steel", "firewall"], label: "Colour" },
#{ key: "stack", type: "int", def: 1, min: 1, max: 4, step: 1, label: "Stacked" },
] }
}
fn prefab_container(o) {
let L = if o.p.size == "10ft" { 2.99 } else if o.p.size == "40ft" { 12.19 } else { 6.06 };
let s = sprite();
for i in 0..o.p.stack {
s.box(-L / 2.0, -1.22, L / 2.0, 1.22, i * 2.59, (i + 1) * 2.59 - 0.02, o.p.colour);
}
}
fn meta_generator() {
#{ label: "Generator", category: "Equipment", icon: "⚡",
props: [
#{ key: "running", type: "bool", def: false, label: "Running (exhaust)" },
] }
}
fn prefab_generator(o) {
let s = sprite();
s.box(-2.4, -1.1, 2.4, 1.1, 0.0, 0.25, "plinth");
s.box(-2.2, -0.95, 2.2, 0.95, 0.25, 2.4, "genset");
s.cyl(1.6, 0.4, 0.12, 2.4, 3.2, "stack");
if o.p.running { s.puffs(1.6, 0.4, 3.3, "smoke", 3, 3.2); }
}
fn meta_pv_field() {
#{ label: "Solar field", category: "Equipment", icon: "▤",
help: "Ground-mounted solar rows facing local -y.",
props: [
#{ key: "w", type: "float", def: 20.0, min: 2.0, max: 500.0, step: 0.5, label: "Row length (m)" },
#{ key: "rows", type: "int", def: 4, min: 1, max: 60, step: 1, label: "Rows" },
#{ key: "pitch", type: "float", def: 4.0, min: 2.0, max: 12.0, step: 0.1, label: "Row spacing (m)" },
] }
}
fn prefab_pv_field(o) {
let p = o.p;
let w = p.w / 2.0;
let y0 = -(p.rows - 1) * p.pitch / 2.0;
for i in 0..p.rows {
// one sprite per row so trees and people sort between them
let y = y0 + i * p.pitch;
let s = sprite();
let n = (p.w / 4.0).ceiling().to_int();
for j in 0..(n + 1) {
let x = -w + j * p.w / n;
s.box(x - 0.05, y + 0.6, x + 0.05, y + 0.7, 0.0, 1.6, "steel");
}
s.poly([[[-w, y - 1.0, 0.7], [w, y - 1.0, 0.7], [w, y + 1.0, 1.9], [-w, y + 1.0, 1.9]]], "pv");
}
}
fn meta_chimney() {
#{ label: "Chimney / stack", category: "Equipment", icon: "▮",
props: [
#{ key: "h", type: "float", def: 12.0, min: 2.0, max: 120.0, step: 0.5, label: "Height (m)" },
#{ key: "r", type: "float", def: 0.5, min: 0.1, max: 5.0, step: 0.05, label: "Radius (m)" },
#{ key: "smoke", type: "choice", def: "puff", choices: ["none", "puff", "puff2", "smoke"], label: "Plume" },
] }
}
fn prefab_chimney(o) {
let p = o.p;
let s = sprite();
s.cyl(0.0, 0.0, p.r, 0.0, p.h, "stack");
s.cyl(0.0, 0.0, p.r * 1.05, p.h - 1.0, p.h - 0.6, "stackband", #{ no_top: true });
if p.smoke != "none" { s.puffs(0.0, 0.0, p.h + 0.3, p.smoke, 4, 4.0); }
}
+154
View File
@@ -0,0 +1,154 @@
// isokit/ground: site plate, paved and green areas, roads, parking, walls,
// fences and lamps. Ground surfaces go to the unsorted "ground" layer in
// document order (later objects lie on top).
fn ground_materials() {
["g-grass", "g-grass2", "g-green", "g-garden", "g-asph", "g-conc", "g-pad", "g-path", "g-sand", "g-court", "dirt"]
}
fn meta_site() {
#{ label: "Site ground", category: "Ground", icon: "▭",
help: "The ground plate: a surface with earth sides. Frames the view.",
props: [
#{ key: "w", type: "float", def: 60.0, min: 4.0, max: 1000.0, step: 1.0, label: "Width (m)" },
#{ key: "d", type: "float", def: 40.0, min: 4.0, max: 1000.0, step: 1.0, label: "Depth (m)" },
#{ key: "mat", type: "material", def: "g-grass", choices: ground_materials(), label: "Surface" },
#{ key: "base", type: "float", def: 1.6, min: 0.2, max: 10.0, step: 0.1, label: "Earth depth (m)" },
] }
}
fn prefab_site(o) {
let p = o.p;
let w = p.w / 2.0;
let d = p.d / 2.0;
layer("ground");
sprite().box(-w, -d, w, d, -p.base, 0.0, "dirt", #{ top: p.mat });
bounds(-w, -d, w, d, -p.base);
}
fn meta_area() {
#{ label: "Area", category: "Ground", icon: "▱",
help: "A paved or planted area lying on the ground.",
props: [
#{ key: "w", type: "float", def: 10.0, min: 0.5, max: 1000.0, step: 0.5, label: "Width (m)" },
#{ key: "d", type: "float", def: 6.0, min: 0.5, max: 1000.0, step: 0.5, label: "Depth (m)" },
#{ key: "mat", type: "material", def: "g-conc", choices: ground_materials(), label: "Surface" },
] }
}
fn prefab_area(o) {
let p = o.p;
layer("ground");
let s = sprite();
if o.pts != () && o.pts.len() >= 3 {
s.flat(o.pts, 0.006, p.mat + " t");
} else {
s.flat(rect(-p.w / 2.0, -p.d / 2.0, p.w / 2.0, p.d / 2.0), 0.006, p.mat + " t");
}
}
fn meta_road() {
#{ label: "Road", category: "Ground", icon: "═",
help: "A straight road along its length, with a dashed centre line.",
props: [
#{ key: "w", type: "float", def: 40.0, min: 2.0, max: 2000.0, step: 1.0, label: "Length (m)" },
#{ key: "d", type: "float", def: 6.0, min: 2.0, max: 40.0, step: 0.5, label: "Width (m)" },
#{ key: "dashed", type: "bool", def: true, label: "Centre line" },
#{ key: "kerb", type: "bool", def: true, label: "Kerb lines" },
] }
}
fn prefab_road(o) {
let p = o.p;
let w = p.w / 2.0;
let d = p.d / 2.0;
layer("ground");
let s = sprite();
s.flat(rect(-w, -d, w, d), 0.007, "g-asph t");
if p.dashed { s.lines([[-w, 0.0, w, 0.0]], 0.012, "dash"); }
if p.kerb { s.lines([[-w, -d, w, -d], [-w, d, w, d]], 0.012, "kerb"); }
}
fn meta_parking() {
#{ label: "Parking bays", category: "Ground", icon: "P",
props: [
#{ key: "n", type: "int", def: 6, min: 1, max: 60, step: 1, label: "Bays" },
#{ key: "bay_w", type: "float", def: 2.5, min: 2.0, max: 4.0, step: 0.1, label: "Bay width (m)" },
#{ key: "bay_d", type: "float", def: 5.0, min: 4.0, max: 7.0, step: 0.1, label: "Bay depth (m)" },
] }
}
fn prefab_parking(o) {
let p = o.p;
let w = p.n * p.bay_w / 2.0;
let d = p.bay_d / 2.0;
layer("ground");
let s = sprite();
s.flat(rect(-w, -d, w, d), 0.008, "g-pad t");
let segs = [];
for i in 0..(p.n + 1) {
let x = -w + i * p.bay_w;
segs.push([x, -d, x, d]);
}
s.lines(segs, 0.014, "bayline");
}
fn meta_wall() {
#{ label: "Wall", category: "Site", icon: "▬",
props: [
#{ key: "w", type: "float", def: 20.0, min: 0.5, max: 500.0, step: 0.5, label: "Length (m)" },
#{ key: "h", type: "float", def: 2.2, min: 0.3, max: 8.0, step: 0.1, label: "Height (m)" },
#{ key: "t", type: "float", def: 0.24, min: 0.1, max: 1.0, step: 0.02, label: "Thickness (m)" },
] }
}
fn prefab_wall(o) {
// long walls in pieces of at most 30 m, each its own sprite for the depth sort
let p = o.p;
let n = (p.w / 30.0).ceiling().to_int();
let L = p.w / n;
for i in 0..n {
let x0 = -p.w / 2.0 + i * L;
sprite().box(x0, -p.t / 2.0, x0 + L, p.t / 2.0, 0.0, p.h, "wallp", #{ top: "wallt" });
}
}
fn meta_fence() {
#{ label: "Fence", category: "Site", icon: "┼",
props: [
#{ key: "w", type: "float", def: 20.0, min: 1.0, max: 500.0, step: 0.5, label: "Length (m)" },
#{ key: "h", type: "float", def: 1.8, min: 0.5, max: 4.0, step: 0.1, label: "Height (m)" },
] }
}
fn prefab_fence(o) {
let p = o.p;
let n = (p.w / 30.0).ceiling().to_int();
let L = p.w / n;
for i in 0..n {
let x0 = -p.w / 2.0 + i * L;
let s = sprite();
s.box(x0, -0.03, x0 + L, 0.03, 0.0, p.h, "fence");
let k = (L / 2.5).floor().to_int();
for j in 0..(k + 1) {
let x = x0 + j * L / fmax(k, 1);
s.box(x - 0.04, -0.04, x + 0.04, 0.04, 0.0, p.h + 0.05, "steel");
}
}
}
fn meta_lamp() {
#{ label: "Street lamp", category: "Site", icon: "†",
help: "Keep lamps on verges and kerbs, not on drive areas.",
props: [ #{ key: "h", type: "float", def: 5.0, min: 2.0, max: 12.0, step: 0.5, label: "Height (m)" } ] }
}
fn prefab_lamp(o) {
let s = sprite();
s.box(-0.07, -0.07, 0.07, 0.07, 0.0, o.p.h, "lampost", #{ no_top: true });
s.circle(0.0, 0.0, o.p.h + 0.1, 1.2, "lamp");
}
fn meta_marker() {
#{ label: "Marker", category: "Site", icon: "⚑",
help: "A pin with a label: set the label in Tag and the tooltip in Info.",
props: [ #{ key: "h", type: "float", def: 1.5, min: 0.3, max: 20.0, step: 0.1, label: "Height (m)" } ] }
}
fn prefab_marker(o) {
let s = sprite();
s.box(-0.05, -0.05, 0.05, 0.05, 0.0, o.p.h, "steel", #{ no_top: true });
s.circle(0.0, 0.0, o.p.h, 2.0, "led ledo");
}
+51
View File
@@ -0,0 +1,51 @@
// isokit/nature: trees, hedges, shrubs.
/// A tree with a round crown of radius r (m) standing at (x, y) of this sprite.
fn tree_at(x, y, r) {
let h = 1.4 + r * 0.6;
this.ext_box(x - r, y - r, x + r, y + r, 0.0, h + r * 1.8);
this.stem(x, y, 0.0, h, 0.7, "trunk");
let R = r * px() * 1.18;
this.circle(x, y, h + r * 0.9, R, "leaf l");
this.circle(x, y, h + r * 0.9, R * 0.58, "leaf t", -R * 0.28, -R * 0.3);
this
}
fn meta_tree() {
#{ label: "Tree", category: "Nature", icon: "🌳",
props: [ #{ key: "r", type: "float", def: 1.6, min: 0.5, max: 6.0, step: 0.1, label: "Crown radius (m)" } ] }
}
fn prefab_tree(o) {
sprite().tree_at(0.0, 0.0, o.p.r);
}
fn meta_tree_row() {
#{ label: "Row of trees", category: "Nature", icon: "🌲",
props: [
#{ key: "w", type: "float", def: 30.0, min: 2.0, max: 500.0, step: 1.0, label: "Length (m)" },
#{ key: "spacing", type: "float", def: 6.0, min: 2.0, max: 30.0, step: 0.5, label: "Spacing (m)" },
#{ key: "r", type: "float", def: 1.5, min: 0.5, max: 6.0, step: 0.1, label: "Crown radius (m)" },
] }
}
fn prefab_tree_row(o) {
let p = o.p;
let n = (p.w / p.spacing).floor().to_int() + 1;
let step = if n > 1 { p.w / (n - 1) } else { 0.0 };
for i in 0..n {
// one sprite per tree; small deterministic size variation
sprite().tree_at(-p.w / 2.0 + i * step, 0.0, p.r * (0.85 + 0.3 * hash(i + p.w)));
}
}
fn meta_hedge() {
#{ label: "Hedge", category: "Nature", icon: "▬",
props: [
#{ key: "w", type: "float", def: 10.0, min: 0.5, max: 300.0, step: 0.5, label: "Length (m)" },
#{ key: "d", type: "float", def: 0.6, min: 0.3, max: 3.0, step: 0.1, label: "Thickness (m)" },
#{ key: "h", type: "float", def: 1.4, min: 0.3, max: 4.0, step: 0.1, label: "Height (m)" },
] }
}
fn prefab_hedge(o) {
let p = o.p;
sprite().box(-p.w / 2.0, -p.d / 2.0, p.w / 2.0, p.d / 2.0, 0.0, p.h, "hedge");
}
+32
View File
@@ -0,0 +1,32 @@
// isokit/people: standing and seated figures, small groups.
fn people_colours() { ["hivis", "guest1", "guest2", "guest3", "guest4", "guide", "suit"] }
fn meta_person() {
#{ label: "Person", category: "People", icon: "🧍",
props: [
#{ key: "cls", type: "choice", def: "hivis", choices: people_colours(), label: "Clothes" },
#{ key: "seated", type: "bool", def: false, label: "Seated" },
] }
}
fn prefab_person(o) {
let s = sprite();
if o.p.seated { s.sit(0.0, 0.0, 0.0, o.p.cls); } else { s.person(0.0, 0.0, 0.0, o.p.cls); }
}
fn meta_crowd() {
#{ label: "Group of people", category: "People", icon: "👥",
props: [
#{ key: "n", type: "int", def: 5, min: 2, max: 40, step: 1, label: "People" },
#{ key: "r", type: "float", def: 2.0, min: 0.6, max: 15.0, step: 0.1, label: "Spread (m)" },
] }
}
fn prefab_crowd(o) {
let cols = ["guest1", "guest2", "guest3", "guest4", "hivis"];
for i in 0..o.p.n {
// one sprite each, scattered deterministically in a disc
let a = hash(i * 1.7 + o.x) * 6.2832;
let r = o.p.r * hash(i * 3.3 + o.y).sqrt();
sprite().person(r * a.cos(), r * a.sin(), 0.0, cols[i % 5]);
}
}
+59
View File
@@ -0,0 +1,59 @@
// isokit/util: helpers shared by the prefabs.
/// Floor index of a level id: "gf" -> 0, "1f" -> 1, "2f" -> 2; anything else
/// (roof, site, "") -> -1, which means "closed buildings".
fn floor_of(lv) {
if lv == "gf" { return 0; }
if lv.len() >= 2 && lv.ends_with("f") {
let k = lv.sub_string(0, lv.len() - 1);
let digits = true;
for c in k.chars() { if !"0123456789".contains(c) { digits = false; } }
if digits { return parse_int(k); }
}
-1
}
/// Walls of a cut-open room over the rectangle [x0, y0, x1, y1] from height z:
/// walls facing the viewer are cut low (lo), the others stay high (hi), so
/// the room can be seen into from every rotation. Each wall is its own sprite
/// for the depth sort. skip: list of walls to leave out (0 S, 1 N, 2 W, 3 E).
fn cut_walls(x0, y0, x1, y1, z, lo, hi, t, mat, skip) {
let W = [
[x0, y0, x1, y0 + t, 0, -1],
[x0, y1 - t, x1, y1, 0, 1],
[x0, y0 + t, x0 + t, y1 - t, -1, 0],
[x1 - t, y0 + t, x1, y1 - t, 1, 0],
];
for i in 0..4 {
if skip.contains(i) { continue; }
let w = W[i];
let h = if vis(w[4], w[5]) { lo } else { hi };
sprite().box(w[0], w[1], w[2], w[3], z, z + h, mat, #{ top: "cut" });
}
}
/// Windows spread along the face a -> b (length L) for one storey:
/// returns [[u0, u1, z0, z1], ...]; leaves a gap around u = gap (or -1).
fn win_list(L, z0, sill, height, width, spacing, gap) {
let out = [];
let n = ((L - 1.0) / spacing).floor().to_int();
if n < 1 { return out; }
let start = (L - (n - 1) * spacing) / 2.0;
for i in 0..n {
let u = start + i * spacing;
if gap >= 0.0 && (u - gap).abs() < width + 0.6 { continue; }
out.push([u - width / 2.0, u + width / 2.0, z0 + sill, z0 + sill + height]);
}
out
}
/// Animate the next drawing of sprite s along (dx, dy) metres of its frame:
/// call before drawing, then s.end(). Only animates when the motion runs
/// along the axis the depth sort compares second; returns whether it does.
fn drive(dx, dy, dur, delay) {
let ok = if dx != 0.0 { along_x() } else { !along_x() };
if !ok { this.begin(""); return false; }
let v = dv(dx, dy, 0.0);
this.begin("a drive", `--dx:${v[0]}px;--dy:${v[1]}px;animation-duration:${dur}s;animation-delay:-${delay}s`);
true
}
+67
View File
@@ -0,0 +1,67 @@
// isokit/vehicles: cars, trucks, motorbikes; optionally driving.
fn car_colours() { ["car1", "car2", "car3", "car4", "car5"] }
/// A car filling the plan rectangle; the long side is the driving axis.
fn car(x0, y0, x1, y1, cls) {
let ax = x1 - x0 >= y1 - y0;
let i = 0.9;
this.box(x0, y0, x1, y1, 0.25, 1.0, cls);
if ax {
this.box(x0 + i, y0 + 0.12, x1 - i, y1 - 0.12, 1.0, 1.5, "glassd");
} else {
this.box(x0 + 0.12, y0 + i, x1 - 0.12, y1 - i, 1.0, 1.5, "glassd");
}
this
}
fn meta_car() {
#{ label: "Car", category: "Vehicles", icon: "🚗",
help: "Faces local +x. A driving car moves along its length (in rotations where the depth sort allows it).",
props: [
#{ key: "colour", type: "choice", def: "car2", choices: car_colours(), label: "Colour" },
#{ key: "moving", type: "bool", def: false, label: "Driving" },
#{ key: "travel", type: "float", def: 25.0, min: 2.0, max: 300.0, step: 1.0, label: "Travel (m)" },
#{ key: "dur", type: "float", def: 11.0, min: 2.0, max: 120.0, step: 0.5, label: "Loop time (s)" },
] }
}
fn prefab_car(o) {
let p = o.p;
let s = sprite();
if p.moving { s.drive(p.travel, 0.0, p.dur, hash(o.x + o.y) * p.dur); }
s.car(-2.2, -0.9, 2.2, 0.9, p.colour);
if p.moving { s.end(); }
}
fn meta_truck() {
#{ label: "Truck", category: "Vehicles", icon: "🚚",
props: [
#{ key: "len", type: "float", def: 8.0, min: 5.0, max: 16.5, step: 0.5, label: "Length (m)" },
#{ key: "box", type: "choice", def: "truckbox", choices: ["truckbox", "cab", "carton", "steel"], label: "Body" },
#{ key: "moving", type: "bool", def: false, label: "Driving" },
#{ key: "travel", type: "float", def: 40.0, min: 2.0, max: 300.0, step: 1.0, label: "Travel (m)" },
#{ key: "dur", type: "float", def: 18.0, min: 2.0, max: 120.0, step: 0.5, label: "Loop time (s)" },
] }
}
fn prefab_truck(o) {
let p = o.p;
let L = p.len / 2.0;
let s = sprite();
if p.moving { s.drive(p.travel, 0.0, p.dur, hash(o.x * 3.0 + o.y) * p.dur); }
s.box(-L, -1.2, L, 1.2, 0.35, 0.95, "chassis");
s.seq([
[-0.9, 0.0, |q| q.box(-L, -1.25, L - 2.2, 1.25, 0.95, 3.6, p.box)],
[L - 1.1, 0.0, |q| { q.box(L - 2.1, -1.2, L, 1.2, 0.95, 2.9, "cab"); q.box(L - 0.12, -1.0, L, 1.0, 1.9, 2.6, "glassd"); }],
]);
if p.moving { s.end(); }
}
fn meta_moto() {
#{ label: "Motorbike", category: "Vehicles", icon: "🛵",
props: [ #{ key: "colour", type: "choice", def: "car1", choices: car_colours(), label: "Colour" } ] }
}
fn prefab_moto(o) {
let s = sprite();
s.box(-0.95, -0.3, 0.95, 0.3, 0.3, 0.85, o.p.colour);
s.person(0.0, 0.0, 0.55);
}