Hotspots

Put small clickable dots on your model, for example to open a panel about a soft-close lid. A hotspot can stand off the surface on a thin line, open and close with an animation, and light up the model around it. Keep it in your viewport only, or show it in renders and videos too.

import { createHotspot, attachHotspots } from '@oeave/bakery3/hotspots';

const lid = createHotspot({
  id: 'lid',
  label: 'Soft-close lid',
  anchor: [0.12, 0.28, 0], // a point on the model's surface
  direction: [0, 1, 0], // which way the dot stands off it
  distance: 0.06, // how far, in meters
  parent: product, // follows the model
});
const knob = createHotspot({ id: 'knob', position: [-0.2, 0.15, 0.1] });
scene.add(knob.object); // no parent: add it yourself

const ui = attachHotspots({
  hotspots: [lid, knob],
  camera,
  domElement: renderer.domElement,
  onSelect: (hotspot) => showPanel(hotspot?.label ?? null),
});

renderer.setAnimationLoop((time) => {
  ui.update(time / 1000);
  renderer.render(scene, camera);
});

A hotspot pulses gently, highlights on hover and opens on click. With an anchor, a thin line runs from a small dot on the surface out to the hotspot. It is plain Three.js, so it works with WebGLRenderer and WebGPURenderer.

Tie a hotspot to the model, or let it float

A hotspot tied to the model has two points: the anchor on the surface and the position where the dot is. A thin line joins them, with a small dot on the surface. Give it the model as its parent, and it turns and moves with the model.

createHotspot({
  id: 'lid',
  anchor: [0.12, 0.28, 0], // on the surface
  position: [0.2, 0.42, 0.05], // the dot, anywhere you like
  parent: product, // turns with the model
});

A free hotspot has only a position: no line, no surface dot. Add it to the scene rather than the model, and it stays where it is while the model turns.

const note = createHotspot({ id: 'note', position: [0.6, 0.9, 0.3] });
scene.add(note.object);

Both points are in the space of the parent (or the scene, without one). Move the dot later with hotspot.setPosition([x, y, z]), and the surface point with hotspot.setAnchor([x, y, z]), which keeps the dot where it is. hotspot.position and hotspot.anchor read them back.

Place a hotspot where the user clicks

hotspotFromHit() turns a raycast hit on your model into an anchor and a direction. The direction follows the surface normal, or points away from the middle of the model with along: 'center', which keeps dots on a bumpy surface from pointing every which way.

import { createHotspot, hotspotFromHit } from '@oeave/bakery3/hotspots';

const hit = raycaster.intersectObject(model, true)[0];
if (hit) {
  const { anchor, direction } = hotspotFromHit(hit, { space: product });
  createHotspot({ id: 'new', anchor, direction, distance: 0.08, parent: product });
}

Change the distance later with hotspot.setDistance(meters), and the direction with hotspot.setDirection([x, y, z]).

Open and close a hotspot

A hotspot is hidden, closed or open. setState() moves it there with an animation: appearing, the dot lands on the surface, the line grows out and the hotspot pops in at its end; opening, it swells and brightens.

lid.setState('hidden', { instant: true });
lid.setState('closed'); // grows out of the surface
lid.setState('open'); // what a click does
lid.phase; // 'entering', 'closed', 'opening', 'open', 'closing' or 'leaving'

Set how long each move takes with timing: { appear: 0.8, open: 0.45 }.

Change how a hotspot looks

createHotspot({
  id: 'lid',
  anchor,
  direction,
  style: 'ring', // 'orb', 'ring', 'glass' or 'solid'
  color: '#33d1ff',
  openColor: '#ff2d95', // the color it turns while open
  intensity: 3, // how bright it glows
  glow: 0.05, // how much it lights the model around it
});

For your own look, pass material: one material for the dot itself, or a function asked once per part (core, halo, ring, shell, tether, dot) that returns a material or undefined to keep the default. A TSL node material works too. Multiply its emissiveNode by bakeryEmissiveIntensity() so it brightens as the hotspot opens in your viewport, as it does in the render.

import { MeshStandardNodeMaterial } from 'three/webgpu';
import { color } from 'three/tsl';
import { bakeryEmissiveIntensity, bakeryFresnel } from '@oeave/bakery3/tsl';

createHotspot({
  id: 'lid',
  anchor,
  direction,
  render: true,
  material: ({ name }) => {
    if (name !== 'core') return undefined;
    const rim = new MeshStandardNodeMaterial();
    rim.emissiveNode = color('#33d1ff')
      .mul(bakeryFresnel(1.45).mul(4).add(0.1))
      .mul(bakeryEmissiveIntensity());
    return rim;
  },
});

Show a label beside a hotspot

createHotspot({
  id: 'lid',
  label: 'Soft-close lid',
  anchor,
  direction,
  showLabel: 'hover', // 'never', 'hover', 'open' or 'always'
  labelSide: 'auto', // or 'left' or 'right'
  labelFont: '600 40px Inter, sans-serif',
});

The label fades in and slides out from the dot. With labelSide: 'auto' it sits on the right, and moves to the left when the hotspot is too close to the right edge of the screen. hover shows it while the pointer is over the dot and while the hotspot is open; a phone has no hover, so there it shows when the hotspot is open. Change the text later with hotspot.setLabel().

The label is drawn by the browser in the font you give, so a render shows the same letters you see. Set the color with labelColor and labelBackground, and the size with labelSize. The font's pixel size only sets how sharp the label is: the label is sized to the dot. A hotspot and its label always face the camera.

Show hotspots in a render

By default a hotspot is for your viewport only and never shows up in a render. Pass render: true to make it part of the scene: it is traced like everything else, and its glow lights your model.

Use sizing: 'world' with a size in meters if the hotspot should have a fixed size in the render. The default, screen, keeps it the same size on screen at any distance, and a render uses its size at the moment you render.

Play hotspots in a video

animateHotspots() puts state changes on a timeline. Scrub the timeline and you see in your viewport what the video will show.

import { animateHotspots } from '@oeave/bakery3/hotspots';

const timeline = bakery3.timeline({ duration: 6 });
timeline.push({ object: 'camera', orbitY: 30 }); // the camera first

animateHotspots(timeline, [lid, knob], {
  events: [
    { hotspot: 'lid', at: 0.3, state: 'closed' },
    { hotspot: 'knob', at: 0.6, state: 'closed' },
    { hotspot: 'lid', at: 2, state: 'open' },
    { hotspot: 'lid', at: 3.5, state: 'closed' },
  ],
});

await bakery3.renderVideo({ timeline });

An event can hover a hotspot instead of changing its state, as the pointer would: { hotspot: 'lid', at: 1.5, hover: true }, and hover: false to move off it. A hotspot with showLabel: 'hover' shows its label in the video then.

Every hotspot starts hidden unless you pass initial. Each must be made with render: true. Call it again to replace what it wrote, and call release() on what it returns to hand the hotspots back to clicks. The pulse is rounded to whole breaths over the clip, so a looping video has no jump.

The glow's light stays where the hotspot ends up, so it lights the model from there while the hotspot grows in. A hotspot on a model that the timeline moves keeps its light where it was when you rendered.

Options

OptionDefaultWhat it does
idrequiredA stable name. It travels with hotspotSpecOf().
labelnoneThe label's text, and a caption for your app to show.
anchornoneA point on the model, in the parent's space. Draws the line and the dot.
directionupWhich way the hotspot stands off the anchor.
distance0.1How far it stands off the anchor, in meters.
positionnoneWhere the dot is, in the parent's space. With an anchor, instead of direction and distance.
parentnoneThe object the hotspot follows. Without one, add hotspot.object to the scene yourself.
renderfalseShow it in renders and videos.
style'orb'orb, ring, glass or solid.
color'#33d1ff'Any CSS color.
openColornoneThe color it turns while open.
intensity3How bright it glows. 1 when it does not render, so the color on screen is exactly color.
glow0How much it lights the model around it while open, in candela. A third of it while closed.
materialnoneYour own material, or a function that picks one per part.
sizing'screen'screen keeps it the same size on screen at any distance. world is a fixed radius in meters.
size0.015The radius: a fraction of the viewport height for screen, or meters for world (default 0.03).
pulsetrueWhether it pulses while closed, or how fast, in cycles a second.
tethertrueDraw the line to the anchor.
dottrueDraw the dot on the anchor.
showLabel'never'When the label shows: never, hover, open or always.
labelSide'auto'Which side the label is on: auto, left or right.
labelFontsystem UIA CSS font for the label.
labelSize1The label's height, as a multiple of the default.
state'closed'The state it starts in.
timingsee aboveSeconds to appear and to open.

Every hotspot has id, label, object (the group to add or move), state, phase, selected, hovered, position, anchor, distance, labelSide, setState(), setSelected(), setHovered(), setLabel(), setPosition(), setAnchor(), setDistance(), setDirection(), update(), fit() and dispose(). attachHotspots() returns update(), selected, select() and dispose(). Without attachHotspots(), call fit(camera) and update(seconds) on each hotspot every frame.

A hotspot that does not render is marked userData.bakery3 = { exclude: true }, the same flag you can put on any object of your own.

Save the hotspots with a render

hotspotSpecOf() returns a hotspot as plain data in world space. Put it in productState, which is stored with the render and never rendered. Your app can then place the hotspots over the finished image.

import { hotspotSpecOf } from '@oeave/bakery3/hotspots';

await bakery3.render({
  productState: {
    finish: 'walnut',
    hotspots: [lid, knob].map(hotspotSpecOf),
  },
});
// hotspotSpecOf(lid):
// { id: 'lid', label: 'Soft-close lid', position: [x, y, z], anchor: [x, y, z] }