Robot arms pass goods from belt to belt; native beam primitive
Deploy web Studio / deploy (push) Successful in 1m3s

- Robot arm (category Industry): picks goods off the end of the nearest conveyor within its reach
  and puts them on the start of another conveyor within reach, or on a pallet a quarter turn on
  (pick / put: auto or a side). One move per item coming in (cycle 0) or a set cycle; waits while
  the line before it stands. The belt it feeds carries its goods at its pace and starts its loop
  when they land (feeds carry a time offset `ph`), so arm and belts stay in step.
- Drawn as a 16-pose flipbook: two-link inverse kinematics per pose, each pose shown for a
  sixteenth of the cycle by a stepped CSS animation (iso-flip; pose 0 alone when unanimated).
  Poses pointing away from the viewer are drawn before the base, the others after it.
- New sprite method beam(a, b, n, w, mat): a square bar between two 3D points, six faces culled
  and shaded like face(). Native, so the factory demo renders in 4.7 ms instead of 18.8 ms.
- Factory demo: a palletiser at the end of the carton line, a loading robot passing drums from the
  incline onto the loading belt into the truck. MCP instructions on robot arms.

Checks: 70 Rust tests (arms and belts in step, waiting arm, beam faces from every side),
Studio 109/109, web 90/90.
This commit is contained in:
grabowski committed 2026-10-24 22:05:46 +07:00
1 parent 18dd324543
commit f49798583e
14 files changed
+460 -31

No files matched your search

+6 -4
View File
@@ -40,7 +40,7 @@ isokit build C:/dev/myproject/site-view # dist/index.html, no server needed
Try the demos: `isokit studio examples/demo` (a works site), Try the demos: `isokit studio examples/demo` (a works site),
`isokit studio examples/house` (a furnished house built from parts) and `isokit studio examples/house` (a furnished house built from parts) and
`isokit studio examples/factory` (presses and conveyors). `isokit studio examples/factory` (presses, conveyors and robot arms).
### Studio (for everyone) ### Studio (for everyone)
@@ -196,8 +196,10 @@ storey.
Production lines: a Producer (category Industry) makes goods at a set rate 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, 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 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 follows rate and speed. A Robot arm placed past a belt's end picks the goods
demo in the web Studio) has a few lines. off and puts them on the next belt or a pallet, in step with them. 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 Animations (fans, smoke, fish, traffic) step on about ten times a second by
default ("light"): running CSS animations repaint the whole drawing every frame, default ("light"): running CSS animations repaint the whole drawing every frame,
@@ -231,7 +233,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 web/ web Studio shell: WASM backend for studio.js, projects, folders, zip, code editor
examples/demo demo project (works site) examples/demo demo project (works site)
examples/house house built from parts (walls, windows, doors, floors, furniture, roof) examples/house house built from parts (walls, windows, doors, floors, furniture, roof)
examples/factory production yard: producers and conveyors examples/factory production yard: producers, conveyors, robot arms
deploy/Caddyfile the web Studio's vhost on the buildfor.life web server (s1) deploy/Caddyfile the web Studio's vhost on the buildfor.life web server (s1)
``` ```
+29
View File
@@ -870,6 +870,35 @@ mod tests {
} }
} }
#[test]
fn beams_show_three_faces_from_any_side() {
for (a, b, n) in [
([0.0, 0.0, 1.0], [4.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
([0.0, 0.0, 1.0], [0.0, -4.0, 1.0], [1.0, 0.0, 0.0]),
([0.0, 0.0, 0.0], [0.0, 0.0, 3.0], [1.0, 0.0, 0.0]),
([0.0, 0.0, 0.0], [2.0, 1.0, 3.0], [-1.0, 2.0, 0.0]),
] {
for rot in 0..4u8 {
let mut sc = Scene::new();
let mut s = Sprite::new("arm", "");
s.beam(a, b, n, 0.4, "iso-robot");
sc.add_sprite("main", s);
let html = render(&sc, rot, &RenderOpts::default()).html;
// three faces; two when one side is seen edge-on (an oblique bar)
let faces = html.matches("iso-robot ").count();
let oblique = a[0] != b[0] && a[2] != b[2];
assert!(
faces == 3 || (oblique && faces == 2),
"{a:?}->{b:?} rot {rot}: {faces} faces"
);
assert!(
html.contains("iso-robot iso-t"),
"{a:?}->{b:?} rot {rot}: no top"
);
}
}
}
#[test] #[test]
fn faces_cull_and_shade_by_normal() { fn faces_cull_and_shade_by_normal() {
let mut sc = Scene::new(); let mut sc = Scene::new();
+57
View File
@@ -388,6 +388,63 @@ impl Sprite {
}) })
} }
/// A square bar `w` wide from `a` to `b` (robot arms, trusses): six faces,
/// culled and shaded by their normals like [`Sprite::face`]. `n` is a
/// side direction across the bar (any direction not along it).
pub fn beam(&mut self, a: P3, b: P3, n: P3, w: f64, mat: &str) -> &mut Self {
let sub = |p: P3, q: P3| [p[0] - q[0], p[1] - q[1], p[2] - q[2]];
let cross = |p: P3, q: P3| {
[
p[1] * q[2] - p[2] * q[1],
p[2] * q[0] - p[0] * q[2],
p[0] * q[1] - p[1] * q[0],
]
};
let unit = |p: P3| {
let l = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
if l < 1e-12 {
None
} else {
Some([p[0] / l, p[1] / l, p[2] / l])
}
};
let (Some(u), Some(v0)) = (unit(sub(b, a)), unit(cross(sub(b, a), n))) else {
return self;
};
// (u, n, v): a right-handed square frame, so every face below winds
// counter-clockwise seen from outside
let n = cross(v0, u);
let h = w / 2.0;
let c = |e: P3, sn: f64, sv: f64| {
[
e[0] + (n[0] * sn + v0[0] * sv) * h,
e[1] + (n[1] * sn + v0[1] * sv) * h,
e[2] + (n[2] * sn + v0[2] * sv) * h,
]
};
let k = [
c(a, 1.0, 1.0),
c(a, -1.0, 1.0),
c(a, -1.0, -1.0),
c(a, 1.0, -1.0),
c(b, 1.0, 1.0),
c(b, -1.0, 1.0),
c(b, -1.0, -1.0),
c(b, 1.0, -1.0),
];
for f in [
[1, 5, 4, 0],
[3, 7, 6, 2],
[3, 0, 4, 7],
[1, 2, 6, 5],
[3, 2, 1, 0],
[4, 5, 6, 7],
] {
self.face(f.iter().map(|&i| k[i]).collect(), mat, None, false);
}
self
}
/// 3D polygons as one path with a fixed class. /// 3D polygons as one path with a fixed class.
pub fn poly(&mut self, polys: Vec<Vec<P3>>, cls: &str, id: Option<String>) -> &mut Self { pub fn poly(&mut self, polys: Vec<Vec<P3>>, cls: &str, id: Option<String>) -> &mut Self {
let polys: Vec<Vec<P3>> = polys let polys: Vec<Vec<P3>> = polys
+3 -1
View File
@@ -27,7 +27,9 @@ meet, so for a plot boundary put each piece's ends exactly on the next piece's e
Production lines (category Industry): a producer (props.rate per minute, product) puts goods out on its local +x \ 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 \ 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). \ 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."; The gap between goods is speed / rate, so set the rate on the producer and the speeds on the belts. \
A robot_arm (props.reach, default 2 m) picks goods off the end of the nearest conveyor within its reach and puts \
them on the start of another conveyor within reach, or on a pallet beside it; place it 1.5 to 2 m past a belt's end.";
fn tool(name: &str, description: &str, props: Value, required: &[&str]) -> Value { fn tool(name: &str, description: &str, props: Value, required: &[&str]) -> Value {
json!({ "name": name, "description": description, json!({ "name": name, "description": description,
+8
View File
@@ -297,6 +297,14 @@ pub fn register_sprite(e: &mut Engine) {
ok(s) ok(s)
}, },
); );
e.register_fn(
"beam",
|s: &mut SpriteH, a: D, b: D, nn: D, w: D, mat: &str| -> RR<SpriteH> {
s.0.borrow_mut()
.beam(p3(&a)?, p3(&b)?, p3(&nn)?, n(&w)?, &classes(mat));
ok(s)
},
);
e.register_fn("face", |s: &mut SpriteH, p: D, mat: &str| -> RR<SpriteH> { e.register_fn("face", |s: &mut SpriteH, p: D, mat: &str| -> RR<SpriteH> {
s.0.borrow_mut().face(pts3(&p)?, &classes(mat), None, false); s.0.borrow_mut().face(pts3(&p)?, &classes(mat), None, false);
ok(s) ok(s)
+89
View File
@@ -643,6 +643,95 @@ fn conveyors_carry_what_the_producer_makes() {
assert_eq!(row("belt3", "Speed"), "0.8 m/s"); assert_eq!(row("belt3", "Speed"), "0.8 m/s");
} }
fn robot_doc(belt1_running: bool) -> Doc {
let mut doc = Doc::default();
for (id, kind, x, y, rot, props) in [
("press1", "producer", -6.0, -4.0, 0.0, json!({"rate": 15})),
(
"belt1",
"conveyor",
-1.4,
-4.0,
0.0,
json!({"w": 6, "running": belt1_running}),
),
("robot1", "robot_arm", 3.2, -4.0, 0.0, json!({})),
("belt2", "conveyor", 3.2, 1.0, 90.0, json!({"w": 6})),
(
"robot2",
"robot_arm",
3.2,
5.8,
0.0,
json!({"colour": "mach2"}),
),
(
"robot3",
"robot_arm",
-6.0,
5.0,
0.0,
json!({"running": false, "pick": "+x", "put": "-y", "goods": "drum"}),
),
] {
let mut o = obj(id, kind, x, y, props);
o.rot = rot;
doc.objects.push(o);
}
doc
}
#[test]
fn robot_arms_pass_goods_from_belt_to_belt() {
let r = Runtime::new(&Bundle::default()).unwrap();
let doc = robot_doc(true);
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"));
// two running arms with 16 poses each, one shown at a time; the stopped one stands still
assert_eq!(
out.html.matches("iso-a iso-flip").count(),
32,
"view {view}"
);
assert_eq!(
out.html.matches("iso-flip iso-pose0").count(),
2,
"view {view}"
);
}
let b = r.build("site", 1, &cfg(), &doc).unwrap();
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("robot1", "Moves"), "cartons from belt1 to belt2");
assert_eq!(row("robot1", "Cycle"), "4 s");
// the belt after the arm carries its goods at its pace, starting its loop when they land
assert_eq!(
row("belt2", "Carries"),
"15 cartons a minute, 2 m apart (from robot1)"
);
assert!(out
.html
.contains("animation-duration:4.0s;animation-delay:-1.75s"));
assert_eq!(row("robot2", "Moves"), "cartons from belt2 to a pallet");
assert_eq!(row("robot3", "Cycle"), "4 s (stopped)");
// a stopped belt before it: the arm waits
let b = r.build("site", 1, &cfg(), &robot_doc(false)).unwrap();
let out = render(&b.scene, 1, &RenderOpts::default());
assert_eq!(out.html.matches("iso-a iso-flip").count(), 0);
assert!(out.info["robot1"].rows[1].1.contains("waiting"));
}
#[test] #[test]
fn site_walls_join_at_corners() { fn site_walls_join_at_corners() {
let r = Runtime::new(&Bundle::default()).unwrap(); let r = Runtime::new(&Bundle::default()).unwrap();
+219 -13
View File
@@ -1,7 +1,9 @@
// isokit/industry: conveyors and producers (machines that make goods at a // isokit/industry: conveyors, producers (machines that make goods at a set
// set rate). Goods flow from a producer onto the conveyor starting at its // rate) and robot arms. Goods flow from a producer onto the conveyor starting
// output and on along the conveyors that start where another one ends: each // at its output and on along the conveyors that start where another one ends:
// belt carries them at its own speed, so the gap between them is speed / rate. // each belt carries them at its own speed, so the gap between them is
// speed / rate. A robot arm picks them off the end of a belt in its reach and
// puts them on the start of another belt, or on a pallet.
fn goods_kinds() { ["carton", "crate", "drum", "sack", "part"] } fn goods_kinds() { ["carton", "crate", "drum", "sack", "part"] }
@@ -67,10 +69,12 @@ fn conveyor_ext(o, e) {
0.0 0.0
} }
/// What reaches conveyor o at its start: #{ rate, item, running, from } /// What reaches conveyor o at its start: #{ rate, item, running, from, ph }
/// (rate in goods per second) from the producer whose output side it starts /// (rate in goods per second; ph: seconds into the loop at which goods land,
/// at, or else from the conveyor ending there (followed back up to 12 belts); /// so a belt after a robot arm runs in step with it) from the producer whose
/// () when nothing feeds it. /// output side it starts at, a robot arm putting goods on it, or else the
/// conveyor ending there (followed back up to 12 belts and arms); () when
/// nothing feeds it.
fn conveyor_feed(o, hops) { fn conveyor_feed(o, hops) {
if hops > 12 { return (); } if hops > 12 { return (); }
let p = o.p; let p = o.p;
@@ -78,10 +82,19 @@ fn conveyor_feed(o, hops) {
for m in objects_at(["producer"], a[0], a[1]) { for m in objects_at(["producer"], a[0], a[1]) {
if (m.z - o.z).abs() > 0.5 { continue; } 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 { 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 }; return #{ rate: m.p.rate / 60.0, item: m.p.product, running: m.p.running, from: m.id, ph: 0.0 };
} }
} }
let s = world_of(o, -p.w / 2.0, 0.0); let s = world_of(o, -p.w / 2.0, 0.0);
for m in objects_of("robot_arm") {
let dx = m.x - s[0];
let dy = m.y - s[1];
if dx * dx + dy * dy > (m.p.reach + 1.0) * (m.p.reach + 1.0) { continue; }
let t = arm_targets(m, hops + 1);
if t.put.belt == o.id {
return #{ rate: 1.0 / t.cycle, item: t.kind, running: t.running, from: m.id, ph: t.ph + t.cycle * 9.0 / 16.0 };
}
}
let zs = o.z + belt_z(p, -p.w / 2.0); let zs = o.z + belt_z(p, -p.w / 2.0);
for c in objects_of("conveyor") { for c in objects_of("conveyor") {
if c.id == o.id { continue; } if c.id == o.id { continue; }
@@ -91,9 +104,9 @@ fn conveyor_feed(o, hops) {
if (c.z + belt_z(c.p, c.p.w / 2.0) - zs).abs() > 0.3 { 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); let f = conveyor_feed(c, hops + 1);
if type_of(f) == "map" { if type_of(f) == "map" {
return #{ rate: f.rate, item: f.item, running: f.running && c.p.running, from: c.id }; return #{ rate: f.rate, item: f.item, running: f.running && c.p.running, from: c.id, ph: f.ph };
} }
return #{ rate: c.p.speed / c.p.gap, item: c.p.item, 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, ph: 0.0 };
} }
() ()
} }
@@ -173,11 +186,15 @@ fn prefab_conveyor(o) {
if moving { if moving {
let per = step / p.speed; let per = step / p.speed;
let v = dv(step, 0.0, p.rise * step / p.w); 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`); // the loop starts when goods land (ph seconds in)
let ph = if fed { feed.ph } else { 0.0 };
let late = ((per - ph) % per + per) % per;
let at = `animation-duration:${per}s;animation-delay:-${late}s`;
s.begin("a ride", `--dx:${v[0]}px;--dy:${v[1]}px;${at}`);
for i in order { for i in order {
let u = u0 + i * step; let u = u0 + i * step;
let fade = if cnt == 1 { "rideio" } else if i == 0 { "ridein" } else if i == cnt - 1 { "rideout" } else { "" }; 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`); } if fade != "" { s.begin("a " + fade, at); }
s.goods(kind, u, 0.0, belt_z(p, u) + lift, k); s.goods(kind, u, 0.0, belt_z(p, u) + lift, k);
if fade != "" { s.end(); } if fade != "" { s.end(); }
} }
@@ -251,3 +268,192 @@ fn prefab_producer(o) {
s.box(cx, -D, L, D, ct, p.h, mat); 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"); s.cyl(px, 0.0, 0.2, p.h, p.h + stroke + 0.15, "press");
} }
// ---------------------------------------------------------- robot arms
fn v3(a, b, k) { [a[0] + b[0] * k, a[1] + b[1] * k, a[2] + b[2] * k] }
/// Angle (radians) of the local plan point (x, y) seen from the origin.
fn plan_angle(x, y) {
let r = (x * x + y * y).sqrt();
if r < 1e-9 { return 0.0; }
let a = clamp(x / r, -1.0, 1.0).acos();
if y < 0.0 { -a } else { a }
}
fn side_angle(side) {
if side == "+y" { PI() / 2.0 } else if side == "-x" { PI() } else if side == "-y" { -PI() / 2.0 } else { 0.0 }
}
/// Where robot arm o picks and puts goods: #{ pick, put } each #{ r, a, z,
/// belt } (distance, angle and height in o's frame; the belt id or ""), the
/// kind of goods, its cycle (s: Cycle, else one per item coming in) and
/// whether it runs (it waits while the line before it stands).
fn arm_targets(o, hops) {
let p = o.p;
let pick = ();
let put = ();
let kind = "carton";
let rate = 0.0;
let fed = true;
let ph = 0.0;
let best = 1e9;
if p.pick == "auto" {
for c in objects_of("conveyor") {
let e = world_of(c, c.p.w / 2.0 - 0.35, 0.0);
let l = local_of(o, e[0], e[1]);
let d = (l[0] * l[0] + l[1] * l[1]).sqrt();
if d < 0.6 || d > p.reach + 0.4 || d >= best { continue; }
best = d;
pick = #{ r: d, a: plan_angle(l[0], l[1]), z: c.z + belt_z(c.p, c.p.w / 2.0) - o.z, belt: c.id };
let f = if hops > 12 { () } else { conveyor_feed(c, hops + 1) };
if type_of(f) == "map" { kind = f.item; rate = f.rate; fed = f.running && c.p.running; ph = f.ph; } else { kind = c.p.item; rate = c.p.speed / c.p.gap; fed = c.p.running; ph = 0.0; }
}
}
if type_of(pick) != "map" {
let side = if p.pick == "auto" { "-x" } else { p.pick };
pick = #{ r: p.reach * 0.75, a: side_angle(side), z: 0.8, belt: "" };
kind = p.goods;
}
if kind == "none" { kind = p.goods; }
best = 1e9;
if p.put == "auto" {
for c in objects_of("conveyor") {
if c.id == pick.belt { continue; }
let e = world_of(c, -c.p.w / 2.0 + 0.35, 0.0);
let l = local_of(o, e[0], e[1]);
let d = (l[0] * l[0] + l[1] * l[1]).sqrt();
if d < 0.6 || d > p.reach + 0.4 || d >= best { continue; }
best = d;
put = #{ r: d, a: plan_angle(l[0], l[1]), z: c.z + belt_z(c.p, -c.p.w / 2.0) - o.z, belt: c.id };
}
}
if type_of(put) != "map" {
let a = if p.put == "auto" { pick.a + PI() / 2.0 } else { side_angle(p.put) };
put = #{ r: p.reach * 0.75, a: a, z: 0.14, belt: "" };
}
let cycle = if p.cycle > 0.0 { p.cycle } else if rate > 0.0 { 1.0 / rate } else { 4.0 };
#{ pick: pick, put: put, kind: kind, cycle: clamp(cycle, 1.5, 120.0), running: p.running && fed, ph: ph }
}
/// One pose of the arm (links al long, shoulder 1.2 m up): the gripper's tip
/// at distance r, angle a, height zt, holding an item of kind unless kind is
/// "". The parts are drawn outer ones last when the arm points at the viewer.
fn arm_pose(r, a, zt, kind, k, mat, al) {
let zs = 1.2;
let a1 = al;
let a2 = al;
let wr = r;
let wz = zt + 0.32;
let dz = wz - zs;
let d = fmin((wr * wr + dz * dz).sqrt(), a1 + a2 - 0.01);
let phi = if d < 1e-6 { 0.0 } else { let q = clamp(wr / d, -1.0, 1.0).acos(); if dz < 0.0 { -q } else { q } };
let al = clamp((a1 * a1 + d * d - a2 * a2) / (2.0 * a1 * d), -1.0, 1.0).acos();
let sh = phi + al;
let er = a1 * sh.cos();
let ez = zs + a1 * sh.sin();
let ca = a.cos();
let sa = a.sin();
let n = [-sa, ca, 0.0];
let S = [0.0, 0.0, zs];
let E = [ca * er, sa * er, ez];
let W = [ca * wr, sa * wr, wz];
let T = [ca * wr, sa * wr, zt];
let parts = [0, 1, 2];
if !vis(ca, sa) { parts.reverse(); }
for part in parts {
if part == 0 {
this.beam(S, E, n, 0.26, mat);
this.beam(v3(E, n, -0.17), v3(E, n, 0.17), [ca, sa, 0.0], 0.3, "robotb");
} else if part == 1 {
this.beam(E, W, n, 0.2, mat);
} else {
if kind != "" { this.goods(kind, T[0], T[1], zt - goods_h(kind) * k, k); }
this.beam(W, T, n, 0.12, "robotb");
this.beam(v3(T, n, -0.16), v3(T, n, 0.16), [ca, sa, 0.0], 0.07, "grip");
}
}
this
}
fn meta_robot_arm() {
#{ label: "Robot arm", category: "Industry", icon: "⟟",
help: "Picks goods off the end of the nearest conveyor in its reach and puts them on the start of another one, or on a pallet beside it (Pick from / Put on: auto, or a side of the arm). With Cycle 0 it keeps pace with the goods.",
props: [
#{ key: "reach", type: "float", def: 2.0, min: 1.0, max: 4.0, step: 0.1, label: "Reach (m)" },
#{ key: "pick", type: "choice", def: "auto", choices: ["auto", "+x", "+y", "-x", "-y"], label: "Pick from" },
#{ key: "put", type: "choice", def: "auto", choices: ["auto", "+x", "+y", "-x", "-y"], label: "Put on" },
#{ key: "cycle", type: "float", def: 0.0, min: 0.0, max: 60.0, step: 0.5, label: "Cycle (s)" },
#{ key: "goods", type: "choice", def: "carton", choices: goods_kinds(), label: "Goods" },
#{ key: "running", type: "bool", def: true, label: "Running" },
#{ key: "colour", type: "choice", def: "robot", choices: ["robot", "mach2", "mach3", "mach1"], label: "Colour" },
] }
}
fn prefab_robot_arm(o) {
let p = o.p;
let t = arm_targets(o, 0);
let run = t.running;
let k = 0.9;
let gh = goods_h(t.kind) * k;
let pk = t.pick;
let pt = t.put;
if pt.belt == "" { pt.z = 0.14 + gh; }
let cycle = t.cycle;
let al = (p.reach + 0.6) / 2.0;
let da = pt.a - pk.a;
while da > PI() { da -= 2.0 * PI(); }
while da < -PI() { da += 2.0 * PI(); }
let pick_from = if pk.belt == "" { "its " + p.pick + " side" } else { pk.belt };
let put_on = if pt.belt == "" { "a pallet" } else { pt.belt };
let stopped = if p.running { if run { "" } else { " (waiting: the line before it stands)" } } else { " (stopped)" };
info_row("Moves", `${t.kind}s from ${pick_from} to ${put_on}`);
info_row("Cycle", `${num1(cycle)} s${stopped}`);
// the poses over one cycle: [turn from pick to put (0..1), lift (0..1), holding]
let F = [[0.0, 0.0, true], [0.0, 0.5, true], [0.0, 1.0, true], [0.2, 1.0, true],
[0.45, 1.0, true], [0.7, 1.0, true], [0.9, 1.0, true], [1.0, 1.0, true],
[1.0, 0.4, true], [1.0, 0.0, false], [1.0, 0.6, false], [0.8, 1.0, false],
[0.55, 1.0, false], [0.3, 1.0, false], [0.1, 1.0, false], [0.0, 0.5, false]];
let N = F.len();
let ptx = pt.r * pt.a.cos();
let pty = pt.r * pt.a.sin();
// poses pointing away from the viewer go behind the base, the others in front
let behind = [];
let front = [];
for i in 0..N {
if !run && i != 2 { continue; }
let te = F[i][0] * F[i][0] * (3.0 - 2.0 * F[i][0]);
let a = pk.a + da * te;
if vis(a.cos(), a.sin()) { front.push(i); } else { behind.push(i); }
}
let s = sprite();
// a pallet with a layer of goods where it puts them, when not on a belt
if pt.belt == "" {
s.box(ptx - 0.6, pty - 0.5, ptx + 0.6, pty + 0.5, 0.0, 0.14, "crate");
let layer = [[ptx - 0.3, pty - 0.25], [ptx + 0.3, pty - 0.25], [ptx - 0.3, pty + 0.25], [ptx + 0.3, pty + 0.25]];
layer.sort(|a, b| if depth(a[0], a[1]) < depth(b[0], b[1]) { -1 } else { 1 });
for q in layer { s.goods(t.kind, q[0], q[1], 0.14, k * 0.85); }
}
for side in [0, 1] {
if side == 1 {
s.box(-0.45, -0.45, 0.45, 0.45, 0.0, 0.25, "robotb");
s.cyl(0.0, 0.0, 0.33, 0.25, 0.95, p.colour);
s.cyl(0.0, 0.0, 0.27, 0.95, 1.2, "robotb");
}
for i in if side == 0 { behind } else { front } {
let f = F[i];
let te = f[0] * f[0] * (3.0 - 2.0 * f[0]);
let a = pk.a + da * te;
let r = pk.r + (pt.r - pk.r) * te;
let z = pk.z + (pt.z - pk.z) * te;
if run {
let cls = if i == 0 { "a flip pose0" } else { "a flip" };
// pose 0 (the grip) when the last item on the pick belt goes (t.ph seconds in)
let delay = (((N - i) % N) * cycle / N - t.ph % cycle + cycle) % cycle;
s.begin(cls, `animation-duration:${cycle}s;animation-delay:-${delay}s`);
}
// put down on a pallet: the item stays there until the next one comes
if !f[2] && pt.belt == "" { s.goods(t.kind, ptx, pty, pt.z - gh, k); }
s.arm_pose(r, a, z + gh + f[1] * 0.5, if f[2] { t.kind } else { "" }, k, p.colour, al);
if run { s.end(); }
}
}
}
+6 -1
View File
@@ -74,7 +74,7 @@ Each phase ends with tests, a Studio/viewer check in Edge, commit and push.
1. ~~Web performance~~ (done, see below) 1. ~~Web performance~~ (done, see below)
2. ~~Conveyors~~ (done, see below) 2. ~~Conveyors~~ (done, see below)
3. ~~Producers with adjustable production speed~~ (done, see below) 3. ~~Producers with adjustable production speed~~ (done, see below)
4. Robotic arms 4. ~~Robotic arms~~ (done, see below)
5. Rounded streets: curved road segments 5. Rounded streets: curved road segments
6. Sidewalks along streets 6. Sidewalks along streets
7. Roundabouts 7. Roundabouts
@@ -103,6 +103,11 @@ height, rise, speed; corners join without overlap). A conveyor starting at a pro
at another conveyor's end, carries those goods, spaced by speed / rate; tooltips show the numbers. at another conveyor's end, carries those goods, spaced by speed / rate; tooltips show the numbers.
`examples/factory`, Factory demo in the web Studio. `examples/factory`, Factory demo in the web Studio.
Robot arm: picks goods off the end of the nearest belt in its reach and puts them on the start of
another belt or on a pallet, one move per item (or a set cycle), waiting while the line before it
stands; the belt after it runs in step. Drawn as a 16-pose flipbook (inverse kinematics, links as
the new native `beam` primitive) shown by a stepped CSS animation.
## Phase 2: richer editing ## Phase 2: richer editing
- [x] Interior prefabs (see Build your own house); a building `rooms` prop is still open - [x] Interior prefabs (see Build your own house); a building `rooms` prop is still open
+14 -2
View File
@@ -83,6 +83,7 @@ shaded automatically by face direction.
| `cyl(cx, cy, r, z0, z1, mat[, opts])`, `frust(cx, cy, r0, r1, z0, z1, mat[, sides[, opts]])` | round things | | `cyl(cx, cy, r, z0, z1, mat[, opts])`, `frust(cx, cy, r0, r1, z0, z1, mat[, sides[, opts]])` | round things |
| `slope(x0, x1, y0, y1, za, zb, th, mat)` | ramp / stair flight | | `slope(x0, x1, y0, y1, za, zb, th, mat)` | ramp / stair flight |
| `face(pts3, mat[, #{id, two_sided}])` | any planar polygon, shaded and culled by its normal (roof slopes, gables) | | `face(pts3, mat[, #{id, two_sided}])` | any planar polygon, shaded and culled by its normal (roof slopes, gables) |
| `beam(a, b, n, w, mat)` | square bar `w` wide between 3D points a and b, `n` a direction across it (robot arms, trusses) |
| `poly(polys3, cls[, id])` | 3D polygons as one path (solar modules) | | `poly(polys3, cls[, id])` | 3D polygons as one path (solar modules) |
| `flat(poly or polys, z, cls[, id])` | flat polygon(s); later ones cut holes | | `flat(poly or polys, z, cls[, id])` | flat polygon(s); later ones cut holes |
| `open(a, b, [[u0, u1, z0, z1], …], cls[, id])` | windows / doors on the face a→b (outward normal on the right) | | `open(a, b, [[u0, u1, z0, z1], …], cls[, id])` | windows / doors on the face a→b (outward normal on the right) |
@@ -122,8 +123,8 @@ cobble, brick) and the site `wall` use it; `house_wall` has its own version
## Production lines ## Production lines
`producer` and `conveyor` (stdlib/industry.rhai) work together through the `producer`, `conveyor` and `robot_arm` (stdlib/industry.rhai) work together
document, like house parts: through the document, like house parts:
- A producer makes one of its `product` goods every 60 / `rate` seconds (its - 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. ram goes down once per item) and puts them out on its local +x side.
@@ -140,6 +141,17 @@ document, like house parts:
- The goods ride a group that moves one gap per loop (`iso-ride`), so their - 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. drawing order never changes; the first fades in, the last fades out.
Tooltips show speed, rate and where the goods come from (`info_row`). Tooltips show speed, rate and where the goods come from (`info_row`).
- A robot arm picks goods off the end of the nearest conveyor within `reach`
(+0.4 m) and puts them on the start of another conveyor within reach, or on
a pallet a quarter turn on (`pick` / `put`: auto or a side of the arm). It
makes one move per item coming in (`cycle` 0) or every `cycle` seconds, and
waits while the line before it stands. The belt it puts on carries its goods
and starts its loop when they land, so arm and belts stay in step.
- The arm is a flipbook: 16 poses (two-link inverse kinematics, links drawn
with `beam`), each shown for a sixteenth of the cycle by a stepped CSS
animation (`iso-flip`); a rotation cannot turn a 3D arm in this projection.
Poses pointing away from the viewer are drawn before the base, the others
after it.
## House parts ## House parts
+3 -2
View File
@@ -1,7 +1,8 @@
# isokit factory demo: producers making goods at a set rate, conveyors carrying them. # isokit factory demo: producers making goods at a set rate, conveyors carrying them,
# robot arms passing them on.
# Open it with `isokit studio examples/factory`. # Open it with `isokit studio examples/factory`.
title = "isokit 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." 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; robot arms pass them on. Tap a press, belt or robot for its numbers. Drag to pan, Ctrl + wheel or pinch to zoom."
default_level = "site" default_level = "site"
default_rotation = 1 default_rotation = 1
prelude = [] prelude = []
+8 -6
View File
@@ -7,20 +7,22 @@
{"id": "press-a", "type": "producer", "x": -14, "y": -4, "props": {"rate": 20, "product": "carton", "colour": "mach1"}, "name": "Carton press", "tag": "Cartons 20/min"}, {"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-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-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": "belt-a3", "type": "conveyor", "x": -1.4, "y": 2, "rot": 0, "props": {"w": 6, "speed": 0.8}, "name": "Carton belt 3"},
{"id": "robot-a", "type": "robot_arm", "x": 3.2, "y": 2, "name": "Palletiser", "tag": "Palletiser"},
{"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": "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-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": "belt-b2", "type": "conveyor", "x": 6.4, "y": -1, "rot": 90, "props": {"w": 6, "d": 1.0, "speed": 0.4, "rise": 0.8}, "name": "Drum incline"},
{"id": "robot-b", "type": "robot_arm", "x": 6.4, "y": 3.8, "props": {"reach": 2.4, "colour": "mach2"}, "name": "Loading robot"},
{"id": "belt-load", "type": "conveyor", "x": 11.5, "y": 5.2, "rot": 0, "props": {"w": 7, "d": 1.0, "speed": 0.6, "rise": 1.0}, "name": "Loading belt"},
{"id": "truck", "type": "truck", "x": 16.6, "y": 5.2, "rot": 90, "props": {"len": 9}, "name": "Truck at the dock"},
{"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": "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-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": "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-1", "type": "container", "x": 12.5, "y": 9.8, "rot": 0, "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": "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-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-c", "type": "person", "x": -11.8, "y": 7.8, "props": {"cls": "hivis"}},
{"id": "op-load", "type": "person", "x": 2.4, "y": 9.6}, {"id": "op-load", "type": "person", "x": 10.0, "y": 7.2},
{"id": "tree-1", "type": "tree", "x": -20, "y": 13}, {"id": "tree-1", "type": "tree", "x": -20, "y": 13},
{"id": "tree-2", "type": "tree", "x": -15, "y": 14}, {"id": "tree-2", "type": "tree", "x": -15, "y": 14},
{"id": "tree-3", "type": "tree", "x": 19, "y": 14}, {"id": "tree-3", "type": "tree", "x": 19, "y": 14},
+10 -2
View File
@@ -379,11 +379,19 @@ with sync_playwright() as p:
pg.locator('#right input[type="number"][data-prop="rate"]').press("Enter") pg.locator('#right input[type="number"][data-prop="rate"]').press("Enter")
pg.wait_for_timeout(900) 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", "?")) check("30 cartons a minute, 1 m apart" in rows().get("Carries", ""), "a faster producer packs the belt closer: " + rows().get("Carries", "?"))
# a robot arm at the belt's end picks the goods off and stacks them on a pallet, one per item
pg.evaluate("__studio.op({op: 'add', type: 'robot_arm', x: 19.2, y: -4.5})")
pg.wait_for_timeout(900)
rid = doc()["objects"][-1]["id"]
rrows = dict(pg.evaluate(f"(__studio.view.info('{rid}') || {{}}).rows || []"))
check(rrows.get("Moves") == f"cartons from {cv['id']} to a pallet" and rrows.get("Cycle") == "2 s" and
pg.evaluate(f"document.querySelectorAll('.iso-svg [data-obj=\"{rid}\"] .iso-flip').length") == 16,
f"a robot arm at the belt's end moves its goods to a pallet in step with them: {rrows}")
pg.screenshot(path=str(OUT / "studio-conveyor.jpg"), type="jpeg", quality=80) pg.screenshot(path=str(OUT / "studio-conveyor.jpg"), type="jpeg", quality=80)
for _ in range(3): for _ in range(4):
pg.locator("#undo").click() pg.locator("#undo").click()
pg.wait_for_timeout(500) pg.wait_for_timeout(500)
check(len(doc()["objects"]) == n4, "undo takes the producer and the belt back") check(len(doc()["objects"]) == n4, "undo takes the robot, the producer and the belt back")
pg.evaluate("__studio.select(null)") pg.evaluate("__studio.select(null)")
pg.wait_for_timeout(300) pg.wait_for_timeout(300)
+3
View File
@@ -321,6 +321,9 @@ try:
f"Factory demo opens a production yard ({nf} objects, goods riding {rides} belts, no warnings)") 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'] || ''") 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) check("20 cartons a minute" in carries and "(from belt-a2)" in carries, "goods come down the line: " + carries)
loads = pg.evaluate("() => Object.fromEntries((__studio.view.info('belt-load') || {rows: []}).rows)['Carries'] || ''")
flips = pg.evaluate("() => document.querySelectorAll('.iso-svg .iso-flip').length")
check("(from robot-b)" in loads and flips == 32, f"robot arms pass the goods on ({flips} poses): " + loads)
shot(pg, "web-factory.jpg") shot(pg, "web-factory.jpg")
# ---------------------------------------------------------------- folder project (OPFS stands in for a picked folder) # ---------------------------------------------------------------- folder project (OPFS stands in for a picked folder)
+5
View File
@@ -122,6 +122,10 @@ html[data-theme=light] .iso-svg .iso-glint { display: inline; }
.iso-svg .iso-rideout { animation: iso-fadeout 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-rideio { animation: iso-fadeio 2s linear infinite; }
.iso-svg .iso-ram { animation: iso-ram 2s ease-in-out infinite; } .iso-svg .iso-ram { animation: iso-ram 2s ease-in-out infinite; }
/* robot arms: a flipbook of 16 poses, each shown for a sixteenth of the cycle (delays set the turn);
unanimated (reduced motion, ghosted) only the first */
.iso-svg .iso-flip { visibility: hidden; animation: iso-flip16 4s step-end infinite; }
.iso-svg .iso-flip.iso-pose0 { visibility: visible; }
.iso-svg .iso-park { display: none; } .iso-svg .iso-park { display: none; }
/* paused off-screen (iso-off), when stopped (iso-still), with animation off and during pan/zoom gestures */ /* paused off-screen (iso-off), when stopped (iso-still), with animation off and during pan/zoom gestures */
.iso-off .iso-a, .iso-still .iso-a, .iso-off .iso-drive, .iso-still .iso-drive, .iso-off .iso-ledr, .iso-off .iso-ledo, .iso-still .iso-ledr, .iso-still .iso-ledo, .iso-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,
@@ -142,6 +146,7 @@ html[data-theme=light] .iso-svg .iso-glint { display: inline; }
@keyframes iso-fadein { 0% { opacity: 0; } 35%, 100% { opacity: 1; } } @keyframes iso-fadein { 0% { opacity: 0; } 35%, 100% { opacity: 1; } }
@keyframes iso-fadeout { 0%, 65% { opacity: 1; } 100% { opacity: 0; } } @keyframes iso-fadeout { 0%, 65% { opacity: 1; } 100% { opacity: 0; } }
@keyframes iso-fadeio { 0%, 100% { opacity: 0; } 30%, 70% { opacity: 1; } } @keyframes iso-fadeio { 0%, 100% { opacity: 0; } 30%, 70% { opacity: 1; } }
@keyframes iso-flip16 { 0% { visibility: visible; } 6.25%, 100% { visibility: hidden; } }
@keyframes iso-ram { 0%, 20%, 100% { transform: translate(0, 0); } 45%, 55% { transform: translate(var(--dx), var(--dy)); } } @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-move { from { transform: translate(0, 0); } to { transform: translate(var(--dx), var(--dy)); } }
@keyframes iso-shuttle { @keyframes iso-shuttle {