b3d-beacon

A clickable hull for anything that has a position and no geometry. A lamp is a light; a positional sound is a sound; a spawn point is a coordinate. All three can be placed, moved and listed — and none of them has anything for a ray to land on, so none of them can be clicked.

Demo

Three lamps with geometry: 'off' — so there is genuinely nothing in the scene but the light they cast. Click where one is and the readout names it. The hulls are invisible; the toggle shows you what you are actually hitting.

import { b3d, b3dLight, b3dSkybox, b3dGround, b3dPointLight, b3dBeacon, beaconOwner, toggle3d, label3d } from 'tosijs-3d'
import { orbitCam } from 'tosijs-3d/demo-utils'
import { elements } from 'tosijs'

const { div } = elements
const readout = div({ class: 'readout' }, 'click a lamp')

const lamp = (name, x, z, colour) =>
  b3dPointLight({ x, y: 1.6, z, diffuse: colour, intensity: 8, range: 6, geometry: 'off' },
    b3dBeacon({ name }))

const sceneEl = b3d(
  {
    style: 'width:100%;height:100%',
    scenePanelOpen: true,
    scenePanel: () => [
      label3d({ text: 'Beacons' }),
      toggle3d({
        label: 'show hulls',
        value: false,
        handleChange: (v) => {
          for (const b of sceneEl.querySelectorAll('tosi-b3d-beacon')) b.show = v ? 'on' : 'off'
        },
      }),
    ],
    sceneCreated(el) {
      orbitCam(el, { alpha: -1.2, beta: 0.85, radius: 20, target: [0, 0.5, 0] })
      // Ordinary picking — no predicate. That is the whole point; see below.
      el.scene.onPointerDown = () => {
        const hit = el.scene.pick(el.scene.pointerX, el.scene.pointerY)
        const owner = beaconOwner(hit?.pickedMesh)
        readout.textContent = owner ? `picked: ${owner.name}` : 'nothing there'
      }
    },
  },
  // No sun: Babylon lights a material with at most four lights by default, and
  // a sun plus ambient plus three lamps is five — the third lamp silently goes
  // dark, which reads as a broken beacon rather than a lighting budget.
  b3dSkybox({ timeOfDay: 21 }),
  b3dLight({ intensity: 0.12 }),
  b3dGround({ size: 60, color: '#3b4152' }),
  lamp('red lamp', -6, 0, '#ff5544'),
  lamp('green lamp', 0, -5, '#44ff88'),
  lamp('blue lamp', 6, 2, '#5588ff')
)

preview.append(readout, sceneEl)
.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
name '' what this beacon stands for, for a readout or a list
size 0.5 cube edge, in metres
width height depth 0 per-axis extent; 0 means "use size"
shape 'box' 'box' or 'sphere'
show 'off' 'on' draws it as a wireframe — for debugging, not for play
follow 'on' track the element this is nested in, rather than its own x/y/z
x y z 0 position, used when follow is off or there is nothing to follow

Invisible, and pickable anyway — the part that is easy to get wrong

⚠️ Babylon's default pick test is isEnabled() && isVisible && isPickable, so hiding a hull the obvious way — isVisible = false — makes it unpickable, which is the only thing it exists for.

tosijs-3d-ensemble hit this and worked around it by always passing its own pick predicate, because Babylon consults a predicate instead of that test rather than as well as it. That works, and it makes the hull silently stop working for any caller who picks without one, so "picking is broken" is rediscovered by every consumer in turn.

This uses visibility = 0 instead, which draws nothing while leaving isVisible true — so an ordinary scene.pick(x, y) with no predicate finds it, and there is no contract to remember. The demo above picks that way on purpose.

Measured against a live scene rather than reasoned about, since "it should still be pickable" is the kind of claim that is wrong quietly:

hull scene.pick(x, y) returns
visibility = 0 the hull
isVisible = false whatever is BEHIND it
isVisible = false, with a predicate the hull

The middle row is the failure, and note what it does: it does not throw or return nothing, it returns a different object. A consumer sees the wrong thing selected, which is a much harder bug to attribute than a dead click.

Invisible, not drawn

The first version of this elsewhere drew an amber cube. It works, and it is clutter: a solid the author cannot move, delete or edit, sitting in a view whose whole purpose is judging an arrangement. Collision geometry is not seen. show exists for when you are debugging the hull itself.

Opt-in, never blanket

A beacon is not given to everything without geometry. Most environment primitives have no location to mark: a skybox is everywhere, fog and ambient are settings, and a sun's direction is a DIRECTION — a dot floating at (-0.5, 1, 0.4) would be a confident lie about where the sun is. Three junk markers near the origin is what the blanket rule produces.

So you nest one where you want one, and nowhere else.

A light's extent is NOT its range

Tempting, and wrong. A 40 m point light would give a 40 m hull that swallows every other pick in the room — including the lamps inside it, so the feature would defeat itself at exactly the density where you need it.

A beacon marks a POINT, so its default is a nominal half-metre cube: big enough to hit on a phone, small enough not to read as scenery. Set size when the thing genuinely has an extent — a trigger volume, a zone, a tile.

It does not scale with the camera

A handle does; a marker does not. A handle is a control, a marker is a fact about the world — so a beacon two hundred metres away is a small target, correctly, because the thing it stands for is far away. b3d-manipulator is the other half of this: a beacon is what you click, handles are what you then drag.