Deploy web Studio / deploy (push) Successful in 4m31s
Roads take sidewalks (none, both, left, right): 12 cm high paving with kerb faces drawn where they face the viewer. Road curve (radius, angle, left/right) carries a street round a bend; centre lines are true arcs when cut and offset, so curve joints are seamless. A road ending on another road's centre line opens that road's kerb and sidewalk (T junction); two roads ending at one corner close it. Roundabout: ring radius and width, one or two lanes, up to four arms with flared kerbs, splitter islands, give-way lines on the entry side (traffic on the left or right) and a central island with apron. Studio: a road run drawn corner by corner gets a curve in each corner (prefab meta `corner`). Tooltips list the streets each one meets. Engine: open `polyline` primitive (Prim::Poly open) so dashes run on round curves. examples/streets and a Streets demo in the web Studio; MCP guidance, docs. ROADMAP: backlog 5-7 done; multi-select and the hosted copy marked done. Tests: Rust 72, Studio 113, web 95.
959 lines
31 KiB
Rust
959 lines
31 KiB
Rust
//! 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<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("\"/>");
|
|
}
|
|
|
|
/// 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,
|
|
"<g class=\"iso-school\" transform=\"matrix({} {} {} {} {} {})\">",
|
|
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,
|
|
"<g transform=\"scale({} {})\"><g class=\"iso-a iso-swim\" style=\"animation-duration:{}s;animation-delay:-{}s{}\"><path class=\"iso-swimbox\" d=\"M{} {}L{} {}\"/><g transform=\"translate(1 0) scale({} {})\"><path class=\"iso-fish iso-{}\" d=\"{b}{t}\"/></g></g></g>",
|
|
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("</g>");
|
|
}
|
|
|
|
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,
|
|
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, "<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::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,
|
|
"<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,
|
|
}
|
|
}
|
|
|
|
/// A fish body nose first along +y (k = length, negative: along -y), l wide.
|
|
fn body_path(k: f64, l: f64) -> 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("<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 beams_show_three_faces_from_any_side() {
|
|
for (a, b, n) in [
|
|
([0.0, 0.0, 1.0], [4.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
|
|
([0.0, 0.0, 1.0], [0.0, -4.0, 1.0], [1.0, 0.0, 0.0]),
|
|
([0.0, 0.0, 0.0], [0.0, 0.0, 3.0], [1.0, 0.0, 0.0]),
|
|
([0.0, 0.0, 0.0], [2.0, 1.0, 3.0], [-1.0, 2.0, 0.0]),
|
|
] {
|
|
for rot in 0..4u8 {
|
|
let mut sc = Scene::new();
|
|
let mut s = Sprite::new("arm", "");
|
|
s.beam(a, b, n, 0.4, "iso-robot");
|
|
sc.add_sprite("main", s);
|
|
let html = render(&sc, rot, &RenderOpts::default()).html;
|
|
// three faces; two when one side is seen edge-on (an oblique bar)
|
|
let faces = html.matches("iso-robot ").count();
|
|
let oblique = a[0] != b[0] && a[2] != b[2];
|
|
assert!(
|
|
faces == 3 || (oblique && faces == 2),
|
|
"{a:?}->{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);
|
|
}
|
|
}
|