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:
1 parent
ccfd619a99
commit
5ec164a633
20 files changed
+698
-23
No files matched your search
@@ -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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -201,6 +201,17 @@ pub enum Prim {
|
||||
},
|
||||
/// Items drawn back to front by the depth of their plan anchor.
|
||||
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)
|
||||
|
||||
@@ -87,6 +87,8 @@ struct Ctx<'a> {
|
||||
p: Proj,
|
||||
uid: &'a str,
|
||||
out: String,
|
||||
/// Keyframes of the route movers by name, written once in one `<style>` at the end.
|
||||
keyframes: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
@@ -572,6 +574,31 @@ impl Ctx<'_> {
|
||||
}
|
||||
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 } => {
|
||||
let p = self.p;
|
||||
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.
|
||||
pub fn hull(s: &Sprite, p: Proj, bb: &Bb) -> Option<sort::Hull> {
|
||||
if s.ext.len() < 3 {
|
||||
@@ -627,6 +709,7 @@ pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered {
|
||||
p,
|
||||
uid: &uid,
|
||||
out: String::with_capacity(64 * 1024),
|
||||
keyframes: BTreeMap::new(),
|
||||
};
|
||||
let mut frame = [1e9f64, 1e9, -1e9, -1e9];
|
||||
let mut sprites = 0;
|
||||
@@ -743,6 +826,13 @@ pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered {
|
||||
);
|
||||
}
|
||||
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
|
||||
if let Some((b, zb)) = scene.bounds {
|
||||
|
||||
@@ -123,6 +123,13 @@ enum Frame {
|
||||
items: Vec<(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)]
|
||||
@@ -172,7 +179,7 @@ impl Sprite {
|
||||
/// the sprite.
|
||||
pub fn push(&mut self, p: Prim) -> &mut Self {
|
||||
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((_, items)) => items.push(p),
|
||||
// a primitive before the first seq_item starts an item at the origin
|
||||
@@ -622,12 +629,46 @@ impl Sprite {
|
||||
}
|
||||
|
||||
pub fn end_group(&mut self) -> &mut Self {
|
||||
if let Some(Frame::Group { cls, style, items }) = self.frames.pop() {
|
||||
self.push(Prim::Group { cls, style, items });
|
||||
match self.frames.pop() {
|
||||
Some(Frame::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
|
||||
}
|
||||
|
||||
/// Start a back-to-front sequence; each [`Sprite::seq_item`] starts an
|
||||
/// item anchored at a plan point; [`Sprite::end_seq`] closes it.
|
||||
pub fn begin_seq(&mut self) -> &mut Self {
|
||||
@@ -674,7 +715,7 @@ impl Sprite {
|
||||
let n = self.frames.len();
|
||||
while let Some(f) = self.frames.last() {
|
||||
match f {
|
||||
Frame::Group { .. } => {
|
||||
Frame::Group { .. } | Frame::Travel { .. } => {
|
||||
self.end_group();
|
||||
}
|
||||
Frame::Seq { .. } => {
|
||||
|
||||
@@ -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. \
|
||||
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. \
|
||||
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 \
|
||||
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). \
|
||||
@@ -58,6 +61,8 @@ pub fn tools() -> Vec<Value> {
|
||||
"info": { "type": "string", "description": "tooltip text" },
|
||||
"tag": { "type": "string", "description": "label pinned above the object" },
|
||||
"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();
|
||||
add.insert("type".into(), json!({ "type": "string", "description": "prefab type, e.g. building, tree, car (list_prefabs)" }));
|
||||
|
||||
@@ -519,6 +519,24 @@ pub fn register_sprite(e: &mut Engine) {
|
||||
s.0.borrow_mut().end_group();
|
||||
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(
|
||||
"group",
|
||||
|ctx: NativeCallContext, s: &mut SpriteH, cls: &str, f: FnPtr| -> RR<SpriteH> {
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn n(d: &Dynamic) -> RR<f64> {
|
||||
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>() {
|
||||
Some(a) => Ok(a.clone()),
|
||||
None => err(format!("{what}: expected an array, got {}", d.type_name())),
|
||||
|
||||
@@ -10,6 +10,7 @@ pub const MODULES: &[(&str, &str)] = &[
|
||||
("isokit/ground", include_str!("../stdlib/ground.rhai")),
|
||||
("isokit/streets", include_str!("../stdlib/streets.rhai")),
|
||||
("isokit/wildlife", include_str!("../stdlib/wildlife.rhai")),
|
||||
("isokit/routes", include_str!("../stdlib/routes.rhai")),
|
||||
("isokit/nature", include_str!("../stdlib/nature.rhai")),
|
||||
("isokit/building", include_str!("../stdlib/building.rhai")),
|
||||
("isokit/vehicles", include_str!("../stdlib/vehicles.rhai")),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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` });
|
||||
}
|
||||
@@ -272,6 +272,8 @@ mach3 = "#5b6370"
|
||||
mach4 = "#b8692b"
|
||||
robot = "#e07a1f"
|
||||
robotb = "#3a3f48"
|
||||
# route paths
|
||||
route = "#e0b84a"
|
||||
# wildlife and garden
|
||||
deer = "#8a5a32"
|
||||
deerl = "#d6c4a6"
|
||||
@@ -581,6 +583,7 @@ mach3 = "#8a93a1"
|
||||
mach4 = "#e08a43"
|
||||
robot = "#f08c2b"
|
||||
robotb = "#5a616d"
|
||||
route = "#b0802a"
|
||||
deer = "#a8703f"
|
||||
deerl = "#ecdfc8"
|
||||
nose = "#3a302a"
|
||||
@@ -918,6 +921,7 @@ f-conc = "conc"
|
||||
".iso-svg .iso-win" = "fill: var(--iso-win); stroke: none"
|
||||
".iso-svg .iso-school" = "pointer-events: 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-bristle" = "fill: none; stroke: var(--iso-bristle); stroke-width: .5; stroke-linecap: round"
|
||||
".iso-svg .iso-bird, .iso-svg .iso-wing" = "stroke: none"
|
||||
|
||||
+8
-1
@@ -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)
|
||||
6. ~~Sidewalks along streets~~ (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)
|
||||
10. ~~Diagonal streets, walls, fences, hedges~~ (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.
|
||||
`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
|
||||
|
||||
- [x] Interior prefabs (see Build your own house); a building `rooms` prop is still open
|
||||
|
||||
@@ -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 |
|
||||
| `seq([[x, y, \|q\| …], …])` | items drawn back to front by their anchor |
|
||||
| `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 |
|
||||
| `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
|
||||
`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
|
||||
|
||||
`producer`, `conveyor` and `robot_arm` (stdlib/industry.rhai) work together
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
{"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-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,7 +1,7 @@
|
||||
# isokit streets demo: roads with sidewalks, a rounded corner, junctions and a roundabout.
|
||||
# Open it with `isokit studio examples/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_rotation = 1
|
||||
prelude = []
|
||||
|
||||
@@ -23,19 +23,19 @@
|
||||
{"id": "tree-3", "type": "tree", "x": 46, "y": -14},
|
||||
{"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": "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-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": "lamp-1", "type": "lamp", "x": 18, "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-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
@@ -209,6 +209,12 @@
|
||||
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 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" +
|
||||
(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") +
|
||||
@@ -223,6 +229,21 @@
|
||||
if (!m.directed) return ax ? 0 : 90;
|
||||
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
|
||||
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
|
||||
@@ -276,6 +297,14 @@
|
||||
function finishChain(keep) {
|
||||
var c = chain; chain = null; ghost(null);
|
||||
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);
|
||||
if (ops.length) {
|
||||
var L = runLength(c.pts);
|
||||
@@ -363,7 +392,7 @@
|
||||
down: function (e, hit) {
|
||||
if (S.placing) {
|
||||
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 };
|
||||
return true;
|
||||
}
|
||||
@@ -443,7 +472,7 @@
|
||||
function hoverPlacing(e) {
|
||||
var m = pmeta(S.placing);
|
||||
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;
|
||||
var z = placeZ(m), sm = snapMount(m, worldAt(e, z), z, null, defOf(m, "w"));
|
||||
ghost(sm ? mountGhost(m, sm) : null);
|
||||
@@ -452,7 +481,7 @@
|
||||
// diagonal by snapCorner), "rect" prefabs (and rooms) fill the rectangle
|
||||
function toolGeom(t) {
|
||||
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);
|
||||
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) {
|
||||
var t = tool, m = t.m, w = worldAt(e, t.z), z = t.z, polys = [];
|
||||
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])];
|
||||
if (m.shape !== "line" && m.shape !== "rect") return;
|
||||
t.b = isRun(m) ? snapCorner(w, z, t.a, 12 * pxM(e), freeAngle(m, e)) : [snap(w[0]), snap(w[1])];
|
||||
if (!isRun(m) && m.shape !== "rect") return;
|
||||
var g = toolGeom(t);
|
||||
if (g.line) {
|
||||
polys = pieceGhost(m, g.a, g.b, z);
|
||||
@@ -483,7 +512,7 @@
|
||||
function finishTool(t, e) {
|
||||
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])];
|
||||
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
|
||||
if (chain) {
|
||||
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;
|
||||
}
|
||||
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")); });
|
||||
if (!keep) setPlacing(null);
|
||||
return;
|
||||
@@ -552,6 +581,7 @@
|
||||
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.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"
|
||||
: "Click on the map to place: " + lab + " · Esc to stop";
|
||||
h.hidden = false;
|
||||
|
||||
@@ -577,6 +577,35 @@ with sync_playwright() as p:
|
||||
pg.wait_for_timeout(700)
|
||||
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
|
||||
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")
|
||||
|
||||
@@ -340,6 +340,13 @@ try:
|
||||
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'] || ''")
|
||||
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")
|
||||
|
||||
# ---------------------------------------------------------------- the country demo: animals, an apiary, diagonals
|
||||
|
||||
@@ -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-hop { animation: iso-hop 3.4s ease-out infinite; }
|
||||
.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 */
|
||||
.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-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-animbtn { min-width: 30px; font-size: 13px; }
|
||||
.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) */
|
||||
@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-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 .iso-park { display: inline; }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user