b3d-manipulator

Translate, rotate and scale handles you can grab — with a mouse, a finger, or a hand in a headset. The maths is manipulator, the geometry is manipulator-view, and this is the element that puts them in a scene and writes the result somewhere.

Demo

Drag the arrows to move, the rings to turn, the cubes to scale. The pads move in a plane. Everything is on at once — the part you grab says what the drag means.

import { b3d, b3dSun, b3dSkybox, b3dLight, b3dGround, b3dBox, b3dManipulator, toggle3d, select3d, label3d } from 'tosijs-3d'
import { orbitCam } from 'tosijs-3d/demo-utils'
import { elements } from 'tosijs'

const { div } = elements
const readout = div({ class: 'readout' }, '0, 0.5, 0')

const gizmo = b3dManipulator({
  target: '#thing',
  turn: 'on',
  gridSnap: 0.25,
  angleSnap: 15,
  handleChange: (t) => {
    const n = (v) => Math.round(v * 100) / 100
    readout.textContent =
      `${n(t.position.x)}, ${n(t.position.y)}, ${n(t.position.z)}` +
      `   ·   ${Math.round(t.rotation.ry)}°   ·   ×${n(t.scale.x)}`
  },
})

// The controls live in the SCENE PANEL, so the same demo is usable in a headset.
const panel = () => [
  label3d({ text: 'Manipulator' }),
  toggle3d({
    label: 'move',
    value: gizmo.move !== 'off',
    handleChange: (v) => { gizmo.move = v ? 'on' : 'off' },
  }),
  toggle3d({
    label: 'turn',
    value: gizmo.turn !== 'off',
    handleChange: (v) => { gizmo.turn = v ? 'on' : 'off' },
  }),
  toggle3d({
    label: 'scale',
    value: gizmo.scale !== 'off',
    // Scale is exclusive of the other two — the element enforces that itself,
    // so the toggles simply follow it.
    handleChange: (v) => { gizmo.scale = v ? 'on' : 'off' },
  }),
  select3d({
    label: 'grid',
    value: 0.25,
    options: [{ label: 'off', value: 0 }, 0.25, 0.5, 1],
    handleChange: (v) => { gizmo.gridSnap = Number(v) },
  }),
  select3d({
    label: 'angle',
    value: 15,
    options: [{ label: 'off', value: 0 }, 5, 15, 45, 90],
    handleChange: (v) => { gizmo.angleSnap = Number(v) },
  }),
]

preview.append(
  readout,
  b3d(
    {
      style: 'width:100%;height:100%',
      scenePanel: panel,
      sceneCreated(el) {
        orbitCam(el, { alpha: -1.1, beta: 1.05, radius: 8, target: [0, 0.5, 0] })
      },
    },
    b3dSun({}),
    b3dSkybox({ timeOfDay: 10 }),
    b3dLight({ intensity: 0.5 }),
    b3dGround({ size: 40, color: '#5d6b4a' }),
    b3dBox({ id: 'thing', y: 0.5, color: '#c8963c' }),
    gizmo
  )
)
.preview { height: 100%; position: relative; }
.readout {
  position: absolute;
  z-index: 1;
  left: 8px;
  bottom: 8px;
  padding: 4px 10px;
  border-radius: 6px;
  font-family: ui-monospace, monospace;
  font-size: 13px;
  color: #fff;
  background: #0009;
}

Attributes

attribute default
target '' CSS selector for the element to manipulate. Or set .node in JS
meshName '' a mesh in the scene, by name — for things that are not elements
move 'on' 'on'/'off' — arrows and plane pads
turn 'off' 'on'/'off' — rings
scale 'off' 'on'/'off' — cubes, plus the uniform centre cube
gridSnap 0 position snap in metres. 0 is off
angleSnap 0 angle snap in degrees. 0 is off
size 0.13 apparent size — the widget is this fraction of its distance from the camera, so it stays constant on screen
disabled false hide the handles and ignore input

handleChange(transform) fires live during a drag; handleCommit(transform) fires once on release, snapped, and only when something actually changed — which is your undo step. Both also dispatch a DOM change / commit event carrying the transform in detail.

Scale is exclusive of move and turn

node.scaling is local, so scale grips ride the object's own axes while move rides the world's. A widget showing both draws two frames at once and can only mislead. Turning scale on therefore turns move and turn off, and vice versa — the element does this to itself rather than letting you build the misleading combination.

Where the transform is written, and why it is a fork

Getting this wrong does not error. It silently does nothing, which is the worst failure mode a manipulator can have.

target written to why
an ELEMENT (b3d-prop, b3d-destroyable, any AbstractMesh) el.x/y/z, el.rx/ry/rz the element OWNS its transform — render() writes mesh.position and rotationQuaternion from those attributes every render, so a write straight to the mesh is undone the next time anything re-renders
a bare NODE node.position, node.rotationQuaternion, node.scaling nothing manages it, so the node IS the truth

Scale goes to the node in both branches. AbstractMesh.render() does not sync scaling, and no element attribute covers per-axis scale, so the node is where a scale survives.

Rotating a node clears its quaternion first. A TransformNode ignores .rotation while it has a rotationQuaternion — and the glTF loader always sets one. That is exactly the bug that made library.instantiate()'s rotation silently inert until 0.7.0: it wrote .rotation, the quaternion won, and every value produced the model's baked orientation. position worked, which is what made it look wired up.

Driving it from XR, or from anything else

The flat pointer is wired for you. Everything else goes through three methods that take world rays, so a controller, a hand or a script drives the same code:

const grabbed = el.grab({ origin, direction }, { secondary: false })
el.drag({ origin, direction })
el.release()

grabNear({x, y, z}) is the near-interaction form: a hand inside a handle grabs it directly, which beats whatever the same controller's ray happens to be crossing further away.

Where this came from

Ported from tosijs-3d-ensemble, which built and shook it out against real authoring — including on a phone, which is where most of the sizing decisions were won. This library had no manipulator at all (#38), and b3d-panel's coloured debug axes look exactly like one, which is its own small cruelty.