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.