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
// 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
registerEditorWindow(title: string, render: (ui: InspectorUI, editor: EditorApi) => void): void
registerEditorTool(name: string, hooks: EditorToolHooks): voidRegistrations 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:
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.
// 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,
InspectorUIcards,Gizmos,EDITOR - level.md — placing many copies in code (
make()factories) instead of by hand