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