Skip to content

Rendering ​

<IsoScene> ​

A viewer that draws one scene as SVG. It has no editing features: only rendering, events, state binding and playback.

propDefaultMeaning
scene(required)The scene to draw
packs / registryA list of asset packs, or a registry made with createRegistry(packs) (one or the other)
rotation0View rotation 0, 90, 180 or 270
statesObject id → state name. Pass live data here
showGridtrueFloor grid lines
animateMotiontrueWhen a person's position or pose changes in the scene data, bridge it with walking and sitting
playTimelinesfalseTimeline playback. true plays all; an array plays only those ids
timelineSpeed1Timeline speed (ignored when clock is given)
playBehaviorsfalseRoutine playback (ignored when behavior is given)
behaviorSpeed1Behavior speed (ignored when clock or behavior is given)
behaviorA useBehaviorPlayback() made by the app. If given, that is what is drawn — for commands and reading, see Moving people
clockA clock shared by behavior and timelines. The app controls play, pause and speed
showActivitiesfalseActivity icon bubbles above people's heads
onActivityClick(id) => void. The person id when a bubble is clicked. Only bubbles get the pointer cursor, and this click does not call onSceneClick or onObjectClick. With ActivityBubbles directly, use onBubbleClick
renderOverlay(overlay) => ReactNode. Draws SVG over people and furniture every frame
onObjectClick(object) => void
onSceneClick(point, object | null) => void. The scene coordinates of the floor under the pointer, and the clicked object
padding24Margin around the scene (SVG units)
highlightColor#3b5bdbOutline color of the hovered object
className · styleApplied to the <svg>. It fills its parent, so give the parent a height

Overlays ​

renderOverlay is called every frame with that frame's silhouettes, so marks above a walking person follow them exactly.

tsx
<IsoScene
  scene={scene}
  registry={registry}
  behavior={behavior}
  renderOverlay={({ hulls, project, top }) => (
    <g pointerEvents="none">
      {/* outline of the selected person */}
      {selected && <HullOutline hull={hulls.get(selected)} color="#f59f00" width={2} />}
      {/* a pin at one scene point (the meeting room entrance) */}
      <circle cx={project([6, 3, 0])[0]} cy={project([6, 3, 0])[1]} r={3} fill="#e03131" />
      {/* name tags above heads */}
      {[...behavior.ids].map((id) => {
        const at = top(id)
        return at && <text key={id} x={at[0]} y={at[1] - 4} textAnchor="middle" fontSize={8}>{names[id]}</text>
      })}
    </g>
  )}
/>
overlayMeaning
hullsObject id → on-screen silhouette (SVG coordinates). People who are away (off work, out) are not included
project(point)Scene coordinates [x, y, z] → SVG coordinates (view rotation applied)
top(id)Top center of the silhouette (above the head, for a person). null if missing
bounds(id)The rectangle around the silhouette. Put a label under the feet at ((minX + maxX) / 2, maxY + margin) (it will not overlap the bubble above the head)

To lay HTML (tooltips, panels) over the SVG instead, convert SVG coordinates to screen pixels: compute new DOMPoint(x, y).matrixTransform(ctm) with svg.getScreenCTM().

Where the user clicked ​

tsx
<IsoScene
  scene={scene}
  registry={registry}
  behavior={behavior}
  onSceneClick={([x, y], object) => {
    if (object && registry.get(object.asset)?.seats?.length) behavior.command('kim', { type: 'do', spot: object.id })
    else behavior.command('kim', { type: 'go', to: [x - 0.25, y - 0.25] })   // to the footprint corner
  }}
/>
  • point is the scene coordinate of the top of the floor tile or rug under the pointer. It matches the pointer even on thick floor tiles.
  • Clicking furniture makes object that furniture. Even when you click on furniture, point is the floor behind it, so handle furniture clicks with object.
  • Outside React, floorPointAt(scene, registry, [sx, sy], rotation) does the same calculation.

Drawing with hooks ​

If <IsoScene>'s layout or events do not fit, assemble the view yourself from hooks and layers. The editor (Studio canvas) works this way.

tsx
import { prepareRender } from '@isora/core'
import {
  ActivityBubbles,
  GroundGrid,
  IsoStyles,
  LiveSceneLayer,
  useBehaviorPlayback,
  useObjectMotion,
  type LiveSource,
} from '@isora/react'
import { useMemo } from 'react'

function Canvas({ scene, registry }) {
  const behavior = useBehaviorPlayback(scene, registry)
  const motion = useObjectMotion(scene, registry, { ignore: behavior.ids })
  // People drawn every frame (walking and sitting people)
  const sources = useMemo(() => [motion.live, behavior.live].filter((s): s is LiveSource => s !== null), [motion.live, behavior.live])
  // Everything else is drawn once, with its front/back edges measured. People drawn every frame are left out via dynamic.
  const prepared = useMemo(
    () => prepareRender(scene, registry, { states: behavior.states, dynamic: new Set(sources.flatMap((s) => [...s.ids])) }),
    [scene, registry, behavior.states, sources],
  )
  return (
    <svg viewBox="-400 -40 800 480">
      <IsoStyles />
      <GroundGrid scene={scene} />
      <LiveSceneLayer prepared={prepared} sources={sources} interactive onObjectClick={(id) => console.log(id)}>
        {(render) => <ActivityBubbles people={behavior.people} hulls={render.hulls} />}
      </LiveSceneLayer>
    </svg>
  )
}
PieceWhat it does
prepareRender(scene, registry, options)Turns static things into parts to draw and measures their front/back edges. Call it again only when the scene, states or rotation change
LiveSceneLayerTakes the people moving this frame from sources, slots them in with composeRender, and draws. children is a render prop that receives this frame's drawing
LiveSource{ ids, sample(wall), until? }. useBehaviorPlayback().live and useObjectMotion().live are this. You can build your own to add other motion
SceneLayerDraws a list of parts as SVG (keeps draw order, click and hover, tick transitions). Use it alone when nothing needs per-frame drawing
GroundGrid · HullOutline · IsoStylesFloor plate and grid, silhouette outline, part animation CSS
useTimelinePlaybackAlso provides timelines (cargo, keyframes) as a live source. Put it in sources together with people

prepareRender options: rotation · states · overrides (values overridden only for drawing, e.g. a drag preview) · extra (objects not in the scene) · partChanges (part changes) · hidden · motion (per-object pose transitions and gait) · dynamic (ids to leave out here).

People and cargo are drawn the same way.

tsx
const behavior = useBehaviorPlayback(scene, registry, { clock })
const timeline = useTimelinePlayback(scene, registry, { clock })
const sources = [behavior.live, timeline.live].filter((s): s is LiveSource => s !== null)
const prepared = useMemo(
  () => prepareRender(scene, registry, { dynamic: new Set(sources.flatMap((s) => [...s.ids])) }),
  [scene, registry, sources],
)
<LiveSceneLayer prepared={prepared} sources={sources} />

Part changes (PartChanges) ​

object id or object id/part id → { offset?, grow?, opacity? }. Moves, stretches and hides parts only when drawing, without changing the scene. Timeline keyframes produce these, and you can also build them yourself and pass them as partChanges (static drawing) or LiveActor.changes (every frame).

ValueMeaning
offsetDisplacement along world axes [x, y, z] (tiles)
growAmount added to the box size [w, d, h]. It grows away from the smaller corner — to stretch downward, lower z along with it. For a crane cable whose whole object moves down, the lower end already follows, so grow only h (adding z too would break the cable)
opacityOpacity multiplier. At 0 the part is also dropped from silhouettes and front/back sorting

Custom motion sources ​

ts
// Move a forklift along a path every frame (for example, interpolating a live position).
const forklift: LiveSource = {
  ids: new Set(['forklift-1']),
  sample(wall) {
    const [x, y] = positionAt(wall)                       // a position the app has
    return [{ object: { ...forkliftObject, position: [x, y, 0.1] } }]   // returning the same array skips redrawing
  },
}

If sample returns the same array when nothing has changed, that frame is not redrawn. With until (wall-clock ms), frames stop running after that time.

Other renderers ​

The core does not depend on the DOM. To draw with Canvas, WebGL or server-side images:

ts
import { behaviorActors, composeRender, createBehaviorWorld, prepareRender } from '@isora/core'

const world = createBehaviorWorld(scene, registry)
const objects = new Map(scene.objects.map((o) => [o.id, o]))
const prepared = prepareRender(scene, registry, { dynamic: new Set(world.people) })

function draw(ctx: CanvasRenderingContext2D, t: number) {
  const render = composeRender(prepared, behaviorActors(objects, world.frameAt(t), world.people))
  for (const part of render.parts) {        // already in draw order (back → front)
    for (const face of part.faces) {
      const points = face.points.split(' ').map((p) => p.split(',').map(Number))
      ctx.beginPath()
      points.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)))
      ctx.fillStyle = face.fill
      ctx.fill()
    }
  }
}
  • A DrawPart has faces (faces), a world box (box, after view rotation), a silhouette (hull), a part animation name (animation) and a screen (screen). Outside SVG, implement or drop the CSS animations (typing, scrolling screens) yourself. Walking and sitting are already baked into the boxes.
  • For a 3D engine, use boxes instead of faces: actorPartBoxes(object, asset, motion) (people) and objectPartBoxes(object, asset) (everything else) give world-space boxes.
  • For a static SVG file, use renderSceneSvg from @isora/pack (CLI: isora export).

Server rendering ​

IsoScene also renders on the server (React 18/19 renderToString, RSC). The first frame shows playback time 0 s, and playback continues after hydration. renderOverlay is also called once on the server.

Performance ​

  • Static things are prepared once with prepareRender; each frame only computes the moving people. In the startup office sample (600 parts, 8 people) that is about 1.5 ms of JS per frame, over 60 fps in a production build.
  • Wrap the inputs that trigger a new prepareRender (scene, states, rotation, dynamic) in useMemo so they are not new objects on every render. useBehaviorPlayback().states stays the same object while its contents are the same.
  • Part animations (screens, typing) are CSS, so they cost no JS. When people walking past change the draw order, nodes are moved with moveBefore so animations do not restart (in browsers without it, they restart).

Released under the MIT License.