LeCodesdocs

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 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):

TypeScript
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.