From e840d360007dd97243ace6573653607b44464f91 Mon Sep 17 00:00:00 2001 From: grabowski Date: Sun, 25 Oct 2026 13:57:23 +0700 Subject: [PATCH] Wildlife and nature: animals, beehives, bushes, flowers, rocks, logs New stdlib module wildlife.rhai: - Animals (new palette category): Deer (stag with antlers, does, fawns), Wild boar, Fox, Elephant (bull with tusks, cow, calf), Rabbits. Low-poly solids from faces (loft, tube), parts drawn back to front by depth, faces edged in their own shade (class beast). Herds scatter round a spot, each animal its own sprite facing about the object's rotation. Grazing animals switch between two head poses (iso-pose2 groups), elephants curl their trunks, rabbits hop. - Birds and Butterflies as flocks: swim() takes a shape (bird, fly, bee); wings flap out of step. Fish output unchanged. - Beehives on stands with supers, lids, entrances and bees flying round; Bush (round, flowering, berries); Flowers (bed in rows or meadow); Rocks; Log or stump. g-meadow and g-soil ground materials. - polyline() takes 3D points as documented (antlers). Theme colours for dark and light, viewer CSS for poses, flapping, hopping. examples/country: elephants in a paddock with a cut corner, deer at a forest edge, boar, fox, rabbits, an apiary by a meadow, a pond, a lane, fence and hedgerow at angles; Country demo in the web Studio and deploy. MCP guidance, README, SCRIPTING, ROADMAP. Tests: Rust 78, Studio 122; existing examples draw the same (only the embedded theme CSS grew). --- .gitea/workflows/deploy-web.yml | 1 + README.md | 11 + crates/isokit-core/src/prim.rs | 4 + crates/isokit-core/src/render.rs | 93 ++- crates/isokit-project/src/mcp.rs | 2 + crates/isokit-project/tests/demo.rs | 7 +- crates/isokit-script/src/api.rs | 1 + crates/isokit-script/src/conv.rs | 30 +- crates/isokit-script/src/stdlib.rs | 1 + crates/isokit-script/src/tests.rs | 95 +++ crates/isokit-script/stdlib/ground.rhai | 2 +- crates/isokit-script/stdlib/wildlife.rhai | 698 ++++++++++++++++++++++ crates/isokit-theme/themes/default.toml | 117 ++++ docs/ROADMAP.md | 8 +- docs/SCRIPTING.md | 25 +- examples/country/isokit.toml | 29 + examples/country/scene.json | 44 ++ tools/build_web.py | 1 + tools/check_studio.py | 22 + tools/check_web.py | 16 + viewer/isokit-view.css | 17 + web/web.js | 3 +- 22 files changed, 1211 insertions(+), 16 deletions(-) create mode 100644 crates/isokit-script/stdlib/wildlife.rhai create mode 100644 examples/country/isokit.toml create mode 100644 examples/country/scene.json diff --git a/.gitea/workflows/deploy-web.yml b/.gitea/workflows/deploy-web.yml index 5b9765f..3f91bec 100644 --- a/.gitea/workflows/deploy-web.yml +++ b/.gitea/workflows/deploy-web.yml @@ -23,6 +23,7 @@ on: - "examples/house/**" - "examples/factory/**" - "examples/streets/**" + - "examples/country/**" - "tools/build_web.py" - "Cargo.toml" - "Cargo.lock" diff --git a/README.md b/README.md index f8493c4..90c9e42 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,17 @@ The **House** palette has the parts, **Furniture** the interior: swimming round under lily pads, ripples and reeds; trees, hedges and rows of trees are in Nature. +**Animals and nature**: Deer (a herd of a stag, does and fawns, or one +kind), Wild boar, Fox, Elephant (bull, cow, calf), Rabbits, a flock of Birds +circling and Butterflies; Beehives on stands with bees flying round, Bushes +(round, flowering, berries), Flowers (a bed in rows or a wildflower meadow), +Rocks and a Log or stump. Animals are low-poly solids that read from every +side; a herd scatters about the object's spot, each animal its own sprite. +Grazing animals lower and raise their heads in turn, elephants curl their +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. + **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 diff --git a/crates/isokit-core/src/prim.rs b/crates/isokit-core/src/prim.rs index 81c26af..08ccd6c 100644 --- a/crates/isokit-core/src/prim.rs +++ b/crates/isokit-core/src/prim.rs @@ -206,6 +206,8 @@ pub enum Prim { /// One fish of a [`Prim::Swim`]: it goes round an ellipse of radii (rx, ry) /// once every `dur` seconds, `delay` seconds into its round at the start, /// counter-clockwise (seen from above) unless `cw`; `len` is its length. +/// `shape`: "fish" (or empty), "bird" (wings spread `len` wide, flapping), +/// "bee" or "fly" (a butterfly, fluttering): flocks use the same orbits. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Fish { pub rx: f64, @@ -215,4 +217,6 @@ pub struct Fish { pub cw: bool, pub len: f64, pub cls: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub shape: String, } diff --git a/crates/isokit-core/src/render.rs b/crates/isokit-core/src/render.rs index 41fe835..5484beb 100644 --- a/crates/isokit-core/src/render.rs +++ b/crates/isokit-core/src/render.rs @@ -307,11 +307,38 @@ impl Ctx<'_> { let e = 1.0 + l / rx.min(ry) + 0.05; // a fish nose first along +y (counter-clockwise) or -y let k = if f.cw { -l } else { l }; - // body widest behind the head, a narrow tail root, a forked tail fin - let (b, t) = (body_path(k, l), tail_path(k, l)); + // body widest behind the head, a narrow tail root, a forked tail fin; birds, + // bees and butterflies in their own shapes, wings flapping in a group of their own + // wingbeats out of step: each bird or butterfly starts its own way into one + let beat = r3s((f.delay * 0.731).fract()); + let body = match f.shape.as_str() { + "bird" => format!( + "", + esc(&f.cls), + bird_body(k, l), + esc(&f.cls), + bird_wings(k, l) + ), + "fly" => format!( + "", + esc(&f.cls), + fly_wings(k, l) + ), + "bee" => format!( + "", + esc(&f.cls), + bee_path(k, l) + ), + _ => format!( + "", + esc(&f.cls), + body_path(k, l), + tail_path(k, l) + ), + }; let _ = write!( self.out, - "", + "{body}", r3s(rx), r3s(ry), r3s(f.dur.max(1.0)), @@ -323,7 +350,6 @@ impl Ctx<'_> { r3s(e), r3s(1.0 / rx), r3s(1.0 / ry), - esc(&f.cls), ); } self.out.push_str(""); @@ -788,6 +814,65 @@ fn body_path(k: f64, l: f64) -> String { } /// The forked tail fin behind [`body_path`]. +/// A bird seen from above flying along +y (k > 0) or -y: a slim body ... +fn bird_body(k: f64, l: f64) -> String { + format!( + "M0 {}L{} 0L0 {}L{} 0Z", + r3s(0.22 * k), + r3s(0.05 * l), + r3s(-0.3 * k), + r3s(-0.05 * l) + ) +} + +/// ... and its wings, `l` from tip to tip, swept back. +fn bird_wings(k: f64, l: f64) -> String { + format!( + "M{} {}L{} {}L0 {}L{} {}L{} {}L{} {}L0 {}L{} {}Z", + r3s(-0.5 * l), + r3s(-0.12 * k), + r3s(-0.22 * l), + r3s(0.1 * k), + r3s(0.04 * k), + r3s(0.22 * l), + r3s(0.1 * k), + r3s(0.5 * l), + r3s(-0.12 * k), + r3s(0.2 * l), + r3s(-0.03 * k), + r3s(-0.07 * k), + r3s(-0.2 * l), + r3s(-0.03 * k) + ) +} + +/// A butterfly's two pairs of wings, `l` across. +fn fly_wings(k: f64, l: f64) -> String { + let w = |s: f64| { + format!( + "M0 {}L{} {}L{} {}L0 {}Z", + r3s(0.08 * k), + r3s(s * 0.5 * l), + r3s(0.32 * k), + r3s(s * 0.42 * l), + r3s(-0.3 * k), + r3s(-0.12 * k) + ) + }; + format!("{}{}", w(1.0), w(-1.0)) +} + +/// A bee: a small oval, `l` long. +fn bee_path(k: f64, l: f64) -> String { + format!( + "M0 {}L{} 0L0 {}L{} 0Z", + r3s(0.5 * k), + r3s(0.3 * l), + r3s(-0.5 * k), + r3s(-0.3 * l) + ) +} + fn tail_path(k: f64, l: f64) -> String { format!( "M0 {}L{} {}L0 {}L{} {}Z", diff --git a/crates/isokit-project/src/mcp.rs b/crates/isokit-project/src/mcp.rs index 0af79cb..74d2654 100644 --- a/crates/isokit-project/src/mcp.rs +++ b/crates/isokit-project/src/mcp.rs @@ -27,6 +27,8 @@ Fences (props.style: mesh, wood, bars, half stone, cobble, brick) and site walls meet, so for a plot boundary put each piece's ends exactly on the next piece's end or line. Line pieces (roads, walls, \ fences, hedges, house walls) can run at any angle: a piece from (x0, y0) to (x1, y1) has x, y = the midpoint, \ 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. \ 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). \ diff --git a/crates/isokit-project/tests/demo.rs b/crates/isokit-project/tests/demo.rs index d94e5e4..2b79da9 100644 --- a/crates/isokit-project/tests/demo.rs +++ b/crates/isokit-project/tests/demo.rs @@ -72,7 +72,7 @@ fn demo_passes_the_check() { assert!(f.is_empty(), "{f:?}"); } -/// The house, factory and streets demos: every view draws every object without warnings +/// The house, factory, streets and country demos: every view draws every object without warnings /// or depth-sort cycles (walls and belts must not overlap), and the projects pass the check. #[test] fn house_demo_renders_every_view_cleanly() { @@ -89,6 +89,11 @@ fn streets_demo_renders_every_view_cleanly() { example_renders_every_view_cleanly("streets"); } +#[test] +fn country_demo_renders_every_view_cleanly() { + example_renders_every_view_cleanly("country"); +} + fn example_renders_every_view_cleanly(name: &str) { let p = Project::load( Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/isokit-script/src/api.rs b/crates/isokit-script/src/api.rs index b374406..0881468 100644 --- a/crates/isokit-script/src/api.rs +++ b/crates/isokit-script/src/api.rs @@ -470,6 +470,7 @@ pub fn register_sprite(e: &mut Engine) { .get("cls") .map(|v| v.to_string()) .unwrap_or_else(|| "koi".into()), + shape: m.get("shape").map(|v| v.to_string()).unwrap_or_default(), }); } s.0.borrow_mut().swim(n(&x)?, n(&y)?, n(&z)?, fish); diff --git a/crates/isokit-script/src/conv.rs b/crates/isokit-script/src/conv.rs index 0658641..bc2af53 100644 --- a/crates/isokit-script/src/conv.rs +++ b/crates/isokit-script/src/conv.rs @@ -90,13 +90,31 @@ pub fn polys3(d: &Dynamic) -> RR>> { } } -/// One line or several, of plan points `[x, y]` (put at height z) or 3D points. +/// One line or several, of plan points `[x, y]` (put at height z) or 3D +/// points `[x, y, z]` (antlers, branches). pub fn polys3_or_flat(d: &Dynamic, z: f64) -> RR>> { - polys(d).map(|ls| { - ls.into_iter() - .map(|l| l.into_iter().map(|p| [p[0], p[1], z]).collect()) - .collect() - }) + let a = arr(d, "polyline")?; + let lines: Vec<&Dynamic> = match a.first() { + None => return Ok(vec![]), + Some(f) if is_point(f) => vec![d], + _ => a.iter().collect(), + }; + lines + .into_iter() + .map(|l| { + arr(l, "polyline")? + .iter() + .map(|q| { + let n = q.read_lock::().map(|v| v.len()).unwrap_or(0); + if n >= 3 { + p3(q) + } else { + p2(q).map(|p| [p[0], p[1], z]) + } + }) + .collect() + }) + .collect() } /// `[[a, b, c, d], ...]` (segments, opening lists) diff --git a/crates/isokit-script/src/stdlib.rs b/crates/isokit-script/src/stdlib.rs index 9c25edc..92136e2 100644 --- a/crates/isokit-script/src/stdlib.rs +++ b/crates/isokit-script/src/stdlib.rs @@ -9,6 +9,7 @@ pub const MODULES: &[(&str, &str)] = &[ ("isokit/util", include_str!("../stdlib/util.rhai")), ("isokit/ground", include_str!("../stdlib/ground.rhai")), ("isokit/streets", include_str!("../stdlib/streets.rhai")), + ("isokit/wildlife", include_str!("../stdlib/wildlife.rhai")), ("isokit/nature", include_str!("../stdlib/nature.rhai")), ("isokit/building", include_str!("../stdlib/building.rhai")), ("isokit/vehicles", include_str!("../stdlib/vehicles.rhai")), diff --git a/crates/isokit-script/src/tests.rs b/crates/isokit-script/src/tests.rs index 51c11b2..b9e7bc2 100644 --- a/crates/isokit-script/src/tests.rs +++ b/crates/isokit-script/src/tests.rs @@ -858,6 +858,101 @@ fn piece(id: &str, kind: &str, a: [f64; 2], b: [f64; 2], props: Value) -> Object o } +#[test] +fn wildlife_and_garden_prefabs_draw_from_every_side() { + let r = Runtime::new(&Bundle::default()).unwrap(); + let doc = Doc { + objects: vec![ + obj("deer", "deer", -18.0, 8.0, json!({"n": 5, "r": 4})), + obj( + "stag", + "deer", + -8.0, + 12.0, + json!({"n": 1, "mix": "stag", "graze": false}), + ), + obj("boar", "boar", -18.0, -6.0, json!({"n": 3})), + obj("fox", "fox", -8.0, -2.0, json!({})), + obj( + "el", + "elephant", + 6.0, + 10.0, + json!({"n": 3, "mix": "herd", "r": 5}), + ), + obj("rab", "rabbits", -4.0, -12.0, json!({"n": 4})), + obj("birds", "birds", 0.0, 0.0, json!({"n": 7})), + obj("bf", "butterflies", 10.0, -12.0, json!({"n": 5})), + obj("hives", "beehive", 16.0, -6.0, json!({"n": 4})), + obj("bed", "flowers", 10.0, -12.0, json!({"w": 5, "d": 3})), + obj( + "mead", + "flowers", + 20.0, + -14.0, + json!({"w": 8, "d": 5, "kind": "meadow"}), + ), + obj("b1", "bush", 3.0, -4.0, json!({})), + obj("b2", "bush", 5.0, -6.0, json!({"kind": "berries"})), + obj("rk", "rocks", 22.0, 6.0, json!({"n": 4})), + obj("lg", "log", 14.0, 4.0, json!({})), + obj("st", "log", 18.0, 2.0, json!({"kind": "stump"})), + ], + ..Default::default() + }; + for view in 0..4u8 { + let b = r.build("site", view, &cfg(), &doc).unwrap(); + assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings); + let out = render(&b.scene, view, &RenderOpts::default()); + assert!(!out.html.contains("NaN")); + // each animal its own sprite: five deer, three boar, one fox, three elephants, four rabbits + for (id, n) in [ + ("deer", 5), + ("stag", 1), + ("boar", 3), + ("fox", 1), + ("el", 3), + ("rab", 4), + ] { + assert_eq!(sprites_of(&b, id), n, "{id}"); + } + // grazing animals carry two head poses taking turns, the first shown unanimated; + // the standing stag and the rabbits have none + assert_eq!( + out.html.matches("iso-a iso-pose2 iso-pose0").count(), + 5 + 3 + 1 + 3 + ); + assert_eq!(out.html.matches("iso-a iso-pose2\"").count(), 5 + 3 + 1 + 3); + assert_eq!(out.html.matches("iso-a iso-hop").count(), 4); + // birds flap, butterflies flutter, bees fly round the hives + assert_eq!(out.html.matches("iso-a iso-flap").count(), 7); + assert_eq!(out.html.matches("iso-a iso-flutter").count(), 5); + assert_eq!(out.html.matches("class=\"iso-bee iso-bee\"").count(), 4 * 8); + // animal faces are edged in their own shade; antlers on the one stag + assert!(out.html.contains("iso-deer iso-beast")); + assert!(out.html.contains("iso-antler")); + } + let b = r.build("site", 0, &cfg(), &doc).unwrap(); + // a stag stands about 1.5 m at the head, antlers up to 2.1 m; it fits its sort box + let st = b.boxes["stag"]; + assert!(st[5] > 1.9 && st[5] < 2.3, "{st:?}"); + // an elephant bull: about 3 m tall at the head + let el = b.boxes["el"]; + assert!(el[5] > 2.6 && el[5] < 3.3, "{el:?}"); + let out = render(&b.scene, 0, &RenderOpts::default()); + let row = |id: &str, k: &str| { + out.info[id] + .rows + .iter() + .find(|r| r.0 == k) + .map(|r| r.1.clone()) + .unwrap_or_default() + }; + assert_eq!(row("deer", "Herd"), "1 stag, 3 does, 1 fawn"); + assert_eq!(row("el", "Herd"), "1 bull, 1 cow, 1 calf"); + assert_eq!(row("hives", "Apiary"), "4 hives, 2 supers each"); +} + #[test] fn diagonal_walls_fences_and_hedges_mitre_and_sort() { let r = Runtime::new(&Bundle::default()).unwrap(); diff --git a/crates/isokit-script/stdlib/ground.rhai b/crates/isokit-script/stdlib/ground.rhai index a15e82e..d8b046d 100644 --- a/crates/isokit-script/stdlib/ground.rhai +++ b/crates/isokit-script/stdlib/ground.rhai @@ -3,7 +3,7 @@ // document order (later objects lie on top). fn ground_materials() { - ["g-grass", "g-grass2", "g-green", "g-garden", "g-asph", "g-conc", "g-pad", "g-path", "g-sand", "g-court", "dirt"] + ["g-grass", "g-grass2", "g-green", "g-garden", "g-asph", "g-conc", "g-pad", "g-path", "g-sand", "g-court", "g-meadow", "g-soil", "dirt"] } fn meta_site() { diff --git a/crates/isokit-script/stdlib/wildlife.rhai b/crates/isokit-script/stdlib/wildlife.rhai new file mode 100644 index 0000000..161d875 --- /dev/null +++ b/crates/isokit-script/stdlib/wildlife.rhai @@ -0,0 +1,698 @@ +// isokit/wildlife: animals (deer, wild boar, fox, elephant, rabbits, a flock +// of birds, butterflies) and beehives, bushes, flowers, rocks and logs. +// +// Animals are low-poly solids drawn face by face (`face`: the engine culls +// the faces turned away and shades the others), their parts back to front by +// depth, so they read right from every side and at any rotation. Herds stand +// at scattered spots with their own headings, each animal its own sprite. +// Grazing animals take turns between two head poses (a CSS flipbook, paused +// like every animation); birds, butterflies and bees fly round on orbits. + +fn animal_kinds() { ["deer", "boar", "fox", "elephant", "rabbits"] } + +// ---------------------------------------------------------------- vectors + +fn vadd(a, b) { [a[0] + b[0], a[1] + b[1], a[2] + b[2]] } +fn vsub(a, b) { [a[0] - b[0], a[1] - b[1], a[2] - b[2]] } +fn vmul(a, k) { [a[0] * k, a[1] * k, a[2] * k] } +fn vdot(a, b) { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] } +fn vcross(a, b) { [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] } +fn vunit(a) { + let l = fmax((a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt(), 1e-12); + [a[0] / l, a[1] / l, a[2] / l] +} + +// ---------------------------------------------------------------- solids + +/// A loft along `spine` (3D points of this frame) with n sides: rings of +/// radii rs (each a number or [across, up]: an oval), side faces between +/// them and a cap at each end; mats: [sides, first cap, last cap]. Bodies, +/// limbs, necks, heads, trunks, logs. The rings keep their turn along a bent +/// spine (parallel transport), so trunks and tails do not twist. +fn loft(spine, rs, n, mats) { + let m = spine.len(); + let rings = []; + let v1 = (); + for i in 0..m { + let a = spine[if i == 0 { 0 } else { i - 1 }]; + let b = spine[if i == m - 1 { m - 1 } else { i + 1 }]; + let u = vunit(vsub(b, a)); + if type_of(v1) == "()" { + v1 = if u[2].abs() > 0.95 { vunit(vcross([0.0, 1.0, 0.0], u)) } else { vunit(vcross([0.0, 0.0, 1.0], u)) }; + } else { + v1 = vunit(vsub(v1, vmul(u, vdot(v1, u)))); + } + let v2 = vcross(u, v1); + let r = rs[i]; + let rw = if type_of(r) == "array" { r[0] } else { r }; + let rh = if type_of(r) == "array" { r[1] } else { r }; + let ring = []; + for k in 0..n { + let t = 2.0 * PI() * (k + 0.5) / n; + ring.push(vadd(spine[i], vadd(vmul(v1, rw * t.cos()), vmul(v2, rh * t.sin())))); + } + rings.push(ring); + } + for i in 1..m { + let p = rings[i - 1]; + let q = rings[i]; + for k in 0..n { + let j = (k + 1) % n; + this.face([p[k], p[j], q[j], q[k]], mats[0]); + } + } + let c0 = rings[0]; + c0.reverse(); + this.face(c0, mats[1]); + this.face(rings[m - 1], mats[2]); + this +} + +/// A tapered tube from a to b, radii ra and rb, n sides. +fn tube(a, b, ra, rb, n, mat) { this.loft([a, b], [ra, rb], n, [mat, mat, mat]) } + +// Parts of a creature, drawn back to front: #{ at, t, ... } with t "loft" +// (spine, rs, n, mats), "face" (pts, mat), "line" (pts, cls) or "alt" (two +// part lists shown in turn: a, b, css). +fn p_loft(spine, rs, n, mats) { #{ at: spine[spine.len() / 2], t: "loft", spine: spine, rs: rs, n: n, mats: mats } } +fn p_tube(a, b, ra, rb, n, mat) { #{ at: vmul(vadd(a, b), 0.5), t: "loft", spine: [a, b], rs: [ra, rb], n: n, mats: [mat, mat, mat] } } +fn p_face(pts, mat) { #{ at: pts[0], t: "face", pts: pts, mat: mat } } +fn p_line(pts, cls) { #{ at: pts[pts.len() / 2], t: "line", pts: pts, cls: cls } } + +/// The depth of a part's anchor: nearer to the viewer is larger (plan depth +/// plus height: the view looks down along (1, 1, 1) in view space). +fn part_key(p) { depth(p.at[0], p.at[1]) + p.at[2] } + +/// Draws parts back to front. +fn draw_parts(parts) { + let ps = parts.map(|p| #{ k: part_key(p), p: p }); + ps.sort(|a, b| if a.k < b.k { -1 } else if a.k > b.k { 1 } else { 0 }); + for e in ps { + let p = e.p; + if p.t == "loft" { + this.loft(p.spine, p.rs, p.n, p.mats.map(|m| m + " beast")); + } else if p.t == "face" { + this.face(p.pts, p.mat + " beast", #{ two_sided: true }); + } else if p.t == "line" { + this.polyline(p.pts, 0.0, p.cls); + } else if p.t == "alt" { + this.begin("a pose2 pose0", p.css); + this.draw_parts(p.a); + this.end(); + this.begin("a pose2", p.css); + this.draw_parts(p.b); + this.end(); + } + } + this +} + +/// A point of a head: `a` along its way f, `b` to its left, `c` up from it, +/// from its base (f points from the neck to the snout, in the x-z plane). +fn hp(base, f, a, b, c) { + let w = [-f[2], 0.0, f[0]]; + vadd(base, vadd(vmul(f, a), vadd([0.0, b, 0.0], vmul(w, c)))) +} + +/// Scale every point of a part list by s (fawns, calves). +fn scale_parts(parts, s) { + parts.map(|p| { + let q = p; + q.at = vmul(p.at, s); + if p.t == "loft" { + q.spine = p.spine.map(|v| vmul(v, s)); + q.rs = p.rs.map(|r| if type_of(r) == "array" { [r[0] * s, r[1] * s] } else { r * s }); + } else if p.t == "face" || p.t == "line" { + q.pts = p.pts.map(|v| vmul(v, s)); + } else if p.t == "alt" { + q.a = scale_parts(p.a, s); + q.b = scale_parts(p.b, s); + } + q + }) +} + +/// Four legs from the underside of a body to the ground: at x fx (front) +/// and bx (back), y = +-y, from height z0, radii r0 at the top, r1 at the hoof. +fn legs(fx, bx, y, z0, r0, r1, mat) { + let out = []; + for x in [fx, bx] { + for s in [1.0, -1.0] { + out.push(p_tube([x, s * y, z0], [x + 0.02 * (if x > 0.0 { 1.0 } else { -1.0 }), s * y, 0.0], r0, r1, 4, mat)); + } + } + out +} + +/// The parts of one animal of `kind` facing +x at (0, 0), and the head +/// poses that take turns when it grazes: #{ body: [...], up: [...], down: [...] }. +fn animal_parts(kind, sub) { + if kind == "deer" { return deer_parts(sub); } + if kind == "boar" { return boar_parts(); } + if kind == "fox" { return fox_parts(); } + if kind == "elephant" { return elephant_parts(sub); } + rabbit_parts() +} + +/// A red or roe deer: sub "stag" (antlers), "doe" or "fawn" (drawn smaller). +fn deer_parts(sub) { + let body = [ + p_loft([[-0.62, 0.0, 0.99], [-0.55, 0.0, 0.97], [-0.3, 0.0, 0.95], [0.15, 0.0, 0.96], [0.5, 0.0, 1.0], [0.57, 0.0, 1.02]], + [[0.08, 0.1], [0.15, 0.18], [0.19, 0.23], [0.2, 0.24], [0.17, 0.22], [0.1, 0.14]], 8, ["deer", "deerl", "deer"]), + p_tube([-0.55, 0.0, 1.05], [-0.64, 0.0, 0.94], 0.04, 0.03, 4, "deerl"), + ]; + body += legs(0.38, -0.4, 0.1, 0.8, 0.045, 0.022, "deer"); + let head = |base, f| { + let out = [ + p_tube(base, hp(base, f, 0.27, 0.0, -0.02), [0.07, 0.08], [0.033, 0.04], 6, "deer"), + p_tube(hp(base, f, 0.25, 0.0, -0.02), hp(base, f, 0.29, 0.0, -0.025), 0.03, 0.022, 4, "nose"), + ]; + for s in [1.0, -1.0] { + out.push(p_face([hp(base, f, 0.03, s * 0.05, 0.05), hp(base, f, -0.03, s * 0.05, 0.04), hp(base, f, -0.04, s * 0.18, 0.15)], "deer")); + if sub == "stag" { + let a = hp(base, f, -0.01, s * 0.04, 0.07); + let b = hp(base, f, -0.08, s * 0.16, 0.3); + let c = hp(base, f, -0.14, s * 0.22, 0.5); + out.push(p_line([a, b, c, hp(base, f, -0.1, s * 0.3, 0.64)], "antler")); + out.push(p_line([b, hp(base, f, 0.06, s * 0.2, 0.4)], "antler")); + out.push(p_line([c, hp(base, f, -0.02, s * 0.32, 0.58)], "antler")); + } + } + out + }; + let up = [p_tube([0.45, 0.0, 1.08], [0.72, 0.0, 1.45], 0.085, 0.06, 6, "deer")] + head.call([0.72, 0.0, 1.47], vunit([0.95, 0.0, -0.3])); + let down = [p_tube([0.47, 0.0, 1.02], [0.8, 0.0, 0.48], 0.085, 0.06, 6, "deer")] + head.call([0.8, 0.0, 0.47], vunit([0.35, 0.0, -0.94])); + let r = #{ body: body, up: up, down: down }; + if sub == "fawn" { + r.body = scale_parts(r.body, 0.6); + r.up = scale_parts(r.up, 0.6); + r.down = scale_parts(r.down, 0.6); + } + r +} + +/// A wild boar: a heavy front, a long snout, small tusks; it roots with its snout. +fn boar_parts() { + let body = [ + p_loft([[-0.66, 0.0, 0.58], [-0.58, 0.0, 0.58], [-0.3, 0.0, 0.6], [0.15, 0.0, 0.64], [0.5, 0.0, 0.66], [0.58, 0.0, 0.67]], + [[0.09, 0.11], [0.17, 0.2], [0.23, 0.27], [0.26, 0.33], [0.24, 0.3], [0.16, 0.2]], 8, ["boar", "boar", "boar"]), + p_tube([-0.58, 0.0, 0.66], [-0.68, 0.0, 0.5], 0.025, 0.015, 4, "boar"), + p_line([[-0.45, 0.0, 0.86], [0.0, 0.0, 0.96], [0.45, 0.0, 0.98]], "bristle"), + ]; + body += legs(0.36, -0.4, 0.12, 0.42, 0.06, 0.035, "boar"); + let head = |base, f| { + let out = [ + p_tube(base, hp(base, f, 0.42, 0.0, -0.04), [0.17, 0.19], [0.06, 0.065], 6, "boar"), + p_tube(hp(base, f, 0.41, 0.0, -0.04), hp(base, f, 0.45, 0.0, -0.045), 0.06, 0.055, 6, "nose"), + ]; + for s in [1.0, -1.0] { + out.push(p_face([hp(base, f, 0.04, s * 0.09, 0.12), hp(base, f, -0.04, s * 0.08, 0.1), hp(base, f, -0.02, s * 0.15, 0.24)], "boar")); + out.push(p_tube(hp(base, f, 0.32, s * 0.06, -0.08), hp(base, f, 0.36, s * 0.09, 0.0), 0.014, 0.008, 4, "tusk")); + } + out + }; + let up = head.call([0.6, 0.0, 0.72], vunit([0.9, 0.0, -0.45])); + let down = head.call([0.62, 0.0, 0.62], vunit([0.55, 0.0, -0.84])); + #{ body: body, up: up, down: down } +} + +/// A red fox: slim, dark stockings, big ears, a bushy tail with a white tip. +fn fox_parts() { + let body = [ + p_loft([[-0.32, 0.0, 0.36], [0.0, 0.0, 0.36], [0.3, 0.0, 0.39], [0.36, 0.0, 0.41]], [[0.08, 0.09], [0.1, 0.11], [0.085, 0.1], [0.05, 0.06]], 8, ["fox", "fox", "foxw"]), + p_loft([[-0.3, 0.0, 0.38], [-0.5, 0.0, 0.33], [-0.68, 0.0, 0.26]], [0.045, 0.085, 0.07], 6, ["fox", "fox", "fox"]), + p_tube([-0.68, 0.0, 0.26], [-0.8, 0.0, 0.22], 0.07, 0.025, 6, "foxw"), + ]; + body += legs(0.24, -0.24, 0.05, 0.3, 0.025, 0.018, "foxd"); + let head = |base, f| { + let out = [ + p_tube([0.27, 0.0, 0.42], base, 0.06, 0.055, 6, "fox"), + p_tube(base, hp(base, f, 0.2, 0.0, -0.03), [0.07, 0.06], [0.014, 0.014], 6, "fox"), + p_tube(hp(base, f, 0.18, 0.0, -0.03), hp(base, f, 0.21, 0.0, -0.035), 0.015, 0.01, 4, "nose"), + ]; + for s in [1.0, -1.0] { + out.push(p_face([hp(base, f, 0.04, s * 0.04, 0.05), hp(base, f, -0.04, s * 0.04, 0.04), hp(base, f, -0.01, s * 0.08, 0.16)], "fox")); + } + out + }; + let up = head.call([0.4, 0.0, 0.5], vunit([0.95, 0.0, -0.25])); + let down = head.call([0.42, 0.0, 0.38], vunit([0.5, 0.0, -0.86])); + #{ body: body, up: up, down: down } +} + +/// An Asian elephant: sub "bull" has tusks. Its trunk hangs or curls up. +fn elephant_parts(sub) { + let body = [ + p_loft([[-1.48, 0.0, 1.85], [-1.3, 0.0, 1.87], [-0.6, 0.0, 1.95], [0.4, 0.0, 2.05], [1.15, 0.0, 2.0], [1.3, 0.0, 2.02]], + [[0.3, 0.38], [0.58, 0.68], [0.75, 0.85], [0.78, 0.9], [0.66, 0.78], [0.42, 0.52]], 8, ["eleph", "eleph", "eleph"]), + p_tube([-1.3, 0.0, 2.1], [-1.4, 0.0, 1.3], 0.04, 0.025, 4, "eleph"), + ]; + body += legs(0.8, -0.85, 0.34, 1.3, 0.24, 0.21, "eleph"); + let base = [1.2, 0.0, 2.25]; + let f = vunit([0.6, 0.0, -0.8]); + let head = [p_tube(base, hp(base, f, 0.62, 0.0, 0.0), [0.46, 0.52], [0.3, 0.3], 8, "eleph")]; + for s in [1.0, -1.0] { + head.push(p_face([[1.3, s * 0.5, 2.6], [1.02, s * 0.86, 2.52], [0.86, s * 0.98, 2.0], [0.98, s * 0.88, 1.45], [1.26, s * 0.56, 1.62]], "elephe")); + if sub == "bull" { + head.push(p_loft([[1.62, s * 0.2, 1.62], [1.85, s * 0.24, 1.42], [2.08, s * 0.22, 1.42]], [0.055, 0.045, 0.025], 5, ["tusk", "tusk", "tusk"])); + } + } + let t0 = hp(base, f, 0.6, 0.0, 0.0); + let hang = [p_loft([t0, [1.92, 0.0, 1.15], [1.98, 0.0, 0.7], [1.93, 0.0, 0.25]], [0.17, 0.13, 0.1, 0.07], 6, ["eleph", "eleph", "eleph"])]; + let curl = [p_loft([t0, [2.0, 0.0, 1.3], [2.2, 0.0, 1.25], [2.28, 0.0, 1.5]], [0.17, 0.13, 0.1, 0.07], 6, ["eleph", "eleph", "eleph"])]; + let r = #{ body: body + head, up: hang, down: curl }; + if sub == "calf" { + r.body = scale_parts(r.body, 0.55); + r.up = scale_parts(r.up, 0.55); + r.down = scale_parts(r.down, 0.55); + } + r +} + +/// A rabbit sitting up, long ears, a white scut. +fn rabbit_parts() { + let body = [ + p_loft([[-0.13, 0.0, 0.11], [0.0, 0.0, 0.12], [0.1, 0.0, 0.15]], [[0.075, 0.08], [0.095, 0.1], [0.07, 0.08]], 6, ["rabbit", "rabbit", "rabbit"]), + p_tube([-0.13, 0.0, 0.13], [-0.17, 0.0, 0.12], 0.035, 0.02, 4, "foxw"), + p_tube([0.1, 0.0, 0.2], [0.2, 0.0, 0.17], [0.05, 0.055], [0.025, 0.03], 6, "rabbit"), + ]; + for s in [1.0, -1.0] { + body.push(p_tube([0.11, s * 0.025, 0.24], [0.05, s * 0.05, 0.37], [0.012, 0.026], [0.008, 0.016], 4, "rabbit")); + } + #{ body: body, up: [], down: [] } +} + +/// Draws one animal of `kind` (sub-kind sub) in this sprite's frame; +/// `graze`: its head poses take turns every `cycle` seconds, `delay` in. +fn animal(kind, sub, graze, cycle, delay) { + let a = animal_parts(kind, sub); + let parts = a.body; + if a.up.len() > 0 { + if graze { + let at = a.up[0].at; + parts.push(#{ at: at, t: "alt", a: a.down, b: a.up, css: `animation-duration:${cycle}s;animation-delay:-${delay}s` }); + } else { + parts += a.up; + } + } + this.draw_parts(parts) +} + +/// The sub-kinds of a herd of `kind`, `mix` as picked in its props. +fn herd_subs(kind, mix, n) { + let out = []; + for i in 0..n { + out.push(if kind == "deer" { + if mix == "herd" { if i == 0 { "stag" } else if i % 3 == 2 { "fawn" } else { "doe" } } else { mix } + } else if kind == "elephant" { + if mix == "herd" { if i == 0 { "bull" } else if i % 3 == 2 { "calf" } else { "cow" } } else { mix } + } else { "" }); + } + out +} + +/// A herd (or one animal) of `kind`: n animals at scattered spots within r +/// of the object's position, facing its rotation give or take 70 degrees. +fn herd(o, kind, mix, graze) { + let p = o.p; + let n = p.n; + let subs = herd_subs(kind, mix, n); + let seed = o.x * 0.731 + o.y * 1.379; + let cycle = if kind == "elephant" { 7.0 } else { 9.0 }; + for i in 0..n { + // a sunflower spread: even, not in rows + let rr = if n == 1 { 0.0 } else { p.r * ((i + 0.5) / n).sqrt() }; + let ang = i * 2.39996 + hash(seed + i) * 0.6; + let lx = rr * ang.cos(); + let ly = rr * ang.sin(); + let w = world_of(o, lx, ly); + let turn = if n == 1 { 0.0 } else { (hash(seed + i * 3.7) - 0.5) * 140.0 }; + frame(w[0], w[1], o.z, o.rot + turn); + let s = sprite(); + if kind == "rabbits" && p.hop { + s.begin("a hop", `animation-delay:-${round1(hash(seed + i * 5.1) * 3.0)}s`); + s.animal(kind, subs[i], false, cycle, 0.0); + s.end(); + } else { + s.animal(kind, subs[i], graze, cycle, round1(hash(seed + i * 2.3) * cycle)); + } + } + frame(o); +} + +fn herd_props(n, r, rmax) { + [ + #{ key: "n", type: "int", def: n, min: 1, max: 12, step: 1, label: "How many" }, + #{ key: "r", type: "float", def: r, min: 0.5, max: rmax, step: 0.5, label: "Spread (m)" }, + ] +} + +fn meta_deer() { + #{ label: "Deer", category: "Animals", icon: "🦌", + help: "A herd of deer grazing: a stag with antlers, does and fawns (or all of one kind), at scattered spots facing about the object's rotation. Grazing deer lower and raise their heads in turn.", + props: herd_props(4, 4.0, 30.0) + [ + #{ key: "mix", type: "choice", def: "herd", choices: ["herd", "stag", "doe", "fawn"], label: "Kind" }, + #{ key: "graze", type: "bool", def: true, label: "Grazing" }, + ] } +} +fn prefab_deer(o) { + herd(o, "deer", o.p.mix, o.p.graze); + let subs = herd_subs("deer", o.p.mix, o.p.n); + let c = |k| subs.filter(|s| s == k).len(); + info_row("Herd", herd_text(c, [["stag", "stags"], ["doe", "does"], ["fawn", "fawns"]])); +} + +/// "1 stag, 2 does, 1 fawn" from counts per sub-kind. +fn herd_text(c, names) { + let parts = []; + for nm in names { + let k = c.call(nm[0]); + if k > 0 { parts.push(`${k} ${if k == 1 { nm[0] } else { nm[1] }}`); } + } + parts.reduce(|s, x| if s == "" { x } else { s + ", " + x }, "") +} + +fn meta_boar() { + #{ label: "Wild boar", category: "Animals", icon: "🐗", + help: "Wild boar rooting about: they push their snouts into the ground and look up in turn.", + props: herd_props(3, 3.0, 20.0) + [ #{ key: "graze", type: "bool", def: true, label: "Rooting" } ] } +} +fn prefab_boar(o) { + herd(o, "boar", "", o.p.graze); + info_row("Animals", `${o.p.n} wild boar`); +} + +fn meta_fox() { + #{ label: "Fox", category: "Animals", icon: "🦊", + help: "A red fox (or a few), nosing about now and then.", + props: herd_props(1, 3.0, 20.0) + [ #{ key: "graze", type: "bool", def: true, label: "Sniffing" } ] } +} +fn prefab_fox(o) { + herd(o, "fox", "", o.p.graze); + info_row("Animals", `${o.p.n} fox${if o.p.n == 1 { "" } else { "es" }}`); +} + +fn meta_elephant() { + #{ label: "Elephant", category: "Animals", icon: "🐘", + help: "Asian elephants: a bull with tusks, cows and calves (or all of one kind); their trunks hang and curl up in turn.", + props: herd_props(1, 6.0, 40.0) + [ + #{ key: "mix", type: "choice", def: "bull", choices: ["herd", "bull", "cow", "calf"], label: "Kind" }, + #{ key: "graze", type: "bool", def: true, label: "Trunk moves" }, + ] } +} +fn prefab_elephant(o) { + herd(o, "elephant", o.p.mix, o.p.graze); + let subs = herd_subs("elephant", o.p.mix, o.p.n); + let c = |k| subs.filter(|s| s == k).len(); + info_row("Herd", herd_text(c, [["bull", "bulls"], ["cow", "cows"], ["calf", "calves"]])); +} + +fn meta_rabbits() { + #{ label: "Rabbits", category: "Animals", icon: "🐇", + help: "Rabbits sitting about a meadow, hopping now and then.", + props: herd_props(4, 2.0, 15.0) + [ #{ key: "hop", type: "bool", def: true, label: "Hopping" } ] } +} +fn prefab_rabbits(o) { + herd(o, "rabbits", "", false); + info_row("Animals", `${o.p.n} rabbit${if o.p.n == 1 { "" } else { "s" }}`); +} + +// ---------------------------------------------------------------- flocks + +fn meta_birds() { + #{ label: "Birds", category: "Animals", icon: "🕊", + help: "A flock of birds circling at a height over the spot, wings flapping.", + props: [ + #{ key: "n", type: "int", def: 9, min: 1, max: 40, step: 1, label: "How many" }, + #{ key: "h", type: "float", def: 14.0, min: 3.0, max: 80.0, step: 0.5, label: "Height (m)" }, + #{ key: "r", type: "float", def: 8.0, min: 1.0, max: 60.0, step: 0.5, label: "Circle (m)" }, + #{ key: "kind", type: "choice", def: "dark", choices: ["dark", "white"], label: "Birds" }, + ] } +} +fn prefab_birds(o) { + let p = o.p; + let seed = o.x * 0.53 + o.y * 0.97; + let fl = []; + for i in 0..p.n { + let rr = p.r * (0.55 + 0.45 * hash(seed + i)); + fl.push(#{ rx: rr, ry: rr * (0.8 + 0.2 * hash(seed + i * 1.7)), dur: 14.0 + 8.0 * hash(seed + i * 2.9), delay: 20.0 * hash(seed + i * 4.1), + cw: false, len: 0.9, cls: if p.kind == "white" { "birdw" } else { "birdd" }, shape: "bird" }); + } + sprite().swim(0.0, 0.0, p.h, fl); + info_row("Flock", `${p.n} birds circling ${num1(p.h)} m up`); +} + +fn meta_butterflies() { + #{ label: "Butterflies", category: "Animals", icon: "🦋", + help: "Butterflies fluttering round over flowers.", + props: [ + #{ key: "n", type: "int", def: 6, min: 1, max: 30, step: 1, label: "How many" }, + #{ key: "r", type: "float", def: 2.0, min: 0.3, max: 15.0, step: 0.1, label: "Circle (m)" }, + #{ key: "h", type: "float", def: 0.8, min: 0.2, max: 4.0, step: 0.1, label: "Height (m)" }, + ] } +} +fn prefab_butterflies(o) { + let p = o.p; + let seed = o.x * 0.41 + o.y * 0.77; + let cls = ["flyo", "flyb", "flyw", "flyy"]; + let low = []; + let high = []; + for i in 0..p.n { + let rr = p.r * (0.3 + 0.7 * hash(seed + i)); + let b = #{ rx: rr, ry: rr * (0.6 + 0.4 * hash(seed + i * 1.3)), dur: 5.0 + 6.0 * hash(seed + i * 2.1), delay: 10.0 * hash(seed + i * 3.3), + cw: hash(seed + i * 5.9) > 0.5, len: 0.22, cls: cls[i % 4], shape: "fly" }; + if i % 2 == 0 { low.push(b); } else { high.push(b); } + } + let s = sprite(); + if low.len() > 0 { s.swim(0.0, 0.0, p.h, low); } + if high.len() > 0 { s.swim(0.0, 0.0, p.h + 0.4, high); } +} + +// ---------------------------------------------------------------- beehives + +fn hive_colours() { ["mixed", "white", "yellow", "blue", "green"] } + +fn meta_beehive() { + #{ label: "Beehives", category: "Nature", icon: "🐝", + help: "A row of beehives on stands along local x, entrances to local -y, bees flying round in front.", + props: [ + #{ key: "n", type: "int", def: 3, min: 1, max: 12, step: 1, label: "Hives" }, + #{ key: "gap", type: "float", def: 0.9, min: 0.6, max: 4.0, step: 0.1, label: "Spacing (m)" }, + #{ key: "colour", type: "choice", def: "mixed", choices: hive_colours(), label: "Paint" }, + #{ key: "supers", type: "int", def: 2, min: 0, max: 4, step: 1, label: "Supers" }, + #{ key: "stand", type: "bool", def: true, label: "Stands" }, + #{ key: "bees", type: "bool", def: true, label: "Bees" }, + ] } +} +fn prefab_beehive(o) { + let p = o.p; + let x0 = -(p.n - 1) * p.gap / 2.0; + let seed = o.x * 0.29 + o.y * 0.61; + for i in 0..p.n { + let x = x0 + i * p.gap; + let col = if p.colour == "mixed" { ["hive1", "hive2", "hive3", "hive4"][i % 4] } else { switch p.colour { "yellow" => "hive2", "blue" => "hive3", "green" => "hive4", _ => "hive1" } }; + let s = sprite(); + let z = 0.0; + if p.stand { + for lx in [-0.2, 0.2] { for ly in [-0.17, 0.17] { s.box(x + lx - 0.025, ly - 0.025, x + lx + 0.025, ly + 0.025, 0.0, 0.33, "woodd"); } } + s.box(x - 0.27, -0.24, x + 0.27, 0.24, 0.33, 0.37, "wood"); + z = 0.37; + } + // brood box with its entrance and landing board, the supers, the lid + s.box(x - 0.25, -0.21, x + 0.25, 0.21, z, z + 0.25, col); + s.box(x - 0.18, -0.29, x + 0.18, -0.21, z, z + 0.02, "wood"); + s.open([x - 0.25, -0.21], [x + 0.25, -0.21], [[0.1, 0.4, 0.02, 0.045]], "hivedoor"); + let zt = z + 0.25; + for k in 0..p.supers { + s.box(x - 0.25, -0.21, x + 0.25, 0.21, zt + 0.004, zt + 0.17, col); + zt += 0.17; + } + s.box(x - 0.29, -0.25, x + 0.29, 0.25, zt + 0.004, zt + 0.07, "hivelid"); + if p.bees { + let bs = []; + for k in 0..8 { + let r = 0.25 + 0.6 * hash(seed + i * 11.0 + k); + bs.push(#{ rx: r, ry: r * 0.7, dur: 1.6 + 2.0 * hash(seed + i * 13.0 + k * 1.9), delay: 4.0 * hash(seed + i * 7.0 + k * 3.1), + cw: k % 2 == 0, len: 0.12, cls: "bee", shape: "bee" }); + } + s.swim(x, -0.55, z + 0.35, bs); + } + } + info_row("Apiary", `${p.n} hive${if p.n == 1 { "" } else { "s" }}, ${p.supers} super${if p.supers == 1 { "" } else { "s" }} each`); +} + +// ---------------------------------------------------------------- plants + +fn flower_colours() { ["mixed", "red", "yellow", "white", "purple", "pink"] } + +/// The flower classes for a colour choice. +fn flower_cls(c) { + if c == "mixed" { ["flr", "fly", "flw", "flp", "flk"] } else { [switch c { "red" => "flr", "yellow" => "fly", "white" => "flw", "purple" => "flp", _ => "flk" }] } +} + +/// Small diamonds at plan points pts, height z, one path per class. +fn blossoms(pts, z, size, classes) { + let by = classes.map(|c| []); + for i in 0..pts.len() { + let q = pts[i]; + let k = i % classes.len(); + let d = by[k]; + d.push([[q[0] - size, q[1], z], [q[0], q[1] - size, z], [q[0] + size, q[1], z], [q[0], q[1] + size, z]]); + by[k] = d; + } + for k in 0..classes.len() { + if by[k].len() > 0 { this.poly(by[k], classes[k]); } + } + this +} + +fn meta_bush() { + #{ label: "Bush", category: "Nature", icon: "🌿", + help: "A shrub: round and green, in flower, or with berries.", + props: [ + #{ key: "r", type: "float", def: 0.9, min: 0.3, max: 4.0, step: 0.1, label: "Size (m)" }, + #{ key: "kind", type: "choice", def: "round", choices: ["round", "flowering", "berries"], label: "Kind" }, + #{ key: "colour", type: "choice", def: "mixed", choices: flower_colours(), label: "Flowers" }, + ] } +} +fn prefab_bush(o) { + let p = o.p; + let r = p.r; + let s = sprite(); + s.ext_box(-r, -r, r, r, 0.0, r * 1.5); + let R = r * px() * 0.95; + let seed = o.x * 0.37 + o.y * 0.83; + // three overlapping clumps, the back ones first, a light top + let clumps = [[-0.35 * r, 0.25 * r, 0.75], [0.3 * r, 0.3 * r, 0.8], [0.0, -0.25 * r, 0.9]]; + clumps.sort(|a, b| { let da = depth(a[0], a[1]); let db = depth(b[0], b[1]); if da < db { -1 } else if da > db { 1 } else { 0 } }); + for c in clumps { + s.circle(c[0], c[1], r * 0.62, R * c[2], "bush l"); + } + s.circle(0.0, 0.0, r * 0.9, R * 0.55, "bush t", -R * 0.2, -R * 0.25); + if p.kind != "round" { + // blossoms or berries dotted over the crown: small, spread up and down its face + let cls = if p.kind == "berries" { ["berry"] } else { flower_cls(p.colour) }; + let dot = R * (if p.kind == "berries" { 0.07 } else { 0.09 }); + for i in 0..22 { + let a = i * 2.39996 + hash(seed + i); + let d = r * (0.15 + 0.5 * hash(seed + i * 1.9)); + let z = r * (0.5 + 0.7 * hash(seed + i * 3.7)); + s.circle(d * a.cos(), d * a.sin(), z, dot, cls[i % cls.len()]); + } + } +} + +fn meta_flowers() { + #{ label: "Flowers", category: "Nature", icon: "✿", shape: "rect", + help: "A flower bed (rows in dark soil) or a wildflower meadow. Drag a rectangle on the map.", + props: [ + #{ key: "w", type: "float", def: 4.0, min: 0.5, max: 200.0, step: 0.5, label: "Width (m)" }, + #{ key: "d", type: "float", def: 2.0, min: 0.5, max: 200.0, step: 0.5, label: "Length (m)" }, + #{ key: "kind", type: "choice", def: "bed", choices: ["bed", "meadow"], label: "Kind" }, + #{ key: "colour", type: "choice", def: "mixed", choices: flower_colours(), label: "Flowers" }, + #{ key: "density", type: "float", def: 3.0, min: 0.5, max: 12.0, step: 0.5, label: "Per m²" }, + ] } +} +fn prefab_flowers(o) { + let p = o.p; + let w = p.w / 2.0; + let d = p.d / 2.0; + layer("ground"); + let s = sprite(); + let bed = p.kind == "bed"; + s.flat(rect(-w, -d, w, d), 0.02, if bed { "g-soil t" } else { "g-meadow t" }); + let n = fmin(600.0, (p.w * p.d * p.density).round()).to_int(); + let seed = o.x * 0.19 + o.y * 0.47; + let pts = []; + if bed { + // rows across the bed's length, a little uneven + let rows = fmax(1.0, (p.d / 0.45).floor()).to_int(); + let per = fmax(1.0, (n.to_float() / rows.to_float()).round()).to_int(); + for j in 0..rows { + let y = -d + (j + 0.5) * p.d / rows; + for i in 0..per { + let x = -w + (i + 0.5) * p.w / per; + pts.push([x + (hash(seed + i * 3.1 + j) - 0.5) * 0.15, y + (hash(seed + i + j * 5.3) - 0.5) * 0.12]); + } + } + } else { + for i in 0..n { + pts.push([-w + p.w * hash(seed + i * 1.37), -d + p.d * hash(seed + i * 2.71)]); + } + } + s.blossoms(pts.map(|q| [q[0] + 0.04, q[1] - 0.04]), 0.05, 0.07, ["leafy"]); + s.blossoms(pts, if bed { 0.32 } else { 0.22 }, 0.06, flower_cls(p.colour)); + info_row("Flowers", `${pts.len()} on ${num1(p.w * p.d)} m²`); +} + +// ---------------------------------------------------------------- rocks and logs + +fn meta_rocks() { + #{ label: "Rocks", category: "Nature", icon: "🪨", + help: "Boulders lying about: low-poly stones of varied size.", + props: [ + #{ key: "n", type: "int", def: 3, min: 1, max: 20, step: 1, label: "How many" }, + #{ key: "r", type: "float", def: 0.6, min: 0.1, max: 5.0, step: 0.1, label: "Size (m)" }, + #{ key: "spread", type: "float", def: 1.5, min: 0.0, max: 20.0, step: 0.1, label: "Spread (m)" }, + ] } +} +fn prefab_rocks(o) { + let p = o.p; + let seed = o.x * 0.67 + o.y * 0.23; + for i in 0..p.n { + let rr = if p.n == 1 { 0.0 } else { p.spread * ((i + 0.5) / p.n).sqrt() }; + let a = i * 2.39996 + hash(seed + i); + let size = p.r * (0.55 + 0.6 * hash(seed + i * 4.3)); + sprite().rock(rr * a.cos(), rr * a.sin(), size, seed + i * 9.1); + } +} + +/// A low-poly boulder of radius r at (x, y): jittered rings from the ground +/// up, widest a little above it, and a flat-ish top. +fn rock(x, y, r, seed) { + let n = 7; + let h = r * (0.75 + 0.35 * hash(seed)); + let rings = [[0.0, 0.9], [0.35, 1.0], [0.75, 0.78], [1.0, 0.4]]; + let pts = []; + for ring in rings { + let q = []; + for k in 0..n { + let a = 2.0 * PI() * (k + 0.3 * hash(seed + k)) / n; + let rk = r * ring[1] * (0.85 + 0.25 * hash(seed + k * 1.7 + ring[0])); + q.push([x + rk * a.cos(), y + rk * a.sin() * 0.85, h * ring[0]]); + } + pts.push(q); + } + for i in 1..pts.len() { + let lo = pts[i - 1]; + let hi = pts[i]; + for k in 0..n { + let j = (k + 1) % n; + this.face([lo[k], lo[j], hi[j], hi[k]], "rock beast"); + } + } + this.face(pts[pts.len() - 1], "rock beast"); + this +} + +fn meta_log() { + #{ label: "Log", category: "Nature", icon: "🪵", + help: "A fallen log along local x, or a tree stump.", + props: [ + #{ key: "kind", type: "choice", def: "log", choices: ["log", "stump"], label: "Kind" }, + #{ key: "w", type: "float", def: 3.0, min: 0.3, max: 20.0, step: 0.1, label: "Length (m)" }, + #{ key: "r", type: "float", def: 0.25, min: 0.05, max: 1.5, step: 0.05, label: "Radius (m)" }, + ] } +} +fn prefab_log(o) { + let p = o.p; + let s = sprite(); + if p.kind == "stump" { + let h = fmax(0.25, p.r * 1.6); + s.loft([[0.0, 0.0, 0.0], [0.0, 0.0, h]], [p.r * 1.15, p.r], 9, ["bark", "bark", "woodend"]); + } else { + let w = p.w / 2.0; + s.loft([[-w, 0.0, p.r * 0.92], [w, 0.0, p.r * 0.92]], [p.r, p.r * 0.9], 9, ["bark", "woodend", "woodend"]); + s.tube([w * 0.3, 0.0, p.r * 1.5], [w * 0.3 + 0.2, 0.22, p.r * 2.1], p.r * 0.25, p.r * 0.15, 5, "bark"); + } +} diff --git a/crates/isokit-theme/themes/default.toml b/crates/isokit-theme/themes/default.toml index 8055d7d..9409bd3 100644 --- a/crates/isokit-theme/themes/default.toml +++ b/crates/isokit-theme/themes/default.toml @@ -272,6 +272,45 @@ mach3 = "#5b6370" mach4 = "#b8692b" robot = "#e07a1f" robotb = "#3a3f48" +# wildlife and garden +deer = "#8a5a32" +deerl = "#d6c4a6" +nose = "#2a2420" +antler = "#c9b48e" +boar = "#5a4b42" +bristle = "#2c2622" +fox = "#c45f25" +foxw = "#e9e1d4" +foxd = "#3a2a22" +rabbit = "#8a7a66" +eleph = "#6f6b69" +elephe = "#625e5c" +tusk = "#e9e2d0" +birdd = "#0b0d10" +birdw = "#e8e6e0" +bee = "#e7b416" +flyo = "#f08a24" +flyb = "#4f86d9" +flyw = "#f2efe6" +flyy = "#f5d33f" +hive1 = "#e6e1d3" +hive2 = "#e2b84f" +hive3 = "#7aa3c4" +hive4 = "#86b07f" +hivelid = "#9aa1a8" +hivedoor = "#1d1a17" +bush = "#2f6a3a" +berry = "#b8303a" +flr = "#d8434a" +fly = "#e8c33a" +flw = "#ece8de" +flp = "#8a5bc4" +flk = "#e27aa5" +leafy = "#2d5e30" +soil = "#4a3828" +meadow = "#2f5a2e" +bark = "#5a3f28" +woodend = "#cfae7c" # streets: sidewalk paving, kerb stone faces, roundabout apron walk = "#555c6b" kerbf = "#7d8494" @@ -542,6 +581,44 @@ mach3 = "#8a93a1" mach4 = "#e08a43" robot = "#f08c2b" robotb = "#5a616d" +deer = "#a8703f" +deerl = "#ecdfc8" +nose = "#3a302a" +antler = "#d8c39c" +boar = "#5e5048" +bristle = "#3a322c" +fox = "#d9732f" +foxw = "#f4efe6" +foxd = "#4a3428" +rabbit = "#a08d76" +eleph = "#8a8582" +elephe = "#7b7673" +tusk = "#f3eee0" +birdd = "#343a44" +birdw = "#f6f4ee" +bee = "#f0c02a" +flyo = "#f59536" +flyb = "#5b93e6" +flyw = "#fbf8f0" +flyy = "#f7dc52" +hive1 = "#f1ece0" +hive2 = "#ecc561" +hive3 = "#8fb6d6" +hive4 = "#97c290" +hivelid = "#b6bcc2" +hivedoor = "#2a2520" +bush = "#4f9a48" +berry = "#d03e48" +flr = "#e45a5f" +fly = "#f1d04e" +flw = "#fbf9f3" +flp = "#9b6fd6" +flk = "#ee93b8" +leafy = "#4a8a40" +soil = "#7a5a42" +meadow = "#78ab55" +bark = "#6e4e32" +woodend = "#ddbf8e" walk = "#d9d6cf" kerbf = "#a5a9b1" apron = "#b9ad98" @@ -556,6 +633,29 @@ g-conc = "conc" g-pad = "pad" g-path = "path" g-walk = "walk" +deer = "deer" +deerl = "deerl" +nose = "nose" +boar = "boar" +fox = "fox" +foxw = "foxw" +foxd = "foxd" +rabbit = "rabbit" +eleph = "eleph" +elephe = "elephe" +tusk = "tusk" +hive1 = "hive1" +hive2 = "hive2" +hive3 = "hive3" +hive4 = "hive4" +hivelid = "hivelid" +hivedoor = "hivedoor" +bush = "bush" +rock = "rock" +bark = "bark" +woodend = "woodend" +g-soil = "soil" +g-meadow = "meadow" g-apron = "apron" plinth = "plinth" wall = "wall" @@ -818,6 +918,23 @@ 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-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" +".iso-svg .iso-birdd" = "fill: var(--iso-birdd)" +".iso-svg .iso-birdw" = "fill: var(--iso-birdw)" +".iso-svg .iso-bee" = "fill: var(--iso-bee); stroke: none" +".iso-svg .iso-flyo" = "fill: var(--iso-flyo)" +".iso-svg .iso-flyb" = "fill: var(--iso-flyb)" +".iso-svg .iso-flyw" = "fill: var(--iso-flyw)" +".iso-svg .iso-flyy" = "fill: var(--iso-flyy)" +".iso-svg .iso-berry" = "fill: var(--iso-berry); stroke: none" +".iso-svg .iso-flr" = "fill: var(--iso-flr); stroke: none" +".iso-svg .iso-fly" = "fill: var(--iso-fly); stroke: none" +".iso-svg .iso-flw" = "fill: var(--iso-flw); stroke: none" +".iso-svg .iso-flp" = "fill: var(--iso-flp); stroke: none" +".iso-svg .iso-flk" = "fill: var(--iso-flk); stroke: none" +".iso-svg .iso-leafy" = "fill: var(--iso-leafy); stroke: none" ".iso-svg .iso-kerbf" = "fill: var(--iso-kerbf); stroke: none" ".iso-svg .iso-lane" = "fill: none; stroke: var(--iso-dash); stroke-width: .6; stroke-dasharray: 4 6" ".iso-svg .iso-giveway" = "fill: none; stroke: var(--iso-dash); stroke-width: .9; stroke-dasharray: 2 2" diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b1b7bec..7d7eb8d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -83,7 +83,7 @@ Each phase ends with tests, a Studio/viewer check in Edge, commit and push. 8. Waypoint animation: a person or car moving along a path of waypoints 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 +11. ~~Wildlife and nature: deer, bushes, beehives and more~~ (done, see below) Done since: the middle mouse button only moves the camera; fences and site walls join like house walls (`line_joint`); fence designs (mesh, wood, bars, half stone, cobble, brick with pillars); @@ -132,6 +132,12 @@ against everything on either side. Studio: runs and drags keep to x or y or go d (Ctrl: any angle), a road run turning 45° gets a 45° curve, Rotation takes any angle. The streets demo has a diagonal lane. +Wildlife and nature (`wildlife.rhai`): Deer, Wild boar, Fox, Elephant, Rabbits as low-poly solids +(`loft`, `tube`, parts drawn back to front), herds scattered round a spot, two head poses taking +turns while grazing, rabbits hopping; Birds and Butterflies as flocks (`swim` shapes, wings flapping +out of step); Beehives with bees, Bush, Flowers (bed or meadow), Rocks, Log / stump. +`examples/country`, Country demo in the web Studio. + ## Phase 2: richer editing - [x] Interior prefabs (see Build your own house); a building `rooms` prop is still open diff --git a/docs/SCRIPTING.md b/docs/SCRIPTING.md index d71b28f..c837fe7 100644 --- a/docs/SCRIPTING.md +++ b/docs/SCRIPTING.md @@ -88,11 +88,11 @@ shaded automatically by face direction. | `flat(poly or polys, z, cls[, id])` | flat polygon(s); later ones cut holes | | `open(a, b, [[u0, u1, z0, z1], …], cls[, id])` | windows / doors on the face a→b (outward normal on the right) | | `lines(segs, z, cls)`, `disc(cx, cy, z, r, cls[, id])` | markings | -| `polyline(lines, z, cls)` | open lines through plan points at height z (or 3D points): a dashed line stays one dash run round a curve | +| `polyline(lines, z, cls)` | open lines through plan points at height z, or 3D points `[x, y, z]` (antlers): a dashed line stays one dash run round a curve | | `circle(x, y, z, r_px, cls[, dx, dy])`, `stem(x, y, z0, z1, w_px, cls)` | screen-space details (crowns, poles) | | `person(x, y, z[, cls])`, `sit(x, y, z, cls)`, `head(x, y, z, r)`, `led(x, y, z[, cls])` | figures | | `puffs(x, y, z[, kind[, n, dur]])`, `fan(x, y, z, r)` | animated steam / smoke / fans | -| `swim(x, y, z, [#{ rx, ry, dur, delay, cw, len, cls }, ...])` | 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) | +| `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()` | `` around parts (animation) | | `ext(x, y, z)`, `ext_box(…)` | widen the sort box | @@ -145,6 +145,27 @@ orders them, the one on the viewer's side going later. A long diagonal wall therefore sorts right against everything on either side of it, and mitred pieces against each other. +## Animals + +`wildlife.rhai` draws animals as low-poly solids from faces. `s.loft(spine, +rs, n, [side, cap0, cap1])` sweeps an n-sided ring (radius r, or `[across, +up]`) along 3D points, keeping its turn round bends; `s.tube(a, b, ra, rb, n, +mat)` is a loft of two rings. `face` shades each face by its direction and +leaves out the ones turned away, so a solid needs no sorting of its own +faces; separate parts (legs, body, neck, head, antlers) are drawn back to +front by `draw_parts`, which sorts them by the depth of their anchors in the +view (`depth(x, y) + z`). The class `beast` edges each face in its own shade +instead of the dark outline, so a body reads as one surface. + +`animal_parts(kind, sub)` returns `#{ body, up, down }` for an animal facing ++x: the parts that stay and two head poses. With grazing on, the poses sit in +two groups `iso-a iso-pose2 iso-pose0` and `iso-a iso-pose2` that take turns +(the CSS shows the first unanimated); each animal starts at its own point of +the cycle. `herd(o, kind, mix, graze)` scatters `n` animals in a sunflower +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`. + ## Production lines `producer`, `conveyor` and `robot_arm` (stdlib/industry.rhai) work together diff --git a/examples/country/isokit.toml b/examples/country/isokit.toml new file mode 100644 index 0000000..1d7443f --- /dev/null +++ b/examples/country/isokit.toml @@ -0,0 +1,29 @@ +# isokit country demo: animals and plants of the countryside, and fences, hedges and a lane at an angle. +# Open it with `isokit studio examples/country`. +title = "isokit country" +note = "A corner of the countryside: elephants in a paddock with a cut corner, deer grazing at the forest edge, wild boar, a fox, rabbits, an apiary by a wildflower meadow, a pond. The lane bends at 45 degrees, the hedgerow runs along it. Tap anything for its numbers. Drag to pan, Ctrl + wheel or pinch to zoom." +default_level = "site" +default_rotation = 0 +prelude = [] +theme = [] + +[[level]] +id = "site" +label = "Site" +title = "Countryside" + +[[param]] +name = "labels" +type = "bool" +default = true +label = "Labels" + +[[param]] +name = "running" +type = "bool" +default = true +label = "Animation" + +[build] +out = "dist/index.html" +budget = 6000 diff --git a/examples/country/scene.json b/examples/country/scene.json new file mode 100644 index 0000000..05db417 --- /dev/null +++ b/examples/country/scene.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "objects": [ + {"id": "ground", "type": "area", "name": "Meadows", "x": 0, "y": 0, "rot": 0, "props": {"w": 96, "d": 74, "mat": "g-grass"}}, + {"id": "lane-w", "type": "road", "name": "Lane (west)", "x": -30.0, "y": -30.0, "rot": 0.0, "props": {"w": 36.0, "d": 4.5, "dashed": false, "kerb": false}}, + {"id": "lane-d", "type": "road", "name": "Lane (diagonal)", "x": 3.0, "y": -15.0, "rot": 45.0, "props": {"w": 42.426, "d": 4.5, "dashed": false, "kerb": false}}, + {"id": "lane-n", "type": "road", "name": "Lane (north)", "x": 18.0, "y": 18.5, "rot": 90.0, "props": {"w": 37.0, "d": 4.5, "dashed": false, "kerb": false}}, + {"id": "paddock-1", "type": "fence", "name": "Paddock fence", "x": -33.0, "y": -6.0, "rot": 0.0, "props": {"w": 22.0, "style": "wood", "h": 1.6}}, + {"id": "paddock-2", "type": "fence", "name": "Paddock fence", "x": -18.0, "y": -2.0, "rot": 45.0, "props": {"w": 11.314, "style": "wood", "h": 1.6}}, + {"id": "paddock-3", "type": "fence", "name": "Paddock fence", "x": -14.0, "y": 14.0, "rot": 90.0, "props": {"w": 24.0, "style": "wood", "h": 1.6}}, + {"id": "paddock-4", "type": "fence", "name": "Paddock fence", "x": -29.0, "y": 26.0, "rot": 180.0, "props": {"w": 30.0, "style": "wood", "h": 1.6}}, + {"id": "paddock-5", "type": "fence", "name": "Paddock fence", "x": -44.0, "y": 10.0, "rot": 270.0, "props": {"w": 32.0, "style": "wood", "h": 1.6}}, + {"id": "elephants", "type": "elephant", "name": "Elephants", "x": -29, "y": 11, "rot": 330, "props": {"n": 3, "mix": "herd", "r": 6}}, + {"id": "pad-rocks", "type": "rocks", "name": "Rocks", "x": -38, "y": 20, "rot": 0, "props": {"n": 3, "r": 0.8, "spread": 2}}, + {"id": "pad-tree", "type": "tree", "x": -20, "y": 21, "rot": 0, "props": {"r": 2.2}}, + {"id": "forest-1", "type": "tree_row", "name": "Forest edge", "x": 36, "y": 34, "rot": 0, "props": {"w": 26, "spacing": 4, "r": 2.0}}, + {"id": "forest-2", "type": "tree_row", "x": 36, "y": 29, "rot": 0, "props": {"w": 24, "spacing": 5, "r": 1.8}}, + {"id": "tree-1", "type": "tree", "x": 24, "y": 24, "rot": 0, "props": {"r": 1.6}}, + {"id": "tree-2", "type": "tree", "x": 41, "y": 22, "rot": 0, "props": {"r": 2.0}}, + {"id": "tree-3", "type": "tree", "x": -6, "y": 34, "rot": 0, "props": {"r": 1.8}}, + {"id": "tree-4", "type": "tree", "x": 4, "y": 33, "rot": 0, "props": {"r": 2.1}}, + {"id": "tree-5", "type": "tree", "x": -2, "y": 29, "rot": 0, "props": {"r": 1.5}}, + {"id": "deer", "type": "deer", "name": "Deer", "x": 30, "y": 14, "rot": 200, "props": {"n": 6, "r": 5}}, + {"id": "boar", "type": "boar", "name": "Wild boar", "x": 40, "y": 28, "rot": 170, "props": {"n": 3, "r": 2.5}}, + {"id": "log", "type": "log", "name": "Fallen log", "x": 8, "y": 28, "rot": 20, "props": {"w": 4, "r": 0.3}}, + {"id": "stump", "type": "log", "x": 12, "y": 31, "rot": 0, "props": {"kind": "stump", "r": 0.4}}, + {"id": "fox", "type": "fox", "name": "Fox", "x": 6, "y": 16, "rot": 300, "props": {"n": 1}}, + {"id": "hedge-1", "type": "hedge", "name": "Hedgerow", "x": 2.0, "y": -8.0, "rot": 45.0, "props": {"w": 22.627, "d": 0.9, "h": 1.5}}, + {"id": "hedge-2", "type": "hedge", "name": "Hedgerow", "x": 10.0, "y": 5.0, "rot": 90.0, "props": {"w": 10.0, "d": 0.9, "h": 1.5}}, + {"id": "bush-1", "type": "bush", "x": -8, "y": -18, "rot": 0, "props": {"r": 1.0, "kind": "berries"}}, + {"id": "bush-2", "type": "bush", "x": 12, "y": 12, "rot": 0, "props": {"r": 0.9, "kind": "flowering", "colour": "white"}}, + {"id": "bush-3", "type": "bush", "x": 2, "y": -4, "rot": 0, "props": {"r": 0.7}}, + {"id": "meadow", "type": "flowers", "name": "Wildflower meadow", "x": 4, "y": -29, "rot": 0, "props": {"w": 18, "d": 7, "kind": "meadow", "density": 5}}, + {"id": "hives", "type": "beehive", "name": "Apiary", "x": 31, "y": -22, "rot": 0, "props": {"n": 4, "gap": 1.0}}, + {"id": "butterflies", "type": "butterflies", "x": 4, "y": -29, "rot": 0, "props": {"n": 8, "r": 4}}, + {"id": "bed", "type": "flowers", "name": "Flower bed", "x": 31, "y": -27, "rot": 0, "props": {"w": 6, "d": 2, "kind": "bed", "colour": "mixed"}}, + {"id": "rabbits", "type": "rabbits", "name": "Rabbits", "x": -28, "y": -20, "rot": 0, "props": {"n": 5, "r": 2.5}}, + {"id": "bush-4", "type": "bush", "x": -20, "y": -22, "rot": 0, "props": {"r": 1.2}}, + {"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}} + ] +} diff --git a/tools/build_web.py b/tools/build_web.py index c6cf6bd..90bbd6d 100644 --- a/tools/build_web.py +++ b/tools/build_web.py @@ -150,6 +150,7 @@ def main(): (web / "house.json").write_text(json.dumps(demo_files("house"), ensure_ascii=False), encoding="utf-8") (web / "factory.json").write_text(json.dumps(demo_files("factory"), ensure_ascii=False), encoding="utf-8") (web / "streets.json").write_text(json.dumps(demo_files("streets"), ensure_ascii=False), encoding="utf-8") + (web / "country.json").write_text(json.dumps(demo_files("country"), ensure_ascii=False), encoding="utf-8") h = hashlib.sha256() for p in sorted((out / "assets").rglob("*")): diff --git a/tools/check_studio.py b/tools/check_studio.py index af5ff91..866c45a 100644 --- a/tools/check_studio.py +++ b/tools/check_studio.py @@ -555,6 +555,28 @@ with sync_playwright() as p: pg.wait_for_timeout(700) check(len(doc()["objects"]) == n5, "four undos take the diagonals back") + # animals and an apiary from the palette: a deer herd grazes, the hives get bees + pal = pg.evaluate("() => [...document.querySelectorAll('.st-cat')].map(e => e.dataset.cat)") + check("Animals" in pal, "the palette has an Animals category") + pg.locator('.st-tile[data-type="deer"]').click() + pg.mouse.click(*at(44, 28)) + pg.wait_for_timeout(900) + deer = doc()["objects"][-1] + pg.locator('.st-tile[data-type="beehive"]').click() + pg.mouse.click(*at(52, 18)) + pg.wait_for_timeout(900) + hives = doc()["objects"][-1] + grazing = pg.evaluate(f"() => document.querySelectorAll('[data-obj=\"{deer['id']}\"] .iso-pose2.iso-pose0').length") + bees = pg.evaluate(f"() => document.querySelectorAll('[data-obj=\"{hives['id']}\"] .iso-bee').length") + herd = pg.evaluate(f"() => Object.fromEntries((__studio.view.info('{deer['id']}') || {{rows: []}}).rows)['Herd'] || ''") + check(deer["type"] == "deer" and hives["type"] == "beehive" and grazing == 4 and bees == 24 and herd == "1 stag, 2 does, 1 fawn", + f"a deer herd and beehives placed from the palette: {grazing} grazing, {bees} bees, herd {herd}") + pg.screenshot(path=str(OUT / "studio-wildlife.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 animals 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") diff --git a/tools/check_web.py b/tools/check_web.py index 8048236..7a7c13b 100644 --- a/tools/check_web.py +++ b/tools/check_web.py @@ -342,6 +342,22 @@ try: check(meets == "east-ave, park-rd" and ring.startswith("4:"), f"streets know what they meet: cross-st meets {meets}; roundabout arms {ring}") shot(pg, "web-streets.jpg") + # ---------------------------------------------------------------- the country demo: animals, an apiary, diagonals + pg.locator("#wb-projects").click() + pg.locator("#wb-ncountry").click() + pg.wait_for_load_state() + ready(pg) + pg.wait_for_timeout(500) + nc = len(doc(pg)["objects"]) + beasts = pg.evaluate("() => new Set([...document.querySelectorAll('.iso-svg .iso-beast')].map(e => e.closest('[data-obj]')?.dataset.obj)).size") + poses = pg.evaluate("() => document.querySelectorAll('.iso-svg .iso-pose2.iso-pose0').length") + check(title(pg) == "isokit country" and nc > 30 and beasts >= 8 and poses >= 12 and pg.evaluate("() => __studio.S.warnings.length") == 0, + f"Country demo opens the countryside ({nc} objects, {beasts} animal and rock groups, {poses} grazing animals, no warnings)") + herd = pg.evaluate("() => Object.fromEntries((__studio.view.info('elephants') || {rows: []}).rows)['Herd'] || ''") + rot = pg.evaluate("() => __studio.S.doc.objects.find(o => o.id === 'lane-d').rot") + check(herd == "1 bull, 1 cow, 1 calf" and abs(rot - 45) < 1e-6, f"elephants: {herd}; the lane bends at {rot} degrees") + shot(pg, "web-country.jpg") + # ---------------------------------------------------------------- a system asking for less motion # (Windows: Animation effects off): the view starts still, but the button stays and turns it on rctx = b.new_context(viewport={"width": 1200, "height": 800}, reduced_motion="reduce") diff --git a/viewer/isokit-view.css b/viewer/isokit-view.css index 2b72066..f479c9f 100644 --- a/viewer/isokit-view.css +++ b/viewer/isokit-view.css @@ -73,6 +73,12 @@ .iso-svg .iso-r { fill: color-mix(in srgb, var(--b, #888) var(--iso-kr), var(--iso-shade)); } .iso-sprites path, .iso-sprites ellipse, .iso-sprites circle { stroke: var(--iso-edge); stroke-width: var(--iso-edgew); stroke-linejoin: round; } .iso-ground path, .iso-ground ellipse { stroke: none; } +/* animals: each face edged in its own shade (no seams, no wireframe), so a body reads as one surface */ +.iso-sprites .iso-beast { stroke-width: .3; } +.iso-sprites .iso-beast.iso-t { stroke: var(--b, #888); } +.iso-sprites .iso-beast.iso-l { stroke: color-mix(in srgb, var(--b, #888) var(--iso-kl), var(--iso-shade)); } +.iso-sprites .iso-beast.iso-c { stroke: color-mix(in srgb, var(--b, #888) var(--iso-kc), var(--iso-shade)); } +.iso-sprites .iso-beast.iso-r { stroke: color-mix(in srgb, var(--b, #888) var(--iso-kr), var(--iso-shade)); } @supports not (color: color-mix(in srgb, red 50%, blue)) { .iso-svg .iso-l, .iso-svg .iso-c, .iso-svg .iso-r { fill: var(--b, #888); } .iso-svg .iso-r { fill-opacity: .82; } @@ -126,6 +132,13 @@ html[data-theme=light] .iso-svg .iso-glint { display: inline; } unanimated (reduced motion, ghosted) only the first */ .iso-svg .iso-flip { visibility: hidden; animation: iso-flip16 4s step-end infinite; } .iso-svg .iso-flip.iso-pose0 { visibility: visible; } +/* animals: two poses taking turns (grazing, looking up; delays set the turn); unanimated the first. + Birds flap and butterflies flutter (wings scaled across their span); rabbits hop now and then */ +.iso-svg .iso-pose2 { visibility: hidden; animation: iso-pose2b 9s step-end infinite; } +.iso-svg .iso-pose2.iso-pose0 { visibility: visible; animation-name: iso-pose2a; } +.iso-svg .iso-flap { transform-box: fill-box; transform-origin: center; animation: iso-flap .8s 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-park { display: none; } /* 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, @@ -147,6 +160,10 @@ html[data-theme=light] .iso-svg .iso-glint { display: inline; } @keyframes iso-fadeout { 0%, 65% { opacity: 1; } 100% { opacity: 0; } } @keyframes iso-fadeio { 0%, 100% { opacity: 0; } 30%, 70% { opacity: 1; } } @keyframes iso-flip16 { 0% { visibility: visible; } 6.25%, 100% { visibility: hidden; } } +@keyframes iso-pose2a { 0% { visibility: visible; } 65%, 100% { visibility: hidden; } } +@keyframes iso-pose2b { 0% { visibility: hidden; } 65%, 100% { visibility: visible; } } +@keyframes iso-flap { from { transform: scaleX(1); } to { transform: scaleX(.25); } } +@keyframes iso-hop { 0%, 70%, 100% { transform: translate(0, 0); } 78% { transform: translate(0, -4px); } 86% { transform: translate(0, 0); } 91% { transform: translate(0, -2px); } } @keyframes iso-ram { 0%, 20%, 100% { transform: translate(0, 0); } 45%, 55% { transform: translate(var(--dx), var(--dy)); } } @keyframes iso-move { from { transform: translate(0, 0); } to { transform: translate(var(--dx), var(--dy)); } } @keyframes iso-shuttle { diff --git a/web/web.js b/web/web.js index 70204db..b029186 100644 --- a/web/web.js +++ b/web/web.js @@ -367,7 +367,7 @@ function injectUI() { '

Projects

' + '
' + '
    ' + - '
    ' + + '
    ' + '
    Import: drop a project folder or .zip here, or ' + (canOpenFolder ? '' : "") + "
    " + '

    Projects are kept in this browser. ' + (canOpenFolder ? "A folder opened live is edited on disk, so agents (isokit mcp), editors and git see the same files." : "To edit a folder on disk directly, use Chrome or Edge.") + "

    " + @@ -420,6 +420,7 @@ function wireProjects() { $("wb-nhouse").addEventListener("click", async function () { openProject((await createProject(await demoFiles("house.json"))).id); }); $("wb-nfactory").addEventListener("click", async function () { openProject((await createProject(await demoFiles("factory.json"))).id); }); $("wb-nstreets").addEventListener("click", async function () { openProject((await createProject(await demoFiles("streets.json"))).id); }); + $("wb-ncountry").addEventListener("click", async function () { openProject((await createProject(await demoFiles("country.json"))).id); }); $("wb-izip").addEventListener("click", function () { $("wb-fzip").click(); }); $("wb-idir").addEventListener("click", function () { $("wb-fdir").click(); }); $("wb-fzip").addEventListener("change", function () { var f = this.files[0]; if (f) importZip(f); this.value = ""; });