widgets3d

A small collection of SVG-native UI widgets with element-creator ergonomics (built on tosijs's svgElements proxy, the same way [[gamepad-svg]] is). They render to SVG — not HTML — so the same widget works both as a DOM overlay on a flat screen and inside a 3D scene on a b3dSvgPlane (where the panel is serialized to a texture; HTML-in-<foreignObject> doesn't rasterize reliably).

Layout protocol

You build a panel3d container and hand it widgets. The container gives each widget its content width; each widget lays itself out and returns the height it needs. The container stacks them and, if the stack overflows, becomes scrollable (wheel + drag). Every widget defaults to ~40px tall.

Binding

A widget's value may be a plain value or a tosijs reactive proxy (e.g. sky.timeOfDay). When it's a proxy the widget reads/writes it and re-renders on external change, so a slider3d and a native bound <input> stay in sync.

The example below has a Test tab: those assertions run in the doc system's in-browser harness (real DOM + real pointer events) — coverage bun test can't reach, since tosijs needs a DOM.

import { tosi, elements } from 'tosijs'
import {
  panel3d, label3d, slider3d, toggle3d, button3d, text3d, list3d,
} from 'tosijs-3d'

const { ui } = tosi({ ui: { time: 8, fog: true } })
const { div, label, input } = elements

const panel = panel3d(
  { width: 340, height: 320 },
  label3d({ text: 'Scene settings' }),
  slider3d({ label: 'time of day', value: ui.time, min: 0, max: 24, step: 0.5 }),
  toggle3d({ label: 'fog', value: ui.fog }),
  button3d({ label: 'Reset', onClick: () => { ui.time.value = 8; ui.fog.value = true } }),
  text3d({ text: 'Drag the slider — the native control below is bound to the same value.' }),
  list3d({
    items: [{ label: 'Talk' }, { label: 'Trade' }, { label: 'Leave' }],
    onSelect: (item) => console.log('picked', item.label),
  })
)

preview.append(
  div(
    { style: 'display:flex; gap:24px; padding:16px; background:#11131a; align-items:flex-start' },
    panel,
    label('native, same binding ', input({ type: 'range', min: 0, max: 24, step: 0.5, bindValue: ui.time }))
  )
)
import { tosi, updates } from 'tosijs'
import { panel3d, slider3d, toggle3d, button3d, list3d } from 'tosijs-3d'
const { s } = tosi({ s: { v: 0, on: false } })

test('slider reflects an external bound change', async () => {
  s.v = 0
  const panel = panel3d({ width: 300, height: 100 }, slider3d({ value: s.v, min: 0, max: 100 }))
  preview.append(panel)
  const knob = panel.querySelector('[data-w3d="slider"] circle')
  const before = Number(knob.getAttribute('cx'))
  s.v = 100 // a tosijs leaf is a boxed proxy — write through .value
  await updates() // let tosijs flush its update queue before asserting
  expect(Number(knob.getAttribute('cx'))).toBeGreaterThan(before)
})

// Widgets are coordinate-routed (no DOM events), so drive panel.handlePointer
// with viewBox coords — the same entry point the overlay and the in-scene/VR
// host both call. A down+up at a point = a click there.
test('toggle flips its bound value when the switch is clicked', async () => {
  s.on = false
  const panel = panel3d({ width: 300, height: 100 }, toggle3d({ label: 'sound', value: s.on }))
  preview.append(panel)
  // The switch is right-aligned (only it is interactive — the label area is
  // scroll surface), so click near the right edge, not the row centre.
  panel.handlePointer('down', 255, 30)
  panel.handlePointer('up', 255, 30)
  await updates()
  expect(s.on.value).toBe(true)
})

test('button fires onClick on release', () => {
  let clicked = false
  const panel = panel3d({ width: 300, height: 100 }, button3d({ label: 'Go', onClick: () => { clicked = true } }))
  preview.append(panel)
  panel.handlePointer('down', 150, 30)
  panel.handlePointer('up', 150, 30)
  expect(clicked).toBe(true)
})

test('list selects the clicked row', () => {
  const picks = []
  const panel = panel3d({ width: 300, height: 200 }, list3d({
    items: [{ label: 'A' }, { label: 'B' }],
    onSelect: (item) => picks.push(item.label),
  }))
  preview.append(panel)
  // The second 40px row: viewBox y = padding(12) + ~58 lands in row B.
  panel.handlePointer('down', 150, 70)
  panel.handlePointer('up', 150, 70)
  expect(picks).toEqual(['B'])
})

test('panel clips when its content overflows', () => {
  const rows = Array.from({ length: 12 }, (_, i) => button3d({ label: 'row ' + i }))
  const panel = panel3d({ width: 300, height: 120 }, ...rows)
  preview.append(panel)
  expect(panel.querySelector('g[clip-path]')).toBeTruthy()
})

In a 3D scene

The same panel rendered onto a b3dSvgPlane, interactive the way VR needs it. Because the panel exposes handlePointer, b3dSvgPlane routes each pick's texture UV → the panel's viewBox coords and lets the panel hit-test and capture in its own SVG coordinate space — no DOM events, no elementFromPoint, no clientX. That same path is fed by mouse, touch, and XR controllers (through the scene's pointer observable), so the identical panel works as a DOM overlay, on a flat canvas, and in immersive VR. Press Enter VR on a headset to drive this exact panel with controllers.

import { b3d, b3dLight, b3dSvgPlane, panel3d, label3d, slider3d, toggle3d, list3d } from 'tosijs-3d'
import { tosi } from 'tosijs'

// distinct namespace — tosi() is a singleton keyed by path, so reusing `ui`
// here would collide with the first example's `ui`.
const { hud } = tosi({ hud: { hue: 200, glow: true } })

const HUES = { Warm: 30, Cool: 210, Lime: 90, Magenta: 320, Gold: 50 }
const panel = panel3d(
  // sized so the list overflows — scrolling is always part of the demo.
  { width: 280, height: 260 },
  label3d({ text: 'In-scene panel' }),
  slider3d({ label: 'hue', value: hud.hue, min: 0, max: 360, step: 1 }),
  toggle3d({ label: 'glow', value: hud.glow }),
  // independent of the slider while debugging — just logs which row was picked.
  list3d({ items: Object.keys(HUES).map((label) => ({ label })), onSelect: (i) => console.log('list picked:', i.label) })
)
// Show it as a DOM overlay too (top-right) — the SAME panel object, so you can
// compare the in-DOM and on-plane surfaces side by side. It's also the texture
// source for the plane (b3dSvgPlane clones it each frame).
panel.style.position = 'absolute'
panel.style.top = '8px'
panel.style.right = '8px'
panel.style.zIndex = '1'
preview.append(panel)

// pointerEvents:false — we route picks ourselves below (the path proven to work
// with controllers in immersive VR). This loop is a candidate to fold back into
// b3dSvgPlane so every plane is VR-interactive without per-demo wiring.
const plane = b3dSvgPlane({ width: 2.4, height: 2, resolution: 512, pointerEvents: false })
plane.svgElement = panel

const sceneEl = b3d(
  {
    sceneCreated(el) {
      const cam = new el.BABYLON.ArcRotateCamera('cam', -Math.PI / 2, Math.PI / 2.4, 4, el.BABYLON.Vector3.Zero(), el.scene)
      el.setActiveCamera(cam)
      // camera.attachControl is what actually feeds scene.onPointerObservable
      // (and XR controller picks). Orbit-on-drag is a known rough edge for now.
      cam.attachControl(el.scene.getEngine().getRenderingCanvas(), true)
      el.scene.constantlyUpdateMeshUnderPointer = true

      // Map each pick's UV → the panel's viewBox coords and route to the panel.
      // Fed by mouse AND XR controllers, so the same loop works flat and in VR
      // (confirmed with controllers in an immersive session). Identify the plane
      // by reference — robust in XR.
      const T = el.BABYLON.PointerEventTypes
      let vx = 0
      let vy = 0
      el.scene.onPointerObservable.add((pi) => {
        const kind = pi.type === T.POINTERDOWN ? 'down' : pi.type === T.POINTERUP ? 'up' : pi.type === T.POINTERMOVE ? 'move' : ''
        if (!kind) return
        const pk = pi.pickInfo
        const onPlane = pk && pk.hit && pk.pickedMesh === plane.mesh
        const uv = onPlane ? pk.getTextureCoordinates() : null
        if (uv) { vx = uv.x * 280; vy = (1 - uv.y) * 260 }
        // Route every event; the panel manages press-capture and hover itself.
        if (kind === 'move' && !uv) panel.handlePointer('leave', 0, 0)
        else if (kind === 'down' && !uv) return
        else panel.handlePointer(kind, vx, vy)
      })
    },
  },
  b3dLight(),
  plane
)
preview.append(sceneEl)
// No manual Enter-VR button needed — b3d offers one automatically whenever an
// immersive-vr session is supported (suppress it with the `no-xr` attribute).

Dual-presence scene panel

The same widgets, authored once, drive a panel that has a presence both in the DOM and in the scene. Pass b3d a scenePanel hook returning the widgets: on a flat screen a gear icon (top-right) toggles them as a DOM overlay; in immersive VR the identical panel floats above the viewer with an Exit VR button prepended (you can't click a DOM button inside a headset). Both surfaces bind to the same reactive values, so they stay in sync.

Click the gear to raise/lower and spin the cube — then try it in VR.

import { b3d, b3dLight, b3dBox, label3d, toggle3d, slider3d } from 'tosijs-3d'
import { tosi } from 'tosijs'

const { cfg } = tosi({ cfg: { spin: true, height: 1 } })

const cube = b3dBox({ meshName: 'cube', size: 1, y: 1, color: '#39c5ff' })

// b3dBox is an AbstractMesh: it drives mesh.rotationQuaternion from its rx/ry/rz,
// and a mesh WITH a rotationQuaternion ignores its euler `rotation`. So nudging
// cube.mesh.rotation does nothing — set the quaternion directly. Tumble on two
// axes so a single-colour cube under flat lighting clearly reads as spinning.
let spin = 0
const sceneEl = b3d(
  {
    scenePanel: () => [
      label3d({ text: 'Scene settings' }),
      toggle3d({ label: 'spin', value: cfg.spin }),
      slider3d({ label: 'height', value: cfg.height, min: 0, max: 3, step: 0.1 }),
    ],
    update(el, BABYLON) {
      if (!cube.mesh) return
      cube.mesh.position.y = cfg.height.value
      if (cfg.spin.value) {
        spin += 0.02
        cube.mesh.rotationQuaternion = BABYLON.Quaternion.RotationYawPitchRoll(spin, spin * 0.6, 0)
      }
    },
  },
  b3dLight(),
  cube
)
preview.append(sceneEl)