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 }; refused adds reason.
| 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.
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)