Editor plugins — registerEditorWindow / registerEditorTool
Extend the le.codes scene editor from project code: overlay panels and viewport tools that
automate routine scene assembly (scattering props, wiring nodes, measuring). Registrations live in
*.editor.ts files — compiled and executed only in editor bundles (scene
files), so plugins cost zero shipped bytes.
// scatter.editor.ts — editor bundles only
const state = { model: "", count: 5 }
registerEditorWindow("Scatter", (ui, editor) => {
state.model = ui.asset("model")
state.count = ui.slider("count", { min: 1, max: 20, step: 1, value: 5 })
ui.toolButton("Paint", "scatter")
ui.info(`${editor.nodes().length} nodes in the scene`)
})
registerEditorTool("scatter", {
cursor: "copy",
onViewportClick(hit, editor) {
editor.transact(() => { // the whole paint = ONE undo step
for (let i = 0; i < state.count; i++) {
editor.addNode(editor.uniqueName("prop"), {
model: state.model,
position: [ hit.point[0] + Math.random(), hit.point[1], hit.point[2] + Math.random() ],
eulerAngles: [ 0, Math.random() * 360, 0 ],
})
}
})
},
})Plugins write the document, never live state
Every editor write lands in the scene file through the editor's normal commit path — so a
plugin action is undoable (one doc-patch stack for humans and plugins alike), shows up as an
ordinary git diff, and applies to the running viewport at the cheapest live tier. A buggy plugin
can at worst write bad data; it cannot corrupt live engine state.
Windows
registerEditorWindow(title, (ui, editor) => …) adds a collapsible panel docked over the
viewport (the inspector rail stays selection-scoped — windows are editor-global). A window that
emits ui.toolButton for a tool is that tool's settings panel: activating the tool expands
and highlights it, so the settings sit right where the tool is used. The
render function is immediate-mode — the same InspectorUI protocol as custom inspector
cards: it re-runs on every interaction
(and on every document change) and describes the panel with widget calls. All window field keys
are transient editor state (windows have no doc entry) — brush sizes, picked assets; they
survive re-renders but are never written to a file. Two window-flavored widgets join the
vocabulary:
| Widget | What it does |
|---|---|
ui.asset(key) |
Project-asset dropdown (the project's GLB paths). Returns the path, "" = none. |
ui.toolButton(label, tool) |
Toggles the named viewport tool — renders pressed while it's active. |
To share widget values with a tool, copy them to module state in the render (the example above) — immediate-mode returns the current value on every run.
Viewport tools
registerEditorTool(name, hooks) adds a toolbar entry beside move/rotate/scale (also activable
via ui.toolButton, deactivated by Esc or W/E/R). While active, the transform gizmo hides,
selection clicks are suspended, and every viewport click calls onViewportClick(hit, editor):
type EditorRayHit = {
point: [number, number, number] // world-space hit position
normal: [number, number, number]
node: string | null // the scene-file node hit, null = ground plane
}On physics builds the hit comes from a precise raycast against the scene's meshes; otherwise (and
on misses) it falls back to the ground plane (y = 0). hooks.cursor sets the viewport's CSS
cursor (default "crosshair"); hooks.icon sets the toolbar button's glyph (one character /
emoji — tools without one all share a generic wand, so set it whenever an editor registers two or
more tools). A throwing hook logs and skips — it can't take the editor down.
The editor API
Handed to window renders and tool hooks:
| Member | What it does |
|---|---|
selection / select(name) |
The selected node name (read / change). |
nodes() |
The document's nodes: { name, kind }[] (mesh / model / light / group). |
uniqueName(base) |
First unused base, base2, … name. |
addNode(name, def) |
Add a node from plain def data (scene-file grammar; a string model value is an asset path). |
setProp(name, key, value) |
Write one def prop — transforms apply live, the rest patches. |
removeNode(name) / duplicate(name) |
Remove / duplicate (returns the copy's name). |
raycast(x, y) |
The same viewport raycast tool clicks get. |
transact(fn) |
Group every doc edit inside fn into ONE undo step. |
Defs with a model asset re-run the scene compile (the bundle must vendor the asset URL);
mesh/light/group defs apply as live patches with no compile.
The __editorPlugins registry the registrations feed is internal — the scene editor's harness
reads it; never touch it directly.