Curve editor

curve3d — an editor for a continuous [0,1] → [0,1]. A Widget3d, so it works as a flat DOM overlay, on an in-scene panel, and in a headset without changing. The rules live in curve.ts; this draws them and routes the pointer.

Its reason for existing is terrain provinces: a province is a footprint plus one curve per layer, so this is how you author a plateau, a crater rim or a treeline without writing code. See PROVINCE-DESIGN.md → "every one of those responses IS a curve", and the province editor demo below.

Demo — a province editor

Two curves and a terrain block. The shape says what height the province wants at each distance from its centre; the falloff says how strongly it overrides the terrain around it. Drag the points, or pick a preset.

import { b3d, b3dLight, panel3d, label3d, button3d, toggle3d, curve3d, footprint3d, slider3d, select3d, presetsFor, PerlinNoise, attachBiomePlugin, blendSample } from 'tosijs-3d'
import { orbitCam } from 'tosijs-3d/demo-utils'
import { elements } from 'tosijs'
const { div } = elements

const SIZE = 24   // metres across
const SUBS = 110  // grid resolution
// `height` scales the whole BLOCK, not the province inside it — see `rebuild`.
// 4.5 over a 24 m tile: the field now spans the whole block, so the old 9 (tuned
// when the base was squeezed into a third of the range) came out as alps.
const state = { height: 4.5, extent: 0.7, noise: 1 }

// A province is a footprint plus one curve per layer. SHAPE says what height it
// wants at each distance from its centre; FALLOFF says how strongly it overrides
// the terrain around it. Both are [0,1] -> [0,1] over normalised distance: 0 at
// the centre, 1 at the extent.
// SHAPE is a levels adjustment: it maps the height SAMPLE to a height, exactly
// like slope-profile's cliff/beach/mesa. It starts as the IDENTITY, so a fresh
// province is invisible — drag it off the diagonal and the terrain responds.
// Try `constant` (flattens whatever is there: a plateau) or drag it the other
// way up (maps low ground high, which lifts the whole province).
// Built by `buildRows` below rather than here, because this panel has TWO
// presentations — flat, and the one that floats in front of you in a headset —
// and a widget's element can only be in one of them at a time. The FLAT set
// stays the source `rebuild` samples; the headset set mirrors into it.
//
// The FOOTPRINT, edited as the shape it is rather than as extent-against-angle
// on a graph: a hexagon looks like a hexagon, and a corner is where the corner
// is. The square is the province's bounds.
let shape = null
let falloff = null
let footprint = null

let ground = null
let biome = null

// Base terrain: seeded fBm, NORMALISED to [0,1] like everything else here.
//
// Four octaves rather than a couple of sine waves — smooth ground gives the eye
// no scale reference, so the province's edge has nothing to be crisp against and
// the whole thing reads as a bulge in a bedsheet. Detail in the base is what
// makes the blend legible.
const noise = new PerlinNoise(1337)
const fbm = (x, z) => {
  let sum = 0, amp = 1, freq = 0.055 * state.noise, norm = 0
  for (let o = 0; o < 4; o++) {
    sum += noise.noise2D(x * freq, z * freq) * amp
    norm += amp
    amp *= 0.5
    freq *= 2.1   // not exactly 2, so octaves do not line up into visible grain
  }
  return sum / norm   // roughly [-1, 1]
}
// The FULL [0,1], because that is the shape curve's DOMAIN.
//
// This was banded to 0.12 … 0.5 for legibility, which quietly broke the remap:
// the editor offers you the whole domain while the terrain only ever asks about
// the bottom third, so a threshold drawn at 0.5 answered 0 for every sample and
// the province went flat. Tonio: "[0,0]-[0.5,0][0.5,1]-[1,1] does NOT work as
// expected" — the curve was right and could not be reached.
//
// A control whose input range does not match its data is worse than a coarse
// one, because it fails silently and looks like the model being wrong.
// …and normalised against its OWN measured range, not against fBm's theoretical
// one. Four octaves of Perlin almost never reach +/-1, so `fbm * 0.5 + 0.5`
// spans about 0.28 to 0.62 — the same domain mismatch, just smaller and harder
// to notice. Measuring the field costs one extra pass over a grid we are
// building anyway.
const base = (x, z) => fbm(x, z)
const normalise = (raw, lo, hi) => (hi - lo < 1e-6 ? 0.5 : (raw - lo) / (hi - lo))

const rebuild = () => {
  // `shape` is null until the flat panel is built, and the scene connects first.
  if (ground == null || shape == null) return
  // Read x/z back out of the buffer and write y: order-independent, so it does
  // not matter how CreateGround laid the grid out.
  const pos = ground.getVerticesData('position')
  const reach = SIZE * 0.5 * state.extent
  // Pass one: the raw field and its extremes, so the shape curve's [0,1] domain
  // maps onto terrain that actually spans [0,1].
  const raw = new Float32Array(pos.length / 3)
  let lo = Infinity
  let hi = -Infinity
  for (let i = 0, k = 0; i < pos.length; i += 3, k++) {
    raw[k] = base(pos[i], pos[i + 2])
    if (raw[k] < lo) lo = raw[k]
    if (raw[k] > hi) hi = raw[k]
  }
  for (let i = 0, k = 0; i < pos.length; i += 3, k++) {
    const x = pos[i], z = pos[i + 2]
    // Direction first: the footprint says how far the province reaches THIS way,
    // and distance is normalised against that. Direction lives in the footprint;
    // response lives in the other two curves.
    const theta = (Math.atan2(z, x) / (Math.PI * 2) + 1) % 1
    const spread = Math.max(0.05, footprint.evaluate(theta))
    const r = Math.min(1, Math.hypot(x, z) / (reach * spread))
    const w = falloff.evaluate(r)
    // THE HEIGHT SAMPLE goes through the shape curve — a levels adjustment, not
    // a function of distance. Tonio: "shape isn't working properly. It's being
    // treated as an output constant NOT as a map from height field to terrain
    // height." It was `shape.evaluate(r)`, which made a profile into a second
    // radial curve and quietly threw away the terrain underneath it.
    //
    // The falloff still works on DISTANCE — that is the split: what the province
    // does to a sample, versus how far its say extends.
    //
    // `blendSample` is convex, so two values in [0,1] mixed by a weight in [0,1]
    // cannot leave [0,1]: the tile's bounds are known before anything is
    // evaluated, and `height` scales the whole block rather than pushing one
    // province through the top of it.
    const sample = normalise(raw[k], lo, hi)
    const h = blendSample(sample, shape.evaluate(sample), w)
    pos[i + 1] = h * state.height
  }
  ground.updateVerticesData('position', pos)
  ground.createNormals(true)
}

const scene = b3d(
  {
    // `flex:1` on the ELEMENT, not just its wrapper: a <tosi-b3d> in a flex row
    // has no flex-grow of its own, so it shrinks to content width — which is 0,
    // and renders a 0x296 canvas that looks exactly like a broken scene.
    style: 'flex:1;min-width:0;border-radius:8px;overflow:hidden',
    // The same editor, reachable in a headset. The flat panel beside the canvas
    // does not exist inside an immersive session, so without this the demo is a
    // terrain you can walk around and cannot edit.
    scenePanel: () => buildRows(false),
    sceneCreated(el) {
      orbitCam(el, { radius: 32, beta: 1.02, alpha: -1.15, target: [0, 1.5, 0] })
      ground = el.make.ground({
        width: SIZE,
        height: SIZE,
        subdivisions: SUBS,
        updatable: true,
        color: '#6d7a58',
      })
      rebuild()
    },
  },
  b3dLight({ intensity: 0.95 })
)

// ONE row list, built twice: once for the flat panel and again for the headset.
//
// `primary` is the flat set, and it is what `rebuild` samples. The headset set
// is seeded from the flat one's current values and mirrors every edit back into
// it, so entering VR picks up what you had drawn and leaving it keeps what you
// drew there. Sharing the widget OBJECTS would not work — an element can only
// be in one panel at a time, so a shared set would empty the flat panel the
// moment the headset one mounted.
const buildRows = (primary) => {
  const s = curve3d({ kind: 'profile', label: 'shape — remaps the height sample', value: primary ? 'no change' : shape.points, aspect: 0.45 })
  const f = curve3d({ kind: 'falloff', label: 'falloff — weight by distance', value: primary ? undefined : falloff.points, aspect: 0.45 })
  const p = footprint3d({ value: primary ? 'hexagon' : footprint.vertices, label: 'footprint — drag the corners' })
  if (primary) { shape = s; falloff = f; footprint = p }

  const sync = primary
    ? () => {}
    : () => {
        shape.setPoints(s.points)
        falloff.setPoints(f.points)
        footprint.setVertices(p.vertices)
      }
  const changed = () => { sync(); rebuild() }
  s.handleChange = changed
  f.handleChange = changed
  p.handleChange = changed

  // A preset menu per curve. Presets are the fastest way to learn what a curve
  // DOES — you pick "desert terraces", see terraces, then drag from there.
  const menu = (widget, kind, value) =>
    select3d({
      value,
      options: presetsFor(kind).map((x) => x.name),
      handleChange: (name) => { widget.applyPreset(name); changed() },
    })
  const del = (widget) =>
    button3d({ label: 'delete selected point', handleClick: () => { widget.deleteSelected(); changed() } })

  return [
    label3d({ text: 'province editor', bold: true }),
    s, menu(s, 'profile', 'no change'), del(s),
    f, menu(f, 'falloff', 'linear'), del(f),
    p, menu(p, 'radial', 'hexagon'),
    slider3d({ label: 'block height', min: 1, max: 16, value: state.height, handleChange: (v) => { state.height = v; rebuild() } }),
    slider3d({ label: 'extent', min: 0.2, max: 1, value: state.extent, handleChange: (v) => { state.extent = v; rebuild() } }),
    slider3d({ label: 'noise scale', min: 0.2, max: 4, value: state.noise, handleChange: (v) => { state.noise = v; rebuild() } }),
    toggle3d({ label: 'wireframe', value: ground?.material?.wireframe ?? false, handleChange: (v) => { if (ground?.material) ground.material.wireframe = v } }),
    // The real biome shader, on this block's material — the same one b3d-terrain
    // puts on a tile, so the province is judged against how it will actually look
    // rather than against a flat green.
    toggle3d({ label: 'terrain shader', value: biome ? biome.isEnabled : false, handleChange: (v) => {
      if (ground?.material == null) return
      if (biome == null) biome = attachBiomePlugin(ground.material)
      biome.isEnabled = v
    } }),
  ]
}

const panel = panel3d({ width: 320 }, ...buildRows(true))

preview.append(
  div(
    { style: 'display:flex;gap:16px;height:100%;padding:12px;background:#0c0e14;box-sizing:border-box' },
    div({ style: 'flex:0 0 320px;overflow:auto' }, panel),
    div({ style: 'flex:1;min-width:0;display:flex' }, scene)
  )
)
.preview {
  height: 100%;
}

Deleting a point is a BUTTON, not a gesture

Adding is a tap on empty space and moving is a drag, which leaves deleting with no obvious third gesture. The usual answers all fail somewhere that matters here: right-click does not exist on a controller, double-tap is unreliable when the pointer is a ray from two metres away, and drag-off-the-edge collides with the clamp that keeps the curve in range.

So the widget exposes selected and deleteSelected(), and the host puts a button somewhere honest. A discoverable button beats a gesture you have to be told about — which is the same argument the popup title bar makes for its grip.

Shared split markers — a light program editor

A light program is one curve per channel divided into attack / sustain / decay by two markers. Those boundaries belong to the lamp, not to any one channel, so both curves below are given the SAME curveMarkers() object: drag a marker in either and both move.

Tonio: "the attack and decay should be shared by the various curves or it just becomes nutty." It is not only tidier — per-curve markers would let brightness and hue disagree about where the attack ends, which is not a state the model can represent, so the editor would be able to author something the runtime cannot run.

import { curve3d, curveMarkers, panel3d, label3d } from 'tosijs-3d'
import { elements } from 'tosijs'
const { div, pre } = elements

const out = pre({ style: 'margin:0;padding:8px 12px;color:#8ea;font:12px ui-monospace,monospace' }, '')

// ONE marker set, shared. This is the whole point.
const splits = curveMarkers([0.35, 0.75], {
  labels: ['attack', 'decay'],
  handleChange: (v) => {
    out.textContent = `attackEnd ${v[0].toFixed(3)}   sustainEnd ${v[1].toFixed(3)}`
  },
})
out.textContent = 'attackEnd 0.350   sustainEnd 0.750'

const brightness = curve3d({
  label: 'brightness — strike, hum, fade',
  markers: splits,
  value: [
    { x: 0, y: 0 }, { x: 0.08, y: 0.9 }, { x: 0.12, y: 0.05 },
    { x: 0.2, y: 1 }, { x: 0.26, y: 0.1 }, { x: 0.35, y: 1 },
    { x: 0.5, y: 0.93 }, { x: 0.75, y: 1 },
    { x: 0.9, y: 0.3 }, { x: 1, y: 0 },
  ],
})
const hue = curve3d({
  label: 'hue — 0.5 leaves the colour alone',
  markers: splits,
  value: [{ x: 0, y: 0.5 }, { x: 0.75, y: 0.5 }, { x: 1, y: 0 }],
})

preview.append(
  div(
    { style: 'display:flex;flex-direction:column;height:100%;background:#0c0e14' },
    div(
      { style: 'flex:1;min-height:0;overflow:auto;padding:12px' },
      panel3d({ width: 340 }, label3d({ text: 'Light program' }), brightness, hue)
    ),
    out
  )
)
.preview {
  height: 100%;
}