Editor plugins — registerEditorWindow / registerEditorTool

Extend the le.codes scene editor (lecodes scene) from project code: panels and viewport tools that automate scene assembly (scattering props, painting, wiring nodes, measuring). A panel for ONE selected node is a custom inspector card instead; placing many copies from game code is make().

  • add a custom panel to the scene editor
  • scatter or place props in the scene with a click
  • paint objects with a brush tool by dragging over the ground
  • add, change and remove scene nodes from an editor script
  • make a plugin's edits one undo step
  • draw helper lines and a brush cursor in the editor viewport

Platforms: the scene editor only (lecodes scene, in the browser) — *.editor.ts runs in editor bundles, never in a shipped app on iOS, Android, desktop or the web.

At a glance: a scatter panel and a click tool

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",
  icon: "⁂",
  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 ],
        })
      }
    })
  },
})

Register in a *.editor.ts file: registerEditorWindow / registerEditorTool

TypeScript
registerEditorWindow(title: string, render: (ui: InspectorUI, editor: EditorApi) => void): void
registerEditorTool(name: string, hooks: EditorToolHooks): void

Registrations live in *.editor.ts files — compiled and executed only in editor bundles (scene files), so plugins cost zero shipped bytes. Registering a title (window) or a name (tool) again replaces the earlier registration in place. The registry they feed is internal — the scene editor's harness reads it through the editor contract, which only an editor bundle is given; never touch it directly.

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.

Add a panel to the editor: registerEditorWindow

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 widgets matter most here:

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.

Click, drag and brush tools in the viewport: registerEditorTool

registerEditorTool(name, hooks) adds a toolbar entry (also activable via ui.toolButton, deactivated by Esc). While it is active the viewport's pointer goes to its hooks:

TypeScript
type EditorToolHooks = {
  cursor?: string        // the viewport's CSS cursor while active (default "crosshair")
  icon?: string          // the toolbar glyph — one character / emoji
  onViewportClick?(hit: EditorRayHit, editor: EditorApi): void
  // drag tools (brushes): a primary-button drag strokes instead of orbiting the camera
  onDragStart?(hit: EditorRayHit, editor: EditorApi): void
  onDrag?(hit: EditorRayHit | null, editor: EditorApi): void    // every move; null while off-world
  onDragEnd?(editor: EditorApi): void
  onHover?(hit: EditorRayHit | null, editor: EditorApi): void   // no button down, once per frame at most — a brush cursor
}

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
}

A tool with onDragStart is a drag tool: its primary-button gestures arrive as strokes, never as onViewportClick (a right-drag still orbits); onDragEnd also ends a cancelled stroke. The hit is a physics raycast against the bodies in the world — edit mode attaches no scene-file aspects, so only what code built (make() output, generators) has one — then terrain nodes, and on a miss the ground plane (y = 0). Tools without an icon all share a generic wand, so set one whenever an editor registers two or more tools. A throwing hook logs and skips — it can't take the editor down.

TypeScript
// brush.editor.ts — a ring cursor on hover, a stone per drag move
const brush = { radius: 1 }

registerEditorWindow("Brush", (ui) => {
  brush.radius = ui.slider("radius", { min: 0.25, max: 5, step: 0.25, value: 1 })
  ui.toolButton("Brush", "brush")
})

const ring = (c: [number, number, number], r: number) => {
  const pts: [number, number, number][] = []
  for (let i = 0; i < 24; i++) {
    const a = (i / 24) * Math.PI * 2
    pts.push([ c[0] + Math.cos(a) * r, c[1] + 0.02, c[2] + Math.sin(a) * r ])
  }
  Gizmos.polyline(pts, { color: "#ffd166", closed: true })
}

registerEditorTool("brush", {
  icon: "◎",
  onHover(hit) { if (hit) ring(hit.point, brush.radius) },
  onDragStart(hit) { ring(hit.point, brush.radius) },
  onDrag(hit, editor) {
    if (!hit) return
    ring(hit.point, brush.radius)
    editor.addNode(editor.uniqueName("stone"), { mesh: { kind: "sphere", radius: 0.1 }, position: hit.point })
  },
})

Add, change and remove scene nodes: 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 | null) 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, kind = the source).
uniqueName(base, parentPath?) First unused base, base2, … among the siblings under parentPath (scene root when omitted).
addNode(name, def): boolean Add a ROOT node from plain def data (scene-file grammar; a string model value is an asset path). Names may not contain / or :; false when the name is taken or invalid.
getProp(path, key) One def prop as document data (asset() refs come as { $asset: "./x" }); undefined for an unknown node or key.
setProp(path, key, value): boolean Write one def prop — transforms apply live, the rest patches. False for an unknown node.
removeNode(path) / duplicate(path) Remove / duplicate (returns the copy's path, or null).
raycast(x, y) The same viewport raycast tool clicks get (viewport pixels), or null.
transact(fn) Group every doc edit inside fn into ONE undo step.
writeAsset(path, ref, name, bytes) Write a binary file for a node (a terrain's .terrain, a baked mask): overwrite its existing ref, or with ref: null create ./<name> at the project root (a free name on collision). Resolves the ref to store in the def (setProp(path, key, { $asset: ref })), null when the host can't write files. Not a doc edit — not undoable.

Draw helper lines and a brush cursor in the viewport: Gizmos

Tool hooks 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, a brush ring — the brush example above).

See also

  • scene-files.md — the scene-file format, InspectorUI cards, Gizmos, EDITOR
  • level.md — placing many copies in code (make() factories) instead of by hand