Deploy web Studio / deploy (push) Successful in 1m5s
Groups: objects of type "group" draw nothing; members carry the group's id in a new `group` field (groups nest). A hidden group hides everything in it (Doc::hidden_in_groups, skipped by the driver), a locked one locks it in the Studio. Removing a group moves its members up into its parent group. isokit check warns about members of missing groups. New `order` op: the whole object order at once (each id once), one undo step. Studio object list as a tree in the document order, front first: each group sits just after its members, so it draws as one block, and ground pieces higher in the list lie on top. Groups fold open and shut; every row has an eye and a lock (shown on hover or when set); a click on a group selects all of it, so a drag on the map moves it all. Group / Ungroup (Ctrl+G, Ctrl+Shift+G), Order up/down (Ctrl+] Ctrl+[, with Shift to the front / back), drag rows above, below or into a group. Group panel: name, zoom, ungroup, to front / back, lock, hide, delete with contents. MCP: group field in the object schema, order op, guidance. Tests: Rust 81 (ops: groups, order, removal; driver: nested hidden groups), Studio 131, web 100.
1366 lines
48 KiB
Rust
1366 lines
48 KiB
Rust
//! Runtime tests: the scripting API, prefabs, the document driver, errors.
|
|
|
|
use crate::doc::{Doc, Object};
|
|
use crate::{Bundle, Runtime};
|
|
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()
|
|
};
|
|
b.sources.insert("scene.rhai".into(), main.into());
|
|
Runtime::new(&b).unwrap_or_else(|e| panic!("{e}"))
|
|
}
|
|
|
|
fn cfg() -> Map<String, Value> {
|
|
Map::new()
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn script_draws_with_mixed_numbers() {
|
|
let r = rt(r#"
|
|
let s = sprite("hall");
|
|
s.box(0, 0, 10, 6.5, 0, 3, "wall", #{ top: "roof" });
|
|
info("hall", "Hall", "10 x 6.5 m");
|
|
tag(5, 3, 4, "Hall", 5);
|
|
"#);
|
|
let b = r.build("roof", 0, &cfg(), &Doc::default()).unwrap();
|
|
let out = render(&b.scene, 0, &RenderOpts::default());
|
|
assert!(out.html.contains("iso-roof iso-t"), "{}", out.html);
|
|
assert!(out.html.contains("iso-wall iso-l"));
|
|
assert!(out.html.contains(">Hall<"));
|
|
assert_eq!(out.info["hall"].detail, "10 x 6.5 m");
|
|
}
|
|
|
|
#[test]
|
|
fn methods_from_the_library_work_on_sprites() {
|
|
let r = rt(r#"
|
|
let s = sprite("t");
|
|
s.tree_at(0, 0, 1.5);
|
|
sprite("c").car(0, 0, 4.4, 1.8, "car2");
|
|
"#);
|
|
let b = r.build("site", 1, &cfg(), &Doc::default()).unwrap();
|
|
let out = render(&b.scene, 1, &RenderOpts::default());
|
|
assert!(out.html.contains("iso-leaf iso-l"));
|
|
assert!(out.html.contains("iso-glassd"));
|
|
}
|
|
|
|
#[test]
|
|
fn seq_group_and_closures() {
|
|
let r = rt(r#"
|
|
let s = sprite("x");
|
|
let h = 2.0;
|
|
s.seq([[0, 0, |q| q.box(0, 0, 1, 1, 0, h, "wall")], [5, 0, |q| q.box(5, 0, 6, 1, 0, h, "roof")]]);
|
|
s.group("a drive", |q| q.box(0, 3, 1, 4, 0, 1, "car1"));
|
|
"#);
|
|
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(),
|
|
);
|
|
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 r = Runtime::new(&b).unwrap();
|
|
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}");
|
|
|
|
b.sources.insert("scene.rhai".into(), "let x = ;".into());
|
|
let e = Runtime::new(&b).err().expect("parse error");
|
|
assert_eq!(e.line, Some(1), "{e}");
|
|
}
|
|
|
|
#[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 r = Runtime::new(&b).unwrap();
|
|
let s = r.build("site", 0, &cfg(), &Doc::default()).unwrap().scene;
|
|
assert_eq!(s.sprite_count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn catalog_lists_library_and_project_prefabs() {
|
|
let mut b = Bundle {
|
|
main: None,
|
|
prelude: vec!["lib/kiosk.rhai".into()],
|
|
..Default::default()
|
|
};
|
|
b.sources.insert(
|
|
"lib/kiosk.rhai".into(),
|
|
r#"
|
|
fn meta_kiosk() { #{ label: "Kiosk", category: "Shop", props: [ #{ key: "h", type: "float", def: 2.5 } ] } }
|
|
fn prefab_kiosk(o) { sprite().box(-1, -1, 1, 1, 0, o.p.h, "wall"); }
|
|
"#
|
|
.into(),
|
|
);
|
|
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",
|
|
] {
|
|
assert!(kinds.contains(&k), "{k} missing from {kinds:?}");
|
|
}
|
|
let k = r.prefab("kiosk").unwrap();
|
|
assert_eq!(k.label, "Kiosk");
|
|
assert_eq!(k.source, "lib/kiosk.rhai");
|
|
assert_eq!(k.props[0].default, json!(2.5));
|
|
}
|
|
|
|
#[test]
|
|
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}),
|
|
);
|
|
b.rot = 90.0;
|
|
b.name = "Main hall".into();
|
|
b.tag = "Hall".into();
|
|
doc.objects.push(b);
|
|
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 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
|
|
);
|
|
}
|
|
|
|
#[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})));
|
|
let mut desk = obj("desk", "tree", 0.0, 0.0, json!({}));
|
|
desk.levels = vec!["gf".into()];
|
|
doc.objects.push(desk);
|
|
let closed = r.build("roof", 0, &cfg(), &doc).unwrap();
|
|
let gf = r.build("gf", 0, &cfg(), &doc).unwrap();
|
|
let up = r.build("3f", 0, &cfg(), &doc).unwrap();
|
|
assert!(!closed.boxes.contains_key("desk"));
|
|
assert!(gf.boxes.contains_key("desk"));
|
|
// cut open: a floor sprite plus four wall sprites
|
|
assert!(gf.scene.sprite_count() > closed.scene.sprite_count());
|
|
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"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn every_library_prefab_renders_cleanly_in_every_rotation() {
|
|
let r = Runtime::new(&Bundle::default()).unwrap();
|
|
let mut doc = Doc::default();
|
|
for (i, m) in r.catalog().iter().enumerate() {
|
|
let mut o = obj(&format!("o{i}"), &m.kind, i as f64 * 15.0, 0.0, json!({}));
|
|
// switch every bool prop on to exercise all branches
|
|
for p in &m.props {
|
|
if p.kind == "bool" {
|
|
o.props.insert(p.key.clone(), json!(true));
|
|
}
|
|
}
|
|
doc.objects.push(o);
|
|
}
|
|
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 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
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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 r = Runtime::new(&b).unwrap();
|
|
let mut doc = Doc::default();
|
|
doc.objects.push(obj("u", "ufo", 0.0, 0.0, json!({})));
|
|
doc.objects.push(obj("x", "bad", 0.0, 0.0, json!({})));
|
|
doc.objects.push(obj("t", "tree", 5.0, 0.0, json!({})));
|
|
let built = r.build("site", 0, &cfg(), &doc).unwrap();
|
|
let w = built.scene.warnings.join("\n");
|
|
assert!(w.contains("object u: unknown type"), "{w}");
|
|
assert!(w.contains("object x:"), "{w}");
|
|
assert!(built.boxes.contains_key("t"));
|
|
}
|
|
|
|
#[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 mut c = Map::new();
|
|
c.insert("big".into(), json!(true));
|
|
let b = r.build("site", 0, &c, &Doc::default()).unwrap();
|
|
assert_eq!(b.scene.layers[2].sprites[0].world_box().unwrap()[5], 9.0);
|
|
}
|
|
|
|
/// A 4 x 3 m room: floor, four walls on its edges, a window and a door.
|
|
fn room_doc() -> Doc {
|
|
let mut doc = Doc::default();
|
|
doc.objects
|
|
.push(obj("floor", "floor", 0.0, 0.0, json!({"w": 4, "d": 3})));
|
|
for (id, x, y, rot, w) in [
|
|
("south", 0.0, -1.5, 0.0, 4.0),
|
|
("north", 0.0, 1.5, 0.0, 4.0),
|
|
("west", -2.0, 0.0, 90.0, 3.0),
|
|
("east", 2.0, 0.0, 90.0, 3.0),
|
|
] {
|
|
let mut o = obj(id, "house_wall", x, y, json!({ "w": w }));
|
|
o.rot = rot;
|
|
doc.objects.push(o);
|
|
}
|
|
// a little off the wall line: still on the south wall
|
|
doc.objects
|
|
.push(obj("win", "window", 0.5, -1.4, json!({"w": 1.2})));
|
|
let mut door = obj("door", "door", 2.05, 0.4, json!({"open": true}));
|
|
door.rot = 90.0;
|
|
doc.objects.push(door);
|
|
doc
|
|
}
|
|
|
|
fn sprites_of(b: &crate::Built, id: &str) -> usize {
|
|
b.scene
|
|
.layers
|
|
.iter()
|
|
.flat_map(|l| l.sprites.iter())
|
|
.filter(|s| s.obj.as_deref() == Some(id))
|
|
.count()
|
|
}
|
|
|
|
#[test]
|
|
fn windows_and_doors_sit_on_walls() {
|
|
let r = Runtime::new(&Bundle::default()).unwrap();
|
|
let doc = room_doc();
|
|
let b = r.build("site", 0, &cfg(), &doc).unwrap();
|
|
assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings);
|
|
// the window snaps onto the south wall's line and the wall leaves a gap
|
|
let w = b.boxes["win"];
|
|
assert!(
|
|
(w[1] + 1.575).abs() < 1e-6 && (w[4] + 1.425).abs() < 1e-6,
|
|
"{w:?}"
|
|
);
|
|
assert!(
|
|
(w[0] + 0.1).abs() < 1e-6 && (w[3] - 1.1).abs() < 1e-6,
|
|
"{w:?}"
|
|
);
|
|
assert_eq!(sprites_of(&b, "south"), 2, "wall split around the window");
|
|
assert_eq!(sprites_of(&b, "north"), 1);
|
|
assert_eq!(sprites_of(&b, "east"), 2, "wall split around the door");
|
|
// the open door swings into the room (rot 90 on a rot 90 wall: towards -x)
|
|
let d = b.boxes["door"];
|
|
assert!(d[0] < 1.5 && d[3] <= 2.08, "{d:?}");
|
|
let html = render(&b.scene, 0, &RenderOpts::default()).html;
|
|
assert!(html.contains("iso-glassp"));
|
|
// corners: the walls along x run through, the others stop at their faces
|
|
let (sw, ww) = (b.boxes["south"], b.boxes["west"]);
|
|
assert!(
|
|
(sw[0] + 2.075).abs() < 1e-6 && (sw[3] - 2.075).abs() < 1e-6,
|
|
"{sw:?}"
|
|
);
|
|
assert!(
|
|
(ww[1] + 1.425).abs() < 1e-6 && (ww[4] - 1.425).abs() < 1e-6,
|
|
"{ww:?}"
|
|
);
|
|
for rot in 0..4u8 {
|
|
for lv in ["site", "gf"] {
|
|
let b = r.build(lv, rot, &cfg(), &doc).unwrap();
|
|
let out = render(&b.scene, rot, &RenderOpts::default());
|
|
assert_eq!(out.cycles, 0, "{lv}/{rot}: depth-sort cycles");
|
|
}
|
|
}
|
|
// moved off every wall, the window stands alone as a wall piece
|
|
let mut alone = doc.clone();
|
|
alone.get_mut("win").unwrap().y = -5.0;
|
|
let b = r.build("site", 0, &cfg(), &alone).unwrap();
|
|
assert_eq!(sprites_of(&b, "south"), 1);
|
|
assert!((b.boxes["win"][5] - 2.8).abs() < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn walls_meeting_at_a_t_or_in_line_do_not_overlap() {
|
|
let r = Runtime::new(&Bundle::default()).unwrap();
|
|
let mut doc = room_doc();
|
|
let mut inner = obj("inner", "house_wall", 0.0, 0.0, json!({"w": 3.0, "t": 0.1}));
|
|
inner.rot = 90.0;
|
|
doc.objects.push(inner);
|
|
// the north wall in two pieces meeting at x = 1
|
|
doc.get_mut("north").unwrap().props = json!({"w": 3.0}).as_object().cloned().unwrap();
|
|
doc.get_mut("north").unwrap().x = -0.5;
|
|
doc.objects
|
|
.push(obj("north2", "house_wall", 1.5, 1.5, json!({"w": 1.0})));
|
|
let b = r.build("site", 1, &cfg(), &doc).unwrap();
|
|
let i = b.boxes["inner"];
|
|
assert!(
|
|
(i[1] + 1.425).abs() < 1e-6 && (i[4] - 1.425).abs() < 1e-6,
|
|
"{i:?}"
|
|
);
|
|
let (n1, n2) = (b.boxes["north"], b.boxes["north2"]);
|
|
assert!(
|
|
(n1[3] - 1.0).abs() < 1e-6 && (n2[0] - 1.0).abs() < 1e-6,
|
|
"{n1:?} {n2:?}"
|
|
);
|
|
assert!((n1[0] + 2.075).abs() < 1e-6 && (n2[3] - 2.075).abs() < 1e-6);
|
|
}
|
|
|
|
/// A 10 x 6 m fence perimeter, a fence across it (a T at both ends; at the
|
|
/// north it meets the joint of the north fence's two pieces) and a spur from
|
|
/// the east fence to a site wall.
|
|
fn fence_doc(style: &str) -> Doc {
|
|
let mut doc = Doc::default();
|
|
for (id, x, y, rot, w) in [
|
|
("south", 0.0, -3.0, 0.0, 10.0),
|
|
("north", -2.5, 3.0, 0.0, 5.0),
|
|
("north2", 2.5, 3.0, 0.0, 5.0),
|
|
("west", -5.0, 0.0, 90.0, 6.0),
|
|
("east", 5.0, 0.0, 90.0, 6.0),
|
|
("inner", 0.0, 0.0, 90.0, 6.0),
|
|
("spur", 6.5, 0.0, 0.0, 3.0),
|
|
] {
|
|
let mut o = obj(id, "fence", x, y, json!({ "w": w, "style": style }));
|
|
o.rot = rot;
|
|
doc.objects.push(o);
|
|
}
|
|
let mut wall = obj("wall", "wall", 8.0, 0.0, json!({"w": 10.0}));
|
|
wall.rot = 90.0;
|
|
doc.objects.push(wall);
|
|
doc
|
|
}
|
|
|
|
#[test]
|
|
fn fences_join_like_walls_in_every_design() {
|
|
let r = Runtime::new(&Bundle::default()).unwrap();
|
|
for (style, ht) in [
|
|
("mesh", 0.04),
|
|
("wood", 0.05),
|
|
("bars", 0.04),
|
|
("half stone", 0.16),
|
|
("cobble", 0.22),
|
|
("brick", 0.18),
|
|
] {
|
|
let doc = fence_doc(style);
|
|
let b = r.build("site", 0, &cfg(), &doc).unwrap();
|
|
assert!(
|
|
b.scene.warnings.is_empty(),
|
|
"{style}: {:?}",
|
|
b.scene.warnings
|
|
);
|
|
let bx = |id: &str| b.boxes[id];
|
|
let near = |a: f64, e: f64| (a - e).abs() < 1e-6;
|
|
// corners: the fence along x runs through, the other stops at its face
|
|
let (s, w) = (bx("south"), bx("west"));
|
|
assert!(
|
|
near(s[0], -5.0 - ht) && near(s[3], 5.0 + ht),
|
|
"{style} south {s:?}"
|
|
);
|
|
assert!(
|
|
near(w[1], -3.0 + ht) && near(w[4], 3.0 - ht),
|
|
"{style} west {w:?}"
|
|
);
|
|
// a T at the south, the end of a straight run at the north: it stops at the faces
|
|
let i = bx("inner");
|
|
assert!(
|
|
near(i[1], -3.0 + ht) && near(i[4], 3.0 - ht),
|
|
"{style} inner {i:?}"
|
|
);
|
|
// in line: the lower piece runs through the shared post, the other starts at it
|
|
let (n1, n2) = (bx("north"), bx("north2"));
|
|
assert!(
|
|
near(n1[3], ht) && near(n2[0], ht),
|
|
"{style} north {n1:?} {n2:?}"
|
|
);
|
|
// the spur stops at the east fence and at the site wall's face
|
|
let sp = bx("spur");
|
|
assert!(
|
|
near(sp[0], 5.0 + ht) && near(sp[3], 8.0 - 0.12),
|
|
"{style} spur {sp:?}"
|
|
);
|
|
for rot in 0..4u8 {
|
|
let b = r.build("site", rot, &cfg(), &doc).unwrap();
|
|
let out = render(&b.scene, rot, &RenderOpts::default());
|
|
assert_eq!(out.cycles, 0, "{style}/{rot}: depth-sort cycles");
|
|
}
|
|
}
|
|
// the brick wall has pillars as their own sprites, with brick courses on its faces
|
|
let b = r.build("site", 0, &cfg(), &fence_doc("brick")).unwrap();
|
|
assert!(sprites_of(&b, "south") > 4);
|
|
assert!(render(&b.scene, 0, &RenderOpts::default())
|
|
.html
|
|
.contains("iso-seam"));
|
|
}
|
|
|
|
#[test]
|
|
fn ponds_have_fish_swimming_in_every_shape() {
|
|
let r = Runtime::new(&Bundle::default()).unwrap();
|
|
for (shape, edge, rot) in [
|
|
("natural", "stone", 0.0),
|
|
("round", "grass", 90.0),
|
|
("square", "stone", 0.0),
|
|
] {
|
|
let mut doc = Doc::default();
|
|
let mut o = obj(
|
|
"pond",
|
|
"pond",
|
|
3.0,
|
|
-2.0,
|
|
json!({"w": 9, "d": 5, "shape": shape, "edge": edge, "fish": 6, "lilies": 4}),
|
|
);
|
|
o.rot = rot;
|
|
doc.objects.push(o);
|
|
for view in 0..4u8 {
|
|
let b = r.build("site", view, &cfg(), &doc).unwrap();
|
|
assert!(
|
|
b.scene.warnings.is_empty(),
|
|
"{shape}: {:?}",
|
|
b.scene.warnings
|
|
);
|
|
let out = render(&b.scene, view, &RenderOpts::default());
|
|
assert_eq!(
|
|
out.html.matches("iso-a iso-swim").count(),
|
|
6,
|
|
"{shape}/{view}"
|
|
);
|
|
assert!(!out.html.contains("NaN"), "{shape}/{view}");
|
|
assert!(out.html.contains("iso-fish iso-koi") && out.html.contains("iso-lily"));
|
|
assert!(out.html.contains("iso-a iso-rip"));
|
|
assert_eq!(out.cycles, 0);
|
|
}
|
|
// the pond stays inside its 9 x 5 m (turned: 5 x 9 m) rectangle; the reeds stand on its edge
|
|
let bx = r.build("site", 0, &cfg(), &doc).unwrap().boxes["pond"];
|
|
let (hw, hd) = if rot == 0.0 { (4.5, 2.5) } else { (2.5, 4.5) };
|
|
assert!(
|
|
bx[0] >= 3.0 - hw - 1e-6
|
|
&& bx[3] <= 3.0 + hw + 1e-6
|
|
&& bx[1] >= -2.0 - hd - 1e-6
|
|
&& bx[4] <= -2.0 + hd + 1e-6,
|
|
"{shape}: {bx:?}"
|
|
);
|
|
}
|
|
// no fish: no school
|
|
let mut doc = Doc::default();
|
|
doc.objects
|
|
.push(obj("p", "pond", 0.0, 0.0, json!({"fish": 0})));
|
|
let b = r.build("site", 0, &cfg(), &doc).unwrap();
|
|
assert!(!render(&b.scene, 0, &RenderOpts::default())
|
|
.html
|
|
.contains("iso-swim"));
|
|
}
|
|
|
|
fn factory_doc() -> Doc {
|
|
let mut doc = Doc::default();
|
|
for (id, kind, x, y, rot, props) in [
|
|
("press1", "producer", -6.0, -4.0, 0.0, json!({"rate": 20})),
|
|
("belt1", "conveyor", -1.4, -4.0, 0.0, json!({"w": 6})),
|
|
("belt2", "conveyor", 1.6, -1.0, 90.0, json!({"w": 6})),
|
|
(
|
|
"belt3",
|
|
"conveyor",
|
|
1.6,
|
|
5.0,
|
|
90.0,
|
|
json!({"w": 6, "speed": 0.8, "rise": 1.6}),
|
|
),
|
|
(
|
|
"belt5",
|
|
"conveyor",
|
|
8.0,
|
|
6.0,
|
|
0.0,
|
|
json!({"w": 8, "item": "crate", "gap": 2}),
|
|
),
|
|
(
|
|
"press3",
|
|
"producer",
|
|
-6.0,
|
|
8.0,
|
|
0.0,
|
|
json!({"rate": 6, "product": "part", "running": false}),
|
|
),
|
|
("belt6", "conveyor", -2.0, 8.0, 0.0, json!({"w": 5})),
|
|
// a closed ring: following the feed back ends after a few belts
|
|
("r1", "conveyor", 30.0, -3.0, 0.0, json!({"w": 6})),
|
|
("r2", "conveyor", 33.0, 0.0, 90.0, json!({"w": 6})),
|
|
("r3", "conveyor", 30.0, 3.0, 180.0, json!({"w": 6})),
|
|
("r4", "conveyor", 27.0, 0.0, 270.0, json!({"w": 6})),
|
|
] {
|
|
let mut o = obj(id, kind, x, y, props);
|
|
o.rot = rot;
|
|
doc.objects.push(o);
|
|
}
|
|
doc
|
|
}
|
|
|
|
#[test]
|
|
fn conveyors_carry_what_the_producer_makes() {
|
|
let r = Runtime::new(&Bundle::default()).unwrap();
|
|
assert!(r.prefab("conveyor").unwrap().directed);
|
|
let doc = factory_doc();
|
|
for view in 0..4u8 {
|
|
let b = r.build("site", view, &cfg(), &doc).unwrap();
|
|
assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings);
|
|
let out = render(&b.scene, view, &RenderOpts::default());
|
|
assert_eq!(out.cycles, 0, "view {view}: depth-sort cycles");
|
|
assert!(!out.html.contains("NaN"));
|
|
// goods move on the running belts (not behind the stopped press), the running press's ram goes
|
|
assert_eq!(
|
|
out.html.matches("iso-a iso-ride\"").count(),
|
|
8,
|
|
"view {view}"
|
|
);
|
|
assert_eq!(out.html.matches("iso-a iso-ram").count(), 1, "view {view}");
|
|
}
|
|
let b = r.build("site", 1, &cfg(), &doc).unwrap();
|
|
let near = |a: f64, e: f64| (a - e).abs() < 1e-6;
|
|
// the first belt starts flush at the press and stops at the side of the belt it feeds,
|
|
// which runs through the corner; in line the belts just meet
|
|
let (b1, b2, b3) = (b.boxes["belt1"], b.boxes["belt2"], b.boxes["belt3"]);
|
|
assert!(near(b1[0], -4.4) && near(b1[3], 1.2), "belt1 {b1:?}");
|
|
assert!(near(b2[1], -4.4) && near(b2[4], 2.0), "belt2 {b2:?}");
|
|
assert!(near(b3[1], 2.0), "belt3 {b3:?}");
|
|
// goods and their spacing come down the line: 20 a minute at 0.8 m/s is 2.4 m apart
|
|
let out = render(&b.scene, 1, &RenderOpts::default());
|
|
let row = |id: &str, k: &str| {
|
|
out.info[id]
|
|
.rows
|
|
.iter()
|
|
.find(|r| r.0 == k)
|
|
.map(|r| r.1.clone())
|
|
.unwrap_or_default()
|
|
};
|
|
assert_eq!(
|
|
row("belt1", "Carries"),
|
|
"20 cartons a minute, 1.5 m apart (from press1)"
|
|
);
|
|
assert_eq!(
|
|
row("belt3", "Carries"),
|
|
"20 cartons a minute, 2.4 m apart (from belt2)"
|
|
);
|
|
assert_eq!(row("belt5", "Carries"), "15 crates a minute, 2 m apart");
|
|
assert_eq!(
|
|
row("belt6", "Carries"),
|
|
"6 parts a minute, 5 m apart (from press3)"
|
|
);
|
|
assert_eq!(
|
|
row("press3", "Makes"),
|
|
"6 parts a minute, one every 10 s (stopped)"
|
|
);
|
|
assert_eq!(row("belt3", "Speed"), "0.8 m/s");
|
|
}
|
|
|
|
fn robot_doc(belt1_running: bool) -> Doc {
|
|
let mut doc = Doc::default();
|
|
for (id, kind, x, y, rot, props) in [
|
|
("press1", "producer", -6.0, -4.0, 0.0, json!({"rate": 15})),
|
|
(
|
|
"belt1",
|
|
"conveyor",
|
|
-1.4,
|
|
-4.0,
|
|
0.0,
|
|
json!({"w": 6, "running": belt1_running}),
|
|
),
|
|
("robot1", "robot_arm", 3.2, -4.0, 0.0, json!({})),
|
|
("belt2", "conveyor", 3.2, 1.0, 90.0, json!({"w": 6})),
|
|
(
|
|
"robot2",
|
|
"robot_arm",
|
|
3.2,
|
|
5.8,
|
|
0.0,
|
|
json!({"colour": "mach2"}),
|
|
),
|
|
(
|
|
"robot3",
|
|
"robot_arm",
|
|
-6.0,
|
|
5.0,
|
|
0.0,
|
|
json!({"running": false, "pick": "+x", "put": "-y", "goods": "drum"}),
|
|
),
|
|
] {
|
|
let mut o = obj(id, kind, x, y, props);
|
|
o.rot = rot;
|
|
doc.objects.push(o);
|
|
}
|
|
doc
|
|
}
|
|
|
|
#[test]
|
|
fn robot_arms_pass_goods_from_belt_to_belt() {
|
|
let r = Runtime::new(&Bundle::default()).unwrap();
|
|
let doc = robot_doc(true);
|
|
for view in 0..4u8 {
|
|
let b = r.build("site", view, &cfg(), &doc).unwrap();
|
|
assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings);
|
|
let out = render(&b.scene, view, &RenderOpts::default());
|
|
assert_eq!(out.cycles, 0, "view {view}: depth-sort cycles");
|
|
assert!(!out.html.contains("NaN"));
|
|
// two running arms with 16 poses each, one shown at a time; the stopped one stands still
|
|
assert_eq!(
|
|
out.html.matches("iso-a iso-flip").count(),
|
|
32,
|
|
"view {view}"
|
|
);
|
|
assert_eq!(
|
|
out.html.matches("iso-flip iso-pose0").count(),
|
|
2,
|
|
"view {view}"
|
|
);
|
|
}
|
|
let b = r.build("site", 1, &cfg(), &doc).unwrap();
|
|
let out = render(&b.scene, 1, &RenderOpts::default());
|
|
let row = |id: &str, k: &str| {
|
|
out.info[id]
|
|
.rows
|
|
.iter()
|
|
.find(|r| r.0 == k)
|
|
.map(|r| r.1.clone())
|
|
.unwrap_or_default()
|
|
};
|
|
assert_eq!(row("robot1", "Moves"), "cartons from belt1 to belt2");
|
|
assert_eq!(row("robot1", "Cycle"), "4 s");
|
|
// the belt after the arm carries its goods at its pace, starting its loop when they land
|
|
assert_eq!(
|
|
row("belt2", "Carries"),
|
|
"15 cartons a minute, 2 m apart (from robot1)"
|
|
);
|
|
assert!(out
|
|
.html
|
|
.contains("animation-duration:4.0s;animation-delay:-1.75s"));
|
|
assert_eq!(row("robot2", "Moves"), "cartons from belt2 to a pallet");
|
|
assert_eq!(row("robot3", "Cycle"), "4 s (stopped)");
|
|
// a stopped belt before it: the arm waits
|
|
let b = r.build("site", 1, &cfg(), &robot_doc(false)).unwrap();
|
|
let out = render(&b.scene, 1, &RenderOpts::default());
|
|
assert_eq!(out.html.matches("iso-a iso-flip").count(), 0);
|
|
assert!(out.info["robot1"].rows[1].1.contains("waiting"));
|
|
}
|
|
|
|
fn street_doc() -> Doc {
|
|
let mut doc = Doc::default();
|
|
let walk = json!({"walk": "both"});
|
|
for (id, kind, x, y, rot, props) in [
|
|
(
|
|
"main",
|
|
"road",
|
|
-10.0,
|
|
0.0,
|
|
0.0,
|
|
json!({"w": 40, "walk": "both"}),
|
|
),
|
|
(
|
|
"side",
|
|
"road",
|
|
-14.0,
|
|
-12.0,
|
|
90.0,
|
|
json!({"w": 24, "walk": "both"}),
|
|
),
|
|
("bend", "road_curve", 10.0, 0.0, 0.0, walk.clone()),
|
|
(
|
|
"north",
|
|
"road",
|
|
20.0,
|
|
18.0,
|
|
90.0,
|
|
json!({"w": 16, "walk": "both"}),
|
|
),
|
|
(
|
|
"c1",
|
|
"road",
|
|
-30.0,
|
|
20.0,
|
|
0.0,
|
|
json!({"w": 20, "walk": "both"}),
|
|
),
|
|
(
|
|
"c2",
|
|
"road",
|
|
-20.0,
|
|
30.0,
|
|
90.0,
|
|
json!({"w": 20, "walk": "both"}),
|
|
),
|
|
("rb", "roundabout", -54.0, 0.0, 0.0, json!({"arm_n": false})),
|
|
] {
|
|
let mut o = obj(id, kind, x, y, props);
|
|
o.rot = rot;
|
|
doc.objects.push(o);
|
|
}
|
|
doc
|
|
}
|
|
|
|
/// Sidewalk strips of object id (paving pieces).
|
|
fn walks_of(b: &crate::Built, id: &str) -> usize {
|
|
b.scene
|
|
.layers
|
|
.iter()
|
|
.flat_map(|l| l.sprites.iter())
|
|
.filter(|s| s.obj.as_deref() == Some(id))
|
|
.flat_map(|s| s.prims.iter())
|
|
.filter(|p| matches!(p, isokit_core::Prim::Flat { cls, .. } if cls.contains("g-walk")))
|
|
.count()
|
|
}
|
|
|
|
#[test]
|
|
fn streets_curve_join_and_meet_at_roundabouts() {
|
|
let r = Runtime::new(&Bundle::default()).unwrap();
|
|
assert_eq!(r.prefab("road").unwrap().corner, "road_curve");
|
|
let doc = street_doc();
|
|
for view in 0..4u8 {
|
|
let b = r.build("site", view, &cfg(), &doc).unwrap();
|
|
assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings);
|
|
let out = render(&b.scene, view, &RenderOpts::default());
|
|
assert_eq!(out.cycles, 0, "view {view}: depth-sort cycles");
|
|
assert!(!out.html.contains("NaN"));
|
|
}
|
|
let b = r.build("site", 0, &cfg(), &doc).unwrap();
|
|
let near = |a: [f64; 6], want: [f64; 6]| a.iter().zip(want).all(|(a, w)| (a - w).abs() < 1e-3);
|
|
// the side road ends on the main street's centre line; the main street's south
|
|
// sidewalk leaves a gap for it (two pieces), its north one runs through
|
|
assert_eq!(walks_of(&b, "main"), 3);
|
|
assert_eq!(walks_of(&b, "side"), 2);
|
|
let sd = b.boxes["side"];
|
|
assert!(near(sd, [-19.0, -24.0, 0.0, -9.0, 0.0, 0.12]), "{sd:?}");
|
|
// the curve carries the street on round 90 degrees, overlapping both by 4 cm
|
|
// along its centre line (6 cm at its outer edge); the main street meets the
|
|
// roundabout's east arm end to end
|
|
let bd = b.boxes["bend"];
|
|
assert!(near(bd, [9.94, -5.0, 0.0, 25.0, 10.06, 0.12]), "{bd:?}");
|
|
let mn = b.boxes["main"];
|
|
assert!(near(mn, [-30.04, -5.0, 0.0, 10.04, 5.0, 0.12]), "{mn:?}");
|
|
// two roads ending at one corner close it: the outer sidewalks go round it
|
|
let (c1, c2) = (b.boxes["c1"], b.boxes["c2"]);
|
|
assert!(
|
|
(c1[3] + 15.0).abs() < 1e-6 && (c1[1] - 15.0).abs() < 1e-6,
|
|
"{c1:?}"
|
|
);
|
|
assert!(
|
|
(c2[1] - 15.0).abs() < 1e-6 && (c2[3] + 15.0).abs() < 1e-6,
|
|
"{c2:?}"
|
|
);
|
|
// the roundabout: arms east, west and south ending 24 m out, the ring's sidewalk to the north
|
|
let rb = b.boxes["rb"];
|
|
assert!(
|
|
(rb[0] + 78.0).abs() < 1e-6
|
|
&& (rb[3] + 30.0).abs() < 1e-6
|
|
&& (rb[1] + 24.0).abs() < 1e-6
|
|
&& (rb[4] - 16.0).abs() < 0.01,
|
|
"{rb:?}"
|
|
);
|
|
let html = render(&b.scene, 0, &RenderOpts::default()).html;
|
|
assert!(html.contains("iso-giveway") && html.contains("iso-kerbf"));
|
|
}
|
|
|
|
/// A piece of `kind` from plan point a to b: x, y = the middle, rot = its angle, w = its length.
|
|
fn piece(id: &str, kind: &str, a: [f64; 2], b: [f64; 2], props: Value) -> Object {
|
|
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
|
let mut p = props;
|
|
p["w"] = json!(dx.hypot(dy));
|
|
let mut o = obj(id, kind, (a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0, p);
|
|
o.rot = dy.atan2(dx).to_degrees().rem_euclid(360.0);
|
|
o
|
|
}
|
|
|
|
#[test]
|
|
fn hidden_groups_hide_what_is_in_them() {
|
|
let r = Runtime::new(&Bundle::default()).unwrap();
|
|
let mut g = obj("g", "group", 0.0, 0.0, json!({}));
|
|
g.hidden = true;
|
|
let mut inner = obj("inner", "group", 0.0, 0.0, json!({}));
|
|
inner.group = "g".into();
|
|
let mut t1 = obj("t1", "tree", 0.0, 0.0, json!({}));
|
|
t1.group = "inner".into();
|
|
let mut t2 = obj("t2", "tree", 5.0, 0.0, json!({}));
|
|
t2.group = "g".into();
|
|
let doc = Doc {
|
|
objects: vec![t1, t2, inner, g, obj("t3", "tree", 10.0, 0.0, json!({}))],
|
|
..Default::default()
|
|
};
|
|
let b = r.build("site", 0, &cfg(), &doc).unwrap();
|
|
// groups draw nothing and warn about nothing
|
|
assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings);
|
|
assert_eq!(sprites_of(&b, "g") + sprites_of(&b, "inner"), 0);
|
|
assert_eq!(sprites_of(&b, "t1") + sprites_of(&b, "t2"), 0);
|
|
assert_eq!(sprites_of(&b, "t3"), 1);
|
|
let mut shown = doc.clone();
|
|
shown.objects[3].hidden = false;
|
|
let b = r.build("site", 0, &cfg(), &shown).unwrap();
|
|
assert_eq!(sprites_of(&b, "t1") + sprites_of(&b, "t2"), 2);
|
|
}
|
|
|
|
/// The windows of every route mover piece of object `id`: (piece sprite, windows).
|
|
fn travel_windows(b: &crate::Built, id: &str) -> Vec<Vec<[f64; 2]>> {
|
|
fn walk(p: &isokit_core::Prim, out: &mut Vec<Vec<[f64; 2]>>) {
|
|
match p {
|
|
isokit_core::Prim::Travel { wins, .. } => out.push(wins.clone()),
|
|
isokit_core::Prim::Group { items, .. } => items.iter().for_each(|q| walk(q, out)),
|
|
_ => {}
|
|
}
|
|
}
|
|
let mut out = vec![];
|
|
for s in b.scene.layers.iter().flat_map(|l| l.sprites.iter()) {
|
|
if s.obj.as_deref() == Some(id) {
|
|
s.prims.iter().for_each(|p| walk(p, &mut out));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[test]
|
|
fn route_movers_show_once_each_at_every_moment() {
|
|
let r = Runtime::new(&Bundle::default()).unwrap();
|
|
let mut cars = obj(
|
|
"cars",
|
|
"route",
|
|
0.0,
|
|
0.0,
|
|
json!({"kind": "car", "n": 4, "closed": true}),
|
|
);
|
|
cars.pts = Some(vec![
|
|
[-25.0, -10.0],
|
|
[25.0, -10.0],
|
|
[25.0, 10.0],
|
|
[-25.0, 10.0],
|
|
]);
|
|
let mut walk = obj(
|
|
"walk",
|
|
"route",
|
|
0.0,
|
|
0.0,
|
|
json!({"kind": "person", "n": 3, "colour": "hivis"}),
|
|
);
|
|
walk.pts = Some(vec![[-15.0, -4.0], [5.0, -4.0], [12.0, 3.0]]);
|
|
let mut deer = obj(
|
|
"deer",
|
|
"route",
|
|
30.0,
|
|
20.0,
|
|
json!({"kind": "deer", "n": 2, "path": false}),
|
|
);
|
|
deer.rot = 30.0;
|
|
deer.pts = Some(vec![[-6.0, 0.0], [6.0, 0.0]]);
|
|
let doc = Doc {
|
|
objects: vec![cars, walk, deer, obj("t1", "tree", 0.0, -14.0, json!({}))],
|
|
..Default::default()
|
|
};
|
|
for view in 0..4u8 {
|
|
let b = r.build("site", view, &cfg(), &doc).unwrap();
|
|
assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings);
|
|
let out = render(&b.scene, view, &RenderOpts::default());
|
|
assert_eq!(out.cycles, 0, "view {view}: depth-sort cycles");
|
|
// one <style> with every keyframe set, each used
|
|
assert_eq!(out.html.matches("<style>").count(), 1);
|
|
for (id, n) in [("cars", 4), ("walk", 3), ("deer", 2)] {
|
|
let wins = travel_windows(&b, id);
|
|
assert!(!wins.is_empty(), "{id}");
|
|
// at any moment, each mover is on exactly one piece
|
|
for k in 0..60 {
|
|
let t = (k as f64 + 0.37) / 60.0;
|
|
let on: usize = wins
|
|
.iter()
|
|
.flatten()
|
|
.filter(|w| {
|
|
let s = w[0].rem_euclid(1.0);
|
|
let e = s + (w[1] - w[0]);
|
|
(t >= s && t < e) || (t + 1.0 >= s && t + 1.0 < e)
|
|
})
|
|
.count();
|
|
assert_eq!(on, n, "{id} view {view} at {t}");
|
|
}
|
|
}
|
|
}
|
|
let b = r.build("site", 0, &cfg(), &doc).unwrap();
|
|
let out = render(&b.scene, 0, &RenderOpts::default());
|
|
let row = |id: &str, k: &str| {
|
|
out.info[id]
|
|
.rows
|
|
.iter()
|
|
.find(|r| r.0 == k)
|
|
.map(|r| r.1.clone())
|
|
.unwrap_or_default()
|
|
};
|
|
// the loop keeps 1.5 m inside the drawn square, its corners rounded
|
|
assert_eq!(row("cars", "Route"), "loop, 140 m");
|
|
assert!(
|
|
row("cars", "Movers").starts_with("4 cars, 8 m/s, round in 15."),
|
|
"{}",
|
|
row("cars", "Movers")
|
|
);
|
|
assert_eq!(row("walk", "Route"), "there and back, 29.9 m each way");
|
|
// one colour for every walker: they share drawings where their windows allow
|
|
let pieces = b
|
|
.scene
|
|
.layers
|
|
.iter()
|
|
.flat_map(|l| l.sprites.iter())
|
|
.filter(|s| s.obj.as_deref() == Some("walk"))
|
|
.count();
|
|
assert!(
|
|
travel_windows(&b, "walk").len() < 3 * (pieces - 1),
|
|
"walkers share drawings"
|
|
);
|
|
// the deer route turns with its object: its strips run at 30 degrees
|
|
assert!(out.html.contains("iso-route"));
|
|
}
|
|
|
|
#[test]
|
|
fn wildlife_and_garden_prefabs_draw_from_every_side() {
|
|
let r = Runtime::new(&Bundle::default()).unwrap();
|
|
let doc = Doc {
|
|
objects: vec![
|
|
obj("deer", "deer", -18.0, 8.0, json!({"n": 5, "r": 4})),
|
|
obj(
|
|
"stag",
|
|
"deer",
|
|
-8.0,
|
|
12.0,
|
|
json!({"n": 1, "mix": "stag", "graze": false}),
|
|
),
|
|
obj("boar", "boar", -18.0, -6.0, json!({"n": 3})),
|
|
obj("fox", "fox", -8.0, -2.0, json!({})),
|
|
obj(
|
|
"el",
|
|
"elephant",
|
|
6.0,
|
|
10.0,
|
|
json!({"n": 3, "mix": "herd", "r": 5}),
|
|
),
|
|
obj("rab", "rabbits", -4.0, -12.0, json!({"n": 4})),
|
|
obj("birds", "birds", 0.0, 0.0, json!({"n": 7})),
|
|
obj("bf", "butterflies", 10.0, -12.0, json!({"n": 5})),
|
|
obj("hives", "beehive", 16.0, -6.0, json!({"n": 4})),
|
|
obj("bed", "flowers", 10.0, -12.0, json!({"w": 5, "d": 3})),
|
|
obj(
|
|
"mead",
|
|
"flowers",
|
|
20.0,
|
|
-14.0,
|
|
json!({"w": 8, "d": 5, "kind": "meadow"}),
|
|
),
|
|
obj("b1", "bush", 3.0, -4.0, json!({})),
|
|
obj("b2", "bush", 5.0, -6.0, json!({"kind": "berries"})),
|
|
obj("rk", "rocks", 22.0, 6.0, json!({"n": 4})),
|
|
obj("lg", "log", 14.0, 4.0, json!({})),
|
|
obj("st", "log", 18.0, 2.0, json!({"kind": "stump"})),
|
|
],
|
|
..Default::default()
|
|
};
|
|
for view in 0..4u8 {
|
|
let b = r.build("site", view, &cfg(), &doc).unwrap();
|
|
assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings);
|
|
let out = render(&b.scene, view, &RenderOpts::default());
|
|
assert!(!out.html.contains("NaN"));
|
|
// each animal its own sprite: five deer, three boar, one fox, three elephants, four rabbits
|
|
for (id, n) in [
|
|
("deer", 5),
|
|
("stag", 1),
|
|
("boar", 3),
|
|
("fox", 1),
|
|
("el", 3),
|
|
("rab", 4),
|
|
] {
|
|
assert_eq!(sprites_of(&b, id), n, "{id}");
|
|
}
|
|
// grazing animals carry two head poses taking turns, the first shown unanimated;
|
|
// the standing stag and the rabbits have none
|
|
assert_eq!(
|
|
out.html.matches("iso-a iso-pose2 iso-pose0").count(),
|
|
5 + 3 + 1 + 3
|
|
);
|
|
assert_eq!(out.html.matches("iso-a iso-pose2\"").count(), 5 + 3 + 1 + 3);
|
|
assert_eq!(out.html.matches("iso-a iso-hop").count(), 4);
|
|
// birds flap, butterflies flutter, bees fly round the hives
|
|
assert_eq!(out.html.matches("iso-a iso-flap").count(), 7);
|
|
assert_eq!(out.html.matches("iso-a iso-flutter").count(), 5);
|
|
assert_eq!(out.html.matches("class=\"iso-bee iso-bee\"").count(), 4 * 8);
|
|
// animal faces are edged in their own shade; antlers on the one stag
|
|
assert!(out.html.contains("iso-deer iso-beast"));
|
|
assert!(out.html.contains("iso-antler"));
|
|
}
|
|
let b = r.build("site", 0, &cfg(), &doc).unwrap();
|
|
// a stag stands about 1.5 m at the head, antlers up to 2.1 m; it fits its sort box
|
|
let st = b.boxes["stag"];
|
|
assert!(st[5] > 1.9 && st[5] < 2.3, "{st:?}");
|
|
// an elephant bull: about 3 m tall at the head
|
|
let el = b.boxes["el"];
|
|
assert!(el[5] > 2.6 && el[5] < 3.3, "{el:?}");
|
|
let out = render(&b.scene, 0, &RenderOpts::default());
|
|
let row = |id: &str, k: &str| {
|
|
out.info[id]
|
|
.rows
|
|
.iter()
|
|
.find(|r| r.0 == k)
|
|
.map(|r| r.1.clone())
|
|
.unwrap_or_default()
|
|
};
|
|
assert_eq!(row("deer", "Herd"), "1 stag, 3 does, 1 fawn");
|
|
assert_eq!(row("el", "Herd"), "1 bull, 1 cow, 1 calf");
|
|
assert_eq!(row("hives", "Apiary"), "4 hives, 2 supers each");
|
|
}
|
|
|
|
#[test]
|
|
fn diagonal_walls_fences_and_hedges_mitre_and_sort() {
|
|
let r = Runtime::new(&Bundle::default()).unwrap();
|
|
let mut doc = Doc {
|
|
objects: vec![
|
|
piece("w1", "wall", [-30.0, -20.0], [-20.0, -20.0], json!({})),
|
|
piece("w2", "wall", [-20.0, -20.0], [-12.0, -12.0], json!({})),
|
|
piece("w3", "wall", [-12.0, -12.0], [-12.0, -2.0], json!({})),
|
|
piece("w4", "wall", [-16.0, -16.0], [-22.0, -10.0], json!({})),
|
|
obj("p_right", "person", -14.0, -18.0, json!({})),
|
|
obj("p_left", "person", -18.0, -15.0, json!({})),
|
|
],
|
|
..Default::default()
|
|
};
|
|
for (i, st) in ["mesh", "wood", "bars", "half stone", "cobble", "brick"]
|
|
.iter()
|
|
.enumerate()
|
|
{
|
|
let x = i as f64 * 10.0;
|
|
doc.objects.push(piece(
|
|
&format!("f{i}a"),
|
|
"fence",
|
|
[x, 5.0],
|
|
[x + 6.0, 5.0],
|
|
json!({ "style": st }),
|
|
));
|
|
doc.objects.push(piece(
|
|
&format!("f{i}b"),
|
|
"fence",
|
|
[x + 6.0, 5.0],
|
|
[x + 10.0, 11.0],
|
|
json!({ "style": st }),
|
|
));
|
|
}
|
|
doc.objects
|
|
.push(piece("h1", "hedge", [5.0, -20.0], [12.0, -13.0], json!({})));
|
|
doc.objects.push(piece(
|
|
"h2",
|
|
"hedge",
|
|
[12.0, -13.0],
|
|
[20.0, -13.0],
|
|
json!({}),
|
|
));
|
|
doc.objects
|
|
.push(obj("fl", "floor", 25.0, 18.0, json!({"w": 10, "d": 8})));
|
|
for (id, a, b) in [
|
|
("hw1", [20.0, 14.0], [30.0, 14.0]),
|
|
("hw2", [30.0, 14.0], [30.0, 19.0]),
|
|
("hw3", [30.0, 19.0], [27.0, 22.0]),
|
|
("hw4", [27.0, 22.0], [20.0, 22.0]),
|
|
("hw5", [20.0, 22.0], [20.0, 14.0]),
|
|
] {
|
|
doc.objects.push(piece(id, "house_wall", a, b, json!({})));
|
|
}
|
|
let mut win = obj("win", "window", 28.5, 20.5, json!({"w": 1.2}));
|
|
win.rot = 135.0;
|
|
doc.objects.push(win);
|
|
for view in 0..4u8 {
|
|
for lv in ["site", "gf"] {
|
|
let b = r.build(lv, view, &cfg(), &doc).unwrap();
|
|
assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings);
|
|
let out = render(&b.scene, view, &RenderOpts::default());
|
|
assert_eq!(out.cycles, 0, "{lv}/{view}: depth-sort cycles");
|
|
assert!(!out.html.contains("NaN"));
|
|
}
|
|
// the person on the side of the diagonal wall turned to the viewer is drawn after it,
|
|
// the other one before
|
|
let b = r.build("site", view, &cfg(), &doc).unwrap();
|
|
let html = render(&b.scene, view, &RenderOpts::default()).html;
|
|
let at = |id: &str| html.find(&format!("data-obj=\"{id}\"")).unwrap();
|
|
// w2 runs along (1, 1); its left side (towards (-1, 1)) holds p_left. In two views the
|
|
// wall is seen edge-on: neither side faces the viewer and the order does not matter.
|
|
let p = isokit_core::Proj::new(view as i64);
|
|
let (left_seen, right_seen) = (p.vis(-1.0, 1.0).is_some(), p.vis(1.0, -1.0).is_some());
|
|
if left_seen || right_seen {
|
|
let (front, back) = if left_seen {
|
|
("p_left", "p_right")
|
|
} else {
|
|
("p_right", "p_left")
|
|
};
|
|
assert!(
|
|
at(back) < at("w2") && at("w2") < at(front),
|
|
"view {view}: {back} < w2 < {front}"
|
|
);
|
|
}
|
|
}
|
|
let b = r.build("site", 0, &cfg(), &doc).unwrap();
|
|
// the 135-degree corner is mitred: w1's outer face reaches 0.12 * tan(22.5) past the corner
|
|
let w1 = b.boxes["w1"];
|
|
assert!(
|
|
(w1[3] + 20.0 - 0.12 * 22.5f64.to_radians().tan()).abs() < 1e-6,
|
|
"{w1:?}"
|
|
);
|
|
// the window on the diagonal wall cuts its opening there
|
|
assert_eq!(sprites_of(&b, "hw3"), 2);
|
|
let wb = b.boxes["win"];
|
|
assert!(
|
|
((wb[0] + wb[3]) / 2.0 - 28.5).abs() < 1e-6 && ((wb[1] + wb[4]) / 2.0 - 20.5).abs() < 1e-6,
|
|
"{wb:?}"
|
|
);
|
|
// diagonal fence corners: the owner draws the corner post as its own sprite (mesh: f0a, the lower id)
|
|
assert_eq!(sprites_of(&b, "f0a"), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn diagonal_streets_meet_at_mitres_and_slanted_junctions() {
|
|
let r = Runtime::new(&Bundle::default()).unwrap();
|
|
let walk = json!({"walk": "both"});
|
|
let mut doc = Doc {
|
|
objects: vec![
|
|
piece("a1", "road", [-60.0, -30.0], [-35.0, -30.0], walk.clone()),
|
|
piece("a2", "road", [-35.0, -30.0], [-20.0, -15.0], walk.clone()),
|
|
piece("m1", "road", [-10.0, -40.0], [40.0, -40.0], walk.clone()),
|
|
piece("s1", "road", [5.0, -40.0], [17.0, -28.0], walk.clone()),
|
|
],
|
|
..Default::default()
|
|
};
|
|
let mut bend = obj(
|
|
"bend",
|
|
"road_curve",
|
|
10.0,
|
|
0.0,
|
|
json!({"r": 12, "angle": 45, "walk": "both"}),
|
|
);
|
|
bend.rot = 0.0;
|
|
doc.objects.push(bend);
|
|
for view in 0..4u8 {
|
|
let b = r.build("site", view, &cfg(), &doc).unwrap();
|
|
assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings);
|
|
assert_eq!(render(&b.scene, view, &RenderOpts::default()).cycles, 0);
|
|
}
|
|
let b = r.build("site", 0, &cfg(), &doc).unwrap();
|
|
// the 135-degree corner is mitred: a1's outer sidewalk back edge (5 m out) reaches
|
|
// 5 * tan(22.5) past it, and its seam patch 6 cm on into a2 (along (1, 1))
|
|
let a1 = b.boxes["a1"];
|
|
let reach = 5.0 * 22.5f64.to_radians().tan() + 0.06 * 0.5f64.sqrt();
|
|
assert!((a1[3] + 35.0 - reach).abs() < 1e-6, "{a1:?}");
|
|
// the diagonal side road opens m1's north sidewalk (two pieces there); its asphalt ends
|
|
// slanted on m1's centre line
|
|
assert_eq!(walks_of(&b, "m1"), 3);
|
|
let s1 = b.boxes["s1"];
|
|
assert!((s1[1] + 40.0).abs() < 1e-6, "{s1:?}");
|
|
let html = render(&b.scene, 0, &RenderOpts::default()).html;
|
|
assert!(html.contains("iso-g-walk"));
|
|
}
|
|
|
|
#[test]
|
|
fn site_walls_join_at_corners() {
|
|
let r = Runtime::new(&Bundle::default()).unwrap();
|
|
let mut doc = Doc::default();
|
|
for (id, x, y, rot, w) in [
|
|
("s", 0.0, -3.0, 0.0, 10.0),
|
|
("n", 0.0, 3.0, 0.0, 10.0),
|
|
("w", -5.0, 0.0, 90.0, 6.0),
|
|
("e", 5.0, 0.0, 90.0, 6.0),
|
|
] {
|
|
let mut o = obj(id, "wall", x, y, json!({ "w": w }));
|
|
o.rot = rot;
|
|
doc.objects.push(o);
|
|
}
|
|
let b = r.build("site", 0, &cfg(), &doc).unwrap();
|
|
let (s, w) = (b.boxes["s"], b.boxes["w"]);
|
|
assert!(
|
|
(s[0] + 5.12).abs() < 1e-6 && (s[3] - 5.12).abs() < 1e-6,
|
|
"{s:?}"
|
|
);
|
|
assert!(
|
|
(w[1] + 2.88).abs() < 1e-6 && (w[4] - 2.88).abs() < 1e-6,
|
|
"{w:?}"
|
|
);
|
|
for rot in 0..4u8 {
|
|
let b = r.build("site", rot, &cfg(), &doc).unwrap();
|
|
assert_eq!(render(&b.scene, rot, &RenderOpts::default()).cycles, 0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cut_levels_lower_the_walls_in_front_of_rooms() {
|
|
let r = Runtime::new(&Bundle::default()).unwrap();
|
|
let mut doc = room_doc();
|
|
doc.objects
|
|
.push(obj("roof", "roof", 0.0, 0.0, json!({"w": 4.15, "d": 3.15})));
|
|
let mut up = obj("chair", "chair", 0.0, 0.0, json!({}));
|
|
up.z = 3.0;
|
|
doc.objects.push(up);
|
|
for rot in 0..4u8 {
|
|
let site = r.build("site", rot, &cfg(), &doc).unwrap();
|
|
let gf = r.build("gf", rot, &cfg(), &doc).unwrap();
|
|
let tops = |b: &crate::Built| -> Vec<f64> {
|
|
["south", "north", "west", "east"]
|
|
.iter()
|
|
.map(|id| b.boxes[*id][5])
|
|
.collect()
|
|
};
|
|
assert!(tops(&site).iter().all(|z| (z - 2.8).abs() < 1e-6));
|
|
// two walls face the viewer: cut low; the two behind the room stay full
|
|
let t = tops(&gf);
|
|
let low = t.iter().filter(|z| (*z - 1.0).abs() < 1e-6).count();
|
|
let full = t.iter().filter(|z| (*z - 2.8).abs() < 1e-6).count();
|
|
assert_eq!((low, full), (2, 2), "rotation {rot}: {t:?}");
|
|
// the roof is a full roof on the site level, an outline when cut open
|
|
assert!(site.boxes["roof"][5] > 4.0);
|
|
assert!(
|
|
(gf.boxes["roof"][5] - 0.02).abs() < 1e-6,
|
|
"outline on the floor"
|
|
);
|
|
// the chair on the first floor: left out on GF, there on 1F
|
|
assert!(!gf.boxes.contains_key("chair"));
|
|
let f1 = r.build("1f", rot, &cfg(), &doc).unwrap();
|
|
assert!(f1.boxes.contains_key("chair"));
|
|
assert!(f1.boxes["south"][5] > 2.7, "GF closed below the cut storey");
|
|
assert!(gf.scene.warnings.is_empty(), "{:?}", gf.scene.warnings);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn object_queries_and_frames() {
|
|
let r = rt(r#"
|
|
let s = objects_of("floor");
|
|
let a = objects_at(["floor"], 1.9, 1.4);
|
|
let b = objects_at(["floor"], 2.1, 0.0);
|
|
let w = objects_of("house_wall")[3];
|
|
frame(w);
|
|
let p = to_world(1.0, 0.5);
|
|
let q = to_local(p[0], p[1]);
|
|
let m = mount_of(objects_of("door")[0], ["house_wall"]);
|
|
let on = mounted(w, ["window", "door"], ["house_wall"]);
|
|
frame(0, 0, 0, 0);
|
|
sprite("probe").box(0, 0, s.len(), a.len() + b.len() * 10, 0, 1, "wall");
|
|
sprite("frame").box(p[0], p[1], p[0] + q[0], p[1] + q[1], 0, 1, "wall");
|
|
sprite("mount").box(0, 0, m.u + 10.0, on.len(), 0, m.v + 10.0, "wall");
|
|
"#);
|
|
let b = r.build("site", 0, &cfg(), &room_doc()).unwrap();
|
|
let probe = b.scene.layers[2]
|
|
.sprites
|
|
.iter()
|
|
.find(|s| s.id == "probe")
|
|
.unwrap()
|
|
.world_box()
|
|
.unwrap();
|
|
assert_eq!(
|
|
(probe[3], probe[4]),
|
|
(1.0, 1.0),
|
|
"one floor, holding one point"
|
|
);
|
|
let f = b.scene.layers[2]
|
|
.sprites
|
|
.iter()
|
|
.find(|s| s.id == "frame")
|
|
.unwrap()
|
|
.world_box()
|
|
.unwrap();
|
|
// east wall at (2, 0) turned 90: local (1, 0.5) is world (1.5, 1)
|
|
assert_eq!([f[0], f[1], f[3], f[4]], [1.5, 1.0, 2.5, 1.5]);
|
|
let m = b.scene.layers[2]
|
|
.sprites
|
|
.iter()
|
|
.find(|s| s.id == "mount")
|
|
.unwrap()
|
|
.world_box()
|
|
.unwrap();
|
|
// the door at (2.05, 0.4) is 0.4 along the east wall, 0.05 off it
|
|
assert!(
|
|
(m[3] - 10.4).abs() < 1e-9 && (m[5] - 9.95).abs() < 1e-9,
|
|
"{m:?}"
|
|
);
|
|
assert_eq!(m[4], 1.0, "one opening on the east wall");
|
|
}
|