Scene files — defineScene

A 3D scene written as data: one defineScene({...}) literal, the default export of a .scene.ts file, that places models, meshes, lights, the camera and their aspects and opens them as the shown scene. The le.codes scene editor (lecodes scene) reads and writes these files; they are equally fine by hand. A scene built in code is Scene; the 2D twin is defineScene2d.

  • lay out a 3D level in a file and open it
  • get a node of the loaded scene by its name or path
  • place models, meshes, lights and the camera in a scene
  • attach a behaviour to a node and set its fields
  • point a behaviour at another node of the scene
  • reuse a scene file as a prefab or spawn copies of it at runtime
  • attach a weapon or a prop to a bone of a model
  • control how an aspect shows and edits in the scene editor

Platforms: every host; the env blocks (baked lighting, navigation, vegetation) are not everywhere — their sections say where — and the editor parts (static fields / editor / inspector, Gizmos, EDITOR) run only in the scene editor.

At a glance

TypeScript
// city.scene.ts
import { Spinner } from './spinner'

export default defineScene({
  env: { skybox: '#10131a', bloom: true },
  nodes: {
    eye: {                                        // the camera: a place + its lens
      position: [0, 6, 12],
      eulerAngles: [-26, 0, 0],
      aspects: [use(CameraPlace, { fov: 50, active: true })],
    },
    ground: {
      mesh: { kind: 'box', size: [20, 1, 20] },
      material: { lit: { color: '#444444' } },
      position: [0, -0.5, 0],
      aspects: [use(Shape, { box: [10, 0.5, 10] }), use(Physics, { motion: 'static' })],
    },
    hero: {
      model: asset('./hero.glb'),
      position: [2, 0, 0],
      aspects: [use(Spinner, { speed: 2 })],
      children: {
        halo: { mesh: { kind: 'sphere', radius: 0.2 }, position: [0, 2, 0] },
      },
    },
    sun: { light: { kind: 'sun', shadowsQuality: 2 } },
  },
})
TypeScript
// main.ts
import city from './city.scene'

const main = async () => {
  const { scene, nodes, get } = await city.open()
  nodes.hero.anim.play('idle')          // nodes[path] — a root node's path is its bare name
  nodes['hero/halo'].visible = false    // nested nodes are addressed by their full path
  get('hero/halo')                      // dynamic lookup — Node | null for unknown paths
}
main()

The handle

defineScene returns a SceneHandle:

Member What it does
load() Build the scene (async — models fetch, make() factories run). Idempotent: one instance per handle. Resolves { scene, nodes, get }; does not show it.
open() load() + scene.open() — make it the shown scene. Resolves the same { scene, nodes, get }.
instantiate({ scene, parent?, name? }) Build the file as a reusable subtree inside an existing scene — as many times as you like (below).
def The literal you passed in.

nodes is keyed by absolute path — /-joined names from the scene root ('hero/halo'); a root node's path is just its name. Each entry is typed by its source: mesh → Mesh, model → Model, light → Light, anything else → Node — with the use(...)d aspects reflected in the type (nodes.hero.spinner). Names must be unique among siblings only (non-empty, no / or : — an invalid name rejects load()) — duplicating or reparenting a group never forces renames inside it. get(path) is the dynamic accessor (typed for the scene's literal paths, Node | null for arbitrary strings). Don't rely on the record's key order — it follows build completion, not file order.

Node entries

Key Meaning
mesh { kind: 'box' | 'sphere' | 'cylinder' | 'capsule' | 'plane', …geometry options } — box takes size, the rest their Mesh factory's options
model GLB url — asset('./hero.glb'). Nodes naming the same GLB share one load (each is a clone)
light { kind: 'sun', …SunOptions } (light.md)
terrain A heightmap ground: a .terrain file or a fresh grid, its layers, stamps and holes — see terrain.md
make A code-built subtree — make(factoryFn, { …literal args }) (below)
prefab Another scene file used as a reusable composition — its imported handle (below)
camera { fov?, near?, far? } — this node IS the scene camera (the older form of CameraPlace)
material For a mesh: { lit: { color?, map?, roughness?, metallic? } }, { unlit: { color?, map?, transparent? } }, { shadow: color }, { shader: asset('./x.mat'), params: { … } } (a custom Filament shader + its parameter values), a material asset (import gold from './materials/gold.material' → material: gold), or a shared Material instance. map and sampler params take asset('./x.png'); a colour is a '#rrggbb' string, a vector an array. See Material assets
position, eulerAngles, scale, visible Transform / visibility, local to the parent (eulerAngles in degrees, scale a number or [x, y, z])
locked Editor-only: viewport manipulation won't target this node (fields still edit). No runtime effect
editor Editor-only, model nodes: { clip?, time? } — the pose the scene editor shows (the clip looped, or frozen at time s), so attachments and sight lines are placed against the animated pose. Never applied when the scene runs
castShadows, receiveShadows Real-time shadow flags of a mesh, a model (every renderable of the GLB instance) or a terrain node
lightmap Baked lighting (a scene with env.lightmap): override the static verdict — by default a model/mesh/terrain node is static unless something moves it (below)
foliage A model node: vegetation — true loads it through the foliage tier (wind, touch bending, per-copy tint), { fade: [start, end] } also thins it out over that distance range (m). The fade is per asset: every node of the same GLB shares it. See foliage.md
nav, navArea Navigation (a scene with env.navmesh, below): nav: false leaves this static body out of the bake, 'unwalkable' cuts its footprint out; navArea names the area (a key of env.navmesh.areas) its surface belongs to
aspects [use(Ctor, props), …] — attach order matters (e.g. Shape before Physics)
children Nested node entries, parented under this node
overrides Model/prefab sources: transform overrides for the internal nodes (below)
mount On a child of a model/prefab node: parent it to that internal part (below)

At most one source block per node — a second one rejects load() (declares more than one source). Every def node joins the scene's draw set (membership and parenting are separate — see node.md).

Baked lighting (env.lightmap)

TypeScript
env: {
  skybox: { texture: SKY },
  lightmap: {
    data: asset('../assets/lightmap/level.bake'),
    light: asset('../assets/lightmap/level-light.ktx2'),     // one atlas page, or [page0, page1, …]
    aux: asset('../assets/lightmap/level-aux.ktx2'),         // pages like `light`
    probes: asset('../assets/lightmap/level-probes.ktx2'),   // optional: reflection probes
    volume: asset('../assets/lightmap/level.lgrid'),         // optional: the light grid for movers
  },
},

With env.lightmap set the level is a baked one (lightmap.md has every key and the bake) and the statics are derived, not declared: every model / mesh / terrain node is a lightmap static — a receiver and an occluder in the bake, no real-time shadow casting once the bake applies — unless something moves it: a Physics aspect (dynamic, Physics' default, or kinematic) or a CharacterController. Children of a mover are movers too. A physics-less decoration is static; a barrel with use(Physics, { motion: 'dynamic' }) keeps its real-time shadow. lightmap: true | false on a node overrides the rule for the exceptions (a code-animated prop without physics → false; a kinematic platform that never actually moves → true). Prefab subtrees inherit their wrapper's verdict; make() subtrees are code and register themselves with Lightmap.add. With a volume, the movers take their ambient light from the bake's light grid.

Keys in lightmap.bake are node paths (perimeter/metalfence1), so a rename or a move means a rebake (lecodes lightmap bake — the bake runs where the runtime would apply it: when load() has built every node). Edit mode ignores the block (the editor renders real-time shadows); its inspector shows the verdict as a "Baked lighting" switch on model/mesh nodes and writes the lightmap key only when you flip it away from the rule.

Vegetation (env.foliage)

TypeScript
env: {
  foliage: { wind: { direction: [1, 0.3], speed: 6, gust: 0.6 }, touch: 0.6, variation: 0.35 },   // speed in m/s
},
nodes: {
  grass: { model: asset('./grass.glb'), foliage: true },
},

env.foliage is Foliage.configure's options, applied before the models load; a node's foliage: true picks the tier for that model (all its copies). See foliage.md.

TypeScript
env: {
  navmesh: {
    data: asset('../assets/nav/yard.navmesh'),                     // what `lecodes navmesh bake` writes
    agent: { radius: 0.4, height: 1.8, climb: 0.4, slope: 50 },   // the size the mesh is built for (these are the defaults)
    areas: { road: 1, mud: 4 },                                    // named areas → traversal cost (1 = plain ground)
  },
},
nodes: {
  pond: { mesh: { kind: 'box', size: [6, 0.2, 6] }, aspects: [use(Shape), use(Physics, { motion: 'static' })], nav: 'unwalkable' },
},

Walkable is derived like the lightmap statics: every static Physics body (what a CharacterController collides with). nav / navArea are the per-node overrides; they apply to this file's own nodes, not to prefab internals. The mesh loads once load() has built every node; edit mode never loads it. See navmesh.md.

Material assets

A material can be its own file — defineMaterial({ … }) in a *.material.ts — and be imported by any scene (or by code: await gold.load() gives the Material). It is ONE shared instance: every node using the asset renders the same material, and editing the asset changes all of them (two looks are two assets). The scene editor lists material assets in its asset drawer, edits their parameters live, and offers "Save as asset…" on any inline material.

TypeScript
// materials/holo-glass.material.ts
export default defineMaterial({
  shader: asset('../assets/shaders/lens.mat'),
  params: {
    tint: '#1d2b26',
    opacity: 0.22,
    mask: asset('../assets/weapons/reticle.png'),   // a sampler parameter takes an image asset
  },
})

// materials/gold.material.ts — a built-in kind, same options as the inline form
export default defineMaterial({ lit: { color: '#c9a227', roughness: 0.35, metallic: 1 } })

// range.scene.ts
import holoGlass from './materials/holo-glass.material'
export default defineScene({ nodes: { lens: { mesh: { kind: 'plane' }, material: holoGlass } } })

Custom shaders need no metadata at runtime: a string parameter is a colour when it starts with #, a texture URL otherwise. The editor's parameter rows (widgets, ranges, defaults, tooltips) come from annotations in the .mat itself — see Material › Editor annotations.

overrides — posing a GLB's internal nodes

A model node may reposition the nodes inside its GLB without touching the file. Keys are part paths — /-joined node names from the model root down; each value takes position, eulerAngles, scale, visible, and materials (the part's primitive slots by index — a material asset, an inline def or a custom shader; slots left out keep the glTF material):

TypeScript
robot: {
  model: asset('./robot.glb'),
  position: [2, 0, 0],
  overrides: {
    'Body/Head': { eulerAngles: [0, 30, 0] },
    'Body/ArmL': { position: [-0.7, 0.2, 0], visible: false },
    'Body/Visor': { materials: { 0: holoGlass } },   // slot 0 of the visor mesh → a material asset
  },
},

A segment is the child's name; duplicates among siblings disambiguate as name[i], unnamed nodes as [i] (index within that same-named group). These part paths are the asset-internal half of an address — the editor joins them to a def node's path with :: (city/lamp::Body/Head). The scene editor writes them for you — expand a model node in its tree and drag a part. Paths that no longer resolve (the GLB changed) are skipped silently. Overrides apply once at load, after the model instantiates; a playing animation clip will overwrite the transforms of the joints it drives.

mount — attaching a node to an internal part

A child of a model/prefab node can parent itself to one of the asset's internal nodes instead of the asset root — a flashlight in a hand, a marker on a bone. mount takes the same part-path grammar as overrides keys:

TypeScript
robot: {
  model: asset('./robot.glb'),
  children: {
    flashlight: { mesh: { kind: 'cylinder', radius: 0.05 }, mount: 'Body/ArmR', position: [0, 0.2, 0] },
  },
},

The def stays an ordinary child of the model node — its path (robot/flashlight), refs, and editing all work unchanged; only the runtime parent differs. The transform is local to the part (its scale included), so the node follows the part through overrides and animation. A path that no longer resolves falls back to the asset root with a console warning. In the scene editor, drag a node onto a part row to mount it; drag it onto the model (or anywhere else) to unmount.

The camera — CameraPlace

A scene has ONE camera (scene.camera, camera.md). A CameraPlace aspect marks a node as a place that camera can be — the node's pose plus the lens used there. Any node can carry one: an empty, an empty mounted on a bone, a node a movement aspect carries (a dolly shot), a model.

TypeScript
eye: {
  position: [0, 1.6, 4],
  aspects: [use(CameraPlace, { fov: 50, far: 5000, active: true })],
},
TypeScript
class CameraPlace extends Aspect<'cameraPlace'>   // node.cameraPlace
  fov = 60          // vertical field of view, degrees
  near = 0.01       // near clip distance, m
  far = 1000        // far clip distance = view range, m
  active = false    // the place that drives scene.camera when this file runs
  applyTo(camera: Camera): void   // copy fov / near / far onto a camera
  • When the file runs (load() / open()), the active: true place drives the camera — with none marked, the first place in file order does. That is scene.camera.follow(node): every late frame the camera takes the node's world pose (looking down the node's local −Z), and the place's fov / near / far become the projection (unset fields = the defaults above). Move the node — a FollowPath on it, a moving parent, the bone it is mounted on — and the view moves.
  • Inside a prefab: or an instantiate()d file a place is inert data — the hosting scene owns its camera. Read it (inst.nodes.head.get(CameraPlace)), anchor on it (inst.alignTo(…), below), or cut to it from code: scene.camera.follow(inst.nodes.head) applies its lens too; scene.camera.follow(null) releases the camera where it is.
  • Precedence: a CameraPlace beats a camera node, which beats the top-level camera: block.
  • In the editor a place draws an anchored frustum marker (white when active) and seeds the editor's starting viewpoint; the fov / near / far written in the file apply to the editor's view too (it is a property of the scene, not of your viewpoint).

Camera nodes

camera: {} makes a node the scene camera the same way — the view follows the node's world transform every frame (position + orientation; the camera looks down the node's local −Z) — the form scene files used before CameraPlace, still honoured. Because it's an ordinary node, everything composes — parent it, animate it with the scenario aspects (FollowPath on a camera node is a flythrough), or drive it from your own aspect:

TypeScript
camera: { camera: {}, position: [0, 5, 10], eulerAngles: [-26, 0, 0] },

The block also carries the lens — fov (vertical, degrees, default 60), near (0.01) and far (1000, the view range). Omitted keys keep the host default, and unlike the pose the projection applies in the editor too:

TypeScript
camera: { camera: { fov: 45, far: 5000 }, position: [0, 5, 10] },

The first camera node in file order wins; camera nodes inside a prefab are ignored (a prefab's own camera:/env: blocks already are). The older top-level camera: { position, target, fov?, near?, far? } block still works as a fallback when no camera place or node exists — it seeds the view once and nothing tracks it afterwards. In the visual editor a camera node shows as a frustum marker (an anchored editor gizmo — click it to select the node), the editor's own orbit camera stays independent, the Set from current view inspector button poses the node from your viewpoint, and the last camera node can't be deleted.

Empties (plain group nodes)

A node with no source block is a group — an invisible transform. The editor calls these Empty and draws a small axis-cross marker for them while editing — an anchored editor gizmo (see below): overlay lines that follow the node, pick it on click and take the selection colour, never part of the scene (nothing in the Rendered view, nothing at runtime). Empties are the working material of no-code scenes: MoveTo targets, FollowPath waypoints (children of a path empty), LookAt subjects, CameraPlace hosts, and plain folders for organizing children.

The generated-file header

Every write from the visual editor starts the file with a two-line comment saying the file is editor-managed. Hand edits remain first-class — values outside the literal grammar are preserved verbatim and shown read-only in the inspector — but formatting, key order, and number rounding (4 decimals) are normalized on the next editor write. Hand-written scene files gain the header the first time the editor writes them.

make(fn, args) — code-built nodes

When a subtree is naturally the output of a function — a fence line, a spiral stair, a procedural cluster — reference the factory instead of hand-writing nodes:

TypeScript
// props.ts — a plain function returning a Node (or a Promise of one)
export const buildFence = (args: { posts?: number, gap?: number }): Node => {
  const root = new Node()
  for (let i = 0; i < (args.posts ?? 5); i++) {
    const post = Mesh.box({ size: [0.12, 1.2, 0.12] })
    post.position = [i * (args.gap ?? 1), 0.6, 0]
    root.add(post)
  }
  return root
}

// city.scene.ts
fence: {
  make: make(buildFence, { posts: 6, gap: 1.2 }),
  position: [-5.5, 0, 3.5],
},

The factory runs after every scene node exists (so ref() args resolve, forward references included) and may be async — load() waits for it, so the content is in place when open() resolves. Its returned subtree mounts under the def node (inside a __generated container child). The split of ownership is the point:

  • The def owns the transform. Moving/rotating the node is an ordinary transform edit — the factory is never re-called for it.
  • The args own the content. Args must be literal data (the same grammar as aspect props, ref() included); in the scene editor each arg is an inspector field, and editing one re-calls the factory live — no compile, since the function is already in the running bundle. A ref() arg also re-calls when the referenced node changes.

Keep factories pure builders: same args → same subtree, no side effects outside the returned nodes. make mirrors use(Ctor, props) deliberately — callee by identifier, literal props — which is what keeps the call editable; a call written any other way (computed args, inline function) is preserved verbatim but shows as "set in code". For lines, grids, rings and scattered placements of many copies, Level builds the group for you.

prefab — scene-in-scene

A .scene.ts file already is a data-described composition — so scene files can instance each other. Import another scene's handle and use it as a source block:

TypeScript
import streetlamp from './streetlamp.scene'

lamp: {
  prefab: streetlamp,
  position: [4, 0, 2],
  overrides: { head: { visible: false } },
},

Each instance builds the prefab's nodes fresh under a plain wrapper node — instances are independent, and editing the prefab file changes every instance on the next load. Semantics:

  • The wrapper is an ordinary node — transform, visible, locked, aspects, children all work; the instance's internals live under it.
  • ref()s inside a prefab resolve file-locally, per instance — a prefab's aspects and make() factories see that instance's own nodes, never the instancing scene's. The prefab's internals don't appear in the instancing handle's path-keyed nodes map.
  • overrides poses the internals exactly like a GLB's — the same part-path grammar, addressed by the prefab's node names ('pole/bulb'); nested models and prefabs inside the prefab drill further ('lamp/head'). In the scene editor, expand a prefab node in the tree and drag a part — the edit persists as an override.
  • The prefab's env/camera are ignored when instanced — the instancing file owns the scene.
  • Prefabs can nest; an import cycle (a scene instancing itself, directly or transitively) is detected at load and that instance is skipped with a console error.

instantiate — a scene file as a reusable object (scene rigs)

load()/open() treat a file as the scene. instantiate treats it as an object: a weapon under a hand bone, a streetlamp per street corner, a vehicle with its seats and lights — built as many times as needed, inside whatever scene is running:

TypeScript
import rifleScene from './scenes/rifle.scene'

const equip = async (scene: Scene, arms: Model) => {
  const rifle = await rifleScene.instantiate({ scene, parent: arms.bone('ik_hand_gun') })
  rifle.root                        // the wrapper Node — position / hide / reparent it
  rifle.nodes.weapon.anim.playLoop('Idle')   // typed like load().nodes
  rifle.get('weapon/aim')           // Node | null
  rifle.alignTo(rifle.nodes['weapon/grip'])  // put that node on the parent's frame (one-shot)
  return rifle                      // later: rifle.dispose() — remove + destroy the subtree
}
TypeScript
handle.instantiate(opts: { scene: Scene, parent?: Node | null, name?: string }): Promise<SceneInstance>
// SceneInstance: { root: Node, nodes, get(path), alignTo(inner: Node): void, dispose(): void }
  • The file's nodes build under a fresh wrapper (root, named name, default '__instance') parented to parent (left unparented without one); transforms are local to it, so author the file with the wrapper as the attachment point (a weapon: the model at identity, the scene's origin = the hand socket).
  • alignTo(inner) is the other way round: it sets root's local transform so that inner — one of the instance's own nodes — lands exactly on the frame root is parented to (origin and axes), from inner's pose at the time of the call. Name the attachment frame in the file (the eye place of a first-person arms scene, the grip of a held prop) and align on it.
  • dispose() removes the subtree from the scene and destroys it; nodes empties and get() answers null afterwards.
  • env / camera are ignored (as for a prefab:), a CameraPlace inside is inert; ref()s resolve per instance; aspects attach per instance.

The contract pattern. Code should never carry an object's geometry (where the sight is, where the muzzle is, how an attachment sits on its bone). Name what the code needs in an aspect with Node | null fields and let the scene file fill them with ref() — the aspect is then typed in code, visible in the inspector (pick dropdowns), rename-safe, and can draw what it means as an editor gizmo:

TypeScript
// weaponRig.ts — code declares what it needs
export class WeaponRig extends Aspect<'weaponRig', Node> {
  /** the sight: −Z down the sight line, +Y up through the sights */
  aim: Node | null = null
  /** the barrel's mouth: −Z the bullet direction */
  muzzle: Node | null = null
  eyeRelief = 0.3
  static editor = { rebuild: true }      // runs in the editor: draws the line being placed
  rebuild() {
    if (!this.aim) return
    const m = this.aim.worldMatrix, p = m.position, fwd = m.basisZ.normalize().scale(-1)
    Gizmos.line(p, p.scaleAndAdd(fwd, 0.5), { color: '#ff4a4a' })
  }
  onAttach() {}
}

// rifle.scene.ts — the editor writes this; the developer drags the empties
weapon: {
  model: asset('../../assets/weapons/tr15.glb'),
  aspects: [use(WeaponRig, { aim: ref('aim'), muzzle: ref('muzzle'), eyeRelief: 0.28 })],
  children: {
    holo:   { model: asset('../../assets/weapons/tr15_xps2.glb'), mount: 'SKM_TR-15/Body/Attach_AR15_XPS2', position: [0, 0, -0.028], eulerAngles: [90, 0, 0] },
    aim:    { mount: 'SKM_TR-15/Body', position: [-0.014, 0.113, -0.105], eulerAngles: [90, -180, 0] },
    muzzle: { mount: 'SKM_TR-15/Body/Attach_AR15_Silencer', position: [0, 0.12, 0], eulerAngles: [90, 0, 0] },
  },
},

// the consumer
const rig = rifle.nodes.weapon.get(WeaponRig)!
if (!rig.aim) throw new Error('rifle.scene.ts: place the aim empty')
rig.aim.worldMatrix   // read every frame — rigid under its bone, follows the takes

Empties mounted on bones follow the animation; a generator's (or a make() node's) __generated container is a child of the host node, so code looking for a GLB's own first child must skip it (children.find(c => c.name !== '__generated')). A missing field is null — check once at rig build with a message that names the file.

use(Ctor, props)

References an aspect by class — the import is the registration, and props is typechecked against the aspect's fields exactly like node.aspect(Ctor, props). Behavior never lives in the scene file: write a custom aspect in a normal .ts file and attach it with use(...).

ref(path) — node references in aspect props

An aspect prop can point at another scene node:

TypeScript
class Road extends Aspect<'road'> {
  from: Node | null = null
  to: Node | null = null
}

// in the scene file — `road` may reference nodes declared below it
road:   { aspects: [use(Road, { from: ref('pointA'), to: ref('pointB') })] },
pointA: { position: [1, 0, 0] },
pointB: { position: [5, 0, 0] },

Resolution is scoped upward from the node that carries the ref, like variable scoping: the host's own children are tried first, then its siblings, then each ancestor's scope up to the scene root. So a duplicated group's internal refs bind to its own copies — ref('pt1') inside in1 finds in1/pt1, not in0/pt1. A multi-segment path (ref('lane/pt1')) descends from whichever scope contains its first segment — and that binding is final: a closer scope that has the first segment but not the rest yields null rather than retrying further out (lexical shadowing). To reach into a sibling's subtree, write the path through the sibling (ref('path1/p1')).

Nodes are instantiated first and aspects attach after, so declaration order doesn't matter. ref() markers resolve to the live nodes at attach (top-level props and one array level deep — waypoints: [ref('a'), ref('b')] works); an unknown path resolves to null, so type such fields Node | null. Refs address def nodes only — no :: or name[i] asset-internal segments. Scene files only — hand-written code passes nodes directly: node.aspect(Road, { from: nodes.pointA }). The scene editor shows ref fields as a path dropdown with a pick-in-viewport button (a Node | null type annotation is enough; editor: 'node' in static fields also works) and writes the shortest ref that resolves from the host.

Inspector metadata (static fields)

The visual editor lists an aspect's public fields and infers a widget from each default value: boolean → switch, number → number, a string starting with # → colour, any other string → text, an array of 2–4 numbers → vec2 / vec3 / vec4; anything else shows read-only. Fields starting with _, function values and the Aspect base's own fields are left out. An optional static fields on the class refines that — ranges, labels, dropdown options, or hiding a field:

TypeScript
class Spinner extends Aspect<'spinner'> {
  speed = 1
  mode: 'local' | 'world' = 'local'

  static fields: FieldMeta<Spinner> = {
    speed: { min: 0, max: 20, step: 0.1 },
    mode: { options: ['local', 'world'] },
  }
}
TypeScript
type FieldMeta<T> = { [field in keyof T]?: {
  label?: string                  // default: the field name
  min?: number, max?: number, step?: number
  options?: (string | number)[]   // → a dropdown
  editor?: 'number' | 'text' | 'color' | 'switch' | 'select' | 'vec2' | 'vec3' | 'vec4' | 'node' | 'asset'   // force a widget
  hidden?: boolean                // leave the field out
} }

A key named only in static fields (a field declared without an initializer) is listed too.

Generator aspects (static editor)

An aspect that derives scene content from its props — a road between two nodes, a fence along a path — can opt into running while the scene is edited:

TypeScript
class Road extends Aspect<'road'> {
  from: Node | null = null
  to: Node | null = null
  width = 2

  static editor = { rebuild: true }

  onAttach() { this.rebuild() }
  rebuild() {
    this.generated.clear()                      // idempotent: clear, then create
    if (!this.from || !this.to) return
    const toLocal = this.generated.worldMatrix.invert()   // the output lives under the host node
    const a = toLocal.transformPoint(this.from.worldPosition)
    const b = toLocal.transformPoint(this.to.worldPosition)
    const road = Mesh.box({ size: [this.width, 0.1, a.distanceTo(b)] })
    road.position = a.add(b).scale(0.5)
    this.generated.add(road)
    road.lookAt(this.to.worldPosition)          // lookAt takes a world point
  }
}

The contract:

  • rebuild() regenerates the output under this.generated — a scene-added container child provided before onAttach/rebuild run (nodes add()ed to it join the draw set automatically; clear() destroys the previous output). this.scene is the Scene the host node was built into (what handle.instantiate({ scene }) needs inside a rebuild). Both are provided for scene-file attachments of generator classes; hand-attached aspects get neither.
  • Play mode: nothing special — onAttach runs normally and calls rebuild() itself.
  • Edit mode: the class is instantiated for real (unlike ordinary aspects, which stay inert data) — node/generated/scene set, ref() props resolved, rebuild() called — but never onAttach (no physics, timers, or loops while editing). The editor then re-runs rebuild() whenever a prop changes in the inspector, a node referenced by a ref() field (or anything in its subtree) moves, or the host node itself moves — drag pointA and the road follows. A throwing rebuild() logs and skips; it can't take the editor down.
  • rebuild() may be async (one that instantiate()s a scene file under this.generated, say). The editor awaits it and drops a superseded run; this.generated.version changes on every clear(), so a rebuild can check it after an await and discard its late result. Gizmos it wants to draw after its awaits go in an optional draw() method, called once the promise settles.
  • Generated output is derived, never saved: it doesn't appear in the scene file or the editor's node tree; the file stores the recipe (the aspect entry), the viewport shows the result.

Editor gizmos (Gizmos)

A generator that wants to annotate rather than build — a path line, a sight line, a marker — draws through the global Gizmos inside rebuild():

TypeScript
rebuild() {
  if (!this.from || !this.to) return
  Gizmos.line(this.from.worldPosition, this.to.worldPosition, { color: '#5b8ef0' })
  Gizmos.cross(this.to.worldPosition, 0.1)
  Gizmos.polyline([this.from.worldPosition, this.node.worldPosition, this.to.worldPosition], { closed: true })
}
TypeScript
Gizmos.line(a: Vec3Like, b: Vec3Like, style?: GizmoStyle): void
Gizmos.polyline(points: Vec3Like[], style?: GizmoStyle & { closed?: boolean }): void  // consecutive segments; closed joins last → first
Gizmos.cross(p: Vec3Like, size?: number, style?: GizmoStyle): void   // three axis segments; size = half extent, default 0.1
Gizmos.frustum(fov: number, style?: GizmoStyle & { aspect?: number, length?: number, matrix?: Mat4Like }): void
  // a camera frustum down −Z: fov vertical degrees, aspect default 16/9, length default 0.6 m;
  // matrix places an unanchored one in world space
type GizmoStyle = { color?: ColorInput, alpha?: number, node?: Node }   // alpha 0..1, default 1

Points are world space by default; color is any ColorInput (default a light gray; its alpha multiplies alpha). The lines are overlay drawings of the scene editor's viewport (lite engine) — not scene content: never outlined with the selection, absent from the Rendered (filament) view and from play mode, where every Gizmos call is a no-op.

Anchored gizmos — { node: this.node } — take points in that node's local frame (its scale ignored) and behave as the node's own picture: they follow the node live through a gizmo drag, clicking them in the viewport selects the node, and they draw in the selection colour while it is selected. That is how the editor's own empty/camera markers work, and how CameraPlace draws its frustum: Gizmos.frustum(this.fov, { node: this.node }). World-space lines (a path between nodes) are never pickable. Each rebuild() starts from an empty picture — what the call draws is the whole picture, no clearing needed. Draw synchronously inside rebuild() (or draw(), a make() factory, an editor tool hook): a call after an await has no run to attach to and is dropped (with one console warning). The built-in movement aspects (MoveTo, FollowPath, LookAt) draw their paths this way.

Custom inspector cards (static inspector)

For full control over how an aspect looks in the scene editor's inspector, declare a static inspector(ui, aspect) — an immediate-mode function (think imgui): it re-runs on every edit or interaction and describes the card with InspectorUI calls. Without it, the editor shows the inferred fields; ui.auto() emits those same fields, so a custom card usually starts there and appends status lines, buttons, or dynamic dropdowns:

TypeScript
class Road extends Aspect<'road'> {
  from: Node | null = null
  to: Node | null = null
  width = 2
  static editor = { rebuild: true }

  static inspector(ui: InspectorUI, road: Road) {
    ui.auto()                                    // the inferred fields, as usual
    if (road.from && road.to) ui.info(`${road.generated.children.length} pieces`)
    else ui.warn('Assign both endpoints')
    if (ui.button('Shuffle')) road.rebuild()     // true on the click's run — act right there
  }
}
TypeScript
// fields — each emits a widget AND returns the current value
ui.number(key, opts?): number              // 0 until set
ui.slider(key, opts?): number              // = number; give it min / max / step for a scrub range
ui.text(key, opts?): string                // '' until set
ui.color(key, opts?): string               // '#ffffff' until set
ui.switch(key, opts?): boolean
ui.select(key, options, opts?): string | number   // the first option until set; options may change every run
ui.vec2(key, opts?) / vec3 / vec4          // number tuples, zeros until set
ui.node(key, opts?): unknown               // the RAW doc value ({ $ref } marker or null) — read the live node off the aspect
ui.asset(key, opts?): string               // a project GLB path, '' = none
// opts: { label?, min?, max?, step?, value? } — `value` is the starting value of an editor-state key

// actions and text
ui.button(label, opts?: { id?: string }): boolean   // true on the run that consumes the click
ui.toolButton(label, tool): void           // toggles a viewport tool — see editor-plugins.md
ui.header(label): void
ui.info(text): void
ui.warn(text): void
ui.auto(...keys: string[]): void           // the inferred fields — all, or just the named ones

Binding rule — one vocabulary, two lifetimes: a field whose key is a declared class field is doc-bound (the editor persists edits to the scene file, with undo); any other key is transient editor state, kept per card and never written anywhere (a preview clip choice, a brush size). select options may be computed fresh every run, so live data makes dropdowns for free.

The aspect argument is the live editor instance for generator classes (static editor), or a preview instance (with node set, refs resolved, never attached) for plain aspects. A throwing inspector logs and renders a warning line — it can't take the editor down. Model nodes get a built-in Animation card (clip / speed / loop, Play/Stop) built on this same protocol.

Editor-only code (EDITOR + *.editor.ts)

EDITOR is a compile-time boolean: true in scene-editor bundles, false in shipped ones — and in production the compiler const-folds it, so if (EDITOR) { … } branches (and everything only they reference) are removed entirely. Gate debug helpers, editor warnings, or expensive validation with it at zero shipped cost:

TypeScript
if (EDITOR && this.segments > 500) console.warn("road: very dense — consider fewer segments")

*.editor.ts files are the file-level form: they are compiled and executed only in editor bundles (imported automatically, after the scene file), and are invisible to production compiles — a production build never imports them, and they are never picked as the entry point. Editor plugins (windows, viewport tools) live in these files — see editor-plugins.md.

Edit mode (how the scene editor runs a scene file)

The scene editor (lecodes scene) compiles an editor bundle around the scene file — the app's own entry is not part of it — and sets edit mode before anything runs: sources are instantiated for real (the viewport shows the scene), but the file's aspects are held as data without attaching — no onAttach side effects, no update() ticks, physics stays inert. What does run: generator rebuild()s, make() factories, static inspector cards and the project's *.editor.ts files. env.lightmap and env.navmesh are ignored; a camera place or camera node only seeds the editor's viewpoint; a model's editor: { clip, time } pose plays. Pressing Play runs the project normally.

Edit mode exists only in a bundle the editor compiled (EDITOR is true there): a published app carries none of its code, and the flag does nothing in one.

Pitfalls

Loading is async and an app bundle has no top-level await (a compile error) — await inside a function:

TypeScript
const { nodes } = await city.open()                                          // ✗ compile error
const main = async () => { const { nodes } = await city.open() }; main()    // ✓

See also