Files
isokit/crates/isokit-core/src/render.rs
T
grabowski c91d63663b
Deploy web Studio / deploy (push) Successful in 4m31s
Streets: sidewalks, road curves, junctions and roundabouts
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.
2026-10-25 12:32:52 +07:00

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);
}
}