vector-field

A coordinate on ONE row. vector3d edits an {x, y, z}; euler3d edits an orientation in degrees. Both are Widget3ds, so they drop into a panel3d beside a slider or a button and work identically flat, in-scene and in VR.

Why this exists

Three stacked labelled fields is the obvious way to edit a coordinate and it is the reason an inspector panel ends up three times taller than it needs to be. Tonio, on the adopter that prompted it: ensemble's UI is "an utter mess right now because simple things like XYZ coords take up huge amounts of space".

A coordinate is one value, not three, and it should occupy one row. That is also why the axis letter is drawn inside the row rather than as its own label column — a column of x: y: z: labels costs more width than the numbers do, and every 3D tool worth using puts the letter against the field.

Degrees, and why euler3d is a separate function

euler3d is vector3d with the angle rules applied, not a styling variant: it wraps (181° is −179°, and dragging through the bottom of a turn should not stop), and it is degrees, which per CLAUDE.md's suffix rule means the bare value is already degrees and no conversion is implied.

Wrapping is the whole difference and it is not a detail — a wrapping value with a min/max clamp fights you at exactly the angles you most want to scrub through.

Focus and typing

The fields are exposed as fields so a fieldGroup can wire focus traversal and an on-screen keyboard across a whole panel. A vector row is three tab stops, not one, because that is what it is to a person filling it in.

Demo

import { b3d, b3dLight, panelScene, panel3d, label3d, vector3d, euler3d } from 'tosijs-3d'
import { elements } from 'tosijs'
const { div, pre } = elements

const readout = pre({ style: 'margin:8px 16px;color:#8ea;font:12px ui-monospace,monospace' }, '')
const state = { pos: { x: 1.5, y: 0, z: -3.25 }, rot: { x: 0, y: 45, z: 0 } }
const show = () => {
  readout.textContent =
    `position  ${state.pos.x}, ${state.pos.y}, ${state.pos.z}\n` +
    `rotation  ${state.rot.x}°, ${state.rot.y}°, ${state.rot.z}°`
}
show()

// panel3d takes its widgets as REST ARGS and returns the <svg> itself — it is
// both the DOM view and the texture source, and `handlePointer` hangs off it.
const panel = panel3d(
  { width: 300 },
  label3d({ text: 'position', muted: true, compact: true }),
  vector3d({
    value: state.pos,
    step: 0.25,
    scrub: 0.02,
    onChange: (v) => { state.pos = v; show() },
  }),
  label3d({ text: 'rotation (degrees)', muted: true, compact: true }),
  euler3d({
    value: state.rot,
    step: 5,
    scrub: 0.5,
    onChange: (v) => { state.rot = v; show() },
  })
)

const { plane, sceneCreated } = panelScene({ svg: panel, target: panel, width: 2.4, camera: { radius: 3.2 } })
const scene = b3d({ style: 'border-radius:8px;overflow:hidden', sceneCreated }, b3dLight({ intensity: 1 }), plane)

preview.append(
  div(
    { style: 'display:flex;flex-direction:column;height:100%;background:#0c0e14' },
    div(
      { style: 'display:flex;gap:24px;flex:1;min-height:0;padding:16px 16px 4px' },
      div({ style: 'color:#9ab;font:12px system-ui;display:flex;flex-direction:column;gap:6px;flex:1;min-width:0' }, 'DOM — drag a number to scrub', panel),
      div({ style: 'color:#9ab;font:12px system-ui;display:flex;flex-direction:column;gap:6px;flex:1;min-width:0' }, '3D texture — same widget', scene)
    ),
    readout
  )
)
.preview {
  height: 100%;
}