@expofp/renderer
v3.45.9
Published
ExpoFP SDK internal: WebGL floorplan renderer
Maintainers
Keywords
Readme
@expofp/renderer
Interactive floor plan renderer built on react-three-fiber.
Architecture
Renderer(src/Renderer.tsx) — the public entry: mounts the R3FCanvasand the scene (controls/,lighting/,batch/). Passdebugto enable the debug overlay.- Scene topics live in their own folders (
batch/,controls/,lighting/, …); add new rendering features as sibling topic folders. debug/— the debug overlay (lil-gui panel + stats-gl overlay), loaded through a single dynamicimport()inRenderer. Everything underdebug/(and its heavier deps, lil-gui + stats-gl) is code-split into one lazily loaded chunk that is never fetched whendebugis off. Add new debug pieces todebug/index.tsxso they ride along in the same chunk.
Navigation
RendererProps carries two different kinds of thing:
- Camera limits —
minZoom/maxZoom/minPitch/maxPitch, declared asCameraPropsinsrc/space/Camera.tsx. They constrain the camera itself, so they hold for a programmatic move as much as for a dragged one.Camerawrites them and defaults them, beside the clipping planes derived from the same numbers;maxPitchboth defaults to and is capped atMAX_PITCH(degrees, inclipping.ts), the angle every depth calculation in the renderer is sized against. controls— what a user may do. Each handler takesfalse,trueor omitted for its defaults, or an options object.
<Renderer
scene={scene}
minZoom={0.1}
maxZoom={100}
minPitch={0}
maxPitch={85}
controls={{
pan: { inertia: { durationMs: 600 } },
zoom: true,
roll: { threshold: 25 },
pitch: false,
}}
/>Pan and zoom default on; roll and pitch default on only in a development build, so this package's Storybook and dev server have them and a production plan does not until a caller asks. An explicit value always wins.
Each handler is a component under src/controls/handlers/, mirroring the
imperative renderer's ui/handlers/, and owns its own config type and defaults.
Mounting is enabling: a handler claims its mouse and touch actions on mount and
gives them back on unmount, and the controls are built with every action switched
off. To add a gesture, write a sibling component and a field on ControlsConfig.
Camera writes the limits in two effects — the pitch limits, and the framing
(distance limits, far plane, and the dolly that preserves zoom across a resize),
which is one transaction because the dolly must follow the limits.
The controls are reached two ways and no other: useCameraControls() reads
them, and useControlsEffect(effect) runs an effect against them once they
exist, handing them over as an argument. The React Compiler rejects assigning to
a field of a hook's return value — a method call on it, or passing it to a
function that does the assigning, is fine — so a component with a field to write
either routes it through a helper or uses useControlsEffect. Two-finger
gestures come off useGesture(type, handler).
Adding GUI controls from a component
debug/gui-context.ts exposes useGui(). It returns the active lil-gui
instance, or null when the overlay is disabled or still loading — so guard on a
non-null value. useGui is imported from the tiny gui-context module (a
type-only lil-gui import), so consuming it does not pull lil-gui into the
main chunk.
function MyThing() {
const gui = useGui();
useEffect(() => {
if (!gui) return;
const folder = gui.addFolder('My thing');
folder.add(/* … */);
return () => folder.destroy();
}, [gui]);
// …
}Recommended extensions
Two browser extensions make what the renderer draws inspectable:
- Three.js DevTools adds a DevTools tab showing the live scene tree — the quickest way to see how Defs translate into three.js objects. Hovering a mesh there highlights its geometry on the plan.
- SpectorJS lists every WebGL draw call of a captured frame, with the intermediate canvas state between them. It takes a little coaxing: tick Show offscreen canvas(es) to see the canvases React renders (this works inconsistently — closing and reopening the extension a couple of times usually gets there), pick the canvas from the Choose canvas… dropdown by matching its dimensions, then press the red Record button. In debug mode the capture opens immediately, because the scene re-renders every frame; without it you have to trigger a frame first, most easily by panning or zooming slightly.
Running unit tests
Run nx test @expofp/renderer to execute the unit tests via Vitest.
Limits of the published package
dist is not supported outside this repository. It does not even resolve.
dist/objects/images/worker/client.js holds
import AtlasWorker from './atlas-worker?worker&inline', and dist/index.js
reaches that file. The ?worker&inline specifier is a Vite instruction, so
only Vite can answer it. Any other consumer — a different bundler, plain Node,
a browser loading the files as they are — fails to resolve the import and stops
there. Nothing in the package loads, so there is no smaller part of it to fall
back on.
This package has no bundler. Its build target is plain tsc, so dist is
file-per-module TypeScript output and nothing else runs over it. tsc copies
the specifier through unchanged, which is where the broken import comes from.
The same absence of a bundler means the React Compiler never runs on dist
either, so the components there ship unoptimized.
Nothing in this repository is affected, because nothing in this repository
consumes dist. The renderer is compiled from its own source instead.
Floorplan's browser build reaches src through the @expofp/source condition
in vite.config.mts, so Vite answers the specifier and inlines the worker
script into the bundle; floorplan also applies its own React Compiler pass.
This package's own unit tests and Storybook import src by relative path, so
Vite answers the specifier for them too: the Storybook run starts the real
worker, and the unit tests drive the protocol against a fake one. Wayfinding
imports types only, which are erased at emit. The offline CLI copies
floorplan's browser bundle rather than importing anything.
To lift the limit, give this package a Vite library build in the shape
floorplan already uses. Keep build (tsc) for the types. Add build-browser
(Vite) for the bundle. Put a browser condition in the exports map, above
import. That answers the worker specifier and runs the React Compiler, so
both problems above go with it.
