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

+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");
}