//! Projection of a scene to SVG for one view rotation. use crate::fmt::{esc, hash, r1, r1s, r3s, 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, } #[derive(Clone, Debug, Serialize)] pub struct Rendered { /// Inner SVG markup (defs, layers, tags). pub html: String, pub info: BTreeMap, /// 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, #[serde(skip_serializing_if = "Vec::is_empty")] pub order: Vec, } /// 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(""); } } 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, "" ); } } #[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], 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(" 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("\"/>"); } /// Fish in the plane at height z: one matrix maps the object's frame /// (metres, x along u) to the screen; each fish sits at (1, 0) of a /// unit circle that a CSS rotation turns and a scale stretches to its /// ellipse. An invisible diagonal centres the turning group's box on /// the circle's middle (`transform-box: fill-box`, as for the fan). fn swim(&mut self, c: P2, z: f64, u: P2, fish: &[crate::prim::Fish]) { if fish.is_empty() { return; } let p = self.p; let o = p.s(c[0], c[1], z); let a = p.s(c[0] + u[0], c[1] + u[1], z); let b = p.s(c[0] - u[1], c[1] + u[0], z); let _ = write!( self.out, "", r3s(a[0] - o[0]), r3s(a[1] - o[1]), r3s(b[0] - o[0]), r3s(b[1] - o[1]), r1s(o[0]), r1s(o[1]) ); for f in fish { let (rx, ry, l) = (f.rx.max(0.05), f.ry.max(0.05), f.len.max(0.05)); let e = 1.0 + l / rx.min(ry) + 0.05; // a fish nose first along +y (counter-clockwise) or -y let k = if f.cw { -l } else { l }; // body widest behind the head, a narrow tail root, a forked tail fin let (b, t) = (body_path(k, l), tail_path(k, l)); let _ = write!( self.out, "", r3s(rx), r3s(ry), r3s(f.dur.max(1.0)), r3s(f.delay.max(0.0)), if f.cw { ";animation-direction:reverse" } else { "" }, r3s(-e), r3s(-e), r3s(e), r3s(e), r3s(1.0 / rx), r3s(1.0 / ry), esc(&f.cls), ); } 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, "", 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, open, } => { 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, *open); } 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, "", 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, "", 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, ""); } self.out.push_str(""); } 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, "", C / 2.0, -C, C / 2.0, r1s(s[0]), r1s(s[1]), -a, -w, -a, -w, -a, -a ); } Prim::Swim { c, z, u, fish } => self.swim(*c, *z, *u, fish), 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, "{svg}", r1s(s[0]), r1s(s[1]) ); } None => self.out.push_str(&svg), } } Prim::Group { cls, style, items } => { self.out.push_str("'); for q in items { self.prim(q); } self.out.push_str(""); } Prim::Seq { items } => { let p = self.p; let mut idx: Vec = (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(""); for d in &scene.defs { cx.out.push_str(&d.replace("{uid}", &uid)); } cx.out.push_str(""); } 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 = 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, "", esc(&layer.opts.cls), esc(&layer.name) ); for i in idx { let s = live[i]; sprites += 1; cx.out.push_str("'); for pr in &s.prims { cx.prim(pr); } cx.out.push_str(""); } cx.out.push_str(""); } // tags cx.out.push_str(""); 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("{}", esc(&t.text) ); } cx.out.push_str(""); // 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(" String { format!( "M0 {}C{} {} {} {} {} {}L{} {}C{} {} {} {} 0 {}Z", r3s(0.5 * k), r3s(0.24 * l), r3s(0.38 * k), r3s(0.24 * l), r3s(-0.1 * k), r3s(0.05 * l), r3s(-0.28 * k), r3s(-0.05 * l), r3s(-0.28 * k), r3s(-0.24 * l), r3s(-0.1 * k), r3s(-0.24 * l), r3s(0.38 * k), r3s(0.5 * k) ) } /// The forked tail fin behind [`body_path`]. fn tail_path(k: f64, l: f64) -> String { format!( "M0 {}L{} {}L0 {}L{} {}Z", r3s(-0.24 * k), r3s(0.2 * l), r3s(-0.54 * k), r3s(-0.42 * k), r3s(-0.2 * l), r3s(-0.54 * k) ) } #[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(""), "{}", 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("{b:?} rot {rot}: {faces} faces" ); assert!( html.contains("iso-robot iso-t"), "{a:?}->{b:?} rot {rot}: no top" ); } } } #[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); } }