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)