curve-program
curveProgram3d — the editor for a whole LightProgram:
several curves that share one pair of split markers. One Widget3d, one
value in, one value out, one undo step per gesture.
Demo
The editor drives a real lamp. Drag a point or a split and the light changes under it; flip the power SWITCH and watch the program's attack and decay play out on the actual scene, which is the only way to tell whether a curve you drew is the curve you wanted.
It uses lightEditor3d, which wraps this widget with the lamp's static properties — colour, intensity, range, shadows, type. A program editor alone is half a job: you can draw a perfect strike and have no way to say the lamp is a warm spot that casts shadows.
import {
lightEditor3d, lightColor, panel3d, label3d,
b3d, b3dLight, b3dPointLight, b3dSpotLight, b3dAreaLight,
} from 'tosijs-3d'
import { orbitCam } from 'tosijs-3d/demo-utils'
import { elements } from 'tosijs'
const { div } = elements
// A fluorescent: strikes in stutters, hums, then dies to a red ember.
const program = {
brightness: [
{ x: 0, y: 0 }, { x: 0.06, y: 0.85 }, { x: 0.1, y: 0.05 },
{ x: 0.17, y: 1 }, { x: 0.23, y: 0.08 }, { x: 0.3, y: 0.95 },
{ x: 0.35, y: 1 }, { x: 0.45, y: 0.93 }, { x: 0.52, y: 1 },
{ x: 0.63, y: 0.9 }, { x: 0.75, y: 1 },
{ x: 0.8, y: 0.34 }, { x: 0.95, y: 0.26 }, { x: 1, y: 0 },
],
hue: [{ x: 0, y: 0.5 }, { x: 0.75, y: 0.5 }, { x: 0.85, y: 0.04 }, { x: 1, y: 0 }],
saturation: [{ x: 0, y: 0.2 }, { x: 0.75, y: 0.2 }, { x: 0.9, y: 1 }, { x: 1, y: 1 }],
hueShiftDeg: 190,
saturationScale: 5,
attackEnd: 0.35, sustainEnd: 0.75,
attack: 1.3, period: 3, decay: 3.2,
}
// The lamp is REPLACED when the type changes, because point / spot / area are
// three different elements. Everything else applies to the live one.
let lamp = null
const mount = document.createElement('div')
const makeLamp = (s) => {
const common = {
x: 0, y: 4.2, z: 0,
diffuse: lightColor(s), range: s.range, intensity: s.intensity,
shadows: s.shadows ? 'on' : 'off',
on: s.on ? 'on' : 'off',
program: s.program,
}
return s.kind === 'spot'
? b3dSpotLight({ ...common, angle: s.angle, exponent: 6 })
: s.kind === 'area'
? b3dAreaLight({ ...common, width: 3, height: 1.2 })
: b3dPointLight(common)
}
// The WHOLE lamp, not just its curves: a power switch, colour, intensity,
// range, shadows, and the program. Everything applies live.
const editor = lightEditor3d({
value: {
kind: 'point', on: true,
hue: 205, saturation: 0.19, intensity: 2.1, range: 15,
shadows: true, program,
},
handleChange: (s) => {
if (lamp == null) return
// A TYPE change is a different element, so swap it. Removing and adding is
// a genuine removal + build, not a move, so the new lamp starts fresh.
if (s.kind !== lamp.dataset.kind) {
const next = makeLamp(s)
next.dataset.kind = s.kind
lamp.replaceWith(next)
lamp = next
return
}
// `on` is an 'on'|'off' STRING, not a boolean — an absent boolean
// attribute reads false, so a default-true boolean is not expressible.
lamp.on = s.on ? 'on' : 'off'
lamp.shadows = s.shadows ? 'on' : 'off'
lamp.diffuse = lightColor(s)
lamp.intensity = s.intensity
lamp.range = s.range
lamp.program = s.program
if (s.kind === 'spot') lamp.angle = s.angle
},
})
const scene = b3d(
{
style: 'flex:1;min-width:0;border-radius:8px;overflow:hidden',
sceneCreated(el, BABYLON) {
el.scene.clearColor = new BABYLON.Color4(0.02, 0.02, 0.03, 1)
const checker = new BABYLON.DynamicTexture('checker', 512, el.scene, false)
const ctx = checker.getContext()
for (let y = 0; y < 16; y++) {
for (let x = 0; x < 16; x++) {
ctx.fillStyle = (x + y) % 2 ? '#3a3f47' : '#2d323a'
ctx.fillRect(x * 32, y * 32, 32, 32)
}
}
checker.update()
checker.uScale = checker.vScale = 2
const mat = new BABYLON.StandardMaterial('floor', el.scene)
mat.diffuseTexture = checker
mat.specularColor = BABYLON.Color3.Black()
const floor = BABYLON.MeshBuilder.CreateGround('floor', { width: 16, height: 16 }, el.scene)
floor.material = mat
const propMat = new BABYLON.StandardMaterial('prop', el.scene)
propMat.diffuseColor = new BABYLON.Color3(0.62, 0.6, 0.58)
propMat.specularColor = BABYLON.Color3.Black()
const props = [
BABYLON.MeshBuilder.CreateBox('prop', { width: 1.2, height: 2.4, depth: 1.2 }, el.scene),
BABYLON.MeshBuilder.CreateBox('prop', { size: 1 }, el.scene),
BABYLON.MeshBuilder.CreateSphere('prop', { diameter: 1.5 }, el.scene),
]
props[0].position.set(-2.2, 1.2, 0.6)
props[1].position.set(1.6, 0.5, -1.4)
props[2].position.set(2.4, 0.75, 1.6)
for (const m of props) m.material = propMat
el.register({ meshes: [floor, ...props] })
orbitCam(el, { alpha: -Math.PI / 2, beta: Math.PI / 3.2, radius: 13, target: [0, 1, 0] })
},
},
b3dLight({ intensity: 0.06 }),
mount
)
lamp = makeLamp(editor.value)
lamp.dataset.kind = editor.value.kind
mount.append(lamp)
preview.append(
div(
{ style: 'display:flex;gap:14px;height:100%;padding:12px;background:#0c0e14;box-sizing:border-box' },
div({ style: 'flex:0 0 330px;overflow:auto' },
panel3d({ width: 310 }, label3d({ text: 'Light' }), editor)),
div({ style: 'flex:1;min-width:0;display:flex' }, scene)
)
)
.preview {
height: 100%;
}
Why this is ONE widget and not six fields
A program is four curves (brightness, hue, saturation, range) plus the
markers that divide all of them into attack / sustain / decay. Those markers
belong to the lamp, not to any channel.
Exposed as six sibling fields, a generated panel would let brightness and hue be
edited into disagreement about where the attack ends — and that is not a state
the runtime can represent, so the document could hold a program that cannot run.
tosijs-3d-ensemble, whose format treats the JSON as the truth, put the
consequence plainly: "a truth that cannot be executed is worse than a coarser
panel." So the invariant lives where it can be enforced — inside one widget.
It also makes the undo story fall out. One gesture is one handleCommit is one
entry in a history, instead of six callbacks nobody can tell belonged together.
Controlled: value in, value out
The widget renders what you give it and emits a new program; it does not hold a second copy of your document. During a drag it keeps a working value (an SVG control has to), but the boundary is explicit:
| callback | when | for |
|---|---|---|
handleChange(program) |
live, including mid-drag | a 3D preview that must follow the gesture |
handleCommit(program, describe) |
once, when the gesture ends | the document, and one undo step |
describe is a bare verb phrase — 'edit brightness curve', 'move attack split' — because a consumer attaches the subject themselves.
Schema
lightProgramSchema() marks the whole object "x-widget": "light-program", and
each channel carries "x-widget": "curve" with an "x-curve-kind". A panel
generated from JSON Schema dispatches on the token and hands the entire value
here.