b3d-manipulator
Translate, rotate and scale handles you can grab — with a mouse, a finger, or a hand in a headset. Drop it in a scene, point it at an element or a mesh, and it writes the result back.
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,
whenChange: (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 |
whenChange(transform) fires live during a drag; whenCommit(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. A component's callbacks are when*, not handle*.
A grip, not a mode
Every enabled affordance is on screen at once, and the grip you grab says what the drag means. A mode switch makes you say what you want twice, once to a toolbar and again to the handle, and costs a round trip for the commonest edit there is: nudge it over, then turn it a bit. The cost is crowding, and the answer to that is the transform set: turn off what you are not using and its grips are not built.
Each grip answers one question: given where the pointer is aiming now, what value should this handle have?
| grip | reading |
|---|---|
translate |
the point on the axis closest to the pointer's ray |
planar |
where the ray crosses the plane the grip lies in, as a point |
rotate |
the angle around the axis where the ray crosses its plane |
scale |
the same projection as translate, read as a ratio |
uniform |
the ray's distance from the widget centre, read as a ratio |
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.
Snapping and the frozen frame
Snapping quantises the resulting VALUE, not each step of the movement, so a snapped object is always exactly on the grid however long the drag. Rotation is the exception: it snaps its DELTA, because a 15° step about the ring you grabbed is what the setting promises, even from a start rotation that is not on the grid.
A drag measures against the object's axes as they were when it began. Reading them live is a feedback loop for rotation: each sample would be measured in a frame your own output just turned, and a tiny movement would spin the object hundreds of degrees.
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. Writing .rotation alone would leave every loaded model at its baked
orientation while position worked, which looks wired up and is not.
Sized for hands, picked in two passes
Every drawn handle carries a second, invisible, fatter mesh that exists only to
be picked, because a gizmo that is visually thin is impossible to grab with a
controller at arm's length or a finger on a phone. A hand inside a handle's
nearRadius grabs it directly; anything further grabs by pointing.
Picking tries the DRAWN handles first, then the fat targets. The fat targets overlap by design, so a single pass over both would let ray depth decide, and a ring's tube passing in front of the arrowhead under the cursor would win.
Handles draw on top (rendering group 1), because the object you most want to move is often embedded in something else. Rendering group has no bearing on picking; it is safe here only because the handle pick admits nothing but handle meshes, so an occluder is never a candidate.
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.
The pure maths (beginDrag, updateDrag, commitTransform, snap, …) and the
handle geometry (createHandles, composeRotation, axisFrameOf) are exported
too, for a consumer building its own widget.