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

+107 -25
View File
@@ -6,7 +6,10 @@ use isokit_core::{render, RenderOpts};
use serde_json::{json, Map, Value};
fn rt(main: &str) -> Runtime {
let mut b = Bundle { main: Some("scene.rhai".into()), ..Default::default() };
let mut b = Bundle {
main: Some("scene.rhai".into()),
..Default::default()
};
b.sources.insert("scene.rhai".into(), main.into());
Runtime::new(&b).unwrap_or_else(|e| panic!("{e}"))
}
@@ -16,7 +19,14 @@ fn cfg() -> Map<String, Value> {
}
fn obj(id: &str, kind: &str, x: f64, y: f64, props: Value) -> Object {
Object { id: id.into(), kind: kind.into(), x, y, props: props.as_object().cloned().unwrap_or_default(), ..Default::default() }
Object {
id: id.into(),
kind: kind.into(),
x,
y,
props: props.as_object().cloned().unwrap_or_default(),
..Default::default()
}
}
#[test]
@@ -59,17 +69,29 @@ fn seq_group_and_closures() {
let b = r.build("site", 0, &cfg(), &Doc::default()).unwrap();
let out = render(&b.scene, 0, &RenderOpts::default());
// from the south-east the box at x = 5 is nearer, so it is drawn after the one at x = 0
let (a, c) = (out.html.find("iso-wall").unwrap(), out.html.find("iso-roof").unwrap());
let (a, c) = (
out.html.find("iso-wall").unwrap(),
out.html.find("iso-roof").unwrap(),
);
assert!(a < c);
assert!(out.html.contains("<g class=\"iso-a iso-drive\">"));
}
#[test]
fn errors_point_at_the_line() {
let mut b = Bundle { main: Some("scene.rhai".into()), ..Default::default() };
b.sources.insert("scene.rhai".into(), "let s = sprite(\"a\");\n\ns.box(0, 0, 1, 1, 0, \"high\", \"wall\");\n".into());
let mut b = Bundle {
main: Some("scene.rhai".into()),
..Default::default()
};
b.sources.insert(
"scene.rhai".into(),
"let s = sprite(\"a\");\n\ns.box(0, 0, 1, 1, 0, \"high\", \"wall\");\n".into(),
);
let r = Runtime::new(&b).unwrap();
let e = r.build("site", 0, &cfg(), &Doc::default()).err().expect("error");
let e = r
.build("site", 0, &cfg(), &Doc::default())
.err()
.expect("error");
assert_eq!(e.file, "scene.rhai");
assert_eq!(e.line, Some(3), "{e}");
assert!(e.message.contains("number"), "{e}");
@@ -81,9 +103,18 @@ fn errors_point_at_the_line() {
#[test]
fn imports_resolve_from_the_bundle() {
let mut b = Bundle { main: Some("scene.rhai".into()), ..Default::default() };
b.sources.insert("lib/kiosk.rhai".into(), "fn kiosk(x, y) { sprite(\"kiosk\").box(x, y, x + 2, y + 2, 0, 2.5, \"wall\"); }".into());
b.sources.insert("scene.rhai".into(), "import \"lib/kiosk\" as k;\nk::kiosk(1, 2);".into());
let mut b = Bundle {
main: Some("scene.rhai".into()),
..Default::default()
};
b.sources.insert(
"lib/kiosk.rhai".into(),
"fn kiosk(x, y) { sprite(\"kiosk\").box(x, y, x + 2, y + 2, 0, 2.5, \"wall\"); }".into(),
);
b.sources.insert(
"scene.rhai".into(),
"import \"lib/kiosk\" as k;\nk::kiosk(1, 2);".into(),
);
let r = Runtime::new(&b).unwrap();
let s = r.build("site", 0, &cfg(), &Doc::default()).unwrap().scene;
assert_eq!(s.sprite_count(), 1);
@@ -91,7 +122,11 @@ fn imports_resolve_from_the_bundle() {
#[test]
fn catalog_lists_library_and_project_prefabs() {
let mut b = Bundle { main: None, prelude: vec!["lib/kiosk.rhai".into()], ..Default::default() };
let mut b = Bundle {
main: None,
prelude: vec!["lib/kiosk.rhai".into()],
..Default::default()
};
b.sources.insert(
"lib/kiosk.rhai".into(),
r#"
@@ -102,7 +137,9 @@ fn catalog_lists_library_and_project_prefabs() {
);
let r = Runtime::new(&b).unwrap();
let kinds: Vec<&str> = r.catalog().iter().map(|m| m.kind.as_str()).collect();
for k in ["site", "area", "road", "building", "tree", "car", "person", "tank", "kiosk"] {
for k in [
"site", "area", "road", "building", "tree", "car", "person", "tank", "kiosk",
] {
assert!(kinds.contains(&k), "{k} missing from {kinds:?}");
}
let k = r.prefab("kiosk").unwrap();
@@ -115,7 +152,13 @@ fn catalog_lists_library_and_project_prefabs() {
fn document_objects_are_placed_in_their_frame() {
let r = Runtime::new(&Bundle::default()).unwrap();
let mut doc = Doc::default();
let mut b = obj("hall", "building", 30.0, 20.0, json!({"w": 10, "d": 6, "floors": 1}));
let mut b = obj(
"hall",
"building",
30.0,
20.0,
json!({"w": 10, "d": 6, "floors": 1}),
);
b.rot = 90.0;
b.name = "Main hall".into();
b.tag = "Hall".into();
@@ -123,23 +166,37 @@ fn document_objects_are_placed_in_their_frame() {
doc.objects.push(obj("t1", "tree", 5.0, 5.0, json!({})));
let built = r.build("roof", 1, &cfg(), &doc).unwrap();
// turned a quarter: 6 m along x, 10 m along y, centred on (30, 20)
let bx = *built.boxes.get("hall").unwrap_or_else(|| panic!("{:?}", built.scene.warnings));
assert!((bx[0] - 27.0).abs() < 1e-9 && (bx[3] - 33.0).abs() < 1e-9, "{bx:?}");
assert!((bx[1] - 15.0).abs() < 1e-9 && (bx[4] - 25.0).abs() < 1e-9, "{bx:?}");
let bx = *built
.boxes
.get("hall")
.unwrap_or_else(|| panic!("{:?}", built.scene.warnings));
assert!(
(bx[0] - 27.0).abs() < 1e-9 && (bx[3] - 33.0).abs() < 1e-9,
"{bx:?}"
);
assert!(
(bx[1] - 15.0).abs() < 1e-9 && (bx[4] - 25.0).abs() < 1e-9,
"{bx:?}"
);
let out = render(&built.scene, 1, &RenderOpts::default());
assert!(out.html.contains("data-obj=\"hall\""));
assert!(out.html.contains("data-obj=\"t1\""));
assert_eq!(out.info["hall"].name, "Main hall");
assert_eq!(out.info["t1"].name, "Tree");
assert!(out.html.contains(">Hall<"));
assert!(built.scene.warnings.is_empty(), "{:?}", built.scene.warnings);
assert!(
built.scene.warnings.is_empty(),
"{:?}",
built.scene.warnings
);
}
#[test]
fn levels_cut_buildings_open_and_filter_objects() {
let r = Runtime::new(&Bundle::default()).unwrap();
let mut doc = Doc::default();
doc.objects.push(obj("b", "building", 0.0, 0.0, json!({"floors": 2})));
doc.objects
.push(obj("b", "building", 0.0, 0.0, json!({"floors": 2})));
let mut desk = obj("desk", "tree", 0.0, 0.0, json!({}));
desk.levels = vec!["gf".into()];
doc.objects.push(desk);
@@ -153,7 +210,10 @@ fn levels_cut_buildings_open_and_filter_objects() {
let html = render(&gf.scene, 0, &RenderOpts::default()).html;
assert!(html.contains("iso-z-floor iso-t"));
let html = render(&up.scene, 0, &RenderOpts::default()).html;
assert!(html.contains("class=\"iso-dim\""), "ghosted above the top storey");
assert!(
html.contains("class=\"iso-dim\""),
"ghosted above the top storey"
);
}
#[test]
@@ -172,13 +232,26 @@ fn every_library_prefab_renders_cleanly_in_every_rotation() {
}
for level in ["site", "gf", "1f"] {
for rot in 0..4 {
let b = r.build(level, rot, &cfg(), &doc).unwrap_or_else(|e| panic!("{level}/{rot}: {e}"));
assert!(b.scene.warnings.is_empty(), "{level}/{rot}: {:?}", b.scene.warnings);
let b = r
.build(level, rot, &cfg(), &doc)
.unwrap_or_else(|e| panic!("{level}/{rot}: {e}"));
assert!(
b.scene.warnings.is_empty(),
"{level}/{rot}: {:?}",
b.scene.warnings
);
let out = render(&b.scene, rot, &RenderOpts::default());
assert!(!out.html.contains("NaN"), "{level}/{rot}");
for m in r.catalog() {
let id = format!("o{}", r.catalog().iter().position(|q| q.kind == m.kind).unwrap());
assert!(b.boxes.contains_key(&id), "{} drew nothing in {level}/{rot}", m.kind);
let id = format!(
"o{}",
r.catalog().iter().position(|q| q.kind == m.kind).unwrap()
);
assert!(
b.boxes.contains_key(&id),
"{} drew nothing in {level}/{rot}",
m.kind
);
}
}
}
@@ -186,8 +259,15 @@ fn every_library_prefab_renders_cleanly_in_every_rotation() {
#[test]
fn unknown_types_and_failing_prefabs_warn_but_render() {
let mut b = Bundle { main: None, prelude: vec!["lib/bad.rhai".into()], ..Default::default() };
b.sources.insert("lib/bad.rhai".into(), "fn prefab_bad(o) { sprite().box(0, 0, 1, 1, 0, o.p.nope, \"wall\"); }".into());
let mut b = Bundle {
main: None,
prelude: vec!["lib/bad.rhai".into()],
..Default::default()
};
b.sources.insert(
"lib/bad.rhai".into(),
"fn prefab_bad(o) { sprite().box(0, 0, 1, 1, 0, o.p.nope, \"wall\"); }".into(),
);
let r = Runtime::new(&b).unwrap();
let mut doc = Doc::default();
doc.objects.push(obj("u", "ufo", 0.0, 0.0, json!({})));
@@ -202,7 +282,9 @@ fn unknown_types_and_failing_prefabs_warn_but_render() {
#[test]
fn cfg_reaches_the_script() {
let r = rt(r#"if cfg("big") { sprite("a").box(0, 0, 9, 9, 0, 9, "wall"); } else { sprite("a").box(0, 0, 1, 1, 0, 1, "wall"); }"#);
let r = rt(
r#"if cfg("big") { sprite("a").box(0, 0, 9, 9, 0, 9, "wall"); } else { sprite("a").box(0, 0, 1, 1, 0, 1, "wall"); }"#,
);
let mut c = Map::new();
c.insert("big".into(), json!(true));
let b = r.build("site", 0, &c, &Doc::default()).unwrap();