curve-field

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.

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.

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).
const shape = curve3d({ kind: 'profile', label: 'shape — remaps the height sample', value: 'no change', aspect: 0.45 })
const falloff = curve3d({ kind: 'falloff', label: 'falloff — weight by distance', aspect: 0.45 })
// 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.
const footprint = footprint3d({ value: 'hexagon', label: 'footprint — drag the corners' })

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 = () => {
  if (ground == 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)
}

shape.onChange = rebuild
falloff.onChange = rebuild
footprint.onChange = rebuild

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',
    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 })
)

// 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((p) => p.name),
    onChange: (name) => { widget.applyPreset(name); rebuild() },
  })

const panel = panel3d(
  { width: 320 },
  label3d({ text: 'province editor', bold: true }),
  shape,
  menu(shape, 'profile', 'no change'),
  button3d({ label: 'delete selected point', onClick: () => shape.deleteSelected() }),
  falloff,
  menu(falloff, 'falloff', 'linear'),
  button3d({ label: 'delete selected point', onClick: () => falloff.deleteSelected() }),
  footprint,
  menu(footprint, 'radial', 'hexagon'),
  slider3d({ label: 'block height', min: 1, max: 16, value: state.height, onChange: (v) => { state.height = v; rebuild() } }),
  slider3d({ label: 'extent', min: 0.2, max: 1, value: state.extent, onChange: (v) => { state.extent = v; rebuild() } }),
  slider3d({ label: 'noise scale', min: 0.2, max: 4, value: state.noise, onChange: (v) => { state.noise = v; rebuild() } }),
  toggle3d({ label: 'wireframe', value: false, onChange: (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: false, onChange: (v) => {
    if (ground?.material == null) return
    if (biome == null) biome = attachBiomePlugin(ground.material)
    biome.isEnabled = v
  } })
)

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%;
}