Routes: movers along waypoints (roadmap 8)
Deploy web Studio / deploy (push) Successful in 1m4s

New route prefab (routes.rhai): cars, buses, motorbikes, cyclists, people,
deer or elephants move along waypoints, round a loop or there and back.
Each way keeps to the left of the line (or right), corners are rounded
into pieces of at most 45 degrees; there-and-back routes become loops with
U-turns, so the two directions never share a strip.

Engine: Prim::Travel / sprite.begin_travel / script travel(a, b, dur,
windows) ... end(): the mover is drawn at a piece's start and slides to
its end while it is on that piece, hidden otherwise. The renderer writes
keyframes per piece (named by a hash of their content) into one <style>
per view. Each piece is its own sprite with the lane strip as its sort
extent (ext_reset), mitred at the corners, so movers pass in front of and
behind trees, lamps and houses. Movers that look alike and never meet on
a piece share one drawing. iso-travel pauses (not hides) in still,
reduced-motion and ghosted views.

Studio: meta shape "path" draws point by point (snapping like runs) and
saves one object with pts; ending on the first point closes the loop.
MCP: pts in the object schema, route guidance.

Demos: streets gets traffic round the block, a bus, motorbikes, cyclists
and strolling people (replacing the one-axis cars); country gets cars on
the lane.

Tests: Rust 79 (each mover on exactly one piece at every moment, in every
rotation), Studio 125, web 99.
This commit is contained in:
grabowski committed 2026-10-25 17:52:17 +07:00
1 parent ccfd619a99
commit 5ec164a633
20 files changed
+698 -23

No files matched your search

+115
View File
@@ -858,6 +858,121 @@ fn piece(id: &str, kind: &str, a: [f64; 2], b: [f64; 2], props: Value) -> Object
o
}
/// 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();