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.

TypeScript
// 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 (also activable via ui.toolButton, deactivated by Esc). While active, every viewport click calls onViewportClick(hit, editor):

TypeScript
type EditorRayHit = {
  point: [number, number, number]    // world-space hit position
  normal: [number, number, number]
  node: string | null                // absolute path of the 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. Nodes are addressed by absolute path ('city/in1/pt1'; a root node's path is its bare name):

Member What it does
selection / select(path) The selected node's path (read / change); may be a path::part key for asset internals.
nodes() The document's nodes: { path, name, kind }[] (path = identity, name = sibling-unique display name).
uniqueName(base, parentPath?) First unused base, base2, … among the siblings under parentPath (scene root when omitted).
addNode(name, def) Add a ROOT node from plain def data (scene-file grammar; a string model value is an asset path). Names may not contain / or :.
setProp(path, key, value) Write one def prop — transforms apply live, the rest patches.
removeNode(path) / duplicate(path) Remove / duplicate (returns the copy's path).
raycast(x, y) The same viewport raycast tool clicks get.
transact(fn) Group every doc edit inside fn into ONE undo step.

Tool hooks may also draw viewport overlay lines through the global Gizmos (line / polyline / cross / frustum, world-space points, or { node }-anchored — pickable, following the node) — see scene-files.md. The picture is cleared on every hook call and on tool switch, so a hook draws its complete picture each time (a preview of where a click will place something, say).

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.