@opsydyn/effect-three-foldkit
v0.4.0
Published
Thin Foldkit adapter for @opsydyn/effect-three.
Readme
@opsydyn/effect-three-foldkit
A thin Foldkit adapter for @opsydyn/effect-three: derive a Three.js scene from your Model, get pointer interaction back as your own typed Messages, and let Foldkit's runtime own the scene's lifetime.
Status:
0.x, released with the core at one version (Stability, Limits). The API is documented inetc/effect-three-foldkit.api.md. Live samples are inapps/webat/effect-threeand on the effect-three promo site (apps/three-promo); their source is inpackages/effect-three-samples.
npm install @opsydyn/effect-three-foldkit @opsydyn/effect-three effect foldkit threeUse it
// entry: once per application
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
export const environment = EffectThreeFoldkit.makeEnvironment(window);
export const sceneMount = EffectThreeFoldkit.makeSceneMount(environment);// message.ts
export const Message = defineMessageUnion({
ClickedCube: { key: Schema.String },
ReceivedSceneLifecycle: { lifecycle: EffectThreeFoldkit.SceneLifecycle },
});
// view.ts
EffectThreeFoldkit.view(
{
scene: sceneFor(model), // plain data derived from the Model
mount: sceneMount,
className: styles.scene, // the element needs a size
onLifecycle: (lifecycle) => Message.ReceivedSceneLifecycle({ lifecycle }),
onClick: ({ key }) => Message.ClickedCube({ key }),
},
h,
);- Updates are in place. Foldkit hands the element a new scene on every render, and the core diffs it, so unchanged scenes cost no host changes and changed ones reuse their three.js objects.
- Animation never touches the Model. Behaviours such as
spinrun in the frame loop, outside Foldkit'supdate. - Events are typed and opt-in:
onClick,onDoubleClick,onContextMenu,onPointerDown,onPointerUp,onPointerEnter,onPointerMove,onPointerLeave,onPointerMissed,onWheel,onDrag,onDragEnd,onGesture,onGestureEnd,onAutoRotated,onCollision,onContactForce,onMeasured,onLabelMeasured,onXRSession,onXRStick,onXRButton,onXRSurface,onXRMoved,onXRPlanes,onXRMeshes,onPerformanceChange,onErrorandonReconciled. Only the handlers you pass are bound. Hits carry the mesh key (and instance), world-space point, distance, face normal and uv. - Controls: with
scene({ gestures: 'scene' }),onGesturereports drags, the wheel and pinches;orbit(model.camera, gesture)moves a camera kept in the Model (ADR-006). - Editing widgets as submodels:
TransformGizmo(three'sTransformControls),PivotControls(Drei's),BoxSelect(three'sSelectionBox, from shift-drags),ViewportGizmo(Drei'sGizmoHelper, in a scene'shud) andMeasuringTapeeach keep a Model, take Messages from the scene's events and report OutMessages (Moved,Finished,Selected,Turned,Measured) for the parent to apply;inspectorViewdraws the fieldsInspector.fieldsreads from a schema as a form. The core keeps the maths pure and checked against three's and Drei's own (ADR-006). - HTML overlays:
overlays: [{ overlay, content }]places Model-rendered HTML at points in the scene every drawn frame, as Drei'sHtml. - Groups and drags: hits carry their ancestor
path, and aninteractivegroup makes every mesh inside it pickable. A press on a mesh captures the pointer, soonDragkeeps firing off the mesh and off the canvas; pair itsraywithpointOnPlaneto drag along a plane. - Pixel ratio:
makeEnvironment(window, { pixelRatio: [1, 2] })takes a fixed ratio or a[min, max]range around the device's (the default is[1, 2], as React Three Fiber'sdpr), and re-measures when the device ratio changes. - Assets:
environment.assets.loadModel(id, url),loadTexture(id, url),loadFont(id, url)andloadEnvironment(id, url)load glTF models, textures, typeface fonts and HDR panoramas as Effects for Commands (settle them intoAsyncData), lend them to the store, and own them: loading an id again replaces and disposes the old asset, andunload(id)frees it. Show a model withborrowed({ id, animation: play('wave') }); use a texture withmap: id, a font withtext({ font: id }), a panorama withenvironment({ map: id })(Drei's presets:environmentPresetUrl('city')). - Engines:
makeEnvironment(window, { physics: loadRapier(), labels: troikaLabels() })adds the Rapier physics engine (effect-three-rapier; scenes wait for it to load) and the troika label engine (effect-three-troika). Without them, bodies stay put and labels are empty. - XR:
environment.xr.supported(mode),enter(mode)andexitare Effects for Commands;enter(from a click) presents the mounted scene that describesxr, in VR or AR. Controllers and hands hover, click and drag; sticks, buttons and AR surfaces arrive throughonXRStick,onXRButtonandonXRSurface(ADR-008). - Browser defaults: a context menu over an interactive mesh keeps the browser's menu shut; elsewhere it opens as usual. Wheel listening is passive, so the page still scrolls.
- Update telemetry:
onReconciledreports what each update did (created, updated, moved, removed, replaced, reordered) and the GPU resources held afterwards. It fires at startup and after updates that changed something, never for no-ops, so storing it in the Model can't loop. - Lifecycle:
StartedSceneonce the scene is running, orFailedScene { reason }(malformed or invalid scene, or no WebGL2).
What the Mount handles
| Concern | Behaviour |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Unmount | Releases the renderer, the WebGL context (forceContextLoss), GPU resources, pointer listeners, observers and the frame subscription. |
| Resize | ResizeObserver updates the camera aspect and drawing buffer; device pixel ratio is capped at 2. |
| Off-screen | IntersectionObserver pauses frames while the element isn't visible. |
| Reduced motion | prefers-reduced-motion: reduce freezes behaviours; frames still render. |
| DevTools time travel | While Foldkit reports the view as Paused, pointer input and scene events are dropped. |
| Validation | Scenes passed through view are type-checked at compile time and skip runtime validation. Any other value assigned to the element is Schema-decoded, and failures arrive as onError with MalformedScene. |
<effect-three-scene> is never registered with customElements, so there's nothing to re-register under HMR or guard during SSR. See ADR-002.
Options
makeEnvironment(window, options) takes:
renderer: a renderer factory per canvas:webglRendererFactory(the default) orwebgpuRendererFactory(three'sWebGPURenderer, with its WebGL 2 backend as fallback; for labels under it, taketroikaLabelsfrom@opsydyn/effect-three-troika/webgpu), or your own for test instrumentation (spread the factory you wrap, so its render kit comes along);pixelRatio: fixed, or[min, max]around the device's;behaviours: behaviours scenes may name withbehave(name), besidefloat;physics: a physics engine, or the promise of one (loadRapier());labels: a label engine (troikaLabels()).
Development
bun run test # unit tests
bun run test:e2e # Playwright in headless Chromium with SwiftShader
bun run check # strict lint
bun run build # dist JS and declarations
bun run api:check # public API reportThe browser tests prove the vertical slice end to end:
- typed click Messages;
- animation with zero
updatecalls; - in-place updates (pixel checks);
- full release on unmount, with listeners read through the Chrome DevTools Protocol;
- 20 mount/unmount cycles without WebGL context warnings;
- golden frames on a paused fake clock (
UPDATE_GOLDENS=1rewrites them); - three's renderer memory returning to baseline;
- the time-travel pause gate;
- contact shadows and the shader grid, by pixel;
- troika labels drawn once their font loads, and redrawn on change;
- WebXR sessions under Meta's IWER: presenting from the origin, controller hover, drag, sticks and buttons, tracked hands and pinches, and AR hit tests against a synthetic floor.
