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

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