diff --git a/.gitea/workflows/deploy-web.yml b/.gitea/workflows/deploy-web.yml index 77c25c9..3004b54 100644 --- a/.gitea/workflows/deploy-web.yml +++ b/.gitea/workflows/deploy-web.yml @@ -21,6 +21,7 @@ on: - "web/**" - "examples/demo/**" - "examples/house/**" + - "examples/factory/**" - "tools/build_web.py" - "Cargo.toml" - "Cargo.lock" diff --git a/README.md b/README.md index 3500fcb..7f40185 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,9 @@ isokit studio C:/dev/myproject/site-view # opens the editor in the browser isokit build C:/dev/myproject/site-view # dist/index.html, no server needed ``` -Try the demos: `isokit studio examples/demo` (a works site) and -`isokit studio examples/house` (a furnished house built from parts). +Try the demos: `isokit studio examples/demo` (a works site), +`isokit studio examples/house` (a furnished house built from parts) and +`isokit studio examples/factory` (presses and conveyors). ### Studio (for everyone) @@ -192,6 +193,12 @@ the footprint, and `rot` turns the object in 90° steps. Levels: `site` (or any other id) shows buildings closed, while `gf`, `1f`, `2f` … cut them open at that storey. +Production lines: a Producer (category Industry) makes goods at a set rate +and puts them out on its +x side; Conveyors drawn from there carry them on, +round corners and up inclines, each at its own speed, so the gap between goods +follows rate and speed. Tooltips show the numbers. `examples/factory` (Factory +demo in the web Studio) has a few lines. + Animations (fans, smoke, fish, traffic) step on about ten times a second by default ("light"): running CSS animations repaint the whole drawing every frame, which keeps a slow laptop busy. The ▷ button in the view toolbar switches @@ -224,6 +231,7 @@ studio/ the Studio GUI (served by `isokit studio`, and by the web web/ web Studio shell: WASM backend for studio.js, projects, folders, zip, code editor examples/demo demo project (works site) examples/house house built from parts (walls, windows, doors, floors, furniture, roof) +examples/factory production yard: producers and conveyors deploy/Caddyfile the web Studio's vhost on the buildfor.life web server (s1) ``` diff --git a/crates/isokit-project/src/mcp.rs b/crates/isokit-project/src/mcp.rs index f4a64ae..ff5f79a 100644 --- a/crates/isokit-project/src/mcp.rs +++ b/crates/isokit-project/src/mcp.rs @@ -23,7 +23,11 @@ then windows and doors with x, y on a wall line (they sit on that wall and cut t the wall's +y side, rot + 180 swings it the other way), a roof (w x d = outer size of the walls) and furniture \ (fronts face local -y, backs go against a wall). Level gf shows the house cut open; a part's storey is its z / 3 m. \ Fences (props.style: mesh, wood, bars, half stone, cobble, brick) and site walls join where their centre-line ends \ -meet, so for a plot boundary put each piece's ends exactly on the next piece's end or line."; +meet, so for a plot boundary put each piece's ends exactly on the next piece's end or line. \ +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). \ +The gap between goods is speed / rate, so set the rate on the producer and the speeds on the belts."; fn tool(name: &str, description: &str, props: Value, required: &[&str]) -> Value { json!({ "name": name, "description": description, diff --git a/crates/isokit-project/tests/demo.rs b/crates/isokit-project/tests/demo.rs index 6e3aa6e..06fce4d 100644 --- a/crates/isokit-project/tests/demo.rs +++ b/crates/isokit-project/tests/demo.rs @@ -72,12 +72,25 @@ fn demo_passes_the_check() { assert!(f.is_empty(), "{f:?}"); } -/// The house demo: every view draws every object without warnings or depth-sort cycles -/// (walls must not overlap), and the project passes the check. +/// The house and factory 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() { - let p = - Project::load(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/house")).unwrap(); + example_renders_every_view_cleanly("house"); +} + +#[test] +fn factory_demo_renders_every_view_cleanly() { + example_renders_every_view_cleanly("factory"); +} + +fn example_renders_every_view_cleanly(name: &str) { + let p = Project::load( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../examples") + .join(name), + ) + .unwrap(); assert!(p.runtime.is_ok(), "{:?}", p.runtime.as_ref().err()); for l in p.manifest.levels() { for rot in 0..4 { diff --git a/crates/isokit-script/src/api.rs b/crates/isokit-script/src/api.rs index 96bd44f..67c0080 100644 --- a/crates/isokit-script/src/api.rs +++ b/crates/isokit-script/src/api.rs @@ -652,6 +652,16 @@ pub(crate) fn register_scene(e: &mut Engine, st: &Shared) { tag(&s, x, y, z, t, n(&p)?, Some(id), k) }, ); + // a row more in the tooltip of the object being drawn + let s = st.clone(); + e.register_fn("info_row", move |k: D, v: D| { + let mut b = s.borrow_mut(); + if let Some(id) = b.obj.clone() { + if let Some(i) = b.scene.info.get_mut(&id) { + i.rows.push((k.to_string(), v.to_string())); + } + } + }); let s = st.clone(); e.register_fn("info", move |id: &str, name: &str| { s.borrow_mut().scene.info(id, name, "") @@ -933,7 +943,8 @@ fn register_objects(e: &mut Engine, st: &Shared) { let m = d.read_lock::()?; let l = xf_of(&m).inv(x, y); let half = prop(&m, "w").unwrap_or(1.0) / 2.0; - let lim = prop(&m, "t").unwrap_or(0.0) / 2.0 + tol; + // half the thickness (walls), else half the width (conveyors) + let lim = prop(&m, "t").or_else(|| prop(&m, "d")).unwrap_or(0.0) / 2.0 + tol; (l[0].abs() <= half + 1e-6 && l[1].abs() <= lim + 1e-6) .then(|| with_uv(d, l[0], l[1])) }) @@ -1008,4 +1019,9 @@ fn register_objects(e: &mut Engine, st: &Shared) { let p = xf_of(&o).inv(n(&x)?, n(&y)?); Ok(vec![Dynamic::from_float(p[0]), Dynamic::from_float(p[1])]) }); + // the world plan point of (u, v) in object o's frame + e.register_fn("world_of", |o: Map, u: D, v: D| -> RR { + let p = xf_of(&o).p(n(&u)?, n(&v)?); + Ok(vec![Dynamic::from_float(p[0]), Dynamic::from_float(p[1])]) + }); } diff --git a/crates/isokit-script/src/doc.rs b/crates/isokit-script/src/doc.rs index fb1cadd..172b565 100644 --- a/crates/isokit-script/src/doc.rs +++ b/crates/isokit-script/src/doc.rs @@ -119,6 +119,10 @@ pub struct PrefabMeta { /// it at z = 1 x storey height (parameter `storey_h`, default 3 m). #[serde(default, skip_serializing_if = "is_false")] pub storey: bool, + /// A line whose way matters (conveyors run along local +x): the Studio + /// turns it to point from where the drag starts to where it ends. + #[serde(default, skip_serializing_if = "is_false")] + pub directed: bool, #[serde(default)] pub props: Vec, /// Module the prefab comes from ("isokit/site", "lib/kiosk.rhai"). diff --git a/crates/isokit-script/src/stdlib.rs b/crates/isokit-script/src/stdlib.rs index 85096f5..64de7e1 100644 --- a/crates/isokit-script/src/stdlib.rs +++ b/crates/isokit-script/src/stdlib.rs @@ -15,4 +15,5 @@ pub const MODULES: &[(&str, &str)] = &[ ("isokit/equipment", include_str!("../stdlib/equipment.rhai")), ("isokit/house", include_str!("../stdlib/house.rhai")), ("isokit/furniture", include_str!("../stdlib/furniture.rhai")), + ("isokit/industry", include_str!("../stdlib/industry.rhai")), ]; diff --git a/crates/isokit-script/src/tests.rs b/crates/isokit-script/src/tests.rs index 52fd2a8..99606c0 100644 --- a/crates/isokit-script/src/tests.rs +++ b/crates/isokit-script/src/tests.rs @@ -542,6 +542,107 @@ fn ponds_have_fish_swimming_in_every_shape() { .contains("iso-swim")); } +fn factory_doc() -> Doc { + let mut doc = Doc::default(); + for (id, kind, x, y, rot, props) in [ + ("press1", "producer", -6.0, -4.0, 0.0, json!({"rate": 20})), + ("belt1", "conveyor", -1.4, -4.0, 0.0, json!({"w": 6})), + ("belt2", "conveyor", 1.6, -1.0, 90.0, json!({"w": 6})), + ( + "belt3", + "conveyor", + 1.6, + 5.0, + 90.0, + json!({"w": 6, "speed": 0.8, "rise": 1.6}), + ), + ( + "belt5", + "conveyor", + 8.0, + 6.0, + 0.0, + json!({"w": 8, "item": "crate", "gap": 2}), + ), + ( + "press3", + "producer", + -6.0, + 8.0, + 0.0, + json!({"rate": 6, "product": "part", "running": false}), + ), + ("belt6", "conveyor", -2.0, 8.0, 0.0, json!({"w": 5})), + // a closed ring: following the feed back ends after a few belts + ("r1", "conveyor", 30.0, -3.0, 0.0, json!({"w": 6})), + ("r2", "conveyor", 33.0, 0.0, 90.0, json!({"w": 6})), + ("r3", "conveyor", 30.0, 3.0, 180.0, json!({"w": 6})), + ("r4", "conveyor", 27.0, 0.0, 270.0, json!({"w": 6})), + ] { + let mut o = obj(id, kind, x, y, props); + o.rot = rot; + doc.objects.push(o); + } + doc +} + +#[test] +fn conveyors_carry_what_the_producer_makes() { + let r = Runtime::new(&Bundle::default()).unwrap(); + assert!(r.prefab("conveyor").unwrap().directed); + let doc = factory_doc(); + for view in 0..4u8 { + let b = r.build("site", view, &cfg(), &doc).unwrap(); + assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings); + let out = render(&b.scene, view, &RenderOpts::default()); + assert_eq!(out.cycles, 0, "view {view}: depth-sort cycles"); + assert!(!out.html.contains("NaN")); + // goods move on the running belts (not behind the stopped press), the running press's ram goes + assert_eq!( + out.html.matches("iso-a iso-ride\"").count(), + 8, + "view {view}" + ); + assert_eq!(out.html.matches("iso-a iso-ram").count(), 1, "view {view}"); + } + let b = r.build("site", 1, &cfg(), &doc).unwrap(); + let near = |a: f64, e: f64| (a - e).abs() < 1e-6; + // the first belt starts flush at the press and stops at the side of the belt it feeds, + // which runs through the corner; in line the belts just meet + let (b1, b2, b3) = (b.boxes["belt1"], b.boxes["belt2"], b.boxes["belt3"]); + assert!(near(b1[0], -4.4) && near(b1[3], 1.2), "belt1 {b1:?}"); + assert!(near(b2[1], -4.4) && near(b2[4], 2.0), "belt2 {b2:?}"); + assert!(near(b3[1], 2.0), "belt3 {b3:?}"); + // goods and their spacing come down the line: 20 a minute at 0.8 m/s is 2.4 m apart + let out = render(&b.scene, 1, &RenderOpts::default()); + let row = |id: &str, k: &str| { + out.info[id] + .rows + .iter() + .find(|r| r.0 == k) + .map(|r| r.1.clone()) + .unwrap_or_default() + }; + assert_eq!( + row("belt1", "Carries"), + "20 cartons a minute, 1.5 m apart (from press1)" + ); + assert_eq!( + row("belt3", "Carries"), + "20 cartons a minute, 2.4 m apart (from belt2)" + ); + assert_eq!(row("belt5", "Carries"), "15 crates a minute, 2 m apart"); + assert_eq!( + row("belt6", "Carries"), + "6 parts a minute, 5 m apart (from press3)" + ); + assert_eq!( + row("press3", "Makes"), + "6 parts a minute, one every 10 s (stopped)" + ); + assert_eq!(row("belt3", "Speed"), "0.8 m/s"); +} + #[test] fn site_walls_join_at_corners() { let r = Runtime::new(&Bundle::default()).unwrap(); diff --git a/crates/isokit-script/stdlib/industry.rhai b/crates/isokit-script/stdlib/industry.rhai new file mode 100644 index 0000000..16ba36e --- /dev/null +++ b/crates/isokit-script/stdlib/industry.rhai @@ -0,0 +1,253 @@ +// isokit/industry: conveyors and producers (machines that make goods at a +// set rate). Goods flow from a producer onto the conveyor starting at its +// output and on along the conveyors that start where another one ends: each +// belt carries them at its own speed, so the gap between them is speed / rate. + +fn goods_kinds() { ["carton", "crate", "drum", "sack", "part"] } + +/// x for a tooltip: whole numbers as they are, others to one decimal. +fn num1(x) { + let r = round1(x); + if (r - r.round()).abs() < 1e-9 { `${r.round().to_int()}` } else { `${r}` } +} + +/// Half the length of one of the goods along the belt (at full size). +fn goods_half(kind) { + if kind == "crate" || kind == "sack" { 0.3 } else if kind == "part" { 0.22 } else { 0.25 } +} + +/// Height of one of the goods (at full size). +fn goods_h(kind) { + if kind == "crate" { 0.36 } else if kind == "drum" { 0.6 } else if kind == "sack" { 0.16 } else if kind == "part" { 0.26 } else { 0.32 } +} + +/// One of the goods standing at (x, y, z), its size scaled by k. +fn goods(kind, x, y, z, k) { + if kind == "crate" { + this.box(x - 0.3 * k, y - 0.25 * k, x + 0.3 * k, y + 0.25 * k, z, z + 0.36 * k, "crate"); + } else if kind == "drum" { + this.cyl(x, y, 0.24 * k, z, z + 0.6 * k, "drum"); + } else if kind == "sack" { + this.box(x - 0.3 * k, y - 0.2 * k, x + 0.3 * k, y + 0.2 * k, z, z + 0.16 * k, "sacks"); + } else if kind == "part" { + this.box(x - 0.22 * k, y - 0.22 * k, x + 0.22 * k, y + 0.22 * k, z, z + 0.1 * k, "steel"); + this.cyl(x, y, 0.1 * k, z + 0.1 * k, z + 0.26 * k, "steel"); + } else { + this.box(x - 0.25 * k, y - 0.2 * k, x + 0.25 * k, y + 0.2 * k, z, z + 0.32 * k, "carton"); + } + this +} + +/// Height of conveyor p's belt top at u along it (it rises by p.rise from +/// its start to its end). +fn belt_z(p, u) { p.h + p.rise * (u + p.w / 2.0) / p.w } + +/// How far conveyor o reaches past its end e (-1 start, 1 end). Alone or on +/// a straight run it ends there. Where another conveyor crosses the end +/// point, the corner square goes to the one that carries on: a belt feeding +/// another one's start stops at that belt's side, the belt taking goods +/// from another's end runs through; at a T it stops at the side. +fn conveyor_ext(o, e) { + let p = o.p; + let u = e * p.w / 2.0; + let pt = world_of(o, u, 0.0); + let zt = o.z + belt_z(p, u); + for c in objects_on(["conveyor"], pt[0], pt[1], 0.05) { + if c.id == o.id || along_world_x(c.rot) == along_world_x(o.rot) { continue; } + if (c.z + belt_z(c.p, c.u) - zt).abs() > 0.3 { continue; } + let at_start = (c.u + c.p.w / 2.0).abs() < 0.05; + let at_end = (c.u - c.p.w / 2.0).abs() < 0.05; + let half = c.p.d / 2.0; + if !at_start && !at_end { return -half; } + if e > 0.0 && at_start { return -half; } + if e < 0.0 && at_end { return half; } + // two ends or two starts meet: the one along world x runs through + return if along_world_x(o.rot) { half } else { -half }; + } + 0.0 +} + +/// What reaches conveyor o at its start: #{ rate, item, running, from } +/// (rate in goods per second) from the producer whose output side it starts +/// at, or else from the conveyor ending there (followed back up to 12 belts); +/// () when nothing feeds it. +fn conveyor_feed(o, hops) { + if hops > 12 { return (); } + let p = o.p; + let a = world_of(o, -p.w / 2.0 - 0.15, 0.0); + for m in objects_at(["producer"], a[0], a[1]) { + if (m.z - o.z).abs() > 0.5 { continue; } + if local_of(m, a[0], a[1])[0] > m.p.w / 2.0 - 1.0 { + return #{ rate: m.p.rate / 60.0, item: m.p.product, running: m.p.running, from: m.id }; + } + } + let s = world_of(o, -p.w / 2.0, 0.0); + let zs = o.z + belt_z(p, -p.w / 2.0); + for c in objects_of("conveyor") { + if c.id == o.id { continue; } + let e = world_of(c, c.p.w / 2.0, 0.0); + let lim = fmax(p.d, c.p.d) / 2.0 + 0.05; + if (e[0] - s[0]).abs() > lim || (e[1] - s[1]).abs() > lim { continue; } + if (c.z + belt_z(c.p, c.p.w / 2.0) - zs).abs() > 0.3 { continue; } + let f = conveyor_feed(c, hops + 1); + if type_of(f) == "map" { + return #{ rate: f.rate, item: f.item, running: f.running && c.p.running, from: c.id }; + } + return #{ rate: c.p.speed / c.p.gap, item: c.p.item, running: c.p.running, from: c.id }; + } + () +} + +fn meta_conveyor() { + #{ label: "Conveyor", category: "Industry", icon: "⇉", shape: "line", directed: true, + help: "Carries goods along local +x (drawn from start to end; turn it 180° to reverse). Started at a producer's output it carries what the producer makes, as fast as it makes them; a conveyor starting where another ends carries on with its goods.", + props: [ + #{ key: "w", type: "float", def: 6.0, min: 1.0, max: 30.0, step: 0.5, label: "Length (m)" }, + #{ key: "d", type: "float", def: 0.8, min: 0.3, max: 2.5, step: 0.05, label: "Belt width (m)" }, + #{ key: "h", type: "float", def: 0.8, min: 0.2, max: 4.0, step: 0.05, label: "Height (m)" }, + #{ key: "rise", type: "float", def: 0.0, min: -6.0, max: 6.0, step: 0.1, label: "Rise (m)" }, + #{ key: "running", type: "bool", def: true, label: "Running" }, + #{ key: "speed", type: "float", def: 0.5, min: 0.05, max: 3.0, step: 0.05, label: "Speed (m/s)" }, + #{ key: "item", type: "choice", def: "carton", choices: goods_kinds() + ["none"], label: "Goods" }, + #{ key: "gap", type: "float", def: 1.5, min: 0.5, max: 20.0, step: 0.1, label: "Spacing (m)" }, + ] } +} +fn prefab_conveyor(o) { + let p = o.p; + let x0 = -p.w / 2.0 - conveyor_ext(o, -1.0); + let x1 = p.w / 2.0 + conveyor_ext(o, 1.0); + if x1 - x0 < 0.1 { return; } + let W = p.d / 2.0; + let th = 0.14; + let za = belt_z(p, x0); + let zb = belt_z(p, x1); + // the side turned to the viewer (-1: -y), drawn after the goods + let ny = if vis(0.0, -1.0) { -1.0 } else { 1.0 }; + let s = sprite(); + // legs in pairs, at most 2.5 m apart; the far row first + let n = fmax(1.0, ((x1 - x0) / 2.5).ceiling()).to_int(); + for side in [-ny, ny] { + let y = side * (W - 0.06); + for i in 0..(n + 1) { + let u = fmin(fmax(x0 + i * (x1 - x0) / n, x0 + 0.08), x1 - 0.08); + let zl = belt_z(p, u) - th; + if zl > 0.05 { s.box(u - 0.04, y - 0.04, u + 0.04, y + 0.04, 0.0, zl, "convleg"); } + } + } + // the drive motor under the head end, on the -y side + let motor = |q| q.box(x1 - 0.55, -W - 0.32, x1 - 0.12, -W, zb - th - 0.36, zb - th + 0.04, "motor"); + if ny > 0.0 && zb - th > 0.45 { motor.call(s); } + // frame and belt + s.prism([[x0, -W], [x1, -W], [x1, W], [x0, W]], [za - th, zb - th, zb - th, za - th], [za, zb, zb, za], "conv", #{ top: "belt" }); + let rail = |q, y0, y1| q.prism([[x0, y0], [x1, y0], [x1, y1], [x0, y1]], [za, zb, zb, za], [za + 0.07, zb + 0.07, zb + 0.07, za + 0.07], "conv"); + let far = if ny < 0.0 { [W - 0.04, W] } else { [-W, -W + 0.04] }; + let near = if ny < 0.0 { [-W, -W + 0.04] } else { [W - 0.04, W] }; + rail.call(s, far[0], far[1]); + // goods: what the feed brings (or the belt's own choice), spaced speed / rate + let feed = conveyor_feed(o, 0); + let fed = type_of(feed) == "map"; + let kind = if fed { feed.item } else { p.item }; + let rate = if fed { feed.rate } else { p.speed / p.gap }; + let moving = p.running && (!fed || feed.running); + let stopped = if p.running { "" } else { " (stopped)" }; + info_row("Speed", `${num1(p.speed)} m/s${stopped}`); + if kind != "none" && rate > 0.0 { + let k = fmin(1.0, (p.d - 0.1) / 0.5); + let hl = goods_half(kind) * k; + let want = p.speed / rate; + let gap = fmax(want, 2.0 * hl + 0.12); + let src = if fed { ` (from ${feed.from})` } else { "" }; + info_row("Carries", `${num1(rate * 60.0)} ${kind}s a minute, ${num1(gap)} m apart${src}`); + if want < gap - 1e-6 { info_row("Backs up", "goods come faster than this belt can space them"); } + let lift = (p.rise / p.w).abs() * hl + 0.01; + let u0 = x0 + hl + 0.06; + let span = x1 - hl - 0.06 - u0; + let cnt = (span / gap).floor().to_int(); + let step = if cnt < 1 { span } else { gap }; + cnt = if cnt < 1 { 1 } else { cnt }; + if span > 0.0 { + // back to front along the belt + let order = []; + for i in 0..cnt { order.push(i); } + if !vis(1.0, 0.0) { order.reverse(); } + if moving { + let per = step / p.speed; + let v = dv(step, 0.0, p.rise * step / p.w); + s.begin("a ride", `--dx:${v[0]}px;--dy:${v[1]}px;animation-duration:${per}s`); + for i in order { + let u = u0 + i * step; + let fade = if cnt == 1 { "rideio" } else if i == 0 { "ridein" } else if i == cnt - 1 { "rideout" } else { "" }; + if fade != "" { s.begin("a " + fade, `animation-duration:${per}s`); } + s.goods(kind, u, 0.0, belt_z(p, u) + lift, k); + if fade != "" { s.end(); } + } + s.end(); + } else { + for i in order { + let u = u0 + (i + 0.5) * step; + if u < x1 - hl - 0.05 { s.goods(kind, u, 0.0, belt_z(p, u) + lift, k); } + } + } + } + } + rail.call(s, near[0], near[1]); + if ny < 0.0 && zb - th > 0.45 { motor.call(s); } +} + +fn meta_producer() { + #{ label: "Producer", category: "Industry", icon: "⚙", shape: "rect", + help: "A press that makes one item every 60 / rate seconds and puts it out on its +x side. A conveyor starting there carries the goods away, spaced by its speed and this rate.", + props: [ + #{ key: "rate", type: "float", def: 12.0, min: 0.5, max: 240.0, step: 0.5, label: "Rate (per min)" }, + #{ key: "product", type: "choice", def: "carton", choices: goods_kinds(), label: "Product" }, + #{ key: "running", type: "bool", def: true, label: "Running" }, + #{ key: "w", type: "float", def: 3.2, min: 1.6, max: 20.0, step: 0.1, label: "Length (m)" }, + #{ key: "d", type: "float", def: 2.2, min: 1.2, max: 12.0, step: 0.1, label: "Width (m)" }, + #{ key: "h", type: "float", def: 2.6, min: 1.6, max: 8.0, step: 0.1, label: "Height (m)" }, + #{ key: "out", type: "float", def: 0.8, min: 0.2, max: 4.0, step: 0.05, label: "Output height" }, + #{ key: "colour", type: "choice", def: "mach1", choices: ["mach1", "mach2", "mach3", "mach4"], label: "Colour" }, + ] } +} +fn prefab_producer(o) { + let p = o.p; + let L = p.w / 2.0; + let D = p.d / 2.0; + let t = fmin(p.out, p.h - 1.2); + let per = 60.0 / p.rate; + let mat = p.colour; + // the press sits over the table at the output end, the control cabinet behind it + let cx = -L + fmin(1.0, p.w * 0.3); + let px = (cx + L) / 2.0; + let ct = p.h - 0.35; + let k = fmin(1.0, (fmin(L - cx, p.d) - 0.4) / 0.6); + // the ram hangs under the crown and comes down to just above the item + let rh = fmin(0.3, (ct - t) * 0.25); + let rest = ct - 0.04 - rh; + let stroke = fmax(0.0, rest - (t + goods_h(p.product) * k) - 0.05); + let stopped = if p.running { "" } else { " (stopped)" }; + info_row("Makes", `${num1(p.rate)} ${p.product}s a minute, one every ${num1(per)} s${stopped}`); + let s = sprite(); + s.box(-L, -D, L, D, 0.0, t, mat); + s.box(-L, -D, cx, D, t, p.h, mat); + s.circle(cx - 0.25, -D + 0.25, p.h + 0.02, 1.2, if p.running { "led ledg" } else { "led ledr" }); + // columns: the far pair, then the ram over the item, then the near pair + let ny = if vis(0.0, -1.0) { -1.0 } else { 1.0 }; + let post = |q, y| { + let yc = y * (D - 0.12); + q.box(cx + 0.02, yc - 0.1, cx + 0.22, yc + 0.1, t, ct, "steel"); + q.box(L - 0.22, yc - 0.1, L - 0.02, yc + 0.1, t, ct, "steel"); + }; + post.call(s, -ny); + s.goods(p.product, px, 0.0, t, k); + if p.running { + let v = dv(0.0, 0.0, -stroke); + s.begin("a ram", `--dx:${v[0]}px;--dy:${v[1]}px;animation-duration:${per}s`); + } + // the rod runs up into the hydraulic cylinder on the crown + s.cyl(px, 0.0, 0.07, rest + rh, p.h - 0.05 + stroke, "steel"); + s.box(px - 0.32, -0.32, px + 0.32, 0.32, rest, rest + rh, "press"); + if p.running { s.end(); } + post.call(s, ny); + s.box(cx, -D, L, D, ct, p.h, mat); + s.cyl(px, 0.0, 0.2, p.h, p.h + stroke + 0.15, "press"); +} diff --git a/crates/isokit-theme/themes/default.toml b/crates/isokit-theme/themes/default.toml index 16bf48d..98d7dc5 100644 --- a/crates/isokit-theme/themes/default.toml +++ b/crates/isokit-theme/themes/default.toml @@ -262,6 +262,16 @@ koi = "#e8742a" koiw = "#e9e4da" koig = "#d9a63a" ripple = "#8cc3d8" +# industry: conveyors, machines, robot arms, goods +conv = "#4f6f93" +crate = "#8a6a3e" +drum = "#2f62a8" +mach1 = "#4f7d62" +mach2 = "#3f6390" +mach3 = "#5b6370" +mach4 = "#b8692b" +robot = "#e07a1f" +robotb = "#3a3f48" [palette.light] sky1 = "#a9d4fb" @@ -519,6 +529,15 @@ koi = "#f08a3c" koiw = "#fbf8f2" koig = "#efc35a" ripple = "#d6f1fa" +conv = "#6f93bd" +crate = "#b48d55" +drum = "#3d7cd0" +mach1 = "#6fa384" +mach2 = "#5b86bd" +mach3 = "#8a93a1" +mach4 = "#e08a43" +robot = "#f08c2b" +robotb = "#5a616d" [materials] warn = "#e0412f" @@ -724,6 +743,17 @@ z-living = "color-mix(in srgb, var(--iso-floor) 64%, #c8a070)" z-bed = "color-mix(in srgb, var(--iso-floor) 70%, #d8b88a)" z-garage = "color-mix(in srgb, var(--iso-floor) 88%, #8d96a8)" genset = "genset" +conv = "conv" +convleg = "steel" +crate = "crate" +drum = "drum" +mach1 = "mach1" +mach2 = "mach2" +mach3 = "mach3" +mach4 = "mach4" +robot = "robot" +robotb = "robotb" +grip = "#2b2f37" # house parts and furniture brick = "brick" woodd = "woodd" @@ -779,6 +809,9 @@ 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-ledr" = "fill: var(--iso-led-r); stroke: none" +".iso-svg .iso-ledg" = "fill: var(--iso-led-g); stroke: none" +".iso-svg .iso-ledo" = "fill: var(--iso-led-o); stroke: none" ".iso-svg .iso-fish" = "stroke: none; fill-opacity: .92" ".iso-svg .iso-koi" = "fill: var(--iso-koi)" ".iso-svg .iso-koiw" = "fill: var(--iso-koiw)" diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e336c3c..105b4ad 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -72,8 +72,8 @@ Each phase ends with tests, a Studio/viewer check in Edge, commit and push. ## Backlog (2026-10) 1. ~~Web performance~~ (done, see below) -2. Conveyors: placeable conveyors -3. Producers with adjustable production speed +2. ~~Conveyors~~ (done, see below) +3. ~~Producers with adjustable production speed~~ (done, see below) 4. Robotic arms 5. Rounded streets: curved road segments 6. Sidewalks along streets @@ -97,6 +97,12 @@ the tags only); hover 22 → 15 ms per move (tooltip placed once per frame on it readout ten times a second); rotate 226 → 154 ms, edit 409 → 305 ms until painted (WASM built with opt-level 3: engine render 11.7 → 9.4 ms at full speed). +Production lines: Producer (a press making cartons, crates, drums, sacks or parts at a set rate +per minute, its ram going down once per item) and Conveyor (drawn the way it runs, belt width, +height, rise, speed; corners join without overlap). A conveyor starting at a producer's output, or +at another conveyor's end, carries those goods, spaced by speed / rate; tooltips show the numbers. +`examples/factory`, Factory 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 4fe2a72..6403407 100644 --- a/docs/SCRIPTING.md +++ b/docs/SCRIPTING.md @@ -32,7 +32,9 @@ fn prefab_kiosk(o) { // draw around (0, 0) in the object's own `d`), default a click. `mount: ["house_wall"]`: the prefab sits on objects of those types; the Studio snaps it onto the nearest one and moves, turns, copies and deletes it with its host. `storey: true`: placed on the storey the - view shows (level `1f` puts it at z = 1 × `storey_h`). + view shows (level `1f` puts it at z = 1 × `storey_h`). `directed: true` (with + `shape: "line"`): the way matters, so the Studio turns the piece to run from + where the drag starts (rot 0, 90, 180 or 270; conveyors). - `o` holds `id, type, name, x, y, z, rot, p` (the props with defaults filled in), `pts`, `tag`, `info`, `level`. - The engine sets the object's frame before calling the prefab. All @@ -56,6 +58,7 @@ shaded automatically by face direction. | `layer("ground" \| "floor" \| "main")`, `layer(name, #{sort, zfirst, cls, before})` | where new sprites go | | `tag(x, y, z, text[, prio[, id[, kind]]])` | label; `kind` "signtag" / "warntag" always shows | | `info(id, name[, detail])`, `info(id, #{name, detail, rows: [[k, v], …]})` | tooltip | +| `info_row(key, value)` | one row more in the tooltip of the object being drawn (a belt's speed) | | `focus(x, y, z)`, `bounds(x0, y0, x1, y1[, depth])`, `defs(svg)`, `warn(msg)` | framing and extras | | `level()`, `rot()`, `cfg()`, `cfg(name)`, `objects()` | inputs (`objects()`: every document object, props resolved) | | `objects_of(types)` | objects of a type or a list of types shown on this level | @@ -64,7 +67,7 @@ shaded automatically by face direction. | `mount_of(o, hosts[, tol])` | the host o sits on (nearest centre line within `p.t / 2 + tol`, default 0.3 m, same height), with `u, v` = o's position in the host's frame; `()` if none | | `mounted(h, types, hosts[, tol])` | the objects of `types` whose host is h, each with `u, v` | | `frame(o)`, `frame(x, y, z, rot)` | draw in another frame from here on (a window draws in its wall's frame) | -| `to_world(x, y)`, `to_local(x, y)`, `local_of(o, x, y)` | between the current frame (or o's) and world plan coordinates | +| `to_world(x, y)`, `to_local(x, y)`, `local_of(o, x, y)`, `world_of(o, u, v)` | between the current frame (or o's) and world plan coordinates | | `vis(nx, ny)` | is a face with that outward normal turned to the viewer? | | `dv(dx, dy, dz)` | screen vector of a world vector (CSS motion) | | `along_x()` | should a mover run along x in this rotation? | @@ -117,6 +120,27 @@ depth sort needs. `fence` (designs `style`: mesh, wood, bars, half stone, cobble, brick) and the site `wall` use it; `house_wall` has its own version (`wall_ext`, house.rhai). +## Production lines + +`producer` and `conveyor` (stdlib/industry.rhai) work together through the +document, like house parts: + +- A producer makes one of its `product` goods every 60 / `rate` seconds (its + ram goes down once per item) and puts them out on its local +x side. +- A conveyor carries goods along its local +x at `speed` m/s. When its start + lies on a producer's output side, it carries that producer's goods at its + rate; when its start is at another conveyor's end, it carries on with what + that one carries (followed back up to 12 belts). The gap between goods is + speed / rate, so a faster belt spreads them out. A stopped producer or belt + stops the goods after it. Alone a conveyor carries its own `item` every + `gap` metres. +- Belts meeting at a corner do not overlap: the belt a feeding belt runs into + runs through the corner and the feeding one stops at its side; at a T the + belt stops at the side. `rise` lifts the end (an incline). +- The goods ride a group that moves one gap per loop (`iso-ride`), so their + drawing order never changes; the first fades in, the last fades out. + Tooltips show speed, rate and where the goods come from (`info_row`). + ## House parts `house_wall`, `window`, `door`, `floor`, `roof`, `stairs` and the furniture diff --git a/examples/factory/isokit.toml b/examples/factory/isokit.toml new file mode 100644 index 0000000..a6dd66c --- /dev/null +++ b/examples/factory/isokit.toml @@ -0,0 +1,29 @@ +# isokit factory demo: producers making goods at a set rate, conveyors carrying them. +# Open it with `isokit studio examples/factory`. +title = "isokit factory" +note = "Presses make goods at a set rate; conveyors carry them at their own speed, so the gap between the goods follows rate and speed. Tap a press or a belt for its numbers. Drag to pan, Ctrl + wheel or pinch to zoom." +default_level = "site" +default_rotation = 1 +prelude = [] +theme = [] + +[[level]] +id = "site" +label = "Site" +title = "The production yard" + +[[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/factory/scene.json b/examples/factory/scene.json new file mode 100644 index 0000000..8c76df1 --- /dev/null +++ b/examples/factory/scene.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "objects": [ + {"id": "site", "type": "site", "x": 0, "y": 1, "props": {"w": 46, "d": 32, "mat": "g-grass"}, "name": "Yard", "locked": true}, + {"id": "road", "type": "road", "x": 0, "y": -12.5, "props": {"w": 46, "d": 5}, "name": "Access road"}, + {"id": "floor", "type": "area", "x": 0, "y": 1.5, "props": {"w": 36, "d": 19, "mat": "g-conc"}, "name": "Production floor", "locked": true}, + {"id": "press-a", "type": "producer", "x": -14, "y": -4, "props": {"rate": 20, "product": "carton", "colour": "mach1"}, "name": "Carton press", "tag": "Cartons 20/min"}, + {"id": "belt-a1", "type": "conveyor", "x": -8.4, "y": -4, "rot": 0, "props": {"w": 8}, "name": "Carton belt 1"}, + {"id": "belt-a2", "type": "conveyor", "x": -4.4, "y": -1, "rot": 90, "props": {"w": 6}, "name": "Carton belt 2"}, + {"id": "belt-a3", "type": "conveyor", "x": -0.4, "y": 2, "rot": 0, "props": {"w": 8, "speed": 0.8}, "name": "Carton belt 3"}, + {"id": "press-b", "type": "producer", "x": 14, "y": -4, "rot": 180, "props": {"rate": 10, "product": "drum", "colour": "mach2", "d": 2.6}, "name": "Drum filler", "tag": "Drums 10/min"}, + {"id": "belt-b1", "type": "conveyor", "x": 9.4, "y": -4, "rot": 180, "props": {"w": 6, "d": 1.0, "speed": 0.4}, "name": "Drum belt"}, + {"id": "belt-b2", "type": "conveyor", "x": 6.4, "y": 0, "rot": 90, "props": {"w": 8, "d": 1.0, "speed": 0.4, "rise": 1.4}, "name": "Drum incline"}, + {"id": "press-c", "type": "producer", "x": -14, "y": 6, "props": {"rate": 6, "product": "part", "colour": "mach4", "running": false}, "name": "Parts press", "tag": "Stopped", "info": "Waiting for a tool change"}, + {"id": "belt-c1", "type": "conveyor", "x": -9.9, "y": 6, "rot": 0, "props": {"w": 5}, "name": "Parts belt"}, + {"id": "belt-load", "type": "conveyor", "x": 6.5, "y": 8.5, "rot": 0, "props": {"w": 7, "item": "sack", "gap": 1.1, "speed": 0.6, "rise": 1.0}, "name": "Loading belt"}, + {"id": "truck", "type": "truck", "x": 13.2, "y": 8.5, "rot": 90, "props": {"len": 9}, "name": "Truck at the dock"}, + {"id": "office", "type": "shed", "x": -5.5, "y": 9.2, "props": {"w": 6, "d": 3.5, "h": 3}, "name": "Shift office"}, + {"id": "cont-1", "type": "container", "x": 16.8, "y": 0.2, "rot": 90, "props": {"size": "20ft", "stack": 2}, "name": "Empty drums"}, + {"id": "cont-2", "type": "container", "x": -16.6, "y": 1.5, "rot": 90, "props": {"size": "20ft", "colour": "truckbox"}, "name": "Carton blanks"}, + {"id": "op-a", "type": "person", "x": -14.2, "y": -6.2}, + {"id": "op-c", "type": "person", "x": -11.8, "y": 7.8, "props": {"cls": "hivis"}}, + {"id": "op-load", "type": "person", "x": 2.4, "y": 9.6}, + {"id": "tree-1", "type": "tree", "x": -20, "y": 13}, + {"id": "tree-2", "type": "tree", "x": -15, "y": 14}, + {"id": "tree-3", "type": "tree", "x": 19, "y": 14}, + {"id": "lamp-1", "type": "lamp", "x": -10, "y": -9.6}, + {"id": "lamp-2", "type": "lamp", "x": 10, "y": -9.6} + ] +} diff --git a/studio/studio.js b/studio/studio.js index 698472b..a30675a 100644 --- a/studio/studio.js +++ b/studio/studio.js @@ -177,14 +177,20 @@ 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 · " + (closes ? "click to close the run" : "click the next corner · double-click or Enter to finish · click the first corner to close") + " · Backspace undoes a corner · Esc cancels"); } + // the turn of a line piece from p to q: along x or y; directed lines (conveyors) point from p to q + function lineRot(m, p, q) { + var ax = Math.abs(q[1] - p[1]) <= Math.abs(q[0] - p[0]); + if (!m.directed) return ax ? 0 : 90; + return ax ? (q[0] >= p[0] ? 0 : 180) : (q[1] >= p[1] ? 90 : 270); + } // the pieces of a run as add ops (pieces longer than the prefab allows are split) function runOps(m, pts, z) { var ops = [], max = +((m.props || []).filter(function (d) { return d.key === "w"; })[0] || {}).max || 1e9; for (var i = 1; i < pts.length; i++) { - var p = pts[i - 1], q = pts[i], L = Math.hypot(q[0] - p[0], q[1] - p[1]), n = Math.ceil(L / max - 1e-9), ax = Math.abs(q[1] - p[1]) < 1e-9; + var p = pts[i - 1], q = pts[i], L = Math.hypot(q[0] - p[0], q[1] - p[1]), n = Math.ceil(L / max - 1e-9); if (L < 0.05) continue; for (var j = 0; j < n; j++) { - var t = (j + 0.5) / n, add = { op: "add", type: m.type, x: r3(p[0] + (q[0] - p[0]) * t), y: r3(p[1] + (q[1] - p[1]) * t), rot: ax ? 0 : 90, props: { w: clampProp(m, "w", L / n) } }; + var t = (j + 0.5) / n, add = { op: "add", type: m.type, x: r3(p[0] + (q[0] - p[0]) * t), y: r3(p[1] + (q[1] - p[1]) * t), rot: lineRot(m, p, q), props: { w: clampProp(m, "w", L / n) } }; if (z) add.z = z; ops.push(add); } @@ -372,7 +378,8 @@ var a = t.a, b = t.b || t.a, dx = b[0] - a[0], dy = b[1] - a[1]; if (t.m.shape === "line") { var ax = Math.abs(dx) >= Math.abs(dy), L = Math.abs(ax ? dx : dy); - return { line: true, ax: ax, len: L, x: ax ? (a[0] + b[0]) / 2 : a[0], y: ax ? a[1] : (a[1] + b[1]) / 2, ok: L >= 0.25 }; + return { line: true, ax: ax, len: L, x: ax ? (a[0] + b[0]) / 2 : a[0], y: ax ? a[1] : (a[1] + b[1]) / 2, ok: L >= 0.25, + rot: lineRot(t.m, a, ax ? [b[0], a[1]] : [a[0], b[1]]) }; } return { w: Math.abs(dx), d: Math.abs(dy), x: (a[0] + b[0]) / 2, y: (a[1] + b[1]) / 2, x0: Math.min(a[0], b[0]), y0: Math.min(a[1], b[1]), x1: Math.max(a[0], b[0]), y1: Math.max(a[1], b[1]), ok: Math.abs(dx) >= 0.25 && Math.abs(dy) >= 0.25 }; @@ -387,7 +394,7 @@ var th = +(defOf(m, "t") || defOf(m, "d")) || 0.3, h = +defOf(m, "h") || 0, L = g.len / 2; polys.push({ pts: g.ax ? rectPts(g.x - L, g.y - th / 2, g.x + L, g.y + th / 2, z) : rectPts(g.x - th / 2, g.y - L, g.x + th / 2, g.y + L, z) }); if (h > 0) polys.push({ pts: planePts(g.ax ? [g.x - L, g.y] : [g.x, g.y - L], g.ax ? [g.x + L, g.y] : [g.x, g.y + L], z, z + h), cls: "st-plane" }); - ghost(polys, m.label + " " + g.len.toFixed(2) + " m · release to place"); + ghost(polys, m.label + " " + g.len.toFixed(2) + " m" + (m.directed ? ", running from where you started" : "") + " · release to place"); return; } polys.push({ pts: rectPts(g.x0, g.y0, g.x1, g.y1, z) }); @@ -426,7 +433,7 @@ if (!keep) setPlacing(null); return; } - if (g && g.ok && m.shape === "line") add = { type: m.type, x: r3(g.x), y: r3(g.y), rot: g.ax ? 0 : 90, props: { w: clampProp(m, "w", g.len) } }; + if (g && g.ok && m.shape === "line") add = { type: m.type, x: r3(g.x), y: r3(g.y), rot: g.rot, props: { w: clampProp(m, "w", g.len) } }; else if (g && g.ok && m.shape === "rect") add = { type: m.type, x: r3(g.x), y: r3(g.y), props: { w: clampProp(m, "w", g.w), d: clampProp(m, "d", g.d) } }; else if (m.mount && m.mount.length) { // onto the nearest wall, opening towards the side clicked on; on no wall it stands alone diff --git a/tools/build_web.py b/tools/build_web.py index 1549bf4..62fd264 100644 --- a/tools/build_web.py +++ b/tools/build_web.py @@ -148,6 +148,7 @@ def main(): shutil.copy2(ROOT / src, out / "assets" / dst / Path(src).name) (web / "demo.json").write_text(json.dumps(demo_files(), ensure_ascii=False), encoding="utf-8") (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") h = hashlib.sha256() for p in sorted((out / "assets").rglob("*")): diff --git a/tools/check_studio.py b/tools/check_studio.py index 212bb5a..984a904 100644 --- a/tools/check_studio.py +++ b/tools/check_studio.py @@ -345,6 +345,48 @@ with sync_playwright() as p: pg.wait_for_timeout(600) pg.evaluate("__studio.select(null)") + # a conveyor runs the way it was drawn; started at a producer's output it carries what the producer + # makes, spaced by its rate (changed in the producer's properties) and the belt's speed + pg.evaluate("__studio.select(null)") + pg.evaluate("__studio.view.focus([25, -4.5, 0], 3)") + pg.wait_for_timeout(400) + n4 = len(doc()["objects"]) + pg.locator('.st-tile[data-type="conveyor"]').click() + a, b2 = at(27, -4.5), at(21, -4.5) + pg.mouse.move(*a) + pg.mouse.down() + for i in range(1, 11): + pg.mouse.move(a[0] + (b2[0] - a[0]) * i / 10, a[1] + (b2[1] - a[1]) * i / 10) + pg.wait_for_timeout(15) + check("running from where you started" in pg.locator("#hint").inner_text(), "the conveyor preview says which way it runs") + pg.mouse.up() + pg.wait_for_timeout(800) + cv = doc()["objects"][-1] + check(cv["type"] == "conveyor" and cv.get("rot") == 180 and abs(cv["x"] - 24) < 1e-6 and abs(cv["props"]["w"] - 6) < 1e-6, + f"a conveyor dragged right to left runs that way (rot {cv.get('rot')}, x {cv['x']})") + pg.evaluate("__studio.op({op: 'add', type: 'producer', x: 28.6, y: -4.5, rot: 180, props: {rate: 20}})") + pg.wait_for_timeout(900) + pid = doc()["objects"][-1]["id"] + rows = lambda: dict(pg.evaluate(f"(__studio.view.info('{cv['id']}') || {{}}).rows || []")) + check(f"(from {pid})" in rows().get("Carries", "") and "20 cartons a minute, 1.5 m apart" in rows().get("Carries", ""), + "the belt carries what the producer at its start makes: " + rows().get("Carries", "?")) + check(pg.evaluate(f"document.querySelectorAll('.iso-svg [data-obj=\"{cv['id']}\"] .iso-ride').length") == 1 and + pg.evaluate(f"document.querySelectorAll('.iso-svg [data-obj=\"{pid}\"] .iso-ram').length") == 1, + "goods ride the belt, the press's ram goes up and down") + pg.locator(f'#objects li[data-id="{pid}"]').click() + pg.wait_for_timeout(300) + pg.locator('#right input[type="number"][data-prop="rate"]').fill("30") + pg.locator('#right input[type="number"][data-prop="rate"]').press("Enter") + pg.wait_for_timeout(900) + check("30 cartons a minute, 1 m apart" in rows().get("Carries", ""), "a faster producer packs the belt closer: " + rows().get("Carries", "?")) + pg.screenshot(path=str(OUT / "studio-conveyor.jpg"), type="jpeg", quality=80) + for _ in range(3): + pg.locator("#undo").click() + pg.wait_for_timeout(500) + check(len(doc()["objects"]) == n4, "undo takes the producer and the belt back") + pg.evaluate("__studio.select(null)") + pg.wait_for_timeout(300) + # performance: animations step about ten times a second by default (no CSS animation runs on its # own), the toolbar button switches smooth / off / light; a pan moves the drawing as one layer and # draws it again (viewBox) only when the drag ends diff --git a/tools/check_web.py b/tools/check_web.py index 4c426d2..47c10f5 100644 --- a/tools/check_web.py +++ b/tools/check_web.py @@ -309,6 +309,20 @@ try: check(an[0] == "light" and an[1] >= 6 and an[2] == 0 and t1 > an[3] and pg.locator('.iso-bar [data-a="anim"]').is_visible(), f"animations are light by default: {an[1]} stepped, none running, time moves on; the toolbar can switch") + # ---------------------------------------------------------------- the factory demo: presses and conveyors + pg.locator("#wb-projects").click() + pg.locator("#wb-nfactory").click() + pg.wait_for_load_state() + ready(pg) + pg.wait_for_timeout(500) + nf = len(doc(pg)["objects"]) + rides = pg.evaluate("() => document.querySelectorAll('.iso-svg .iso-ride').length") + check(title(pg) == "isokit factory" and nf > 15 and rides >= 5 and pg.evaluate("() => __studio.S.warnings.length") == 0, + f"Factory demo opens a production yard ({nf} objects, goods riding {rides} belts, no warnings)") + carries = pg.evaluate("() => Object.fromEntries((__studio.view.info('belt-a3') || {rows: []}).rows)['Carries'] || ''") + check("20 cartons a minute" in carries and "(from belt-a2)" in carries, "goods come down the line: " + carries) + shot(pg, "web-factory.jpg") + # ---------------------------------------------------------------- folder project (OPFS stands in for a picked folder) demo = {f.relative_to(ROOT / "examples" / "demo").as_posix(): f.read_text(encoding="utf-8") for f in (ROOT / "examples" / "demo").rglob("*") if f.is_file() and "dist" not in f.parts} diff --git a/viewer/isokit-view.css b/viewer/isokit-view.css index 4622333..6e2dd66 100644 --- a/viewer/isokit-view.css +++ b/viewer/isokit-view.css @@ -115,6 +115,13 @@ html[data-theme=light] .iso-svg .iso-glint { display: inline; } /* fish: each turns round its pond's middle (the box is centred there), at its own speed */ .iso-svg .iso-swim { transform-box: fill-box; transform-origin: center; animation: iso-spin 20s linear infinite; } .iso-svg .iso-rip { transform-box: fill-box; transform-origin: center; animation: iso-rip 5.1s ease-out infinite; } +/* conveyors: the goods move one spacing per loop and start again, the first fading in, the last out; + presses go down once per item */ +.iso-svg .iso-ride { animation: iso-move 2s linear infinite; } +.iso-svg .iso-ridein { animation: iso-fadein 2s linear infinite; } +.iso-svg .iso-rideout { animation: iso-fadeout 2s linear infinite; } +.iso-svg .iso-rideio { animation: iso-fadeio 2s linear infinite; } +.iso-svg .iso-ram { animation: iso-ram 2s ease-in-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, @@ -132,6 +139,10 @@ html[data-theme=light] .iso-svg .iso-glint { display: inline; } @keyframes iso-rip { 0% { transform: scale(.2); opacity: 0; } 15% { opacity: .9; } 100% { transform: scale(1.5); opacity: 0; } } @keyframes iso-glint { 0%, 84%, 100% { opacity: 0; } 91% { opacity: .9; } } @keyframes iso-blink { 0%, 100% { opacity: 1; } 50% { opacity: .45; } } +@keyframes iso-fadein { 0% { opacity: 0; } 35%, 100% { opacity: 1; } } +@keyframes iso-fadeout { 0%, 65% { opacity: 1; } 100% { opacity: 0; } } +@keyframes iso-fadeio { 0%, 100% { opacity: 0; } 30%, 70% { opacity: 1; } } +@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 { 0%, 8% { transform: translate(0, 0); } diff --git a/web/web.js b/web/web.js index 1759e31..7fd98d2 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.") + "

    " + @@ -418,6 +418,7 @@ function wireProjects() { $("wb-ntitle").addEventListener("keydown", function (e) { if (e.key === "Enter") $("wb-ncreate").click(); }); $("wb-ndemo").addEventListener("click", async function () { openProject((await createProject(await demoFiles())).id); }); $("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-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 = ""; });