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,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 { .. }));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user