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,
panelTitle: 'Beacons',
scenePanel: () => [
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, { alphaDeg: -68.8, betaDeg: 48.7, 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.
Passing your own pick predicate works around it, because Babylon consults a predicate instead of that test rather than as well as it. But then the hull silently stops working for any caller who picks without one.
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
A drawn marker would be 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.