isokit: engine core, theme and Rhai scene runtime with prefab library
This commit is contained in:
commit
95c80ec6b1
32 files changed
+5896
No files matched your search
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "isokit-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "isokit"
|
||||
path = "src/main.rs"
|
||||
@@ -0,0 +1 @@
|
||||
fn main() {}
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "isokit-core"
|
||||
description = "Isometric scene engine: projection, primitives, sprites, depth sort, SVG output"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Number and text formatting shared by the SVG writer and the scripts.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
/// Append `v` rounded to 0.1 the way the JS views did (`Math.round(v * 10) / 10`):
|
||||
/// halves round up, no trailing `.0`, never `-0`. Non-finite values print as
|
||||
/// `NaN` so the checks can find them in the output.
|
||||
pub fn r1(out: &mut String, v: f64) {
|
||||
if !v.is_finite() {
|
||||
out.push_str("NaN");
|
||||
return;
|
||||
}
|
||||
let t = (v * 10.0 + 0.5).floor() as i64;
|
||||
if t < 0 {
|
||||
out.push('-');
|
||||
}
|
||||
let a = t.unsigned_abs();
|
||||
let _ = write!(out, "{}", a / 10);
|
||||
if a % 10 != 0 {
|
||||
let _ = write!(out, ".{}", a % 10);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`r1`] into a new string.
|
||||
pub fn r1s(v: f64) -> String {
|
||||
let mut s = String::new();
|
||||
r1(&mut s, v);
|
||||
s
|
||||
}
|
||||
|
||||
/// Round to 0.1 as a number.
|
||||
pub fn round1(v: f64) -> f64 {
|
||||
(v * 10.0 + 0.5).floor() / 10.0
|
||||
}
|
||||
|
||||
/// Escape text for SVG/HTML content and attribute values.
|
||||
pub fn esc(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len());
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'&' => o.push_str("&"),
|
||||
'<' => o.push_str("<"),
|
||||
'>' => o.push_str(">"),
|
||||
'"' => o.push_str("""),
|
||||
c => o.push(c),
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// Deterministic pseudo-random number in [0, 1) from any number (the views'
|
||||
/// `hash(i)`): scenes use it to vary trees, cars and delays without changing
|
||||
/// from one build to the next.
|
||||
pub fn hash(i: f64) -> f64 {
|
||||
let x = (i * 12.9898 + 78.233).sin() * 43758.5453;
|
||||
x - x.floor()
|
||||
}
|
||||
|
||||
/// Whole number with thousands separators: 12345.6 -> "12,346".
|
||||
pub fn thousands(n: f64) -> String {
|
||||
if !n.is_finite() {
|
||||
return "NaN".into();
|
||||
}
|
||||
let v = (n + 0.5).floor() as i64;
|
||||
let s = v.unsigned_abs().to_string();
|
||||
let mut o = String::new();
|
||||
for (i, ch) in s.chars().enumerate() {
|
||||
if i > 0 && (s.len() - i) % 3 == 0 {
|
||||
o.push(',');
|
||||
}
|
||||
o.push(ch);
|
||||
}
|
||||
if v < 0 {
|
||||
format!("-{o}")
|
||||
} else {
|
||||
o
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rounding_matches_js() {
|
||||
assert_eq!(r1s(12.34), "12.3");
|
||||
assert_eq!(r1s(12.35), "12.4");
|
||||
assert_eq!(r1s(12.0), "12");
|
||||
assert_eq!(r1s(-0.04), "0");
|
||||
assert_eq!(r1s(-2.25), "-2.2"); // JS Math.round(-22.5) = -22
|
||||
assert_eq!(r1s(-7.0), "-7");
|
||||
assert_eq!(r1s(f64::NAN), "NaN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thousands_sep() {
|
||||
assert_eq!(thousands(1234567.4), "1,234,567");
|
||||
assert_eq!(thousands(999.0), "999");
|
||||
assert_eq!(thousands(-1500.0), "-1,500");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_is_stable() {
|
||||
let a = hash(3.0);
|
||||
assert!((0.0..1.0).contains(&a));
|
||||
assert_eq!(a, hash(3.0));
|
||||
assert_ne!(a, hash(4.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! isokit-core: the isometric engine.
|
||||
//!
|
||||
//! World coordinates are metres: x east, y north, z up. A [`Scene`] holds
|
||||
//! [`Sprite`]s in [`Layer`]s; each sprite is a list of world-space
|
||||
//! [`Prim`]itives plus the extent points used for the depth sort. [`render`]
|
||||
//! projects a scene for one of four 90 degree view rotations, culls and
|
||||
//! shades the faces, orders the sprites back to front and writes SVG.
|
||||
//!
|
||||
//! The engine is a port of the CNXIso / CNX Scuba / Mooban views (plain JS)
|
||||
//! and keeps their conventions: faces get the classes `iso-t` (top), `iso-l`,
|
||||
//! `iso-c`, `iso-r` (sides by the direction they face), materials are CSS
|
||||
//! classes that set `--b`, tags are counter-scaled by the viewer.
|
||||
//!
|
||||
//! Nothing here knows about scripts, files or the browser: the script layer
|
||||
//! (isokit-script) and the CLI build on this crate, and a Rust program can use
|
||||
//! it directly.
|
||||
|
||||
pub mod fmt;
|
||||
pub mod prim;
|
||||
pub mod proj;
|
||||
pub mod render;
|
||||
pub mod scene;
|
||||
pub mod sort;
|
||||
pub mod sprite;
|
||||
|
||||
pub use prim::{Prim, Style, Zs, P2, P3};
|
||||
pub use proj::{shade, Proj, C, CZ};
|
||||
pub use render::{render, RenderOpts, Rendered};
|
||||
pub use scene::{Info, Layer, LayerOpts, Scene, Tag};
|
||||
pub use sprite::{Sprite, Xf};
|
||||
|
||||
/// Prefix every space-separated class token with `iso-` unless it already
|
||||
/// has it. Scripts write `"wall"` or `"z-living t"`; the SVG gets
|
||||
/// `iso-wall` / `iso-z-living iso-t`. A token starting with `!` is kept
|
||||
/// verbatim without the `!` (for host-page classes).
|
||||
pub fn classes(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 8);
|
||||
for tok in s.split_whitespace() {
|
||||
if !out.is_empty() {
|
||||
out.push(' ');
|
||||
}
|
||||
if let Some(raw) = tok.strip_prefix('!') {
|
||||
out.push_str(raw);
|
||||
} else if tok.starts_with("iso-") {
|
||||
out.push_str(tok);
|
||||
} else {
|
||||
out.push_str("iso-");
|
||||
out.push_str(tok);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::classes;
|
||||
|
||||
#[test]
|
||||
fn class_prefixing() {
|
||||
assert_eq!(classes("wall"), "iso-wall");
|
||||
assert_eq!(classes("z-living t"), "iso-z-living iso-t");
|
||||
assert_eq!(classes("iso-roof !host-x"), "iso-roof host-x");
|
||||
assert_eq!(classes(""), "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! World-space primitives. A sprite is a list of these; they are projected,
|
||||
//! culled and shaded only when the scene is rendered for a rotation.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub type P2 = [f64; 2];
|
||||
pub type P3 = [f64; 3];
|
||||
|
||||
/// Height of a prism's bottom or top: one value, or one per footprint vertex
|
||||
/// (sloped roofs, stair flights).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Zs {
|
||||
One(f64),
|
||||
Each(Vec<f64>),
|
||||
}
|
||||
|
||||
impl Zs {
|
||||
#[inline]
|
||||
pub fn at(&self, i: usize) -> f64 {
|
||||
match self {
|
||||
Zs::One(z) => *z,
|
||||
Zs::Each(v) => v.get(i).copied().unwrap_or(f64::NAN),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f64> for Zs {
|
||||
fn from(z: f64) -> Self {
|
||||
Zs::One(z)
|
||||
}
|
||||
}
|
||||
|
||||
/// Classes and switches of a solid. `mat` is the material class for every
|
||||
/// face; `side` / `top` override it for the side faces / the top.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Style {
|
||||
pub mat: String,
|
||||
pub side: Option<String>,
|
||||
pub top: Option<String>,
|
||||
pub no_side: bool,
|
||||
pub no_top: bool,
|
||||
/// Pick id on the faces themselves (otherwise the sprite's id applies).
|
||||
pub id: Option<String>,
|
||||
}
|
||||
|
||||
impl Style {
|
||||
pub fn mat(mat: impl Into<String>) -> Self {
|
||||
Style { mat: mat.into(), ..Default::default() }
|
||||
}
|
||||
pub fn id(mut self, id: impl Into<String>) -> Self {
|
||||
self.id = Some(id.into());
|
||||
self
|
||||
}
|
||||
pub fn top(mut self, top: impl Into<String>) -> Self {
|
||||
self.top = Some(top.into());
|
||||
self
|
||||
}
|
||||
pub fn side(mut self, side: impl Into<String>) -> Self {
|
||||
self.side = Some(side.into());
|
||||
self
|
||||
}
|
||||
pub fn no_top(mut self) -> Self {
|
||||
self.no_top = true;
|
||||
self
|
||||
}
|
||||
pub fn side_cls(&self) -> &str {
|
||||
self.side.as_deref().unwrap_or(&self.mat)
|
||||
}
|
||||
pub fn top_cls(&self) -> &str {
|
||||
self.top.as_deref().unwrap_or(&self.mat)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Prim {
|
||||
/// Vertical prism over a convex counter-clockwise footprint.
|
||||
Prism { pts: Vec<P2>, z0: Zs, z1: Zs, st: Style },
|
||||
/// Upright cylinder (two side faces and an elliptic top).
|
||||
Cyl { c: P2, r: f64, z0: f64, z1: f64, st: Style },
|
||||
/// Frustum (cone) with n sides: radius r0 at z0, r1 at z1.
|
||||
Frust { c: P2, r0: f64, r1: f64, z0: f64, z1: f64, n: u32, st: Style },
|
||||
/// Openings (windows, doors) on the vertical face through a -> b; the
|
||||
/// outward normal is on the right of a -> b. Each entry is
|
||||
/// [u0, u1, z0, z1]: distance along the face and heights.
|
||||
Open { a: P2, b: P2, list: Vec<[f64; 4]>, cls: String, id: Option<String> },
|
||||
/// Flat polygon(s) at height z; later polygons cut holes (even-odd).
|
||||
Flat { polys: Vec<Vec<P2>>, z: f64, cls: String, id: Option<String> },
|
||||
/// Line segments [x0, y0, x1, y1] at height z.
|
||||
Lines { segs: Vec<[f64; 4]>, z: f64, cls: String, id: Option<String> },
|
||||
/// Flat ellipse (a circle on the ground plane) at height z.
|
||||
Disc { c: P2, z: f64, r: f64, cls: String, id: Option<String> },
|
||||
/// Planar polygon in 3D (roof slopes, gables, ramps), shaded by its
|
||||
/// normal: mostly upward = top, else left/centre/right by direction.
|
||||
/// One-sided faces (counter-clockwise seen from outside) are culled
|
||||
/// when turned away; two-sided faces always draw.
|
||||
Face { pts: Vec<P3>, mat: String, id: Option<String>, two_sided: bool },
|
||||
/// 3D polygons merged into one path with a fixed class (PV modules,
|
||||
/// signs, markings on slopes): no shading, no culling.
|
||||
Poly { polys: Vec<Vec<P3>>, cls: String, id: Option<String> },
|
||||
/// Screen-space circle of radius r (screen units) at a world point,
|
||||
/// shifted by off (screen units): heads, lamps, LEDs, tree crowns.
|
||||
Circle { at: P3, r: f64, off: P2, cls: String, id: Option<String> },
|
||||
/// Screen-space vertical strip of half width w between two heights:
|
||||
/// trunks, thin poles.
|
||||
Stem { at: P2, z0: f64, z1: f64, w: f64, cls: String, id: Option<String> },
|
||||
/// Rising steam or smoke puffs (CSS animation).
|
||||
Puffs { at: P3, kind: String, n: u32, dur: f64 },
|
||||
/// Spinning fan lying in the plane at height at.z (CSS animation).
|
||||
Fan { at: P3, r: f64 },
|
||||
/// Raw SVG translated to a world point (or untranslated without one).
|
||||
/// `{uid}` in the text is replaced by the view's unique id.
|
||||
Raw { at: Option<P3>, svg: String },
|
||||
/// `<g class style>` around nested primitives (animation, ghosting).
|
||||
Group { cls: String, style: String, items: Vec<Prim> },
|
||||
/// Items drawn back to front by the depth of their plan anchor.
|
||||
Seq { items: Vec<(P2, Vec<Prim>)> },
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! 2:1 isometric projection with four 90 degree view rotations.
|
||||
//!
|
||||
//! View coordinates (vx, vy): the viewer sits at +vx +vy, so faces facing
|
||||
//! +vy show on the left (mid tone), faces facing +vx on the right (dark).
|
||||
|
||||
use crate::fmt::r1;
|
||||
|
||||
/// Screen units per metre along a ground axis.
|
||||
pub const C: f64 = 4.0;
|
||||
/// Screen units per metre of height.
|
||||
pub const CZ: f64 = 4.0;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Proj {
|
||||
/// 0: from the south-east, 1: south-west, 2: north-west, 3: north-east.
|
||||
pub rot: u8,
|
||||
}
|
||||
|
||||
impl Proj {
|
||||
pub fn new(rot: i64) -> Self {
|
||||
Proj { rot: rot.rem_euclid(4) as u8 }
|
||||
}
|
||||
|
||||
/// World plan point to view coordinates.
|
||||
#[inline]
|
||||
pub fn r(&self, x: f64, y: f64) -> [f64; 2] {
|
||||
match self.rot {
|
||||
0 => [x, -y],
|
||||
1 => [-y, -x],
|
||||
2 => [-x, y],
|
||||
_ => [y, x],
|
||||
}
|
||||
}
|
||||
|
||||
/// View coordinates back to the world plan.
|
||||
#[inline]
|
||||
pub fn r_inv(&self, vx: f64, vy: f64) -> [f64; 2] {
|
||||
match self.rot {
|
||||
0 => [vx, -vy],
|
||||
1 => [-vy, -vx],
|
||||
2 => [-vx, vy],
|
||||
_ => [vy, vx],
|
||||
}
|
||||
}
|
||||
|
||||
/// World point to screen.
|
||||
#[inline]
|
||||
pub fn s(&self, x: f64, y: f64, z: f64) -> [f64; 2] {
|
||||
let v = self.r(x, y);
|
||||
[(v[0] - v[1]) * C, (v[0] + v[1]) * C / 2.0 - z * CZ]
|
||||
}
|
||||
|
||||
/// Screen point to the world plan at height z.
|
||||
pub fn unproject(&self, sx: f64, sy: f64, z: f64) -> [f64; 2] {
|
||||
let a = sx / C; // vx - vy
|
||||
let b = (sy + z * CZ) * 2.0 / C; // vx + vy
|
||||
self.r_inv((a + b) / 2.0, (b - a) / 2.0)
|
||||
}
|
||||
|
||||
/// The view vector of a face normal if the face is turned towards the
|
||||
/// viewer, else None.
|
||||
#[inline]
|
||||
pub fn vis(&self, nx: f64, ny: f64) -> Option<[f64; 2]> {
|
||||
let v = self.r(nx, ny);
|
||||
if v[0] + v[1] > 1e-6 {
|
||||
Some(v)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Screen displacement of a world displacement (for CSS motion).
|
||||
pub fn dv(&self, dx: f64, dy: f64, dz: f64) -> [f64; 2] {
|
||||
let a = self.s(dx, dy, dz);
|
||||
let b = self.s(0.0, 0.0, 0.0);
|
||||
[a[0] - b[0], a[1] - b[1]]
|
||||
}
|
||||
|
||||
/// Distance towards the viewer of a plan point (larger = nearer).
|
||||
#[inline]
|
||||
pub fn depth(&self, x: f64, y: f64) -> f64 {
|
||||
let v = self.r(x, y);
|
||||
v[0] + v[1]
|
||||
}
|
||||
|
||||
/// True when world x maps onto view y in this rotation: a mover should
|
||||
/// then run along x (the axis the depth sort compares second).
|
||||
pub fn along_x(&self) -> bool {
|
||||
self.r(1.0, 0.0)[0] == 0.0
|
||||
}
|
||||
|
||||
/// Append an SVG path `M..L..Z` through the projected points.
|
||||
pub fn d(&self, out: &mut String, pts: &[[f64; 3]], open: bool) {
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
let s = self.s(p[0], p[1], p[2]);
|
||||
out.push(if i == 0 { 'M' } else { 'L' });
|
||||
r1(out, s[0]);
|
||||
out.push(' ');
|
||||
r1(out, s[1]);
|
||||
}
|
||||
if !open && !pts.is_empty() {
|
||||
out.push('Z');
|
||||
}
|
||||
}
|
||||
|
||||
/// Angle of north on screen in degrees (for the compass needle).
|
||||
pub fn north_deg(&self) -> f64 {
|
||||
let n = self.r(0.0, 1.0);
|
||||
((n[0] + n[1]) / 2.0).atan2(n[0] - n[1]).to_degrees() + 90.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Face class for a visible side face from its view-space normal.
|
||||
pub fn shade(v: [f64; 2]) -> &'static str {
|
||||
let a = v[1].atan2(v[0]).to_degrees();
|
||||
if a > 67.5 {
|
||||
"iso-l"
|
||||
} else if a < 22.5 {
|
||||
"iso-r"
|
||||
} else {
|
||||
"iso-c"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rotation_zero_matches_js() {
|
||||
let p = Proj::new(0);
|
||||
// JS: ROT[0](x, y) = [x, -y]; s = [(vx - vy) * 4, (vx + vy) * 2 - z * 4]
|
||||
assert_eq!(p.s(1.0, 0.0, 0.0), [4.0, 2.0]);
|
||||
assert_eq!(p.s(0.0, 1.0, 0.0), [4.0, -2.0]);
|
||||
assert_eq!(p.s(0.0, 0.0, 1.0), [0.0, -4.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotations_wrap() {
|
||||
assert_eq!(Proj::new(5).rot, 1);
|
||||
assert_eq!(Proj::new(-1).rot, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unproject_round_trips() {
|
||||
for rot in 0..4 {
|
||||
let p = Proj::new(rot);
|
||||
let s = p.s(12.5, -3.25, 2.0);
|
||||
let w = p.unproject(s[0], s[1], 2.0);
|
||||
assert!((w[0] - 12.5).abs() < 1e-9 && (w[1] + 3.25).abs() < 1e-9, "rot {rot}: {w:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_two_of_four_walls_face_the_viewer() {
|
||||
for rot in 0..4 {
|
||||
let p = Proj::new(rot);
|
||||
let n = [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)]
|
||||
.iter()
|
||||
.filter(|(x, y)| p.vis(*x, *y).is_some())
|
||||
.count();
|
||||
assert_eq!(n, 2, "rot {rot}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faces_get_left_and_right_shades() {
|
||||
let p = Proj::new(0);
|
||||
// rotation 0 looks from the south-east: the south face (normal -y) is on the left
|
||||
assert_eq!(shade(p.vis(0.0, -1.0).unwrap()), "iso-l");
|
||||
assert_eq!(shade(p.vis(1.0, 0.0).unwrap()), "iso-r");
|
||||
assert_eq!(shade(p.vis(1.0, -1.0).unwrap()), "iso-c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_grows_towards_viewer() {
|
||||
let p = Proj::new(0);
|
||||
assert!(p.depth(10.0, 0.0) > p.depth(0.0, 0.0)); // east is nearer from the south-east
|
||||
assert!(p.depth(0.0, -10.0) > p.depth(0.0, 0.0)); // south is nearer
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
//! Projection of a scene to SVG for one view rotation.
|
||||
|
||||
use crate::fmt::{esc, hash, r1, r1s, round1};
|
||||
use crate::prim::{Prim, Style, P2};
|
||||
use crate::proj::{shade, Proj, C};
|
||||
use crate::scene::{Info, Scene};
|
||||
use crate::sort::{self, Bb, Sb};
|
||||
use crate::sprite::Sprite;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write;
|
||||
|
||||
const SQ2: f64 = std::f64::consts::SQRT_2;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RenderOpts {
|
||||
/// Unique id of the mounted view (`{uid}` in defs and raw SVG).
|
||||
pub uid: u32,
|
||||
/// Show plain tags (tags with a kind always show).
|
||||
pub labels: bool,
|
||||
/// Add `data-src` to sprites and keep the draw order (dev viewer).
|
||||
pub dev: bool,
|
||||
}
|
||||
|
||||
impl Default for RenderOpts {
|
||||
fn default() -> Self {
|
||||
RenderOpts { uid: 0, labels: true, dev: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct OrderItem {
|
||||
pub layer: String,
|
||||
pub id: String,
|
||||
pub bb: [f64; 6],
|
||||
pub sb: [f64; 4],
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub src: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Rendered {
|
||||
/// Inner SVG markup (defs, layers, tags).
|
||||
pub html: String,
|
||||
pub info: BTreeMap<String, Info>,
|
||||
/// Frame of the drawing in screen units: [x, y, w, h].
|
||||
pub bbox: [f64; 4],
|
||||
/// Screen point the view centres on when zoomed in by default.
|
||||
pub focus: [f64; 2],
|
||||
pub rot: u8,
|
||||
/// Compass needle angle in degrees.
|
||||
pub north: f64,
|
||||
pub sprites: usize,
|
||||
/// Number of SVG elements.
|
||||
pub nodes: usize,
|
||||
/// Depth-sort cycles that had to be broken (0 is ideal).
|
||||
pub cycles: usize,
|
||||
pub warnings: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub order: Vec<OrderItem>,
|
||||
}
|
||||
|
||||
/// JS-style number: shortest round-trip form, no `-0`.
|
||||
fn jn(out: &mut String, v: f64) {
|
||||
if !v.is_finite() {
|
||||
out.push_str("NaN");
|
||||
} else if v == 0.0 {
|
||||
out.push('0');
|
||||
} else {
|
||||
let _ = write!(out, "{v}");
|
||||
}
|
||||
}
|
||||
|
||||
fn did(out: &mut String, id: Option<&str>) {
|
||||
if let Some(id) = id.filter(|s| !s.is_empty()) {
|
||||
out.push_str(" data-id=\"");
|
||||
out.push_str(&esc(id));
|
||||
out.push('"');
|
||||
}
|
||||
}
|
||||
|
||||
struct Ctx<'a> {
|
||||
p: Proj,
|
||||
uid: &'a str,
|
||||
out: String,
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
fn path_open(&mut self, cls: &str, id: Option<&str>) {
|
||||
self.out.push_str("<path class=\"");
|
||||
self.out.push_str(cls);
|
||||
self.out.push('"');
|
||||
did(&mut self.out, id);
|
||||
self.out.push_str(" d=\"");
|
||||
}
|
||||
|
||||
fn prism(&mut self, pts: &[P2], z0: &crate::Zs, z1: &crate::Zs, st: &Style) {
|
||||
let p = self.p;
|
||||
let n = pts.len();
|
||||
let id = st.id.as_deref();
|
||||
if !st.no_side {
|
||||
for i in 0..n {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % n]);
|
||||
let Some(v) = p.vis(b[1] - a[1], a[0] - b[0]) else { continue };
|
||||
let j = (i + 1) % n;
|
||||
if z1.at(i) - z0.at(i) < 1e-6 && z1.at(j) - z0.at(j) < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
self.path_open(&format!("{} {}", st.side_cls(), shade(v)), id);
|
||||
p.d(
|
||||
&mut self.out,
|
||||
&[[a[0], a[1], z0.at(i)], [b[0], b[1], z0.at(j)], [b[0], b[1], z1.at(j)], [a[0], a[1], z1.at(i)]],
|
||||
false,
|
||||
);
|
||||
self.out.push_str("\"/>");
|
||||
}
|
||||
}
|
||||
if !st.no_top {
|
||||
let tp: Vec<[f64; 3]> = pts.iter().enumerate().map(|(i, q)| [q[0], q[1], z1.at(i)]).collect();
|
||||
self.path_open(&format!("{} iso-t", st.top_cls()), id);
|
||||
p.d(&mut self.out, &tp, false);
|
||||
self.out.push_str("\"/>");
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn cyl(&mut self, c: P2, r: f64, z0: f64, z1: f64, st: &Style) {
|
||||
let p = self.p;
|
||||
let b = p.s(c[0], c[1], z0);
|
||||
let t = p.s(c[0], c[1], z1);
|
||||
let rx = r * C * SQ2;
|
||||
let ry = rx / 2.0;
|
||||
let (bx, by, ty) = (r1s(b[0]), r1s(b[1]), r1s(t[1]));
|
||||
let (l, rr, rxs, rys) = (r1s(b[0] - rx), r1s(b[0] + rx), r1s(rx), r1s(ry));
|
||||
let (fb, ft) = (r1s(b[1] + ry), r1s(t[1] + ry));
|
||||
let id = st.id.as_deref();
|
||||
let side = st.side_cls();
|
||||
if z1 > z0 && !st.no_side {
|
||||
self.path_open(&format!("{side} iso-l"), id);
|
||||
let _ = write!(
|
||||
self.out,
|
||||
"M{l} {ty}L{l} {by}A{rxs} {rys} 0 0 0 {bx} {fb}L{bx} {ft}A{rxs} {rys} 0 0 1 {l} {ty}Z\"/>"
|
||||
);
|
||||
self.path_open(&format!("{side} iso-r"), id);
|
||||
let _ = write!(
|
||||
self.out,
|
||||
"M{bx} {fb}A{rxs} {rys} 0 0 0 {rr} {by}L{rr} {ty}A{rxs} {rys} 0 0 1 {bx} {ft}Z\"/>"
|
||||
);
|
||||
}
|
||||
if !st.no_top {
|
||||
let _ = write!(self.out, "<ellipse class=\"{} iso-t\"", st.top_cls());
|
||||
did(&mut self.out, id);
|
||||
let _ = write!(self.out, " cx=\"{bx}\" cy=\"{ty}\" rx=\"{rxs}\" ry=\"{rys}\"/>");
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn frust(&mut self, c: P2, r0: f64, r1_: f64, z0: f64, z1: f64, n: u32, st: &Style) {
|
||||
let p = self.p;
|
||||
let id = st.id.as_deref();
|
||||
let mut top = Vec::with_capacity(n as usize);
|
||||
let tau = std::f64::consts::TAU;
|
||||
for i in 0..n {
|
||||
let a0 = i as f64 / n as f64 * tau;
|
||||
let a1 = (i + 1) as f64 / n as f64 * tau;
|
||||
let am = (a0 + a1) / 2.0;
|
||||
top.push([c[0] + r1_ * a0.cos(), c[1] + r1_ * a0.sin(), z1]);
|
||||
if st.no_side {
|
||||
continue;
|
||||
}
|
||||
let Some(v) = p.vis(am.cos(), am.sin()) else { continue };
|
||||
self.path_open(&format!("{} {}", st.side_cls(), shade(v)), id);
|
||||
p.d(
|
||||
&mut self.out,
|
||||
&[
|
||||
[c[0] + r0 * a0.cos(), c[1] + r0 * a0.sin(), z0],
|
||||
[c[0] + r0 * a1.cos(), c[1] + r0 * a1.sin(), z0],
|
||||
[c[0] + r1_ * a1.cos(), c[1] + r1_ * a1.sin(), z1],
|
||||
[c[0] + r1_ * a0.cos(), c[1] + r1_ * a0.sin(), z1],
|
||||
],
|
||||
false,
|
||||
);
|
||||
self.out.push_str("\"/>");
|
||||
}
|
||||
if r1_ > 0.01 && !st.no_top {
|
||||
self.path_open(&format!("{} iso-t", st.top_cls()), id);
|
||||
p.d(&mut self.out, &top, false);
|
||||
self.out.push_str("\"/>");
|
||||
}
|
||||
}
|
||||
|
||||
fn open(&mut self, a: P2, b: P2, list: &[[f64; 4]], cls: &str, id: Option<&str>) {
|
||||
if list.is_empty() {
|
||||
return;
|
||||
}
|
||||
let p = self.p;
|
||||
let Some(v) = p.vis(b[1] - a[1], a[0] - b[0]) else { return };
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let l = (dx * dx + dy * dy).sqrt().max(1e-9);
|
||||
let (ux, uy) = (dx / l, dy / l);
|
||||
self.path_open(&format!("{cls} {}", shade(v)), id);
|
||||
for q in list {
|
||||
p.d(
|
||||
&mut self.out,
|
||||
&[
|
||||
[a[0] + ux * q[0], a[1] + uy * q[0], q[2]],
|
||||
[a[0] + ux * q[1], a[1] + uy * q[1], q[2]],
|
||||
[a[0] + ux * q[1], a[1] + uy * q[1], q[3]],
|
||||
[a[0] + ux * q[0], a[1] + uy * q[0], q[3]],
|
||||
],
|
||||
false,
|
||||
);
|
||||
}
|
||||
self.out.push_str("\"/>");
|
||||
}
|
||||
|
||||
fn flat(&mut self, polys: &[Vec<P2>], z: f64, cls: &str, id: Option<&str>) {
|
||||
let p = self.p;
|
||||
let mut d = String::new();
|
||||
for poly in polys {
|
||||
let p3: Vec<[f64; 3]> = poly.iter().map(|q| [q[0], q[1], z]).collect();
|
||||
p.d(&mut d, &p3, false);
|
||||
}
|
||||
if d.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.out.push_str("<path class=\"");
|
||||
self.out.push_str(cls);
|
||||
self.out.push('"');
|
||||
did(&mut self.out, id);
|
||||
if polys.len() > 1 {
|
||||
self.out.push_str(" fill-rule=\"evenodd\"");
|
||||
}
|
||||
self.out.push_str(" d=\"");
|
||||
self.out.push_str(&d);
|
||||
self.out.push_str("\"/>");
|
||||
}
|
||||
|
||||
fn lines(&mut self, segs: &[[f64; 4]], z: f64, cls: &str, id: Option<&str>) {
|
||||
if segs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let p = self.p;
|
||||
self.path_open(cls, id);
|
||||
for s in segs {
|
||||
let a = p.s(s[0], s[1], z);
|
||||
let b = p.s(s[2], s[3], z);
|
||||
self.out.push('M');
|
||||
r1(&mut self.out, a[0]);
|
||||
self.out.push(' ');
|
||||
r1(&mut self.out, a[1]);
|
||||
self.out.push('L');
|
||||
r1(&mut self.out, b[0]);
|
||||
self.out.push(' ');
|
||||
r1(&mut self.out, b[1]);
|
||||
}
|
||||
self.out.push_str("\"/>");
|
||||
}
|
||||
|
||||
fn disc(&mut self, c: P2, z: f64, r: f64, cls: &str, id: Option<&str>) {
|
||||
let s = self.p.s(c[0], c[1], z);
|
||||
let rx = r * C * SQ2;
|
||||
let _ = write!(self.out, "<ellipse class=\"{cls}\"");
|
||||
did(&mut self.out, id);
|
||||
let _ = write!(self.out, " cx=\"{}\" cy=\"{}\" rx=\"{}\" ry=\"{}\"/>", r1s(s[0]), r1s(s[1]), r1s(rx), r1s(rx / 2.0));
|
||||
}
|
||||
|
||||
/// Planar polygon shaded by its Newell normal. The view direction in
|
||||
/// view space is (1, 1, 1) (C = CZ), so a face is turned towards the
|
||||
/// viewer when vx + vy + nz > 0.
|
||||
fn face(&mut self, pts: &[[f64; 3]], mat: &str, id: Option<&str>, two_sided: bool) {
|
||||
let n = pts.len();
|
||||
if n < 3 {
|
||||
return;
|
||||
}
|
||||
let mut nn = [0.0f64; 3];
|
||||
for i in 0..n {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % n]);
|
||||
nn[0] += (a[1] - b[1]) * (a[2] + b[2]);
|
||||
nn[1] += (a[2] - b[2]) * (a[0] + b[0]);
|
||||
nn[2] += (a[0] - b[0]) * (a[1] + b[1]);
|
||||
}
|
||||
let len = (nn[0] * nn[0] + nn[1] * nn[1] + nn[2] * nn[2]).sqrt();
|
||||
if len < 1e-12 {
|
||||
return;
|
||||
}
|
||||
let (mut nx, mut ny, mut nz) = (nn[0] / len, nn[1] / len, nn[2] / len);
|
||||
let mut v = self.p.r(nx, ny);
|
||||
let facing = v[0] + v[1] + nz;
|
||||
if facing <= 1e-6 {
|
||||
if !two_sided {
|
||||
return;
|
||||
}
|
||||
nx = -nx;
|
||||
ny = -ny;
|
||||
nz = -nz;
|
||||
v = self.p.r(nx, ny);
|
||||
}
|
||||
let face = if nz >= 0.5 || v[0] + v[1] <= 1e-6 { "iso-t" } else { shade(v) };
|
||||
self.path_open(&format!("{mat} {face}"), id);
|
||||
let p = self.p;
|
||||
p.d(&mut self.out, pts, false);
|
||||
self.out.push_str("\"/>");
|
||||
}
|
||||
|
||||
fn prim(&mut self, pr: &Prim) {
|
||||
match pr {
|
||||
Prim::Prism { pts, z0, z1, st } => self.prism(pts, z0, z1, st),
|
||||
Prim::Cyl { c, r, z0, z1, st } => self.cyl(*c, *r, *z0, *z1, st),
|
||||
Prim::Frust { c, r0, r1, z0, z1, n, st } => self.frust(*c, *r0, *r1, *z0, *z1, *n, st),
|
||||
Prim::Open { a, b, list, cls, id } => self.open(*a, *b, list, cls, id.as_deref()),
|
||||
Prim::Flat { polys, z, cls, id } => self.flat(polys, *z, cls, id.as_deref()),
|
||||
Prim::Lines { segs, z, cls, id } => self.lines(segs, *z, cls, id.as_deref()),
|
||||
Prim::Disc { c, z, r, cls, id } => self.disc(*c, *z, *r, cls, id.as_deref()),
|
||||
Prim::Face { pts, mat, id, two_sided } => self.face(pts, mat, id.as_deref(), *two_sided),
|
||||
Prim::Poly { polys, cls, id } => {
|
||||
if polys.is_empty() {
|
||||
return;
|
||||
}
|
||||
let p = self.p;
|
||||
self.path_open(cls, id.as_deref());
|
||||
for q in polys {
|
||||
p.d(&mut self.out, q, false);
|
||||
}
|
||||
self.out.push_str("\"/>");
|
||||
}
|
||||
Prim::Circle { at, r, off, cls, id } => {
|
||||
let s = self.p.s(at[0], at[1], at[2]);
|
||||
let _ = write!(self.out, "<circle class=\"{cls}\"");
|
||||
did(&mut self.out, id.as_deref());
|
||||
let _ = write!(
|
||||
self.out,
|
||||
" cx=\"{}\" cy=\"{}\" r=\"{}\"/>",
|
||||
r1s(s[0] + off[0]),
|
||||
r1s(s[1] + off[1]),
|
||||
r1s(*r)
|
||||
);
|
||||
}
|
||||
Prim::Stem { at, z0, z1, w, cls, id } => {
|
||||
let b = self.p.s(at[0], at[1], *z0);
|
||||
let t = self.p.s(at[0], at[1], *z1);
|
||||
self.path_open(cls, id.as_deref());
|
||||
let _ = write!(
|
||||
self.out,
|
||||
"M{} {}L{} {}L{} {}L{} {}Z\"/>",
|
||||
r1s(b[0] - w),
|
||||
r1s(b[1]),
|
||||
r1s(t[0] - w),
|
||||
r1s(t[1]),
|
||||
r1s(t[0] + w),
|
||||
r1s(t[1]),
|
||||
r1s(b[0] + w),
|
||||
r1s(b[1])
|
||||
);
|
||||
}
|
||||
Prim::Puffs { at, kind, n, dur } => {
|
||||
let s = self.p.s(at[0], at[1], at[2]);
|
||||
let _ = write!(self.out, "<g class=\"iso-vent\" transform=\"translate({} {})\">", r1s(s[0]), r1s(s[1]));
|
||||
let r = if kind == "iso-smoke" {
|
||||
3.2
|
||||
} else if kind == "iso-puff2" {
|
||||
2.4
|
||||
} else {
|
||||
1.5
|
||||
};
|
||||
for i in 0..*n {
|
||||
let delay = i as f64 * dur / *n as f64 + hash(at[0] + at[1] + i as f64) * 0.4;
|
||||
let _ = write!(self.out, "<circle class=\"iso-a {kind}\" r=\"{r}\" style=\"animation-delay:-{delay:.2}s;animation-duration:");
|
||||
jn(&mut self.out, *dur);
|
||||
self.out.push_str("s\"/>");
|
||||
}
|
||||
self.out.push_str("</g>");
|
||||
}
|
||||
Prim::Fan { at, r } => {
|
||||
self.disc([at[0], at[1]], at[2], *r, "iso-fanring", None);
|
||||
let s = self.p.s(at[0], at[1], at[2]);
|
||||
let (a, w) = ((r * 0.85 * 1000.0).round() / 1000.0, 0.12);
|
||||
let _ = write!(
|
||||
self.out,
|
||||
"<g transform=\"matrix({C} {} {} {} {} {})\"><g class=\"iso-a iso-fan\"><path d=\"M{} {}H{a}V{w}H{}ZM{} {}V{a}H{w}V{}Z\"/></g></g>",
|
||||
C / 2.0,
|
||||
-C,
|
||||
C / 2.0,
|
||||
r1s(s[0]),
|
||||
r1s(s[1]),
|
||||
-a,
|
||||
-w,
|
||||
-a,
|
||||
-w,
|
||||
-a,
|
||||
-a
|
||||
);
|
||||
}
|
||||
Prim::Raw { at, svg } => {
|
||||
let svg = svg.replace("{uid}", self.uid);
|
||||
match at {
|
||||
Some(at) => {
|
||||
let s = self.p.s(at[0], at[1], at[2]);
|
||||
let _ = write!(self.out, "<g transform=\"translate({} {})\">{svg}</g>", r1s(s[0]), r1s(s[1]));
|
||||
}
|
||||
None => self.out.push_str(&svg),
|
||||
}
|
||||
}
|
||||
Prim::Group { cls, style, items } => {
|
||||
self.out.push_str("<g");
|
||||
if !cls.is_empty() {
|
||||
let _ = write!(self.out, " class=\"{}\"", esc(cls));
|
||||
}
|
||||
if !style.is_empty() {
|
||||
let _ = write!(self.out, " style=\"{}\"", esc(style));
|
||||
}
|
||||
self.out.push('>');
|
||||
for q in items {
|
||||
self.prim(q);
|
||||
}
|
||||
self.out.push_str("</g>");
|
||||
}
|
||||
Prim::Seq { items } => {
|
||||
let p = self.p;
|
||||
let mut idx: Vec<usize> = (0..items.len()).collect();
|
||||
idx.sort_by(|&a, &b| {
|
||||
let (pa, pb) = (items[a].0, items[b].0);
|
||||
p.depth(pa[0], pa[1]).total_cmp(&p.depth(pb[0], pb[1]))
|
||||
});
|
||||
for i in idx {
|
||||
for q in &items[i].1 {
|
||||
self.prim(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort box of a sprite in a rotation: (view box, screen box, has extent).
|
||||
pub fn boxes(s: &Sprite, p: Proj) -> (Bb, Sb, bool) {
|
||||
let mut bb: Bb = [1e9, 1e9, -1e9, -1e9, 1e9, -1e9];
|
||||
let mut sb: Sb = [1e9, 1e9, -1e9, -1e9];
|
||||
for e in &s.ext {
|
||||
let v = p.r(e[0], e[1]);
|
||||
let q = p.s(e[0], e[1], e[2]);
|
||||
bb[0] = bb[0].min(v[0]);
|
||||
bb[1] = bb[1].min(v[1]);
|
||||
bb[2] = bb[2].max(v[0]);
|
||||
bb[3] = bb[3].max(v[1]);
|
||||
bb[4] = bb[4].min(e[2]);
|
||||
bb[5] = bb[5].max(e[2]);
|
||||
sb[0] = sb[0].min(q[0]);
|
||||
sb[1] = sb[1].min(q[1]);
|
||||
sb[2] = sb[2].max(q[0]);
|
||||
sb[3] = sb[3].max(q[1]);
|
||||
}
|
||||
(bb, sb, !s.ext.is_empty())
|
||||
}
|
||||
|
||||
/// Render a scene for one rotation.
|
||||
pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered {
|
||||
let p = Proj::new(rot as i64);
|
||||
let uid = opts.uid.to_string();
|
||||
let mut cx = Ctx { p, uid: &uid, out: String::with_capacity(64 * 1024) };
|
||||
let mut frame = [1e9f64, 1e9, -1e9, -1e9];
|
||||
let mut sprites = 0;
|
||||
let mut cycles = 0;
|
||||
let mut order = vec![];
|
||||
let mut warnings = scene.warnings.clone();
|
||||
|
||||
if !scene.defs.is_empty() {
|
||||
cx.out.push_str("<defs>");
|
||||
for d in &scene.defs {
|
||||
cx.out.push_str(&d.replace("{uid}", &uid));
|
||||
}
|
||||
cx.out.push_str("</defs>");
|
||||
}
|
||||
|
||||
for layer in &scene.layers {
|
||||
let live: Vec<&Sprite> = layer.sprites.iter().filter(|s| !s.is_empty()).collect();
|
||||
if live.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let bx: Vec<(Bb, Sb, bool)> = live.iter().map(|s| boxes(s, p)).collect();
|
||||
for (_, sb, has) in &bx {
|
||||
if *has {
|
||||
frame[0] = frame[0].min(sb[0]);
|
||||
frame[1] = frame[1].min(sb[1]);
|
||||
frame[2] = frame[2].max(sb[2]);
|
||||
frame[3] = frame[3].max(sb[3]);
|
||||
}
|
||||
}
|
||||
let idx: Vec<usize> = if layer.opts.sort {
|
||||
let pairs: Vec<(Bb, Sb)> = bx.iter().map(|(b, s, _)| (*b, *s)).collect();
|
||||
let o = sort::sort(&pairs, layer.opts.zfirst);
|
||||
cycles += o.cycles;
|
||||
o.order
|
||||
} else {
|
||||
(0..live.len()).collect()
|
||||
};
|
||||
let _ = write!(cx.out, "<g class=\"{}\" data-layer=\"{}\">", esc(&layer.opts.cls), esc(&layer.name));
|
||||
for i in idx {
|
||||
let s = live[i];
|
||||
sprites += 1;
|
||||
cx.out.push_str("<g");
|
||||
did(&mut cx.out, Some(&s.id));
|
||||
if !s.cls.is_empty() {
|
||||
let _ = write!(cx.out, " class=\"{}\"", esc(&s.cls));
|
||||
}
|
||||
if let Some(o) = &s.obj {
|
||||
let _ = write!(cx.out, " data-obj=\"{}\"", esc(o));
|
||||
}
|
||||
if opts.dev {
|
||||
if let Some(src) = &s.src {
|
||||
let _ = write!(cx.out, " data-src=\"{}\"", esc(src));
|
||||
}
|
||||
let (bb, sb, _) = bx[i];
|
||||
order.push(OrderItem {
|
||||
layer: layer.name.clone(),
|
||||
id: s.id.clone(),
|
||||
bb: bb.map(round1),
|
||||
sb: sb.map(round1),
|
||||
src: s.src.clone(),
|
||||
});
|
||||
}
|
||||
cx.out.push('>');
|
||||
for pr in &s.prims {
|
||||
cx.prim(pr);
|
||||
}
|
||||
cx.out.push_str("</g>");
|
||||
}
|
||||
cx.out.push_str("</g>");
|
||||
}
|
||||
|
||||
// tags
|
||||
cx.out.push_str("<g class=\"iso-tags\">");
|
||||
for t in &scene.tags {
|
||||
if t.text.is_empty() || (t.kind.is_empty() && !opts.labels) {
|
||||
continue;
|
||||
}
|
||||
let q = p.s(t.at[0], t.at[1], t.at[2]);
|
||||
let w = (t.text.chars().count() as f64 * 5.6 + 12.0 + 0.5).floor();
|
||||
let mut d = String::new();
|
||||
d.push('M');
|
||||
jn(&mut d, -w / 2.0);
|
||||
d.push_str(" -21h");
|
||||
jn(&mut d, w);
|
||||
d.push_str("v15h");
|
||||
jn(&mut d, -(w / 2.0 - 4.0));
|
||||
d.push_str("l-4 5l-4 -5h");
|
||||
jn(&mut d, -(w / 2.0 - 4.0));
|
||||
d.push('z');
|
||||
cx.out.push_str("<g class=\"iso-tag");
|
||||
if !t.kind.is_empty() {
|
||||
cx.out.push(' ');
|
||||
cx.out.push_str(&esc(&t.kind));
|
||||
}
|
||||
cx.out.push('"');
|
||||
did(&mut cx.out, Some(&t.id));
|
||||
let (qx, qy) = (r1s(q[0]), r1s(q[1]));
|
||||
let _ = write!(cx.out, " data-p=\"");
|
||||
jn(&mut cx.out, t.prio);
|
||||
let _ = write!(
|
||||
cx.out,
|
||||
"\" data-w=\"{w}\" data-x=\"{qx}\" data-y=\"{qy}\" style=\"transform:translate({qx}px,{qy}px) scale(var(--iso-k,1))\"><path d=\"{d}\"/><text y=\"-10\">{}</text></g>",
|
||||
esc(&t.text)
|
||||
);
|
||||
}
|
||||
cx.out.push_str("</g>");
|
||||
|
||||
// frame: the scene bounds (plan rectangle down to the ground depth) or the drawing
|
||||
if let Some((b, zb)) = scene.bounds {
|
||||
let top = frame[1];
|
||||
frame = [1e9, 1e9, -1e9, -1e9];
|
||||
for (x, y) in [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])] {
|
||||
for z in [0.0, zb] {
|
||||
let q = p.s(x, y, z);
|
||||
frame[0] = frame[0].min(q[0]);
|
||||
frame[1] = frame[1].min(q[1]);
|
||||
frame[2] = frame[2].max(q[0]);
|
||||
frame[3] = frame[3].max(q[1]);
|
||||
}
|
||||
}
|
||||
frame[1] = frame[1].min(top);
|
||||
}
|
||||
if frame[0] > frame[2] {
|
||||
frame = [-100.0, -100.0, 100.0, 100.0];
|
||||
warnings.push("the scene is empty".into());
|
||||
}
|
||||
let bbox = [frame[0], frame[1] - 30.0, frame[2] - frame[0], frame[3] - frame[1] + 30.0].map(round1);
|
||||
let focus = match scene.focus_point() {
|
||||
Some(f) => p.s(f[0], f[1], f[2]),
|
||||
None => [bbox[0] + bbox[2] / 2.0, bbox[1] + bbox[3] / 2.0],
|
||||
};
|
||||
|
||||
let html = cx.out;
|
||||
let nodes = html.matches('<').count() - html.matches("</").count();
|
||||
Rendered {
|
||||
info: scene.info.clone(),
|
||||
bbox,
|
||||
focus: focus.map(round1),
|
||||
rot: p.rot,
|
||||
north: round1(p.north_deg()),
|
||||
sprites,
|
||||
nodes,
|
||||
cycles,
|
||||
warnings,
|
||||
order,
|
||||
html,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::scene::Scene;
|
||||
use crate::sprite::Sprite;
|
||||
|
||||
fn one_box() -> Scene {
|
||||
let mut sc = Scene::new();
|
||||
let mut s = Sprite::new("hall", "");
|
||||
s.cuboid(0.0, 0.0, 10.0, 6.0, 0.0, 4.0, Style::mat("iso-wall"));
|
||||
sc.add_sprite("main", s);
|
||||
sc.info("hall", "Hall", "10 x 6 m");
|
||||
sc.tag([5.0, 3.0, 5.0], "Hall", 5.0, "hall", "");
|
||||
sc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_shows_two_sides_and_a_top_in_every_rotation() {
|
||||
let sc = one_box();
|
||||
for rot in 0..4 {
|
||||
let r = render(&sc, rot, &RenderOpts::default());
|
||||
assert_eq!(r.html.matches("iso-wall iso-").count(), 3, "rot {rot}: {}", r.html);
|
||||
assert!(r.html.contains("iso-wall iso-t"));
|
||||
assert!(r.html.contains("iso-wall iso-l"));
|
||||
assert!(r.html.contains("iso-wall iso-r"));
|
||||
assert!(!r.html.contains("NaN"));
|
||||
assert_eq!(r.sprites, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_path_matches_the_js_engine() {
|
||||
// JS (iso-mooban.js prismS, rotation 0) for box(0,0,10,6,0,4): the top face
|
||||
let sc = one_box();
|
||||
let r = render(&sc, 0, &RenderOpts::default());
|
||||
assert!(r.html.contains("<path class=\"iso-wall iso-t\" d=\"M0 -16L40 4L64 -8L24 -28Z\"/>"), "{}", r.html);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_off_hides_plain_tags_only() {
|
||||
let mut sc = one_box();
|
||||
sc.tag([0.0, 0.0, 0.0], "Gate", 9.0, "gate", "iso-signtag");
|
||||
let r = render(&sc, 1, &RenderOpts { labels: false, ..Default::default() });
|
||||
assert!(r.html.contains(">Gate<"));
|
||||
assert!(!r.html.contains(">Hall<"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_and_counts() {
|
||||
let r = render(&one_box(), 1, &RenderOpts::default());
|
||||
assert!(r.bbox[2] > 0.0 && r.bbox[3] > 30.0);
|
||||
assert!(r.nodes >= 5, "{}", r.nodes);
|
||||
assert_eq!(r.info.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_and_frustum_render() {
|
||||
let mut sc = Scene::new();
|
||||
let mut s = Sprite::new("tank", "");
|
||||
s.cyl(0.0, 0.0, 2.0, 0.0, 5.0, Style::mat("iso-tank"));
|
||||
s.frust(0.0, 0.0, 2.0, 0.3, 5.0, 6.0, 12, Style::mat("iso-tank"));
|
||||
sc.add_sprite("main", s);
|
||||
for rot in 0..4 {
|
||||
let r = render(&sc, rot, &RenderOpts::default());
|
||||
assert!(r.html.contains("<ellipse class=\"iso-tank iso-t\""));
|
||||
// about half of the 12 frustum sides face the viewer (5 or 6: a side seen
|
||||
// edge-on is culled), plus the cylinder's two sides and the frustum top
|
||||
let n = r.html.matches("<path").count();
|
||||
assert!((2 + 5 + 1..=2 + 6 + 1).contains(&n), "rot {rot}: {n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faces_cull_and_shade_by_normal() {
|
||||
let mut sc = Scene::new();
|
||||
let mut s = Sprite::new("roof", "");
|
||||
// south-facing roof slope rising to the north (ccw seen from above/outside)
|
||||
s.face(vec![[0.0, 0.0, 3.0], [10.0, 0.0, 3.0], [10.0, 4.0, 5.0], [0.0, 4.0, 5.0]], "iso-roof", None, false);
|
||||
// a vertical wall facing north (ccw seen from the north)
|
||||
s.face(vec![[10.0, 6.0, 0.0], [0.0, 6.0, 0.0], [0.0, 6.0, 3.0], [10.0, 6.0, 3.0]], "iso-wall", None, false);
|
||||
sc.add_sprite("main", s);
|
||||
let r0 = render(&sc, 0, &RenderOpts::default()); // from the south-east: north wall hidden
|
||||
assert!(r0.html.contains("iso-roof iso-t"));
|
||||
assert!(!r0.html.contains("iso-wall"), "{}", r0.html);
|
||||
let r2 = render(&sc, 2, &RenderOpts::default()); // from the north-west: north wall shown
|
||||
assert!(r2.html.contains("iso-wall iso-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dev_mode_adds_source_and_order() {
|
||||
let mut sc = one_box();
|
||||
sc.layers[2].sprites[0].src = Some("scene.rhai:3".into());
|
||||
let r = render(&sc, 0, &RenderOpts { dev: true, ..Default::default() });
|
||||
assert!(r.html.contains("data-src=\"scene.rhai:3\""));
|
||||
assert_eq!(r.order.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! The scene: layers of sprites, tags, pick info and framing hints.
|
||||
|
||||
use crate::prim::{P2, P3};
|
||||
use crate::sprite::Sprite;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct LayerOpts {
|
||||
/// Depth-sort the sprites (false: draw in insertion order, e.g. ground).
|
||||
pub sort: bool,
|
||||
/// Compare heights first (under ground: stacked basins and floors).
|
||||
pub zfirst: bool,
|
||||
/// Class of the layer's `<g>`; `iso-ground` drops the edge strokes.
|
||||
pub cls: String,
|
||||
}
|
||||
|
||||
impl Default for LayerOpts {
|
||||
fn default() -> Self {
|
||||
LayerOpts { sort: true, zfirst: false, cls: "iso-sprites".into() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Layer {
|
||||
pub name: String,
|
||||
pub opts: LayerOpts,
|
||||
pub sprites: Vec<Sprite>,
|
||||
}
|
||||
|
||||
/// A label pinned to a world point. Higher `prio` wins when tags collide;
|
||||
/// tags with a `kind` (signs, warnings) show even with labels switched off.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Tag {
|
||||
pub at: P3,
|
||||
pub text: String,
|
||||
pub prio: f64,
|
||||
pub id: String,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// What the viewer shows for a picked id.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Info {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub detail: String,
|
||||
/// Optional key/value rows (figures) shown under the detail.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub rows: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Scene {
|
||||
pub layers: Vec<Layer>,
|
||||
pub tags: Vec<Tag>,
|
||||
pub info: BTreeMap<String, Info>,
|
||||
/// SVG `<defs>` content; `{uid}` is replaced per view.
|
||||
pub defs: Vec<String>,
|
||||
/// Point the view centres on when zoomed in by default (small screens,
|
||||
/// rotation while zoomed).
|
||||
pub focus: Option<P3>,
|
||||
/// Plan rectangle [x0, y0, x1, y1] and ground depth used to frame the
|
||||
/// view instead of the drawn geometry.
|
||||
pub bounds: Option<([f64; 4], f64)>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for Scene {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// A scene with the standard layers: `ground` (unsorted, no edges),
|
||||
/// `floor` (sorted, drawn before everything standing on it) and `main`.
|
||||
pub fn new() -> Self {
|
||||
Scene {
|
||||
layers: vec![
|
||||
Layer { name: "ground".into(), opts: LayerOpts { sort: false, zfirst: false, cls: "iso-ground".into() }, sprites: vec![] },
|
||||
Layer { name: "floor".into(), opts: LayerOpts::default(), sprites: vec![] },
|
||||
Layer { name: "main".into(), opts: LayerOpts::default(), sprites: vec![] },
|
||||
],
|
||||
tags: vec![],
|
||||
info: BTreeMap::new(),
|
||||
defs: vec![],
|
||||
focus: None,
|
||||
bounds: None,
|
||||
warnings: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn layer_index(&self, name: &str) -> Option<usize> {
|
||||
self.layers.iter().position(|l| l.name == name)
|
||||
}
|
||||
|
||||
/// Add (or reconfigure) a layer, inserted before `before` or at the end.
|
||||
pub fn add_layer(&mut self, name: &str, opts: LayerOpts, before: Option<&str>) -> usize {
|
||||
if let Some(i) = self.layer_index(name) {
|
||||
self.layers[i].opts = opts;
|
||||
return i;
|
||||
}
|
||||
let at = before.and_then(|b| self.layer_index(b)).unwrap_or(self.layers.len());
|
||||
self.layers.insert(at, Layer { name: name.into(), opts, sprites: vec![] });
|
||||
at
|
||||
}
|
||||
|
||||
/// The named layer, created at the end with default options if missing.
|
||||
pub fn layer_mut(&mut self, name: &str) -> &mut Layer {
|
||||
let i = match self.layer_index(name) {
|
||||
Some(i) => i,
|
||||
None => self.add_layer(name, LayerOpts::default(), None),
|
||||
};
|
||||
&mut self.layers[i]
|
||||
}
|
||||
|
||||
pub fn add_sprite(&mut self, layer: &str, s: Sprite) {
|
||||
self.layer_mut(layer).sprites.push(s);
|
||||
}
|
||||
|
||||
pub fn tag(&mut self, at: P3, text: impl Into<String>, prio: f64, id: impl Into<String>, kind: impl Into<String>) {
|
||||
self.tags.push(Tag { at, text: text.into(), prio, id: id.into(), kind: kind.into() });
|
||||
}
|
||||
|
||||
pub fn info(&mut self, id: impl Into<String>, name: impl Into<String>, detail: impl Into<String>) {
|
||||
let id = id.into();
|
||||
self.info.insert(id.clone(), Info { id, name: name.into(), detail: detail.into(), rows: vec![] });
|
||||
}
|
||||
|
||||
pub fn sprite_count(&self) -> usize {
|
||||
self.layers.iter().map(|l| l.sprites.len()).sum()
|
||||
}
|
||||
|
||||
/// Every pick id used by a sprite or a primitive.
|
||||
pub fn ids(&self) -> std::collections::BTreeSet<String> {
|
||||
use crate::prim::Prim;
|
||||
fn walk(p: &Prim, out: &mut std::collections::BTreeSet<String>) {
|
||||
let id = match p {
|
||||
Prim::Prism { st, .. } | Prim::Cyl { st, .. } | Prim::Frust { st, .. } => st.id.clone(),
|
||||
Prim::Open { id, .. }
|
||||
| Prim::Flat { id, .. }
|
||||
| Prim::Lines { id, .. }
|
||||
| Prim::Disc { id, .. }
|
||||
| Prim::Face { id, .. }
|
||||
| Prim::Poly { id, .. }
|
||||
| Prim::Circle { id, .. }
|
||||
| Prim::Stem { id, .. } => id.clone(),
|
||||
Prim::Group { items, .. } => {
|
||||
items.iter().for_each(|q| walk(q, out));
|
||||
None
|
||||
}
|
||||
Prim::Seq { items } => {
|
||||
items.iter().flat_map(|(_, v)| v.iter()).for_each(|q| walk(q, out));
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(id) = id.filter(|s| !s.is_empty()) {
|
||||
out.insert(id);
|
||||
}
|
||||
}
|
||||
let mut out = std::collections::BTreeSet::new();
|
||||
for l in &self.layers {
|
||||
for s in &l.sprites {
|
||||
if !s.id.is_empty() && !s.prims.is_empty() {
|
||||
out.insert(s.id.clone());
|
||||
}
|
||||
s.prims.iter().for_each(|p| walk(p, &mut out));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The default focus point: the explicit one, else the bounds centre.
|
||||
pub fn focus_point(&self) -> Option<P3> {
|
||||
self.focus.or_else(|| self.bounds.map(|(b, _)| [(b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0, 0.0]))
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan centre of a rectangle.
|
||||
pub fn centre(r: [f64; 4]) -> P2 {
|
||||
[(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Back-to-front ordering of sprites (port of the views' `sortSprites`).
|
||||
//!
|
||||
//! Each sprite has a view-space box `bb = [vx0, vy0, vx1, vy1, z0, z1]` and a
|
||||
//! screen box `sb = [x0, y0, x1, y1]`. Only pairs whose screen boxes overlap
|
||||
//! are compared; a pair is ordered when the boxes are separated along x,
|
||||
//! then y, then z (or z first under ground). The resulting graph is drawn in
|
||||
//! topological order, choosing the smallest centre key among the ready
|
||||
//! sprites; a cycle is broken at the sprite with the fewest unmet edges.
|
||||
|
||||
pub type Bb = [f64; 6];
|
||||
pub type Sb = [f64; 4];
|
||||
|
||||
/// -1: a is drawn before b, 1: after, 0: no separating axis (fall back to the key).
|
||||
pub fn cmp_bb(a: &Bb, b: &Bb, zf: bool) -> i32 {
|
||||
let e = 1e-6;
|
||||
if zf {
|
||||
if a[5] <= b[4] + e {
|
||||
return -1;
|
||||
}
|
||||
if b[5] <= a[4] + e {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
if a[2] <= b[0] + e {
|
||||
return -1;
|
||||
}
|
||||
if b[2] <= a[0] + e {
|
||||
return 1;
|
||||
}
|
||||
if a[3] <= b[1] + e {
|
||||
return -1;
|
||||
}
|
||||
if b[3] <= a[1] + e {
|
||||
return 1;
|
||||
}
|
||||
if a[5] <= b[4] + e {
|
||||
return -1;
|
||||
}
|
||||
if b[5] <= a[4] + e {
|
||||
return 1;
|
||||
}
|
||||
let ka = a[0] + a[2] + a[1] + a[3];
|
||||
let kb = b[0] + b[2] + b[1] + b[3];
|
||||
if ka < kb {
|
||||
-1
|
||||
} else if ka > kb {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of [`sort`]: the draw order and how many times a cycle had to be broken.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Order {
|
||||
pub order: Vec<usize>,
|
||||
pub cycles: usize,
|
||||
}
|
||||
|
||||
pub fn sort(boxes: &[(Bb, Sb)], zf: bool) -> Order {
|
||||
let n = boxes.len();
|
||||
let mut adj: Vec<Vec<usize>> = vec![vec![]; n];
|
||||
let mut indeg = vec![0usize; n];
|
||||
let mut done = vec![false; n];
|
||||
let key: Vec<f64> = boxes
|
||||
.iter()
|
||||
.map(|(b, _)| (b[0] + b[2]) / 2.0 + (b[1] + b[3]) / 2.0 + (b[4] + b[5]) * 0.05)
|
||||
.collect();
|
||||
for i in 0..n {
|
||||
let (ab, asb) = &boxes[i];
|
||||
for j in (i + 1)..n {
|
||||
let (bb, bsb) = &boxes[j];
|
||||
if asb[2] <= bsb[0] || bsb[2] <= asb[0] || asb[3] <= bsb[1] || bsb[3] <= asb[1] {
|
||||
continue;
|
||||
}
|
||||
match cmp_bb(ab, bb, zf) {
|
||||
c if c < 0 => {
|
||||
adj[i].push(j);
|
||||
indeg[j] += 1;
|
||||
}
|
||||
c if c > 0 => {
|
||||
adj[j].push(i);
|
||||
indeg[i] += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Order { order: Vec::with_capacity(n), cycles: 0 };
|
||||
for _ in 0..n {
|
||||
let mut best: Option<usize> = None;
|
||||
for i in 0..n {
|
||||
if !done[i] && indeg[i] == 0 && best.is_none_or(|b| key[i] < key[b]) {
|
||||
best = Some(i);
|
||||
}
|
||||
}
|
||||
if best.is_none() {
|
||||
out.cycles += 1;
|
||||
for i in 0..n {
|
||||
if done[i] {
|
||||
continue;
|
||||
}
|
||||
best = match best {
|
||||
None => Some(i),
|
||||
Some(b) if indeg[i] < indeg[b] || (indeg[i] == indeg[b] && key[i] < key[b]) => Some(i),
|
||||
keep => keep,
|
||||
};
|
||||
}
|
||||
}
|
||||
let b = best.expect("n > 0");
|
||||
done[b] = true;
|
||||
out.order.push(b);
|
||||
for &j in &adj[b] {
|
||||
indeg[j] = indeg[j].saturating_sub(1);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bx(x0: f64, y0: f64, x1: f64, y1: f64, z0: f64, z1: f64) -> (Bb, Sb) {
|
||||
// screen boxes all overlap so every pair is compared
|
||||
([x0, y0, x1, y1, z0, z1], [0.0, 0.0, 10.0, 10.0])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separated_on_x_draws_far_first() {
|
||||
let o = sort(&[bx(5.0, 0.0, 6.0, 1.0, 0.0, 1.0), bx(0.0, 0.0, 1.0, 1.0, 0.0, 1.0)], false);
|
||||
assert_eq!(o.order, vec![1, 0]);
|
||||
assert_eq!(o.cycles, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_boxes_draw_bottom_first() {
|
||||
let o = sort(&[bx(0.0, 0.0, 1.0, 1.0, 1.0, 2.0), bx(0.0, 0.0, 1.0, 1.0, 0.0, 1.0)], false);
|
||||
assert_eq!(o.order, vec![1, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_overlap_on_screen_means_no_edge() {
|
||||
let a = ([5.0, 0.0, 6.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]);
|
||||
let b = ([0.0, 0.0, 1.0, 1.0, 0.0, 1.0], [5.0, 5.0, 6.0, 6.0]);
|
||||
// falls back to the key: b (smaller centre) first
|
||||
assert_eq!(sort(&[a, b], false).order, vec![1, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn z_first_orders_basins_by_height() {
|
||||
// overlapping in x and y; z-first puts the lower one first even though x would not decide
|
||||
let lo = bx(0.0, 0.0, 4.0, 4.0, -3.0, -2.0);
|
||||
let hi = bx(1.0, 1.0, 3.0, 3.0, -1.0, 0.0);
|
||||
assert_eq!(sort(&[hi, lo], true).order, vec![1, 0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
//! Sprites: the unit of the depth sort. A sprite owns world-space
|
||||
//! primitives and the extent points its sort box is computed from.
|
||||
//!
|
||||
//! Every drawing method takes coordinates in the sprite's frame ([`Xf`]):
|
||||
//! an object placed at (x, y, z) and turned in 90 degree steps draws around
|
||||
//! its own origin, and the sprite stores world coordinates.
|
||||
|
||||
use crate::prim::{Prim, Style, Zs, P2, P3};
|
||||
use crate::proj::C;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Placement frame: translate by (x, y, z) after turning `q` quarter turns
|
||||
/// counter-clockwise (seen from above).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Xf {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub z: f64,
|
||||
pub q: u8,
|
||||
}
|
||||
|
||||
impl Xf {
|
||||
/// Frame at (x, y, z) turned by `deg` (rounded to a multiple of 90).
|
||||
pub fn new(x: f64, y: f64, z: f64, deg: f64) -> Self {
|
||||
let q = ((deg / 90.0).round() as i64).rem_euclid(4) as u8;
|
||||
Xf { x, y, z, q }
|
||||
}
|
||||
/// Turn a plan vector into the world.
|
||||
#[inline]
|
||||
pub fn v(&self, x: f64, y: f64) -> P2 {
|
||||
match self.q {
|
||||
0 => [x, y],
|
||||
1 => [-y, x],
|
||||
2 => [-x, -y],
|
||||
_ => [y, -x],
|
||||
}
|
||||
}
|
||||
/// World plan vector back into the frame.
|
||||
#[inline]
|
||||
pub fn v_inv(&self, x: f64, y: f64) -> P2 {
|
||||
match self.q {
|
||||
0 => [x, y],
|
||||
1 => [y, -x],
|
||||
2 => [-x, -y],
|
||||
_ => [-y, x],
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
pub fn p(&self, x: f64, y: f64) -> P2 {
|
||||
let v = self.v(x, y);
|
||||
[v[0] + self.x, v[1] + self.y]
|
||||
}
|
||||
#[inline]
|
||||
pub fn p3(&self, p: P3) -> P3 {
|
||||
let q = self.p(p[0], p[1]);
|
||||
[q[0], q[1], p[2] + self.z]
|
||||
}
|
||||
#[inline]
|
||||
pub fn z(&self, z: f64) -> f64 {
|
||||
z + self.z
|
||||
}
|
||||
/// An axis-aligned rectangle in the frame as a world rectangle.
|
||||
pub fn rect(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> [f64; 4] {
|
||||
let a = self.p(x0, y0);
|
||||
let b = self.p(x1, y1);
|
||||
[a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]
|
||||
}
|
||||
pub fn pts(&self, pts: &[P2]) -> Vec<P2> {
|
||||
pts.iter().map(|q| self.p(q[0], q[1])).collect()
|
||||
}
|
||||
/// World point back into the frame.
|
||||
pub fn inv(&self, x: f64, y: f64) -> P2 {
|
||||
self.v_inv(x - self.x, y - self.y)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Frame {
|
||||
Group { cls: String, style: String, items: Vec<Prim> },
|
||||
Seq { items: Vec<(P2, Vec<Prim>)>, cur: Option<(P2, Vec<Prim>)> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Sprite {
|
||||
/// Pick id; every element of the sprite carries it unless it has its own.
|
||||
pub id: String,
|
||||
/// Extra class on the sprite's `<g>` (e.g. `iso-dim`).
|
||||
pub cls: String,
|
||||
/// Where the sprite was created ("scene.rhai:42"), shown by the dev viewer.
|
||||
pub src: Option<String>,
|
||||
/// Scene-document object the sprite belongs to (the Studio selects and
|
||||
/// drags by this, tooltips use `id`).
|
||||
pub obj: Option<String>,
|
||||
pub prims: Vec<Prim>,
|
||||
/// Points that span the sprite's sort box (world coordinates).
|
||||
pub ext: Vec<P3>,
|
||||
/// Frame the drawing methods' coordinates are in.
|
||||
#[serde(skip)]
|
||||
pub xf: Xf,
|
||||
#[serde(skip)]
|
||||
frames: Vec<Frame>,
|
||||
}
|
||||
|
||||
/// Points of an axis-aligned rectangle, counter-clockwise from (x0, y0).
|
||||
pub fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec<P2> {
|
||||
vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]]
|
||||
}
|
||||
|
||||
impl Sprite {
|
||||
pub fn new(id: impl Into<String>, cls: impl Into<String>) -> Self {
|
||||
Sprite { id: id.into(), cls: cls.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
pub fn with_frame(mut self, xf: Xf) -> Self {
|
||||
self.xf = xf;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a world-space primitive to the open group / sequence item, or to
|
||||
/// the sprite.
|
||||
pub fn push(&mut self, p: Prim) -> &mut Self {
|
||||
match self.frames.last_mut() {
|
||||
Some(Frame::Group { items, .. }) => items.push(p),
|
||||
Some(Frame::Seq { cur, .. }) => match cur {
|
||||
Some((_, items)) => items.push(p),
|
||||
// a primitive before the first seq_item starts an item at the origin
|
||||
None => *cur = Some(([0.0, 0.0], vec![p])),
|
||||
},
|
||||
None => self.prims.push(p),
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- extents (frame coordinates)
|
||||
|
||||
pub fn ext(&mut self, x: f64, y: f64, z: f64) -> &mut Self {
|
||||
let w = self.xf.p3([x, y, z]);
|
||||
self.ext.push(w);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn ext_box(&mut self, x0: f64, y0: f64, x1: f64, y1: f64, z0: f64, z1: f64) -> &mut Self {
|
||||
for z in [z0, z1] {
|
||||
self.ext(x0, y0, z).ext(x1, y0, z).ext(x1, y1, z).ext(x0, y1, z);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
fn ext_world(&mut self, pts: &[P2], z: f64) {
|
||||
for p in pts {
|
||||
self.ext.push([p[0], p[1], z]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- solids
|
||||
|
||||
/// Axis-aligned box (`box` in scripts; `box` is a Rust keyword).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn cuboid(&mut self, x0: f64, y0: f64, x1: f64, y1: f64, z0: f64, z1: f64, st: Style) -> &mut Self {
|
||||
let r = self.xf.rect(x0, y0, x1, y1);
|
||||
let (z0, z1) = (self.xf.z(z0), self.xf.z(z1));
|
||||
let pts = rect(r[0], r[1], r[2], r[3]);
|
||||
self.ext_world(&pts, z0);
|
||||
self.ext_world(&pts, z1);
|
||||
self.push(Prim::Prism { pts, z0: Zs::One(z0), z1: Zs::One(z1), st })
|
||||
}
|
||||
|
||||
pub fn prism(&mut self, pts: Vec<P2>, z0: Zs, z1: Zs, st: Style) -> &mut Self {
|
||||
let pts = self.xf.pts(&pts);
|
||||
let dz = self.xf.z;
|
||||
let sh = |z: Zs| match z {
|
||||
Zs::One(v) => Zs::One(v + dz),
|
||||
Zs::Each(v) => Zs::Each(v.into_iter().map(|q| q + dz).collect()),
|
||||
};
|
||||
let (z0, z1) = (sh(z0), sh(z1));
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
self.ext.push([p[0], p[1], z0.at(i)]);
|
||||
self.ext.push([p[0], p[1], z1.at(i)]);
|
||||
}
|
||||
self.push(Prim::Prism { pts, z0, z1, st })
|
||||
}
|
||||
|
||||
pub fn cyl(&mut self, cx: f64, cy: f64, r: f64, z0: f64, z1: f64, st: Style) -> &mut Self {
|
||||
self.ext_box(cx - r, cy - r, cx + r, cy + r, z0, z1);
|
||||
let c = self.xf.p(cx, cy);
|
||||
let (z0, z1) = (self.xf.z(z0), self.xf.z(z1));
|
||||
self.push(Prim::Cyl { c, r, z0, z1, st })
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn frust(&mut self, cx: f64, cy: f64, r0: f64, r1: f64, z0: f64, z1: f64, n: u32, st: Style) -> &mut Self {
|
||||
let r = r0.max(r1);
|
||||
self.ext_box(cx - r, cy - r, cx + r, cy + r, z0, z1);
|
||||
let c = self.xf.p(cx, cy);
|
||||
let (z0, z1) = (self.xf.z(z0), self.xf.z(z1));
|
||||
self.push(Prim::Frust { c, r0, r1, z0, z1, n: n.max(3), st })
|
||||
}
|
||||
|
||||
/// Sloped slab (stair flight, ramp) over x0..x1, rising from y0 (za) to
|
||||
/// y1 (zb), th thick.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn slope(&mut self, x0: f64, x1: f64, y0: f64, y1: f64, za: f64, zb: f64, th: f64, mat: &str) -> &mut Self {
|
||||
let zt = vec![za, za, zb, zb];
|
||||
let zb_: Vec<f64> = zt.iter().map(|z| z - th).collect();
|
||||
self.prism(rect(x0, y0, x1, y1), Zs::Each(zb_), Zs::Each(zt), Style::mat(mat))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- surfaces
|
||||
|
||||
/// Openings on the vertical face a -> b (outward normal on the right).
|
||||
pub fn open(&mut self, a: P2, b: P2, list: Vec<[f64; 4]>, cls: &str, id: Option<String>) -> &mut Self {
|
||||
let (a, b) = (self.xf.p(a[0], a[1]), self.xf.p(b[0], b[1]));
|
||||
let dz = self.xf.z;
|
||||
let list: Vec<[f64; 4]> = list.into_iter().map(|q| [q[0], q[1], q[2] + dz, q[3] + dz]).collect();
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let l = (dx * dx + dy * dy).sqrt().max(1e-9);
|
||||
let (ux, uy) = (dx / l, dy / l);
|
||||
for q in &list {
|
||||
self.ext.push([a[0] + ux * q[0], a[1] + uy * q[0], q[2]]);
|
||||
self.ext.push([a[0] + ux * q[1], a[1] + uy * q[1], q[3]]);
|
||||
}
|
||||
self.push(Prim::Open { a, b, list, cls: cls.into(), id })
|
||||
}
|
||||
|
||||
pub fn flat(&mut self, polys: Vec<Vec<P2>>, z: f64, cls: &str, id: Option<String>) -> &mut Self {
|
||||
let polys: Vec<Vec<P2>> = polys.iter().map(|p| self.xf.pts(p)).collect();
|
||||
let z = self.xf.z(z);
|
||||
for p in &polys {
|
||||
self.ext_world(p, z);
|
||||
}
|
||||
self.push(Prim::Flat { polys, z, cls: cls.into(), id })
|
||||
}
|
||||
|
||||
pub fn lines(&mut self, segs: Vec<[f64; 4]>, z: f64, cls: &str) -> &mut Self {
|
||||
let segs: Vec<[f64; 4]> = segs
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let a = self.xf.p(s[0], s[1]);
|
||||
let b = self.xf.p(s[2], s[3]);
|
||||
[a[0], a[1], b[0], b[1]]
|
||||
})
|
||||
.collect();
|
||||
let z = self.xf.z(z);
|
||||
for s in &segs {
|
||||
self.ext.push([s[0], s[1], z]);
|
||||
self.ext.push([s[2], s[3], z]);
|
||||
}
|
||||
self.push(Prim::Lines { segs, z, cls: cls.into(), id: None })
|
||||
}
|
||||
|
||||
pub fn disc(&mut self, cx: f64, cy: f64, z: f64, r: f64, cls: &str, id: Option<String>) -> &mut Self {
|
||||
self.ext_box(cx - r, cy - r, cx + r, cy + r, z, z);
|
||||
let c = self.xf.p(cx, cy);
|
||||
let z = self.xf.z(z);
|
||||
self.push(Prim::Disc { c, z, r, cls: cls.into(), id })
|
||||
}
|
||||
|
||||
/// Planar 3D polygon shaded by its normal (see [`Prim::Face`]).
|
||||
pub fn face(&mut self, pts: Vec<P3>, mat: &str, id: Option<String>, two_sided: bool) -> &mut Self {
|
||||
let pts: Vec<P3> = pts.into_iter().map(|p| self.xf.p3(p)).collect();
|
||||
self.ext.extend(pts.iter().copied());
|
||||
self.push(Prim::Face { pts, mat: mat.into(), id, two_sided })
|
||||
}
|
||||
|
||||
/// 3D polygons as one path with a fixed class.
|
||||
pub fn poly(&mut self, polys: Vec<Vec<P3>>, cls: &str, id: Option<String>) -> &mut Self {
|
||||
let polys: Vec<Vec<P3>> = polys.into_iter().map(|p| p.into_iter().map(|q| self.xf.p3(q)).collect()).collect();
|
||||
for p in &polys {
|
||||
self.ext.extend(p.iter().copied());
|
||||
}
|
||||
self.push(Prim::Poly { polys, cls: cls.into(), id })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- screen-space details
|
||||
|
||||
/// Circle of r screen units (not part of the sort box: add `ext` if it
|
||||
/// sticks out of the sprite).
|
||||
pub fn circle(&mut self, at: P3, r: f64, off: P2, cls: &str) -> &mut Self {
|
||||
let at = self.xf.p3(at);
|
||||
self.push(Prim::Circle { at, r, off, cls: cls.into(), id: None })
|
||||
}
|
||||
|
||||
pub fn stem(&mut self, x: f64, y: f64, z0: f64, z1: f64, w: f64, cls: &str) -> &mut Self {
|
||||
self.ext(x, y, z0).ext(x, y, z1);
|
||||
let at = self.xf.p(x, y);
|
||||
let (z0, z1) = (self.xf.z(z0), self.xf.z(z1));
|
||||
self.push(Prim::Stem { at, z0, z1, w, cls: cls.into(), id: None })
|
||||
}
|
||||
|
||||
/// A head (ball of radius r metres) centred at z.
|
||||
pub fn head(&mut self, x: f64, y: f64, z: f64, r: f64) -> &mut Self {
|
||||
self.ext(x, y, z + r);
|
||||
self.circle([x, y, z], r * C * 1.2, [0.0, 0.0], "iso-headc")
|
||||
}
|
||||
|
||||
/// Standing figure: torso cylinder and head.
|
||||
pub fn person(&mut self, x: f64, y: f64, z: f64, cls: &str) -> &mut Self {
|
||||
self.cyl(x, y, 0.2, z, z + 1.2, Style::mat(cls).no_top());
|
||||
self.ext(x, y, z + 1.65);
|
||||
self.circle([x, y, z + 1.45], 0.2 * C * 1.2, [0.0, 0.0], "iso-headc")
|
||||
}
|
||||
|
||||
/// Seated figure: torso and head.
|
||||
pub fn sit(&mut self, x: f64, y: f64, z: f64, cls: &str) -> &mut Self {
|
||||
self.cyl(x, y, 0.21, z + 0.42, z + 1.12, Style::mat(cls).no_top());
|
||||
self.head(x, y, z + 1.32, 0.2)
|
||||
}
|
||||
|
||||
pub fn led(&mut self, x: f64, y: f64, z: f64, cls: &str) -> &mut Self {
|
||||
let c = if cls.is_empty() { "iso-led".to_string() } else { format!("iso-led {cls}") };
|
||||
self.circle([x, y, z], 0.9, [0.0, 0.0], &c)
|
||||
}
|
||||
|
||||
pub fn puffs(&mut self, x: f64, y: f64, z: f64, kind: &str, n: u32, dur: f64) -> &mut Self {
|
||||
let at = self.xf.p3([x, y, z]);
|
||||
self.push(Prim::Puffs { at, kind: kind.into(), n: n.max(1), dur })
|
||||
}
|
||||
|
||||
pub fn fan(&mut self, x: f64, y: f64, z: f64, r: f64) -> &mut Self {
|
||||
let at = self.xf.p3([x, y, z]);
|
||||
self.push(Prim::Fan { at, r })
|
||||
}
|
||||
|
||||
pub fn raw(&mut self, at: Option<P3>, svg: impl Into<String>) -> &mut Self {
|
||||
let at = at.map(|a| self.xf.p3(a));
|
||||
self.push(Prim::Raw { at, svg: svg.into() })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- structure
|
||||
|
||||
/// Open a `<g class style>`; close it with [`Sprite::end_group`].
|
||||
pub fn begin_group(&mut self, cls: &str, style: &str) -> &mut Self {
|
||||
self.frames.push(Frame::Group { cls: cls.into(), style: style.into(), items: vec![] });
|
||||
self
|
||||
}
|
||||
|
||||
pub fn end_group(&mut self) -> &mut Self {
|
||||
if let Some(Frame::Group { cls, style, items }) = self.frames.pop() {
|
||||
self.push(Prim::Group { cls, style, items });
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Start a back-to-front sequence; each [`Sprite::seq_item`] starts an
|
||||
/// item anchored at a plan point; [`Sprite::end_seq`] closes it.
|
||||
pub fn begin_seq(&mut self) -> &mut Self {
|
||||
self.frames.push(Frame::Seq { items: vec![], cur: None });
|
||||
self
|
||||
}
|
||||
|
||||
pub fn seq_item(&mut self, at: P2) -> &mut Self {
|
||||
let at = self.xf.p(at[0], at[1]);
|
||||
if let Some(Frame::Seq { items, cur }) = self.frames.last_mut() {
|
||||
if let Some(c) = cur.take() {
|
||||
items.push(c);
|
||||
}
|
||||
*cur = Some((at, vec![]));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn end_seq(&mut self) -> &mut Self {
|
||||
if let Some(Frame::Seq { mut items, cur }) = self.frames.pop() {
|
||||
if let Some(c) = cur {
|
||||
items.push(c);
|
||||
}
|
||||
self.push(Prim::Seq { items });
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Draw items back to front by the depth of their anchor.
|
||||
pub fn seq(&mut self, items: Vec<(P2, Box<dyn FnOnce(&mut Sprite) + '_>)>) -> &mut Self {
|
||||
self.begin_seq();
|
||||
for (at, f) in items {
|
||||
self.seq_item(at);
|
||||
f(self);
|
||||
}
|
||||
self.end_seq()
|
||||
}
|
||||
|
||||
/// Close any group or sequence left open (returns how many were open).
|
||||
pub fn finish(&mut self) -> usize {
|
||||
let n = self.frames.len();
|
||||
while let Some(f) = self.frames.last() {
|
||||
match f {
|
||||
Frame::Group { .. } => {
|
||||
self.end_group();
|
||||
}
|
||||
Frame::Seq { .. } => {
|
||||
self.end_seq();
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.prims.is_empty()
|
||||
}
|
||||
|
||||
/// World bounding box of the extent points: [x0, y0, z0, x1, y1, z1].
|
||||
pub fn world_box(&self) -> Option<[f64; 6]> {
|
||||
if self.ext.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut b = [f64::MAX, f64::MAX, f64::MAX, f64::MIN, f64::MIN, f64::MIN];
|
||||
for e in &self.ext {
|
||||
for k in 0..3 {
|
||||
b[k] = b[k].min(e[k]);
|
||||
b[k + 3] = b[k + 3].max(e[k]);
|
||||
}
|
||||
}
|
||||
Some(b)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn box_extents_span_eight_corners() {
|
||||
let mut s = Sprite::new("a", "");
|
||||
s.cuboid(0.0, 0.0, 2.0, 3.0, 0.0, 4.0, Style::mat("iso-wall"));
|
||||
assert_eq!(s.ext.len(), 8);
|
||||
assert_eq!(s.prims.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_moves_and_turns_a_box() {
|
||||
let mut s = Sprite::new("a", "").with_frame(Xf::new(10.0, 20.0, 1.0, 90.0));
|
||||
// 4 m along local x, 2 m along local y, centred: after a quarter turn 2 m along x, 4 m along y
|
||||
s.cuboid(-2.0, -1.0, 2.0, 1.0, 0.0, 3.0, Style::mat("x"));
|
||||
let b = s.world_box().unwrap();
|
||||
assert_eq!(b, [9.0, 18.0, 1.0, 11.0, 22.0, 4.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_round_trips_points() {
|
||||
for deg in [0.0, 90.0, 180.0, 270.0, -90.0, 450.0] {
|
||||
let f = Xf::new(3.0, -4.0, 0.0, deg);
|
||||
let w = f.p(1.5, 2.5);
|
||||
let l = f.inv(w[0], w[1]);
|
||||
assert!((l[0] - 1.5).abs() < 1e-12 && (l[1] - 2.5).abs() < 1e-12, "{deg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_and_sequences_nest() {
|
||||
let mut s = Sprite::new("a", "");
|
||||
s.begin_group("iso-a", "");
|
||||
s.begin_seq();
|
||||
s.seq_item([0.0, 0.0]);
|
||||
s.cuboid(0.0, 0.0, 1.0, 1.0, 0.0, 1.0, Style::mat("x"));
|
||||
s.seq_item([5.0, 0.0]);
|
||||
s.cuboid(5.0, 0.0, 6.0, 1.0, 0.0, 1.0, Style::mat("x"));
|
||||
s.end_seq();
|
||||
s.end_group();
|
||||
assert_eq!(s.prims.len(), 1);
|
||||
match &s.prims[0] {
|
||||
Prim::Group { items, .. } => match &items[0] {
|
||||
Prim::Seq { items } => assert_eq!(items.len(), 2),
|
||||
p => panic!("{p:?}"),
|
||||
},
|
||||
p => panic!("{p:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_closes_open_frames() {
|
||||
let mut s = Sprite::new("a", "");
|
||||
s.begin_group("g", "");
|
||||
s.cuboid(0.0, 0.0, 1.0, 1.0, 0.0, 1.0, Style::mat("x"));
|
||||
assert_eq!(s.finish(), 1);
|
||||
assert!(matches!(s.prims[0], Prim::Group { .. }));
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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")),
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "isokit-theme"
|
||||
description = "isokit themes: TOML palettes, materials and rules -> CSS"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
toml = { workspace = true }
|
||||
@@ -0,0 +1,204 @@
|
||||
//! isokit themes.
|
||||
//!
|
||||
//! A theme is TOML with four parts, all optional:
|
||||
//!
|
||||
//! ```toml
|
||||
//! [palette.dark] # CSS variables --iso-<name>; dark is the default
|
||||
//! wall = "#7a8496"
|
||||
//! [palette.light] # applies under html[data-theme=light]
|
||||
//! wall = "#e3e7ec"
|
||||
//! [materials] # class iso-<name> sets the face colour --b
|
||||
//! wall = "wall" # a palette name -> var(--iso-wall)
|
||||
//! sofa = "#6b5a8e" # or any CSS colour
|
||||
//! [rules] # any other CSS: selector = declarations
|
||||
//! ".iso-svg .iso-win" = "fill: var(--iso-win); stroke: none"
|
||||
//! [extra]
|
||||
//! css = "..." # appended verbatim
|
||||
//! ```
|
||||
//!
|
||||
//! Themes stack: the built-in default first, then each project theme file;
|
||||
//! later entries override earlier ones by key, keeping the first position.
|
||||
|
||||
use std::fmt::Write;
|
||||
use toml::Table;
|
||||
|
||||
/// The built-in theme (palette, materials and rules of the CNX / Mooban views).
|
||||
pub const DEFAULT: &str = include_str!("../themes/default.toml");
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Theme {
|
||||
pub dark: Vec<(String, String)>,
|
||||
pub light: Vec<(String, String)>,
|
||||
pub materials: Vec<(String, String)>,
|
||||
pub rules: Vec<(String, String)>,
|
||||
pub extra: String,
|
||||
}
|
||||
|
||||
fn upsert(list: &mut Vec<(String, String)>, k: &str, v: String) {
|
||||
match list.iter_mut().find(|(key, _)| key == k) {
|
||||
Some(e) => e.1 = v,
|
||||
None => list.push((k.to_string(), v)),
|
||||
}
|
||||
}
|
||||
|
||||
fn strings(t: Option<&toml::Value>, what: &str) -> Result<Vec<(String, String)>, String> {
|
||||
let Some(v) = t else { return Ok(vec![]) };
|
||||
let tab = v.as_table().ok_or_else(|| format!("[{what}] must be a table"))?;
|
||||
tab.iter()
|
||||
.map(|(k, v)| match v {
|
||||
toml::Value::String(s) => Ok((k.clone(), s.clone())),
|
||||
toml::Value::Integer(i) => Ok((k.clone(), i.to_string())),
|
||||
toml::Value::Float(f) => Ok((k.clone(), f.to_string())),
|
||||
_ => Err(format!("{what}.{k}: expected a string")),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
/// The built-in default theme.
|
||||
pub fn default_theme() -> Self {
|
||||
let mut t = Theme::default();
|
||||
t.merge_str(DEFAULT, "default").expect("built-in theme parses");
|
||||
t
|
||||
}
|
||||
|
||||
/// Merge one theme layer (TOML text). `name` is used in error messages.
|
||||
pub fn merge_str(&mut self, src: &str, name: &str) -> Result<(), String> {
|
||||
let tab: Table = src.parse().map_err(|e| format!("{name}: {e}"))?;
|
||||
let pal = tab.get("palette").and_then(|p| p.as_table());
|
||||
let err = |e: String| format!("{name}: {e}");
|
||||
for (k, v) in strings(pal.and_then(|p| p.get("dark")), "palette.dark").map_err(err)? {
|
||||
upsert(&mut self.dark, &k, v);
|
||||
}
|
||||
for (k, v) in strings(pal.and_then(|p| p.get("light")), "palette.light").map_err(err)? {
|
||||
upsert(&mut self.light, &k, v);
|
||||
}
|
||||
for (k, v) in strings(tab.get("materials"), "materials").map_err(err)? {
|
||||
upsert(&mut self.materials, &k, v);
|
||||
}
|
||||
for (k, v) in strings(tab.get("rules"), "rules").map_err(err)? {
|
||||
upsert(&mut self.rules, &k, v);
|
||||
}
|
||||
if let Some(css) = tab.get("extra").and_then(|e| e.get("css")).and_then(|c| c.as_str()) {
|
||||
self.extra.push_str(css);
|
||||
self.extra.push('\n');
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_palette(&self, name: &str) -> bool {
|
||||
self.dark.iter().any(|(k, _)| k == name) || self.light.iter().any(|(k, _)| k == name)
|
||||
}
|
||||
|
||||
/// Value of a material: a palette name becomes `var(--iso-name)`.
|
||||
pub fn material_value(&self, v: &str) -> String {
|
||||
if self.is_palette(v) {
|
||||
format!("var(--iso-{v})")
|
||||
} else {
|
||||
v.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// The theme as CSS. `scope` is the selector the variables hang on
|
||||
/// (`.iso-wrap` in pages, `svg` in standalone SVG files). `light`:
|
||||
/// None = dark with a light block under `html[data-theme=light]`,
|
||||
/// Some(x) = only that scheme.
|
||||
pub fn css_scoped(&self, scope: &str, light: Option<bool>) -> String {
|
||||
let mut o = String::with_capacity(32 * 1024);
|
||||
let block = |o: &mut String, sel: &str, vars: &[(String, String)]| {
|
||||
if vars.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = write!(o, "{sel} {{");
|
||||
for (k, v) in vars {
|
||||
let _ = write!(o, " --iso-{k}: {v};");
|
||||
}
|
||||
o.push_str(" }\n");
|
||||
};
|
||||
match light {
|
||||
None => {
|
||||
block(&mut o, scope, &self.dark);
|
||||
block(&mut o, &format!("html[data-theme=light] {scope}"), &self.light);
|
||||
}
|
||||
Some(false) => block(&mut o, scope, &self.dark),
|
||||
Some(true) => {
|
||||
block(&mut o, scope, &self.dark);
|
||||
block(&mut o, scope, &self.light);
|
||||
}
|
||||
}
|
||||
for (k, v) in &self.materials {
|
||||
let _ = writeln!(o, ".iso-{k} {{ --b: {}; }}", self.material_value(v));
|
||||
}
|
||||
for (sel, decl) in &self.rules {
|
||||
let _ = writeln!(o, "{sel} {{ {decl}; }}");
|
||||
}
|
||||
o.push_str(&self.extra);
|
||||
o
|
||||
}
|
||||
|
||||
/// CSS for a page: dark by default, light under `html[data-theme=light]`.
|
||||
pub fn css(&self) -> String {
|
||||
self.css_scoped(".iso-wrap", None)
|
||||
}
|
||||
}
|
||||
|
||||
/// The default theme with project layers merged on top: (name, TOML text).
|
||||
pub fn load(layers: &[(&str, &str)]) -> Result<Theme, String> {
|
||||
let mut t = Theme::default_theme();
|
||||
for (name, src) in layers {
|
||||
t.merge_str(src, name)?;
|
||||
}
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_theme_has_the_core_materials() {
|
||||
let t = Theme::default_theme();
|
||||
assert!(t.dark.len() > 100 && t.light.len() == t.dark.len());
|
||||
let css = t.css();
|
||||
assert!(css.contains(".iso-wall { --b: var(--iso-wall); }"));
|
||||
assert!(css.contains("html[data-theme=light] .iso-wrap {"));
|
||||
assert!(css.contains(".iso-svg .iso-win { fill: var(--iso-win); stroke: none; }"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_layer_overrides_and_extends() {
|
||||
let t = load(&[(
|
||||
"p",
|
||||
r##"
|
||||
[palette.dark]
|
||||
wall = "#ff0000"
|
||||
brick = "#aa4433"
|
||||
[palette.light]
|
||||
brick = "#cc6655"
|
||||
[materials]
|
||||
brick = "brick"
|
||||
glassy = "#88ccff"
|
||||
[rules]
|
||||
".iso-svg .iso-win" = "fill: red"
|
||||
[extra]
|
||||
css = ".x { y: z }"
|
||||
"##,
|
||||
)])
|
||||
.unwrap();
|
||||
let css = t.css();
|
||||
assert!(css.contains("--iso-wall: #ff0000;"));
|
||||
assert!(css.contains(".iso-brick { --b: var(--iso-brick); }"));
|
||||
assert!(css.contains(".iso-glassy { --b: #88ccff; }"));
|
||||
assert!(css.contains(".iso-svg .iso-win { fill: red; }"));
|
||||
assert!(!css.contains("fill: var(--iso-win); stroke: none"));
|
||||
assert!(css.ends_with(".x { y: z }\n"));
|
||||
// the override keeps the default's position: wall still comes before brick
|
||||
assert!(css.find("--iso-wall:").unwrap() < css.find("--iso-brick:").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_layer_reports_its_name() {
|
||||
let e = load(&[("theme.toml", "[materials]\nx = [1]")]).unwrap_err();
|
||||
assert!(e.starts_with("theme.toml:"), "{e}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
# isokit default theme, converted from the CNX Vinyl / CNX Scuba / Mooban views.
|
||||
# palette.*: CSS variables --iso-<name> (dark is the default, light applies under
|
||||
# html[data-theme=light]); materials: class iso-<name> sets the face colour --b to a
|
||||
# palette entry or a literal colour; rules: any other CSS, selector = declarations.
|
||||
# A project theme file uses the same tables and overrides entries by key.
|
||||
|
||||
name = "default"
|
||||
|
||||
[palette.dark]
|
||||
sky1 = "#0b1428"
|
||||
sky2 = "#251f42"
|
||||
star = "rgba(255, 255, 255, .55)"
|
||||
shade = "#04060c"
|
||||
kl = "78%"
|
||||
kc = "70%"
|
||||
kr = "58%"
|
||||
edge = "rgba(4, 7, 14, .72)"
|
||||
edgew = ".32"
|
||||
grass = "#1d3a2b"
|
||||
dirt = "#3d3026"
|
||||
grid = "rgba(190, 230, 210, .06)"
|
||||
asph = "#252a34"
|
||||
conc = "#3c4250"
|
||||
pad = "#454c5a"
|
||||
path = "#4b5161"
|
||||
dash = "#cdbb68"
|
||||
kerb = "#59606e"
|
||||
bayline = "#8d96a8"
|
||||
hazard = "#b58a12"
|
||||
plinth = "#5a606c"
|
||||
wall = "#7a8496"
|
||||
wall2 = "#737d8f"
|
||||
roof = "#8792a6"
|
||||
roof2 = "#808a9c"
|
||||
roof3 = "#8f8c9a"
|
||||
roofin = "#777484"
|
||||
office = "#8a8796"
|
||||
yardb = "#6f798b"
|
||||
cut = "#222733"
|
||||
firewall = "#8c4a48"
|
||||
floor = "#4a5263"
|
||||
part = "#161b27"
|
||||
mark = "#d9ad2e"
|
||||
trench = "#2b303b"
|
||||
glass = "#f2c56e"
|
||||
glasso = ".72"
|
||||
glassw = "#9fd0ff"
|
||||
glasswo = ".16"
|
||||
win = "#283248"
|
||||
winlit = "#ffcd6a"
|
||||
door = "#353d4c"
|
||||
dock = "#8a93a1"
|
||||
open = "#12161e"
|
||||
pv = "#17275a"
|
||||
pvl = "#4d68ad"
|
||||
glint = "#ffffff"
|
||||
led-r = "#ff5a3c"
|
||||
led-g = "#46f59a"
|
||||
led-o = "#ffae34"
|
||||
fire = "#ff8a2a"
|
||||
lamp = "#ffd98a"
|
||||
lampo = ".5"
|
||||
steam = "#dfe7f1"
|
||||
smoke = "#7b7f88"
|
||||
steel = "#8b95a6"
|
||||
press = "#2f3a4f"
|
||||
press2 = "#3e4b64"
|
||||
ex50 = "#3a7bd2"
|
||||
ex35 = "#9a68d4"
|
||||
hopper = "#bfc6d1"
|
||||
classic = "#2c9a60"
|
||||
vinyl = "#121419"
|
||||
hivis = "#f0b400"
|
||||
head = "#e9c9a3"
|
||||
galv = "#4d97a3"
|
||||
galv2 = "#5aa9b5"
|
||||
bath = "#3fd0a8"
|
||||
sibert = "#b6c1ce"
|
||||
bench = "#8b7b63"
|
||||
rack = "#c45c2b"
|
||||
carton = "#9c7a4e"
|
||||
crane = "#e3b100"
|
||||
col = "#687386"
|
||||
rail = "#c7a300"
|
||||
boilere = "#c6cbd4"
|
||||
boilerb = "#b0412e"
|
||||
padc = "#686e7a"
|
||||
padempty = "#535a67"
|
||||
chiller = "#a7b1be"
|
||||
screen = "#6d7685"
|
||||
bess = "#c9cfd8"
|
||||
inv = "#39414f"
|
||||
trafo = "#58626f"
|
||||
water = "#6f8fb0"
|
||||
firetank = "#b03a3a"
|
||||
stack = "#99a0ac"
|
||||
chips = "#9a7744"
|
||||
tank = "#7f8ea3"
|
||||
silok = "#22252c"
|
||||
siloy = "#d4b229"
|
||||
silor = "#b5403a"
|
||||
canopy = "#687182"
|
||||
car1 = "#b8382b"
|
||||
car2 = "#2e7fd2"
|
||||
car3 = "#d5dbe2"
|
||||
car4 = "#2d3238"
|
||||
glassd = "#1c2433"
|
||||
cab = "#e6ebf2"
|
||||
truckbox = "#3a6db4"
|
||||
chassis = "#20242c"
|
||||
sacks = "#ddd2b8"
|
||||
fence = "#9aa3b1"
|
||||
fenceo = ".22"
|
||||
gate = "#8a94a4"
|
||||
boom = "#e04b3a"
|
||||
ac = "#a1a9b6"
|
||||
trunk = "#5a4430"
|
||||
leaf = "#2c6640"
|
||||
person = "#f0b400"
|
||||
tagbg = "rgba(10, 16, 30, .9)"
|
||||
tagline = "#3b5277"
|
||||
tagfg = "#e4ecf7"
|
||||
signbg = "#5e4300"
|
||||
signline = "#d9a300"
|
||||
signfg = "#ffe2a2"
|
||||
warnbg = "#5a1f1f"
|
||||
warnline = "#ff6b6b"
|
||||
warnfg = "#ffd4d4"
|
||||
tipbg = "#0f1626"
|
||||
tipfg = "#d6deeb"
|
||||
tipline = "#2a3a56"
|
||||
tipmute = "#8a97b0"
|
||||
btn = "#0f1626"
|
||||
btnfg = "#d6deeb"
|
||||
btnline = "#2a3a56"
|
||||
btnon = "#12261c"
|
||||
accent = "#3ddc84"
|
||||
sel = "#3ddc84"
|
||||
hov = "#9fd0ff"
|
||||
mute = "#8a97b0"
|
||||
water1 = "#2f8fc4"
|
||||
water2 = "#06243f"
|
||||
watero = ".46"
|
||||
surf = "#4fb3e6"
|
||||
surfo = ".5"
|
||||
moon = "#7fd3ff"
|
||||
earth = "#4a3a2c"
|
||||
earth2 = "#3a2d22"
|
||||
strata = "rgba(0, 0, 0, .28)"
|
||||
secconc = "#6b7180"
|
||||
tile = "#5f8fa6"
|
||||
tile2 = "#4d7a91"
|
||||
sand = "#a8946a"
|
||||
sand2 = "#7e6f52"
|
||||
deep = "#04101d"
|
||||
coral1 = "#ff7a5c"
|
||||
coral2 = "#c26bdc"
|
||||
coral3 = "#f2c14e"
|
||||
rock = "#6d6258"
|
||||
kelp = "#3f9a6a"
|
||||
suit = "#1c2129"
|
||||
tankc = "#f0b400"
|
||||
fin = "#36c0d8"
|
||||
silt = "#a88a5c"
|
||||
stal = "#b9a582"
|
||||
stone = "#6f665d"
|
||||
acryl = "#8fd6ff"
|
||||
acrylo = ".28"
|
||||
deck = "#8b96a6"
|
||||
aqua = "#3aa7d8"
|
||||
dine = "#6b5848"
|
||||
guest1 = "#e07a5f"
|
||||
guest2 = "#81b29a"
|
||||
guest3 = "#f2cc8f"
|
||||
guest4 = "#7a8fd8"
|
||||
cloth = "#eef1f5"
|
||||
songthaew = "#c8352b"
|
||||
umb1 = "#e85d4a"
|
||||
umb2 = "#f2c14e"
|
||||
umb3 = "#36c0d8"
|
||||
chamber = "#d9dee5"
|
||||
guide = "#f4f1e6"
|
||||
render = "#8f8b84"
|
||||
render2 = "#7c8089"
|
||||
rooft = "#4b5263"
|
||||
fascia = "#3b4150"
|
||||
wood = "#8a6a48"
|
||||
g2 = "#22432f"
|
||||
green = "#2a5639"
|
||||
sandp = "#6b6047"
|
||||
poolw = "#2f8fc4"
|
||||
garden = "#2f5e3a"
|
||||
hedge = "#1f4a2a"
|
||||
padel = "#2a5d9c"
|
||||
court = "#8f5236"
|
||||
dry = "#335c3b"
|
||||
dryedge = "rgba(170, 215, 160, .55)"
|
||||
courtl = "rgba(240, 244, 250, .8)"
|
||||
nets = "#3cc8c0"
|
||||
sport = "#b08654"
|
||||
plotl = "rgba(200, 225, 190, .2)"
|
||||
netw = "#4fb3ff"
|
||||
nete = "#ffae34"
|
||||
cable = "#11141a"
|
||||
bed = "#5568a8"
|
||||
kitchenc = "#9d968a"
|
||||
wardrobe = "#6d5a45"
|
||||
evc = "#2fae68"
|
||||
kerbm = "#6b717d"
|
||||
pillar = "#8a8580"
|
||||
wallp = "#6f6c67"
|
||||
wallt = "#8f8b85"
|
||||
stpw = "#3f6b4a"
|
||||
concb = "#646a78"
|
||||
moto = "#c8352b"
|
||||
car5 = "#2fae68"
|
||||
guard = "#34507a"
|
||||
gym = "#39414f"
|
||||
playp = "#e0b030"
|
||||
playd = "#d0453f"
|
||||
slide = "#2f7de1"
|
||||
pole = "#9aa0aa"
|
||||
lounger = "#b9b3a6"
|
||||
tower = "#8fa3bd"
|
||||
ringl = "rgba(235, 238, 242, .55)"
|
||||
genset = "#56685c"
|
||||
|
||||
[palette.light]
|
||||
sky1 = "#a9d4fb"
|
||||
sky2 = "#e8f4ff"
|
||||
star = "transparent"
|
||||
shade = "#263040"
|
||||
kl = "86%"
|
||||
kc = "78%"
|
||||
kr = "68%"
|
||||
edge = "rgba(36, 46, 62, .6)"
|
||||
edgew = ".3"
|
||||
grass = "#95c477"
|
||||
dirt = "#8f6c4c"
|
||||
grid = "rgba(20, 60, 20, .07)"
|
||||
asph = "#6b717c"
|
||||
conc = "#c3c7ce"
|
||||
pad = "#b6bbc3"
|
||||
path = "#d6d0c1"
|
||||
dash = "#ffffff"
|
||||
kerb = "#9aa0aa"
|
||||
bayline = "#f4f6f8"
|
||||
hazard = "#e7b70f"
|
||||
plinth = "#b5bac3"
|
||||
wall = "#e3e7ec"
|
||||
wall2 = "#dce1e7"
|
||||
roof = "#f1f4f8"
|
||||
roof2 = "#e9edf2"
|
||||
roof3 = "#ece6da"
|
||||
roofin = "#ddd6c8"
|
||||
office = "#efe8db"
|
||||
yardb = "#d6dbe2"
|
||||
cut = "#565d6a"
|
||||
firewall = "#cf8a82"
|
||||
floor = "#d4d8df"
|
||||
part = "#6f7889"
|
||||
mark = "#d39d00"
|
||||
trench = "#8d939c"
|
||||
glass = "#8fc0e6"
|
||||
glasso = ".85"
|
||||
glassw = "#5aa6e6"
|
||||
glasswo = ".14"
|
||||
win = "#86b3dc"
|
||||
winlit = "#9ac3e6"
|
||||
door = "#858e9c"
|
||||
dock = "#b6bdc7"
|
||||
open = "#444a55"
|
||||
pv = "#22448d"
|
||||
pvl = "#8aa8e2"
|
||||
glint = "#ffffff"
|
||||
led-r = "#d63b1f"
|
||||
led-g = "#18a35a"
|
||||
led-o = "#d98300"
|
||||
fire = "#f07b1c"
|
||||
lamp = "#fff2c4"
|
||||
lampo = "0"
|
||||
steam = "#ffffff"
|
||||
smoke = "#8d9097"
|
||||
steel = "#a9b2bf"
|
||||
press = "#46546c"
|
||||
press2 = "#5a6984"
|
||||
ex50 = "#2f7de1"
|
||||
ex35 = "#9b5de5"
|
||||
hopper = "#dde2e8"
|
||||
classic = "#1fae68"
|
||||
vinyl = "#1b1e25"
|
||||
hivis = "#f2b600"
|
||||
head = "#e2b892"
|
||||
galv = "#6ab3bd"
|
||||
galv2 = "#7cc2cb"
|
||||
bath = "#2fbf98"
|
||||
sibert = "#ccd5df"
|
||||
bench = "#c8b28c"
|
||||
rack = "#d76b32"
|
||||
carton = "#c59b64"
|
||||
crane = "#f2c200"
|
||||
col = "#9aa4b3"
|
||||
rail = "#d6b000"
|
||||
boilere = "#eef0f4"
|
||||
boilerb = "#c9523b"
|
||||
padc = "#c1c5cc"
|
||||
padempty = "#b1b6be"
|
||||
chiller = "#dbe0e6"
|
||||
screen = "#a0a9b6"
|
||||
bess = "#f2f4f7"
|
||||
inv = "#5b6370"
|
||||
trafo = "#7d8794"
|
||||
water = "#9cb9d6"
|
||||
firetank = "#d0453f"
|
||||
stack = "#b9bfc8"
|
||||
chips = "#b88b4c"
|
||||
tank = "#a7b4c5"
|
||||
silok = "#2b2f37"
|
||||
siloy = "#e9c22e"
|
||||
silor = "#d0453f"
|
||||
canopy = "#a8b0bd"
|
||||
car1 = "#d0453f"
|
||||
car2 = "#2f7de1"
|
||||
car3 = "#f3f5f8"
|
||||
car4 = "#3a3f47"
|
||||
glassd = "#3d4c62"
|
||||
cab = "#ffffff"
|
||||
truckbox = "#2f6fd0"
|
||||
chassis = "#2a2e36"
|
||||
sacks = "#efe6cf"
|
||||
fence = "#7d8794"
|
||||
fenceo = ".25"
|
||||
gate = "#9aa3b1"
|
||||
boom = "#e0412f"
|
||||
ac = "#ced3da"
|
||||
trunk = "#6e5238"
|
||||
leaf = "#4c9a4b"
|
||||
person = "#f2b600"
|
||||
tagbg = "rgba(255, 255, 255, .93)"
|
||||
tagline = "#a9b8cc"
|
||||
tagfg = "#1b2533"
|
||||
signbg = "#ffe7a6"
|
||||
signline = "#c27c00"
|
||||
signfg = "#5b3b00"
|
||||
warnbg = "#fdeaea"
|
||||
warnline = "#c62828"
|
||||
warnfg = "#8b1a1a"
|
||||
tipbg = "#ffffff"
|
||||
tipfg = "#1b2533"
|
||||
tipline = "#cfd8e6"
|
||||
tipmute = "#5b6880"
|
||||
btn = "#ffffff"
|
||||
btnfg = "#1b2533"
|
||||
btnline = "#cfd8e6"
|
||||
btnon = "#e1f3e8"
|
||||
accent = "#0e8f4f"
|
||||
sel = "#0e8f4f"
|
||||
hov = "#2f7de1"
|
||||
mute = "#5b6880"
|
||||
water1 = "#6cc4ef"
|
||||
water2 = "#135f9a"
|
||||
watero = ".4"
|
||||
surf = "#7fd0f5"
|
||||
surfo = ".55"
|
||||
moon = "#a8e4ff"
|
||||
earth = "#a07b57"
|
||||
earth2 = "#8a6748"
|
||||
strata = "rgba(60, 35, 10, .22)"
|
||||
secconc = "#b9bec7"
|
||||
tile = "#a9d6e8"
|
||||
tile2 = "#8cc2d8"
|
||||
sand = "#e3d2a6"
|
||||
sand2 = "#c9b688"
|
||||
deep = "#0b3558"
|
||||
coral1 = "#ff7a5c"
|
||||
coral2 = "#c26bdc"
|
||||
coral3 = "#f2c14e"
|
||||
rock = "#8f8377"
|
||||
kelp = "#4fb07a"
|
||||
suit = "#262c36"
|
||||
tankc = "#f2b600"
|
||||
fin = "#1fa3c0"
|
||||
silt = "#8a6a3e"
|
||||
stal = "#dccaa4"
|
||||
stone = "#8d8378"
|
||||
acryl = "#9ad8f7"
|
||||
acrylo = ".3"
|
||||
deck = "#dfe5ec"
|
||||
aqua = "#57b9e3"
|
||||
dine = "#b59a80"
|
||||
guest1 = "#e07a5f"
|
||||
guest2 = "#5f9e80"
|
||||
guest3 = "#e9b965"
|
||||
guest4 = "#5d74c9"
|
||||
cloth = "#ffffff"
|
||||
songthaew = "#d23b2f"
|
||||
umb1 = "#e85d4a"
|
||||
umb2 = "#f2c14e"
|
||||
umb3 = "#36c0d8"
|
||||
chamber = "#f2f4f7"
|
||||
guide = "#ffffff"
|
||||
render = "#f4efe6"
|
||||
render2 = "#e3e1dc"
|
||||
rooft = "#5d6574"
|
||||
fascia = "#4b5261"
|
||||
wood = "#a77d55"
|
||||
g2 = "#9fcd7f"
|
||||
green = "#b5dc96"
|
||||
sandp = "#ead9a8"
|
||||
poolw = "#63c3ee"
|
||||
garden = "#c4e6a4"
|
||||
hedge = "#4f8a45"
|
||||
padel = "#3a7fd0"
|
||||
court = "#d9825b"
|
||||
dry = "#a6cf88"
|
||||
dryedge = "rgba(60, 110, 50, .6)"
|
||||
courtl = "rgba(255, 255, 255, .92)"
|
||||
nets = "#108a84"
|
||||
sport = "#d9ad70"
|
||||
plotl = "rgba(70, 105, 50, .38)"
|
||||
netw = "#1f78d1"
|
||||
nete = "#e07a10"
|
||||
cable = "#2a2f38"
|
||||
bed = "#6f86c9"
|
||||
kitchenc = "#e3ddd2"
|
||||
wardrobe = "#b08d6a"
|
||||
evc = "#1fae68"
|
||||
kerbm = "#c5c9cf"
|
||||
pillar = "#cfc9c0"
|
||||
wallp = "#d8d3cb"
|
||||
wallt = "#ebe7e0"
|
||||
stpw = "#6f9f78"
|
||||
concb = "#b9bec7"
|
||||
moto = "#d0453f"
|
||||
car5 = "#1fae68"
|
||||
guard = "#34507a"
|
||||
gym = "#4b5563"
|
||||
playp = "#f2c200"
|
||||
playd = "#e0412f"
|
||||
slide = "#2f7de1"
|
||||
pole = "#b9bfc8"
|
||||
lounger = "#f1ece0"
|
||||
tower = "#d6e2ef"
|
||||
ringl = "rgba(255, 255, 255, .95)"
|
||||
genset = "#7f9a86"
|
||||
|
||||
[materials]
|
||||
warn = "#e0412f"
|
||||
suit = "suit"
|
||||
dirt = "dirt"
|
||||
g-grass = "grass"
|
||||
g-asph = "asph"
|
||||
g-conc = "conc"
|
||||
g-pad = "pad"
|
||||
g-path = "path"
|
||||
plinth = "plinth"
|
||||
wall = "wall"
|
||||
wall2 = "wall2"
|
||||
roof = "roof"
|
||||
roof2 = "roof2"
|
||||
roof3 = "roof3"
|
||||
office = "office"
|
||||
yardb = "yardb"
|
||||
cut = "cut"
|
||||
firewall = "firewall"
|
||||
conc = "plinth"
|
||||
steel = "steel"
|
||||
press = "press"
|
||||
press2 = "press2"
|
||||
ex50 = "ex50"
|
||||
ex35 = "ex35"
|
||||
hopper = "hopper"
|
||||
classic = "classic"
|
||||
vinyl = "vinyl"
|
||||
hivis = "hivis"
|
||||
guide = "accent"
|
||||
visitor = "#d77a5a"
|
||||
visitor2 = "#7a8fd8"
|
||||
galv = "galv"
|
||||
galv2 = "galv2"
|
||||
bath = "bath"
|
||||
sibert = "sibert"
|
||||
bench = "bench"
|
||||
desk = "bench"
|
||||
table = "bench"
|
||||
sofa = "#6b5a8e"
|
||||
rackf = "rack"
|
||||
shelf = "steel"
|
||||
archive = "#7d8aa0"
|
||||
carton = "carton"
|
||||
crane = "crane"
|
||||
hoist = "#b98f00"
|
||||
hook = "#39404c"
|
||||
col = "col"
|
||||
rail = "rail"
|
||||
boilere = "boilere"
|
||||
boilerb = "boilerb"
|
||||
panel = "#3a4352"
|
||||
pad = "padc"
|
||||
padempty = "padempty"
|
||||
chiller = "chiller"
|
||||
screen = "screen"
|
||||
bess = "bess"
|
||||
inv = "inv"
|
||||
trafo = "trafo"
|
||||
water = "water"
|
||||
firetank = "firetank"
|
||||
stack = "stack"
|
||||
stackband = "#b8413a"
|
||||
chips = "chips"
|
||||
tank = "tank"
|
||||
evap = "#9aa8bb"
|
||||
softener = "#4f7fc0"
|
||||
silok = "silok"
|
||||
siloy = "siloy"
|
||||
silor = "silor"
|
||||
frame = "steel"
|
||||
bigbag = "#e8e2d0"
|
||||
canopy = "canopy"
|
||||
car1 = "car1"
|
||||
car2 = "car2"
|
||||
car3 = "car3"
|
||||
car4 = "car4"
|
||||
glassd = "glassd"
|
||||
cab = "cab"
|
||||
truckbox = "truckbox"
|
||||
chassis = "chassis"
|
||||
sacks = "sacks"
|
||||
gate = "gate"
|
||||
boom = "boom"
|
||||
ac = "ac"
|
||||
lampost = "#4a5160"
|
||||
roofcap = "#6d7889"
|
||||
roofcap2 = "roof2"
|
||||
locker = "#6d7f99"
|
||||
printer = "#d5d9df"
|
||||
mill = "#5d7aa0"
|
||||
granul = "#4c8a8f"
|
||||
instr = "#dfe3e8"
|
||||
instr2 = "#4b6a9c"
|
||||
optic = "#d9dee5"
|
||||
dome = "#2c3442"
|
||||
bin = "#b8413a"
|
||||
speaker = "#2b2f37"
|
||||
shrink = "#9aa4b2"
|
||||
cass = "#333a46"
|
||||
cdtower = "#c7ccd4"
|
||||
forklift = "#e3a400"
|
||||
pump = "#3f78c0"
|
||||
motor = "#59606b"
|
||||
accu = "#c9473a"
|
||||
comp = "#4f8d58"
|
||||
oil = "#b88a2a"
|
||||
belt = "#2b313b"
|
||||
hx = "#8aa0b8"
|
||||
lathe = "#3c4658"
|
||||
stair = "#9aa0aa"
|
||||
lift = "#737b88"
|
||||
rail2 = "steel"
|
||||
server = "#27303f"
|
||||
shower = "#5f9fd6"
|
||||
wc = "#8e9aa9"
|
||||
sanit = "#eef2f5"
|
||||
cubicle = "#a7b9c9"
|
||||
sign = "signline"
|
||||
fanbox = "#6b7585"
|
||||
bushing = "#c9b8a4"
|
||||
pipes = "#c9473a"
|
||||
pipec = "#3f78c0"
|
||||
pipec2 = "#6aa8ff"
|
||||
fence = "fence"
|
||||
pal-k = "#1f2228"
|
||||
pal-w = "#e6eaef"
|
||||
pal-r = "#d0453f"
|
||||
pal-y = "#e8c22e"
|
||||
pal-b = "#2f7de1"
|
||||
pal-g = "#2fae68"
|
||||
pal-p = "#9b5de5"
|
||||
pal-o = "#f08a2a"
|
||||
pal-bx = "carton"
|
||||
leaf = "leaf"
|
||||
z-staff = "color-mix(in srgb, var(--iso-floor) 82%, #b48ae0)"
|
||||
z-galv = "color-mix(in srgb, var(--iso-floor) 76%, #4fc3d0)"
|
||||
z-lab = "color-mix(in srgb, var(--iso-floor) 80%, #a77ad8)"
|
||||
z-prep = "color-mix(in srgb, var(--iso-floor) 80%, #d8c76a)"
|
||||
z-colour = "color-mix(in srgb, var(--iso-floor) 82%, #e07a4a)"
|
||||
z-plant = "color-mix(in srgb, var(--iso-floor) 78%, #5fbf72)"
|
||||
z-pack = "color-mix(in srgb, var(--iso-floor) 84%, #c8a070)"
|
||||
z-corr = "color-mix(in srgb, var(--iso-floor) 74%, #9fd0ff)"
|
||||
z-arch = "color-mix(in srgb, var(--iso-floor) 76%, #6aa8ff)"
|
||||
z-core = "color-mix(in srgb, var(--iso-floor) 88%, #c9ced6)"
|
||||
z-hall = "color-mix(in srgb, var(--iso-office) 55%, var(--iso-floor))"
|
||||
z-staff2 = "color-mix(in srgb, var(--iso-floor) 80%, #7fa8d8)"
|
||||
z-kitchen = "color-mix(in srgb, var(--iso-floor) 76%, #c89a6a)"
|
||||
z-wc = "color-mix(in srgb, var(--iso-floor) 80%, #8fbfb4)"
|
||||
z-offices = "color-mix(in srgb, var(--iso-floor) 76%, #8fb0e0)"
|
||||
z-it = "color-mix(in srgb, var(--iso-floor) 70%, #4f7fc0)"
|
||||
z-pantry = "color-mix(in srgb, var(--iso-floor) 74%, #e0b070)"
|
||||
z-flex = "color-mix(in srgb, var(--iso-floor) 80%, #b8a8e0)"
|
||||
z-print = "color-mix(in srgb, var(--iso-floor) 74%, #d08ad0)"
|
||||
z-stock = "color-mix(in srgb, var(--iso-floor) 76%, #e0cc90)"
|
||||
z-packmat = "color-mix(in srgb, var(--iso-floor) 78%, #90d0a0)"
|
||||
render = "render"
|
||||
render2 = "render2"
|
||||
rooft = "rooft"
|
||||
fascia = "fascia"
|
||||
wood = "wood"
|
||||
bed = "bed"
|
||||
kitchenc = "kitchenc"
|
||||
wardrobe = "wardrobe"
|
||||
evc = "evc"
|
||||
kerbm = "kerbm"
|
||||
pillar = "pillar"
|
||||
wallp = "wallp"
|
||||
wallt = "wallt"
|
||||
stpw = "stpw"
|
||||
concb = "concb"
|
||||
moto = "moto"
|
||||
car5 = "car5"
|
||||
guard = "guard"
|
||||
gym = "gym"
|
||||
playp = "playp"
|
||||
playd = "playd"
|
||||
slide = "slide"
|
||||
pole = "pole"
|
||||
lounger = "lounger"
|
||||
songthaew = "songthaew"
|
||||
umb1 = "umb1"
|
||||
umb2 = "umb2"
|
||||
tower = "tower"
|
||||
guest1 = "guest1"
|
||||
guest2 = "guest2"
|
||||
guest3 = "guest3"
|
||||
guest4 = "guest4"
|
||||
bin2 = "#2fae68"
|
||||
bin3 = "#e8c22e"
|
||||
g-grass2 = "g2"
|
||||
g-green = "green"
|
||||
g-sand = "sandp"
|
||||
poolw = "poolw"
|
||||
g-garden = "garden"
|
||||
hedge = "hedge"
|
||||
g-padel = "padel"
|
||||
g-court = "court"
|
||||
g-dry = "dry"
|
||||
z-sport = "color-mix(in srgb, var(--iso-floor) 40%, var(--iso-sport))"
|
||||
z-living = "color-mix(in srgb, var(--iso-floor) 64%, #c8a070)"
|
||||
z-bed = "color-mix(in srgb, var(--iso-floor) 70%, #d8b88a)"
|
||||
z-garage = "color-mix(in srgb, var(--iso-floor) 88%, #8d96a8)"
|
||||
genset = "genset"
|
||||
|
||||
[rules]
|
||||
".iso-ground .iso-dirt" = "stroke: var(--iso-edge); stroke-width: .4"
|
||||
".iso-svg .iso-grid" = "fill: none; stroke: var(--iso-grid); stroke-width: .5"
|
||||
".iso-svg .iso-dash" = "fill: none; stroke: var(--iso-dash); stroke-width: .6; stroke-dasharray: 8 8"
|
||||
".iso-svg .iso-kerb" = "fill: none; stroke: var(--iso-kerb); stroke-width: .6"
|
||||
".iso-svg .iso-bayline" = "fill: none; stroke: var(--iso-bayline); stroke-width: .45; opacity: .8"
|
||||
".iso-svg .iso-hazard" = "fill: var(--iso-hazard); opacity: .85"
|
||||
".iso-svg .iso-buried" = "fill: none; stroke: var(--iso-mark); stroke-width: .5; stroke-dasharray: 2 3; opacity: .7"
|
||||
".iso-svg .iso-pool" = "opacity: var(--iso-lampo); pointer-events: none"
|
||||
".iso-pool0" = "stop-color: var(--iso-lamp); stop-opacity: .55"
|
||||
".iso-pool1" = "stop-color: var(--iso-lamp); stop-opacity: 0"
|
||||
".iso-svg .iso-headc" = "fill: var(--iso-head)"
|
||||
".iso-svg .iso-fence, .iso-svg .iso-fence.iso-t" = "fill-opacity: var(--iso-fenceo); --b: var(--iso-fence)"
|
||||
".iso-svg .iso-glassw" = "--b: var(--iso-glassw); fill-opacity: var(--iso-glasswo)"
|
||||
".iso-svg path.iso-glassw.iso-t" = "fill-opacity: .5"
|
||||
".iso-svg .iso-win" = "fill: var(--iso-win); stroke: none"
|
||||
".iso-svg .iso-win.iso-lit" = "fill: var(--iso-winlit)"
|
||||
".iso-svg .iso-glass" = "fill: var(--iso-glass); fill-opacity: var(--iso-glasso); stroke: none"
|
||||
".iso-svg .iso-door" = "fill: var(--iso-door); stroke: none"
|
||||
".iso-svg .iso-door.iso-on" = "fill: color-mix(in srgb, var(--iso-door) 70%, var(--iso-winlit))"
|
||||
".iso-svg .iso-door2" = "fill: var(--iso-dock); stroke: var(--iso-edge); stroke-width: .3"
|
||||
".iso-svg .iso-dock" = "fill: var(--iso-dock); stroke: var(--iso-hazard); stroke-width: .5"
|
||||
".iso-svg .iso-open" = "fill: var(--iso-open); stroke: none"
|
||||
".iso-svg .iso-louvre, .iso-svg .iso-fins, .iso-svg .iso-grille" = "fill: var(--iso-open); fill-opacity: .55; stroke: none"
|
||||
".iso-svg .iso-slats" = "fill: var(--iso-open); fill-opacity: .35; stroke: none"
|
||||
".iso-svg .iso-bessdoor" = "fill: none; stroke: var(--iso-edge); stroke-width: .35"
|
||||
".iso-svg .iso-chipsin" = "fill: var(--iso-chips); stroke: none"
|
||||
".iso-svg .iso-fire" = "fill: var(--iso-fire); stroke: none"
|
||||
".iso-svg .iso-boxes" = "fill: #b79b6a; fill-opacity: .75; stroke: none"
|
||||
".iso-svg .iso-pv" = "fill: var(--iso-pv); stroke: var(--iso-pvl); stroke-width: .28"
|
||||
".iso-svg .iso-roofin" = "fill: var(--iso-roofin); stroke: none"
|
||||
".iso-svg .iso-seam" = "fill: none; stroke: var(--iso-edge); stroke-width: .3; opacity: .55"
|
||||
".iso-svg .iso-reserve" = "fill: var(--iso-mark); fill-opacity: .05; stroke: var(--iso-mark); stroke-width: .6; stroke-dasharray: 4 3; opacity: .7"
|
||||
".iso-svg .iso-ventc" = "fill: #1d222b"
|
||||
".iso-svg .iso-tankring" = "fill: none; stroke: var(--iso-edge); stroke-width: .4"
|
||||
".iso-svg .iso-fanring" = "fill: #252b35"
|
||||
".iso-svg .iso-sink" = "fill: #263241"
|
||||
".iso-svg .iso-cathode" = "fill: #7f8b9c"
|
||||
".iso-svg .iso-vinylc" = "fill: #101217"
|
||||
".iso-svg .iso-platter" = "fill: #9aa3b1"
|
||||
".iso-svg .iso-lacquer" = "fill: #1a1d24"
|
||||
".iso-svg .iso-lab1" = "fill: #ffcf4a"
|
||||
".iso-svg .iso-lab2" = "fill: #ff6b6b"
|
||||
".iso-svg .iso-lab3" = "fill: #6aa8ff"
|
||||
".iso-svg .iso-lab4" = "fill: #f2f2f2"
|
||||
".iso-svg .iso-screenm" = "--b: #2a3343"
|
||||
".iso-svg .iso-steps" = "fill: none; stroke: var(--iso-edge); stroke-width: .35"
|
||||
".iso-svg .iso-later" = "fill: none; stroke: var(--iso-mark); stroke-width: .4; stroke-dasharray: 1.5 1.5"
|
||||
".iso-svg .iso-trunk" = "fill: var(--iso-trunk); stroke: none"
|
||||
".iso-svg .iso-rope" = "fill: none; stroke: #2a2f38; stroke-width: .5"
|
||||
".iso-svg .iso-lamp" = "fill: var(--iso-lamp); stroke: none"
|
||||
"html[data-theme=light] .iso-svg .iso-lamp" = "fill: #c9ced6"
|
||||
".iso-z-floor, .iso-z-press, .iso-z-aisle, .iso-z-yard" = "--b: var(--iso-floor)"
|
||||
".iso-z-shop, .iso-z-shop2" = "--b: color-mix(in srgb, var(--iso-floor) 82%, #e0c26a)"
|
||||
".iso-z-trim, .iso-z-qc" = "--b: color-mix(in srgb, var(--iso-floor) 80%, #6aa8ff)"
|
||||
".iso-z-wh, .iso-z-disp" = "--b: color-mix(in srgb, var(--iso-floor) 84%, #8fa6c8)"
|
||||
".iso-z-lobby, .iso-z-canteen" = "--b: color-mix(in srgb, var(--iso-office) 70%, var(--iso-floor))"
|
||||
".iso-z-cut, .iso-z-listen" = "--b: color-mix(in srgb, var(--iso-floor) 72%, #6b4a8e)"
|
||||
".iso-z-meet, .iso-z-meet2" = "--b: color-mix(in srgb, var(--iso-floor) 74%, #7fcf98)"
|
||||
".iso-svg .iso-part" = "fill: none; stroke: var(--iso-part); stroke-width: .9; stroke-linecap: square"
|
||||
".iso-svg .iso-mark" = "fill: none; stroke: var(--iso-mark); stroke-width: .7; stroke-dasharray: 3 2"
|
||||
".iso-svg .iso-trench" = "fill: var(--iso-trench); stroke: none"
|
||||
".iso-svg .iso-trenchl" = "fill: none; stroke: var(--iso-trench); stroke-width: 2.2"
|
||||
".iso-svg .iso-grate" = "fill: none; stroke: var(--iso-part); stroke-width: .35"
|
||||
".iso-svg .iso-slot" = "fill: none; stroke: var(--iso-mark); stroke-width: .6"
|
||||
".iso-svg .iso-slot.iso-empty" = "stroke-dasharray: 3 2.4; fill: var(--iso-mark); fill-opacity: .06"
|
||||
".iso-svg .iso-cell" = "fill: none; stroke: var(--iso-hivis); stroke-width: .45; stroke-dasharray: 1.4 1.4; opacity: .8"
|
||||
".iso-svg .iso-bay" = "fill: var(--iso-mark); fill-opacity: .1; stroke: var(--iso-mark); stroke-width: .5; stroke-dasharray: 2 2"
|
||||
".iso-svg .iso-void" = "fill: var(--iso-open); stroke: none"
|
||||
".iso-svg .iso-lining" = "fill: none; stroke: #8a6fb0; stroke-width: 1.4; opacity: .75"
|
||||
".iso-svg .iso-ringl" = "fill: none; stroke: var(--iso-ringl); stroke-width: .6"
|
||||
".iso-svg .iso-glassp, .iso-svg .iso-glassp.iso-t" = "fill-opacity: .3; --b: #a9d8ec"
|
||||
".iso-svg .iso-netb, .iso-svg .iso-netb.iso-t" = "fill-opacity: .55; --b: #eef1f5"
|
||||
".iso-svg .iso-courtl" = "fill: none; stroke: var(--iso-courtl); stroke-width: .55"
|
||||
".iso-svg .iso-dryedge" = "fill: none; stroke: var(--iso-dryedge); stroke-width: .8; stroke-dasharray: 4 3"
|
||||
".iso-svg .iso-nets" = "fill: none; stroke: var(--iso-nets); stroke-width: 1.4; stroke-dasharray: 6 2 1 2"
|
||||
".iso-svg .iso-slatsw" = "--b: var(--iso-wood); fill-opacity: .6"
|
||||
".iso-svg .iso-plotl" = "fill: none; stroke: var(--iso-plotl); stroke-width: .5"
|
||||
".iso-svg .iso-netw" = "fill: none; stroke: var(--iso-netw); stroke-width: 1.4; stroke-dasharray: 5 3"
|
||||
".iso-svg .iso-nete" = "fill: none; stroke: var(--iso-nete); stroke-width: 1.4; stroke-dasharray: 2 2"
|
||||
".iso-svg .iso-cable" = "fill: none; stroke: var(--iso-cable); stroke-width: .5"
|
||||
Reference in new issue
Block a user