# 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.

```js
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
  )
)
```
```css
.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:

```javascript
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.
