Files
isokit/crates/isokit-script/src/tests.rs
T
grabowski c8becc2e59
Deploy web Studio / deploy (push) Successful in 1m4s
Point handles for routes and outlines; Area corners; bus goes round right
Studio: one selected object with pts (a route, an outlined area) shows a
handle on each point and a + in the middle of each side: drag a point to
move it, drag a + to add one there, double-click a point to remove it
(open routes keep 2, outlines 3). Points snap to the grid and onto wall,
fence and road ends, at any angle; one undo step each. Handles keep their
screen size (new viewer callback onView on every redraw) and follow
re-renders and selection. The viewer captures the pointer, so a double-
click targets the drawing: the last point pressed is remembered instead.

Prefab meta `outline`: the prefab draws pts as its outline. Area has it:
Edit corners turns its rectangle into four points, Rectangle again
removes them.

Routes: where a path doubles back, the lane offset puts a point on each
side, so it rounds into a U-turn instead of a diagonal across the lanes.
The streets demo bus now runs east, three quarters round the roundabout
(clockwise, left-hand traffic), down south street, turns and comes back
by the short way, instead of going round the wrong way on its way out.

Tests: Rust 81 (a doubling-back route: each mover once, no sort cycles),
Studio 134 (corners, move, add, remove, undo), web 100.
2026-10-25 18:46:47 +07:00

1382 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]]);
// a loop that doubles back twice (a spur up to a stop and back, a U-turn at the west end):
// each way keeps to its side, so the strips never overlap
let mut spur = obj(
"spur",
"route",
0.0,
40.0,
json!({"kind": "bus", "n": 2, "closed": true}),
);
spur.pts = Some(vec![[-20.0, 0.0], [20.0, 0.0], [20.0, 15.0], [20.0, 0.0]]);
let doc = Doc {
objects: vec![
cars,
walk,
deer,
spur,
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), ("spur", 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");
}