Conveyors and producers: goods flow from presses down the belts
Deploy web Studio / deploy (push) Successful in 1m4s

- Producer (category Industry): a press making cartons, crates, drums, sacks or parts at a set
  rate per minute; its ram goes down once per item, a green / red light shows running / stopped.
- Conveyor: drawn the way it runs (new prefab meta `directed`: the Studio turns the piece from
  where the drag starts), belt width, height, rise (inclines), speed, goods and spacing; legs,
  rails and a drive motor. Corners join without overlap (the receiving belt runs through, the
  feeding one stops at its side).
- A conveyor starting on a producer's output side carries its goods at its rate; one starting at
  another's end carries on with what that one carries. The gap is speed / rate. A stopped press or
  belt stops the goods after it. Tooltips show speed, rate and source.
- The goods ride a group moving one gap per loop (no reordering), first fading in, last out.
- Script API: world_of(o, u, v), info_row(key, value); objects_on falls back to half of p.d.
- Theme: industry colours, LED colours (the marker's LED had none).
- examples/factory + Factory demo in the web Studio; MCP instructions on production lines.

Checks: 68 Rust tests (conveyor joints, feed and spacing, every view of the factory demo),
Studio 108/108 (draw direction, feed from a producer, rate change), web 89/89.
This commit is contained in:
grabowski committed 2026-10-24 21:46:10 +07:00
1 parent a202283a60
commit 18dd324543
20 files changed
+617 -18

No files matched your search

+1
View File
@@ -21,6 +21,7 @@ on:
- "web/**"
- "examples/demo/**"
- "examples/house/**"
- "examples/factory/**"
- "tools/build_web.py"
- "Cargo.toml"
- "Cargo.lock"
+10 -2
View File
@@ -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)
```
+5 -1
View File
@@ -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,
+17 -4
View File
@@ -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 {
+17 -1
View File
@@ -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::<Map>()?;
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<Array> {
let p = xf_of(&o).p(n(&u)?, n(&v)?);
Ok(vec![Dynamic::from_float(p[0]), Dynamic::from_float(p[1])])
});
}
+4
View File
@@ -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<PropDef>,
/// Module the prefab comes from ("isokit/site", "lib/kiosk.rhai").
+1
View File
@@ -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")),
];
+101
View File
@@ -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();
+253
View File
@@ -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");
}
+33
View File
@@ -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)"
+8 -2
View File
@@ -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
+26 -2
View File
@@ -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
+29
View File
@@ -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
+30
View File
@@ -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}
]
}
+12 -5
View File
@@ -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
+1
View File
@@ -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("*")):
+42
View File
@@ -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
+14
View File
@@ -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}
+11
View File
@@ -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); }
+2 -1
View File
@@ -367,7 +367,7 @@ function injectUI() {
'<h3>Projects</h3>' +
'<div class="wb-cur" id="wb-cur"></div>' +
'<ul class="wb-plist" id="wb-plist"></ul>' +
'<div class="wb-new"><input id="wb-ntitle" placeholder="Title of a new project" autocomplete="off"><button type="button" id="wb-ncreate">New project</button><button type="button" id="wb-ndemo" title="A fresh copy of the demo scene">Demo copy</button><button type="button" id="wb-nhouse" title="A house built from parts: walls, windows, doors, floors, furniture">House demo</button></div>' +
'<div class="wb-new"><input id="wb-ntitle" placeholder="Title of a new project" autocomplete="off"><button type="button" id="wb-ncreate">New project</button><button type="button" id="wb-ndemo" title="A fresh copy of the demo scene">Demo copy</button><button type="button" id="wb-nhouse" title="A house built from parts: walls, windows, doors, floors, furniture">House demo</button><button type="button" id="wb-nfactory" title="Presses making goods at a set rate, conveyors carrying them">Factory demo</button></div>' +
'<div class="wb-drop" id="wb-drop">Import: drop a project folder or .zip here, or <button type="button" id="wb-izip" title="Import a project .zip into this browser">.zip…</button><button type="button" id="wb-idir" title="Copy a project folder into this browser">folder…</button>' +
(canOpenFolder ? '<span class="st-grow"></span><button type="button" id="wb-ofolder" title="Edit the files in the folder on disk directly; changes made there show up live">Open folder live…</button>' : "") + "</div>" +
'<p class="st-help">Projects are kept in this browser. ' + (canOpenFolder ? "A folder opened live is edited on disk, so agents (<b>isokit mcp</b>), editors and git see the same files." : "To edit a folder on disk directly, use Chrome or Edge.") + "</p>" +
@@ -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 = ""; });