surface
The UI surface — a root that holds a content box plus a top overlay layer where popups live. Popups mount at the surface root (NOT clipped by any box), get positioned by placePopup (flip near an edge, clamp into the surface), and stack for cascade menus: a submenu opens beside its parent item, and the same surface routes the pointer to the top-most popup, dismissing on an outside press. Renders in the DOM and on a 3D texture like everything else.
Menus vs. panels
Two popup kinds share the overlay:
- Menus — transient cascades.
openMenu(surface, anchor, items)opens a menu; asubmenuitem opens a child popup to its right (flipping left near the edge) — the tree stays visible, not push/pop. A leaf selects and closes the whole cascade; an outside press dismisses it. - Panels — persistent floating windows.
surface.openPanel(at, box, { title, draggable })opens a titled panel that stays until closed (its × orclosePopup) and can be dragged by its title bar (clamped to the surface). An outside press does NOT dismiss it — ideal for a pinned debug/inspector panel.
Demo
import { b3d, b3dLight, b3dSvgPlane, box, textBlock, button, surface, openMenu } from 'tosijs-3d'
import { svgElements, elements } from 'tosijs'
const { svg } = svgElements
const { div } = elements
const W = 300
const H = 260
const readout = div({ style: 'margin:6px 16px 16px;color:#8ea;font:13px system-ui' }, 'Pick a menu item.')
const items = [
{ label: 'Talk', onSelect: () => (readout.textContent = 'Selected: Talk') },
{ label: 'Trade', onSelect: () => (readout.textContent = 'Selected: Trade') },
{
label: 'More',
submenu: [
{ label: 'Inspect', onSelect: () => (readout.textContent = 'Selected: Inspect') },
{ label: 'Give', onSelect: () => (readout.textContent = 'Selected: Give') },
{ label: 'Leave', onSelect: () => (readout.textContent = 'Selected: Leave') },
],
},
]
const mono = { size: 12, family: 'ui-monospace, monospace' }
const debugRows = () => box(
{ width: 150, padding: 10, gap: 3, background: '#0e1116' },
textBlock('fps 60', { font: mono, color: '#9fe0a0' }),
textBlock('draws 214', { font: mono, color: '#9fb0c3' }),
textBlock('tris 128k', { font: mono, color: '#9fb0c3' }),
textBlock('mem 84 MB', { font: mono, color: '#9fb0c3' })
)
const make = () => {
const s = surface({ width: W, height: H })
const menuBtn = button('Menu ▾', { onActivate: () => openMenu(s, s.__triggerRect, items, 'below') })
const dbgBtn = button('Debug ▾', { onActivate: () => s.openPanel({ x: W - 168, y: 92 }, debugRows(), { title: 'Debug' }) })
const panel = box(
{ width: W, height: H, padding: 16, gap: 12, background: '#12151c' },
textBlock('Menus + panels', { font: { size: 18, weight: 600 }, color: '#e6e6e6' }),
textBlock('Click Menu to cascade a submenu. Debug opens a persistent panel — drag its bar to move it, hit × to close.', { font: { size: 13 }, color: '#9fb0c3' }),
menuBtn,
dbgBtn
)
s.setContent(panel)
const r = panel.childRect(2) // the Menu button
s.__triggerRect = { x: r.x, y: r.y, width: r.width, height: r.height }
// pre-open a draggable/closable debug panel so it's visible at a glance
s.openPanel({ x: W - 168, y: 92 }, debugRows(), { title: 'Debug' })
return s
}
const sheet = (s) => svg({ viewBox: `0 0 ${W} ${H}`, width: W, height: H }, s.el)
// ONE surface, shown in the DOM AND handed to the plane. b3dSvgPlane's SvgTexture
// CLONES the live element each frame, so it stays in the DOM (visible) while the
// texture mirrors it — a click in either view updates the same surface, in sync.
const s = make()
const svgEl = sheet(s)
const toXY = (e) => { const r = svgEl.getBoundingClientRect(); return [((e.clientX-r.left)/r.width)*W, ((e.clientY-r.top)/r.height)*H] }
svgEl.addEventListener('pointerdown', (e) => s.handlePointer('down', ...toXY(e)))
svgEl.addEventListener('pointerup', (e) => s.handlePointer('up', ...toXY(e)))
const plane = b3dSvgPlane({ width: 2.6, height: (2.6*H)/W, resolution: 640, materialChannel: 'emissive', pointerEvents: false })
plane.svgElement = svgEl // the same element the DOM shows — cloned each frame → in sync
const scene = b3d(
{
style: 'width:340px;height:300px;display:block;border-radius:8px;overflow:hidden',
sceneCreated(el) {
const cam = new el.BABYLON.ArcRotateCamera('cam', -Math.PI/2, Math.PI/2.5, 3.4, el.BABYLON.Vector3.Zero(), el.scene)
el.setActiveCamera(cam)
cam.attachControl(el.scene.getEngine().getRenderingCanvas(), true)
const T = el.BABYLON.PointerEventTypes
el.scene.onPointerObservable.add((pi) => {
const kind = pi.type === T.POINTERDOWN ? 'down' : pi.type === T.POINTERUP ? 'up' : ''
if (!kind) return
const pk = pi.pickInfo
if (pk && pk.hit && pk.pickedMesh === plane.mesh) {
const uv = pk.getTextureCoordinates()
if (uv) s.handlePointer(kind, uv.x * W, (1 - uv.y) * H)
}
})
},
},
b3dLight({ intensity: 1 }),
plane
)
preview.append(
div(
{ style: 'display:flex;gap:24px;align-items:flex-start;padding:16px 16px 4px;background:#0c0e14' },
div({ style: 'color:#9ab;font:12px system-ui' }, 'DOM — click; the 3D view mirrors it', svgEl),
div({ style: 'color:#9ab;font:12px system-ui' }, '3D texture — click the items', scene)
),
readout
)