Rendering
<IsoScene>
A viewer that draws one scene as SVG. It has no editing features: only rendering, events, state binding and playback.
| prop | Default | Meaning |
|---|---|---|
scene | (required) | The scene to draw |
packs / registry | A list of asset packs, or a registry made with createRegistry(packs) (one or the other) | |
rotation | 0 | View rotation 0, 90, 180 or 270 |
states | Object id → state name. Pass live data here | |
showGrid | true | Floor grid lines |
animateMotion | true | When a person's position or pose changes in the scene data, bridge it with walking and sitting |
playTimelines | false | Timeline playback. true plays all; an array plays only those ids |
timelineSpeed | 1 | Timeline speed (ignored when clock is given) |
playBehaviors | false | Routine playback (ignored when behavior is given) |
behaviorSpeed | 1 | Behavior speed (ignored when clock or behavior is given) |
behavior | A useBehaviorPlayback() made by the app. If given, that is what is drawn — for commands and reading, see Moving people | |
clock | A clock shared by behavior and timelines. The app controls play, pause and speed | |
showActivities | false | Activity 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 | |
padding | 24 | Margin around the scene (SVG units) |
highlightColor | #3b5bdb | Outline color of the hovered object |
className · style | Applied 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.
<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>
)}
/>| overlay | Meaning |
|---|---|
hulls | Object 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
<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
}}
/>pointis 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
objectthat furniture. Even when you click on furniture,pointis the floor behind it, so handle furniture clicks withobject. - 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.
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>
)
}| Piece | What 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 |
LiveSceneLayer | Takes 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 |
SceneLayer | Draws 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 · IsoStyles | Floor plate and grid, silhouette outline, part animation CSS |
useTimelinePlayback | Also 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.
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).
| Value | Meaning |
|---|---|
offset | Displacement along world axes [x, y, z] (tiles) |
grow | Amount 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) |
opacity | Opacity multiplier. At 0 the part is also dropped from silhouettes and front/back sorting |
Custom motion sources
// 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:
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
DrawParthas 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) andobjectPartBoxes(object, asset)(everything else) give world-space boxes. - For a static SVG file, use
renderSceneSvgfrom@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) inuseMemoso they are not new objects on every render.useBehaviorPlayback().statesstays 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
moveBeforeso animations do not restart (in browsers without it, they restart).