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