Files
isokit/docs/SCRIPTING.md
T
grabowski 9619d0f267 Wildlife: hamster and hamster wheel prefabs
Ported from the thaiai.lol easter egg (thaiai iso/hamster/lib/hamster.rhai);
the data-centre joke parts stay there.

- hamster (Animals): golden, grey or cream with a white belly, pink nose,
  ears and paws, buck teeth and whiskers; standing, running on the spot or
  asleep (eyes shut, z Z); n and spread like the herds; life size (about
  17 cm) at scale 1, up to 40 times.
- hamster_wheel (Animals): diameter (0.3 m life size, up to 12 m), running,
  A-frame stand or a cage-wall axle stub, fur colour. Running legs and the
  rungs are flipbooks of 4 poses, 0.1 s each; the spokes turn by a CSS
  rotation in a matrix that maps the wheel's plane onto the screen.
- draw_parts learns loft2 (two-tone lofts), eye and flip parts;
  place_parts scales and moves any part list; loft and loft2 share
  loft_rings (the other animals draw byte for byte as before).
- Things under 2 m are drawn ten times bigger in a group that scales them
  back (fine_begin / fine_end), so a life-size hamster keeps its shape on
  the drawing's 0.1-unit grid; sort boxes stay real size.
- The legs push back on the ground and swing forward lifted (the original
  lifted them on the way back).
- Theme: hamster, hamsterg, hamsterc, hamsterw, hamsterp, hwheel, hwhub,
  whisker and hamz in both palettes, materials, rules for eyes, whiskers,
  rims, spokes, hub, rungs and the z Z. Viewer CSS: iso-flip4,
  iso-wheelspin, iso-runbob, iso-zzz; beast edges take --bw (default .3).
- Test hamsters_run_in_wheels_from_every_side: no sort cycles in any
  rotation, flipbook counts, scaled groups, real-size sort boxes, tooltips.
- README, ROADMAP, SCRIPTING and the MCP instructions list them.
2026-10-27 16:39:43 +07:00

21 KiB
Raw Blame History

Scripting and prefabs

Scenes and prefabs are written in Rhai, a small scripting language embedded in isokit. Nothing is compiled: save the file and the Studio redraws.

A prefab

A prefab is an object type that can be placed in the Studio. It is two functions in a module that is listed in isokit.toml under prelude:

fn meta_kiosk() {                      // label, palette group and property form
    #{ label: "Gate kiosk", category: "Site", icon: "⌂", help: "…",
       props: [
         #{ key: "h", type: "float", def: 2.8, min: 2.2, max: 4.0, step: 0.1, label: "Height (m)" },
         #{ key: "boom", type: "bool", def: true, label: "Boom barrier" },
       ] }
}

fn prefab_kiosk(o) {                   // draw around (0, 0) in the object's own frame
    let s = sprite();                  // sprite ids default to the object id
    s.box(-1.4, -1.1, 1.4, 1.1, 0.0, o.p.h, "brick", #{ top: "roof3" });
}
  • Property types: float, int, bool, choice (with choices), material (a theme material, optionally with choices), text. Write def: for the default (default is a reserved word in Rhai).
  • How the Studio places it: shape: "line" (drag along the map: sets rot and the length prop w), shape: "rect" (drag a rectangle: sets w and 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). 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 coordinates are local: the origin is the object's position, and local x and y turn with rot. vis(), dv(), tag() and focus() use the frame too.
  • A prefab with the same name in the project overrides the built-in one.
  • If a prefab fails, the engine logs a warning for that object and still draws the rest.

Drawing API

Numbers can be ints or floats. Classes are CSS classes without the iso- prefix ("wall" becomes iso-wall). A material sets the face colour, which is shaded automatically by face direction.

Scene

Function
sprite() / sprite(id) / sprite(id, cls) new sprite (the unit of the depth sort) in the current layer
ground() the shared unsorted ground sprite
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
objects_at(types, x, y) … whose footprint (p.w × p.d around the origin) holds the world point
objects_on(types, x, y, tol) … whose centre line (local x, length p.w) passes within p.t / 2 + tol of the point, with u, v = the point in their frame
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); rot is any angle in degrees (quarter turns stay exact)
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?
hash(x), fmt(n), round1(x), fmin, fmax, clamp, px() helpers
rect(x0, y0, x1, y1), ellipse(cx, cy, a, b, n), rsegs(r), sides(x0, y0, x1, y1) geometry

Sprite methods (chainable)

Method
box(x0, y0, x1, y1, z0, z1, mat[, #{id, top, side, no_top, no_side}]) axis-aligned box
prism(pts, z0, z1, mat[, opts]) convex counter-clockwise footprint; z0/z1 one value or one per vertex
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
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)
flat(poly or polys, z, cls[, id]) flat polygon(s); later ones cut holes
open(a, b, [[u0, u1, z0, z1], …], cls[, id]) windows / doors on the face a→b (outward normal on the right)
lines(segs, z, cls), disc(cx, cy, z, r, cls[, id]) markings
polyline(lines, z, cls) open lines through plan points at height z, or 3D points [x, y, z] (antlers): a dashed line stays one dash run round a curve
circle(x, y, z, r_px, cls[, dx, dy]), stem(x, y, z0, z1, w_px, cls) screen-space details (crowns, poles)
person(x, y, z[, cls]), sit(x, y, z, cls), head(x, y, z, r), led(x, y, z[, cls]) figures
puffs(x, y, z[, kind[, n, dur]]), fan(x, y, z, r) animated steam / smoke / fans
swim(x, y, z, [#{ rx, ry, dur, delay, cw, len, cls[, shape] }, ...]) fish going round ellipses (radii rx, ry along the object's axes) about (x, y) on the plane at z, one round per dur s, clockwise when cw; cls koi / koiw / koig (the pond); shape "bird" (wings len across, flapping), "fly" (a butterfly) or "bee" makes it a flock
seq([[x, y, |q| …], …]) items drawn back to front by their anchor
group(cls[, css], |q| …), begin(cls[, css]) … end() <g> around parts (animation)
travel(a, b, dur, [[s, e], …]) … end() a mover on a straight piece: what is drawn (at a) slides to b during each window (fractions of the dur-second cycle, may wrap past 1) and is hidden otherwise
ext_reset() forget the sort extent drawn so far (set it with ext afterwards)
ext(x, y, z), ext_box(…) widen the sort box
raw([x, y, z,] svg) raw SVG ({uid} becomes the view's id)

Library helpers (stdlib/*.rhai): s.tree_at(x, y, r), s.car(x0, y0, x1, y1, cls), s.drive(dx, dy, dur, delay) … s.end(), cut_walls(...), win_list(...), floor_of(level), building_windows, gable_roof, flat_roof_kit; for house parts (stdlib/house.rhai, stdlib/furniture.rhai): storey_of(z), house_view(o) (0 closed, 1 on the storey cut open, 2 above it: leave out), wall_top(w), opening_at(o), s.slab(a, b, th, z0, z1, mat), s.chair_at(x, y, bx, by, mat), s.office_chair(x, y, mat).

Joints of walls and fences

line_joint(o, e, same, stops) (util.rhai) tells a line piece (centre line along local x, length p.w) how it meets others at its end e (-1 or 1): #{ ext, post }, how far it reaches past the end and whether it draws its end post. same are the types it joins with, stops the types it ends against; half thicknesses come from line_ht(o) (p.t / 2, for fences the design's). Alone a piece reaches half its thickness past the end; on a straight run the lower piece runs through and draws the shared post; at a corner the thicker piece (equal: the one along world x) runs through and the other stops at its face; at a T the stem stops at the face without an end post; against a stops piece it stops at the face and keeps its post. Pieces never overlap, which the 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).

Diagonal pieces

Objects turn by any angle (rot in degrees); a box drawn in a turned frame becomes a prism with its own four corners. Where a piece off the axes is involved, line_meet(o, e, same, stops) (util.rhai) replaces the rules above for that end: #{ kind, ext, k, ... } with kind axis (every piece there keeps to the axes: use the old rules, so such scenes draw as before), free, run (in line), corner (a mitre on the bisector; owner draws a fence's corner post) or tee (stop at the other's face). The end is slanted: the line v to the piece's left reaches ext + k * v past it. end_cut(w, e, m) turns that into #{ x, k } and s.cut_box(x0, k0, x1, k1, y0, y1, z0, z1, mat[, opts]) draws a box with such ends. Fences put their corner post (or brick pillar) in its own sprite and stop their panels at it; stone bases and cobble walls are mitred. Helpers: on_axes(rot), dir_of(o), dot2(a, b).

The depth sort keeps diagonal pieces right through outlines: a sprite whose plan outline is not its box (turned or mitred pieces) carries its convex outline, and when two boxes overlap an outline edge that separates them orders them, the one on the viewer's side going later. A long diagonal wall therefore sorts right against everything on either side of it, and mitred pieces against each other.

Animals

wildlife.rhai draws animals as low-poly solids from faces. s.loft(spine, rs, n, [side, cap0, cap1]) sweeps an n-sided ring (radius r, or [across, up]) along 3D points, keeping its turn round bends; s.tube(a, b, ra, rb, n, mat) is a loft of two rings. face shades each face by its direction and leaves out the ones turned away, so a solid needs no sorting of its own faces; separate parts (legs, body, neck, head, antlers) are drawn back to front by draw_parts, which sorts them by the depth of their anchors in the view (depth(x, y) + z). The class beast edges each face in its own shade instead of the dark outline, so a body reads as one surface.

animal_parts(kind, sub) returns #{ body, up, down } for an animal facing +x: the parts that stay and two head poses. With grazing on, the poses sit in two groups iso-a iso-pose2 iso-pose0 and iso-a iso-pose2 that take turns (the CSS shows the first unanimated); each animal starts at its own point of the cycle. herd(o, kind, mix, graze) scatters n animals in a sunflower spread within r of the object, each facing its rotation give or take 70°, each its own sprite. Flocks of birds, butterflies and bees use swim with a shape.

Hamsters add three kinds of parts to draw_parts: loft2 (a loft in two colours: faces pointing down get the belly colour, see loft2), eye (a shiny bead, or shut) and flip (a flipbook: one part list per pose, shown 0.1 s each in groups iso-a iso-flip4 [iso-pose0], the step of the viewer's light animation mode). hamster_parts(run, sleep, n, np, delay, fur) builds one about 1.2 m long, place_parts(parts, s, o) scales and moves any part list, and s.hamster_at(x, z, size, run, sleep, fur, delay) draws it (hamster_life() = life size). s.hamster_wheel(R, run, stand, fur, delay) draws a wheel with a hamster in it: the rungs join the wheel's two planes, so they are a flipbook too, drawn behind or in front of the hamster pose by pose; the spokes turn by CSS inside vplane_g(ux, uy), a raw group mapping a vertical plane onto the screen in metres.

Small things lose their shape to the drawing's 0.1-unit grid (2.5 cm): draw them fine_k(size) times bigger between s.fine_begin(x, y, z, fz, k, css) and s.fine_end() (a group that scales them back about the point), then set the sort extent with ext_reset() and ext_box(). Screen-unit values inside (stroke widths, --bw for the beast edges) are k times bigger too.

Points (pts)

An object's pts are plan points relative to its x, y in its turned frame. A prefab that draws them as its outline sets outline: true in its meta (Area): the Studio then offers Edit corners, which turns its w x d rectangle into four points, and Rectangle again, which removes them. Every selected object with pts shows handles: drag a point, drag the + of a side to add one, double-click a point to remove it (an open route keeps 2, a closed outline 3). Route points say whether they close with props.closed; any other pts are a closed outline.

Groups and the drawing order

Objects of type group are never passed to scripts (they draw nothing), nor is anything inside a hidden group (Doc::hidden_in_groups); prefabs never see the group field's effect. The order of scene.json is the drawing order of the ground layer (unsorted): later ground pieces lie on top. Sorted layers ignore it except for ties. The Studio keeps each group's members just before the group object (tree, flatten in studio.js) and sends tree edits as one order op.

Routes

routes.rhai (route prefab, meta shape: "path": the Studio draws it point by point and stores the points in the object's pts, relative to its x, y). The movers' path is one closed line: a loop is offset to the left of the drawn line by the kind's lane (keep: "right" the other way); an open route runs out on one side and back on the other, so it is a loop too, and its two strips never overlap. Corners are rounded into pieces of at most 45° (rt_round; people keep sharp corners, they look the same from every side).

Each straight piece is its own sprite in the piece's frame: for every group of movers that look alike and are never on the piece together, travel([0,0,0], [len,0,0], dur, wins) with the mover drawn at the start, then ext_reset() and the lane strip as its extent, mitred at both ends so neighbouring pieces never overlap. Mover k is on a piece that starts at metres along a loop of total metres while (t / dur - at / total + k / n) mod 1 lies in [0, len / total]. The renderer turns each travel into keyframes (visibility and translation, named by a hash of their content, all in one <style> per view). Movers carry iso-travel, not iso-a: views without animation (reduced motion, stopped, ghosted) pause them where they are instead of hiding them.

Production lines

producer, conveyor and robot_arm (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).
  • 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.

Streets

road, road_curve and roundabout (stdlib/streets.rhai; road lives in ground.rhai) meet by their centre lines:

  • Each street is a centre line (a polyline or a true arc) with the carriageway d wide around it; walk (none, both, left, right, looking along local +x) adds sidewalks walk_w wide behind the kerbs: 12 cm high paving with the kerb face drawn where it is turned to the viewer. Arcs stay arcs when cut and offset, so a curve meets a straight road exactly.
  • End to end (a curve continuing a road, a road continuing a roundabout arm) the pieces carry on, overlapping by 4 cm so no hairline shows.
  • A street ending on a road's centre line makes a T, at any angle: the road leaves a gap in its kerb and sidewalk on that side (slanted along the street coming in), the street ends its asphalt on the road's centre line, its centre line and kerbs at the road's edge and its sidewalks at the road's sidewalk.
  • Two streets ending at one point at any angle meet in a mitre on the bisector (thin seam patches reach over it, so no hairline shows).
  • Every part of a street end is a cut #{ e, k }: the line w to the left looking out reaches e + k * w past the end (street_join); gaps are #{ lo, hi, c } ranges that slant with the side offset (street_gaps).
  • A roundabout's arms end r + arm_len from its centre, along its local +x, +y, -x, -y. Its kerbs flare into the ring with a fillet; splitter islands and give-way lines sit on the entry side for drive left or right.
  • The prefab meta corner: "road_curve" on road tells the Studio to round each corner of a road run with a curve (up to its default radius, less where the pieces next to a corner are short).
  • street_ends(o), street_join(o, end, others), street_gaps(o, others) and street_path(o, line, j0, j1, gaps) are there for your own street prefabs; tooltips list the streets each one meets (Meets).

House parts

house_wall, window, door, floor, roof, stairs and the furniture prefabs work together through the document, not through a parent object:

  • A window or door at a point on a wall's centre line (within the wall's half thickness + 0.3 m, same height) sits on that wall (mount_of). The wall leaves a gap for it (mounted), the window draws the opening in the wall's frame with the wall's height, thickness and material. On no wall it stands alone as a short wall piece. Its rotation relative to the wall picks a door's swing side (0, 90: towards the wall's +y side) and hinge (0, 180: at the low end).
  • Wall ends adapt to the walls they meet, so no two walls overlap (the depth sort needs them apart): along a line two walls just meet, at a T the stem stops at the other wall's face, at a corner the wall along world x runs through and the other stops at it.
  • A part's storey is floor((z + 0.5) / storey_h) (storey_h: project parameter, default 3 m). On level gf (1f, …) the parts on that storey are cut open: a wall stays full height only when a floor lies on the viewer's side of it and none behind it (it is a back wall), all others are cut to 1 m. Roofs on that storey become an outline on the floor, parts on storeys above are left out, the storeys below stay closed.
  • Ground-level floors and rugs go to the floor layer; upper floors are slabs whose top is at the object's z. Furniture starts 0.02 m above its z.

Rules that keep the depth sort right

These are lessons from the CNX/Mooban views, and the engine relies on them:

  • One sprite per thing that can stand in front of another. Split long walls, fences and solar rows into pieces (≤ 30 m). One big sprite overlapping many others creates sort cycles (isokit check reports them).
  • Things under a cover belong in the cover's sprite: draw cars under a carport before its posts and roof (see prefab_carport).
  • Floors of cut-open storeys go in the floor layer (ground floor) or in the same sprite as the storey below. Start walls and furniture standing on them a little above the floor (+0.02 m).
  • Movers (drive) run along the axis the sort compares second (along_x()). Along the other axis (and on diagonals) they stay still.
  • Diagonal pieces sort by their outlines (see Diagonal pieces), so they need no extra splitting; things standing exactly on a wall's line still interpenetrate it and sort by their centres.
  • Street furniture stays off drive areas, and pipe runs end on walls or supports.

Themes

theme.toml stacks on the built-in palette:

[palette.dark]
brick = "#8a4f3e"
[palette.light]
brick = "#b8664c"
[materials]
brick = "brick"          # class iso-brick, colour var(--iso-brick)
[rules]
".iso-svg .iso-sign2" = "fill: #ffcc00; stroke: none"

isokit check warns about any class in the drawing that has no style.