@forgeax/engine-scene 0.1.20 → 0.1.23

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 (80) hide show
  1. package/README.md +37 -9
  2. package/dist/__tests__/flat-propagation.contract.test.d.ts +2 -0
  3. package/dist/__tests__/flat-propagation.contract.test.d.ts.map +1 -0
  4. package/dist/__tests__/flat-propagation.derived-writer.contract.test.d.ts +2 -0
  5. package/dist/__tests__/flat-propagation.derived-writer.contract.test.d.ts.map +1 -0
  6. package/dist/__tests__/flat-propagation.perf.test.d.ts +2 -0
  7. package/dist/__tests__/flat-propagation.perf.test.d.ts.map +1 -0
  8. package/dist/__tests__/scene-binding.integration.test.d.ts +2 -0
  9. package/dist/__tests__/scene-binding.integration.test.d.ts.map +1 -0
  10. package/dist/__tests__/schedule-order.contract.test.d.ts +2 -0
  11. package/dist/__tests__/schedule-order.contract.test.d.ts.map +1 -0
  12. package/dist/__tests__/structural.test.d.ts +2 -0
  13. package/dist/__tests__/structural.test.d.ts.map +1 -0
  14. package/dist/__tests__/transform-carrier-consumers.test-d.d.ts +2 -0
  15. package/dist/__tests__/transform-carrier-consumers.test-d.d.ts.map +1 -0
  16. package/dist/__tests__/transform-carrier-prototype.bench.d.ts +39 -0
  17. package/dist/__tests__/transform-carrier-prototype.bench.d.ts.map +1 -0
  18. package/dist/__tests__/transform-carrier-prototype.test-d.d.ts +2 -0
  19. package/dist/__tests__/transform-carrier-prototype.test-d.d.ts.map +1 -0
  20. package/dist/__tests__/transform-carrier-prototype.test.d.ts +2 -0
  21. package/dist/__tests__/transform-carrier-prototype.test.d.ts.map +1 -0
  22. package/dist/__tests__/transform-component-version.unit.test.d.ts +2 -0
  23. package/dist/__tests__/transform-component-version.unit.test.d.ts.map +1 -0
  24. package/dist/assets/scene-decoder.d.ts.map +1 -1
  25. package/dist/components/child-of.d.ts +15 -8
  26. package/dist/components/child-of.d.ts.map +1 -1
  27. package/dist/components/children.d.ts.map +1 -1
  28. package/dist/components/transform.d.ts +7 -43
  29. package/dist/components/transform.d.ts.map +1 -1
  30. package/dist/errors.d.ts +23 -2
  31. package/dist/errors.d.ts.map +1 -1
  32. package/dist/index.d.ts +3 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.mjs +616 -372
  35. package/dist/index.mjs.map +1 -1
  36. package/dist/instances/binding.d.ts +29 -0
  37. package/dist/instances/binding.d.ts.map +1 -0
  38. package/dist/instances/externalization.d.ts.map +1 -1
  39. package/dist/instances/scene-instances.d.ts +5 -17
  40. package/dist/instances/scene-instances.d.ts.map +1 -1
  41. package/dist/instances/state.d.ts +28 -0
  42. package/dist/instances/state.d.ts.map +1 -0
  43. package/dist/plugin.d.ts.map +1 -1
  44. package/dist/systems/hierarchy-projection.d.ts.map +1 -1
  45. package/dist/systems/index.d.ts +1 -1
  46. package/dist/systems/index.d.ts.map +1 -1
  47. package/dist/systems/propagate-transforms.d.ts +1 -2
  48. package/dist/systems/propagate-transforms.d.ts.map +1 -1
  49. package/package.json +5 -5
  50. package/src/__tests__/components.unit.test.ts +34 -2
  51. package/src/__tests__/flat-propagation.contract.test.ts +128 -0
  52. package/src/__tests__/flat-propagation.derived-writer.contract.test.ts +39 -0
  53. package/src/__tests__/flat-propagation.perf.test.ts +336 -0
  54. package/src/__tests__/plugin.integration.test.ts +2 -2
  55. package/src/__tests__/propagation.unit.test.ts +207 -91
  56. package/src/__tests__/scene-binding.integration.test.ts +124 -0
  57. package/src/__tests__/schedule-order.contract.test.ts +43 -0
  58. package/src/__tests__/structural.test.ts +23 -0
  59. package/src/__tests__/transform-carrier-consumers.test-d.ts +156 -0
  60. package/src/__tests__/transform-carrier-prototype.bench.ts +168 -0
  61. package/src/__tests__/transform-carrier-prototype.test-d.ts +71 -0
  62. package/src/__tests__/transform-carrier-prototype.test.ts +170 -0
  63. package/src/__tests__/{transform-change-journal.unit.test.ts → transform-component-version.unit.test.ts} +32 -39
  64. package/src/assets/scene-decoder.ts +6 -1
  65. package/src/components/child-of.ts +15 -8
  66. package/src/components/children.ts +6 -0
  67. package/src/components/transform.ts +23 -81
  68. package/src/errors.ts +26 -2
  69. package/src/index.ts +10 -1
  70. package/src/instances/binding.ts +77 -0
  71. package/src/instances/externalization.ts +7 -1
  72. package/src/instances/scene-instances.ts +47 -76
  73. package/src/instances/state.ts +62 -0
  74. package/src/plugin.ts +9 -2
  75. package/src/systems/hierarchy-projection.ts +25 -8
  76. package/src/systems/index.ts +3 -0
  77. package/src/systems/propagate-transforms.ts +376 -245
  78. package/dist/.tsbuildinfo +0 -1
  79. package/dist/__tests__/transform-change-journal.unit.test.d.ts +0 -2
  80. package/dist/__tests__/transform-change-journal.unit.test.d.ts.map +0 -1
@@ -1,313 +1,443 @@
1
1
  import {
2
2
  defineSystem,
3
3
  defineSystemSet,
4
+ type EcsError,
4
5
  type EntityHandle,
5
6
  FixedUpdate,
7
+ type Query,
6
8
  type SystemHandle,
7
9
  Update,
8
10
  type World,
9
11
  } from '@forgeax/engine-ecs';
10
- import {
11
- createWorldProjection,
12
- setDerivedComponent,
13
- type WorldProjection,
14
- } from '@forgeax/engine-ecs/projection';
15
12
  import { type Mat4, mat4 } from '@forgeax/engine-math';
16
13
  import { err, ok, type Result } from '@forgeax/engine-types';
17
14
  import { ChildOf } from '../components/child-of';
18
- import { Transform } from '../components/transform';
15
+ import { GlobalTransform, Transform } from '../components/transform';
19
16
  import { SceneError } from '../errors';
20
- import { projectHierarchy, type SceneHierarchySnapshot } from './hierarchy-projection';
21
17
 
22
18
  export const PROPAGATE_TRANSFORMS_SYSTEM = 'propagateTransforms' as const;
23
19
  export const PROPAGATE_TRANSFORMS_FIXED_SYSTEM = 'propagateTransformsFixed' as const;
24
20
  export const TransformSet = defineSystemSet({ name: 'transform' });
25
21
  export const TransformFixedSet = defineSystemSet({ name: 'transform-fixed' });
26
22
 
27
- interface LocalState {
28
- readonly pos: Float32Array;
29
- readonly quat: Float32Array;
30
- readonly scale: Float32Array;
31
- }
32
-
33
- interface PropagationCache {
34
- readonly projection: WorldProjection;
35
- readonly hierarchy: SceneHierarchySnapshot;
36
- readonly parentOf: ReadonlyMap<EntityHandle, EntityHandle>;
37
- readonly childrenOf: ReadonlyMap<EntityHandle, readonly EntityHandle[]>;
38
- readonly entities: ReadonlySet<EntityHandle>;
39
- readonly locals: Map<EntityHandle, LocalState>;
40
- readonly derived: Map<EntityHandle, Float32Array>;
41
- result: Result<void, SceneError>;
23
+ interface Scratch {
24
+ position: Float32Array;
25
+ rotation: Float32Array;
26
+ scale: Float32Array;
27
+ local: Mat4;
28
+ hierarchyPath: number[];
29
+ flatQuery?: FlatQuery;
30
+ outputQuery?: OutputQuery;
31
+ hierarchyQuery?: HierarchyQuery;
32
+ missingGlobalQuery?: MissingGlobalQuery;
33
+ missingTransformQuery?: MissingTransformQuery;
42
34
  }
43
35
 
44
- const CACHE = new WeakMap<World, PropagationCache>();
36
+ type OutputQuery = Query<readonly [], readonly [typeof GlobalTransform]>;
37
+ type FlatQuery = Query<readonly [typeof Transform], readonly [typeof GlobalTransform]>;
38
+ type HierarchyQuery = Query<readonly [typeof Transform, typeof ChildOf]>;
39
+ type MissingGlobalQuery = Query<readonly [], readonly [], readonly []>;
40
+ type MissingTransformQuery = Query<readonly [], readonly [], readonly []>;
45
41
 
46
- interface TransformRegistrationLease {
42
+ interface RegistrationLease {
47
43
  refs: number;
48
44
  }
49
45
 
50
- // A World can be consumed by more than one renderer/view. Keep the schedule
51
- // registration leased per World so one owner disposing cannot remove the
52
- // shared transform system from another owner. This is lifecycle bookkeeping,
53
- // not a second component/system authority; the World schedule remains the
54
- // source of truth.
55
- const REGISTRATION_LEASES = new WeakMap<World, TransformRegistrationLease>();
56
-
57
- function copyState(value: {
58
- readonly pos: ArrayLike<number>;
59
- readonly quat: ArrayLike<number>;
60
- readonly scale: ArrayLike<number>;
61
- }): LocalState {
62
- return {
63
- pos: new Float32Array(value.pos),
64
- quat: new Float32Array(value.quat),
65
- scale: new Float32Array(value.scale),
66
- };
67
- }
68
-
69
- function sameArray(left: ArrayLike<number>, right: ArrayLike<number>): boolean {
70
- if (left.length !== right.length) return false;
71
- for (let index = 0; index < left.length; index += 1) {
72
- if (left[index] !== right[index]) return false;
73
- }
74
- return true;
75
- }
46
+ const SCRATCH = new WeakMap<World, Scratch>();
47
+ const REGISTRATION_LEASES = new WeakMap<World, RegistrationLease>();
76
48
 
77
- function sameState(left: LocalState | undefined, right: LocalState): boolean {
78
- return (
79
- left !== undefined &&
80
- sameArray(left.pos, right.pos) &&
81
- sameArray(left.quat, right.quat) &&
82
- sameArray(left.scale, right.scale)
49
+ function pairError(entity: EntityHandle, expected: string): Result<void, SceneError> {
50
+ return err(
51
+ new SceneError({
52
+ code: 'hierarchy-broken',
53
+ expected,
54
+ hint: 'attach both Transform and GlobalTransform at scene authoring or import time, then retry propagation',
55
+ detail: { entity, parent: entity },
56
+ }),
83
57
  );
84
58
  }
85
59
 
86
- function compose(state: LocalState, out: Mat4): void {
87
- mat4.compose(out, state.pos, state.quat, state.scale);
60
+ function ensureQueries(world: World, scratch: Scratch): Result<void, SceneError> {
61
+ if (
62
+ scratch.flatQuery !== undefined &&
63
+ scratch.outputQuery !== undefined &&
64
+ scratch.hierarchyQuery !== undefined &&
65
+ scratch.missingGlobalQuery !== undefined &&
66
+ scratch.missingTransformQuery !== undefined
67
+ ) {
68
+ return ok(undefined);
69
+ }
70
+ const flatOutput = world.query({
71
+ read: [Transform],
72
+ write: [GlobalTransform],
73
+ without: [ChildOf],
74
+ changed: [Transform],
75
+ });
76
+ const output = world.query({ write: [GlobalTransform] });
77
+ const hierarchy = world.query({ read: [Transform, ChildOf] });
78
+ const missingGlobal = world.query({ with: [Transform], without: [GlobalTransform] });
79
+ const missingTransform = world.query({ with: [GlobalTransform], without: [Transform] });
80
+ if (!flatOutput.ok || !output.ok || !hierarchy.ok || !missingGlobal.ok || !missingTransform.ok) {
81
+ return pairError(0 as EntityHandle, 'valid Transform and GlobalTransform pair queries');
82
+ }
83
+ scratch.flatQuery = flatOutput.value as FlatQuery;
84
+ scratch.outputQuery = output.value as OutputQuery;
85
+ scratch.hierarchyQuery = hierarchy.value as HierarchyQuery;
86
+ scratch.missingGlobalQuery = missingGlobal.value as MissingGlobalQuery;
87
+ scratch.missingTransformQuery = missingTransform.value as MissingTransformQuery;
88
+ return ok(undefined);
88
89
  }
89
90
 
90
- function indexChildren(
91
- hierarchy: SceneHierarchySnapshot,
92
- ): ReadonlyMap<EntityHandle, readonly EntityHandle[]> {
93
- const childrenOf = new Map<EntityHandle, EntityHandle[]>();
94
- for (const [child, parent] of hierarchy.parentOf) {
95
- const children = childrenOf.get(parent);
96
- if (children === undefined) childrenOf.set(parent, [child]);
97
- else children.push(child);
91
+ function validateTransformPairs(world: World, scratch: Scratch): Result<void, SceneError> {
92
+ const queryResult = ensureQueries(world, scratch);
93
+ if (!queryResult.ok) return queryResult;
94
+ const missingGlobal = scratch.missingGlobalQuery;
95
+ const missingTransform = scratch.missingTransformQuery;
96
+ if (missingGlobal === undefined || missingTransform === undefined) {
97
+ return pairError(0 as EntityHandle, 'valid Transform and GlobalTransform pair queries');
98
+ }
99
+ for (const row of missingGlobal) {
100
+ return pairError(row.entity, 'each Transform entity to carry a GlobalTransform pair');
98
101
  }
99
- return childrenOf;
102
+ for (const row of missingTransform) {
103
+ return pairError(row.entity, 'each GlobalTransform entity to carry a Transform pair');
104
+ }
105
+ return ok(undefined);
100
106
  }
101
107
 
102
- function descendants(
103
- childrenOf: ReadonlyMap<EntityHandle, readonly EntityHandle[]>,
104
- roots: ReadonlySet<EntityHandle>,
105
- ): Set<EntityHandle> {
106
- const affected = new Set(roots);
107
- const pending = [...roots];
108
- for (let index = 0; index < pending.length; index += 1) {
109
- const parent = pending[index];
110
- if (parent === undefined) continue;
111
- for (const child of childrenOf.get(parent) ?? []) {
112
- if (affected.has(child)) continue;
113
- affected.add(child);
114
- pending.push(child);
115
- }
116
- }
117
- return affected;
108
+ function scratchFor(world: World): Scratch {
109
+ const existing = SCRATCH.get(world);
110
+ if (existing !== undefined) return existing;
111
+ const created = {
112
+ position: new Float32Array(3),
113
+ rotation: new Float32Array(4),
114
+ scale: new Float32Array(3),
115
+ local: mat4.create(),
116
+ hierarchyPath: [] as number[],
117
+ };
118
+ SCRATCH.set(world, created);
119
+ return created;
118
120
  }
119
121
 
120
- function hierarchyError(hierarchy: SceneHierarchySnapshot): Result<void, SceneError> {
121
- const first = hierarchy.diagnostics[0];
122
- if (first === undefined) return ok(undefined);
123
- return err(
124
- new SceneError({
125
- code: first.code,
126
- expected: first.expected,
127
- hint: first.hint,
128
- detail: first.detail,
129
- }),
130
- );
122
+ function composeColumns(
123
+ position: ArrayLike<number>,
124
+ rotation: ArrayLike<number>,
125
+ scale: ArrayLike<number>,
126
+ out: Mat4,
127
+ scratch: Scratch,
128
+ positionStart = 0,
129
+ rotationStart = 0,
130
+ ): void {
131
+ scratch.position[0] = position[positionStart] ?? 0;
132
+ scratch.position[1] = position[positionStart + 1] ?? 0;
133
+ scratch.position[2] = position[positionStart + 2] ?? 0;
134
+ scratch.rotation[0] = rotation[rotationStart] ?? 0;
135
+ scratch.rotation[1] = rotation[rotationStart + 1] ?? 0;
136
+ scratch.rotation[2] = rotation[rotationStart + 2] ?? 0;
137
+ scratch.rotation[3] = rotation[rotationStart + 3] ?? 1;
138
+ scratch.scale[0] = scale[positionStart] ?? 1;
139
+ scratch.scale[1] = scale[positionStart + 1] ?? 1;
140
+ scratch.scale[2] = scale[positionStart + 2] ?? 1;
141
+ mat4.compose(out, scratch.position, scratch.rotation, scratch.scale);
131
142
  }
132
143
 
133
- function buildCache(world: World, projection: WorldProjection): PropagationCache {
134
- const hierarchy = projectHierarchy(world);
135
- const locals = new Map<EntityHandle, LocalState>();
136
- const entities = new Set<EntityHandle>();
137
- const query = world.query({ read: [Transform], optional: [ChildOf] });
138
- if (query.ok) {
139
- for (const row of query.value) {
140
- const transform = row.get(Transform);
141
- if (transform === undefined) continue;
142
- entities.add(row.entity);
143
- locals.set(row.entity, copyState(transform));
144
- }
144
+ function composeFlatColumns(
145
+ positions: ArrayLike<number>,
146
+ rotations: ArrayLike<number>,
147
+ scales: ArrayLike<number>,
148
+ worlds: Float32Array,
149
+ count: number,
150
+ ): void {
151
+ for (let row = 0; row < count; row += 1) {
152
+ const position = row * 3;
153
+ const rotation = row * 4;
154
+ const world = row * 16;
155
+ const x = rotations[rotation] ?? 0;
156
+ const y = rotations[rotation + 1] ?? 0;
157
+ const z = rotations[rotation + 2] ?? 0;
158
+ const w = rotations[rotation + 3] ?? 1;
159
+ const x2 = x + x;
160
+ const y2 = y + y;
161
+ const z2 = z + z;
162
+ const xx = x * x2;
163
+ const xy = x * y2;
164
+ const xz = x * z2;
165
+ const yy = y * y2;
166
+ const yz = y * z2;
167
+ const zz = z * z2;
168
+ const wx = w * x2;
169
+ const wy = w * y2;
170
+ const wz = w * z2;
171
+ const sx = scales[position] ?? 1;
172
+ const sy = scales[position + 1] ?? 1;
173
+ const sz = scales[position + 2] ?? 1;
174
+
175
+ worlds[world] = (1 - (yy + zz)) * sx;
176
+ worlds[world + 1] = (xy + wz) * sx;
177
+ worlds[world + 2] = (xz - wy) * sx;
178
+ worlds[world + 3] = 0;
179
+ worlds[world + 4] = (xy - wz) * sy;
180
+ worlds[world + 5] = (1 - (xx + zz)) * sy;
181
+ worlds[world + 6] = (yz + wx) * sy;
182
+ worlds[world + 7] = 0;
183
+ worlds[world + 8] = (xz + wy) * sz;
184
+ worlds[world + 9] = (yz - wx) * sz;
185
+ worlds[world + 10] = (1 - (xx + yy)) * sz;
186
+ worlds[world + 11] = 0;
187
+ worlds[world + 12] = positions[position] ?? 0;
188
+ worlds[world + 13] = positions[position + 1] ?? 0;
189
+ worlds[world + 14] = positions[position + 2] ?? 0;
190
+ worlds[world + 15] = 1;
145
191
  }
146
- return {
147
- projection,
148
- hierarchy,
149
- parentOf: hierarchy.parentOf,
150
- childrenOf: indexChildren(hierarchy),
151
- entities,
152
- locals,
153
- derived: new Map(),
154
- result: ok(undefined),
155
- };
156
192
  }
157
193
 
158
- function deriveEntity(
194
+ function writeWorld(
159
195
  world: World,
196
+ output: ReturnType<World['query']> extends Result<infer Q, unknown> ? Q : never,
160
197
  entity: EntityHandle,
161
- cache: PropagationCache,
162
- affected: ReadonlySet<EntityHandle>,
163
- visiting: Set<EntityHandle>,
164
- published: Set<EntityHandle>,
198
+ value: Mat4,
165
199
  ): Result<void, SceneError> {
166
- if (!affected.has(entity) || cache.derived.has(entity)) return ok(undefined);
167
- if (visiting.has(entity)) return ok(undefined);
168
- visiting.add(entity);
169
- const local = cache.locals.get(entity);
170
- if (local === undefined) {
171
- visiting.delete(entity);
172
- return ok(undefined);
173
- }
174
- const parent = cache.parentOf.get(entity);
175
- if (parent !== undefined && cache.entities.has(parent)) {
176
- const parentResult = deriveEntity(world, parent, cache, affected, visiting, published);
177
- if (!parentResult.ok) return parentResult;
200
+ const row = output.at(entity);
201
+ if (row === undefined) {
202
+ return err(
203
+ new SceneError({
204
+ code: 'hierarchy-broken',
205
+ expected: 'each Transform entity to carry a GlobalTransform pair',
206
+ hint: 'attach GlobalTransform at scene authoring or import time, then retry propagation',
207
+ detail: { entity, parent: entity },
208
+ }),
209
+ );
178
210
  }
179
- const localWorld = mat4.create();
180
- compose(local, localWorld);
181
- const parentWorld = parent === undefined ? undefined : cache.derived.get(parent);
182
- const resolved = mat4.create();
183
- if (parentWorld === undefined) resolved.set(localWorld);
184
- else mat4.multiply(resolved, parentWorld, localWorld);
185
- const write = setDerivedComponent(world, entity, Transform, { world: resolved });
186
- if (!write.ok) {
187
- visiting.delete(entity);
211
+ const current = world.get(entity, GlobalTransform);
212
+ if (!current.ok)
188
213
  return err(
189
214
  new SceneError({
190
215
  code: 'hierarchy-broken',
191
- expected: 'the derived Transform.world write to succeed',
192
- hint: 'inspect the ECS mutation error before retrying transform propagation',
216
+ expected: 'each Transform entity to carry a GlobalTransform pair',
217
+ hint: 'attach GlobalTransform at scene authoring or import time, then retry propagation',
193
218
  detail: { entity, parent: entity },
194
219
  }),
195
220
  );
221
+ let changed = false;
222
+ for (let index = 0; index < value.length; index += 1) {
223
+ if (current.value.world[index] !== value[index]) {
224
+ changed = true;
225
+ break;
226
+ }
196
227
  }
197
- cache.derived.set(entity, resolved);
198
- published.add(entity);
199
- visiting.delete(entity);
228
+ if (!changed) return ok(undefined);
229
+ row.mut(GlobalTransform).world.set(value);
200
230
  return ok(undefined);
201
231
  }
202
232
 
203
- function drainPublishedChanges(
204
- cache: PropagationCache,
205
- published: ReadonlySet<EntityHandle>,
206
- ): boolean {
207
- const drained = cache.projection.poll();
208
- if (drained.status === 'rebuild') return true;
209
- return drained.changes.every(
210
- (change) =>
211
- change.kind === 'derived-component-changed' &&
212
- change.component === Transform &&
213
- published.has(change.entity),
214
- );
233
+ function propagateFlat(scratch: Scratch): Result<void, SceneError> {
234
+ const query = scratch.flatQuery;
235
+ if (query === undefined)
236
+ return err(
237
+ new SceneError({
238
+ code: 'hierarchy-broken',
239
+ expected: 'a valid changed Transform write query',
240
+ hint: 'register the scene components before running TransformPropagation',
241
+ }),
242
+ );
243
+ const spans = query.spans();
244
+ if (!spans.ok) return pairError(0 as EntityHandle, 'dense numeric Transform spans');
245
+ let bindingIndex = 0;
246
+ try {
247
+ for (const span of spans.value) {
248
+ const local = span.get(Transform);
249
+ const world = span.mut(GlobalTransform).world;
250
+ composeFlatColumns(local.pos, local.quat, local.scale, world, span.length);
251
+ bindingIndex += 1;
252
+ }
253
+ } catch (cause) {
254
+ const error = cause as EcsError;
255
+ return err(
256
+ new SceneError({
257
+ code: 'hierarchy-broken',
258
+ expected: 'derived GlobalTransform range publication to succeed',
259
+ hint: error.hint,
260
+ detail: {
261
+ kind: 'derived-write',
262
+ entity: 0 as EntityHandle,
263
+ parent: 0 as EntityHandle,
264
+ bindingIndex,
265
+ base: 0,
266
+ start: 0,
267
+ count: 0,
268
+ cause: error,
269
+ },
270
+ }),
271
+ );
272
+ }
273
+ return ok(undefined);
215
274
  }
216
275
 
217
- export function propagateTransforms(
276
+ interface HierarchyEntry {
277
+ readonly entity: EntityHandle;
278
+ parent: EntityHandle | undefined;
279
+ readonly position: Float32Array;
280
+ readonly rotation: Float32Array;
281
+ readonly scale: Float32Array;
282
+ readonly world: Mat4;
283
+ state: 0 | 1 | 2;
284
+ }
285
+
286
+ function findEntry(entries: readonly HierarchyEntry[], entity: EntityHandle): number {
287
+ for (let index = 0; index < entries.length; index += 1) {
288
+ if (entries[index]?.entity === entity) return index;
289
+ }
290
+ return -1;
291
+ }
292
+
293
+ function resolveEntry(
294
+ index: number,
295
+ entries: HierarchyEntry[],
218
296
  world: World,
219
- _hierarchy?: SceneHierarchySnapshot,
297
+ output: ReturnType<World['query']> extends Result<infer Q, unknown> ? Q : never,
298
+ scratch: Scratch,
220
299
  ): Result<void, SceneError> {
221
- let cache = CACHE.get(world);
222
- if (cache === undefined) {
223
- cache = buildCache(world, createWorldProjection(world, { components: [Transform, ChildOf] }));
224
- CACHE.set(world, cache);
225
- const initial = new Set(cache.entities);
226
- const published = new Set<EntityHandle>();
227
- for (const entity of initial) {
228
- const result = deriveEntity(world, entity, cache, initial, new Set(), published);
229
- if (!result.ok) return result;
230
- }
231
- if (!drainPublishedChanges(cache, published)) {
232
- CACHE.delete(world);
233
- return propagateTransforms(world);
300
+ const entry = entries[index];
301
+ if (entry === undefined || entry.state === 2) return ok(undefined);
302
+ if (entry.state === 1) {
303
+ // A cycle is an input error, but leaving every member on its previous
304
+ // GlobalTransform would make the renderer consume stale derived facts.
305
+ // Cut the complete active cycle segment to roots for this failed frame;
306
+ // the authored ChildOf values remain untouched and the next repaired
307
+ // propagation recomputes the normal hierarchy.
308
+ const cycleParent = entry.parent ?? entry.entity;
309
+ const cycleStart = scratch.hierarchyPath.indexOf(index);
310
+ const start = cycleStart < 0 ? 0 : cycleStart;
311
+ for (let pathIndex = start; pathIndex < scratch.hierarchyPath.length; pathIndex += 1) {
312
+ const cycleEntry = entries[scratch.hierarchyPath[pathIndex] ?? -1];
313
+ if (cycleEntry !== undefined) cycleEntry.parent = undefined;
234
314
  }
235
- cache.result = hierarchyError(cache.hierarchy);
236
- return cache.result;
315
+ return err(
316
+ new SceneError({
317
+ code: 'hierarchy-cycle',
318
+ expected: 'a parent-before-child Transform hierarchy',
319
+ hint: 'repair the ChildOf cycle and retry TransformPropagation',
320
+ detail: { entity: entry.entity, parent: cycleParent },
321
+ }),
322
+ );
237
323
  }
238
-
239
- const evidence = cache.projection.poll();
240
- if (evidence.status === 'rebuild') {
241
- cache = buildCache(world, cache.projection);
242
- CACHE.set(world, cache);
243
- const all = new Set(cache.entities);
244
- const published = new Set<EntityHandle>();
245
- for (const entity of all) {
246
- const result = deriveEntity(world, entity, cache, all, new Set(), published);
247
- if (!result.ok) return result;
248
- }
249
- if (!drainPublishedChanges(cache, published)) {
250
- CACHE.delete(world);
251
- return propagateTransforms(world);
324
+ entry.state = 1;
325
+ scratch.hierarchyPath.push(index);
326
+ let parentIndex = entry.parent === undefined ? -1 : findEntry(entries, entry.parent);
327
+ let failure: SceneError | undefined;
328
+ if (entry.parent !== undefined && parentIndex < 0) {
329
+ const local = world.get(entry.parent, Transform);
330
+ const global = world.get(entry.parent, GlobalTransform);
331
+ if (!local.ok || !global.ok) {
332
+ // The edge is invalid for this frame. Treat the child as a local root
333
+ // so the output column reflects current authored input instead of the
334
+ // previous frame's world matrix, while still returning the structured
335
+ // error to the caller.
336
+ failure = new SceneError({
337
+ code: 'hierarchy-broken',
338
+ expected: 'each ChildOf parent to carry Transform and GlobalTransform',
339
+ hint: 'repair the missing parent pair before retrying propagation',
340
+ detail: { entity: entry.entity, parent: entry.parent },
341
+ });
342
+ entry.parent = undefined;
343
+ parentIndex = -1;
344
+ } else {
345
+ const relation = world.get(entry.parent, ChildOf);
346
+ entries.push({
347
+ entity: entry.parent,
348
+ parent: relation.ok && relation.value.parent !== null ? relation.value.parent : undefined,
349
+ position: new Float32Array(local.value.pos),
350
+ rotation: new Float32Array(local.value.quat),
351
+ scale: new Float32Array(local.value.scale),
352
+ world: mat4.create(),
353
+ state: 0,
354
+ });
355
+ parentIndex = entries.length - 1;
252
356
  }
253
- cache.result = hierarchyError(cache.hierarchy);
254
- return cache.result;
255
357
  }
256
- if (evidence.changes.length === 0) return cache.result;
257
-
258
- const transformSeeds = new Set<EntityHandle>();
259
- let rebuild = false;
260
- for (const change of evidence.changes) {
261
- if (
262
- change.kind === 'entity-removed' ||
263
- change.kind === 'component-added' ||
264
- change.kind === 'component-removed'
265
- ) {
266
- rebuild = true;
267
- break;
268
- }
269
- if (change.kind !== 'component-changed') continue;
270
- if (change.component === ChildOf) {
271
- rebuild = true;
272
- break;
273
- }
274
- // Derived Transform publications are deliberately not dirty seeds. Only
275
- // authored component-changed records can invalidate local TRS state.
276
- if (change.component === Transform) transformSeeds.add(change.entity);
358
+ if (parentIndex >= 0) {
359
+ const parentResult = resolveEntry(parentIndex, entries, world, output, scratch);
360
+ if (!parentResult.ok) failure = parentResult.error;
361
+ // A cycle fallback can cut this entry's authored edge while resolving its
362
+ // parent. Re-read the edge before composing so no cycle member consumes an
363
+ // uninitialized parent matrix.
364
+ parentIndex = entry.parent === undefined ? -1 : findEntry(entries, entry.parent);
365
+ if (parentIndex >= 0 && entries[parentIndex]?.state !== 2) parentIndex = -1;
277
366
  }
278
- if (rebuild) {
279
- CACHE.delete(world);
280
- return propagateTransforms(world);
367
+ composeColumns(entry.position, entry.rotation, entry.scale, scratch.local, scratch);
368
+ if (parentIndex >= 0) {
369
+ const parent = entries[parentIndex];
370
+ if (parent !== undefined) mat4.multiply(entry.world, parent.world, scratch.local);
371
+ } else {
372
+ entry.world.set(scratch.local);
281
373
  }
282
-
283
- // A World can record several authored writes before this pass. Read each
284
- // seed once so the final local state decides whether propagation is needed.
285
- const changed = new Set<EntityHandle>();
286
- for (const entity of transformSeeds) {
287
- const current = world.get(entity, Transform);
288
- if (!current.ok) {
289
- CACHE.delete(world);
290
- return propagateTransforms(world);
291
- }
292
- const next = copyState(current.value);
293
- if (!sameState(cache.locals.get(entity), next)) changed.add(entity);
294
- cache.locals.set(entity, next);
374
+ const write = writeWorld(world, output, entry.entity, entry.world);
375
+ if (!write.ok) {
376
+ scratch.hierarchyPath.pop();
377
+ return write;
295
378
  }
296
- if (changed.size === 0) return cache.result;
379
+ entry.state = 2;
380
+ scratch.hierarchyPath.pop();
381
+ return failure === undefined ? ok(undefined) : err(failure);
382
+ }
297
383
 
298
- const affected = descendants(cache.childrenOf, changed);
299
- const published = new Set<EntityHandle>();
300
- for (const entity of affected) cache.derived.delete(entity);
301
- for (const entity of affected) {
302
- const result = deriveEntity(world, entity, cache, affected, new Set(), published);
303
- if (!result.ok) return result;
384
+ function propagateHierarchy(world: World, scratch: Scratch): Result<void, SceneError> {
385
+ const query = scratch.hierarchyQuery;
386
+ const output = scratch.outputQuery;
387
+ if (query === undefined || output === undefined) {
388
+ return err(
389
+ new SceneError({
390
+ code: 'hierarchy-broken',
391
+ expected: 'paired Transform and GlobalTransform query access',
392
+ hint: 'register the scene components before running TransformPropagation',
393
+ }),
394
+ );
304
395
  }
305
- if (!drainPublishedChanges(cache, published)) {
306
- CACHE.delete(world);
307
- return propagateTransforms(world);
396
+ const entries: HierarchyEntry[] = [];
397
+ for (const row of query) {
398
+ if (findEntry(entries, row.entity) >= 0) continue;
399
+ const local = row.get(Transform);
400
+ const parentValue = row.get(ChildOf).parent;
401
+ const parent = parentValue === null ? undefined : parentValue;
402
+ entries.push({
403
+ entity: row.entity,
404
+ parent,
405
+ position: new Float32Array(local.pos),
406
+ rotation: new Float32Array(local.quat),
407
+ scale: new Float32Array(local.scale),
408
+ world: mat4.create(),
409
+ state: 0,
410
+ });
308
411
  }
309
- cache.result = hierarchyError(cache.hierarchy);
310
- return cache.result;
412
+ scratch.hierarchyPath.length = 0;
413
+ let firstError: SceneError | undefined;
414
+ for (let index = 0; index < entries.length; index += 1) {
415
+ const result = resolveEntry(index, entries, world, output, scratch);
416
+ if (!result.ok) {
417
+ const currentEntity = Number(result.error.detail?.entity ?? Number.MAX_SAFE_INTEGER);
418
+ if (firstError === undefined) {
419
+ firstError = result.error;
420
+ } else {
421
+ const firstEntity = Number(firstError.detail?.entity ?? Number.MAX_SAFE_INTEGER);
422
+ if (
423
+ currentEntity < firstEntity ||
424
+ (currentEntity === firstEntity && result.error.code.localeCompare(firstError.code) < 0)
425
+ ) {
426
+ firstError = result.error;
427
+ }
428
+ }
429
+ }
430
+ }
431
+ return firstError === undefined ? ok(undefined) : err(firstError);
432
+ }
433
+
434
+ export function propagateTransforms(world: World): Result<void, SceneError> {
435
+ const scratch = scratchFor(world);
436
+ const pairs = validateTransformPairs(world, scratch);
437
+ if (!pairs.ok) return pairs;
438
+ const flat = propagateFlat(scratch);
439
+ if (!flat.ok) return flat;
440
+ return propagateHierarchy(world, scratch);
311
441
  }
312
442
 
313
443
  export const PropagateTransforms: SystemHandle<readonly []> = defineSystem({
@@ -341,7 +471,7 @@ export function registerPropagateTransforms(
341
471
  world.removeSystem(FixedUpdate, PROPAGATE_TRANSFORMS_FIXED_SYSTEM);
342
472
  world.removeSystem(Update, PROPAGATE_TRANSFORMS_SYSTEM);
343
473
  REGISTRATION_LEASES.delete(world);
344
- CACHE.delete(world);
474
+ SCRATCH.delete(world);
345
475
  }
346
476
  };
347
477
  }
@@ -360,17 +490,18 @@ export function registerPropagateTransforms(
360
490
  .unwrap();
361
491
  }
362
492
  world.addSystems(FixedUpdate, TransformFixedSet, [PropagateTransformsFixed]).unwrap();
363
- const lease: TransformRegistrationLease = { refs: 1 };
364
- REGISTRATION_LEASES.set(world, lease);
493
+ REGISTRATION_LEASES.set(world, { refs: 1 });
365
494
  let active = true;
366
495
  return () => {
367
496
  if (!active) return;
368
497
  active = false;
498
+ const lease = REGISTRATION_LEASES.get(world);
499
+ if (lease === undefined) return;
369
500
  lease.refs -= 1;
370
501
  if (lease.refs !== 0) return;
371
502
  world.removeSystem(FixedUpdate, PROPAGATE_TRANSFORMS_FIXED_SYSTEM);
372
503
  world.removeSystem(Update, PROPAGATE_TRANSFORMS_SYSTEM);
373
504
  REGISTRATION_LEASES.delete(world);
374
- CACHE.delete(world);
505
+ SCRATCH.delete(world);
375
506
  };
376
507
  }