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
+697 -22

No files matched your search

+9
View File
@@ -90,6 +90,15 @@ trunks, rabbits hop, birds flap. `examples/country` (Country demo in the web
Studio) puts them in a countryside corner with a lane, fence and hedgerow at Studio) puts them in a countryside corner with a lane, fence and hedgerow at
an angle. an angle.
**Routes**: a Route moves cars, buses, motorbikes, cyclists, people, deer or
elephants along waypoints. Pick Route and click point after point on the map
(points snap onto road and wall ends; double-click or Enter ends it, the
first point again makes a loop). A loop is driven round, an open route there
and back; each way keeps to the left of the line (or the right), corners are
rounded, and the movers pass in front of and behind trees and houses as they
go. The streets demo has traffic round the block, a bus, motorbikes, cyclists
and people strolling.
**Fences and walls** around the plot: the Fence has six designs (mesh, wooden **Fences and walls** around the plot: the Fence has six designs (mesh, wooden
boards, steel bars, mesh on a stone base, cobble wall, brick wall with boards, steel bars, mesh on a stone base, cobble wall, brick wall with
pillars). Fences and site walls whose ends meet join like house walls: corners pillars). Fences and site walls whose ends meet join like house walls: corners
+11
View File
@@ -201,6 +201,17 @@ pub enum Prim {
}, },
/// Items drawn back to front by the depth of their plan anchor. /// Items drawn back to front by the depth of their plan anchor.
Seq { items: Vec<(P2, Vec<Prim>)> }, Seq { items: Vec<(P2, Vec<Prim>)> },
/// A mover on one straight piece of a route: the items, drawn at `from`, slide to
/// `to` (world points) during each window and are hidden the rest of the cycle of
/// `dur` seconds. A window is [start, end] in fractions of the cycle; it may run
/// past 1 and wraps round.
Travel {
from: P3,
to: P3,
dur: f64,
wins: Vec<[f64; 2]>,
items: Vec<Prim>,
},
} }
/// One fish of a [`Prim::Swim`]: it goes round an ellipse of radii (rx, ry) /// One fish of a [`Prim::Swim`]: it goes round an ellipse of radii (rx, ry)
+90
View File
@@ -87,6 +87,8 @@ struct Ctx<'a> {
p: Proj, p: Proj,
uid: &'a str, uid: &'a str,
out: String, out: String,
/// Keyframes of the route movers by name, written once in one `<style>` at the end.
keyframes: BTreeMap<String, String>,
} }
impl Ctx<'_> { impl Ctx<'_> {
@@ -572,6 +574,31 @@ impl Ctx<'_> {
} }
self.out.push_str("</g>"); self.out.push_str("</g>");
} }
Prim::Travel {
from,
to,
dur,
wins,
items,
} => {
let (a, b) = (
self.p.s(from[0], from[1], from[2]),
self.p.s(to[0], to[1], to[2]),
);
let kf = travel_keyframes(b[0] - a[0], b[1] - a[1], wins);
// named by its content: views on one page never clash, equal ones share
let name = format!("iso-tv{:08x}", fnv(&kf));
let _ = write!(
self.out,
"<g class=\"iso-travel\" style=\"animation:{name} {}s linear infinite\">",
r3s(*dur)
);
self.keyframes.entry(name).or_insert(kf);
for q in items {
self.prim(q);
}
self.out.push_str("</g>");
}
Prim::Seq { items } => { Prim::Seq { items } => {
let p = self.p; let p = self.p;
let mut idx: Vec<usize> = (0..items.len()).collect(); let mut idx: Vec<usize> = (0..items.len()).collect();
@@ -589,6 +616,61 @@ impl Ctx<'_> {
} }
} }
/// FNV-1a hash of a text (keyframe names).
fn fnv(s: &str) -> u32 {
s.bytes().fold(0x811c_9dc5u32, |h, b| {
(h ^ b as u32).wrapping_mul(0x0100_0193)
})
}
/// Keyframes of a [`Prim::Travel`]: hidden outside the windows, visible inside, sliding
/// by (dx, dy) screen units from each window's start to its end.
fn travel_keyframes(dx: f64, dy: f64, wins: &[[f64; 2]]) -> String {
const EPS: f64 = 2e-5;
// (time, visible, how far along) at the ends of every window, wrapped into 0..1
let mut pts: Vec<(f64, bool, f64)> = vec![];
for w in wins {
let (s, e) = (w[0].rem_euclid(1.0), w[0].rem_euclid(1.0) + (w[1] - w[0]));
if e - s <= 2.0 * EPS {
continue;
}
let at = |t: f64| ((t - s) / (e - s)).clamp(0.0, 1.0);
pts.push((s, false, 0.0));
pts.push((s + EPS, true, at(s + EPS)));
if e <= 1.0 {
pts.push((e, true, 1.0));
pts.push(((e + EPS).min(1.0), false, 1.0));
} else {
pts.push((1.0, true, at(1.0)));
pts.push((0.0, true, at(1.0)));
pts.push((e - 1.0, true, 1.0));
pts.push((e - 1.0 + EPS, false, 1.0));
}
}
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut out = String::new();
let put = |t: f64, vis: bool, f: f64, out: &mut String| {
let _ = write!(
out,
"{}%{{visibility:{};transform:translate({}px,{}px)}}",
r3s(t * 100.0),
if vis { "visible" } else { "hidden" },
r3s(dx * f),
r3s(dy * f)
);
};
if pts.first().is_none_or(|p| p.0 > 0.0) {
put(0.0, false, pts.first().map_or(0.0, |p| p.2), &mut out);
}
for &(t, vis, f) in &pts {
put(t, vis, f, &mut out);
}
if pts.last().is_none_or(|p| p.0 < 1.0) {
put(1.0, false, pts.last().map_or(0.0, |p| p.2), &mut out);
}
out
}
/// The sprite's convex plan outline in view space when it is not its box. /// The sprite's convex plan outline in view space when it is not its box.
pub fn hull(s: &Sprite, p: Proj, bb: &Bb) -> Option<sort::Hull> { pub fn hull(s: &Sprite, p: Proj, bb: &Bb) -> Option<sort::Hull> {
if s.ext.len() < 3 { if s.ext.len() < 3 {
@@ -627,6 +709,7 @@ pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered {
p, p,
uid: &uid, uid: &uid,
out: String::with_capacity(64 * 1024), out: String::with_capacity(64 * 1024),
keyframes: BTreeMap::new(),
}; };
let mut frame = [1e9f64, 1e9, -1e9, -1e9]; let mut frame = [1e9f64, 1e9, -1e9, -1e9];
let mut sprites = 0; let mut sprites = 0;
@@ -743,6 +826,13 @@ pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered {
); );
} }
cx.out.push_str("</g>"); cx.out.push_str("</g>");
if !cx.keyframes.is_empty() {
cx.out.push_str("<style>");
for (name, kf) in &cx.keyframes {
let _ = write!(cx.out, "@keyframes {name}{{{kf}}}");
}
cx.out.push_str("</style>");
}
// frame: the scene bounds (plan rectangle down to the ground depth) or the drawing // frame: the scene bounds (plan rectangle down to the ground depth) or the drawing
if let Some((b, zb)) = scene.bounds { if let Some((b, zb)) = scene.bounds {
+44 -3
View File
@@ -123,6 +123,13 @@ enum Frame {
items: Vec<(P2, Vec<Prim>)>, items: Vec<(P2, Vec<Prim>)>,
cur: Option<(P2, Vec<Prim>)>, cur: Option<(P2, Vec<Prim>)>,
}, },
Travel {
from: P3,
to: P3,
dur: f64,
wins: Vec<[f64; 2]>,
items: Vec<Prim>,
},
} }
#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[derive(Clone, Debug, Default, Serialize, Deserialize)]
@@ -172,7 +179,7 @@ impl Sprite {
/// the sprite. /// the sprite.
pub fn push(&mut self, p: Prim) -> &mut Self { pub fn push(&mut self, p: Prim) -> &mut Self {
match self.frames.last_mut() { match self.frames.last_mut() {
Some(Frame::Group { items, .. }) => items.push(p), Some(Frame::Group { items, .. }) | Some(Frame::Travel { items, .. }) => items.push(p),
Some(Frame::Seq { cur, .. }) => match cur { Some(Frame::Seq { cur, .. }) => match cur {
Some((_, items)) => items.push(p), Some((_, items)) => items.push(p),
// a primitive before the first seq_item starts an item at the origin // a primitive before the first seq_item starts an item at the origin
@@ -622,9 +629,43 @@ impl Sprite {
} }
pub fn end_group(&mut self) -> &mut Self { pub fn end_group(&mut self) -> &mut Self {
if let Some(Frame::Group { cls, style, items }) = self.frames.pop() { match self.frames.pop() {
Some(Frame::Group { cls, style, items }) => {
self.push(Prim::Group { cls, style, items }); self.push(Prim::Group { cls, style, items });
} }
Some(Frame::Travel {
from,
to,
dur,
wins,
items,
}) => {
self.push(Prim::Travel {
from,
to,
dur,
wins,
items,
});
}
Some(f) => self.frames.push(f),
None => {}
}
self
}
/// Open a mover on a straight piece from `a` to `b` (points of this frame): what
/// is drawn until [`Sprite::end_group`] slides from a to b during each window of
/// the `dur`-second cycle and is hidden otherwise (see [`Prim::Travel`]).
pub fn begin_travel(&mut self, a: P3, b: P3, dur: f64, wins: Vec<[f64; 2]>) -> &mut Self {
let (from, to) = (self.xf.p3(a), self.xf.p3(b));
self.frames.push(Frame::Travel {
from,
to,
dur,
wins,
items: vec![],
});
self self
} }
@@ -674,7 +715,7 @@ impl Sprite {
let n = self.frames.len(); let n = self.frames.len();
while let Some(f) = self.frames.last() { while let Some(f) = self.frames.last() {
match f { match f {
Frame::Group { .. } => { Frame::Group { .. } | Frame::Travel { .. } => {
self.end_group(); self.end_group();
} }
Frame::Seq { .. } => { Frame::Seq { .. } => {
+5
View File
@@ -29,6 +29,9 @@ fences, hedges, house walls) can run at any angle: a piece from (x0, y0) to (x1,
w = the distance and rot = atan2(y1 - y0, x1 - x0) in degrees; where diagonal pieces meet, corners are mitred. \ w = the distance and rot = atan2(y1 - y0, x1 - x0) in degrees; where diagonal pieces meet, corners are mitred. \
Animals (category Animals: deer, boar, fox, elephant, rabbits, birds, butterflies) are herds: props.n animals \ Animals (category Animals: deer, boar, fox, elephant, rabbits, birds, butterflies) are herds: props.n animals \
scattered within props.r metres of x, y, facing about the rotation; keep them out of roads, ponds and buildings. \ scattered within props.r metres of x, y, facing about the rotation; keep them out of roads, ponds and buildings. \
A route (type route) moves props.n movers (props.kind: car, bus, motorbike, cyclist, person, deer, elephant) along \
waypoints pts (relative to x, y): round them when props.closed, else there and back; each way keeps to the left \
of the line (props.keep). For traffic, put the points on the road centre lines; keep routes clear of buildings. \
Production lines (category Industry): a producer (props.rate per minute, product) puts goods out on its local +x \ Production lines (category Industry): a producer (props.rate per minute, product) puts goods out on its local +x \
side; a conveyor (rot 0 runs towards +x, 90 +y, 180 -x, 270 -y; w = length, props.speed m/s) starting exactly \ side; a conveyor (rot 0 runs towards +x, 90 +y, 180 -x, 270 -y; w = length, props.speed m/s) starting exactly \
at that side carries them, and a conveyor whose start is at another's end carries on with them (corners join). \ at that side carries them, and a conveyor whose start is at another's end carries on with them (corners join). \
@@ -58,6 +61,8 @@ pub fn tools() -> Vec<Value> {
"info": { "type": "string", "description": "tooltip text" }, "info": { "type": "string", "description": "tooltip text" },
"tag": { "type": "string", "description": "label pinned above the object" }, "tag": { "type": "string", "description": "label pinned above the object" },
"levels": { "type": "array", "items": { "type": "string" }, "description": "levels it shows on (empty: all)" }, "levels": { "type": "array", "items": { "type": "string" }, "description": "levels it shows on (empty: all)" },
"pts": { "type": "array", "items": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 },
"description": "points [[x, y], ...] relative to x, y (in the object's turned frame): a route's waypoints, an area's outline" },
}); });
let mut add = obj_fields.as_object().unwrap().clone(); let mut add = obj_fields.as_object().unwrap().clone();
add.insert("type".into(), json!({ "type": "string", "description": "prefab type, e.g. building, tree, car (list_prefabs)" })); add.insert("type".into(), json!({ "type": "string", "description": "prefab type, e.g. building, tree, car (list_prefabs)" }));
+18
View File
@@ -519,6 +519,24 @@ pub fn register_sprite(e: &mut Engine) {
s.0.borrow_mut().end_group(); s.0.borrow_mut().end_group();
s.clone() s.clone()
}); });
// a mover on one straight piece of a route (Prim::Travel); close with end()
e.register_fn(
"travel",
|s: &mut SpriteH, a: D, b: D, dur: D, wins: D| -> RR<SpriteH> {
let wins = arr(&wins, "travel windows")?
.iter()
.map(|w| p2(w).map(|p| [p[0], p[1]]))
.collect::<RR<Vec<_>>>()?;
s.0.borrow_mut()
.begin_travel(p3(&a)?, p3(&b)?, n(&dur)?, wins);
Ok(s.clone())
},
);
// forget the sort extent drawn so far (set it with ext() afterwards)
e.register_fn("ext_reset", |s: &mut SpriteH| -> SpriteH {
s.0.borrow_mut().ext.clear();
s.clone()
});
e.register_fn( e.register_fn(
"group", "group",
|ctx: NativeCallContext, s: &mut SpriteH, cls: &str, f: FnPtr| -> RR<SpriteH> { |ctx: NativeCallContext, s: &mut SpriteH, cls: &str, f: FnPtr| -> RR<SpriteH> {
+1 -1
View File
@@ -23,7 +23,7 @@ pub fn n(d: &Dynamic) -> RR<f64> {
err(format!("expected a number, got {}", d.type_name())) err(format!("expected a number, got {}", d.type_name()))
} }
fn arr(d: &Dynamic, what: &str) -> RR<Array> { pub fn arr(d: &Dynamic, what: &str) -> RR<Array> {
match d.read_lock::<Array>() { match d.read_lock::<Array>() {
Some(a) => Ok(a.clone()), Some(a) => Ok(a.clone()),
None => err(format!("{what}: expected an array, got {}", d.type_name())), None => err(format!("{what}: expected an array, got {}", d.type_name())),
+1
View File
@@ -10,6 +10,7 @@ pub const MODULES: &[(&str, &str)] = &[
("isokit/ground", include_str!("../stdlib/ground.rhai")), ("isokit/ground", include_str!("../stdlib/ground.rhai")),
("isokit/streets", include_str!("../stdlib/streets.rhai")), ("isokit/streets", include_str!("../stdlib/streets.rhai")),
("isokit/wildlife", include_str!("../stdlib/wildlife.rhai")), ("isokit/wildlife", include_str!("../stdlib/wildlife.rhai")),
("isokit/routes", include_str!("../stdlib/routes.rhai")),
("isokit/nature", include_str!("../stdlib/nature.rhai")), ("isokit/nature", include_str!("../stdlib/nature.rhai")),
("isokit/building", include_str!("../stdlib/building.rhai")), ("isokit/building", include_str!("../stdlib/building.rhai")),
("isokit/vehicles", include_str!("../stdlib/vehicles.rhai")), ("isokit/vehicles", include_str!("../stdlib/vehicles.rhai")),
+115
View File
@@ -858,6 +858,121 @@ fn piece(id: &str, kind: &str, a: [f64; 2], b: [f64; 2], props: Value) -> Object
o 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] #[test]
fn wildlife_and_garden_prefabs_draw_from_every_side() { fn wildlife_and_garden_prefabs_draw_from_every_side() {
let r = Runtime::new(&Bundle::default()).unwrap(); let r = Runtime::new(&Bundle::default()).unwrap();
+281
View File
@@ -0,0 +1,281 @@
// isokit/routes: movers on a path of waypoints - cars, buses, motorbikes,
// cyclists, people, deer, elephants - going round a loop or there and back.
//
// The path is cut into straight pieces (corners rounded into short ones), each
// piece its own sprite whose sort box is the lane strip, mitred at the corners
// so neighbouring pieces never overlap. A mover is drawn at a piece's start and
// slides to its end while it is on that piece (`travel`), so the depth sort
// stays right as it goes past trees, lamps and houses.
fn route_kinds() { ["car", "bus", "motorbike", "cyclist", "person", "deer", "elephant"] }
/// A mover of `kind`: length, width, height, usual speed (m/s), lane offset
/// from the drawn line, corner radius and its name.
fn mover_spec(kind) {
switch kind {
"car" => #{ l: 4.4, w: 1.8, h: 1.5, v: 8.0, lane: 1.5, r: 4.0, one: "car", many: "cars" },
"bus" => #{ l: 10.0, w: 2.5, h: 3.0, v: 6.0, lane: 1.6, r: 7.0, one: "bus", many: "buses" },
"motorbike" => #{ l: 1.9, w: 0.7, h: 1.7, v: 7.0, lane: 1.2, r: 2.5, one: "motorbike", many: "motorbikes" },
"cyclist" => #{ l: 1.8, w: 0.6, h: 1.9, v: 4.0, lane: 0.7, r: 2.0, one: "cyclist", many: "cyclists" },
"deer" => #{ l: 1.6, w: 0.5, h: 1.6, v: 1.2, lane: 0.5, r: 1.0, one: "deer", many: "deer" },
"elephant" => #{ l: 3.6, w: 1.8, h: 3.0, v: 1.0, lane: 1.3, r: 3.0, one: "elephant", many: "elephants" },
// people look the same from every side: no rounded corners, fewer pieces
_ => #{ l: 0.5, w: 0.5, h: 1.75, v: 1.3, lane: 0.4, r: 0.0, one: "person", many: "people" },
}
}
fn route_colours() { ["mixed"] + car_colours() + people_colours() }
/// The look (colour class) of mover k.
fn mover_look(kind, colour, k) {
if colour != "mixed" { return colour; }
if kind == "car" || kind == "bus" || kind == "motorbike" { car_colours()[k % 5] }
else if kind == "person" || kind == "cyclist" { people_colours()[k % 7] }
else { "" }
}
/// Draws a mover of `kind` centred at (0, 0), facing local +x.
fn mover(kind, look) {
if kind == "car" {
this.car(-2.2, -0.9, 2.2, 0.9, look);
} else if kind == "bus" {
this.box(-5.0, -1.25, 5.0, 1.25, 0.35, 1.6, look);
this.box(-4.9, -1.2, 4.9, 1.2, 1.6, 2.6, "glassd");
this.box(-5.0, -1.25, 5.0, 1.25, 2.6, 3.0, look);
} else if kind == "motorbike" {
this.box(-0.95, -0.3, 0.95, 0.3, 0.3, 0.85, look);
this.person(0.0, 0.0, 0.55);
} else if kind == "cyclist" {
this.box(-0.85, -0.05, 0.85, 0.05, 0.3, 0.7, "steel");
this.person(0.0, 0.0, 0.5, look);
} else if kind == "deer" {
let a = animal_parts("deer", "doe");
this.draw_parts(a.body + a.up);
} else if kind == "elephant" {
let a = animal_parts("elephant", "cow");
this.draw_parts(a.body + a.up);
} else {
this.person(0.0, 0.0, 0.0, look);
}
this
}
// ---------------------------------------------------------------- plan helpers
fn rt_sub(a, b) { [a[0] - b[0], a[1] - b[1]] }
fn rt_add(a, b) { [a[0] + b[0], a[1] + b[1]] }
fn rt_mul(a, k) { [a[0] * k, a[1] * k] }
fn rt_len(a) { (a[0] * a[0] + a[1] * a[1]).sqrt() }
fn rt_unit(a) { let l = fmax(rt_len(a), 1e-9); [a[0] / l, a[1] / l] }
/// The left normal of a direction.
fn rt_left(u) { [-u[1], u[0]] }
/// The waypoints in the object's frame: its pts, or a 20 m line along local x.
/// Points closer than 5 cm to the one before are dropped, and a loop's last point
/// when it repeats the first.
fn route_pts(o, closed) {
let src = if o.pts != () && o.pts.len() >= 2 { o.pts } else { [[-10.0, 0.0], [10.0, 0.0]] };
let out = [];
for q in src {
let p = [q[0] + 0.0, q[1] + 0.0];
if out.len() == 0 || rt_len(rt_sub(p, out[out.len() - 1])) > 0.05 { out.push(p); }
}
if closed && out.len() > 2 && rt_len(rt_sub(out[0], out[out.len() - 1])) <= 0.05 { out.pop(); }
out
}
/// The line `d` metres to the left of a path (mitred corners).
fn rt_offset(pts, d, closed) {
if d == 0.0 { return pts; }
let n = pts.len();
let out = [];
for i in 0..n {
let has_prev = closed || i > 0;
let has_next = closed || i < n - 1;
let n1 = if has_prev { rt_left(rt_unit(rt_sub(pts[i], pts[(i + n - 1) % n]))) } else { () };
let n2 = if has_next { rt_left(rt_unit(rt_sub(pts[(i + 1) % n], pts[i]))) } else { () };
if type_of(n1) == "()" { n1 = n2; }
if type_of(n2) == "()" { n2 = n1; }
let s = 1.0 + n1[0] * n2[0] + n1[1] * n2[1];
let m = if s < 0.3 { n2 } else { rt_mul(rt_add(n1, n2), 1.0 / s) };
out.push(rt_add(pts[i], rt_mul(m, d)));
}
out
}
/// A closed path with its corners rounded to radius r: each corner becomes an
/// arc of short straight pieces (at most 45 degrees each).
fn rt_round(pts, r) {
let n = pts.len();
if r <= 0.0 || n < 3 { return pts; }
let out = [];
for i in 0..n {
let p = pts[i];
let a = pts[(i + n - 1) % n];
let b = pts[(i + 1) % n];
let la = rt_len(rt_sub(p, a));
let lb = rt_len(rt_sub(b, p));
let u = rt_unit(rt_sub(p, a));
let v = rt_unit(rt_sub(b, p));
let c = fmax(-1.0, fmin(1.0, u[0] * v[0] + u[1] * v[1]));
let turn = c.acos();
if turn < 0.05 { out.push(p); continue; }
let t = fmin(r * (turn / 2.0).tan(), fmin(0.45 * la, 0.45 * lb));
let rr = t / (turn / 2.0).tan();
let side = if u[0] * v[1] - u[1] * v[0] > 0.0 { 1.0 } else { -1.0 };
let p0 = rt_sub(p, rt_mul(u, t));
let cc = rt_add(p0, rt_mul(rt_left(u), rr * side));
let a0 = (p0[1] - cc[1]).atan(p0[0] - cc[0]);
let steps = fmax(1.0, (turn / (PI() / 4.0) - 1e-6).ceiling()).to_int();
out.push(p0);
for k in 1..=steps {
let ang = a0 + side * turn * k / steps;
out.push([cc[0] + rr * ang.cos(), cc[1] + rr * ang.sin()]);
}
}
out
}
/// The closed path the movers follow, in the object's frame: a loop keeps
/// `lane` metres to the left of the drawn line (right when negative); there and
/// back runs on each side and turns at the ends. Corners rounded to r.
fn route_path(o, spec, lane, r) {
let p = o.p;
let pts = route_pts(o, p.closed);
if pts.len() < 2 { return []; }
let path = if p.closed && pts.len() >= 3 {
rt_offset(pts, lane, true)
} else {
// each way keeps to its side, so the two strips never overlap
let s = if lane < 0.0 { -1.0 } else { 1.0 };
let d = s * fmax(lane.abs(), spec.w / 2.0 + 0.15);
let back = pts;
back.reverse();
rt_offset(pts, d, false) + rt_offset(back, d, false)
};
rt_round(path, r)
}
/// For movers on one piece: the windows (fractions of the cycle) in which mover
/// k is on it, grouped into tracks that never show two at once; movers on one
/// track share one drawing, so they must look alike. Returns [#{ look, wins }].
fn rt_tracks(kind, colour, n, s0, w) {
let gap = 1e-4;
let by_look = #{};
for k in 0..n {
let st = s0 - k.to_float() / n;
st -= st.floor();
let look = mover_look(kind, colour, k);
let key = `l${look}`;
let g = if by_look.contains(key) { by_look[key] } else { #{ look: look, wins: [] } };
g.wins.push([st, st + w]);
by_look[key] = g;
}
let out = [];
for key in by_look.keys() {
let g = by_look[key];
let ws = g.wins;
ws.sort(|a, b| if a[0] < b[0] { -1 } else if a[0] > b[0] { 1 } else { 0 });
let tracks = [];
for win in ws {
let placed = false;
for i in 0..tracks.len() {
let t = tracks[i];
if !placed && win[0] >= t.last + gap && win[1] <= t.first + 1.0 - gap {
t.wins.push(win);
t.last = win[1];
tracks[i] = t;
placed = true;
}
}
if !placed { tracks.push(#{ first: win[0], last: win[1], wins: [win] }); }
}
for t in tracks { out.push(#{ look: g.look, wins: t.wins }); }
}
out
}
fn meta_route() {
#{ label: "Route", category: "Routes", icon: "〰", shape: "path",
help: "Movers on a path of waypoints: click point after point on the map (double-click or Enter ends it; ending on the first point makes a loop). Cars, buses, motorbikes, cyclists, people, deer or elephants go round the loop, or there and back, keeping to one side of the line.",
props: [
#{ key: "kind", type: "choice", def: "car", choices: route_kinds(), label: "Movers" },
#{ key: "n", type: "int", def: 3, min: 1, max: 30, step: 1, label: "How many" },
#{ key: "speed", type: "float", def: 0.0, min: 0.0, max: 40.0, step: 0.1, label: "Speed m/s (0: usual)" },
#{ key: "closed", type: "bool", def: false, label: "Loop" },
#{ key: "keep", type: "choice", def: "left", choices: ["left", "right"], label: "Keep to" },
#{ key: "colour", type: "choice", def: "mixed", choices: route_colours(), label: "Colour" },
#{ key: "path", type: "bool", def: true, label: "Show path" },
] }
}
fn prefab_route(o) {
let p = o.p;
let spec = mover_spec(p.kind);
let v = if p.speed > 0.0 { p.speed } else { spec.v };
let lane = if p.keep == "right" { -spec.lane } else { spec.lane };
let pts = route_pts(o, p.closed);
if p.path && pts.len() >= 2 {
layer("ground");
let line = pts;
if p.closed && pts.len() >= 3 { line.push(pts[0]); }
sprite().polyline(line, 0.03, "route");
layer("main");
}
let path = route_path(o, spec, lane, spec.r);
let n = path.len();
if n < 2 { return; }
// pieces, their lengths and where each starts along the loop
let segs = [];
let total = 0.0;
for i in 0..n {
let a = path[i];
let b = path[(i + 1) % n];
let len = rt_len(rt_sub(b, a));
if len > 1e-3 { segs.push(#{ a: a, b: b, len: len, at: total }); total += len; }
}
if segs.len() < 2 || total <= 0.0 { return; }
let dur = total / v;
let m = segs.len();
// the sort strip of each piece, mitred at both ends
let hw = spec.w / 2.0 + 0.1;
let mitre = [];
for i in 0..m {
let n1 = rt_left(rt_unit(rt_sub(segs[(i + m - 1) % m].b, segs[(i + m - 1) % m].a)));
let n2 = rt_left(rt_unit(rt_sub(segs[i].b, segs[i].a)));
let s = 1.0 + n1[0] * n2[0] + n1[1] * n2[1];
mitre.push(if s < 0.3 { n2 } else { rt_mul(rt_add(n1, n2), 1.0 / s) });
}
for i in 0..m {
let sg = segs[i];
let u = rt_unit(rt_sub(sg.b, sg.a));
let ang = u[1].atan(u[0]);
let w = world_of(o, sg.a[0], sg.a[1]);
frame(w[0], w[1], o.z, o.rot + ang * 180.0 / PI());
let s = sprite();
for t in rt_tracks(p.kind, p.colour, p.n, sg.at / total, sg.len / total) {
s.travel([0.0, 0.0, 0.0], [sg.len, 0.0, 0.0], dur, t.wins);
s.mover(p.kind, t.look);
s.end();
}
// sort by the strip only: the start drawing is never seen standing there
s.ext_reset();
let ends = [[sg.a, mitre[i]], [sg.b, mitre[(i + 1) % m]]];
for e in ends {
for side in [1.0, -1.0] {
let q = rt_sub(rt_add(e[0], rt_mul(e[1], side * hw)), sg.a);
let lx = q[0] * u[0] + q[1] * u[1];
let ly = -q[0] * u[1] + q[1] * u[0];
s.ext(lx, ly, 0.0);
s.ext(lx, ly, spec.h);
}
}
}
frame(o);
let noun = if p.n == 1 { spec.one } else { spec.many };
info_row("Movers", `${p.n} ${noun}, ${num1(v)} m/s, round in ${num1(dur)} s`);
let base = 0.0;
for i in 1..pts.len() { base += rt_len(rt_sub(pts[i], pts[i - 1])); }
if p.closed && pts.len() >= 3 { base += rt_len(rt_sub(pts[0], pts[pts.len() - 1])); }
info_row("Route", if p.closed && pts.len() >= 3 { `loop, ${num1(base)} m` } else { `there and back, ${num1(base)} m each way` });
}
+4
View File
@@ -272,6 +272,8 @@ mach3 = "#5b6370"
mach4 = "#b8692b" mach4 = "#b8692b"
robot = "#e07a1f" robot = "#e07a1f"
robotb = "#3a3f48" robotb = "#3a3f48"
# route paths
route = "#e0b84a"
# wildlife and garden # wildlife and garden
deer = "#8a5a32" deer = "#8a5a32"
deerl = "#d6c4a6" deerl = "#d6c4a6"
@@ -581,6 +583,7 @@ mach3 = "#8a93a1"
mach4 = "#e08a43" mach4 = "#e08a43"
robot = "#f08c2b" robot = "#f08c2b"
robotb = "#5a616d" robotb = "#5a616d"
route = "#b0802a"
deer = "#a8703f" deer = "#a8703f"
deerl = "#ecdfc8" deerl = "#ecdfc8"
nose = "#3a302a" nose = "#3a302a"
@@ -918,6 +921,7 @@ f-conc = "conc"
".iso-svg .iso-win" = "fill: var(--iso-win); stroke: none" ".iso-svg .iso-win" = "fill: var(--iso-win); stroke: none"
".iso-svg .iso-school" = "pointer-events: none" ".iso-svg .iso-school" = "pointer-events: none"
".iso-svg .iso-swimbox" = "fill: none; stroke: none" ".iso-svg .iso-swimbox" = "fill: none; stroke: none"
".iso-svg .iso-route" = "fill: none; stroke: var(--iso-route); stroke-width: 1.2; stroke-dasharray: 5 4; stroke-linecap: round; stroke-linejoin: round; opacity: .75"
".iso-svg .iso-antler" = "fill: none; stroke: var(--iso-antler); stroke-width: .35; stroke-linecap: round; stroke-linejoin: round" ".iso-svg .iso-antler" = "fill: none; stroke: var(--iso-antler); stroke-width: .35; stroke-linecap: round; stroke-linejoin: round"
".iso-svg .iso-bristle" = "fill: none; stroke: var(--iso-bristle); stroke-width: .5; stroke-linecap: round" ".iso-svg .iso-bristle" = "fill: none; stroke: var(--iso-bristle); stroke-width: .5; stroke-linecap: round"
".iso-svg .iso-bird, .iso-svg .iso-wing" = "stroke: none" ".iso-svg .iso-bird, .iso-svg .iso-wing" = "stroke: none"
+8 -1
View File
@@ -80,7 +80,7 @@ Each phase ends with tests, a Studio/viewer check in Edge, commit and push.
5. ~~Rounded streets: curved road segments~~ (done, see below) 5. ~~Rounded streets: curved road segments~~ (done, see below)
6. ~~Sidewalks along streets~~ (done, see below) 6. ~~Sidewalks along streets~~ (done, see below)
7. ~~Roundabouts~~ (done, see below) 7. ~~Roundabouts~~ (done, see below)
8. Waypoint animation: a person or car moving along a path of waypoints 8. ~~Waypoint animation: a person or car moving along a path of waypoints~~ (done, see below)
9. ~~Build your own house~~ (done, see above) 9. ~~Build your own house~~ (done, see above)
10. ~~Diagonal streets, walls, fences, hedges~~ (done, see below) 10. ~~Diagonal streets, walls, fences, hedges~~ (done, see below)
11. ~~Wildlife and nature: deer, bushes, beehives and more~~ (done, see below) 11. ~~Wildlife and nature: deer, bushes, beehives and more~~ (done, see below)
@@ -145,6 +145,13 @@ turns while grazing, rabbits hopping; Birds and Butterflies as flocks (`swim` sh
out of step); Beehives with bees, Bush, Flowers (bed or meadow), Rocks, Log / stump. out of step); Beehives with bees, Bush, Flowers (bed or meadow), Rocks, Log / stump.
`examples/country`, Country demo in the web Studio. `examples/country`, Country demo in the web Studio.
Routes (`routes.rhai`, `travel` primitive): cars, buses, motorbikes, cyclists, people, deer and
elephants along waypoints drawn point by point in the Studio (`shape: "path"`, `pts`); loops or
there and back, each way on its side of the line, rounded corners. Every straight piece is a
sprite whose sort box is its lane strip, so movers pass in front of and behind what stands
beside the road; keyframes per piece show a mover only while it is on it. Streets demo: traffic
round the block, a bus, motorbikes, cyclists, people; country demo: cars on the lane.
## Phase 2: richer editing ## Phase 2: richer editing
- [x] Interior prefabs (see Build your own house); a building `rooms` prop is still open - [x] Interior prefabs (see Build your own house); a building `rooms` prop is still open
+21
View File
@@ -95,6 +95,8 @@ shaded automatically by face direction.
| `swim(x, y, z, [#{ rx, ry, dur, delay, cw, len, cls[, shape] }, ...])` | fish going round ellipses (radii rx, ry along the object's axes) about (x, y) on the plane at z, one round per `dur` s, clockwise when `cw`; `cls` koi / koiw / koig (the pond); `shape` "bird" (wings `len` across, flapping), "fly" (a butterfly) or "bee" makes it a flock | | `swim(x, y, z, [#{ rx, ry, dur, delay, cw, len, cls[, shape] }, ...])` | fish going round ellipses (radii rx, ry along the object's axes) about (x, y) on the plane at z, one round per `dur` s, clockwise when `cw`; `cls` koi / koiw / koig (the pond); `shape` "bird" (wings `len` across, flapping), "fly" (a butterfly) or "bee" makes it a flock |
| `seq([[x, y, \|q\| …], …])` | items drawn back to front by their anchor | | `seq([[x, y, \|q\| …], …])` | items drawn back to front by their anchor |
| `group(cls[, css], \|q\| …)`, `begin(cls[, css])` … `end()` | `<g>` around parts (animation) | | `group(cls[, css], \|q\| …)`, `begin(cls[, css])` … `end()` | `<g>` around parts (animation) |
| `travel(a, b, dur, [[s, e], …])` … `end()` | a mover on a straight piece: what is drawn (at a) slides to b during each window (fractions of the `dur`-second cycle, may wrap past 1) and is hidden otherwise |
| `ext_reset()` | forget the sort extent drawn so far (set it with `ext` afterwards) |
| `ext(x, y, z)`, `ext_box(…)` | widen the sort box | | `ext(x, y, z)`, `ext_box(…)` | widen the sort box |
| `raw([x, y, z,] svg)` | raw SVG (`{uid}` becomes the view's id) | | `raw([x, y, z,] svg)` | raw SVG (`{uid}` becomes the view's id) |
@@ -166,6 +168,25 @@ spread within `r` of the object, each facing its rotation give or take 70°,
each its own sprite. Flocks of birds, butterflies and bees use `swim` with a each its own sprite. Flocks of birds, butterflies and bees use `swim` with a
`shape`. `shape`.
## Routes
`routes.rhai` (`route` prefab, meta `shape: "path"`: the Studio draws it point by point and
stores the points in the object's `pts`, relative to its x, y). The movers' path is one closed
line: a loop is offset to the left of the drawn line by the kind's lane (`keep: "right"` the
other way); an open route runs out on one side and back on the other, so it is a loop too, and
its two strips never overlap. Corners are rounded into pieces of at most 45° (`rt_round`;
people keep sharp corners, they look the same from every side).
Each straight piece is its own sprite in the piece's frame: for every group of movers that look
alike and are never on the piece together, `travel([0,0,0], [len,0,0], dur, wins)` with the
mover drawn at the start, then `ext_reset()` and the lane strip as its extent, mitred at both
ends so neighbouring pieces never overlap. Mover k is on a piece that starts `at` metres along
a loop of `total` metres while `(t / dur - at / total + k / n) mod 1` lies in `[0, len / total]`.
The renderer turns each travel into keyframes (visibility and translation, named by a hash of
their content, all in one `<style>` per view). Movers carry `iso-travel`, not `iso-a`: views
without animation (reduced motion, stopped, ghosted) pause them where they are instead of
hiding them.
## Production lines ## Production lines
`producer`, `conveyor` and `robot_arm` (stdlib/industry.rhai) work together `producer`, `conveyor` and `robot_arm` (stdlib/industry.rhai) work together
+2 -1
View File
@@ -39,6 +39,7 @@
{"id": "bush-5", "type": "bush", "x": -36, "y": -18, "rot": 0, "props": {"r": 0.8, "kind": "flowering", "colour": "pink"}}, {"id": "bush-5", "type": "bush", "x": -36, "y": -18, "rot": 0, "props": {"r": 0.8, "kind": "flowering", "colour": "pink"}},
{"id": "pond", "type": "pond", "name": "Pond", "x": 36, "y": -6, "rot": 0, "props": {"w": 11, "d": 8, "fish": 6, "lilies": 4}}, {"id": "pond", "type": "pond", "name": "Pond", "x": 36, "y": -6, "rot": 0, "props": {"w": 11, "d": 8, "fish": 6, "lilies": 4}},
{"id": "pond-rocks", "type": "rocks", "x": 42, "y": -1, "rot": 0, "props": {"n": 2, "r": 0.6, "spread": 1}}, {"id": "pond-rocks", "type": "rocks", "x": 42, "y": -1, "rot": 0, "props": {"n": 2, "r": 0.6, "spread": 1}},
{"id": "birds", "type": "birds", "name": "Birds", "x": 0, "y": 0, "rot": 0, "props": {"n": 10, "h": 18, "r": 14}} {"id": "birds", "type": "birds", "name": "Birds", "x": 0, "y": 0, "rot": 0, "props": {"n": 10, "h": 18, "r": 14}},
{"id": "lane-cars", "type": "route", "name": "Cars on the lane", "x": -14.0, "y": 2.5, "pts": [[-32.0, -32.5], [2.0, -32.5], [32.0, -2.5], [32.0, 32.5]], "props": {"kind": "car", "n": 2, "path": false, "colour": "mixed"}}
] ]
} }
+1 -1
View File
@@ -1,7 +1,7 @@
# isokit streets demo: roads with sidewalks, a rounded corner, junctions and a roundabout. # isokit streets demo: roads with sidewalks, a rounded corner, junctions and a roundabout.
# Open it with `isokit studio examples/streets`. # Open it with `isokit studio examples/streets`.
title = "isokit streets" title = "isokit streets"
note = "Streets drawn along their centre lines: curves carry them round corners, side streets open the sidewalks they join, and the roundabout's kerbs flare into its arms. Tap a street for its numbers. Drag to pan, Ctrl + wheel or pinch to zoom." note = "Streets drawn along their centre lines: curves carry them round corners, side streets open the sidewalks they join, and the roundabout's kerbs flare into its arms. Cars go round the block, a bus, motorbikes, cyclists and people along their routes. Tap a street for its numbers. Drag to pan, Ctrl + wheel or pinch to zoom."
default_level = "site" default_level = "site"
default_rotation = 1 default_rotation = 1
prelude = [] prelude = []
+7 -7
View File
@@ -23,19 +23,19 @@
{"id": "tree-3", "type": "tree", "x": 46, "y": -14}, {"id": "tree-3", "type": "tree", "x": 46, "y": -14},
{"id": "tree-4", "type": "tree", "x": 48, "y": -34, "props": {"r": 2.4}}, {"id": "tree-4", "type": "tree", "x": 48, "y": -34, "props": {"r": 2.4}},
{"id": "trees-west", "type": "tree_row", "name": "Avenue trees", "x": -36, "y": 9, "props": {"w": 28}}, {"id": "trees-west", "type": "tree_row", "name": "Avenue trees", "x": -36, "y": 9, "props": {"w": 28}},
{"id": "car-1", "type": "car", "x": 30, "y": 1.6, "props": {"moving": true, "travel": 22, "dur": 6, "colour": "car1"}},
{"id": "car-2", "type": "car", "x": 44, "y": -1.6, "rot": 180, "props": {"moving": true, "travel": 22, "dur": 7}},
{"id": "car-3", "type": "car", "x": -1.6, "y": -30, "rot": 90, "props": {"colour": "car3"}},
{"id": "bus", "type": "truck", "name": "Delivery", "x": -40, "y": 1.6, "props": {"len": 7}},
{"id": "moto", "type": "moto", "x": 1.6, "y": 26, "rot": 270},
{"id": "walker-1", "type": "person", "x": 24, "y": 4.6, "props": {"cls": "guest1"}}, {"id": "walker-1", "type": "person", "x": 24, "y": 4.6, "props": {"cls": "guest1"}},
{"id": "walker-2", "type": "person", "x": -26, "y": -4.7}, {"id": "walker-2", "type": "person", "x": -26, "y": -4.7},
{"id": "walker-3", "type": "person", "x": 31.6, "y": 30, "props": {"cls": "guest3"}}, {"id": "walker-3", "type": "person", "x": 30, "y": 30, "props": {"cls": "guest3"}},
{"id": "crowd", "type": "crowd", "name": "Pupils", "x": -24, "y": -11, "props": {"n": 6, "r": 1.6}}, {"id": "crowd", "type": "crowd", "name": "Pupils", "x": -24, "y": -11, "props": {"n": 6, "r": 1.6}},
{"id": "lamp-1", "type": "lamp", "x": 18, "y": -5.2}, {"id": "lamp-1", "type": "lamp", "x": 18, "y": -5.2},
{"id": "lamp-2", "type": "lamp", "x": 44, "y": -5.2}, {"id": "lamp-2", "type": "lamp", "x": 44, "y": -5.2},
{"id": "lamp-3", "type": "lamp", "x": -28, "y": 5.2}, {"id": "lamp-3", "type": "lamp", "x": -28, "y": 5.2},
{"id": "lamp-4", "type": "lamp", "x": -44, "y": 5.2}, {"id": "lamp-4", "type": "lamp", "x": -44, "y": 5.2},
{"id": "lamp-5", "type": "lamp", "x": 5.2, "y": -28} {"id": "lamp-5", "type": "lamp", "x": 5.2, "y": -28},
{"id": "traffic", "type": "route", "name": "Traffic round the block", "x": 17.0, "y": 23.0, "pts": [[3.0, -23.0], [17.0, -23.0], [17.0, 23.0], [-5.0, 23.0], [-11.0, 21.392], [-15.392, 17.0], [-17.0, 11.0], [-17.0, -3.0], [-12.408, -11.913], [-5.913, -18.408]], "props": {"kind": "car", "n": 4, "closed": true, "path": false}},
{"id": "bus-line", "type": "route", "name": "Bus line", "x": -25.0, "y": -20.0, "pts": [[-25.0, 20.0], [5.0, 20.0], [13.913, 15.408], [20.408, 8.913], [25.0, 0.0], [25.0, -20.0]], "props": {"kind": "bus", "n": 1, "path": false, "colour": "car4"}},
{"id": "motos", "type": "route", "name": "Motorbikes", "x": 35.75, "y": -11.75, "pts": [[-8.25, 8.25], [8.25, -8.25]], "props": {"kind": "motorbike", "n": 3, "path": false}},
{"id": "cyclists", "type": "route", "name": "Cyclists", "x": 46.5, "y": 46.0, "pts": [[-9.5, 0.0], [9.5, 0.0]], "props": {"kind": "cyclist", "n": 2, "path": false}},
{"id": "strollers", "type": "route", "name": "People strolling", "x": 38.0, "y": 23.0, "pts": [[0.0, -17.0], [0.0, 17.0]], "props": {"kind": "person", "n": 3, "path": false}}
] ]
} }
+37 -7
View File
@@ -209,6 +209,12 @@
polys.push(cornerMark(c.pts[0], c.z)); polys.push(cornerMark(c.pts[0], c.z));
var closes = c.pts.length > 2 && Math.hypot(q[0] - c.pts[0][0], q[1] - c.pts[0][1]) < 1e-6; var closes = c.pts.length > 2 && Math.hypot(q[0] - c.pts[0][0], q[1] - c.pts[0][1]) < 1e-6;
var n = c.pts.length - 1 + (Math.hypot(q[0] - last[0], q[1] - last[1]) > 0.05 ? 1 : 0); var n = c.pts.length - 1 + (Math.hypot(q[0] - last[0], q[1] - last[1]) > 0.05 ? 1 : 0);
if (c.m.shape === "path") {
ghost(polys, c.m.label + ": " + (n + 1) + " points, " + (runLength(c.pts) + Math.hypot(q[0] - last[0], q[1] - last[1])).toFixed(1) + " m · " +
(closes ? "click to close the loop" : "click the next point · double-click or Enter to finish (there and back) · click the first point for a loop") +
" · diagonals at 45°, Ctrl: any angle · Backspace undoes a point · Esc cancels");
return;
}
ghost(polys, c.m.label + ": " + n + " piece" + (n === 1 ? "" : "s") + ", " + (runLength(c.pts) + Math.hypot(q[0] - last[0], q[1] - last[1])).toFixed(1) + " m" + ghost(polys, c.m.label + ": " + n + " piece" + (n === 1 ? "" : "s") + ", " + (runLength(c.pts) + Math.hypot(q[0] - last[0], q[1] - last[1])).toFixed(1) + " m" +
(c.m.corner ? ", corners rounded" : "") + " · " + (c.m.corner ? ", corners rounded" : "") + " · " +
(closes ? "click to close the run" : "click the next corner · double-click or Enter to finish · click the first corner to close") + (closes ? "click to close the run" : "click the next corner · double-click or Enter to finish · click the first corner to close") +
@@ -223,6 +229,21 @@
if (!m.directed) return ax ? 0 : 90; if (!m.directed) return ax ? 0 : 90;
return ax ? (q[0] >= p[0] ? 0 : 180) : (q[1] >= p[1] ? 90 : 270); return ax ? (q[0] >= p[0] ? 0 : 180) : (q[1] >= p[1] ? 90 : 270);
} }
// prefabs drawn corner by corner: "line" pieces (walls, fences, roads: one object per piece) and
// "path" objects (routes: one object whose pts are the points clicked)
function isRun(m) { return !!m && (m.shape === "line" || m.shape === "path"); }
// the add op of a path object through world points: x, y in the middle of them, pts relative
// to it; ending on the first point closes it (props.closed, when the prefab has that prop)
function pathOp(m, pts, z) {
var p = pts.slice(), closed = p.length > 3 && Math.hypot(p[p.length - 1][0] - p[0][0], p[p.length - 1][1] - p[0][1]) < 1e-6;
if (closed) p.pop();
var xs = p.map(function (q) { return q[0]; }), ys = p.map(function (q) { return q[1]; });
var cx = r3((Math.min.apply(null, xs) + Math.max.apply(null, xs)) / 2), cy = r3((Math.min.apply(null, ys) + Math.max.apply(null, ys)) / 2);
var add = { op: "add", type: m.type, x: cx, y: cy, pts: p.map(function (q) { return [r3(q[0] - cx), r3(q[1] - cy)]; }), props: {} };
if ((m.props || []).some(function (d) { return d.key === "closed"; })) add.props.closed = closed;
if (z) add.z = z;
return add;
}
// Ctrl (Cmd) frees the angle of the piece being drawn; directed lines (conveyors) keep to the axes // Ctrl (Cmd) frees the angle of the piece being drawn; directed lines (conveyors) keep to the axes
function freeAngle(m, e) { return !!(e && (e.ctrlKey || e.metaKey) && !m.directed); } function freeAngle(m, e) { return !!(e && (e.ctrlKey || e.metaKey) && !m.directed); }
// the corners of a run rounded by the prefab's corner piece (roads: curves of up to its default // the corners of a run rounded by the prefab's corner piece (roads: curves of up to its default
@@ -276,6 +297,14 @@
function finishChain(keep) { function finishChain(keep) {
var c = chain; chain = null; ghost(null); var c = chain; chain = null; ghost(null);
if (!c) return; if (!c) return;
if (c.m.shape === "path") {
if (c.pts.length >= 2) {
var po = pathOp(c.m, c.pts, c.z), np = po.pts.length, closed = !!(po.props && po.props.closed);
op(po).then(function (r) { if (r) toast(c.m.label + ": " + np + " points, " + runLength(c.pts).toFixed(1) + " m" + (closed ? ", a loop" : ", there and back") + " · Ctrl+Z takes it back"); });
}
if (keep) setPlacing(c.m.type); else setPlacing(null);
return;
}
var ops = runOps(c.m, c.pts, c.z); var ops = runOps(c.m, c.pts, c.z);
if (ops.length) { if (ops.length) {
var L = runLength(c.pts); var L = runLength(c.pts);
@@ -363,7 +392,7 @@
down: function (e, hit) { down: function (e, hit) {
if (S.placing) { if (S.placing) {
var pm = pmeta(S.placing), z = placeZ(pm), a = worldAt(e, z); var pm = pmeta(S.placing), z = placeZ(pm), a = worldAt(e, z);
a = pm.shape === "line" ? snapCorner(a, z, chain ? chain.pts[chain.pts.length - 1] : null, 12 * pxM(e), freeAngle(pm, e)) : [snap(a[0]), snap(a[1])]; a = isRun(pm) ? snapCorner(a, z, chain ? chain.pts[chain.pts.length - 1] : null, 12 * pxM(e), freeAngle(pm, e)) : [snap(a[0]), snap(a[1])];
tool = { m: pm, z: z, a: a, b: null }; tool = { m: pm, z: z, a: a, b: null };
return true; return true;
} }
@@ -443,7 +472,7 @@
function hoverPlacing(e) { function hoverPlacing(e) {
var m = pmeta(S.placing); var m = pmeta(S.placing);
if (chain) { drawChain(e); return; } if (chain) { drawChain(e); return; }
if (m && m.shape === "line") { var z0 = placeZ(m); ghost([cornerMark(snapCorner(worldAt(e, z0), z0, null, 12 * pxM(e)), z0)]); return; } if (m && isRun(m)) { var z0 = placeZ(m); ghost([cornerMark(snapCorner(worldAt(e, z0), z0, null, 12 * pxM(e)), z0)]); return; }
if (!m || !m.mount || !m.mount.length) return; if (!m || !m.mount || !m.mount.length) return;
var z = placeZ(m), sm = snapMount(m, worldAt(e, z), z, null, defOf(m, "w")); var z = placeZ(m), sm = snapMount(m, worldAt(e, z), z, null, defOf(m, "w"));
ghost(sm ? mountGhost(m, sm) : null); ghost(sm ? mountGhost(m, sm) : null);
@@ -452,7 +481,7 @@
// diagonal by snapCorner), "rect" prefabs (and rooms) fill the rectangle // diagonal by snapCorner), "rect" prefabs (and rooms) fill the rectangle
function toolGeom(t) { function toolGeom(t) {
var a = t.a, b = t.b || t.a, dx = b[0] - a[0], dy = b[1] - a[1]; var a = t.a, b = t.b || t.a, dx = b[0] - a[0], dy = b[1] - a[1];
if (t.m.shape === "line") { if (isRun(t.m)) {
var L = Math.hypot(dx, dy); var L = Math.hypot(dx, dy);
return { line: true, len: L, a: a, b: b, x: (a[0] + b[0]) / 2, y: (a[1] + b[1]) / 2, ok: L >= 0.25, rot: lineRot(t.m, a, b) }; return { line: true, len: L, a: a, b: b, x: (a[0] + b[0]) / 2, y: (a[1] + b[1]) / 2, ok: L >= 0.25, rot: lineRot(t.m, a, b) };
} }
@@ -462,8 +491,8 @@
function drawTool(e) { function drawTool(e) {
var t = tool, m = t.m, w = worldAt(e, t.z), z = t.z, polys = []; var t = tool, m = t.m, w = worldAt(e, t.z), z = t.z, polys = [];
if (chain) { drawChain(e); return; } if (chain) { drawChain(e); return; }
t.b = m.shape === "line" ? snapCorner(w, z, t.a, 12 * pxM(e), freeAngle(m, e)) : [snap(w[0]), snap(w[1])]; t.b = isRun(m) ? snapCorner(w, z, t.a, 12 * pxM(e), freeAngle(m, e)) : [snap(w[0]), snap(w[1])];
if (m.shape !== "line" && m.shape !== "rect") return; if (!isRun(m) && m.shape !== "rect") return;
var g = toolGeom(t); var g = toolGeom(t);
if (g.line) { if (g.line) {
polys = pieceGhost(m, g.a, g.b, z); polys = pieceGhost(m, g.a, g.b, z);
@@ -483,7 +512,7 @@
function finishTool(t, e) { function finishTool(t, e) {
var m = t.m, keep = e.shiftKey, g = t.b ? toolGeom(t) : null, add; var m = t.m, keep = e.shiftKey, g = t.b ? toolGeom(t) : null, add;
var at = worldAt(e, t.z), p = [snap(at[0]), snap(at[1])]; var at = worldAt(e, t.z), p = [snap(at[0]), snap(at[1])];
if (m.shape === "line") { if (isRun(m)) {
// a run: a click starts it, each click (or drag) adds a corner, the first corner closes it // a run: a click starts it, each click (or drag) adds a corner, the first corner closes it
if (chain) { if (chain) {
var last = chain.pts[chain.pts.length - 1], q = snapCorner(at, t.z, last, 12 * pxM(e), freeAngle(m, e)); var last = chain.pts[chain.pts.length - 1], q = snapCorner(at, t.z, last, 12 * pxM(e), freeAngle(m, e));
@@ -494,7 +523,7 @@
return; return;
} }
if (!g || !g.ok) { chain = { m: m, z: t.z, pts: [t.a] }; drawChain(e); return; } if (!g || !g.ok) { chain = { m: m, z: t.z, pts: [t.a] }; drawChain(e); return; }
var ops = runOps(m, [t.a, t.b], t.z); var ops = m.shape === "path" ? [pathOp(m, [t.a, t.b], t.z)] : runOps(m, [t.a, t.b], t.z);
opList(ops).then(function (r) { if (r) toast(m.label + " added" + (keep ? "" : " · Shift to draw several")); }); opList(ops).then(function (r) { if (r) toast(m.label + " added" + (keep ? "" : " · Shift to draw several")); });
if (!keep) setPlacing(null); if (!keep) setPlacing(null);
return; return;
@@ -552,6 +581,7 @@
h.textContent = m === ROOM ? "Drag a rectangle on the map: a room (floor and walls) · Esc to stop" h.textContent = m === ROOM ? "Drag a rectangle on the map: a room (floor and walls) · Esc to stop"
: m && m.mount && m.mount.length ? "Click on a wall to place: " + lab + " · Esc to stop" : m && m.mount && m.mount.length ? "Click on a wall to place: " + lab + " · Esc to stop"
: m && m.shape === "line" ? "Drag along the map to draw one " + lab + ", or click corner after corner for a run (double-click or Enter ends it, the first corner closes it); ends snap onto walls and fences · Esc to stop" : m && m.shape === "line" ? "Drag along the map to draw one " + lab + ", or click corner after corner for a run (double-click or Enter ends it, the first corner closes it); ends snap onto walls and fences · Esc to stop"
: m && m.shape === "path" ? "Click point after point on the map for the " + lab + " (double-click or Enter ends it; the first point again makes a loop); points snap onto road and wall ends · Esc to stop"
: m && m.shape === "rect" ? "Drag a rectangle on the map: " + lab + " (a click gives the default size) · Esc to stop" : m && m.shape === "rect" ? "Drag a rectangle on the map: " + lab + " (a click gives the default size) · Esc to stop"
: "Click on the map to place: " + lab + " · Esc to stop"; : "Click on the map to place: " + lab + " · Esc to stop";
h.hidden = false; h.hidden = false;
+29
View File
@@ -577,6 +577,35 @@ with sync_playwright() as p:
pg.wait_for_timeout(700) pg.wait_for_timeout(700)
check(len(doc()["objects"]) == n5, "two undos take the animals back") check(len(doc()["objects"]) == n5, "two undos take the animals back")
# a route drawn point by point: four corners and the first again make a loop of cars; two
# points and Enter a there-and-back walk. Each is one object whose pts are the points
pg.locator('.st-tile[data-type="route"]').click()
hint = pg.locator("#hint").inner_text()
for x, y in [(42, 14), (56, 14), (56, 24), (42, 24), (42, 14)]:
pg.mouse.click(*at(x, y))
pg.wait_for_timeout(150)
pg.wait_for_timeout(1000)
loop = doc()["objects"][-1]
trav = pg.evaluate(f"() => document.querySelectorAll(`[data-obj='{loop['id']}'] .iso-travel`).length")
rows = pg.evaluate(f"() => Object.fromEntries((__studio.view.info('{loop['id']}') || {{rows: []}}).rows)")
check("point after point" in hint and loop["type"] == "route" and loop["pts"] == [[-7, -5], [7, -5], [7, 5], [-7, 5]] and loop["x"] == 49 and loop["y"] == 19
and loop["props"].get("closed") is True and trav > 4 and rows.get("Route") == "loop, 48 m",
f"a route drawn point by point loops: {loop.get('pts')} at ({loop['x']}, {loop['y']}), {trav} pieces, {rows}")
pg.locator('.st-tile[data-type="route"]').click()
for x, y in [(42, 28), (56, 28)]:
pg.mouse.click(*at(x, y))
pg.wait_for_timeout(150)
pg.keyboard.press("Enter")
pg.wait_for_timeout(1000)
walk = doc()["objects"][-1]
check(walk["type"] == "route" and walk["pts"] == [[-7, 0], [7, 0]] and walk["props"].get("closed") is False,
f"two points and Enter: there and back {walk.get('pts')}")
pg.screenshot(path=str(OUT / "studio-route.jpg"), type="jpeg", quality=80)
for _ in range(2):
pg.locator("#undo").click()
pg.wait_for_timeout(700)
check(len(doc()["objects"]) == n5, "two undos take the routes back")
# an agent edits over MCP: the open Studio follows # an agent edits over MCP: the open Studio follows
init = mcp({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "0"}}}) init = mcp({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "0"}}})
check(init["result"]["serverInfo"]["name"] == "isokit", "MCP initialize") check(init["result"]["serverInfo"]["name"] == "isokit", "MCP initialize")
+7
View File
@@ -340,6 +340,13 @@ try:
meets = pg.evaluate("() => Object.fromEntries((__studio.view.info('cross-st') || {rows: []}).rows)['Meets'] || ''") meets = pg.evaluate("() => Object.fromEntries((__studio.view.info('cross-st') || {rows: []}).rows)['Meets'] || ''")
ring = pg.evaluate("() => Object.fromEntries((__studio.view.info('rb') || {rows: []}).rows)['Arms'] || ''") ring = pg.evaluate("() => Object.fromEntries((__studio.view.info('rb') || {rows: []}).rows)['Arms'] || ''")
check(meets == "east-ave, park-rd" and ring.startswith("4:"), f"streets know what they meet: cross-st meets {meets}; roundabout arms {ring}") check(meets == "east-ave, park-rd" and ring.startswith("4:"), f"streets know what they meet: cross-st meets {meets}; roundabout arms {ring}")
# route movers: each shows once at any moment (cars 4, bus 1, motorbikes 3, cyclists 2, people 3)
movers = pg.evaluate("""() => { const out = {}; for (const id of ['traffic', 'bus-line', 'motos', 'cyclists', 'strollers'])
out[id] = [...document.querySelectorAll(`.iso-svg [data-obj='${id}'] .iso-travel`)].filter(e => getComputedStyle(e).visibility === 'visible').length;
return out; }""")
traffic = pg.evaluate("() => Object.fromEntries((__studio.view.info('traffic') || {rows: []}).rows)['Movers'] || ''")
check(movers == {"traffic": 4, "bus-line": 1, "motos": 3, "cyclists": 2, "strollers": 3} and traffic.startswith("4 cars, 8 m/s"),
f"movers on their routes, each once: {movers}; {traffic}")
shot(pg, "web-streets.jpg") shot(pg, "web-streets.jpg")
# ---------------------------------------------------------------- the country demo: animals, an apiary, diagonals # ---------------------------------------------------------------- the country demo: animals, an apiary, diagonals
+6 -1
View File
@@ -140,10 +140,14 @@ html[data-theme=light] .iso-svg .iso-glint { display: inline; }
.iso-svg .iso-flutter { transform-box: fill-box; transform-origin: center; animation: iso-flap .3s ease-in-out infinite alternate; } .iso-svg .iso-flutter { transform-box: fill-box; transform-origin: center; animation: iso-flap .3s ease-in-out infinite alternate; }
.iso-svg .iso-hop { animation: iso-hop 3.4s ease-out infinite; } .iso-svg .iso-hop { animation: iso-hop 3.4s ease-out infinite; }
.iso-svg .iso-park { display: none; } .iso-svg .iso-park { display: none; }
/* route movers: each piece's copy shows only while a mover is on it (keyframes made per piece);
not .iso-a, so unanimated views pause them where they are instead of hiding them */
.iso-svg .iso-travel { visibility: hidden; }
/* paused off-screen (iso-off), when stopped (iso-still), with animation off and during pan/zoom gestures */ /* paused off-screen (iso-off), when stopped (iso-still), with animation off and during pan/zoom gestures */
.iso-off .iso-a, .iso-still .iso-a, .iso-off .iso-drive, .iso-still .iso-drive, .iso-off .iso-ledr, .iso-off .iso-ledo, .iso-still .iso-ledr, .iso-still .iso-ledo, .iso-off .iso-a, .iso-still .iso-a, .iso-off .iso-drive, .iso-still .iso-drive, .iso-off .iso-ledr, .iso-off .iso-ledo, .iso-still .iso-ledr, .iso-still .iso-ledo,
.iso-anim-off .iso-a, .iso-anim-off .iso-drive, .iso-anim-off .iso-ledr, .iso-anim-off .iso-ledo, .iso-anim-off .iso-a, .iso-anim-off .iso-drive, .iso-anim-off .iso-ledr, .iso-anim-off .iso-ledo,
.iso-gesture .iso-a, .iso-gesture .iso-drive, .iso-gesture .iso-ledr, .iso-gesture .iso-ledo { animation-play-state: paused !important; } .iso-gesture .iso-a, .iso-gesture .iso-drive, .iso-gesture .iso-ledr, .iso-gesture .iso-ledo,
.iso-off .iso-travel, .iso-still .iso-travel, .iso-anim-off .iso-travel, .iso-gesture .iso-travel, .iso-svg .iso-dim .iso-travel { animation-play-state: paused !important; }
.iso-still .iso-park, .iso-anim-off .iso-park { display: inline; } .iso-still .iso-park, .iso-anim-off .iso-park { display: inline; }
.iso-animbtn { min-width: 30px; font-size: 13px; } .iso-animbtn { min-width: 30px; font-size: 13px; }
.iso-bar [hidden] { display: none !important; } .iso-bar [hidden] { display: none !important; }
@@ -180,6 +184,7 @@ html[data-theme=light] .iso-svg .iso-glint { display: inline; }
/* a system asking for less motion: still pictures, until the viewer turns animation on (iso-anim-on) */ /* a system asking for less motion: still pictures, until the viewer turns animation on (iso-anim-on) */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.iso-wrap:not(.iso-anim-on) .iso-svg .iso-a, .iso-wrap:not(.iso-anim-on) .iso-svg .iso-ledr, .iso-wrap:not(.iso-anim-on) .iso-svg .iso-ledo { animation: none !important; } .iso-wrap:not(.iso-anim-on) .iso-svg .iso-a, .iso-wrap:not(.iso-anim-on) .iso-svg .iso-ledr, .iso-wrap:not(.iso-anim-on) .iso-svg .iso-ledo { animation: none !important; }
.iso-wrap:not(.iso-anim-on) .iso-svg .iso-travel { animation-play-state: paused !important; }
.iso-wrap:not(.iso-anim-on) .iso-svg :is(.iso-puff, .iso-puff2, .iso-smoke, .iso-disc, .iso-glint, .iso-rip) { display: none; } .iso-wrap:not(.iso-anim-on) .iso-svg :is(.iso-puff, .iso-puff2, .iso-smoke, .iso-disc, .iso-glint, .iso-rip) { display: none; }
.iso-wrap:not(.iso-anim-on) .iso-svg .iso-park { display: inline; } .iso-wrap:not(.iso-anim-on) .iso-svg .iso-park { display: inline; }
} }