Clayground.Lab
The experiment kernel for Clayground Labs: turns any sandbox into an
interactive, deterministic, agent-verifiable experimentation space.
Components
Lab (singleton) — registry connecting everything; agents read/write
parameters via Lab.p(name) / Lab.set(name, value) and fetch
Lab.labInfo() through the inspector's eval action.
Parameter — named, ranged tunable; bind your system to value.
Probe — named observable sampled on a fixed sim-time grid.
SimClock — seeded deterministic clock; with a world attached, sim
time advances with the physics steps (exact under the inspector's
time/step action); seeded random()/randomRange() are the only
randomness a lab may use.
ParamPanel — auto-generated slider panel for all parameters. Rows are
Tab-reachable and arrow-operable, with a visible focus ring.
Plot2D — live autoscaled strip chart of probes. Pass probes for a
fixed set, or series: [{probe, label, color, style, sigmaProbe}] when the
plotted set is built at runtime: the lab then owns naming and colouring, an
empty array draws placeholder instead of every probe, and legend entries
become clickable (seriesClicked) so the user can drop a curve where it is
named. style: "scatter" leaves discrete measurements unjoined,
sigmaProbe fills a translucent ±σ band behind a curve, and hovering the
chart reads every visible series back at the nearest sample.
DataRecorder — probes to a run record (record.js): lab, scenario,
seed, every parameter, the per-probe series with their summaries and the
command that regenerates it, no wall clock anywhere - so two runs of one
seed are byte-identical and a paper can cite the file by id. A .csv
destination still writes the flat table, but a CSV carries no provenance
and so cannot be cited. Always set an explicit destination (a relative
default once littered the repo root).
LabTheme / ThemeSwitch / ScaleSwitch — every colour, shape, type and
spacing token, in a light and a dark palette that swap at runtime, all
multiplied by one uiScale factor. The two palettes are counterparts
rather than inversions, and the rules that make them so live (and are
tested) in palette.js — most importantly LabTheme.inkOn(fill), which
every chip and badge must use instead of naming an ink, and
LabTheme.step(c, amount), which moves a colour away from the ground in
whichever direction the palette has room. The measurement half is
tokens.js: seven type roles (fontMicro … fontTitle), six spacing
steps (spaceXs … spaceXxl) and LabTheme.px(n) for one-off geometry.
node palette.test.js and node tokens.test.js check the relationships,
not the values. Two roles are easy to miss: the board has its own (board
for the sky, table, sheet, inkSolid for a rim or wall - ink as a lit
surface cannot simply invert or it becomes a light source), because a
recessed 2D paperDeep well still sinks in the dark while the board's
ordering inverts; and data tokens keep their identity across themes - the
paper says "the rose track is GPS" and the legend has to agree in both, so a
colour is measured on the dark ground and lifted along its own hue only if
it fails to read there. The scale exists because a lab shown on a large
external screen had HUD controls nobody could read and nothing to turn:
every size in the chrome was a bare pixel literal.
LabPrefs — the three settings that belong to the person rather than to
the run: ui.theme, ui.scale, ui.lang. Backed by Clayground.Storage
when it is present and by memory when it is not, so a lab that never links
the storage plugin still runs — it just forgets on exit.
LabLang / LangSwitch — runtime language switch for a published lab.
Whoever owns a vocabulary registers it (LabLang.register(dict) with
{lang: {key: text}}), strings are ordinary bindings on LabLang.t(key)
/ tf(key, ...), and LabLang.num(v, digits) prints numbers in the
language's notation (German gets a decimal comma; Plot2D, BudgetBar
and ParamPanel already use it). LabLang.qty(v, unit, digits) adds the
quantity layer on top — SI prefixes with the mA↔A, ms↔s and k/M crossovers
every lab used to hand-roll (format.js, node format.test.js). Not
qsTr: retranslating a live engine is a C++ call on the QQmlEngine,
which a lab hosted by the dojo or exported to WASM does not own. Drop
LangSwitch in a corner and it offers exactly the registered languages.
Scenario / ScenarioSet — named, scripted situations wiring the
scenarios()/applyScenario() inspector convention; applying resets
the clock so runs are reproducible.
Flow / FlowStep / Narrator / FlowChip — the guided, narrated
walkthrough: verbs-as-data driven through the lab's own flowActions(),
demo/task/watch steps, hint→solve escalation, checkpoint scrubbing.
FlowChip is the on-screen offer to be taught, so the lesson is not
hidden behind a key nobody knows.
CameraDirector — which shot when, for a lab with a presenter in it:
journey (start, destination and subject in one frame, the presenter
followed until it lands), twoShot (presenter and the thing pointed at),
portrait (level with the face, for explanation), cutaway (one thing
alone for a beat, then back), wide. Science-TV grammar written once,
composed through OrbitCamera3D.fit() with a safe area for the chrome,
so no lab hand-rolls arrival/address hooks and no finger points at
something outside the picture. The professor kit's FlowGuide takes one
as director:.
Focus mode
Tab clears the HUD: LabView.focus goes true and the instruments, panels,
plot, compass, clock and switches step out of the way, leaving the scene and -
while a flow runs - the Narrator. It is for studying a scene when nothing is
being changed or measured. Most of it is automatic: anything built on
LabPanel fades on its own, as do the kernel's own pieces. A lab only wires
what it built itself (a scrim, a button declared beside a panel rather than
inside it) with visible: !LabView.focus, and a panel that must survive
focus mode sets hideOnFocus: false. LabPanel uses opacity and enabled
rather than visible, because labs bind visible on their own panels
constantly and a component assigning it would be silently overwritten by
exactly the labs that use it most. The alarm banner deliberately does not
hide - a short circuit outranks whatever you were looking at.
The board — what a build lab is made of
A lab that places typed parts on a grid, wires their pads and solves the
result owns none of that mechanism any more. The domain kit hands over a
part spec (`spec[type] = { terminals, half, actuator, fields, rows,
watch }, see board.js`), a solver and a part visual; the kernel does the
rest. board.js is pure JS (node board.test.js), tests/tst_board.qml
drives the store and the gesture with no GPU.
Board — the store: parts, wires, rev, hit test, keep-out, the
mutations, batching, state()/load(), an optional router; changed
is where the domain re-solves.
BoardInput — the mouse: wire pads, select and drag, tap a wire, the
two-step actuator (operate), eraser, the right-click cancel chain; the
camera and the instrument belt are asked first, always. A part's state
belongs on its selection card (a resistor's ohms, a gate's function, a
switch's on/off), which is also the only channel that survives having no
pointer on a touch screen; operating a part in the scene is a shortcut
gated on selection - the part you picked is the part that responds - so a
click during building flips nothing and no mode has to be remembered. An
actuator region comes first in the hit test (actuatorHalf(type)), has its
own gesture (no selection, no drag, fires on release) and three signals
that agree on one predicate: a pointing-hand cursor, the lever lightening
(never recolouring what already carries state), and a hint-bar line naming
what the click will do. electronics-101's switch was the cautionary tale: its
pads reached inward and left a few pixels in the middle as the only place a
click flipped it, which read as a bug in wiring rather than in the switch.
BoardWires3D — every wire as one flat batch plus the dangling preview;
the lab's lineOf styles them.
PartPlacer — the palette's parts as one handheld: take, ghost, place.
BoardPalette — presets, parts and tools in foldable sections.
PartCard — the selection card with the domain's rows between the
kernel's title/reading and plot/tag rows; a keyboard target via keys.
BoardOverlay — value labels, wire readings, watch marks, pinned tags.
Chrome — what makes two labs look like one product
Everything below was hand-rolled in two labs before it moved here.
LabPanel — the titled paper panel every HUD is made of; children
stack, or set a size and anchor to its body.
LabKeys / LabHelp — the canonical key map (1..9 presets, T flow,
f jump labels / ⇧F frame (plain F in a jump-less lab), 0 reset,
H takes the next instrument, P keeps its reading,
Shift+R record, ? help, arrows and WASD travel, Shift+arrows
turn) plus the lab's own keys as data. The arrows used to turn, which a
drag already did well; crossing the scene had no key at all, so turning
moved onto Shift and watching moved off W to Q. f/⇧F are the
map's first Shift-differentiated pair - f acquires a target, ⇧F frames
it. While a flow runs →/← and Space are the flow's, so the arrows are
the camera's only when nothing narrates.
HintJump — keyboard selection: f labels every target the lab
names (1–2 home-row letters), typing one selects it in place; off-screen
targets become a grouped badge strip and do fly on pick. The lab
supplies targets() (id, world pos, name, group) and wires
jump: on LabKeys; the letters are physical, the capture follows the
pin-prompt guard, and a focus loss puts the labels away. Declaring a key is what documents
it: LabHelp renders the map from the same list, so the two can never
drift. The six travel letters are reserved and dispatched after the
lab's own keys, so claiming one silently costs a pan direction.
HintBar — bottom-centre line that steps aside while a flow narrates
and stays width-capped against its neighbours.
ScenarioBar — clickable preset chips, each with the one-line reason
it exists (scenario.note.).
WatchMonitor — watch a thing, get a probe, a colour and a curve;
owns probe lifecycle, stable names and the one-quantity-per-axis rule —
which, since traces became (id, quantity) pairs (traceIn), means one
strip per traced quantity, stacked on a shared time axis and cursor
(maxStrips, default 3). A part keeps one colour across strips, so its
WatchMark matches every curve it owns.
WatchChip is the per-object toggle that feeds it (watch / watched /
plot full, the limit read off the monitor), WatchMark the dot the
object then wears in the world, in its curve's colour.
WorldLabel — 2D paper chip pinned to a 3D point.
MarkLayer — rings on the world points something is naming right
now, optionally captioned. Fed by a FlowStep.mark list or a
performance script's mark ... cue; keepOut keeps a ring off a
presenter standing in front of the part it marks.
SelectionFrame3D — the shared hover/select language on the work
surface (thin outline hovering, full frame plus facing mark selected).
Used by the circuit kit; note that its lift is measured from the object,
so a part sunk into its board needs a matching height.
Compass — which way the work surface faces while you circle it.
GridMode — snap/free placement with grafli's contract (# cycles,
Alt inverts for one gesture). It holds the mode and draws nothing;
LabStage3D is what shows it.
LabStage3D — the ground every 3D lab stands on, plus the light rig
and the SceneEnvironment that go with it. One quad, no texture: the
raster is computed in the fragment shader from world coordinates, so it
is millimeter paper on the light palette and blueprint on the dark one,
one line weight at any zoom, and it dissolves into the sky rather than
ending at a board edge. It shows GridMode's mode as crosses or dots at
the intersections, answers worldAt(view, mx, my) for mouse editing, and
publishes the height/depthBias budget flat overlays have to stay
inside. Three labs built a table, a sheet, a rim, a light rig and 585 peg
Models between them before this existed. Three things to know: GridMode
draws nothing - hand it over with gridMode: and set cueSize: 0 in a lab
that places nothing, because a snap cue on a surface nobody snaps to is a
lie; worldAt(view, mx, my) is the pick, the plane being the only pickable
thing the stage adds (a raster not centred on the origin says so with
rasterOrigin); and flat markings sit between overlayMinY and
overlayMaxY (overlayY(layer) stacks them) with a depthBias in
overlayMinBias..overlayMaxBias - depthBias only settles sort order, so
the lift is what actually does the work.
Instruments — the shelf
Promoted from the labs, which had proved each of them (some three times over):
InstrumentScale — what a reading means, with nothing that draws it:
value or probe, unit, fixed limits or a self-ranging set of ranges,
linear or log positioning, severity bands, nice-number gradations, and the
lag and peak-hold of a real movement. One of these feeds as many faces as
the page shows, so they cannot disagree; adaptability lives in the model x
face matrix, so a music VU meter is a BarFace on a log scale with
peak-hold, not a new component. Bands (okUntil/warnUntil, or zones)
are the scale's, not the face's: declared once they colour the needle, the
fill, the digits and the tint behind them from LabTheme's severity
tokens, and a reading past the end of the scale takes the band at that end
- a pinned needle on a red-topped dial must not read back "ok".
settleTime is a swing for a value that changes on an action; damping
is the lag of a real movement, for a continuously noisy one. Never both.
Which face: Gauge for *what is this relative to what the instrument can
take, BarFace for how far along, ColumnFace for how much* read off
the scale, DigitFace for what is the number - paired with a needle or a
column, because alone it says nothing about what the number is worth.
demo/Instruments.qml is the reference page: one scale under four faces.
Gauge — the needle face. Given ranges it selects its own, and prints
the one it settled on. Laid out in fractions of its own size, so the same
component serves a HUD dial and a Texture baked onto a 3D part.
BarFace — the level face, horizontal or vertical, optionally as the LED
ladder of a level meter, with the held peak marked. A music VU meter is
this on a log scale, not a component of its own.
ColumnFace — the thermometer face: every major gradation labelled, for
a quantity read off the scale rather than as a proportion.
DigitFace — the numeric face, in mono digits through LabLang.qty().
InstrumentDock / DockedInstrument — the HUD column, where each
instrument can be put away by the reader and taken back out of a tray at
its foot. The visible set rides in the lab's viewState().
ReadoutPanel / ReadoutRow — swatch · name · live value rows, built from
data, with an optional share bar per row.
MiniMap — the abstract view: fit-to-content projection plus the repaint
plumbing, driven by a draw(ctx, map) callback the lab supplies.
LabBanner — the centred status pill, severity in the fill and the ink
from inkOn(), blinking only for a live fault.
TransportChip — sim time, pause and speed, driving SimClock.timeScale
from outside.
RecIndicator — the recording dot, so a growing CSV is never a secret.
Instruments you hold
The shelf above is mounted: the lab author bound what each one measures
when the lab was written, and it reads for the whole run. These are the
other half — the viewer binds the subject at runtime by pointing, and the
reading dies with the gesture.
HandheldInstrument — the contract. An instrument declares what a click
contributes (pickKind: a "point" on the ground, an "object" in the
scene, or a "moment" in sim time), how many it takes (maxPicks, 0 for
an endless chain), and what the reading means (value / valueText). It
handles no input and knows nothing about the camera. That is the
acceptance test: a new instrument is one file saying what it picks and
what that means, with no gesture code in it.
InstrumentBelt — what the viewer can pick up, and the owner of the
hand's click. One line inside the View3D (pointer: nav, unit: the
lab's unit) and the lab has a TapeMeasure and a Stopwatch; a kit's
own instrument is declared inside the belt and joins the same row. A ruler
you have to install first is a ruler nobody reaches for.
TapeMeasure — the screen-space tape. Its arithmetic is measure.js,
checked by node, so lengths and angles are not geometry that only exists
inside a paint call.
Stopwatch — the same contract against the sim clock rather than the
ground.
CameraAnchorMark — the dotted ring showing what the view is orbiting
and zooming about. A screen-space overlay, because as world content it
clipped into geometry at close range.
pin() is the one transition out: it names the reading, registers it as a
Probe, and from then on it is sampled on the clock grid like any other —
so it lands in the run record and a paper can cite it. It asks for the name
because that name is what gets cited. A measurement itself is never in
viewState(): it is a question being asked now, not scene state.
The mouse
One rule, and the rest follows from it: **the left button is never the
camera's**. A mode used to exist only because the camera wanted LMB — panning
sat there, so a lab that needed LMB had to be able to take it back, and the
thing that took it back was the mode. OrbitInput3D declines the left button
instead, so nothing has to.
RMB drag turns the view about the point under the cursor; a right
click cancels — the "put it down" gesture.
Middle drag pans, wheel zooms towards the cursor, double-click
focuses. Always live, never taken away.
LMB is the lab's: its own tool, or a click handed to whatever
instrument is in the hand. Holding Space lends it to the camera for as
long as the key is down.
A lab with nothing to build may spend LMB on the view deliberately
(panButtons: Qt.LeftButton | Qt.MiddleButton) — one decision, made once,
not a mode.
The determinism contract
Every lab must (a) derive all randomness from SimClock (seeded),
(b) run correctly under the inspector's time pause/step actions,
(c) expose labInfo() — typically just return Lab.labInfo().
Same seed + same stepped frames ⇒ identical probe series.
Minimal lab skeleton
Do not write one — generate it:
sh
tools/lab-new/lab-new --kind build|continuous|draw --purpose learning|teaching|research
The generated Sandbox.qml answers the whole conventions contract
(scenarios(), applyScenario(), labInfo(), viewState(),
flowActions(), …), comes with a bilingual strings.js, the records and
figures drivers and the paper/board skeletons, and its template is itself
a lab that ctest -R lab_new boots. tools/lab-new/README.md explains the
tokens and how a kind is added.
See demo/Sandbox.qml — a damped oscillator with a noisy measurement, used
as an excuse to put every instrument on one page, and the fastest way to see
whether a change to the palette or the scale has broken anything. Render it at
two scales in two themes:
bash
clayrender plugins/clay_lab/demo/Sandbox.qml --out shot.png --size 1400x900 \
--eval 'LabTheme.mode = "dark"' --eval 'LabTheme.uiScale = 1.6' --frames 300
labs/electronics-101/ and labs/hydraulics-101/ are full build labs on the board layer.