b3d-interactive
Makes a mesh touchable. Point at it, reach it, use it — and it tells you, by callback or by bubbling event. It is the substrate doors, knobs, switches, levers, consoles and lamps all stand on; the rules it enforces are pure and tested in interaction, and the scene bridge is interactive-behavior.
b3d-button is a floating Babylon GUI widget. This is the other thing: world
geometry you reach out to.
Demo
import { b3d, b3dBox, b3dSphere, b3dGround, b3dLight, b3dSkybox, b3dInteractive } from 'tosijs-3d'
import { orbitCam } from 'tosijs-3d/demo-utils'
import { tosi, elements } from 'tosijs'
const { div, p, span } = elements
const { demo } = tosi({ demo: { status: 'Click a brass knob.', key: 'no key' } })
// Two sliding doors. Each is a BOX plus a KNOB, and the interactive targets the
// KNOB — the sub-mesh you actually touch, not the whole panel. Knobs sit at -z
// because the default camera is on that side; a knob on the far face is behind
// its own door and unpickable, which reads as "the interactive is broken".
const doorL = b3dBox({ meshName: 'doorL', width: 1.6, height: 2.4, depth: 0.14, x: -2.2, y: 1.2, color: '#7a5236' })
const knobL = b3dSphere({ meshName: 'knobL', diameter: 0.22, x: -1.55, y: 1.2, z: -0.14, color: '#d9a441' })
const doorR = b3dBox({ meshName: 'doorR', width: 1.6, height: 2.4, depth: 0.14, x: 2.2, y: 1.2, color: '#7a5236' })
const knobR = b3dSphere({ meshName: 'knobR', diameter: 0.22, x: 1.55, y: 1.2, z: -0.14, color: '#b0483c' })
// A key on a plinth. Picking it up is just another interactive.
const key = b3dBox({ meshName: 'key', width: 0.35, height: 0.1, depth: 0.1, x: 0, y: 1.05, z: -0.1, color: '#ffd45e', glow: 0.8 })
const plinth = b3dBox({ meshName: 'plinth', width: 0.6, height: 1, depth: 0.6, x: 0, y: 0.5, color: '#555a60' })
// A lamp and its switch — the same primitive doing something that isn't a door.
const ambient = b3dLight({ y: 1, intensity: 0.35 })
const lamp = b3dSphere({ meshName: 'lamp', diameter: 0.5, x: 0, y: 3.2, color: '#332a12', glow: 0.1 })
const post = b3dBox({ meshName: 'post', width: 0.12, height: 1.3, depth: 0.12, x: 0.9, y: 0.65, z: -0.9, color: '#4b5057' })
const lampSwitch = b3dBox({ meshName: 'switch', width: 0.3, height: 0.4, depth: 0.12, x: 0.9, y: 1.35, z: -0.9, color: '#c8ccd0' })
let hasKey = false
let openL = 0, wantL = 0, openR = 0, wantR = 0, lit = false
// LEFT door: unlocked. Note it moves the ELEMENT (`x`), never `mesh.position` —
// an element that manages a node owns its transform, so writing the mesh is
// undone on the next render.
const useL = b3dInteractive({
target: 'knobL',
whenActivated: () => { wantL = wantL ? 0 : 1; demo.status.value = wantL ? 'Left door opening.' : 'Left door closing.' },
})
// RIGHT door: locked, by COMPOSITION. `lockable` is a veto on the same piece —
// the interactive never learns what a lock is, and a refusal names the refuser.
const useR = b3dInteractive({
target: 'knobR',
whenActivated: () => { wantR = wantR ? 0 : 1; demo.status.value = wantR ? 'Right door opening.' : 'Right door closing.' },
whenRefused: (info) => { demo.status.value = `It will not budge — ${info.reason}.` },
})
useR.vetoes.push({ name: 'locked', blocks: () => !hasKey })
const takeKey = b3dInteractive({
target: 'key',
whenActivated: () => {
hasKey = true
demo.key.value = 'brass key'
demo.status.value = 'You take the key. The red knob will turn now.'
key.remove()
// Material, not placement — b3d-primitives build their material once, so a
// live recolour goes through the material rather than the `color` attribute.
knobR.mesh.material.diffuseColor.set(0.85, 0.64, 0.25)
},
})
const flick = b3dInteractive({
target: 'switch',
whenActivated: () => {
lit = !lit
ambient.intensity = lit ? 1.1 : 0.35
lamp.mesh.material.emissiveColor.set(lit ? 1 : 0.1, lit ? 0.94 : 0.08, lit ? 0.72 : 0.04)
demo.status.value = lit ? 'Lamp on.' : 'Lamp off.'
},
})
// Cosmetic easing only — the doors slide by writing their elements' x.
setInterval(() => {
openL += (wantL - openL) * 0.12
openR += (wantR - openR) * 0.12
doorL.x = -2.2 - openL * 1.55; knobL.x = -1.55 - openL * 1.55
doorR.x = 2.2 + openR * 1.55; knobR.x = 1.55 + openR * 1.55
}, 16)
preview.append(
b3d(
{
glowLayerIntensity: 0.7,
sceneCreated(el) {
orbitCam(el, { alphaDeg: -90, betaDeg: 68, radius: 11, target: [0, 1.2, 0] })
},
},
ambient,
b3dSkybox({ timeOfDay: 17 }),
b3dGround({ size: 30, color: '#4a4f45' }),
doorL, knobL, doorR, knobR, plinth, key, lamp, post, lampSwitch,
useL, useR, takeKey, flick,
),
div(
{ style: 'position:absolute; top:8px; left:8px; background:rgba(0,0,0,0.6); color:white; padding:8px 12px; border-radius:6px; font:14px monospace' },
p('Hover highlights. Press and release ON a thing to use it.'),
span({ bindText: demo.status }),
p({ style: 'opacity:0.7' }, 'carrying: ', span({ bindText: demo.key })),
)
)
Attributes
| Attribute | Default | Description |
|---|---|---|
target |
'' |
Mesh or node name to make touchable. Empty = the mesh of the element this one is nested inside |
include |
'subtree' |
'subtree' counts the named node and everything under it; 'self' counts only the node itself |
reach |
0 |
Max distance in world units; 0 means no limit |
disabled |
false |
Refuses hover, and drops a press already in flight |
highlight |
'#ffcc44' |
Hover outline colour; 'none' for no highlight |
Events and callbacks
Every one carries { mesh, point, distance, source, actor }; refused adds
reason. source is how the activation arrived — 'pointer', 'near'
(useNearest), or 'api' (a direct activate()); actor is whatever the
caller passed, and is deliberately opaque here.
| Event | Callback prop | When |
|---|---|---|
hover |
whenHovered |
The pointer arrived (and is in reach) |
unhover |
whenUnhovered |
It left, or the thing was disabled under it |
activate |
whenActivated |
A press that started AND ended on it, with no veto |
refused |
whenRefused |
The same press, but a veto said no |
Callbacks are named whenX, not onX, on purpose: elementCreator treats any
on* prop as a DOM event listener, so an onActivate prop would silently become
addEventListener('activate') and your function would never be called.
Composing other features onto it
Push a veto and something else on the same piece can refuse an activation without either feature knowing the other exists:
import { b3dInteractive } from 'tosijs-3d'
const use = b3dInteractive({ target: 'hatch' })
use.vetoes.push({ name: 'powered', blocks: () => !reactor.online })
use.vetoes.push({ name: 'locked', blocks: () => !player.has('hatch-key') })
The first refuser's name comes back on the refused event, which is the
difference between a locked door and a broken one.
A veto is TOLD about the activation
blocks(info) receives the same description the refused event carries, which
is what lets one door answer differently for different callers:
// Two actors: the NPC has the key, the player does not. One closure over
// `player.has(…)` could not express this.
use.vetoes.push({ name: 'locked', blocks: (info) => info.actor !== npc })
// Near versus far: a lock you can REACH is not a lock you can merely SEE.
use.vetoes.push({
name: 'out-of-reach',
blocks: (info) => info.source !== 'near' && info.distance > 2,
})
// And a veto that ignores its argument is still a veto.
use.vetoes.push({ name: 'powered', blocks: () => !reactor.online })
Pass the actor in when you activate it yourself — and the distance, if any
veto reads it. An activation that does not say how far away it is is told
Infinity and a reach veto refuses it, deliberately: "we do not know that you
are near" is the safe answer, and the one scenario reach vetoes exist for is an
NPC across the map activating a door nobody has ever hovered.
use.activate({ actor: npc, distance: npc.distanceTo(door) })
useNearest measures the distance itself, so it needs neither.
Raised by tosijs-3d-ensemble (#36), with the argument that settled it: it
costs nothing today and cannot be added later without changing every veto
anyone has written.
Reaching it without pointing at it
useNearest(scene, position) activates the closest thing within its own reach —
the "walk up and press E" control, for wiring to ControlInput.interact:
import { useNearest } from 'tosijs-3d'
if (input.interact) useNearest(scene, camera.globalPosition)