effect-motion 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +2 -2
  2. package/dist/Camera.d.ts +186 -49
  3. package/dist/Camera.js +343 -76
  4. package/dist/Color.d.ts +101 -1
  5. package/dist/Color.js +101 -1
  6. package/dist/EffectMotionError.d.ts +17 -0
  7. package/dist/EffectMotionError.js +17 -0
  8. package/dist/Entity.d.ts +682 -38
  9. package/dist/Entity.js +271 -27
  10. package/dist/Font.d.ts +108 -0
  11. package/dist/Font.js +95 -0
  12. package/dist/Image.d.ts +71 -0
  13. package/dist/Image.js +50 -0
  14. package/dist/Instance.d.ts +73 -11
  15. package/dist/Instance.js +44 -11
  16. package/dist/Motion.d.ts +328 -53
  17. package/dist/Motion.js +278 -46
  18. package/dist/Physics.d.ts +154 -20
  19. package/dist/Physics.js +92 -11
  20. package/dist/Projection.d.ts +37 -132
  21. package/dist/Projection.js +33 -292
  22. package/dist/Resource.d.ts +26 -0
  23. package/dist/Resource.js +41 -0
  24. package/dist/Runner.d.ts +653 -330
  25. package/dist/Runner.js +208 -158
  26. package/dist/Scene.d.ts +621 -171
  27. package/dist/Scene.js +620 -89
  28. package/dist/Timing.d.ts +170 -13
  29. package/dist/Timing.js +124 -6
  30. package/dist/Tree.d.ts +39 -0
  31. package/dist/Tree.js +127 -0
  32. package/dist/index.d.ts +54 -5
  33. package/dist/index.js +56 -5
  34. package/dist/particles/Particle.d.ts +2 -2
  35. package/dist/particles/ParticleField.d.ts +8 -6
  36. package/dist/particles/ParticleField.js +8 -9
  37. package/dist/particles/constructors.d.ts +5 -6
  38. package/dist/particles/constructors.js +8 -3
  39. package/dist/particles/legacy.d.ts +58 -0
  40. package/dist/particles/legacy.js +46 -0
  41. package/dist/particles/simulate.js +13 -5
  42. package/dist/particles/step.js +14 -10
  43. package/dist/types.d.ts +5 -0
  44. package/dist/types.js +1 -0
  45. package/package.json +2 -4
  46. package/dist/CameraHelpers.d.ts +0 -70
  47. package/dist/CameraHelpers.js +0 -239
  48. package/dist/CanvasExporter.d.ts +0 -12
  49. package/dist/CanvasExporter.js +0 -40
  50. package/dist/Fonts.d.ts +0 -41
  51. package/dist/Fonts.js +0 -27
  52. package/dist/Images.d.ts +0 -33
  53. package/dist/Images.js +0 -24
  54. package/dist/PngExporter.d.ts +0 -6
  55. package/dist/PngExporter.js +0 -85
  56. package/dist/Renderer.d.ts +0 -118
  57. package/dist/Renderer.js +0 -381
  58. package/dist/Shapes.d.ts +0 -11
  59. package/dist/Shapes.js +0 -11
  60. package/dist/demo.d.ts +0 -3
  61. package/dist/demo.js +0 -65
  62. package/dist/render/dof.d.ts +0 -27
  63. package/dist/render/dof.js +0 -37
  64. package/dist/render/paint.d.ts +0 -30
  65. package/dist/render/paint.js +0 -36
  66. package/dist/render/shapes.d.ts +0 -42
  67. package/dist/render/shapes.js +0 -310
  68. package/dist/shapes/Circle.d.ts +0 -32
  69. package/dist/shapes/Circle.js +0 -9
  70. package/dist/shapes/Ellipse.d.ts +0 -35
  71. package/dist/shapes/Ellipse.js +0 -10
  72. package/dist/shapes/Group.d.ts +0 -116
  73. package/dist/shapes/Group.js +0 -82
  74. package/dist/shapes/Hud.d.ts +0 -38
  75. package/dist/shapes/Hud.js +0 -35
  76. package/dist/shapes/Image.d.ts +0 -45
  77. package/dist/shapes/Image.js +0 -28
  78. package/dist/shapes/Line.d.ts +0 -47
  79. package/dist/shapes/Line.js +0 -35
  80. package/dist/shapes/Path.d.ts +0 -107
  81. package/dist/shapes/Path.js +0 -32
  82. package/dist/shapes/Rect.d.ts +0 -51
  83. package/dist/shapes/Rect.js +0 -22
  84. package/dist/shapes/Shape2D.d.ts +0 -49
  85. package/dist/shapes/Shape2D.js +0 -52
  86. package/dist/shapes/Shapes.d.ts +0 -11
  87. package/dist/shapes/Shapes.js +0 -11
  88. package/dist/shapes/Square.d.ts +0 -32
  89. package/dist/shapes/Square.js +0 -11
  90. package/dist/shapes/Text.d.ts +0 -51
  91. package/dist/shapes/Text.js +0 -24
package/dist/Runner.js CHANGED
@@ -1,30 +1,75 @@
1
1
  import { Layer } from "effect";
2
2
  import * as Context from "effect/Context";
3
3
  import * as Effect from "effect/Effect";
4
- import { Camera, identity } from "./Camera.js";
5
4
  import * as Color from "./Color.js";
5
+ import * as Entity from "./Entity.js";
6
6
  import * as Instance from "./Instance.js";
7
7
  import * as Phaser from "./Phaser.js";
8
8
  import * as Projection from "./Projection.js";
9
- import { Group } from "./shapes/Group.js";
10
- import { Text } from "./shapes/Text.js";
9
+ import { ROOT_ID, Tree } from "./Tree.js";
11
10
  export const TypeId = "~motion/SceneRunner";
12
- /** conventional id of the implicit root group every instance attaches to */
13
- export const ROOT_ID = "root";
14
- /** the fixed default: scenes are deterministic even with no seed set */
11
+ export { ROOT_ID };
12
+ /**
13
+ * The seed used when none is given.
14
+ *
15
+ * @remarks
16
+ * Fixed rather than random on purpose: a scene using randomness still
17
+ * produces identical frames on every run unless you deliberately vary the
18
+ * seed.
19
+ */
15
20
  export const defaultSeed = "effect-motion";
21
+ export const defaultComp = {
22
+ width: 1920,
23
+ height: 1080,
24
+ backgroundColor: Color.transparent,
25
+ };
16
26
  /**
17
27
  * The ambient mount parent for `instantiate` — provided per scene
18
28
  * evaluation (`Scene.play({ parent })`); `null` means the runner root.
19
29
  */
20
30
  export const CurrentParent = Context.Reference("motion/Runner/CurrentParent", { defaultValue: () => null });
31
+ const nodeNotFound = (id) => {
32
+ throw new Error(`Runner: node "${id}" not found`);
33
+ };
34
+ /**
35
+ * The entity → view bridge. `Projection.CameraView` is flat (x/y/z,
36
+ * rotX/rotY/rotZ) while the Camera entity nests position/rotation, so the
37
+ * conversion happens HERE, once, at the frame boundary — the renderer and
38
+ * the projection math keep the contract they already had.
39
+ */
40
+ const toCameraView = (camera) => ({
41
+ x: camera.position.x,
42
+ y: camera.position.y,
43
+ z: camera.position.z,
44
+ rotX: camera.rotation.x,
45
+ rotY: camera.rotation.y,
46
+ rotZ: camera.rotation.z,
47
+ focalLength: camera.focalLength,
48
+ focusDistance: camera.focusDistance,
49
+ aperture: camera.aperture,
50
+ ...(camera.poi === null
51
+ ? {}
52
+ : { poiX: camera.poi.x, poiY: camera.poi.y, poiZ: camera.poi.z }),
53
+ });
54
+ /**
55
+ * The resting camera for a comp of the given width: width-relative focal
56
+ * length (AE's 50mm equivalent), positioned so the z=0 plane projects at
57
+ * scale 1 and is in focus.
58
+ */
59
+ export const identityCameraView = (width) => toCameraView(identityCamera(width));
60
+ export const identityCamera = (width) => {
61
+ const focalLength = Projection.defaultFocalLength(width);
62
+ const z = Projection.defaultCameraZ(focalLength);
63
+ return Entity.Camera.make({
64
+ position: Entity.vec3({ x: 0, y: 0, z }),
65
+ focalLength,
66
+ focusDistance: z,
67
+ aperture: 0,
68
+ });
69
+ };
21
70
  export class Runner extends Context.Service()("Runner", {
22
- make: Effect.fnUntraced(function* (settings = {}) {
23
- const instances = {};
24
- // each instance's current parent group id (or null = detached / root).
25
- // Tracked so appendChild detaches from the old parent in O(1) rather
26
- // than scanning the tree. The root is not tracked (it has no parent).
27
- const parentOf = {};
71
+ make: Effect.fnUntraced(function* (settings = {}, comp = defaultComp) {
72
+ const tree = new Tree();
28
73
  const phaser = yield* Phaser.Phaser.make;
29
74
  // concurrent branches spawned by Scene.fork / Scene.play /
30
75
  // Scene.background. `forks` hold the scene's end hostage until their
@@ -40,93 +85,67 @@ export class Runner extends Context.Service()("Runner", {
40
85
  // first NON-finished branch failure; failures in a tail (after
41
86
  // Scene.finish) are deliberately not reported
42
87
  let failure;
43
- let idCounter = 0;
44
- const generateId = (name) => {
45
- return `${name}_${idCounter++}`;
46
- };
47
- const setDataUnsafe = (instance, data) => {
48
- // preserve $visible across data updates; new instances default visible
49
- // ($visible is set explicitly by instantiate when overridden)
50
- const prev = instances[instance.id];
51
- instances[instance.id] = {
52
- data: instance.entity.data.make(data),
53
- entity: instance.entity,
54
- $visible: prev?.$visible ?? true,
55
- };
56
- };
57
- const setVisibleUnsafe = (id, visible) => {
58
- const entry = instances[id];
59
- if (entry !== undefined) {
60
- instances[id] = { ...entry, $visible: visible };
61
- }
88
+ /**
89
+ * Mounted scenes, by the id of the group they mount under.
90
+ *
91
+ * A comp is a render-to-texture boundary — its own scene, render
92
+ * target, and identity camera — created ONLY by `Scene.play`. It used
93
+ * to be inferred from a Group carrying width/height, which duplicated
94
+ * what the child Scene already owned and made "is this a comp" a
95
+ * question about field presence. It is now declared here explicitly.
96
+ */
97
+ const comps = new Map();
98
+ const setDataUnsafe = (instance, state) => {
99
+ const entry = tree.getEntry(instance.id) ?? nodeNotFound(instance.id);
100
+ entry.state = state;
62
101
  };
63
102
  const getDataUnsafe = (instance) => {
64
- return instances[instance.id]?.data ?? null;
103
+ const entry = tree.getEntry(instance.id);
104
+ if (entry === null) {
105
+ return null;
106
+ }
107
+ // the instance's tag names the entry's state; the tree stores mixed
108
+ // tags, so this is the one place the two are reconciled
109
+ return entry.state;
65
110
  };
66
111
  const resolvedSettings = {
67
112
  ...settings,
68
113
  frameRate: settings.frameRate ?? 60,
69
- width: settings.width ?? 500,
70
- height: settings.height ?? 300,
71
- backgroundColor: settings.backgroundColor ?? Color.rgba(22, 22, 29),
72
114
  seed: settings.seed ?? defaultSeed,
73
115
  maxFrames: settings.maxFrames ?? 36_000,
74
116
  };
75
- // the root group: never rendered itself, holds the top level
76
- const root = Instance.make(Group, ROOT_ID);
77
- setDataUnsafe(root, {});
78
- // the active camera: an ordinary instance (so the animators drive it),
79
- // never registered with a sink so it never draws. A default identity
80
- // camera is present from the start, so `depth`/zoom work with no author
81
- // ceremony; `setCamera` swaps which instance is active.
82
- const camera = Instance.make(Camera, "camera");
83
- setDataUnsafe(camera, identity(resolvedSettings.width));
117
+ // the root group: never rendered itself, holds the top level. Its
118
+ // entry is created by the tree itself.
119
+ const root = Instance.makeInstance(ROOT_ID, "Group");
120
+ // the active camera: an ordinary tree node (so the animators drive it),
121
+ // never rendered. A default resting camera is present from the start,
122
+ // so depth/zoom work with no author ceremony; `setCamera` swaps which
123
+ // instance is active.
124
+ tree.createNode(identityCamera(comp.width), "camera");
125
+ const camera = Instance.makeInstance("camera", "Camera");
84
126
  let activeCameraId = camera.id;
85
127
  const cameraState = () => {
86
- const data = instances[activeCameraId]?.data;
87
- // a destroyed active camera falls back to identity rather than dying:
88
- // the view is not scene-critical state
89
- return data ?? identity(resolvedSettings.width);
90
- };
91
- // append `id` to a group's children and record it as the child's parent
92
- const attach = (parent, id) => {
93
- const data = getDataUnsafe(parent);
94
- if (data === null) {
95
- throw new Error(`Runner: parent group "${parent.id}" was destroyed`);
96
- }
97
- setDataUnsafe(parent, { ...data, children: [...data.children, id] });
98
- parentOf[id] = parent.id;
99
- };
100
- // remove `id` from its current parent's children (O(1) via parentOf),
101
- // leaving it detached. No-op if already detached or parent is gone.
102
- const detach = (id) => {
103
- const parentId = parentOf[id];
104
- if (parentId == null) {
105
- return;
106
- }
107
- const parentEntry = instances[parentId];
108
- const children = parentEntry?.data
109
- ?.children;
110
- if (parentEntry !== undefined && Array.isArray(children)) {
111
- setDataUnsafe({ id: parentId, entity: parentEntry.entity }, {
112
- ...parentEntry.data,
113
- children: children.filter((c) => c !== id),
114
- });
128
+ const entry = tree.getEntry(activeCameraId);
129
+ // a destroyed (or swapped-to-non-camera) active camera falls back to
130
+ // the resting view rather than dying: the view is not scene-critical
131
+ if (entry === null || entry.state._tag !== "Camera") {
132
+ return toCameraView(identityCamera(comp.width));
115
133
  }
116
- parentOf[id] = null;
134
+ return toCameraView(entry.state);
117
135
  };
118
- // move `child` under `parent`: detach from its current parent first
119
- // (so it is never double-referenced), then attach.
136
+ // move `child` under `parent`: the tree detaches it from its current
137
+ // parent first, so it is never double-referenced.
120
138
  const appendChild = (parent, child) => {
121
- if (instances[child.id] === undefined) {
139
+ const childEntry = tree.getEntry(child.id);
140
+ if (childEntry === null) {
122
141
  throw new Error(`Runner: child "${child.id}" was destroyed`);
123
142
  }
124
- detach(child.id);
125
- attach(parent, child.id);
143
+ tree.appendChild(parent.id, childEntry);
126
144
  };
127
145
  const removeChild = (parent, child) => {
128
- if (parentOf[child.id] === parent.id) {
129
- detach(child.id);
146
+ const entry = tree.getEntry(child.id);
147
+ if (entry !== null && entry.parentId === parent.id) {
148
+ tree.removeFromParent(entry);
130
149
  }
131
150
  };
132
151
  // normalize a polymorphic children list into stored child ids, in
@@ -136,8 +155,8 @@ export class Runner extends Context.Service()("Runner", {
136
155
  const ids = [];
137
156
  for (const child of children) {
138
157
  if (typeof child === "string") {
139
- const child$ = yield* self.instantiate(Text, { text: child });
140
- ids.push(child$.id);
158
+ const text = yield* self.instantiate("Text", { text: child });
159
+ ids.push(text.id);
141
160
  }
142
161
  else if (Instance.isInstance(child)) {
143
162
  ids.push(child.id);
@@ -151,61 +170,39 @@ export class Runner extends Context.Service()("Runner", {
151
170
  });
152
171
  const self = {
153
172
  root,
154
- instantiate: Effect.fnUntraced(function* (entity, props) {
155
- // peel off builtin ($visible) and polymorphic children before the
156
- // schema constructs the data — neither is an entity-data field
157
- // = props
158
- // children are born (via normalizeChildren) attached to their
159
- // ambient parent — reparent them into THIS instance below
160
- const childIds = "children" in props && props.children
161
- ? yield* normalizeChildren(props.children)
162
- : undefined;
163
- const id = generateId(entity.name);
164
- const instance = Instance.make(entity, id);
173
+ instantiate: Effect.fnUntraced(function* (kind, props) {
174
+ const raw = props;
175
+ const children = Array.isArray(raw.children)
176
+ ? raw.children
177
+ : [];
178
+ const childIds = children.length > 0 ? yield* normalizeChildren(children) : undefined;
165
179
  // cameras get width-relative z/focalLength defaults filled here
166
- // (AE's 50mm equivalent — see Camera.ts): the schema can't default
167
- // them because only the Runner knows the scene width. Filling for
168
- // EVERY Camera instance (not just the built-in one) keeps a
169
- // setCamera swap from jumping zoom.
170
- const cameraDefaults = (() => {
171
- if (entity.name !== Camera.name) {
172
- return undefined;
173
- }
174
- const p = props;
175
- const focalLength = p.focalLength ??
176
- Projection.defaultFocalLength(resolvedSettings.width);
177
- return {
178
- focalLength,
179
- z: p.z ?? focalLength,
180
- // depth of field: focus at the resting distance by default,
181
- // so the z=0 plane is sharp for an untouched camera
182
- focusDistance: p.focusDistance ?? focalLength,
183
- };
184
- })();
185
- setDataUnsafe(instance, {
186
- ...props,
187
- ...cameraDefaults,
188
- children: childIds,
180
+ // (AE's 50mm equivalent): the schema cannot default them because
181
+ // only the Runner knows the comp width. Filled for EVERY Camera,
182
+ // not just the built-in one, so a setCamera swap never jumps zoom.
183
+ const defaults = kind === "Camera" ? cameraDefaults(raw, comp.width) : undefined;
184
+ // raw children never reach stored data: normalized ids are
185
+ // appended through the tree below, in list order
186
+ const { children: _children, ...rest } = raw;
187
+ const definition = Entity.getEntityDefinitionByTag(kind);
188
+ const state = definition.make({
189
+ ...rest,
190
+ ...defaults,
189
191
  });
190
- if (props.$visible === false) {
191
- setVisibleUnsafe(id, false);
192
- }
193
- // cameras are view state, not tree nodes: they live in `instances`
194
- // so the animators drive them, but must NOT be mounted into the
195
- // render tree (no sink renders a Camera — the renderer would die on
196
- // the unknown entity). Everything else mounts under the ambient
197
- // parent (Scene.play), defaulting to root.
198
- if (entity.name !== Camera.name) {
192
+ const entry = tree.createNode(state);
193
+ const instance = Instance.makeInstance(entry.id, kind);
194
+ // cameras are view state, not scene content: they live in the tree
195
+ // so the animators drive them, but must NOT be mounted under a
196
+ // group. Everything else mounts under the ambient parent.
197
+ if (kind !== "Camera") {
199
198
  const ambient = yield* CurrentParent;
200
- attach(ambient ?? root, id);
199
+ tree.appendChild((ambient ?? root).id, entry);
201
200
  }
202
201
  // adopt listed children: they were attached to the ambient parent
203
- // at birth; detach them there and record this instance as parent
204
- // (their ids already live in this instance's `children` data)
202
+ // at birth; appendChild moves each under this instance in order
205
203
  if (childIds !== undefined) {
206
204
  for (const childId of childIds) {
207
- detach(childId);
208
- parentOf[childId] = id;
205
+ tree.appendChild(entry, childId);
209
206
  }
210
207
  }
211
208
  return instance;
@@ -218,24 +215,53 @@ export class Runner extends Context.Service()("Runner", {
218
215
  // leaving it detached from the tree (still alive, just unmounted)
219
216
  removeChild,
220
217
  settings: resolvedSettings,
218
+ // the root scene's composition config (resolution + background)
219
+ comp,
221
220
  getDataUnsafe,
222
221
  setDataUnsafe,
223
222
  state: Effect.sync(() => {
224
- // the active camera lives in `instances` so the animators drive it,
225
- // but it is view state, not a renderable instance — omit it from the
226
- // frame's instance map (its data is surfaced separately as `camera`)
227
- const { [activeCameraId]: _camera, ...renderable } = instances;
223
+ // the active camera lives in the tree so the animators drive it,
224
+ // but it is view state, not a renderable instance — omit it from
225
+ // the frame's instance map (its data is surfaced as `camera`)
226
+ const instances = {};
227
+ for (const [id, entry] of Object.entries(tree.map)) {
228
+ if (id === activeCameraId) {
229
+ continue;
230
+ }
231
+ instances[id] = { data: entry.state };
232
+ }
228
233
  return {
229
- instances: renderable,
234
+ instances,
230
235
  root: ROOT_ID,
231
236
  frameRate: resolvedSettings.frameRate,
232
- width: resolvedSettings.width,
233
- height: resolvedSettings.height,
234
- backgroundColor: resolvedSettings.backgroundColor,
237
+ width: comp.width,
238
+ height: comp.height,
239
+ backgroundColor: comp.backgroundColor,
235
240
  camera: cameraState(),
241
+ // mounted scenes, by mount-group id: the renderer reads this
242
+ // to know a subtree is a render-to-texture boundary, instead
243
+ // of inferring it from a group carrying a size
244
+ comps: Object.fromEntries(comps),
236
245
  };
237
246
  }),
238
- // the default identity camera (animate it, or swap via setCamera)
247
+ /**
248
+ * ponytail: create a tree node for an entity that is NOT in the
249
+ * union — the particle system only (design D10). Mounts under the
250
+ * ambient parent like any instance. Delete with the particles
251
+ * rewrite; see `particlesEscapeInstantiate`.
252
+ */
253
+ instantiateEscape: Effect.fnUntraced(function* (state) {
254
+ const entry = tree.createNode(state);
255
+ const ambient = yield* CurrentParent;
256
+ tree.appendChild((ambient ?? root).id, entry);
257
+ return Instance.makeInstance(entry.id, state._tag);
258
+ }),
259
+ // declare the group at `id` to be a mounted scene with these bounds
260
+ // (see `comps`); called by Scene.play, never by authors
261
+ registerComp: (id, config) => {
262
+ comps.set(id, config);
263
+ },
264
+ // the default resting camera (animate it, or swap via setCamera)
239
265
  camera,
240
266
  // swap the active camera to another instance; its live data becomes
241
267
  // the view on every subsequent frame
@@ -243,25 +269,11 @@ export class Runner extends Context.Service()("Runner", {
243
269
  activeCameraId = instance.id;
244
270
  },
245
271
  destroy: (instance) => {
246
- // O(1) detach from the tracked parent, then drop the instance
247
- detach(instance.id);
248
- delete instances[instance.id];
249
- delete parentOf[instance.id];
250
- // backstop scan: stays correct even after manual reparenting via
251
- // raw data updates (which bypass parentOf tracking)
252
- for (const [id, entry] of Object.entries(instances)) {
253
- const children = entry.data.children;
254
- if (Array.isArray(children) && children.includes(instance.id)) {
255
- instances[id] = {
256
- entity: entry.entity,
257
- $visible: entry.$visible,
258
- data: entry.entity.data.make({
259
- ...entry.data,
260
- children: children.filter((child) => child !== instance.id),
261
- }),
262
- };
263
- }
272
+ // double-destroy is a no-op, like the old map-based delete
273
+ if (tree.getEntry(instance.id) === null) {
274
+ return;
264
275
  }
276
+ tree.remove(instance.id);
265
277
  },
266
278
  phaser,
267
279
  forks,
@@ -279,4 +291,42 @@ export class Runner extends Context.Service()("Runner", {
279
291
  }),
280
292
  }) {
281
293
  }
294
+ /**
295
+ * Width-relative camera defaults the schema cannot express, since only the
296
+ * Runner knows the comp width. Applied to any Camera at instantiate.
297
+ */
298
+ const cameraDefaults = (props, width) => {
299
+ const position = props.position;
300
+ const focalLength = typeof props.focalLength === "number" && props.focalLength !== 0
301
+ ? props.focalLength
302
+ : Projection.defaultFocalLength(width);
303
+ const restingZ = Projection.defaultCameraZ(focalLength);
304
+ return {
305
+ focalLength,
306
+ position: Entity.vec3({
307
+ x: position?.x ?? 0,
308
+ y: position?.y ?? 0,
309
+ z: position?.z ?? restingZ,
310
+ }),
311
+ // depth of field: focus at the resting distance by default, so the
312
+ // z=0 plane is sharp for an untouched camera
313
+ focusDistance: typeof props.focusDistance === "number" && props.focusDistance !== 0
314
+ ? props.focusDistance
315
+ : restingZ,
316
+ };
317
+ };
282
318
  export const layer = Layer.effect(Runner, Runner.make());
319
+ /** the tag particle fields are stored under; never a union member */
320
+ export const PARTICLE_FIELD_TAG = "particles/ParticleField";
321
+ /** instantiate a non-union entity. Particles only. */
322
+ export const particlesEscapeInstantiate = (runner, state) =>
323
+ // bypasses the tag→EntityMap lookup, which by construction has no
324
+ // ParticleField. The state is stored verbatim; only particles read it.
325
+ runner.instantiateEscape({
326
+ ...state,
327
+ _tag: PARTICLE_FIELD_TAG,
328
+ });
329
+ /** read a non-union entity's state. Particles only. */
330
+ export const particlesEscapeRead = (runner, instance) => runner.getDataUnsafe(instance);
331
+ /** write a non-union entity's state. Particles only. */
332
+ export const particlesEscapeWrite = (runner, instance, state) => runner.setDataUnsafe(instance, state);