isokit: project layer, Studio GUI, MCP server, viewer, CLI, demo project and docs

This commit is contained in:
grabowski committed 2026-10-22 15:29:07 +07:00
1 parent 95c80ec6b1
commit 564aeb75f6
47 files changed
+5978 -366

No files matched your search

+2 -2
View File
@@ -16,7 +16,7 @@ pub fn r1(out: &mut String, v: f64) {
}
let a = t.unsigned_abs();
let _ = write!(out, "{}", a / 10);
if a % 10 != 0 {
if !a.is_multiple_of(10) {
let _ = write!(out, ".{}", a % 10);
}
}
@@ -65,7 +65,7 @@ pub fn thousands(n: f64) -> String {
let s = v.unsigned_abs().to_string();
let mut o = String::new();
for (i, ch) in s.chars().enumerate() {
if i > 0 && (s.len() - i) % 3 == 0 {
if i > 0 && (s.len() - i).is_multiple_of(3) {
o.push(',');
}
o.push(ch);
+89 -14
View File
@@ -46,7 +46,10 @@ pub struct Style {
impl Style {
pub fn mat(mat: impl Into<String>) -> Self {
Style { mat: mat.into(), ..Default::default() }
Style {
mat: mat.into(),
..Default::default()
}
}
pub fn id(mut self, id: impl Into<String>) -> Self {
self.id = Some(id.into());
@@ -75,44 +78,116 @@ impl Style {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Prim {
/// Vertical prism over a convex counter-clockwise footprint.
Prism { pts: Vec<P2>, z0: Zs, z1: Zs, st: Style },
Prism {
pts: Vec<P2>,
z0: Zs,
z1: Zs,
st: Style,
},
/// Upright cylinder (two side faces and an elliptic top).
Cyl { c: P2, r: f64, z0: f64, z1: f64, st: Style },
Cyl {
c: P2,
r: f64,
z0: f64,
z1: f64,
st: Style,
},
/// Frustum (cone) with n sides: radius r0 at z0, r1 at z1.
Frust { c: P2, r0: f64, r1: f64, z0: f64, z1: f64, n: u32, st: Style },
Frust {
c: P2,
r0: f64,
r1: f64,
z0: f64,
z1: f64,
n: u32,
st: Style,
},
/// Openings (windows, doors) on the vertical face through a -> b; the
/// outward normal is on the right of a -> b. Each entry is
/// [u0, u1, z0, z1]: distance along the face and heights.
Open { a: P2, b: P2, list: Vec<[f64; 4]>, cls: String, id: Option<String> },
Open {
a: P2,
b: P2,
list: Vec<[f64; 4]>,
cls: String,
id: Option<String>,
},
/// Flat polygon(s) at height z; later polygons cut holes (even-odd).
Flat { polys: Vec<Vec<P2>>, z: f64, cls: String, id: Option<String> },
Flat {
polys: Vec<Vec<P2>>,
z: f64,
cls: String,
id: Option<String>,
},
/// Line segments [x0, y0, x1, y1] at height z.
Lines { segs: Vec<[f64; 4]>, z: f64, cls: String, id: Option<String> },
Lines {
segs: Vec<[f64; 4]>,
z: f64,
cls: String,
id: Option<String>,
},
/// Flat ellipse (a circle on the ground plane) at height z.
Disc { c: P2, z: f64, r: f64, cls: String, id: Option<String> },
Disc {
c: P2,
z: f64,
r: f64,
cls: String,
id: Option<String>,
},
/// Planar polygon in 3D (roof slopes, gables, ramps), shaded by its
/// normal: mostly upward = top, else left/centre/right by direction.
/// One-sided faces (counter-clockwise seen from outside) are culled
/// when turned away; two-sided faces always draw.
Face { pts: Vec<P3>, mat: String, id: Option<String>, two_sided: bool },
Face {
pts: Vec<P3>,
mat: String,
id: Option<String>,
two_sided: bool,
},
/// 3D polygons merged into one path with a fixed class (PV modules,
/// signs, markings on slopes): no shading, no culling.
Poly { polys: Vec<Vec<P3>>, cls: String, id: Option<String> },
Poly {
polys: Vec<Vec<P3>>,
cls: String,
id: Option<String>,
},
/// Screen-space circle of radius r (screen units) at a world point,
/// shifted by off (screen units): heads, lamps, LEDs, tree crowns.
Circle { at: P3, r: f64, off: P2, cls: String, id: Option<String> },
Circle {
at: P3,
r: f64,
off: P2,
cls: String,
id: Option<String>,
},
/// Screen-space vertical strip of half width w between two heights:
/// trunks, thin poles.
Stem { at: P2, z0: f64, z1: f64, w: f64, cls: String, id: Option<String> },
Stem {
at: P2,
z0: f64,
z1: f64,
w: f64,
cls: String,
id: Option<String>,
},
/// Rising steam or smoke puffs (CSS animation).
Puffs { at: P3, kind: String, n: u32, dur: f64 },
Puffs {
at: P3,
kind: String,
n: u32,
dur: f64,
},
/// Spinning fan lying in the plane at height at.z (CSS animation).
Fan { at: P3, r: f64 },
/// Raw SVG translated to a world point (or untranslated without one).
/// `{uid}` in the text is replaced by the view's unique id.
Raw { at: Option<P3>, svg: String },
/// `<g class style>` around nested primitives (animation, ghosting).
Group { cls: String, style: String, items: Vec<Prim> },
Group {
cls: String,
style: String,
items: Vec<Prim>,
},
/// Items drawn back to front by the depth of their plan anchor.
Seq { items: Vec<(P2, Vec<Prim>)> },
}
+7 -2
View File
@@ -18,7 +18,9 @@ pub struct Proj {
impl Proj {
pub fn new(rot: i64) -> Self {
Proj { rot: rot.rem_euclid(4) as u8 }
Proj {
rot: rot.rem_euclid(4) as u8,
}
}
/// World plan point to view coordinates.
@@ -147,7 +149,10 @@ mod tests {
let p = Proj::new(rot);
let s = p.s(12.5, -3.25, 2.0);
let w = p.unproject(s[0], s[1], 2.0);
assert!((w[0] - 12.5).abs() < 1e-9 && (w[1] + 3.25).abs() < 1e-9, "rot {rot}: {w:?}");
assert!(
(w[0] - 12.5).abs() < 1e-9 && (w[1] + 3.25).abs() < 1e-9,
"rot {rot}: {w:?}"
);
}
}
+159 -25
View File
@@ -24,7 +24,11 @@ pub struct RenderOpts {
impl Default for RenderOpts {
fn default() -> Self {
RenderOpts { uid: 0, labels: true, dev: false }
RenderOpts {
uid: 0,
labels: true,
dev: false,
}
}
}
@@ -101,7 +105,9 @@ impl Ctx<'_> {
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 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;
@@ -109,14 +115,23 @@ impl Ctx<'_> {
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)]],
&[
[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();
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("\"/>");
@@ -150,7 +165,10 @@ impl Ctx<'_> {
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}\"/>");
let _ = write!(
self.out,
" cx=\"{bx}\" cy=\"{ty}\" rx=\"{rxs}\" ry=\"{rys}\"/>"
);
}
}
@@ -168,7 +186,9 @@ impl Ctx<'_> {
if st.no_side {
continue;
}
let Some(v) = p.vis(am.cos(), am.sin()) else { 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,
@@ -194,7 +214,9 @@ impl Ctx<'_> {
return;
}
let p = self.p;
let Some(v) = p.vis(b[1] - a[1], a[0] - b[0]) else { return };
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);
@@ -262,7 +284,14 @@ impl Ctx<'_> {
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));
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
@@ -296,7 +325,11 @@ impl Ctx<'_> {
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) };
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);
@@ -307,12 +340,31 @@ impl Ctx<'_> {
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::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::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;
@@ -324,7 +376,13 @@ impl Ctx<'_> {
}
self.out.push_str("\"/>");
}
Prim::Circle { at, r, off, cls, id } => {
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());
@@ -336,7 +394,14 @@ impl Ctx<'_> {
r1s(*r)
);
}
Prim::Stem { at, z0, z1, w, cls, id } => {
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());
@@ -355,7 +420,12 @@ impl Ctx<'_> {
}
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 _ = 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" {
@@ -396,7 +466,12 @@ impl Ctx<'_> {
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]));
let _ = write!(
self.out,
"<g transform=\"translate({} {})\">{svg}</g>",
r1s(s[0]),
r1s(s[1])
);
}
None => self.out.push_str(&svg),
}
@@ -457,7 +532,11 @@ pub fn boxes(s: &Sprite, p: Proj) -> (Bb, Sb, bool) {
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 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;
@@ -494,7 +573,12 @@ pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered {
} else {
(0..live.len()).collect()
};
let _ = write!(cx.out, "<g class=\"{}\" data-layer=\"{}\">", esc(&layer.opts.cls), esc(&layer.name));
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;
@@ -583,7 +667,13 @@ pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered {
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 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],
@@ -627,7 +717,12 @@ mod tests {
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_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"));
@@ -641,14 +736,26 @@ mod tests {
// 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);
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() });
let r = render(
&sc,
1,
&RenderOpts {
labels: false,
..Default::default()
},
);
assert!(r.html.contains(">Gate<"));
assert!(!r.html.contains(">Hall<"));
}
@@ -683,9 +790,29 @@ mod tests {
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);
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);
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"));
@@ -698,7 +825,14 @@ mod tests {
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() });
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);
}
+76 -13
View File
@@ -17,7 +17,11 @@ pub struct LayerOpts {
impl Default for LayerOpts {
fn default() -> Self {
LayerOpts { sort: true, zfirst: false, cls: "iso-sprites".into() }
LayerOpts {
sort: true,
zfirst: false,
cls: "iso-sprites".into(),
}
}
}
@@ -78,9 +82,25 @@ impl Scene {
pub fn new() -> Self {
Scene {
layers: vec![
Layer { name: "ground".into(), opts: LayerOpts { sort: false, zfirst: false, cls: "iso-ground".into() }, sprites: vec![] },
Layer { name: "floor".into(), opts: LayerOpts::default(), sprites: vec![] },
Layer { name: "main".into(), opts: LayerOpts::default(), sprites: vec![] },
Layer {
name: "ground".into(),
opts: LayerOpts {
sort: false,
zfirst: false,
cls: "iso-ground".into(),
},
sprites: vec![],
},
Layer {
name: "floor".into(),
opts: LayerOpts::default(),
sprites: vec![],
},
Layer {
name: "main".into(),
opts: LayerOpts::default(),
sprites: vec![],
},
],
tags: vec![],
info: BTreeMap::new(),
@@ -101,8 +121,17 @@ impl Scene {
self.layers[i].opts = opts;
return i;
}
let at = before.and_then(|b| self.layer_index(b)).unwrap_or(self.layers.len());
self.layers.insert(at, Layer { name: name.into(), opts, sprites: vec![] });
let at = before
.and_then(|b| self.layer_index(b))
.unwrap_or(self.layers.len());
self.layers.insert(
at,
Layer {
name: name.into(),
opts,
sprites: vec![],
},
);
at
}
@@ -119,13 +148,39 @@ impl Scene {
self.layer_mut(layer).sprites.push(s);
}
pub fn tag(&mut self, at: P3, text: impl Into<String>, prio: f64, id: impl Into<String>, kind: impl Into<String>) {
self.tags.push(Tag { at, text: text.into(), prio, id: id.into(), kind: kind.into() });
pub fn tag(
&mut self,
at: P3,
text: impl Into<String>,
prio: f64,
id: impl Into<String>,
kind: impl Into<String>,
) {
self.tags.push(Tag {
at,
text: text.into(),
prio,
id: id.into(),
kind: kind.into(),
});
}
pub fn info(&mut self, id: impl Into<String>, name: impl Into<String>, detail: impl Into<String>) {
pub fn info(
&mut self,
id: impl Into<String>,
name: impl Into<String>,
detail: impl Into<String>,
) {
let id = id.into();
self.info.insert(id.clone(), Info { id, name: name.into(), detail: detail.into(), rows: vec![] });
self.info.insert(
id.clone(),
Info {
id,
name: name.into(),
detail: detail.into(),
rows: vec![],
},
);
}
pub fn sprite_count(&self) -> usize {
@@ -137,7 +192,9 @@ impl Scene {
use crate::prim::Prim;
fn walk(p: &Prim, out: &mut std::collections::BTreeSet<String>) {
let id = match p {
Prim::Prism { st, .. } | Prim::Cyl { st, .. } | Prim::Frust { st, .. } => st.id.clone(),
Prim::Prism { st, .. } | Prim::Cyl { st, .. } | Prim::Frust { st, .. } => {
st.id.clone()
}
Prim::Open { id, .. }
| Prim::Flat { id, .. }
| Prim::Lines { id, .. }
@@ -151,7 +208,10 @@ impl Scene {
None
}
Prim::Seq { items } => {
items.iter().flat_map(|(_, v)| v.iter()).for_each(|q| walk(q, out));
items
.iter()
.flat_map(|(_, v)| v.iter())
.for_each(|q| walk(q, out));
None
}
_ => None,
@@ -174,7 +234,10 @@ impl Scene {
/// The default focus point: the explicit one, else the bounds centre.
pub fn focus_point(&self) -> Option<P3> {
self.focus.or_else(|| self.bounds.map(|(b, _)| [(b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0, 0.0]))
self.focus.or_else(|| {
self.bounds
.map(|(b, _)| [(b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0, 0.0])
})
}
}
+21 -4
View File
@@ -86,7 +86,10 @@ pub fn sort(boxes: &[(Bb, Sb)], zf: bool) -> Order {
}
}
}
let mut out = Order { order: Vec::with_capacity(n), cycles: 0 };
let mut out = Order {
order: Vec::with_capacity(n),
cycles: 0,
};
for _ in 0..n {
let mut best: Option<usize> = None;
for i in 0..n {
@@ -102,7 +105,9 @@ pub fn sort(boxes: &[(Bb, Sb)], zf: bool) -> Order {
}
best = match best {
None => Some(i),
Some(b) if indeg[i] < indeg[b] || (indeg[i] == indeg[b] && key[i] < key[b]) => Some(i),
Some(b) if indeg[i] < indeg[b] || (indeg[i] == indeg[b] && key[i] < key[b]) => {
Some(i)
}
keep => keep,
};
}
@@ -128,14 +133,26 @@ mod tests {
#[test]
fn separated_on_x_draws_far_first() {
let o = sort(&[bx(5.0, 0.0, 6.0, 1.0, 0.0, 1.0), bx(0.0, 0.0, 1.0, 1.0, 0.0, 1.0)], false);
let o = sort(
&[
bx(5.0, 0.0, 6.0, 1.0, 0.0, 1.0),
bx(0.0, 0.0, 1.0, 1.0, 0.0, 1.0),
],
false,
);
assert_eq!(o.order, vec![1, 0]);
assert_eq!(o.cycles, 0);
}
#[test]
fn stacked_boxes_draw_bottom_first() {
let o = sort(&[bx(0.0, 0.0, 1.0, 1.0, 1.0, 2.0), bx(0.0, 0.0, 1.0, 1.0, 0.0, 1.0)], false);
let o = sort(
&[
bx(0.0, 0.0, 1.0, 1.0, 1.0, 2.0),
bx(0.0, 0.0, 1.0, 1.0, 0.0, 1.0),
],
false,
);
assert_eq!(o.order, vec![1, 0]);
}
+200 -32
View File
@@ -63,7 +63,12 @@ impl Xf {
pub fn rect(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> [f64; 4] {
let a = self.p(x0, y0);
let b = self.p(x1, y1);
[a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]
[
a[0].min(b[0]),
a[1].min(b[1]),
a[0].max(b[0]),
a[1].max(b[1]),
]
}
pub fn pts(&self, pts: &[P2]) -> Vec<P2> {
pts.iter().map(|q| self.p(q[0], q[1])).collect()
@@ -76,8 +81,15 @@ impl Xf {
#[derive(Clone, Debug)]
enum Frame {
Group { cls: String, style: String, items: Vec<Prim> },
Seq { items: Vec<(P2, Vec<Prim>)>, cur: Option<(P2, Vec<Prim>)> },
Group {
cls: String,
style: String,
items: Vec<Prim>,
},
Seq {
items: Vec<(P2, Vec<Prim>)>,
cur: Option<(P2, Vec<Prim>)>,
},
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
@@ -101,6 +113,9 @@ pub struct Sprite {
frames: Vec<Frame>,
}
/// One item of [`Sprite::seq`]: a plan anchor and the drawing.
pub type SeqItem<'a> = (P2, Box<dyn FnOnce(&mut Sprite) + 'a>);
/// Points of an axis-aligned rectangle, counter-clockwise from (x0, y0).
pub fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec<P2> {
vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]]
@@ -108,7 +123,11 @@ pub fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec<P2> {
impl Sprite {
pub fn new(id: impl Into<String>, cls: impl Into<String>) -> Self {
Sprite { id: id.into(), cls: cls.into(), ..Default::default() }
Sprite {
id: id.into(),
cls: cls.into(),
..Default::default()
}
}
pub fn with_frame(mut self, xf: Xf) -> Self {
@@ -141,7 +160,10 @@ impl Sprite {
pub fn ext_box(&mut self, x0: f64, y0: f64, x1: f64, y1: f64, z0: f64, z1: f64) -> &mut Self {
for z in [z0, z1] {
self.ext(x0, y0, z).ext(x1, y0, z).ext(x1, y1, z).ext(x0, y1, z);
self.ext(x0, y0, z)
.ext(x1, y0, z)
.ext(x1, y1, z)
.ext(x0, y1, z);
}
self
}
@@ -156,13 +178,27 @@ impl Sprite {
/// Axis-aligned box (`box` in scripts; `box` is a Rust keyword).
#[allow(clippy::too_many_arguments)]
pub fn cuboid(&mut self, x0: f64, y0: f64, x1: f64, y1: f64, z0: f64, z1: f64, st: Style) -> &mut Self {
pub fn cuboid(
&mut self,
x0: f64,
y0: f64,
x1: f64,
y1: f64,
z0: f64,
z1: f64,
st: Style,
) -> &mut Self {
let r = self.xf.rect(x0, y0, x1, y1);
let (z0, z1) = (self.xf.z(z0), self.xf.z(z1));
let pts = rect(r[0], r[1], r[2], r[3]);
self.ext_world(&pts, z0);
self.ext_world(&pts, z1);
self.push(Prim::Prism { pts, z0: Zs::One(z0), z1: Zs::One(z1), st })
self.push(Prim::Prism {
pts,
z0: Zs::One(z0),
z1: Zs::One(z1),
st,
})
}
pub fn prism(&mut self, pts: Vec<P2>, z0: Zs, z1: Zs, st: Style) -> &mut Self {
@@ -188,30 +224,73 @@ impl Sprite {
}
#[allow(clippy::too_many_arguments)]
pub fn frust(&mut self, cx: f64, cy: f64, r0: f64, r1: f64, z0: f64, z1: f64, n: u32, st: Style) -> &mut Self {
pub fn frust(
&mut self,
cx: f64,
cy: f64,
r0: f64,
r1: f64,
z0: f64,
z1: f64,
n: u32,
st: Style,
) -> &mut Self {
let r = r0.max(r1);
self.ext_box(cx - r, cy - r, cx + r, cy + r, z0, z1);
let c = self.xf.p(cx, cy);
let (z0, z1) = (self.xf.z(z0), self.xf.z(z1));
self.push(Prim::Frust { c, r0, r1, z0, z1, n: n.max(3), st })
self.push(Prim::Frust {
c,
r0,
r1,
z0,
z1,
n: n.max(3),
st,
})
}
/// Sloped slab (stair flight, ramp) over x0..x1, rising from y0 (za) to
/// y1 (zb), th thick.
#[allow(clippy::too_many_arguments)]
pub fn slope(&mut self, x0: f64, x1: f64, y0: f64, y1: f64, za: f64, zb: f64, th: f64, mat: &str) -> &mut Self {
pub fn slope(
&mut self,
x0: f64,
x1: f64,
y0: f64,
y1: f64,
za: f64,
zb: f64,
th: f64,
mat: &str,
) -> &mut Self {
let zt = vec![za, za, zb, zb];
let zb_: Vec<f64> = zt.iter().map(|z| z - th).collect();
self.prism(rect(x0, y0, x1, y1), Zs::Each(zb_), Zs::Each(zt), Style::mat(mat))
self.prism(
rect(x0, y0, x1, y1),
Zs::Each(zb_),
Zs::Each(zt),
Style::mat(mat),
)
}
// ---------------------------------------------------------- surfaces
/// Openings on the vertical face a -> b (outward normal on the right).
pub fn open(&mut self, a: P2, b: P2, list: Vec<[f64; 4]>, cls: &str, id: Option<String>) -> &mut Self {
pub fn open(
&mut self,
a: P2,
b: P2,
list: Vec<[f64; 4]>,
cls: &str,
id: Option<String>,
) -> &mut Self {
let (a, b) = (self.xf.p(a[0], a[1]), self.xf.p(b[0], b[1]));
let dz = self.xf.z;
let list: Vec<[f64; 4]> = list.into_iter().map(|q| [q[0], q[1], q[2] + dz, q[3] + dz]).collect();
let list: Vec<[f64; 4]> = list
.into_iter()
.map(|q| [q[0], q[1], q[2] + dz, q[3] + dz])
.collect();
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l = (dx * dx + dy * dy).sqrt().max(1e-9);
let (ux, uy) = (dx / l, dy / l);
@@ -219,16 +298,33 @@ impl Sprite {
self.ext.push([a[0] + ux * q[0], a[1] + uy * q[0], q[2]]);
self.ext.push([a[0] + ux * q[1], a[1] + uy * q[1], q[3]]);
}
self.push(Prim::Open { a, b, list, cls: cls.into(), id })
self.push(Prim::Open {
a,
b,
list,
cls: cls.into(),
id,
})
}
pub fn flat(&mut self, polys: Vec<Vec<P2>>, z: f64, cls: &str, id: Option<String>) -> &mut Self {
pub fn flat(
&mut self,
polys: Vec<Vec<P2>>,
z: f64,
cls: &str,
id: Option<String>,
) -> &mut Self {
let polys: Vec<Vec<P2>> = polys.iter().map(|p| self.xf.pts(p)).collect();
let z = self.xf.z(z);
for p in &polys {
self.ext_world(p, z);
}
self.push(Prim::Flat { polys, z, cls: cls.into(), id })
self.push(Prim::Flat {
polys,
z,
cls: cls.into(),
id,
})
}
pub fn lines(&mut self, segs: Vec<[f64; 4]>, z: f64, cls: &str) -> &mut Self {
@@ -245,30 +341,67 @@ impl Sprite {
self.ext.push([s[0], s[1], z]);
self.ext.push([s[2], s[3], z]);
}
self.push(Prim::Lines { segs, z, cls: cls.into(), id: None })
self.push(Prim::Lines {
segs,
z,
cls: cls.into(),
id: None,
})
}
pub fn disc(&mut self, cx: f64, cy: f64, z: f64, r: f64, cls: &str, id: Option<String>) -> &mut Self {
pub fn disc(
&mut self,
cx: f64,
cy: f64,
z: f64,
r: f64,
cls: &str,
id: Option<String>,
) -> &mut Self {
self.ext_box(cx - r, cy - r, cx + r, cy + r, z, z);
let c = self.xf.p(cx, cy);
let z = self.xf.z(z);
self.push(Prim::Disc { c, z, r, cls: cls.into(), id })
self.push(Prim::Disc {
c,
z,
r,
cls: cls.into(),
id,
})
}
/// Planar 3D polygon shaded by its normal (see [`Prim::Face`]).
pub fn face(&mut self, pts: Vec<P3>, mat: &str, id: Option<String>, two_sided: bool) -> &mut Self {
pub fn face(
&mut self,
pts: Vec<P3>,
mat: &str,
id: Option<String>,
two_sided: bool,
) -> &mut Self {
let pts: Vec<P3> = pts.into_iter().map(|p| self.xf.p3(p)).collect();
self.ext.extend(pts.iter().copied());
self.push(Prim::Face { pts, mat: mat.into(), id, two_sided })
self.push(Prim::Face {
pts,
mat: mat.into(),
id,
two_sided,
})
}
/// 3D polygons as one path with a fixed class.
pub fn poly(&mut self, polys: Vec<Vec<P3>>, cls: &str, id: Option<String>) -> &mut Self {
let polys: Vec<Vec<P3>> = polys.into_iter().map(|p| p.into_iter().map(|q| self.xf.p3(q)).collect()).collect();
let polys: Vec<Vec<P3>> = polys
.into_iter()
.map(|p| p.into_iter().map(|q| self.xf.p3(q)).collect())
.collect();
for p in &polys {
self.ext.extend(p.iter().copied());
}
self.push(Prim::Poly { polys, cls: cls.into(), id })
self.push(Prim::Poly {
polys,
cls: cls.into(),
id,
})
}
// ---------------------------------------------------------- screen-space details
@@ -277,14 +410,27 @@ impl Sprite {
/// sticks out of the sprite).
pub fn circle(&mut self, at: P3, r: f64, off: P2, cls: &str) -> &mut Self {
let at = self.xf.p3(at);
self.push(Prim::Circle { at, r, off, cls: cls.into(), id: None })
self.push(Prim::Circle {
at,
r,
off,
cls: cls.into(),
id: None,
})
}
pub fn stem(&mut self, x: f64, y: f64, z0: f64, z1: f64, w: f64, cls: &str) -> &mut Self {
self.ext(x, y, z0).ext(x, y, z1);
let at = self.xf.p(x, y);
let (z0, z1) = (self.xf.z(z0), self.xf.z(z1));
self.push(Prim::Stem { at, z0, z1, w, cls: cls.into(), id: None })
self.push(Prim::Stem {
at,
z0,
z1,
w,
cls: cls.into(),
id: None,
})
}
/// A head (ball of radius r metres) centred at z.
@@ -307,13 +453,22 @@ impl Sprite {
}
pub fn led(&mut self, x: f64, y: f64, z: f64, cls: &str) -> &mut Self {
let c = if cls.is_empty() { "iso-led".to_string() } else { format!("iso-led {cls}") };
let c = if cls.is_empty() {
"iso-led".to_string()
} else {
format!("iso-led {cls}")
};
self.circle([x, y, z], 0.9, [0.0, 0.0], &c)
}
pub fn puffs(&mut self, x: f64, y: f64, z: f64, kind: &str, n: u32, dur: f64) -> &mut Self {
let at = self.xf.p3([x, y, z]);
self.push(Prim::Puffs { at, kind: kind.into(), n: n.max(1), dur })
self.push(Prim::Puffs {
at,
kind: kind.into(),
n: n.max(1),
dur,
})
}
pub fn fan(&mut self, x: f64, y: f64, z: f64, r: f64) -> &mut Self {
@@ -323,14 +478,21 @@ impl Sprite {
pub fn raw(&mut self, at: Option<P3>, svg: impl Into<String>) -> &mut Self {
let at = at.map(|a| self.xf.p3(a));
self.push(Prim::Raw { at, svg: svg.into() })
self.push(Prim::Raw {
at,
svg: svg.into(),
})
}
// ---------------------------------------------------------- structure
/// Open a `<g class style>`; close it with [`Sprite::end_group`].
pub fn begin_group(&mut self, cls: &str, style: &str) -> &mut Self {
self.frames.push(Frame::Group { cls: cls.into(), style: style.into(), items: vec![] });
self.frames.push(Frame::Group {
cls: cls.into(),
style: style.into(),
items: vec![],
});
self
}
@@ -344,7 +506,10 @@ impl Sprite {
/// Start a back-to-front sequence; each [`Sprite::seq_item`] starts an
/// item anchored at a plan point; [`Sprite::end_seq`] closes it.
pub fn begin_seq(&mut self) -> &mut Self {
self.frames.push(Frame::Seq { items: vec![], cur: None });
self.frames.push(Frame::Seq {
items: vec![],
cur: None,
});
self
}
@@ -370,7 +535,7 @@ impl Sprite {
}
/// Draw items back to front by the depth of their anchor.
pub fn seq(&mut self, items: Vec<(P2, Box<dyn FnOnce(&mut Sprite) + '_>)>) -> &mut Self {
pub fn seq(&mut self, items: Vec<SeqItem<'_>>) -> &mut Self {
self.begin_seq();
for (at, f) in items {
self.seq_item(at);
@@ -442,7 +607,10 @@ mod tests {
let f = Xf::new(3.0, -4.0, 0.0, deg);
let w = f.p(1.5, 2.5);
let l = f.inv(w[0], w[1]);
assert!((l[0] - 1.5).abs() < 1e-12 && (l[1] - 2.5).abs() < 1e-12, "{deg}");
assert!(
(l[0] - 1.5).abs() < 1e-12 && (l[1] - 2.5).abs() < 1e-12,
"{deg}"
);
}
}