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:

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
)