A desktop scene editor for threepp: build a scene, tweak it, save it as three.js JSON, and press Play to hand it to a runtime (PhysX ships in the box).
cmake -S . -B build -DTHREEPP_BUILD_EDITOR=ON
cmake --build build --target threepp_editor
./build/bin/threepp_editor [scene.json]
THREEPP_BUILD_EDITOR defaults to ON when threepp is the top-level project and
GLFW is enabled, and OFF for anyone consuming threepp as a subproject.
THREEPP_EDITOR_WITH_PYTHON (also ON by default) builds the Python scripting
support and quietly turns itself off when no Python3 with the
Development.Embed component is found.
Command line:
| flag | meaning |
|---|---|
scene.json |
open this document on start |
--vulkan |
use the Vulkan backend (OpenGL is the default and the supported path) |
--urdf=<file> |
selftest only: also exercise the URDF import and round trip |
--frames=N |
render N frames and exit — for smoke tests |
--play |
press Play as soon as the scene is open — with --frames, a whole play session without a hand on the mouse |
--example=<slug> |
open a shipped example instead of the template scene (hover-arena) |
--screenshot=<png> |
write PNGs and exit. With no document of its own it builds the spline-tube acceptance scenario and writes one file per view; with a scene.json or --example it photographs that instead, honouring the three flags below |
--seconds=N |
how long that pass plays before the first shot (default 3) |
--keys=W,A |
hold these keys for those seconds, then let go — a scene you are meant to drive cannot be reviewed standing still |
--hold-keys |
keep holding them through the shots instead of letting go and waiting for the scene to settle. What a manoeuvre looks like halfway through it — a drone mid-turn, with the chase camera coming round behind it — is a picture the settled pose cannot show |
--shot=px,py,pz@tx,ty,tz[:tag] |
camera position and target for that pass, repeatable; tag becomes a file-name suffix. None given keeps the camera the session already has — an authored editorView, or wherever Follow Selection has chased to — and frames the whole document only when there is neither |
--bench=N |
time N frames after --seconds of warmup and print the result. Turns vsync off, because a present-capped frame time measures the display rather than the renderer; the status bar's fps cannot answer this question for the same reason (it is a smoothed average of frames the swapchain paced). Reports median, p95 and max CPU frame time in milliseconds, and on Vulkan the per-pass GPU medians from the backend's timestamp queries. Composes with --example/--play/--keys, so a demo can be timed while it is being flown. THREEPP_BENCH_VSYNC=1 puts the cap back, for the one question the uncapped number cannot answer: whether the editor as shipped holds the refresh rate. THREEPP_BENCH_DISABLE=a,b,… strips pieces of the frame so a cost can be attributed rather than guessed at — cloud, sensors, ui, overlay, play, and the deferred pipeline's own ao, probegi, restir, denoise, halfres (render at half scale: a cost that halves is per-pixel work, one that does not is fixed) |
Two layers, deliberately separated:
threepp/extras/editor/* — the reusable core. Part of the threepp library,
with no ImGui, no window and no GL/Vulkan dependency. It is what a different
front end (a Qt tool, a Python binding, a batch script) would build on — and
what threepp_player, the second front end that ships, is built
on:
| type | responsibility |
|---|---|
Command, CommandStack |
undo/redo with drag coalescing |
SetTransformCommand, PropertyCommand<T>, AddObjectCommand, RemoveObjectCommand, ReparentCommand, SetMaterialMapCommand |
the concrete edits |
Selection |
the current object + change notifications |
SceneDocument |
the open Scene, its path, its dirty flag, save/open |
SceneSnapshot |
scene ⇄ JSON, with live textures preserved |
ObjectFactory |
primitives, lights, groups, cameras with unique names |
PlaySession, PlayController |
the play-mode state machine |
PhysicsConfig |
per-object rigid-body and soft-body authoring, stored in userData |
RenderConfig |
the renderer settings a document is saved with, as a difference from the editor's defaults, stored in the scene root's userData |
PhysicsPlaySession |
the PhysX runtime (header-only, PhysX-gated) |
ScriptConfig |
per-object Python script — a file path or inline source — plus its parameters, stored in userData |
ScriptWorkspace |
the .vscode/settings.json that teaches Pylance about threepp, and the text normalization an external edit round-trips through |
EditorSettings |
recent files and last-used directories |
apps/editor/* — the application. EditorApp owns the canvas, renderer,
editor camera, OrbitControls, TransformControls, raycaster and ImGui context,
and the panels are its member functions split across panels/*.cpp.
Viewport. Grid + origin axes, orbit camera, click to pick, bounding-box
highlight on the selection, full transform gizmo (TransformControls) with
local/world space and snapping. Orbiting is disabled while a gizmo handle is
being dragged.
Picking selects the top-level object of whatever was hit — clicking an imported model selects the model, not one of its sub-meshes. Click again inside the same subtree to drill down to the exact node.
Follow Selection. View ▸ Follow Selection (Shift+F) turns the viewport
into a chase camera: every frame the orbit target walks towards the selection's
world position and the camera is carried with it, keeping the offset you orbited
to, so orbiting, panning and zooming keep working while it follows. The approach
is exponential with a ~90 ms time constant rather than a hard lock — a rigid body
has a tremble, and a camera bolted to it inherits it.
The offset is kept in the subject's heading frame, which is what makes it a
chase rather than a fixed vantage that the subject flies out of: yaw the drone
with A/D and the camera comes round behind the new heading instead of
watching it leave sideways. What "heading" means is the object's forward axis
(-Z, threepp's own convention — the direction lookAt aims) projected onto the
ground plane, read off its world quaternion, so it works for anything that faces
where it goes. Specifically:
- Yaw only. Never pitch, never roll. A hover drone banks constantly to move, and a camera that inherited attitude would roll the horizon with every input. Camera up stays world up and is never touched.
- Its own time constant, 150 ms — slower than the position follow, because a
heading error swings the whole frame. The number was measured on the shipped
drone rather than guessed: hovering, its heading wanders slowly enough that
no exponential filter takes that out, and at full stick it yaws at 200 °/s,
where the steady lag is exactly
tau× that. This one keeps the subject astern through a turn while still letting you see that it is turning. - Orbiting composes in the subject's frame. Drag the camera over its left shoulder and it stays over its left shoulder through every turn. Zoom is still just a shorter offset.
- A nose pointed at the sky keeps the last heading — the ground-plane projection is noise there, and something tumbling must not whip the camera.
- An orthographic view follows by translation alone. An axis view is a direction of view; rotating it would mean it is no longer the view you asked for. Following a subject that never turns is therefore exactly what it always was, in either projection.
It works while playing, which is the point: a drone you fly with W/A/S/D
leaves the frame in about two seconds otherwise. Shift+F is answered even while
a script owns the plain keys — deciding to watch what you
are flying is a view command, not teleop. Deselecting pauses the chase (there
is nothing to chase, and the view stays where the chase left it); selecting
something again resumes it. It is session state, not a saved preference, and a
document can ask for it on open — see editorView.
Markers. Cameras and lights render nothing, so each one gets a billboarded
icon at a constant screen size, tinted with the accent colour while selected.
Every kind has its own shape — a camera body, a sun for directional, a bulb for
point, a beam for spot, a ringed core for ambient, a dome over ground for
hemisphere, a speaker for a sound, an open hinge for a joint, a car in profile
for a vehicle — so a scene reads without clicking anything.
Clicking an icon selects its owner, and it wins over geometry behind it — the
icons draw on top, so picking follows what you see rather than raw depth order.
The artwork is SVG parsed at startup by threepp's SVGLoader and embedded as
source in ViewportMarkers.cpp, so the editor does not depend on finding asset
files at runtime. Selecting a camera additionally shows its frustum
(CameraHelper), which tracks fov/near/far edits live, and renders its view into
the camera dock beside the bottom panel. Objects that bound to
nothing get no selection box — an empty Box3 would leave BoxHelper showing a
degenerate speck at the origin.
Splines. Add ▸ Spline drops a curve whose control points are ordinary scene
nodes — drag them with the gizmo, insert and delete them, and the sampled curve
redraws live. See Splines in userData.
Trees. Add ▸ Tree grows a procedural tree — four species presets (oak, pine,
birch, willow), a seed that picks the individual, and forty-odd generator knobs
that regrow the trunk and canopy live as you drag them. Bark and leaf textures
are procedural too, and the whole thing saves as ordinary meshes. See
Trees in userData.
Sound. Add ▸ Sound places an emitter — a speaker marker, an audio file, and
a distance falloff drawn as min/max rings on the ground while it is selected.
Audition hears the file without pressing Play; Play spatializes it through
miniaudio and the sound follows whatever moves its object. See
Sounds in userData.
Vehicles. Import a car, point the Vehicle section at its four wheel
meshes, press Play, and drive it with W/S/A/D — chassis, wheels, track
and wheelbase are derived from the picks, so no number needs typing. See
Vehicles in userData.
Generators. A scene can carry inline Python that BUILDS it: select Scene,
write a rule, press Regenerate, and what the script creates becomes ordinary
saved scene content. Editable in VS Code with completion, where a save re-runs it.
This is how you author at counts a mouse cannot — a few hundred instanced props
placed by rule, reproducible from a seed. See
Generators in userData.
Hierarchy. The full Object3D tree, selection synced both ways with the
viewport, double-click to rename, drag-and-drop to reparent (undoable, world
transform preserved), and a right-click menu with Add ▸ / Rename / Copy Name /
Duplicate / Focus / Delete. Copy Name puts the node's name on the clipboard —
it is what every by-name reference (a vehicle wheel pick, a joint's Body B)
wants pasted.
Inspector. Object flags, transform (rotation shown in degrees), material (type, colour, emissive, roughness, metalness, opacity, transparency, side, wireframe, flat shading, six texture slots with previews), read-only geometry counts, light parameters, camera parameters, spline curve parameters, attached scripts and physics. Every edit is undoable, and a drag collapses into a single undo step.
Bottom panel. A console showing loader and exporter warnings, an asset browser (double-click a file to open/import/assign), the live sensor readout, and — while any are open — the Script Editor's scripts. Console is the first tab: it is where import results and errors land. Collapsible, resizable by dragging its top edge, and it runs to the left edge of the window.
Camera dock. The band beside the bottom panel, under the inspector, renders the selected camera live. It collapses with the bottom panel and paints itself when nothing is selected, so that corner is never a sliver of viewport too small to see into or click in.
Panel sizes. The hierarchy and inspector are resized by dragging their inner edge, the bottom panel by dragging its top edge, and all three sizes persist in the settings file. Every one is clamped on read and on drag — the bottom panel against the window itself, so a settings file written on a bigger monitor cannot leave a small one with no viewport at all. The hierarchy also scrolls horizontally, because a deep tree indents past any fixed width and the panel cannot be widened past the screen.
Files. New / Open / Save / Save As, Import Model, Set Environment
(.hdr / .exr), Recent Files. All dialogs are a first-party ImGui file browser — no native
dialog library. Drag-and-drop onto the window works too: .json opens a scene,
.hdr/.exr becomes the environment, model files are imported, .py attaches to the
selected object as a script, and image files become the selected mesh's
base-colour map.
Play mode. ▶ snapshots the scene and starts the runtimes, ⏸ suspends stepping, ⏹ stops them and restores the snapshot. The inspector is read-only while playing and a banner sits over the viewport.
The transform gizmo is parked for the whole session: Play detaches it, and the toolbar's Select/Move/Rotate/Scale and Local/World buttons grey out with it. Hiding it would not have been enough — attached, it rides a body the solver is moving and keeps offering handles for an edit that Play would only refuse (every document mutation goes through one gate; the greyed menu items are the visible half of it). Stop puts the gizmo back on the selection (re-resolved by uuid across the scene swap) with the mode, the space and the snap it had.
The rest of the authoring layer goes with it. During Play the viewport shows the scene, not the editing furniture, so these are hidden for the session and restored on Stop:
| hidden while playing | why |
|---|---|
| the selection bounding box, and the outline around a picked instance | it says what you are editing |
| the viewport marker icons — cameras, lights, spline points, sensors | same, and an icon you cannot see is not clickable either: picking through the icons is switched off with them |
| the selected camera's frustum helper | same |
| the transform gizmo | above |
And these deliberately stay:
| still drawn while playing | why |
|---|---|
| the sensor point cloud | it is what the scene is doing — play data, not furniture |
| the physics collider overlay | a debug view that only means anything while a world exists to debug |
| the grid and the origin axes | View-menu preferences somebody set on purpose |
| the play banner and the status bar | UI, not scene |
Picking itself still works, and the hierarchy still shows what is selected —
watching what the simulation does to an object is half of why anyone presses
Play. Selecting something during play is fine too: the outline it builds
arrives hidden and appears on Stop. The --screenshot pass over a document hides
the same layer through the same predicate, for the same reason.
File ▸ Open Example lists the scenes that ship inside the binary. They go
through the same unsaved-changes guard as Open and the same loader, and they
arrive as an untitled document — no path, not dirty — so the first Save asks
where to put it and nothing can write over an example in place. The JSON is
embedded as source for the reason ViewportMarkers.cpp embeds its SVG: the
editor does not depend on finding asset files at runtime. Each example's JSON is
its own GENERATED translation unit (apps/editor/ExampleScene*.cpp), written by
the program that authors that scene; the registry listing them —
apps/editor/ExampleScenes.cpp — is hand-written, so adding one does not mean
regenerating the others.
A physics drone you fly through five glowing goal rings, in a generated arena of pillars. It exists because every feature below is easy to describe and hard to believe until something is using all of them at once:
It opens ready to fly. The document carries an
editorView: 7 m behind the
drone and 2.4 m above it, looking down the course with the first gate behind the
drone, the rest of the rings receding and the beacon standing at the far end. It
also carries editorFollow = Drone, so the drone is selected and
Follow Selection is on — and because Follow keeps the offset the
camera has when the document opens, that one authored vantage is the chase
camera. Press Play and the view goes with the drone — and because the offset is
kept in the drone's heading frame, A/D turn the camera too: it comes round
behind the new heading instead of watching the drone leave sideways.
| what you see | what it is |
|---|---|
| the drone holds a 2.2 m hover, hands off, with a slight wobble | fixed_update applying apply_force/apply_torque through rigid_body_from_object, on the physics clock so the gains mean the same thing on any machine |
| it knows how high it is | a short downward threepp.editor.raycast with ignore=self — without the ignore the ray starts inside the hull and finds it at range zero |
| the wobble itself | the attitude damping closes on the authored IMU's reading through imu_from_object — noisy, seeded and rate-gated. It is not a controller that has never met a sensor, and the seed makes the same wobble happen on the next run |
| the coloured cloud sweeping the floor | a VLP-16 LIDAR authored on the drone, drawn by the Sensor Point Cloud overlay (on by default) |
| a ring turns amber and the beacon warms toward gold | each ring's hole is an invisible trigger volume; its on_trigger_enter flashes the torus and calls the scoreboard's live instance through script_from_object. Take all five and every ring pulses |
| the hull flashes when you clip a pillar | on_collision_enter, which nobody had to tick a box for |
| the floor, walls, pillars and crates | a seeded generator on the scene root. Its committed output ships in the document — opening a scene never runs anything — and the rule travels with it, so Regenerate rebuilds the arena from SEED without moving the hand-placed course |
Controls, which the drone's script also prints to the console at Play:
| key | |
|---|---|
W / S |
forward / back |
A / D |
yaw left / right |
Q / E |
strafe left / right |
R / F |
climb / descend — the stick sets a height, and lets go holding it |
Remember that a script reading the keyboard takes the plain keys
off the editor for the rest of the session; Ctrl+S and friends still work, and
so does Shift+F — switching the chase camera on and off is the one view command
you want while flying.
Without the PhysX build there is no body to push, no ray to cast and no IMU
to read, and those names are absent from threepp.editor rather than present
and answering None. The scripts ask with getattr before reaching, so the
scene loads, renders and plays; the drone sits where it was put, and the console
says Hover Arena needs the PhysX build to fly once. Without Python nothing
runs at all — the scene is still a scene, the scripts are still saved, and the
Script section says which build would run them.
The document is regenerable end to end: apps/editor/tools/HoverArenaAuthor.cpp
builds it through the editor core, runs the generator exactly as Regenerate
does, and writes both the .json and the translation unit that embeds it.
tests/extras/EditorExampleScene_test.cpp loads that same embedded string and
flies it headlessly, and --selftest opens it through the menu's own code path.
A working sawmill yard that runs itself. Nothing here is flown or driven: eight logs come off a gravity rack one at a time, ride a conveyor into a saw that cuts them in half, and stack against a flap at the far end — and the whole sequence is six inline Python scripts and two coroutines. Where Hover Arena is a thing you operate, this is a thing you watch, and it exists to show what coroutines do to code that takes time.
It opens on the mechanism, untethered. The document's
editorView frames the yard
square-on — rack to stack, left to right — and that is all it authors: no
editorFollow, so the camera is the ordinary orbit from the first frame. The
machine is fixed and the vantage is composed; a tether was tried and bought
nothing the framing did not already give (and following a log parks you at
the far end looking at a stack).
| what you see | what it is |
|---|---|
| the gate lifts, one log rolls out, the gate drops | one coroutine, read top to bottom: until(bay clear) → clamp → wait(0.35) → drive open → wait(1.4) → drive shut → until(the encoder says shut). The waits are SIMULATED seconds, so the yard runs the same number of substeps on any machine |
| the blade cuts the log in two | every log is authored as two halves on a breakable fixed joint with a slot between them, and the blade is thicker than the slot: it meets the halves on their inner faces, which is a wedge, and the joint gives way. The halves part around the blade and ride on as two. The blade itself is a body on a driven revolute joint (zero stiffness, a velocity target — a motor), so it is genuinely spinning, genuinely in the way, and visibly bogs down as it bites |
| the halves separate, they do not fly | the blade's collider is thin (9 cm) though its disc is not, and the logs have zero restitution. A fat collider bites harder but leaves the solver a deep overlap to push out in the substep the joint snaps, and the halves inherit that shove instead of the belt's momentum — off the side of the belt, in the first version of this. The kerb rails down both edges are the backstop, and the headless test watches every log body every substep for one straying past them |
| the counter still says eight | the bay counts one named half and skips the other, and latches on the name — a log is one log however many bodies cross the volume and however many times they cross it |
| the second log does not follow it out | the hold-back clamp, a second driven joint the same coroutine works. A packed queue on a slope moves as one body, so timing alone cannot release one log — this is an escapement, and it is why there are two joints |
| the gate knows it is shut | an Encoder authored on the joint node — a joint sensor on a joint needs no joint name, the node is the reference |
| each arrival counted, with a throughput | an invisible trigger volume under the saw. on_trigger_enter stamps threepp.editor.time.sim_time per log, so logs per second is a fact about the yard rather than about the frame rate |
| the yellow box that flashes as one lands | threepp.editor.draw_box, for the frame it lasts |
| the flap at the end of the belt, and the green line that appears at its mount when cargo leans on it | a breakable joint carrying a Force/Torque sensor. fixed_update reads the load cell on the physics clock and draw_line draws the force vector — but only above a quarter of the break threshold, so the instrument shows up exactly when something is pushing and reddens toward the snap. (Idle, it drew a wobbling stick nobody could name.) One belt-driven log leans the flap open and walks through |
| hold SPACE and the rack pours out | the override folds is_key_down into the coroutine's until() and skips the escapement, so the whole rack goes onto the belt at once. The stop bar's breakable joint and its on_break() — which marks the mission failed through script_from_object — are the guard rail against what that produces |
| the lettering on the sign | the editor's TEXT authoring — TextConfig, glyphs baked into the mesh, so it reads with no font file present. Flat (depth 0): extruded, "TIMBER YARD" alone costs 1.5 MB of vertices |
| the mission itself | a second coroutine on the yard sign: settle, then for i in range(8): yield self.release_one(i) — a NESTED generator per log, which costs no frame of its own — then one report on both clocks, sim_time against wall_time |
The scripts print to the process stdout, not the console panel, so a headless run reports its own throughput.
A note on the stop bar, because the scene used to claim more. Before the saw existed, a SPACE-forced pour-out loaded the flap at the end of the belt harder than an authored mission did, and the example advertised "two logs together snap it". With sawn cargo that is no longer true and the measurements say so: a full mission peaks at 267 N there, a forced pour-out at 170 N — a crowd of half-logs arriving at a flap that is already swinging leans on it less than one log shouldering it open does. Calibrated by outcome the two overlap completely, so nothing separates them and the headless test does not pretend otherwise. What demonstrates a joint giving way — eight times a run, on camera — is the saw.
Every threshold in the scene was measured per substep rather than guessed, and
one of them had to be settled by outcome instead: PxConstraint::getForce
reads zero the instant a joint breaks, so the spike that breaks a joint is
exactly the spike a probe never sees.
Two things this scene is not. It carries no procedural trees and no log
cabin, though both ship with the editor and both were built, exported and
measured for it: a generator emits a BufferGeometry and the serializer writes
every vertex of one as text, which puts the smallest cabin that is still a
building at ~985 KB and a pine that still reads as a tree at ~340 KB — against a
whole document of 352 KB. Sharing one generated pine between six trees (which
the serializer does support — a geometry is written once per uuid) does not
rescue it either, because what is left is six copies of the same tree. So the
clearing and the yard office are primitives, which serialize as their
parameters: a cone costs nine numbers however finely it is tessellated.
The document is regenerable end to end by
apps/editor/tools/TimberYardAuthor.cpp, and that program is byte
reproducible — every uuid is derived from the scene path of the node carrying
it, textures included, so regenerating an unchanged scene leaves the committed
file untouched and a diff is something somebody authored.
timber_yard_author --check authors the document twice and compares the bytes.
| key | action |
|---|---|
W / E / R |
translate / rotate / scale gizmo |
Q |
toggle local / world space |
Shift (hold) |
snap while dragging |
F |
frame selection |
Shift+F |
follow selection — chase camera, works while playing |
Del |
delete selection |
Esc |
deselect |
Ctrl+D |
duplicate |
Ctrl+Z |
undo |
Ctrl+Y, Ctrl+Shift+Z |
redo |
Ctrl+N / Ctrl+O / Ctrl+S |
new / open / save |
Shortcuts are suppressed whenever ImGui wants the keyboard (a text field has focus, a modal is open), and picking is suppressed whenever ImGui wants the mouse.
The editor's document format is three.js "Object" JSON, metadata version
4.5 — exactly what ObjectExporter writes and ObjectLoader reads. There is
no editor-specific format and no sidecar file.
Two consequences worth knowing:
- Every uuid (object, geometry, material, texture) is preserved across a round-trip, which is what lets the editor re-resolve the selection after a reload or a play-mode restore.
- Output is deterministic: saving an unchanged scene twice produces byte-identical files, so documents diff cleanly in version control.
Saved documents embed texture images as base64 PNG data-URIs, so a scene file stands on its own.
The grid, origin axes, selection outline and transform gizmo have to be in the
scene graph to be rendered, but must never end up in a saved file.
ObjectExporter has no exclusion filter, and adding one would have meant
changing a format-level API for a tool-level concern. Instead the editor keeps
all of them under a single overlay Group registered with
SceneDocument::addEditorOnly(), and the document detaches that node for the
duration of every export (save and play snapshot) and re-attaches it
afterwards. The cost is one detach/attach per save; the benefit is that
ObjectExporter stays a pure serializer.
Two optional keys on the scene root say how a document wants to be looked at.
They are read by the open paths — File ▸ Open, File ▸ Open Example, a .json
dropped on the window, a scene named on the command line — and by nothing else:
userData["editorView"] 0,4.6,21@0,2.2,14 (camera position @ orbit target)
userData["editorFollow"] Drone (an object name)
editorView is the same px,py,pz@tx,ty,tz the --shot command line speaks,
parsed by the same function (EditorApp::parseViewSpec), so a vantage found by
orbiting can be copied into a scene and back out again. It places the camera and
the orbit target in a perspective view; a malformed value is a console line and
is otherwise ignored. editorFollow names an object to select and
follow on open — a name that is not in the scene is a console line
too.
Two rules make them safe rather than surprising:
- They apply when a document is OPENED, and never again. In particular they are not applied by the scene-replaced listener, which is also what Stop goes through: restoring the play snapshot must never teleport the camera away from wherever the user drove it while watching the simulation.
- A document that says nothing gets nothing. No
editorViewleaves the camera where it was (an example, having nothing to preserve, frames itself instead); noeditorFollowswitches following off, since following belongs to a subject and a new document is a new subject.
A third key on the scene root carries what the View ▸ Renderer Settings
panel was set to when the document was saved — exposure and tone map, render
scale and upscaler, GI and denoiser, fog, bloom, clouds, depth of field, the
camera triplet. It is written on every save and read by the same open paths as
editorView, plus File ▸ New:
userData["render"] renderscale=0.5;bloom=0.4;heightfog=1;fogdensity=0.05
RenderConfig::encode() / decode() own that format — the same flat
key=value;key=value string as physics below, for the same reason (userData
round-trips scalars only), and with the same rule that an unknown key is ignored
on read.
What is different from every other config in the editor is that the string is a
difference from a baseline, not a full state. The baseline is
RenderConfig::capture() of the renderer as the editor set it up, taken once in
EditorApp's constructor and kept in renderDefaults_:
- On save, only the fields that differ from that baseline are written. A
document nobody re-lit therefore carries no
renderkey at all, and the four keys above are the whole of what one that was re-lit says. - On open, the text is layered over the same baseline key by key. A document that says nothing about fog gets the editor's fog — not the ground mist the previously open document left on the renderer. That inheritance is the bug the baseline exists to prevent, and it is why File ▸ New applies it too.
Two things are deliberately not in here. The diagnostic controls (G-buffer
debug view, overlay layer) are about inspecting a frame, not authoring one. The
sensor-simulation controls (lens distortion, sensor noise) describe an
instrument pointed at the scene, and belong to the per-object sensor
authoring SensorConfig already carries.
On Vulkan the editor's baseline sets render scale 0.8 — the deferred shade
is the frame's dominant cost and it scales with pixels, so an authoring viewport
takes 64% of them for a difference TAA reconstructs most of the way back. The
renderer's own default is still 1.0 for every example and test, and the Render
scale slider (or a saved renderscale=) overrides it per document.
Per-object physics rides in object.userData["physics"], so it serializes with
the scene for free. userData round-trips scalars only (bool / int / float /
double / string — see ObjectExporter::writeUserData), so a nested JSON object
is not available. The value is therefore one flat, deterministic string:
body=dynamic;shape=convex;trigger=0;mass=12.5;friction=0.8;restitution=0.35;young=1000000;poisson=0.45;voxel=10;iterations=20;selfcollision=0;hulls=16;hullverts=64;voxels=100000
PhysicsConfig::encode() / decode() own that format. Unknown keys are ignored
on read, so a document written by a newer editor still loads — and a document
written by an older one loads too, since a missing key keeps its default. A
disabled body removes the entry entirely rather than writing enabled=false, so
turning physics off leaves no trace in the file. Every key is written whatever
the body type, so switching a body from soft to dynamic and back does not
quietly discard the settings the other type was using — trigger=1 on a body
you flip to soft and back is still trigger=1, even though the checkbox is
hidden in between (see Trigger volumes).
The Shape picker decides what a body actually collides with. The primitives (Box, Sphere, Capsule) and TriMesh (a static/kinematic-only triangle mesh) are exact and cheap; the interesting cases are the convex ones, because PhysX dynamics can only use convex shapes and most real geometry is not convex.
-
Auto reads the geometry: a stock Box/Sphere/Capsule primitive gets its analytic shape, a static shaped mesh gets a triangle mesh, and a moving shaped mesh gets a single convex hull.
-
Convex is one convex hull — the smallest envelope around the vertices. It roofs over any concavity, which is fine for a rock and wrong for a mug.
-
Convex Pieces runs V-HACD convex decomposition: the mesh is split into several convex hulls that together approximate the concave shape, welded into one rigid body. This is what lets a mug hold water, a pipe stay hollow, a chair drop something between its legs. Three parameters ride in
userData, always written:Field Meaning Max Hulls ( hulls)The most pieces V-HACD produces (default 16). More hulls hug a concave shape more closely, at more narrowphase cost. Verts / Hull ( hullverts)Vertices per hull (default 64, PhysX's GPU-compatible ceiling). Voxel Res ( voxels)V-HACD's voxel grid resolution (default 100000). Higher resolves thin features but costs cook time.
Imported models collide as themselves now. An imported model is a Group
with sub-meshes, and a Group has no geometry of its own — so a dynamic one
used to fall back to a 1 m unit box at the origin (a chair colliding as a
cube). Authoring physics on the Group now gathers its sub-meshes into one
compound convex actor: Auto (moving) or Convex gives one hull per
sub-mesh, Convex Pieces decomposes each sub-mesh and divides the hull budget
across them (with a log line when it is split, so nothing is truncated
silently). Each sub-mesh's transform and scale bake into its shape's local pose,
so the compound matches where the parts actually are. A static Group is
unchanged: it still collides as its triangle-mesh subtree, which is exact.
Cooking is cached. V-HACD on a dense mesh is seconds, so a decomposition
is keyed on the geometry uuid and its parameters and reused for the rest of
the Play session — duplicates of one model decompose once, exactly like the
soft-body tet cache. The cook time is logged per geometry (so a Play that took a
moment says why), and a mesh over ~200k triangles logs a warning before it
starts rather than freezing silently. A build without V-HACD (no PhysX, or the
v-hacd vcpkg dependency absent) falls back to a single hull with one log line,
so Convex Pieces still simulates — just without the concavity.
The cooked hulls are drawn by the Physics Debug overlay (PhysX's own collision-shape visualization), so the quality of a decomposition is visible in the viewport, not just inferred from behaviour.
Tick Trigger and the body stops colliding and starts reporting. Its shapes
are cooked with PhysX's eTRIGGER_SHAPE instead of eSIMULATION_SHAPE — the two
are mutually exclusive on one shape — so bodies pass straight through it and what
it produces instead is an overlap event: who came in, and who left. That is the
goal zone, the checkpoint, the kill plane, the "the player reached the door"
region. A script on either side hears about it through
on_trigger_enter / on_trigger_exit;
with no script anywhere it is simply a body that collides with nothing.
trigger=0/1inuserData, written every time like every other key, so ticking it, switching the body to Soft and switching back does not lose the tick.- Static, Dynamic and Kinematic. A static volume is the common case and the cheap one; PhysX allows a trigger shape on any rigid actor, so a moving trigger (a zone carried by a lift, a proximity bubble on a vehicle) works too. A dynamic trigger still falls — it has mass and gravity, it just has nothing to land on.
- Hidden for Soft. A deformable volume's collider is the tetrahedral mesh PhysX cooks from the geometry, which is not a shape anything can raise the trigger flag on, so the checkbox disappears the same way the Shape picker does.
- A trigger is not a collider, at all. No contacts, so
on_collision_enternever fires for an overlap with one and aContactSensornever measures one. If you want an object to both block and report, use two objects: a collider and a trigger over it. - Scene queries still see it. A trigger shape keeps
eSCENE_QUERY_SHAPE, sothreepp.editor.raycasthits it like anything else — which is what lets a script find a zone before entering it, and what a ground check has to know when casting through one. - The overlay draws it. The Physics Debug overlay shows a trigger volume exactly as it shows a collider (measured: PhysX's collision-shape visualization emits the same lines either way), so a volume you cannot see bodies bouncing off is still visible in the viewport.
Triangle meshes cannot be triggers, and this is the one place the editor substitutes something. PhysX refuses the flag outright — "triangle mesh and heightfield triggers are not supported" — because a trigger asks whether a point is inside it and a triangle soup is a surface with no inside. Rather than cook a trigger that silently does not exist, the play session falls back to a convex hull and says so in the console, once, naming the object. That covers:
| authored as | cooked as | why |
|---|---|---|
| TriMesh on a mesh | one convex hull of that mesh | the flag would be refused |
| Convex on a static mesh | one convex hull of that mesh | a static Convex is normally cooked as an exact triangle mesh, which is free for something that never moves — and not available here |
| Auto on a static shaped mesh | one convex hull | Auto resolves to TriMesh for a static shaped mesh |
Auto/TriMesh on a static Group |
one hull per sub-mesh, welded into one compound | the subtree is cooked as triangle meshes |
| Box / Sphere / Capsule / Convex Pieces | unchanged | already volumes |
So a concave trigger volume wants Convex Pieces, whose hulls are volumes already and need no substitution.
body=soft makes the object a deformable volume: PhysX cooks a tetrahedral
mesh out of the object's own geometry and rewrites its vertex positions every
step, so the mesh itself bends, squashes and jiggles rather than moving as a
rigid whole. The Shape picker disappears for a soft body — its collider is
always that cooked volume — and these parameters take its place:
| Field | Meaning |
|---|---|
| Stiffness (Pa) | Young's modulus. ~1e4 is jelly, 1e6 rubber, 1e8 near-rigid. Logarithmic drag, because the useful range spans four decades. |
| Poisson Ratio | 0 squashes freely, →0.5 preserves volume. |
| Resolution | Voxels along the longest axis of the simulation mesh. Higher = finer deformation and more solver work. |
| Iterations | Solver iterations per step. |
| Self Collision | Whether folds of the body collide with each other. |
| Mass, Friction | As for a rigid body. Restitution has no soft-body equivalent and is hidden. |
Two things follow from PhysX's implementation, and the editor deals with both:
- Soft bodies run on CUDA only.
PhysicsPlaySessiontherefore switches itsPhysxWorldto GPU dynamics as soon as the scene contains one — and only then, since a CUDA context is not free. On a machine without a CUDA device the world is rebuilt with the GPU off and the rigid bodies play on their own, with one line in the log; Play is not failed over it. - The mesh is the simulation output.
addSoftBodybakes the world matrix into the geometry and zeroes the object's local transform, then writes world-space vertices into it each step. Nothing special is needed to undo that: the play snapshot restores geometry along with everything else, so Stop hands back the rest pose and the authored transform.
Cooking is the expensive part of start(), so identical geometries share one
cooked mesh — keyed on the geometry uuid, and only for an unscaled object, since
the cached path re-applies rotation and translation but not scale.
A soft body needs a closed, indexed triangle surface to cook from. A mesh with no index buffer is skipped with a log line rather than simulated into nonsense; the stock Sphere, Box, Cylinder, Cone and Torus primitives are all fine, rotated or not.
That last part is not free. The visual mesh is skinned from the cooked tet
mesh, and the tet mesh is built from a remeshed surface that does not quite
reach a sharp corner — the apex of a cone, or any corner of a rotated box. Two
things in SoftBody make that come out right, and both were originally wrong:
a vertex outside every tet binds to the nearest one by extrapolation (so it
sits exactly where it was authored, rather than snapped onto the hull and
visibly rounded off), and the index-for-index fast path is taken only when the
tet and visual positions genuinely correspond — equal vertex counts alone
are a coincidence waiting to scatter the mesh. tests/extras/SoftBody_test.cpp
pins the rest pose to under a millimetre for both.
Animation clips themselves already round-trip through the three.js JSON
(ObjectExporter/ObjectLoader serialize Object3D::animations), and every
model loader attaches a file's clips to the imported root group. The editor
adds authoring on top, in object.userData["animation"], same flat format as
physics:
autoplay=1;loop=1;speed=1.25;clip=Walk
AnimationConfig owns the format. The Animation inspector section appears
on any object that carries clips: pick the clip, toggle Play on Start and
Loop, set the speed, and Preview it right in the viewport — preview records
every node transform (and morph influences) up front and restores them on stop,
so it never dirties the document or the undo stack.
During Play, AnimationPlaySession (always registered) walks the scene and
plays the authored clip on every object with autoplay on — the default for
anything that has clips, so an imported character moves the first time you
press Play. The play snapshot restores all poses on Stop.
Add ▸ Sound creates a plain Object3D carrying userData["sound"] — a
sound has no geometry, so what shows it in the viewport is a speaker marker,
billboarded like the camera and light icons. Any other object can become one
too: load a file from the Sound inspector section and it gains the same two
entries.
Two entries, because the file needs its own key — a Windows path contains the
= and ; the flat format uses as delimiters, the same wall RobotConfig and
ScriptConfig hit:
userData["sound"] positional=1;autoplay=1;loop=1;volume=1;rate=1;minDistance=1;maxDistance=10000;rolloff=1;model=inverse
userData["soundFile"] C:/audio/rain.wav
SoundConfig owns the format. The path is stored exactly as the file dialog
handed it over (absolute, forward slashes), like the script and URDF references;
a relative path in a hand-edited document resolves against the document's own
directory, so a scene and its audio can be moved together.
The Sound section carries the file row, Positional (off makes it ambience
at a constant level — music, room tone), Play on Start, Loop, Volume,
Playback rate, and, for a positional sound, the distance falloff: min/max
distance, rolloff and the distance-model curve (none / inverse / linear /
exponential, mirroring the Web Audio panner models). Max distance is a hard
audibility bound under every model: the play session eases the sound out over
a short band before max and silences it past it — miniaudio alone merely clamps
the falloff there, which left an inverse sound faintly audible across the
whole map. Under none that makes the sound a constant level within range and
silent outside (a zone ambience); min distance and rolloff shape nothing there
and are greyed. The falloff entries are hidden rather than dropped when
Positional is off, so switching it back on does not reset a tuned curve. Remove sound drops both entries as one undo
step.
Selecting a positional sound draws its min and max distance rings on the
ground — two line circles under the editor overlay, so they are never saved and
never picked. maxDistance defaults to miniaudio's 10000, which means "no
limit" rather than "ten kilometres", and a ring that big draws as a straight
line across the viewport and says nothing — so past 1000 m the ring is simply
left out. The number is still in the inspector.
Audition (edit mode only) plays the file without pressing Play, on the editor's own listener. It is deliberately flat: the authored volume and rate, no spatialization. It answers "is this the right file, at the right level"; how it sounds from over there is what Play is for. The audition stops on the button, on a selection change, on entering Play and on a scene replace.
During Play, AudioPlaySession walks the scene and builds one PositionalAudio
per authored node (parented to it with addRef, so the spatialization follows
whatever moves the object) or a plain non-spatial Audio when positional=0.
The listener rides the perspective viewport camera. Sounds with autoplay on
are rewound and started; on Stop every sound is unparented and destroyed before
the engine is, and the play snapshot takes the rest back.
No device, no problem. AudioListener's constructor throws when
ma_engine_init fails — a headless CI machine, a driver holding the device in
exclusive mode — and each file load throws on its own. Both are caught: the
failure is logged to the console and the session becomes a no-op (or skips the
one bad file), so a scene whose audio moved still plays everything else. Same
discipline as the soft-body CUDA fallback.
The whole feature is gated on -DTHREEPP_WITH_AUDIO=ON (the default). A build
without it still authors sounds — the config is plain userData — and shows
"Built without audio - authoring only" where the audition button would be, so a
document does not lose its sounds by being opened on the wrong build.
miniaudio decodes .wav, .mp3 and .flac with no third-party code, and those
are exactly the three the file dialog offers.
Add ▸ Joint creates a plain Object3D carrying userData["joint"]. A
joint has no geometry — what shows it in the viewport is a hinge marker, and
what edits it is the ordinary transform gizmo, because the node's transform
is the joint frame: anchor at its origin, hinge/slide axis along its local X
(the same convention the articulation builder uses). Its parent chain is body
A — the nearest ancestor with a rigid body governs it, the same walk a sensor
resolves its attachment with — and the other body is referenced by name;
empty means the world (a pendulum pivot, a door frame bolted to nothing).
Because each joint is its own child node, a chassis carries four wheel joints
as four children, and deleting a joint is deleting a node.
Two entries, because a scene-object name is user-typed and free to contain the
= and ; the flat format uses as delimiters — the same wall the sound file
hit:
userData["joint"] type=revolute;limited=1;lower=-0.5236;upper=0.5236;coney=0.785398;conez=0.785398;stiffness=0;damping=0;maxforce=1000000;target=0;velocity=0;breakforce=0;breaktorque=0;collide=0
userData["jointBody"] Post
JointConfig owns the format. Angles are radians in the document and degrees
in the inspector, the same split the robot joint sliders make. Presence of the
entry is what makes the node a joint (writing it never omits defaults), and
every field rides along whatever the type is, so switching type and back does
not reset the ones the other type hides.
Five types: Fixed welds body A to body B; Revolute hinges about X, with
optional lower/upper limits and a PD drive (a door, a wheel, a pendulum);
Prismatic slides along X, limits and drive in metres (a piston, a drawer);
Spherical is a ball socket with an optional swing cone (cone Y/Z
half-angles; twist stays free); Distance is a tether that keeps the anchors
within min/max metres, its stiffness/damping pair acting as the tether's spring
instead of a motor. Drives are force mode, so stiffness/damping/max-force
mean the same as they do in userData["articulation"] — and each half gates
its own input: the drive force is stiffness·(target−x) + damping·(velocity−v),
so Target acts through stiffness and Velocity through damping. The
inspector makes that structural: a Driven checkbox (a Spring one for
Distance) that seeds both to the articulation defaults (500/50) when ticked —
"driven" is not a stored flag, it is stiffness/damping > 0, exactly the
condition the play session builds a drive under. The seeding also matters
mechanically: the sliders are logarithmic, and a log drag anchored at zero
compresses so hard near the bottom it can never escape it. Every type can be
made breakable (a checkbox seeding break force/torque the same way; off =
unbreakable) and can opt back into collision between the two bodies it
joins — off by default, since bodies meeting at a joint overlap at the anchor
and contacts there fight the constraint.
The Joint inspector section leads with what actually connects ("Connects Gate to Post"), resolved the way the play session will resolve it. Body B is a combo over the bodies that exist to be picked — every enabled rigid body and every simulated robot, ancestors excluded (they are body A's side) — because the reference is a scene object: choosing one that exists is the feature, typing one that resolves is the error path. Selecting a joint draws its axis helper under the editor overlay: the anchor cross, the X axis with an arrowhead (both ways for prismatic), the rotation ring for a hinge, two rings for a ball — constant screen size, drawn through geometry, because the anchor usually sits inside the body it hinges.
During Play, PhysicsPlaySession builds the authored joints last, after
every rigid body and articulation exists: body A from the parent walk, body B
by name — and either side may be an articulation link (a
PxArticulationLink is a rigid actor), which is how a prop attaches to a
robot's gripper. One PxD6Joint configured per type backs everything except
Distance, which is PhysX's own PxDistanceJoint. An unresolvable joint — a
missing name, a body with no rigid actor — is one console line and no joint,
never a refused Play. Stop releases the joints before the world, and the play
snapshot takes the scene back.
While playing, the Physics Debug overlay (View menu) draws the joints the solver is actually enforcing, from PhysX's own render buffer: an RGB frame triad at each constraint's live anchor and its limit geometry beside it, in PhysX's own colours. That is the running counterpart of the authored axis helper, which Play hides with the rest of the authoring layer — and a joint that broke vanishes from the overlay, which is exactly the news.
Sensors sit on joints too. Author an Encoder or a Force/Torque sensor on
the joint node itself and it reads that joint — no joint-name picker (the
node is the reference): the encoder reports the joint coordinate and its rate
(twist angle, slide displacement, tether distance), the load cell reports the
solver's constraint force, PxConstraint::getForce, in world axes — the
maximal-coordinate twin of the articulation cache's incoming joint force. Same
noise/quantization/rate model as their articulation forms, same Sensors-panel
traces, same CSV.
A breakable joint's failure load is on the record. The load that snapped a
joint used to be unobservable — the sample stream just stopped somewhere below
it. It is captured now, at the one point where it is defined: PhysX reports a
break during fetchResults of the breaking substep, while
PxConstraint::getForce still holds that step's solver result, and the break
callback latches it (Joint::breakWrench). The load cell's first sample after
the break carries exactly that wrench — the true failure load, necessarily
past the authored threshold — and every later sample reads zero, because a
broken joint transmits nothing. (Left alone, getForce would freeze on the
breaking value and report it as live load forever; Joint::reactionForce
answers zero once broken instead.) The console line names the measured load
too: "broke at 450.3 N / 0.0 N*m".
Scripts. threepp.editor.joint_from_object(obj) on the joint node returns
the live Joint handle: position / velocity (the joint coordinate and its
rate), broken, reaction_force / reaction_torque, break_force /
break_torque (the latched failure load — zero until broken), and
set_drive_target / set_drive_velocity — the setpoints act through the
authored stiffness and damping respectively, exactly like the inspector's
fields. A script on the joint node may also define on_break(), called
once when the constraint exceeds its break threshold; every break also gets a
console line, listener or not. on_break() takes no payload on purpose — the
handle is the named way to the facts, so joint.break_force inside the
callback IS the failure measurement (same doctrine that moved the scene out of
start()'s argument list). The standalone wheel binds the same runtime
type as threepp.Joint (with Joint.Params / Joint.Type), taking two
RigidBody handles — or None for the world — and a world-space frame;
there the pair is reaction() and break_wrench().
The runtime type is threepp::Joint (extras/physx/Joint.hpp), usable
without the editor: two PxRigidActor* (either may be null, meaning the
world), one world-space frame, one Params struct — the same escape the
examples used to hand-roll per demo.
Import a car, open its Vehicle section (offered on any node with enough
descendant meshes to pick four wheels from), tick Simulate as Vehicle,
point the four combos at the wheels — each opens on a search field, because
an imported car is hundreds of nodes: type a fragment of the name and Enter
commits a lone match — press Play, and drive it with W/S/A/D (SPACE
brakes). A pick may be a group: a wheel authored as an assembly (rim +
tire + caliper) measures as its whole subtree, which is usually what is
right. Duplicate names — four assemblies all called Wheel is the
normal shape of an imported asset — are told apart by an ordinal reference:
Wheel is the first in document order, Wheel#2 the second, and the combos
list exactly those, so every duplicate is individually pickable (a literal
name match always wins first, so a node genuinely named Wheel#2 still
resolves as itself). That closed loop is the whole feature: everything
geometric — chassis dimensions, wheel radius, track, wheelbase, where the
suspension attaches — is derived from the four picked wheels, so an
imported car drives on its first Play with no number typed. Even the facing
comes from the picks: forward is rear axle towards front axle, so a model
authored facing −Z (or +X) drives the way it looks, with no convention
imposed.
The authoring sits on the model's root node, five entries. One flat string
for the scalars, and one plain key per wheel name — user-typed, so free to
contain the flat format's =/; delimiters, the same wall jointBody and
the sound file hit:
userData["vehicle"] drive=direct;driven=awd;auto=1;chassiswidth=1.8;chassisheight=1;chassislength=4.5;wheelradius=0.4;wheelwidth=0.3;track=1.6;wheelbase=2.8;suspensiony=-0.4;mass=1500;travel=0.3;stiffness=35000;damping=4500;friction=2;brake=5000;steer=0.6;throttle=1500
userData["vehicleWheelFR"] WheelFrontRight
userData["vehicleWheelFL"] WheelFrontLeft
userData["vehicleWheelRR"] WheelRearRight
userData["vehicleWheelRL"] WheelRearLeft
VehicleConfig owns the format. Presence of the entry is what makes the node
a vehicle (writing it never omits defaults), the steer angle is radians in the
document and degrees in the inspector, and wheel order everywhere is the
runtime's: FR, FL, RR, RL. Four wheels only — the runtime is a
std::array<4>; trucks and trailers are out of scope as vehicles, but a
trailer is buildable as a second body hitched to the car with an authored
joint (the chassis actor answers to the root's name, so Body B can be the
car).
While auto=1 the stored geometry fields are ignored and the derived numbers
show in the section; unticking Auto Geometry seeds every field with the
derived values (the Joint section's Driven-checkbox pattern) and the fields
become yours. The derivation also compensates for static suspension settle,
so the car rests at its authored ride height instead of sagging on the
first Play. Two drive types, both through the proven PhysxVehicle runtime
the demo ships: Direct (throttle torque straight to the wheels — the
robust default) and Engine (the engine/clutch/gearbox chain). The
transmission is automatic all the way — teleop and scripts only ever
select forward or reverse; no manual-shift control exists in the editor.
Driven wheels are AWD by default (grip before wheelspin), RWD/FWD a combo
away.
Selecting a vehicle draws its wheel rings under the editor overlay: a circle of the effective radius lying exactly on each resolved wheel, plus a forward chevron at the derived chassis centre — real metres, drawn through bodywork, because the rings are the proof of the picks. The root also gets a car icon marker, the same argument as the sensor's: "this drives" is otherwise only discoverable by selecting everything in turn.
During Play, PhysicsPlaySession builds one PhysX vehicle per authored root
— its own chassis actor plus wheel rigs, stepped through the world's substep
loop — and excludes the model's subtree from rigid-body cooking (the
vehicle is the body for all of it, the articulated-robot rule). The model
root mirrors the chassis pose each frame and the wheel meshes mirror the
per-wheel local poses, steer and suspension included; the mesh's authored
orientation is preserved and spin composes on top in chassis space, so no
spin axis is ever inferred from the geometry. An unresolvable pick — a
missing name, a wheel with no geometry — is one console line and no vehicle,
never a refused Play. Stop tears the vehicle down and the play snapshot puts
the model back where it was authored.
While a played scene has a vehicle, the editor's plain keys become the pedals (and the editor shortcuts on those keys yield until Stop, the same rule as a script that polls the keyboard): W throttle, S brake while rolling and reverse once stopped, A/D steer with speed-sensitive slew, SPACE handbrake.
Sensors ride along: an IMU authored on the vehicle root (or anything in the model) resolves to the chassis actor, so acceleration and rates read the drive.
Scripts. threepp.editor.vehicle_from_object(obj) — from the root or
anything under it — returns the live Vehicle handle: set_throttle /
set_brake / set_steer, the reverse selector, speed,
wheel_spin_rates, wheels_grounded, and the chassis position /
rotation. The controls are held pedals, not impulses, so a driving script
sets them when they change rather than every frame — and teleop only writes
while its keys are involved, so the two do not fight.
Importing a .urdf / .xacro routes to URDFLoader instead of ModelLoader
and yields a Robot — an Object3D that additionally owns a joint table. The
inspector shows a Robot section with one slider per articulated DOF,
labelled with the joint's URDF name, clamped to its limits, in degrees for
revolute joints and metres for prismatic ones. Edits are undoable and coalesce
per drag; Home returns every joint to zero as a single undo entry.
A URDF also describes collision geometry, which URDFLoader builds as white
wireframe hulls sitting directly on top of the visual meshes — and leaves
visible. The editor hides them on import and offers Show Colliders to bring
them back. That choice is stored rather than treated as a transient view
setting, because Play rebuilds the robot from its URDF: a non-persisted toggle
would reset itself exactly when you wanted to watch the hulls. Hidden is the
default, so the key is only written when the toggle is on.
None of that articulation can go into the three.js JSON, which knows only about transforms — a saved robot would come back correctly posed but frozen. So the document stores a reference instead:
userData["urdf"] C:/models/lbr_iiwa_14_r820.urdf
userData["jointValues"] 0.3,0,-1.2,0,0,0,0
userData["showColliders"] true (omitted when hidden, which is the default)
Joint values are always radians/metres regardless of what the slider displays.
On load — including the play snapshot restore — EditorApp::rearticulateRobots
re-imports each referenced URDF and transplants the live Robot over the frozen
placeholder, keeping its uuid, name and placement so selection and undo
rebinding still resolve, then reapplies the stored pose. The geometry is still
written to the document, so a scene whose URDF has moved away (or one opened by
another tool) renders exactly as saved — it just cannot be re-jointed, and says
so in the console.
Descendant userData survives the transplant. The swap keeps the placeholder
root's identity and userData, but a sensor (or a physics entry) is often
authored on a link, not the root — and the fresh robot's link nodes are new
objects. So before the swap each placeholder descendant's userData is collected
by node name and re-applied to the same-named node of the rebuilt robot (first
match wins; a name the URDF no longer has is reported, not lost). That is what
makes "an encoder on the wrist link" a durable authoring choice rather than
something a document load quietly drops. The transplant is a free function,
transplantRobot, so this rule is the same headless and in the app.
By default a saved robot is a kinematic prop — Play renders it and its joints
move only if a script drives them. Tick Simulate in the Robot section and it
becomes a PhysX reduced-coordinate articulation while playing: the joints
are real DOFs, gravity and contact act on the links, a PD drive holds the
authored pose, and — the point of the whole thing — the joint sensors below have
something to measure. Stop restores the pre-play snapshot, so nothing the
simulation did survives, exactly like the rigid bodies PhysicsConfig authors.
This is a separate entry from the URDF reference above because its fields are all
scalars, so unlike the path/vector pair it rides the same flat key=value;
format PhysicsConfig uses — one string in userData["articulation"], every key
written every time, unknown keys ignored on read, and the entry removed entirely
when Simulate is off. Its presence is the "simulate this robot" signal.
fixedbase=1;stiffness=500;damping=50;maxforce=1000000;selfcollision=0;iterations=12;density=1000
| key | meaning |
|---|---|
fixedbase |
pin the base to the world (an arm) or let it float free (a quadruped, a drone) |
stiffness, damping |
the per-joint PD drive that holds the authored pose; 0 stiffness is a passive/force-controlled robot |
maxforce |
the drive's per-joint effort ceiling |
selfcollision |
whether links of the same robot collide (off by default — the primitive colliders overlap at the joints) |
iterations |
solver position iterations |
density |
fallback density for links whose URDF gives no <inertial><mass> |
The colliders PhysX simulates are an approximation: box/sphere/capsule for a
<collision> primitive, and a link's <mesh> collision by one convex hull
of that mesh — not the visual meshes, which still render exactly as imported. The
hull is a large improvement on the bounding box this used to produce (a slab
that swallowed every concavity between a robot's links); it is still a physical
twin, not a digital one, which is the right trade for grasping, balancing and
proprioception. A collision mesh with fewer than four vertices falls back to its
bounding box so the link still becomes a body.
PhysicsPlaySession builds the articulation from the same URDF the visual robot
came from, at the robot's world pose. The two share URDFLoader's frame handling
(URDF is Z-up and neither side rotates it), so no Z-up→Y-up correction is applied
— a frame-consistency test pins the visual link and the simulated link to the
same place. Each step, the solved joint positions are mirrored back onto the
visual Robot (through a name map — the articulation's DOF add-order is not the
robot's joint order, so the joint name is the bridge); a floating base also
writes the solved root pose, so a walker actually travels. A link that carries
its own PhysicsConfig is not given a second rigid body — the articulation
link is already the body there — and a robot with a non-unit world scale is
skipped with one log line, because PhysX links cannot be scaled.
An Encoder or a Force/Torque sensor reads one joint of a simulated
robot. Author it like any other sensor (Sensor section, on the robot or one of
its links), and pick the joint from the combo the section grows for these two
types — it lists the robot ancestor's articulated joints by name and writes the
choice as joint=<name> in userData["sensor"]. The section says why when
there is no robot ancestor, or when the robot has Simulate off.
The encoder's combo has one more entry: All joints (joint=*), which fans
one authored sensor out to one live encoder per articulated DOF at Play — the
joint-state sensor a whole robot reports itself with. It exists because an
object carries exactly one sensor entry, so without it instrumenting a 7-DOF
arm means seven encoders on seven link nodes. Each fanned-out encoder is its
own row in the Sensors panel and its own CSV, labelled with its joint name,
and each draws from its own seeded noise stream so one authored seed does not
correlate seven channels. The Force/Torque sensor deliberately does not take
it — a load cell is one piece of hardware bolted into one joint.
At Play the sensor session resolves that name against the played articulation and
builds a live JointEncoder (position/velocity, quantized by the encoder's
resolution) or ForceTorqueSensor (the six-axis wrench through the joint, in its
child frame). Both are registered with the physics world and sampled on its
fixed-substep loop, drained into the Sensor panel's traces and any CSV recording,
exactly like the IMU. Without the joint, or off a robot, the sensor is authored
and saved but reports why it is not measuring — the same "counted, not live"
contract every proprioceptive sensor has.
An IMU or a contact sensor authored on a robot link works too. The
visual link nodes are never bound to the world (the joint mirror drives them;
a world-pose write-back would fight the kinematic chain), so the play session
associates each link node with its articulation link — resolution only, no
pose writes — and the sensor's usual walk-up-the-ancestry lookup finds the
body. The IMU still measures in its authored node's frame, so an offset mount
under a link measures at the offset; a URDF's fixed imu_link resolves to the
link it was welded into, which is where the mount physically rides; and the
robot node itself resolves to the root link, so a base IMU needs no link names
at all.
One teardown detail worth knowing, because it is a class of bug this codebase
cares about: sessions stop in reverse registration order, physics last, so on
the normal Stop the sensor session unregisters from a world that still exists —
a Force/Torque sensor releases its PxArticulationCache cleanly through
onUnregister. The other path still exists for a teardown that skips the
controller (the editor closed mid-Play, destructors running in member order):
there the SDK is already gone, the cache's memory went with it, and the session
abandons the pointer rather than releasing it against a freed allocator; the
SDK teardown already reclaimed the buffer.
Everything a sensor measures is readable from a play script too — see Sensors from a script, which is where a controller closes its loop on the noisy reading instead of on the solver's truth.
Attach a script to any object and it becomes a behaviour, MonoBehaviour style.
On Play the editor instantiates the class and drives it; on Stop it calls
stop() and throws the instance away, and the usual snapshot restore puts the
scene back.
The code comes in one of two forms, never both: a .py file referenced by
path, or inline source stored in the scene itself and edited in the editor's
Script Editor tab. A file is shared between objects and scenes and editable
in whatever tooling you already have; inline source travels with the scene, so a
.json handed to somebody else is complete.
Opening a scene never runs anything. Neither form executes on load, on import, or on selection: scripts run when you press Play, and the inspector imports a script's module only when it draws that object's parameters (a class body has to execute for its attributes to exist). The Script Editor's Apply compiles the source without executing it. A scene file is data, not a program.
import threepp # for the IDE; resolves against the embedded module at Play
class Spinner:
speed = 1.5 # exposed in the inspector, saved with the scene
def start(self, obj: threepp.Object3D):
self.obj = obj
def update(self, dt: float):
self.obj.rotation.y += self.speed * dt
def stop(self):
passEvery method is optional; missing ones are skipped. There are five more:
fixed_update(self, dt), which runs on the physics clock rather than on the
frame (see The physics clock),
on_collision_enter(self, contact) / on_collision_exit(self, contact), which
run when the body governing the object starts and stops touching another (see
Collisions), and
on_trigger_enter(self, other) / on_trigger_exit(self, other), which run when
a body enters and leaves a trigger volume — on the volume's
script and on the entering body's alike (see
Trigger volumes). In a file, the
class is the one whose name matches it (spinner.py → Spinner,
case-insensitively), or — failing that — the single class in the file that
defines update(). Anything else is reported rather than guessed at.
The clock is threepp.editor.time, readable from all of them: sim_time and
sim_dt for the simulated clock, wall_time and frame_dt for the real one,
and the two are not the same (see Both clocks).
The scene is threepp.editor.scene() — the ordinary threepp.Scene, so
scene.get_object_by_name("Ground"), scene.children, and so on. It answers
from start() onwards and for as long as the session runs, which means
update(), fixed_update() and the collision and trigger callbacks can all just
ask; there is no need to stash it on self. Outside a session it raises, because
outside a session there is no scene to answer with — a script's __init__ runs
before its authored fields are even applied and is not a place to look at the
world from. The same lifetime rule as every handle applies to what you resolve
through it: use it during the session, never stash it across Play sessions.
That is also where a script reaches another script's running instance, rather
than its node: see Talking to other
scripts.
Exposed parameters are the class's plain int / float / bool / str
attributes: no leading underscore, not callable, no properties or descriptors.
They appear in the inspector as real widgets, undoable and coalescing per drag,
and their authored values are applied to the instance before start() runs. A
field the document has no value for shows the class attribute, which is exactly
what the script will see.
Like the URDF reference, this does not pack into one key=value string — a
Windows path contains the delimiters, and source text contains everything — so
each gets its own entry:
userData["script"] C:/projects/scripts/spinner.py
userData["scriptSource"] class Spinner:\n speed = 1.5\n ...
userData["scriptFields"] speed=1.5;clockwise=1;label=spin
script and scriptSource are mutually exclusive: assigning one clears the
other, and a document carrying both (hand-edited, or merged) is read as inline —
the scene's own copy of the code beats a path into someone else's disk — and
rewritten with one form the next time it is saved. scriptFields works
identically for both: the parameters belong to the class, not to where the class
came from.
Values are stored as text and typed on the way in, from the class attribute they correspond to. That is what lets a build with no Python at all still round-trip a script's parameters instead of silently dropping them.
Editing the file and pressing Play again re-runs the new code. The loader
reads and compiles the source itself rather than going through importlib,
whose __pycache__ entry is validated against (mtime, size) — both of which
survive a same-second edit that happens not to change the file's length. That
one case is exactly the edit-run-edit loop this feature exists for, so there is
no bytecode cache to go stale.
update(dt) is called once per frame, with the real delta between two
drawn images. That is right for anything visual and wrong for anything
physical: a force applied per frame is a force scaled by your frame rate, so
the same script settles at a different height on a different machine — and
gives a different answer on the same machine when a texture load stutters.
A script may therefore also define fixed_update(self, dt). It runs from
inside the physics step, once per fixed substep, and its dt is the world's
timestep — the same number on every call, every run, every machine:
import threepp
class Thrust:
height = 3.0
stiffness = 40.0
def start(self, obj: threepp.Object3D):
self.body = threepp.editor.rigid_body_from_object(obj)
def fixed_update(self, dt: float):
if self.body is None:
return
# Once per substep, at 1/60 s whatever the window is doing.
error = self.height - self.body.position.y
lift = self.stiffness * error - 5.0 * self.body.velocity.y
self.body.apply_force(threepp.Vector3(0, lift * self.body.mass, 0))- Forces, impulses, drive targets and sensor reads belong here.
update(dt)stays the frame's final word on transforms — it runs after physics, the animation player and the sensors, and it is where you move a camera, a marker or anything else you are looking at. - It fires per substep, not per frame. A frame longer than the timestep runs
it several times; a frame shorter than one runs it not at all, and the
leftover is carried over.
PhysxWorldcaps the catch-up at 4 substeps per frame, so a genuine hitch drops simulated time rather than spiralling. - It runs before that frame's
update(), because the physics session is updated first andfixed_updatehappens inside that call. - The measurements it reads are the ones the frame started with. Sensors are
sampled at the end of each substep, and the sensor session hands its retained
copies to script handles once per frame (
drain()empties a sensor's ring, so there is exactly one drainer — see Sensors from a script). Solatest()insidefixed_updateis the newest measurement that existed when the frame began: one substep old at a matched frame rate, and the same value for both substeps of a double-length frame, with the pair arriving together on the next one. A controller is unbothered — acting on the last measurement is what a real one does, and the command written here lands in the solve that starts immediately after — but do not expect one new sample per call.latest()isNoneuntil the first substep of the session has been taken. - No physics world, no fixed clock. Without the PhysX build, or with no
physics session playing,
fixed_updatenever runs and the console says so once when Play starts. Nothing here invents a clock of its own: afixed_updatethat is not on the physics clock would be a lie about the one thing the name promises. (A scene with no physics objects is fine — the session builds a world regardless, and the clock is the world's, not any body's.) - Pause pauses it, for free: paused means no session is updated, which means no step, which means no substeps.
- Errors work as everywhere else. The first raise is reported once with its traceback and disables that instance for the rest of the session — all of its methods, not just this one. Other scripts keep running.
The section above says update(dt) is wall time and fixed_update(dt) is
simulated time. threepp.editor.time is how a script reads either one from
anywhere, instead of having to be handed it:
import threepp
class Mission:
def update(self, dt: float):
t = threepp.editor.time
# 20 s of simulation, however long that takes to render.
if t.sim_time > 20.0:
print(f"done after {t.steps} substeps, {t.wall_time:.1f} s of your life")| field | is |
|---|---|
frame_dt |
wall seconds the last frame took — the number update(dt) is handed |
wall_time |
real seconds since Play started |
sim_time |
simulated seconds since Play started |
sim_dt |
the fixed substep — the number fixed_update(dt) is handed |
steps |
fixed substeps completed since Play started |
fixed_clock |
True when the sim fields come from a playing physics world |
playing |
True between a session's start and its stop |
- The two clocks diverge, permanently. A frame that hitches advances
wall_timein full, takes at most 4 substeps, and discards the rest of the accumulator so it cannot spiral. Nothing catches that up later. One second spent in a single frame buys you 4/60 s of simulation and 1.0 s of wall clock, and they stay that far apart for the rest of the run. Anything integrating toward a physics quantity should readsim_time— or live infixed_update, which is handed the right number by construction. sim_timeis the sensor clock. It is read straight off the physics world, the samedoublethat stamps every sensor sample — so comparing a sample's timestamp against it is comparing one number against itself, not two clocks that happen to be close.- Read it from anywhere:
start,update,fixed_update, the collision and trigger callbacks,stop. Insidefixed_updatethe sim fields describe the substep about to be solved —sim_timeis its start,stepscounts the ones already done. The wall fields there are one frame stale: physics steps before the frame sweep refreshes them, sowall_timeandframe_dtread as of the last completed frame. Afixed_updatehas no business on the wall clock anyway — that staleness is the reminder. - It is live, not a snapshot.
threepp.editor.timeis one object whose fields are read when you ask for them, so stashingself.t = threepp.editor.timeinstart()and readingself.t.sim_timea thousand frames later works. Assigning a field (self.t0 = threepp.editor.time.sim_time) copies a number, which is exactly what you want for "how long since I started". - No world, no fixed clock. Without the PhysX build, or with no physics
session playing,
sim_timefalls back towall_timeandsim_dttoframe_dt, withfixed_clockreadingFalse. That is a deliberate difference fromfixed_update, which refuses to run at all rather than fake its clock: "how long have I been playing" still has an honest answer here, andfixed_clockis how a script tells which kind of answer it got.stepsstays0, because no substep ran. - Every field is zero outside Play, and
playingisFalse. The clock starts from zero on each new session — a second Play does not inherit the first one's elapsed time.
update() has to return before the frame can continue, so anything that takes
time — settle, lift, wait half a second, open the gripper, drive to the next
marker — comes out as a state machine: an enum, a timer, and the actual sequence
scattered across both. threepp.editor.start_coroutine lets you write the
sequence as a sequence. Hand it a generator; it yields a condition, and the
session resumes it once per frame when that condition is met:
import threepp
class Mission:
def start(self, obj: threepp.Object3D):
self.obj = obj
self.body = threepp.editor.rigid_body_from_object(obj)
self.task = threepp.editor.start_coroutine(self.run())
def run(self):
yield threepp.editor.wait(1.0) # let it settle
self.body.apply_impulse(threepp.Vector3(0, 12, 0))
down = threepp.Vector3(0, -1, 0)
hit = yield threepp.editor.until( # ...until it is home again
lambda: threepp.editor.raycast(self.obj.position, down, 0.6, self.obj))
print("landed on", hit.object.name)| yield | resumes |
|---|---|
yield |
at the next frame's pump |
yield threepp.editor.wait(s) |
at the first pump s simulated seconds later |
yield threepp.editor.until(pred) |
at the first pump where pred() is truthy — and that value is what the yield evaluates to |
yield another_generator() |
when that generator returns — and its return value is what the yield evaluates to |
That is the whole vocabulary. There is no any_of, no wall-clock wait and no
cross-script join, because none of them has been needed yet and each would be a
promise about ordering that this section would then have to keep.
start_coroutine returns a Task: task.cancel() stops it, task.done says
whether it has finished. A script may run as many as it likes.
-
wait()is on the simulated clock, the onethreepp.editor.time.sim_timereports. A mission written this way therefore freezes when physics is paused or starved and takes the same number of substeps on a machine that renders half as fast — which is the entire reason to prefer it to countingupdate'sdt. Without a physics worldsim_timefalls back to wall time, so a wait still measures something honest there. -
The pump runs after everything else in the frame. The order inside a frame is: physics steps (and
fixed_updatewith it) → collisions → triggers → breaks → everyupdate()→ the coroutine pump. So anuntil()predicate reads the settled frame: physics has run, and so has every script'supdate(), including ones authored after yours. A flag another script sets in itsupdate()is caught by your predicate in the same frame. -
Nothing runs inside
start_coroutine. The body waits for the next pump, even the part before its firstyield. That is what makes it safe to start one fromfixed_update, where you are standing inside a physics substep — the coroutine still runs frame-paced, where it belongs. Registration works from any script method:start,update,fixed_update, the collision, trigger and break callbacks,stop, and from inside another coroutine. -
A yielded generator costs no frame of its own. It is pushed onto the same task and entered immediately, and the parent resumes the moment it returns — so
yield self.approach(marker)reads like a call, and nesting three deep is three deep, not three frames of latency. -
Tasks belong to a script instance, and which one is decided by whose method was being dispatched when
start_coroutinewas called. Called from nowhere — module scope, a callback the editor never dispatched — it raises and says so rather than registering an orphan. The edge worth knowing: a coroutine started inside a neighbour's method that you reached throughscript_from_objectbelongs to your instance, because yours is the one being dispatched. -
A raise inside a coroutine disables the owning instance whole, exactly as a raise in
update()does: one traceback in the console, every method of that instance dead for the rest of the session, its other tasks dropped with it, and every other script untouched. -
cancel()is not an error. The generator isclose()d, so afinally:inside it runs — which is where anything a half-finished mission has to put back belongs:def run(self): self.gripper.close() try: yield threepp.editor.wait(5.0) finally: self.gripper.open() # runs on cancel, and at Stop
-
Tasks die at Stop. They are session state: everything still running is unwound (
finally:blocks included) before the scripts' ownstop()methods are called, and the next Play starts from nothing. Do not keep aTaskacross sessions. Afinally:may tidy up, not start new work:start_coroutineraises while the task it is called from is being wound up — there is no session left for the newcomer to run in. -
Pause pauses them, for free, and for the same reason
fixed_updatestops: paused means no session is updated, which means no pump.
Two more optional methods, fired when the body governing the script's object starts and stops touching another body:
import threepp
class Impact:
threshold = 2.0 # N*s; below this it is a nudge, not a hit
def start(self, obj: threepp.Object3D):
self.obj = obj
self.material = obj.material
self.rest = self.material.color.get_hex()
def on_collision_enter(self, contact: threepp.editor.Collision):
i = contact.impulse
strength = (i.x * i.x + i.y * i.y + i.z * i.z) ** 0.5
if strength >= self.threshold:
print(self.obj.name, "hit", contact.other.name if contact.other else "?",
"at", strength)
self.material.color.set_hex(0xff3020)
def on_collision_exit(self, contact: threepp.editor.Collision):
self.material.color.set_hex(self.rest)contact is a threepp.editor.Collision, and it carries:
| field | meaning |
|---|---|
other |
the object on the far side, as its concrete type (Mesh, Robot, …) — or None when that body belongs to nothing the script can see |
point |
world-space position of the hardest-hit manifold point |
normal |
unit normal there, pointing into this script's body — the direction the other body is pushing it |
impulse |
total impulse over the manifold, in N·s, same orientation. Divide by the timestep to read it as a force |
otheris the object the physics was authored on, found by walking up the scene graph exactly asrigid_body_from_objectdoes — so a contact against one cooked mesh of a spline's tube names the spline, not the mesh, and a contact against a simulated robot's link names the robot. It is a handle like any other: use it during the session, do not stash it across Play sessions.- Reporting turns itself on. There is no box to tick. At Play the session
finds the body governing every scripted object whose class defines either
method and enables PhysX contact reporting on it. An object with the callbacks
and no body gets one line in the console at Play and never fires — the
same voice, and the same reason, as
fixed_update's missing clock. - Both sides are called. If both objects in a pair carry scripts, each gets
its own callback with its own
other, and eachnormalpoints into its own body. - One touch is one pair of bodies. A box landing on the ground touches
through four manifold points, possibly through several shapes: that is one
on_collision_enter. Settling on a surface does not re-fire it — only a genuine separation followed by a new touch does. - They are delivered from the frame sweep, just before
update(dt). PhysX reports contacts from inside the solver; calling into Python there would let a script mutate a world the solver still owns. So the reports are copied into a queue and handed over whereupdate()runs — after physics has stepped and mirrored, so a callback reads the settled world this frame will draw. Every edge this frame's substeps produced arrives in this frame. - Both edges always survive. A touch that begins and ends between two
deliveries — a fast bounce, or two substeps of one long frame — still produces
on_collision_enterfollowed byon_collision_exit. The queue is a list, not a state flag; nothing collapses a whole touch into "no change". - Errors work as everywhere else. The first raise is reported once with its traceback and disables that instance whole for the rest of the session.
- A
ContactSensoron the same body keeps working. The opt-in is one bit per actor, shared: the sensor's noisy, rate-gated readings and the script's edges come out of the same reports, and neither starves the other.
What is reported is what the physics world already generates: dynamic against dynamic, dynamic against static, and dynamic against kinematic. PhysX does not generate contacts for kinematic-against-static or kinematic-against-kinematic pairs unless the scene asks for them, and this one does not — so a script on a kinematic body hears about the dynamic bodies it pushes and about nothing else.
A trigger volume generates no contacts at all, so none of this fires for one — that is a separate pair of callbacks, below.
Out of scope, deliberately: on_collision_stay (a resting contact stops
being re-reported the moment PhysX puts the pair to sleep, so a per-frame "still
touching" callback would lie), per-contact-point lists beyond the single
representative point above, soft bodies (a deformable volume has no rigid
actor to watch), and watching a robot — a simulated robot's links are the
articulation's, not the session's actor registry's, so a script on a robot
resolves no body of its own and gets the no-body line. (Being hit by one is
fine: a contact against a link names the robot, the same way a raycast does.)
The other pair, for the other kind of body. Tick Trigger on an object's physics (see Trigger volumes) and it stops colliding: things pass through it and it reports who is inside. Both scripts hear about it — the one on the volume, and the one on the body that walked in:
def on_trigger_enter(self, other: threepp.Object3D): ...
def on_trigger_exit(self, other: threepp.Object3D): ...A goal zone, a scoreboard, and a ball — three features composing (a trigger, a script reaching another script's live instance, and the callbacks themselves):
scoreboard.py, on anything:
import threepp
class Scoreboard:
def start(self, obj: threepp.Object3D):
self.obj = obj
self.score = 0
def scored(self, who: threepp.Object3D):
self.score += 1
print("goal by", who.name, "- score is now", self.score)
self.obj.scale.y = 1.0 + self.score # the bar growsgoal_zone.py, on the trigger volume:
import threepp
class GoalZone:
def start(self, obj: threepp.Object3D):
# Resolve in start(), use later: every script instance exists by now,
# but its own start() may not have run yet.
self.board = threepp.editor.script_from_object(
threepp.editor.scene().get_object_by_name("Scoreboard"))
def on_trigger_enter(self, other: threepp.Object3D):
if self.board is not None and other is not None:
self.board.scored(other)otheris the object, not aCollision. A trigger produces no manifold — no point, no normal, no impulse, because PhysX has none to give — and a struct full of zeroes would be a lie with fields on it. So the callback takes the far object directly, as its concrete type (Mesh,Robot, …), resolved the same waycontact.otheris: the node the physics was authored on, so a body crossing one cooked mesh of a spline's tube names the spline, and a robot crossing it names the robot. It isNonewhen that actor answers to nothing the scene can name.- Both sides are called, and only one of them is the volume. The volume's script is told who came in; the entering body's script is told what it entered. The entering body is not itself a trigger and needs no tick of its own — a script that only defines these two methods, on an ordinary falling box, works.
- Nothing turns it on. Contact reporting is an opt-in bit per actor; trigger
reporting is the shape being a trigger, which the document already says. So
there is no box to tick beyond the one that made the volume. A script with
these methods on a body that is not a trigger and never gets entered by one
simply never fires — but a script with them on an object with no physics body
at all gets one line in the console at Play, the same voice and the same
reason as
fixed_update's missing clock. - One crossing is one pair of bodies. A compound volume, or a body with
several shapes, overlaps through several shape pairs: that is one
on_trigger_enter, refcounted the same way a collision is, so a partial separation cannot report an exit the rest of the overlap is still holding. - Delivered from the frame sweep, just before
update(dt), exactly like the collision callbacks and for exactly the same reason: PhysX reports from inside the solver, so the report is copied into a queue and handed over whereupdate()runs. Contacts are delivered first, then triggers — an arbitrary but fixed order, so a script defining both sees the same sequence every run. - Both edges always survive. A body that crosses a thin volume and leaves
between two deliveries still produces
on_trigger_enterfollowed byon_trigger_exit. The queue is a list, not a state flag. - There is no "still inside" event. PhysX reports
TOUCH_FOUNDandTOUCH_LOSTfor a trigger and nothing between them —eNOTIFY_TOUCH_PERSISTSis explicitly unsupported for triggers. A body sitting inside a volume also falls asleep, and a sleeping pair reports nothing further. Neither is a problem because there is no persist event to lose: the enter has already been delivered and the exit still arrives when the body wakes and leaves. Track the two edges yourself if you need an "is inside" flag; do not try to derive one from a per-frame event that does not exist. - Errors work as everywhere else. The first raise is reported once with its traceback and disables that instance whole for the rest of the session.
Interplay, stated rather than left to be discovered:
- No collisions. A trigger shape generates no contacts, so
on_collision_enter/on_collision_exitnever fire for an overlap with one, aContactSensornever measures one, and bodies pass through. That last part is the feature. - Raycasts still hit it. A trigger keeps its scene-query flag, so
threepp.editor.raycastreports it like any other body — see the table there. - Triangle meshes are substituted. PhysX has no triangle-mesh trigger, so a volume authored as one is cooked as a convex hull with one console line saying so. The rule and the table are in the authoring section.
- Trigger-against-trigger is nothing. PhysX does not report overlaps between two trigger shapes, so two volumes crossing each other produce no callbacks on either.
- Soft bodies have no trigger. The checkbox is hidden for
body=soft, and a soft body crossing a volume is not reported — a deformable volume is not a rigid actor and never enters the trigger pair.
A collision tells you what already happened. A raycast asks a question about the world now — what is under my feet, what is in front of me, what am I aiming at — and answers synchronously, wherever you call it from:
hit = threepp.editor.raycast(origin, direction, max_distance=100.0, ignore=self.obj)
if hit is not None:
hit.object # the object the physics was authored on, as its concrete type, or None
hit.point # threepp.Vector3, world space
hit.normal # threepp.Vector3, unit, pointing out of the surface hit
hit.distance # float, metres from origin along the rayThe whole ground check, which is what it is mostly for:
import threepp
class Hopper:
probe = 0.6 # metres: just past the bottom of a unit box
def start(self, obj: threepp.Object3D):
self.obj = obj
self.body = threepp.editor.rigid_body_from_object(obj)
def fixed_update(self, dt: float):
if self.body is None:
return
down = threepp.Vector3(0, -1, 0)
hit = threepp.editor.raycast(self.body.position, down, self.probe, ignore=self.obj)
if hit is not None and self.body.velocity.y <= 0.0:
self.body.apply_impulse(threepp.Vector3(0, 4.0 * self.body.mass, 0))- A miss is
None. Not playing is aRuntimeError. Those are two different answers and they are kept different: if "no physics world" also came back asNone, a ground check written outside Play would look like it worked and silently report empty air forever. Same reasoning asencoder_from_objectraising for an unknown joint. A zero-lengthdirectionis aValueError, as is amax_distanceof zero or less. ignore=objexcludes every actor governing that object — the walk up the ancestry that finds its body, and then all of the actors recorded against the node it lands on, since a subtree collider or a compound is many actors under one authored node. Pass your own object for any cast that starts inside your own collider, which a cast frombody.positionalways does: a ray that starts inside a box hits that box at distance zero, and a ground check withoutignoreis a ground check that has found itself.originanddirectionareVector3s in world space. The direction is normalised for you, soVector3(0, -1, 0)andVector3(0, -37, 0)are the same ray.max_distanceis in metres and defaults to unbounded, so a miss means the ray genuinely left the world rather than ran out of budget.- The nearest hit, and only that one. There is no all-hits form and no sweep; this is the primitive, not a query library.
hit.objectis the object the physics was authored on, as its concrete type (Mesh,Group,Robot, …) — the same answercontact.othergives, so a hit on the fourth cooked mesh of a spline's tube names the spline, and a hit on a robot's forearm names the robot. It isNoneonly when the actor answers to nothing the scene can name;hit.point,hit.normalandhit.distanceare still valid in that case.- Call it from anywhere a script runs:
update,fixed_update, the collision callbacks and the trigger callbacks. All of those run on the editor thread with the GIL held and outsidesimulate()— a substep ispre-hook → simulate → fetchResults → post-hook, so the pre-substep hookfixed_updateruns from sits between one substep's results and the next solve, which is exactly where a scene query is legal. A cast fromfixed_updatereads the substep it is standing in, not last frame's pose.
What the query sees in this world, stated rather than assumed:
| body | seen? |
|---|---|
| static | yes |
| dynamic | yes |
| kinematic | yes — a kinematic actor is a PxRigidDynamic, and the query asks for static and dynamic both |
| an articulated robot's links | yes, and each one answers as the robot |
| trigger volumes | yes — a trigger shape loses eSIMULATION_SHAPE but keeps eSCENE_QUERY_SHAPE, so a ray finds a volume that bodies pass straight through. Pass it to ignore, or check hit.object, if a ground check must not stop at one |
| soft bodies | no — a deformable volume's shape is created without PhysX's scene-query flag, so it is not in the query structures at all |
scene.get_object_by_name("Door") gives you the door's node.
threepp.editor.script_from_object(...) gives you the door's live script
instance — the actual Python object this Play session is driving:
door.py, on the door:
import threepp
class Door:
open = False
def start(self, obj: threepp.Object3D):
self.obj = obj
def on_opened(self):
self.obj.position.y += 1.0button.py, on whatever opens it:
import threepp
class Button:
def start(self, obj: threepp.Object3D):
# Resolve neighbours here, once: every instance exists by now.
self.door = threepp.editor.script_from_object(
threepp.editor.scene().get_object_by_name("Door"))
def update(self, dt: float):
if self.door is None or self.door.open:
return
if threepp.editor.is_key_down("SPACE"):
self.door.on_opened() # calling a method IS the signal
self.door.open = True # and so is setting an attributeThere is no event bus and no message type, deliberately. What comes back is
the instance, so its methods and its attributes are the whole API — this is
Unity's GetComponent without the component-type dance, since an object carries
exactly one script.
- Every instance exists before any
start()runs. Play brings every script up first — compile, construct, apply the authored field values — and only then callsstart()on all of them. So resolving a neighbour instart()works whichever way round the scene happens to be, and the neighbour's parameters are already the ones the inspector shows rather than the class defaults. What is not guaranteed is that itsstart()has run: if you need state a neighbour builds there, resolve instart()and use it fromupdate(). That is the pattern above, and it is the one to copy. Noneis a normal answer, never an exception — a missing neighbour is something a script checks for, exactly as it is forrigid_body_from_object. You getNonewhen nothing is playing, when the object carries no script, and when that script's instance is dead: a constructor that raised, or astart/update/fixed_updatethat raised later. A disabled script is disabled whole, so it stops answering lookups as well. That is about the lookup, though: a reference you are already holding onto a script that has since been disabled stays perfectly good Python. It is simply not being driven any more.- The exact object, never an ancestor.
rigid_body_from_objectwalks up the scene graph because a collider governs a whole subtree; a script governs nothing but the node it was authored on, so this asks for that node and no other. Pass the object the script is on. - Do not keep it across sessions. Same rule as every other handle: resolve in
start(), use it during the session, ask again after the next Play. A reference held past Stop is a harmless dead object — the session has dropped it and nothing calls it again — not a dangling pointer, but it is not the new session's instance either, and the new session will never see your writes to it. - Call it from anywhere a script runs —
start,update,fixed_update, the collision and trigger callbacks, andstop, where every instance is still alive. It reads the session's own instance list and touches neither the scene nor the physics world, so it needs no PhysX build: unlike the handles above it, this is there in every build that has scripts at all.
New Inline Script in the Script section writes a template into the object and
opens it in the bottom panel's Scripts tab, bringing the panel up if it was
collapsed. Edit… opens an existing one the same way.
As many at once as you open. Inside the Scripts tab is a second bar with one
entry per open script, labelled with its object and carrying an asterisk while
its buffer differs from the document. Each keeps its own text, its own undo
position in the box and its own syntax error until you close it with its ×, so
writing two scripts that talk to each other is a click between tabs rather than
a round trip through the hierarchy. When more are open than fit, the ▾ button at
the left of the bar lists them all by name. Still one script per object —
that is ScriptConfig's rule; this is one tab per object.
Selecting an object that already has a tab open raises that tab, and that is
all the selection does: on a change of selection, not on every frame the visible
tab and the selection disagree — otherwise opening a script for anything other
than what is selected would bounce straight back. Selecting an object with no
tab open opens nothing; Edit… is what opens one. The single editor this
replaced instead retargeted itself onto the selection, and could only ever do
it when the buffer was clean, because pointing a buffer somebody is typing into
at another object drops what they typed.
It is docked rather than floating because floating is what was wrong with it: a
text box big enough to write in covered the object the script was about. Beside
the console it takes room from nothing, and the bottom panel's top edge drags,
so "big enough" costs a drag rather than a window parked over the scene. The
panel's v collapses the lot; the Scripts tab itself is there only while at
least one script is.
- Apply (or
Ctrl+Enter) commits the buffer as one undoable step, keyed per object so it lands in the undo stack next to every other inspector edit. - Tabs become four spaces on the way in. The box takes real tabs — a Python
editor that cannot indent is not one — and Python raises
TabErroron source that mixes them, so normalizing makes the failure impossible instead of diagnosing it later. - The source is
compile()d, never executed. A syntax error appears in red with its line number and is written to the console — but it does not block Apply: half-written code is a normal thing to want to save, and an editor that refuses to let go of what you typed is a worse editor. - Revert reloads the committed text; Clear in the inspector removes the
script and closes the tab (one
Ctrl+Zaway, and the buffer is still there if you reopen the object). - It is read-only while playing, like the inspector: the snapshot restore on Stop would throw the edit away.
Inline source is compiled fresh on every Play — it is inherently hot, with no
file, no cache and no stamp anywhere in the path. Each object's source gets its
own synthetic module named after its uuid, so two objects whose scripts both
define class Behaviour do not overwrite each other, and tracebacks are filed
under <inline:Box> — which object the code belongs to, since there is no file
name to point at. With no file stem to match, the class is the only one the
module defines, or failing that the single one defining update(); several
candidates is reported against the object, not guessed at.
Plain text, deliberately: no highlighting, no completion, no third-party editor
widget and no new dependency. Editing an external .py in this tab is out of
scope — that file already has an editor, and so, now, does inline source: see
below.
Edit in VS Code in the Script section opens the script in a real editor, with
completion for the threepp API. It works on both forms, and what it does
differs because what they are differs.
A file script is simply handed over. VS Code opens on the folder, the file
is revealed, and nothing is watched: Play recompiles the .py from source every
time, so the file was already hot. Edit, save, press Play.
Inline source has no file, so one is made. The committed source is exported
to <temp>/threepp-editor/scripts/<uuid8>_<name>.py, VS Code opens on it, and
the editor polls that file about once a second from the frame loop. Every save
comes back in through the Script Editor's own Apply — the same tab and
line-ending normalization, the same compile check, the same undoable commit —
and the tab shows the source read-only with a banner and a Stop external
edit button for as long as the session lives.
Details that are not incidental:
- Content decides, not the write time. Two saves a millisecond apart can
carry the same
last_write_time; a watcher that misses one of those has silently lost work. The file is read and compared instead — and compared normalized, because otherwise every save from a Windows editor would look like a change to every line in the file. - Ten saves are one undo step. The session holds a command-stack transaction open, so saves after the first merge into the same entry. The first still starts a fresh one: a transaction never merges into what preceded it.
- The session survives a play/stop. It holds the object's uuid, not a pointer, and re-resolves it on every poll — so the graph can be replaced underneath it and external editing simply carries on. Iterating on Play with VS Code open is the point of the whole feature.
- A save that arrives while playing is parked. Committing then would be swallowed by the snapshot restore on Stop, so nothing is consumed, the banner says the save applies on Stop, and the poll after Stop applies that same file to the restored object. (An async import that lands during Play is parked the same way.)
- It ends on Stop external edit, on the script being cleared, on the object leaving the scene, on the file being deleted, or when the editor closes — and the scratch file goes with it. One session at a time, the same rule the Script Editor window follows.
- Polling, not
ReadDirectoryChangesW/inotify: a watcher means a thread, and a thread means a callback arriving mid-frame with the scene half-rebuilt. This lands in a frame, on the main thread, where every other editor mutation lands. - Launching is
ShellExecute/CreateProcess, neverstd::system: detached, non-blocking, and without the console flash a.cmdon the PATH would produce (codeis a.cmd, so the realCode.exebeside it is preferred). With nocodeon the PATH at all, the file opens in whatever the OS uses for.pyand the console says so — that editor knows nothing about the stubs.
Both tiers first write .vscode/settings.json in the folder being opened, if —
and only if — there is not one there already. Your own settings are never
overwritten; delete the file and the editor writes a fresh one.
stubPath, not extraPaths, and the difference is worth knowing. The stub
package is python/threepp/threepp/__init__.pyi, so a stubPath of
python/threepp resolves import threepp straight onto it — the native
module, which is what the editor's embedded interpreter actually serves. An
extraPaths of python/ also resolves (both were checked with pyright against
the real tree), but it lands on the wheel's Python package first and would offer
flush_material, threepp.rl and friends, none of which exist inside the
editor. The cost of the stub route is reportMissingModuleSource — the stubs
have no .py beside them, because the module is compiled into the editor — and
the generated file turns that one diagnostic off.
The path is absolute and baked in at build time (THREEPP_EDITOR_PYTHON_STUBS,
from PROJECT_SOURCE_DIR); THREEPP_PYTHON_STUBS in the environment overrides
it, which is what an installed binary that has moved away from its source tree
needs. The commented-out defaultInterpreterPath is the other half: point it at
your own interpreter — a RoboStack environment, say — and VS Code will also
resolve numpy, rclpy and anything else that interpreter has. Scripts still
run in the editor's embedded interpreter; that line only changes what VS Code
understands.
Handles are yours to annotate. The editor passes the object's concrete type, and saying so is what turns completion on for it:
import threepp
class Spinner:
speed = 1.5
def start(self, obj: threepp.Mesh) -> None: # or threepp.Robot, threepp.Light, ...
self.obj = obj
mat = obj.material
assert isinstance(mat, threepp.MeshBasicMaterial)
mat.color.set_hex(0xff8844) # completes, and is checked
def update(self, dt: float) -> None:
self.obj.rotation.y += self.speed * dtThe assert is not ceremony. .material is typed Material | None, and
Material carries only what every material shares — name, opacity, side,
blending, needs_update(), and so on. color is not among them: a
MeshNormalMaterial has none, so a checker is right to reject it until you say
which material you mean. isinstance does that and opens up the rest of the
concrete surface. The same move works on an Object3D from
get_object_by_name, which is likewise typed as the base while the runtime
object is the concrete subclass.
The stubs are a superset of what the editor serves: they are generated from
the full wheel module, which also binds the renderers, loaders, physics and
sensors that the script host deliberately leaves out. threepp.Mesh completes
and works; threepp.GLTFLoader completes and raises AttributeError at Play.
threepp/editor.pyi is the exception, and the reason it is hand-maintained.
threepp.editor runs the superset the other way: RigidBody, SoftBody,
Articulation and the three *_from_object lookups come from
python/src/bind_editor_physics.cpp, which only this app compiles
(apps/editor/CMakeLists.txt, registered by ScriptModule.cpp behind
THREEPP_EDITOR_WITH_PHYSX). They are handles onto a live PhysicsPlaySession,
so the wheel has no use for them and does not bind them — and a stub generated
from the wheel would therefore delete them. python/scripts/gen_stubs.py lists
the file in HAND_MAINTAINED and restores it verbatim after every run. Change
bind_editor_physics.cpp, and edit editor.pyi by hand to match.
Errors are never fatal. Every call into Python is wrapped; a traceback goes
to the console, the offending instance is disabled for the rest of the session,
and everything else keeps playing. A script that raises on every frame is
reported once, not sixty times a second. Syntax errors, a missing class, a file
that has moved away and an update() that throws are all just messages — the
last one is also shown in red in that object's Script section.
Input: threepp.editor.is_key_down(key). A play session is something you can drive.
Poll it from update() — 'W', 'UP', 'KP8', 'SPACE', the same key names
Canvas.is_key_down takes — and a script becomes a controller: teleop a robot, nudge a
body, cycle a mode.
def update(self, dt):
from threepp import editor
vx = (1.0 if editor.is_key_down("UP") else 0.0) - (1.0 if editor.is_key_down("DOWN") else 0.0)It answers False while ImGui wants the keyboard — real text entry, an open popup, the file
browser — on the same rule the editor's own shortcuts follow, so driving a robot can never eat
somebody's rename. It reads ImGui's key state rather than the canvas's: preventKeyboardEvent
gates on WantCaptureKeyboard, which stays true for as long as a panel keeps focus after a
click, so the canvas's held-key set would be stale exactly when someone is watching the
viewport. In a build or a pass with no window (no provider installed) it answers False rather
than raising, so a script that steers still runs headlessly, just uncommanded.
A script that reads the keyboard takes it over. The keys a controller reaches for are the
keys the editor already uses: W/E/R are the gizmo modes, Q is local/world, and the
numpad is the axis viewpoints. Both firing on one press means driving a robot forward also
retargets the gizmo and snaps the camera to a front view. So the first is_key_down call of a
play session hands the plain keys to the scripts for the rest of it — one console line says
so — and the shortcuts come back on Stop. Modified commands are untouched: Ctrl+S, Ctrl+Z
and the Alt+digit viewpoints keep working while playing, because a script polls unmodified
keys and silently losing save-while-playing would be its own surprise. Shift+F
(Follow Selection) is answered ahead of the hand-over for the same reason and one
more: the session where you most want to chase the thing you are flying is exactly this one.
A scene whose scripts never ask keeps every shortcut.
This is the only input a script gets, and it is a poll, not an event: there is no key-press callback, no mouse, and the inspector is read-only while playing — so a parameter is something you set before pressing Play, not a live control.
The GIL, and threads. The editor holds the GIL only inside a sweep and
acquires it once for the whole frame, not once per script. A script must
therefore only touch the scene from start / update / stop: work handed to a
threading.Thread runs without the editor's cooperation, and mutating an
Object3D from it races the renderer. Spawn threads for I/O if you must, and
apply what they produce from the next update().
Objects handed to a script are the concrete type — a Mesh is a
threepp.Mesh, not a threepp.Object3D. That is not a nicety: Mesh, Points
and Line derive from Object3D virtually, and pybind11 mishandles every
access that crosses that base (assigning the inherited name through an
Object3D-typed handle corrupts the heap). They are also handed over as
shared_ptr, so a script that stashes self.obj past Stop holds a detached but
perfectly alive object rather than a dangling pointer.
The runtime lives in apps/editor/scripting, not in the library: ScriptConfig
is dependency-free like the other configs, but ScriptPlaySession needs CPython
and libthreepp stays free of it. The whole directory is compiled only when
Python3 (component Development.Embed) and pybind11 are found, mirroring how
PhysX gates the physics session. Without it the editor still authors and saves
scripts, and the Script section says so.
The embedded interpreter is served the same binding translation units the
Python wheel is built from (python/src/bind_*.cpp), linked into the editor and
registered with PYBIND11_EMBEDDED_MODULE. Importing a wheel-built threepp
into the embedded interpreter instead would register a second, independent set
of C++ types, and the editor would be handing scripts objects that module has
never seen. Eleven areas are linked — math, textures, core, geometries, curves,
materials, objects, animation, cameras, lights, robot — and the renderer,
loader, physics, sensor and Vulkan areas are deliberately left out: a script has
no business creating a window, and the editor already owns the ones that exist.
A generator is inline Python that AUTHORS content, as opposed to a script,
which is behaviour that runs during Play. It normally belongs to the scene — a
generated scene's rule is a property of that scene, and the root is where you
would look for it — so select Scene in the hierarchy and the inspector offers a
Generator section. Any Group can carry one too, for a scene that wants several
independently re-runnable ones (props, then clutter on top).
userData["generatorSource"] count = 400\nfor i in range(count): ...
userData["generatorFields"] count=400;seed=7
Source is newlines and arbitrary Python, so like a script's it gets its own plain
entry rather than riding in the flat key=value format PhysicsConfig uses.
Inline is the only form on purpose: the point is a document that carries the rule
that built it, and a path into another machine's disk gives that up.
Press Regenerate and what the script builds lands under ONE child tagged
userData["generated"], replaced wholesale on every run:
import math, random
import threepp
from threepp import editor
random.seed(7) # determinism is yours to declare
geometry = threepp.BoxGeometry(0.5, 0.5, 0.5)
material = threepp.MeshStandardMaterial()
material.color = threepp.Color(0x88aa55)
field = threepp.InstancedMesh(geometry, material, 400)
for i in range(400):
angle = random.uniform(0, 2 * math.pi)
r = 8.0 * math.sqrt(random.random()) # uniform over a disc
m = threepp.Matrix4()
m.make_rotation_y(random.uniform(0, 2 * math.pi))
m.set_position(r * math.cos(angle), 0.25, r * math.sin(angle))
field.set_matrix_at(i, m)
field.instance_matrix_needs_update()
editor.add(field)Generated content can carry configs. Object3D.set_user_data(key, value) is
the write side of the get_user_data escape hatch, and it is what makes a
generated level playable: physics, a sensor, a script and an animation all ride
in userData as one flat key=value;… string, so the generator writes exactly
what the inspector would have.
pillar.set_user_data("physics", "body=static;shape=box;friction=0.6;restitution=0.05")Strings only, for the reason the reader gives — userData round-trips scalars
and the editor's own configs are all one string — and an empty value removes
the entry, which is how every config spells "off". Without this a generator could
build a hundred pillars and not one of them would collide with anything.
editor.add(object, parent=None) is the only authoring verb, and it returns what
you passed so you can nest into it. It appends to a node that is not in the
document yet; the editor attaches that node only once the script finishes. Two
consequences worth relying on: a script that raises — immediately, or after
adding half its objects — commits nothing at all, and calling add() outside a
generator run raises rather than quietly doing nothing. editor.scene() reads the
live scene, for placing content relative to what already exists; objects reached
that way are not this generator's output and survive its re-runs.
Opening a scene never runs a generator, exactly as for scripts. The output is
ordinary saved scene content, so loading a generated .json shows the result
without executing anything — and that is what makes such a file safe to open at
all. Regenerating is always something you asked for. It follows that a scene from
somebody else is data until you press the button, and pressing it runs their code
with your privileges; read it first, the same as any script.
The script is the source of truth for its output. Because a run replaces the output wholesale, a material or a physics config hand-edited onto generated content is gone at the next Regenerate — set it in the script instead. (The spline's tube sync can preserve a uuid and a material because it knows the output is one mesh; a generator's output is arbitrary.) Clear removes the script and its output together, in one undo step, rather than leaving orphaned content carrying the generated tag for a later generator to adopt.
Edit in VS Code exports the source to a scratch .py, opens it, and polls it
back once a second, committing each save through the undo stack — the same round
trip an inline behaviour script gets, including the .vscode/settings.json that
makes Pylance complete import threepp. A save also re-runs the generator, so
the loop is edit-save-look; a file that does not parse is synced but not run, so
the last good output stands.
Determinism is your declaration, not the editor's guarantee: seed your RNG (as above) and a regenerate reproduces the scene exactly, which is what makes a generated scene usable as reproducible training data. An unseeded script is a different scene every run, and nothing stops you writing one.
Known gaps: generatorFields round-trips but is not yet injected into the run, so
there are no inspector inputs for parameters — a script's own module-level
count = 400 would overwrite an injected global, and resolving that needs a
convention rather than plumbing. There is also no headless entry point yet, so
batch generation over many seeds is not a one-liner.
Add ▸ Tree creates a Group carrying userData["tree"] — the parameters
threepp::vegetation::TreeGenerator takes — with two generated children under
it, tagged userData["treeDerived"] with the values trunk and leaves:
Tree userData["tree"] = seed=1337;trunkHeight=3.5;...
├── Trunk userData["treeDerived"] = "trunk"
└── Leaves userData["treeDerived"] = "leaves"
Two meshes rather than one because the halves want different materials: opaque bark, and alpha-tested double-sided foliage reading a baked canopy occlusion out of its vertex colours. They are real document nodes, like the spline's tube — saved, picked, materialled, physics-configurable — so a saved scene shows its trees with no editor and no generator present. The config is only needed to edit the tree again, and it travels in the same file.
The entry is the flat key=value;… format the rest of the file uses, one key per
TreeParams field, so a saved document reads as the parameter block it is:
seed=1337;trunkHeight=3.5;trunkRadius=0.18;crownShape=sphere;crownRadiusX=4;…;barkColor=0.3,0.22,0.15;leafColor=0.24,0.44,0.13
Enums are written as words (crownShape=sphere, barkStyle=furrowed,
leafStyle=crossQuad) rather than ordinals, so reordering one in the header
cannot silently reinterpret an existing document. Colours are one key holding a
comma-separated triple. TreeConfig::encode() / decode() own the format,
unknown keys are ignored on read, and write() never erases: the entry is the
tree.
Authoring. Select the tree and the inspector's Tree section offers the four species presets, the seed (with Randomize for another individual of the same species), and the generator's parameters grouped as trunk, crown, branching, bark, leaves and colours. Applying a preset replaces every parameter but the seed — a species is a whole description, and leaking half the previous one is how you get a whorled "oak". The branching group swaps its contents with the mode, because the space-colonisation builder and the conifer whorl builder take disjoint parameters and showing both would be forty knobs of which half do nothing.
Edits write the config, and the config regrows the tree. Like the spline's
tube, the meshes are derived state: syncTreeOverlays() follows the entry
wherever an edit, an undo, a redo or a load leaves it. That is what keeps undo
cheap — the command carries forty floats, not a canopy — and what makes a
deleted half come back on the next frame.
Two clocks, because the halves of the work cost three orders of magnitude apart (measured, RelWithDebInfo, across the four presets):
| cost | when | |
|---|---|---|
| geometry (skeleton + trunk + leaves) | 1.4 – 4.6 ms | immediately, so a dragged slider regrows the tree live |
| procedural bark + leaf atlases | 34 – 77 ms | once you let go of the widget |
The atlases are a function of only a handful of parameters (seed, bark style, leaf style/shape and the two colours), so changing the trunk height does not redraw the bark at all. Two trees whose atlas parameters match share the textures, which is what keeps a saved forest from embedding a base64 PNG per tree.
A tree the document already carries is adopted, not regrown: a loaded scene's meshes were generated from the config that loaded with them, so opening a forest costs nothing.
Add ▸ Spline creates a Group carrying userData["spline"], with four
control-point children forming an arc. The rule is one sentence:
Every direct child of a spline is a control point, in child order — except the generated mesh, which is tagged.
Nothing else marks them. A control point is a plain Object3D, its local
position is that point's position in the spline's space, and reordering,
renaming, deleting or dragging one is the ordinary operation on an ordinary
scene node. That is the entire point of the design: serialization, undo/redo,
the transform gizmo, the hierarchy, duplication and delete are the ones the
editor already has, and the spline feature adds none of them back.
The curve's own parameters ride in the same flat format as physics and animation:
type=centripetal;closed=0;tension=0.5;samples=24;mesh=none;radius=0.25;radialSegments=8;width=4;uvLength=4
| key | values | meaning |
|---|---|---|
type |
centripetal, chordal, catmullrom |
three.js's parameterisations; centripetal avoids cusps |
closed |
0, 1 |
join the last point back to the first |
tension |
0…1 |
catmullrom only — stored and inert for the other two, as in three.js |
samples |
1…200 |
curve samples per segment; drives the overlay and what the mesh is fitted from |
mesh |
none, tube |
what the spline generates as real geometry |
radius |
metres | tube — cross-section radius |
radialSegments |
3…64 |
tube — sides of the cross-section |
SplineConfig::encode() / decode() own that format, unknown keys are ignored
on read, and — unlike PhysicsConfig — write() never erases the entry: the
entry is the spline, so removing it would stop the object being one.
Authoring. Select the spline for type / tension / closed / samples and an
Add Point button, which appends past the last point along the last segment
so the curve visibly extends. Select a control point and the inspector shows its
index and Insert Before / Insert After, which put a new point midway to
its neighbour. Removing a point is Del. Every one of those is an undo entry.
The curve you see is not in the document. Control points draw nothing, so
the editor samples CatmullRomCurve3 into one Line per spline under the
editor-only overlay — never saved, never picked, never in the camera preview.
It is rebuilt when a hash over the point count, their positions and the encoded
config changes, which is what makes dragging a point redraw the curve live.
Control points additionally get a marker icon through the same machinery
cameras and lights use, so they are clickable in the viewport at all.
The mesh you generate is. Set Mesh to Tube in the spline section and the
spline grows one more child: a real Mesh, with a MeshStandardMaterial,
shadows on, carrying userData["splineDerived"] = "1". That tag is the only
thing separating it from a control point — every count and index in the editor
goes through SplineConfig::controlPoints() / pointIndexOf() rather than
children, so adding geometry never shifts a point index. It is a document node
in every other respect: it saves, it reloads, and a scene with a tube in it
renders and collides in a plain viewer with no editor present. Select it and the
ordinary Material and Physics sections apply — or put the physics on the
spline itself, which collides as the tube underneath it.
Tube sweeps threepp's TubeGeometry along the curve, at the radius and radial
segment count the config carries. A closed cross-section is also what a triangle
mesh collider handles well, which is why a tube needs nothing special to stand
on.
Regeneration is derived state, not a command. The undoable step is the
config edit; the sync pass then adds, rebuilds or removes the mesh to follow
whatever the config says — so undoing "Mesh = Tube" brings the mesh back on the
next frame's sync, and there is never a second entry on the undo stack fighting
the first. Rebuilds preserve the node: same object, same uuid, same material,
same userData, with only the geometry swapped (and the orphaned one disposed).
Anything you configure on the mesh survives dragging the control points around.
A loaded document already has its mesh, and the first sync adopts that one
rather than adding a second.
From a script. threepp.editor.spline_from_object turns an authored spline
into a SplinePath — no child filtering, no config parsing, and the authored
closed, curve type and tension are honored. It returns None when the
object is not a spline or has fewer than two control points, so the result of
get_object_by_name pipes straight in:
import threepp
class FollowSpline:
spline_name = "Spline" # the spline Group to follow
speed = 0.2 # laps per second
def start(self, obj: threepp.Object3D):
self.obj = obj
self.u = 0.0
self.path = threepp.editor.spline_from_object(
threepp.editor.scene().get_object_by_name(self.spline_name))
def update(self, dt: float):
if self.path is None:
return
self.u = (self.u + self.speed * dt) % 1.0
# get_point_at is arc-length parameterised, so the speed is constant
# along the curve rather than per segment.
self.obj.position.copy(self.path.get_point_at(self.u))Attach it to any object, set spline_name to your spline in the inspector, and
press Play. get_point_at(u) and get_tangent_at(u) answer in world space
(the follower's position is world space too, as long as it sits under the
scene root); get_length() is the arc length in the spline's local space, so a
scaled spline scales distances with it.
The path's contract in one sentence: the local-space curve — control points,
closed, type, tension — is captured when the path is created (or
refresh()ed), and the spline's world transform is applied live on every
sample. A spline riding a moving platform therefore stays followable for free,
while moving control points during Play needs path.refresh() before the
change bends the path. Sampling runs off the curve's own arc-length table,
independent of the overlay's samples density. For anything beyond points and
tangents the captured local-space curve is exposed as path.curve (a plain
threepp.CatmullRomCurve3), and Object3D.get_user_data(key) remains the raw
escape hatch: it returns any string userData entry — every editor config
(spline, physics, script fields) is one flat key=value;… string.
Add ▸ Conveyor creates a Group carrying userData["conveyor"], with
three waypoint children forming a straight run at working height. The authoring
model is the spline's, restated:
Every direct child of a conveyor is a path waypoint, in child order — except the generated parts group, which is tagged.
A waypoint is a plain Object3D; drag it with the gizmo, insert, delete and
undo like any node. The belt's parameters ride in the usual flat format:
width=0.6;speed=0.6;reverse=0;smooth=1;separator=0;wallHeight=0.5;rollerRadius=0.05;cleatHeight=0.15;cleatSpacing=0.6;samples=12;frame=1
| key | values | meaning |
|---|---|---|
width |
metres | belt width |
speed |
m/s | surface speed along travel; 0 = a static machine |
reverse |
0, 1 |
flip the travel direction |
smooth |
0, 1 |
Catmull-Rom through the waypoints, or the raw polyline |
separator |
0, 1 |
a collision-only guide wall along the path instead of a belt |
wallHeight |
metres | separator only |
rollerRadius |
metres | roller segments — cylinder radius |
cleatHeight, cleatSpacing |
metres | cleat segments — flight bar size and pitch |
samples |
2…64 |
resample density per waypoint segment |
frame |
0, 1 |
generate the support frame (rails, legs, end drums) |
Two things live on the waypoint node itself, under
userData["conveyorWp"] (radius=…;seg=flat|rollers|cleats), so they follow
the node through reorder, undo and serialization; the inspector's Conveyor
Waypoint section edits both:
- Corner radius — a non-zero radius rounds the corner at this waypoint with an exact circular fillet. The waypoint stays on the path; the arc's centre and tangent points are DERIVED, inserted tangent to both adjacent segments, and the radius clamps itself to what those segments allow — so a bend cannot kink, whatever gets dragged where. (Chained rounded corners split the straight they share rather than overlapping.) Under Play the whole bend is one rotating collider built from the same fillet, so the surface velocity is exactly tangential everywhere along it. A U-turn is two 90° corners.
- Segment surface — the span leaving the waypoint is a flat belt by
default; per segment you can choose a roller bed or cleats (flight
bars standing across the belt that travel with it and catch cargo on an
incline). Runs share boundary points, so a flat→rollers change meets
gap-free. A roller bed is not décor: each roller is a real driven
collider — a kinematic capsule spinning about its own axis at
speed / rollerRadius— and a rollers span builds no belt box underneath, so cargo genuinely rides the rollers, gaps included.
Walls belong to conveyors. The Add Wall button in the Conveyor section
attaches a wall as a child of the conveyor — a Group carrying
userData["conveyorWall"] (height=…) whose own children are the wall's
points, so dragging a point, rotating the whole wall with the gizmo, deleting
and undoing are all the ordinary operations. The default is one short
segment at the start of the belt, on the outer edge — a piece, not a plan.
Building the walls you mean is incremental: slide a segment along the belt
with the gizmo (it stays on the edge — see below), grow it point by point
(select an end point, Insert After, drag the new point where the wall should
reach next, repeat), and press Add Wall again for the next section — each
new wall tiles in a gap after the furthest one on the same side, then starts
over on the other side once that edge is built out. Sections are the model:
a stretch with no wall is just the gap between two wall pieces, which is
exactly where a diverter feeds cargo off the belt. Drag any point toward
the middle to sweep that stretch inward into a diverter. The built wall
always
FOLLOWS the path between its points — each point reads as a station along the
belt plus a lateral offset, blended between points — so an edge guide hugs a
bend exactly rather than cutting the chord, and its base rides the deck
throughout, slopes included: author entirely in plan, nothing ever wedges
underneath. Wall colliders are deliberately low-friction, so the grippy
belt keeps pushing while cargo slides along a plow into its lane (a wall built
from belt material would hold cargo like a hand instead). The old
whole-conveyor separator flag remains for free-standing rails between
machines.
The viewport helps you author this: every waypoint gets a clickable diamond
marker (the spline points' machinery, with its own glyph), the path overlay
carries chevrons pointing along the flow (they flip with reverse), and
selecting a rounded corner draws its derived arc centre and the two tangent
spokes, so what the radius is doing to the path is visible while it is tuned.
The ball on the arc midpoint is a radius handle: drag it along the
corner's bisector to widen or tighten the bend directly in the viewport (a
sharp corner offers the ball just off the waypoint, so a bend can be dragged
into being). The whole drag lands as one undo entry, exactly like a gizmo
move, and dragging past what the segments allow just pins the bend at its
maximum.
The look is generated, and it is first-party. Every conveyor carries one
tagged child (userData["conveyorDerived"]), a Group holding the parts: the
belt ribbon with a procedural scrolling texture, roller cylinders, cleat bars,
the separator wall, and a support frame — side rails following the path, legs
down to the conveyor's local ground plane, an end drum (pulley) at each open
end. All of it is generated geometry; there are no imported models anywhere in
the feature. Each part is tagged with its role in userData["conveyorRole"]
(belt / roller / cleat / wall / frame / drum), which is how the
play session finds the moving parts — and how your own tooling can.
Unlike a spline's single tube, regeneration is wholesale: the part count varies with the path, so the sync pass replaces the group's content whenever a waypoint or the config changes (the group node itself keeps its uuid). Treat the parts as output — a material you hand-edit on one belt mesh will not survive the next regeneration.
Play makes it convey. The conveyor session builds kinematic colliders into
the physics session's world: straight runs as chains of drag boxes, each bend
as one body rotating about its arc centre, tiled with convex wedges that share
their radial faces. Every physics substep the colliders' kinematic targets are
advanced along travel and then teleported back — the surface never moves, but
everything resting on it inherits the belt's surface velocity. That trick works
for rigid bodies and for soft bodies: the wedges are cooked GPU-compatible,
so a Body::Soft object dropped on a belt is carried like anything else.
Cleat bars are the one exception — a teleported-back wall would un-do its push,
so the bars genuinely travel, wrapping end→start with a teleport and folding
flat at the pulleys. Alongside the physics, the session scrolls the belt
texture, spins the rollers and drums, and drives visible bars along each cleat
track, all from one speed scale so the picture never disagrees with the
simulation.
A conveyor works without the editor. Everything above is plain scene
content, so a saved document carries the whole machine. An external consumer —
a soft-body simulation, a headless data generator — rebuilds the physics in a
few lines against its own PhysxWorld:
#include "threepp/extras/conveyor/ConveyorPhysics.hpp"
#include "threepp/extras/editor/ConveyorConfig.hpp"
std::vector<threepp::conveyor::ConveyorSpec> specs;
scene->updateMatrixWorld(true);
scene->traverse([&](threepp::Object3D& object) {
const auto config = threepp::editor::ConveyorConfig::read(object);
if (!config) return;
auto spec = config->spec(object); // local-space description
for (auto& wp : spec.waypoints) // → world space
wp.pos.applyMatrix4(*object.matrixWorld);
specs.push_back(std::move(spec));
});
threepp::conveyor::ConveyorPhysics belts(world, specs); // colliders + substep hooks
belts.speedScale = 1.f; // live speed control
// destroy `belts` before `world`; it unregisters and releases what it builtThe generated meshes are already in the document, so a consumer that only
renders needs nothing regenerated; one that wants to re-skin or re-tessellate
can call ConveyorConfig::syncDerived() — the same function the editor's sync
pass uses, so the content is identical either way. EditorConveyor_test
holds this whole path green: author → save → load → rebuild → a box conveys.
threepp.editor.rigid_body_from_object hands a script the body PhysX is
actually simulating, so it can push and steer it instead of fighting the
simulation by writing transforms that the next step overwrites:
import threepp
class Hover:
height = 3.0
stiffness = 40.0
def start(self, obj: threepp.Object3D):
self.obj = obj
self.body = threepp.editor.rigid_body_from_object(obj)
def update(self, dt: float):
if self.body is None:
return
# A spring toward `height`, damped by the body's own velocity.
error = self.height - self.body.position.y
lift = self.stiffness * error - 5.0 * self.body.velocity.y
self.body.apply_force(threepp.Vector3(0, lift * self.body.mass, 0))Read velocity, angular_velocity, mass, position, rotation,
sleeping, is_static and is_kinematic; write velocity,
angular_velocity and mass; call apply_force, apply_impulse,
apply_torque, apply_torque_impulse, wake_up, and — on a body authored
Kinematic — set_kinematic_target(position, rotation=None), which sweeps the
body so it pushes dynamics on the way rather than teleporting through them.
Forces are per-step, not settings: call them every update while the force
should act. Asking a static body for velocity, mass or forces raises rather
than answering a zero.
Scripts run after physics each frame, so a read sees the step that just
happened and a force lands on the next one. Which also means a force applied
from update is applied once per frame, at whatever rate the window happens
to be running — put it in
fixed_update instead and it is applied once
per substep, at a constant dt.
The body is also what
on_collision_enter / on_collision_exit
and
on_trigger_enter / on_trigger_exit
watch: same lookup, same object, and no handle to ask for — the session resolves
it at Play for any script that defines any of them. To ask about a body that
is not yours — what is under your feet, what is in front of you — cast a ray:
threepp.editor.raycast.
Soft bodies get threepp.editor.soft_body_from_object, which is deliberately
thinner — PhysX drives a deformable volume through per-vertex GPU buffers, so
there is no per-actor "push this" to expose. What it does answer is where the
thing is: center, bounds_min, bounds_max, vertex_count, and the
recompute_normals toggle. That matters more than it sounds, because a soft
body's object sits at the origin for the whole of Play — the mesh carries
world-space vertices — so obj.position is useless for following it and
body.center is the answer.
Robots being simulated (see Robots) get
threepp.editor.articulation_from_object — the joint-space face of the same
idea, and the seam a policy-playback script stands on. Read joint_names,
num_dof, joint_positions and joint_velocities (radians/metres, in the
articulation's own DOF order — fixed URDF joints are collapsed, so match by
name rather than assuming the inspector's slider order), plus the root link's
world root_position / root_rotation / root_velocity /
root_angular_velocity. Command it with set_drive_targets(values) (one per
DOF) or set_drive_target(joint, value) by name or index. Targets are PD
setpoints for the drive authored in the robot's Articulation section — the
joint is pulled there over the coming steps, not teleported, and with zero
authored stiffness the drive is off and targets are inert. Do not call
robot.set_joint_value from a script while the robot simulates: the mirror
writes the solved pose back every frame, so the simulation overwrites you —
the drive target is the steering wheel.
import threepp
class Waver:
amplitude = 0.6
period = 4.0
def start(self, obj: threepp.Robot):
self.art = threepp.editor.articulation_from_object(obj)
self.t = 0.0
def update(self, dt: float):
if self.art is None:
return
self.t += dt
import math
target = self.amplitude * math.sin(2 * math.pi * self.t / self.period)
self.art.set_drive_targets([target] * self.art.num_dof)The handle resolves from the robot or any node inside it — a script on a gripper link gets the whole robot's joint table, which is what a controller wants.
Three things separate these from spline_from_object:
- They only exist during Play. A spline is authoring data and reads back any
time; a body is not created until the physics session builds it, so both
functions return
Noneoutside Play (and for an object with no physics). - A handle belongs to the play session that made it. Stop releases every
actor, so a handle kept across a stop raises instead of reading freed memory —
check
body.valid, or just ask again instart(). A script's ownstop()is inside the session: sessions stop in reverse registration order, physics last, so the body is still live there — parking a robot or logging a final pose fromstop()is fine. What the check guards is a handle kept beyond the session, stashed somewhere the next Play can see. - They need the PhysX SDK. Without it the names are absent from
threepp.editorrather than present and always failing.
The lookup walks up the scene graph (like PhysxWorld::findActor), so a
script on a child of a physics object still finds the body governing it. It
resolves against the play session's own record of what it created, not
PhysxWorld's binding list — a static body is never bound, since it has no
pose to write back, and would otherwise be invisible to a script.
The play scene is an ordinary threepp graph, so a script builds and parents
objects into it with the ordinary calls — scene.add(mesh), remove, clear.
There is no editor verb for this and none is needed. Stop restores the scene
from the snapshot it took at Play, so whatever a script spawned simply is not
there afterwards; nothing has to be cleaned up.
threepp.editor.world() is the other half — the PhysxWorld the session is
stepping, so a spawned mesh gets a body the same way a standalone threepp
program would give it one:
import threepp
editor = threepp.editor
class Spawner:
def start(self, obj):
self.left = 5
def update(self, dt):
if self.left <= 0:
return
self.left -= 1
crate = threepp.Mesh(threepp.BoxGeometry(0.6, 0.6, 0.6),
threepp.MeshStandardMaterial())
crate.position.set(0.0, 6.0, 0.0)
editor.scene().add(crate) # it renders
editor.world().add(crate) # ...and it fallsDo not confuse this with editor.add(), which is a
generator verb: that one writes into the document, as one undoable step,
and refuses during Play precisely because Play must not touch the document.
scene().add() writes into the play scene, which is transient by
construction. Different targets, both correct.
Two limits worth knowing before leaning on this:
- The play sessions collect their objects once, at
start(). A spawned object gets no authored physics, no sensors, and no script of its own — even if you spawn it carryingscriptSourcein itsuserData.world().add()works because it goes straight to the world rather than through the authoredPhysicsConfigsweep. If a spawned thing needs behaviour, the spawner drives it. world().add()hands back a rawthreepp.RigidBody, not the lifetime-checkedthreepp.editor.RigidBodythatrigid_body_from_objectreturns. Within the session —update(), the callbacks, andstop(), which runs before the world goes down — it is fine. But it is not invalidated when the world dies, so one stashed beyond its session dereferences a released actor where the checked handle would raise. For anything held longer than the session, prefer the checked handle. This is the wheel's own contract, inherited: athreepp.RigidBodyis valid while its world is alive.
threepp.PhysxWorld is visible in the editor but cannot be constructed
there — its constructor raises, and says to use editor.world() instead. The
editor plays exactly one world; a second would stand up a second PhysX
foundation beside it. Every other method on the class is the one the wheel
binds, because it is literally the same translation unit.
Every controller script computes geometry nobody can see — the altimeter ray,
the contact normal, the drive target — and its only instrument used to be
print(). threepp.editor.draw_* puts those vectors in the viewport, over the
scene, for one frame:
| call | draws |
|---|---|
draw_line(a, b, color=None) |
a world-space segment |
draw_ray(origin, direction, length=1.0, color=None) |
origin + direction*length — pass hit.distance to see exactly the ray that hit |
draw_point(point, size=0.25, color=None) |
a small axis-aligned cross |
draw_box(center, size, color=None) |
the 12 edges of an AABB (size = full extents) |
draw_sphere(center, radius=1.0, color=None) |
three great circles |
draw_axes(object, size=1.0) |
the object's world frame, X red / Y green / Z blue |
color is a hex int (0xff8800) or a threepp.Color; default white.
def update(self, dt):
hit = editor.raycast(self.body.position, DOWN, self.probe, ignore=self.obj)
if hit is not None:
editor.draw_ray(self.body.position, DOWN, hit.distance, 0x25d6f0)
editor.draw_point(hit.point, 0.3, 0xff9430)
editor.draw_axes(self.obj)The rules, all of one piece:
- Immediate mode. A draw lasts the frame it was made in; a line that should
persist is redrawn every
update()— which is exactly when a script is called anyway. Nothing to clean up, nothing to leak. (Pause keeps the last picture on screen rather than blanking it.) - No-op outside Play, same reasoning as
is_key_downansweringFalseheadless: a script that draws must still run in a headless pass, just unseen. Nothing raises. - Drawn on top. Depth test off — the whole point is seeing the ray that
ends inside a mesh. Everything decomposes to segments into one overlay
LineSegments, so a thousand calls are still one draw. - Furniture, not scene. Never saved, never picked, and hidden during every
sensor scan — a lidar cannot range against your debug arrow. Screenshots
taken with
--screenshotdo show it, which is the point: a review pass can see what the controller was thinking, not just where the body ended up. - Capped (100 000 segments a frame), with one console line when a script
blows past it — a per-body-per-substep loop gone wrong must not freeze the
editor. Draw from
update(), notfixed_update(), unless you want a segment per substep.
The physics handles above read ground truth: articulation_from_object hands
back the exact joint angle the solver holds, to the last bit. A controller
written against that is a controller that has never met a sensor. The sensor
handles are the other half — the noisy, seeded, rate-gated numbers the
authored sensors actually produced, so a script can close its
loop on what the robot would really measure:
import threepp
class ElbowHold:
target = 0.6
gain = 0.5
def start(self, obj: threepp.Robot):
self.art = threepp.editor.articulation_from_object(obj)
self.enc = threepp.editor.encoder_from_object(obj, joint="elbow")
def update(self, dt: float):
if self.enc is None:
return
reading = self.enc.latest()
if reading is None:
return # no measurement yet this Play
# Closed on the MEASURED position - quantized to whole encoder ticks and
# noise-corrupted - not on the joint angle the solver knows.
command = reading.position + self.gain * (self.target - reading.position)
self.art.set_drive_target(self.enc.joint, command)Written in update this loop closes once per drawn frame, at whatever rate the
window happens to run. Rename the method to
fixed_update — nothing else about the
script changes — and it closes once per physics substep instead, at a constant
dt, which is the clock the measurements themselves are stamped on and the one a
deployed controller runs on. What it reads is still the batch drained at the end
of the last frame; what changes is that the command goes out at a rate the frame
rate cannot move.
Four lookups, one per proprioceptive sensor type:
| function | returns | reading fields |
|---|---|---|
imu_from_object(obj) |
Imu |
time, angular_velocity, acceleration |
encoder_from_object(obj, joint=None) |
Encoder |
time, position, velocity |
force_torque_from_object(obj) |
ForceTorque |
time, force, torque |
contact_from_object(obj) |
Contact |
time, touching, force |
The IMU's acceleration is specific force, so a level sensor at rest reads
+9.81 on its up axis and one in free fall reads ~0. The contact sensor's
touching is the latch: it stays True while resting on something, including
after PhysX puts the pair to sleep and force goes quiet — which is the channel
a foot-down check wants, and the reason there are two.
Every handle reads the same two ways:
latest()— the newest measurement, orNonebefore the first one. Does not move the handle's read cursor, so polling it in a control loop is free.read_new()— every measurement since this handle last read, oldest first, and advances its cursor. A fresh handle starts empty: it reports what arrives from now on, never a backlog you did not ask for. Each handle carries its own cursor, so two scripts reading one sensor never steal samples from each other, and neither starves the Sensors panel's plots or the CSV recording. Falling more than 256 readings behind loses the oldest.
That last point is the design constraint the whole thing is built around.
drain() empties a sensor's ring, so the session is the only party allowed to
call it — a second drainer would leave the panel plotting half the data. Handles
therefore read the session's own retained copies, never a live sensor and never
PhysX state. Which is also what makes them safe at Stop: physics stops first,
so anything reaching into a sensor from a script's stop() would be reading
through a torn-down SDK.
The encoder fans out. An encoder authored for All joints becomes one live
encoder per DOF (see Joint sensors), so
encoder_from_object(obj) with no joint raises when more than one answers
rather than picking an arbitrary one — pass joint="elbow", or take them all:
for enc in threepp.editor.encoders_from_object(robot):
print(enc.joint, enc.latest().position)Naming a joint no encoder measures raises too, listing the ones that do: a typo
answering None is indistinguishable from "Play is not running", and that costs
an afternoon.
Otherwise the contract is the physics handles' contract, for the same reasons:
- They only exist during Play. An authored
userData["sensor"]is not a sensor until the session builds one, so every lookup returnsNoneoutside Play, and for an object carrying no sensor of that kind. - A handle belongs to the play session that made it. Stop drops every
sensor, so a handle kept across a stop raises
RuntimeErrorinstead of reading freed memory — check.valid, or just ask again instart(). - They need the PhysX SDK. Without it the names are absent from
threepp.editorrather than present and always answeringNone. - The lookup walks up the scene graph, so a script on a child of an instrumented link still finds the sensor measuring it. The nearest ancestor carrying one wins.
Vision sensors (depth, lidar) are deliberately not here: a scan is tens of thousands of points, which wants a buffer protocol rather than a per-sample handle. Read those from the overlay, the panel, or a CSV recording for now.
A vision sensor's Near and Far bound the range it can report — a blind
sphere of radius Near, out to a maximum range of Far — and mean exactly the same
thing on GL as on Vulkan, inclusive at both ends. They are not view-space clip
planes: an object off to the side is judged by how far away it is, not by its
depth along some axis, so a Near large enough to hide the machine the sensor
rides on has to clear that machine's radius. (The shipped Hover Arena uses
1.6 m for exactly this reason: the drone's rotor rings reach 1.46 m from the
lidar.) SensorBackendParity_test scans one scene on both backends and pins the
two to the same answer.
Sensors are rebuilt from the authored seed on every Play, so a script that reacts to noise reacts to the same noise on the next run — a closed loop under test is reproducible, which is the entire point of the seed.
A vision scan is fired on one frame and delivered on a later one. On a raster backend the two are the same frame (the scan is six blocking framebuffer reads); on Vulkan the beams are traced against the renderer's acceleration structure and collected by a later frame's fence poll — never a wait. That is not a refinement, it is the difference between a smooth frame and a periodic stall: taking delivery means waiting on a GPU fence, and that fence sits behind every frame already queued. Measured on an RTX 4070 with two frames in flight, collecting a 1.2 ms VLP-16 trace immediately cost 28 ms, and the Hover Arena's 10 Hz lidar delivered that ten times a second — a 34 ms frame in an otherwise 15 ms scene. So the cloud a panel or the overlay reads is one frame old, at the pose the beams were fired from, and a sensor with a scan still owed does not fire again. Which frame a scan lands on is a property of the machine; what the seed guarantees — the same cloud, from the same pose, on every run — is unchanged.
Import Model… and file drops never block the UI: loads run one at a time on
a worker thread, with a spinner toast (bottom-left) showing the file and queue
depth. Success selects the new group and flashes a summary in the status bar;
failure opens a dialog with the loader's error. An import that finishes during
Play is parked until Stop, so it cannot be swallowed by the snapshot restore.
Passing a model path on the command line (threepp_editor model.glb) imports
it into a fresh template scene.
Object3D::copy() did not copy userData, so clone() silently dropped it —
a duplicated object lost its physics setup (and anything else an application had
attached). three.js copies userData in Object3D.copy, so this was a parity
bug rather than a design choice; it is fixed, with a test in
tests/core/Object3d_test.cpp.
PlaySession is three methods:
class MyRuntime : public threepp::editor::PlaySession {
public:
void start(threepp::Scene& scene) override; // build your state from the scene
void update(float dt) override; // one frame; not called while paused
void stop() override; // release everything
std::string name() const override { return "MyRuntime"; }
};Register it before the first Play:
playController.addSession(std::make_shared<MyRuntime>());Your session does not have to be reversible. Before any session starts, the
editor captures the whole scene with SceneSnapshot; on Stop it parses that
snapshot back into a fresh Scene and hands it to the document. Move objects,
retarget materials, delete half the graph — none of it survives Stop.
Two details that make this cheap and lossless:
- The snapshot does not embed images. It keeps the live
std::shared_ptr<Texture>objects keyed by uuid and re-binds them into the restored materials, so no texture is ever re-decoded or re-uploaded. - Sessions start in registration order. If one throws from
start(), the ones that already started are stopped again and the whole play attempt is abandoned — the editor never ends up half-playing.
The shipped PhysicsPlaySession reads PhysicsConfig from every object,
creates a static/dynamic/kinematic PxRigidActor with the requested shape (or a
soft body's deformable volume), and lets PhysxWorld write the poses back into
the scene graph each step. It is
header-only and compiled only when the PhysX SDK is found (vcpkg feature
physx); without it the editor still authors and saves physics settings, and
Play simply runs with no physics session. The sensor session is split along the
same line: SensorPlaySession is PhysX-free and runs the vision sensors (a
depth or lidar scan needs a renderer, not a physics world) in every build, while
PhysxSensorPlaySession — the subclass a PhysX build constructs instead — adds
the live IMU/contact/joint sensors. In a build without the SDK a body or joint
sensor is authored, counted, and carries a status naming the missing build.
Three sessions ship, and they start in this order: physics, then the animation player, then scripts. Scripts run last on purpose — a script's transform edits are the final word for the frame, after the simulation and the mixer have had their say.
threepp_player is the second front end over this same core: it
loads a scene.json and plays it with none of the editing machinery, for policy
validation and data generation.
threepp_player arena.json --headless --episodes=100 --seconds=20 --record=runs
It registers the same four sessions in the same order, runs each episode as a full play/stop cycle (so episodes are independent), records what the sensors measured, and exits nonzero if any script raised or the document would not play — which is what makes a scene something CI can gate on. See doc/player.md.
- Single selection. Multi-select, box-select and group transforms are not implemented.
- The viewport is the whole window. Panels are drawn over a full-window render rather than into a framebuffer sized to the free space, so the camera aspect is the window aspect. Picking and gizmo input are suppressed wherever ImGui owns the mouse, so this is invisible in use, but a very wide inspector does shift the visual centre.
- Texture previews are mosaics. A
Texture's GPU handle belongs to the renderer and is not exposed, so previews are drawn as a 12×12 grid of filled rects sampled from the CPU-side image. It reads correctly as a thumbnail at inspector size and behaves identically on every backend, but it is not a full-resolution image. - Vulkan is best-effort.
--vulkanis wired throughcreateRendererandImguiContextalready supports the Vulkan backend, but OpenGL is the tested and supported path. The editor always names its backend explicitly —createRenderer's "no preference" overload prints a console menu and blocks onstd::cin, which a windowed application must never do. - Scripting needs the Python it was built against. The editor embeds
CPython rather than shipping it: a binary built against 3.14 loads
python314.dlland the standard library from that installation, and on a machine without it the executable will not start at all. Deploying the editor with scripting therefore means shipping the matching runtime beside it (or building the editor without Python, where it still authors and saves scripts and simply does not run them). The interpreter is also deliberately never finalized —Py_Finalizefrom pybind11 3.0.4 access-violates on CPython 3.14 under Windows, measured with a barescoped_interpreterand no bindings at all — so the process exits with Python still up. Nothing leaks that the OS does not reclaim. - One script per object, in one form.
userData["script"]is a single path anduserData["scriptSource"]a single body of source, and an object carries one or the other. Several behaviours on one object means several child objects, or one script that composes them. - The Script Editor is a text box. No syntax highlighting, no completion, no
find-and-replace, and it edits inline source only. It does hold several
scripts open at once, one tab each, because that costs nothing but a vector
and the alternative — one buffer retargeting itself onto the selection — makes
writing two scripts that talk to each other a chore. The one thing it adds
over Notepad is the compile check; for anything more,
Edit in VS Codehands the same source to an editor that has all of it. - External editing syncs one way. The scratch file is the source of truth while a session is live: an undo in the editor moves the document out from under it without touching the file, and the next save puts the file's version back. Undo what you did in VS Code, or stop the session before undoing here. The stubs are also a superset of what a script can reach — they come from the full wheel module, while the script host binds ten areas of it — so a name that completes is not proof that it exists at Play.
- Generators have no parameters yet.
userData["generatorFields"]round-trips, but the values are not injected into the run and the inspector shows no inputs for them, so changing a count means editing the line. This is a design gap, not missing plumbing: a script's own module-levelcount = 400would overwrite any value injected as a global, so making the field authoritative needs a convention (read-with-default, or rewriting the assignment) rather than a wire-up. - Generators are one-shot, and only from the UI. A run appends nothing and
replaces everything: re-running rebuilds the whole output, so there is no
incremental or partial regeneration, and no general headless entry point —
batch generation over many seeds is not a one-liner yet.
(
apps/editor/tools/HoverArenaAuthor.cppis one for exactly one scene, and it is the shape a general one would take: set the sink, run the source, attach on success.) There is also no bake step, so keeping generated content while dropping its rule means clearing the script and accepting that Clear takes the output with it. - Splines are Catmull-Rom only. One curve type, parameterised three ways —
no Bézier handles, no per-point tangents or twist, and no closed-form
reordering of control points beyond dragging them in the hierarchy. A spline
is also not a path anything follows by itself: it is data, and a script (or
your own
PlaySession) is what walks it. - Generated geometry is derived, so removing it is destructive. Setting Mesh back to None deletes the node; undoing that config edit re-derives a new mesh rather than restoring the old one, and the material, physics and name you had put on it are gone with the node. Undo covers the config edit, which is all it claims to. Nothing else loses that state — dragging points, changing the radius, reloading the document all rebuild through the same node. There is also one generated mesh per spline, and one material with it.
- Duplicate shares geometry.
Object3D::clone()shares both geometry and materials; the editor clones the materials afterwards so recolouring a copy does not recolour the original, but geometry stays shared. That is intentional (duplication stays cheap), and it means a future mesh-editing feature would need an explicit "make unique" step.
{ "python.analysis.stubPath": "C:/dev/threepp/python/threepp", "python.analysis.diagnosticSeverityOverrides": { "reportMissingModuleSource": "none" }, // "python.defaultInterpreterPath": "C:/Users/you/mambaforge/envs/robostack/python.exe", "files.eol": "\n", "editor.insertSpaces": true, "editor.tabSize": 4 }