@forgeax/engine-scene 0.1.23 → 0.1.24

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.
@@ -250,7 +250,7 @@ export declare function worldValidateMountOverrides(world: World, mount: SceneIn
250
250
  * chain `cube -> innerSyntheticRoot -> mountEntity -> outerSyntheticRoot`,
251
251
  * so it MUST carry Transform whenever Transform is registered (mirrors
252
252
  * the D-V-0 synthetic-root invariant). Otherwise propagateTransforms
253
- * walking the chain hits a Transform-less parent and emits per-frame
253
+ * expanding the chain hits a Transform-less parent and emits per-frame
254
254
  * `RhiError(hierarchy-broken)` (verify R1 root cause of the
255
255
  * hello-scene-nesting demo black frames).
256
256
  */
@@ -5,6 +5,27 @@ export declare const PROPAGATE_TRANSFORMS_SYSTEM: "propagateTransforms";
5
5
  export declare const PROPAGATE_TRANSFORMS_FIXED_SYSTEM: "propagateTransformsFixed";
6
6
  export declare const TransformSet: import("@forgeax/engine-ecs").SystemSet;
7
7
  export declare const TransformFixedSet: import("@forgeax/engine-ecs").SystemSet;
8
+ /**
9
+ * Optional, test-owned counters for the parent-first executor. The counters
10
+ * are disabled unless a caller brackets a run with begin/end; production
11
+ * propagation therefore pays only one predictable branch per instrumented
12
+ * event. They are deliberately not a second execution state or a public
13
+ * dirty/cache contract.
14
+ */
15
+ export interface TransformPropagationTrace {
16
+ hierarchyRootInvocations: number;
17
+ hierarchyRootCursorReuses: number;
18
+ hierarchyRootCursorAllocations: number;
19
+ hierarchyEntityLookups: number;
20
+ hierarchyRowsEvaluated: number;
21
+ hierarchyEdgesVisited: number;
22
+ hierarchyPublishedRows: number;
23
+ hierarchyPublishedRuns: number;
24
+ hierarchyResidualParentProbes: number;
25
+ flatStructuralRootRows: number;
26
+ }
27
+ export declare function beginTransformPropagationTrace(): void;
28
+ export declare function endTransformPropagationTrace(): TransformPropagationTrace;
8
29
  export declare function propagateTransforms(world: World): Result<void, SceneError>;
9
30
  export declare const PropagateTransforms: SystemHandle<readonly []>;
10
31
  export declare const PropagateTransformsFixed: SystemHandle<readonly []>;
@@ -1 +1 @@
1
- {"version":3,"file":"propagate-transforms.d.ts","sourceRoot":"","sources":["../../src/systems/propagate-transforms.ts"],"names":[],"mappings":"AAAA,OAAO,EAOL,KAAK,YAAY,EAEjB,KAAK,KAAK,EACX,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAAW,KAAK,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAG7D,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAEvC,eAAO,MAAM,2BAA2B,EAAG,qBAA8B,CAAC;AAC1E,eAAO,MAAM,iCAAiC,EAAG,0BAAmC,CAAC;AACrF,eAAO,MAAM,YAAY,yCAAyC,CAAC;AACnE,eAAO,MAAM,iBAAiB,yCAA+C,CAAC;AA6Z9E,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,CAAC,IAAI,EAAE,UAAU,CAAC,CAO1E;AAED,eAAO,MAAM,mBAAmB,EAAE,YAAY,CAAC,SAAS,EAAE,CAOxD,CAAC;AAEH,eAAO,MAAM,wBAAwB,EAAE,YAAY,CAAC,SAAS,EAAE,CAI7D,CAAC;AAEH,wBAAgB,2BAA2B,CACzC,KAAK,EAAE,KAAK,EACZ,OAAO,GAAE;IAAE,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAAO,GAC1C,MAAM,IAAI,CA8CZ"}
1
+ {"version":3,"file":"propagate-transforms.d.ts","sourceRoot":"","sources":["../../src/systems/propagate-transforms.ts"],"names":[],"mappings":"AAAA,OAAO,EAQL,KAAK,YAAY,EAEjB,KAAK,KAAK,EACX,MAAM,qBAAqB,CAAC;AAS7B,OAAO,EAAW,KAAK,MAAM,EAAE,MAAM,uBAAuB,CAAC;AAI7D,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAEvC,eAAO,MAAM,2BAA2B,EAAG,qBAA8B,CAAC;AAC1E,eAAO,MAAM,iCAAiC,EAAG,0BAAmC,CAAC;AACrF,eAAO,MAAM,YAAY,yCAAyC,CAAC;AACnE,eAAO,MAAM,iBAAiB,yCAA+C,CAAC;AAE9E;;;;;;GAMG;AACH,MAAM,WAAW,yBAAyB;IACxC,wBAAwB,EAAE,MAAM,CAAC;IACjC,yBAAyB,EAAE,MAAM,CAAC;IAClC,8BAA8B,EAAE,MAAM,CAAC;IACvC,sBAAsB,EAAE,MAAM,CAAC;IAC/B,sBAAsB,EAAE,MAAM,CAAC;IAC/B,qBAAqB,EAAE,MAAM,CAAC;IAC9B,sBAAsB,EAAE,MAAM,CAAC;IAC/B,sBAAsB,EAAE,MAAM,CAAC;IAC/B,6BAA6B,EAAE,MAAM,CAAC;IACtC,sBAAsB,EAAE,MAAM,CAAC;CAChC;AAWD,wBAAgB,8BAA8B,IAAI,IAAI,CAcrD;AAED,wBAAgB,4BAA4B,IAAI,yBAAyB,CAsBxE;AAklCD,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,CAAC,IAAI,EAAE,UAAU,CAAC,CAO1E;AAED,eAAO,MAAM,mBAAmB,EAAE,YAAY,CAAC,SAAS,EAAE,CAOxD,CAAC;AAEH,eAAO,MAAM,wBAAwB,EAAE,YAAY,CAAC,SAAS,EAAE,CAI7D,CAAC;AAEH,wBAAgB,2BAA2B,CACzC,KAAK,EAAE,KAAK,EACZ,OAAO,GAAE;IAAE,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAAO,GAC1C,MAAM,IAAI,CA8CZ"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeax/engine-scene",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -22,10 +22,10 @@
22
22
  "LICENSE"
23
23
  ],
24
24
  "dependencies": {
25
- "@forgeax/engine-ecs": "0.1.23",
26
- "@forgeax/engine-math": "0.1.23",
27
- "@forgeax/engine-plugin": "0.1.23",
28
- "@forgeax/engine-types": "0.1.23"
25
+ "@forgeax/engine-ecs": "0.1.24",
26
+ "@forgeax/engine-math": "0.1.24",
27
+ "@forgeax/engine-plugin": "0.1.24",
28
+ "@forgeax/engine-types": "0.1.24"
29
29
  },
30
30
  "forgeax": {
31
31
  "metrics": {
@@ -1,15 +1,37 @@
1
1
  import type { EntityHandle, World } from '@forgeax/engine-ecs';
2
+ import { componentId } from '../../../../ecs/src/component';
3
+ import { entityIndex } from '../../../../ecs/src/entity-handle';
4
+ import { worldInternal } from '../../../../ecs/src/world-internal';
2
5
  import { ChildOf } from '../../index';
3
6
 
4
7
  /**
5
- * Scene-owned malformed-edge fixture. The public relationship write accepts
6
- * stale handles as data; the projection then owns the liveness diagnostic.
7
- * Keeping this fixture on `world.set` avoids a private ECS graph seam.
8
+ * Scene-owned malformed-edge fixture. Production relationship writes reject
9
+ * stale targets and cycles before touching either side of the relationship.
10
+ * This test-only fixture deliberately corrupts the source column through the
11
+ * existing package-internal graph seam so projection can exercise its
12
+ * fail-closed diagnostics without weakening the public write contract.
8
13
  */
9
14
  export function setMalformedParentEdge(
10
15
  world: World,
11
16
  child: EntityHandle,
12
17
  parent: EntityHandle,
13
18
  ): void {
14
- world.set(child, ChildOf, { parent }).unwrap();
19
+ const record = world[worldInternal].getRecords()[entityIndex(child)];
20
+ if (record === undefined || record.archetypeId < 0) {
21
+ throw new Error(`Malformed hierarchy fixture child ${child} is not live.`);
22
+ }
23
+ const graph = world[worldInternal].getGraph();
24
+ const archetype = graph.archetypes[record.archetypeId];
25
+ if (archetype === undefined) throw new Error(`Missing archetype ${record.archetypeId}.`);
26
+ const table = graph.tables[archetype.tableId];
27
+ const column = table?.storage.get(componentId(ChildOf))?.fields.get('parent');
28
+ const tableRow = archetype.rows[record.archetypeRow];
29
+ if (column === undefined || tableRow === undefined) {
30
+ throw new Error(`Malformed hierarchy fixture child ${child} lacks ChildOf storage.`);
31
+ }
32
+ column.view[tableRow] = parent as number;
33
+ // Keep the corruption internal, but make the source mutation observable to
34
+ // the normal change-token consumers so an already-built projection cannot
35
+ // mask the deliberately malformed value.
36
+ world[worldInternal].markComponentChanged(child, componentId(ChildOf));
15
37
  }
@@ -0,0 +1,149 @@
1
+ // Resource-safe hierarchy performance evidence: the largest default case is
2
+ // 10k fully dynamic nodes. Cross-commit AB/BA timing remains an external
3
+ // runner concern; this gate records deterministic owner counters and exact
4
+ // matrices without claiming a speedup from one local process.
5
+ import { type EntityHandle, World } from '@forgeax/engine-ecs';
6
+ import { describe, expect, it } from 'vitest';
7
+ import { ChildOf, GlobalTransform, Transform } from '../index';
8
+ import {
9
+ beginTransformPropagationTrace,
10
+ endTransformPropagationTrace,
11
+ propagateTransforms,
12
+ type TransformPropagationTrace,
13
+ } from '../systems/propagate-transforms';
14
+
15
+ type HierarchyShape = 'wide' | 'balanced';
16
+
17
+ interface Workload {
18
+ readonly shape: HierarchyShape;
19
+ readonly rows: number;
20
+ }
21
+
22
+ interface Receipt {
23
+ readonly shape: HierarchyShape;
24
+ readonly rows: number;
25
+ readonly fullyDynamicRows: number;
26
+ readonly propagationMs: number;
27
+ readonly trace: TransformPropagationTrace;
28
+ }
29
+
30
+ const BRANCHING_FACTOR = 4;
31
+ const WARMUP_FRAMES = 2;
32
+ const WORKLOADS: readonly Workload[] = [
33
+ { shape: 'wide', rows: 1_000 },
34
+ { shape: 'wide', rows: 4_000 },
35
+ { shape: 'wide', rows: 10_000 },
36
+ { shape: 'balanced', rows: 1_000 },
37
+ { shape: 'balanced', rows: 4_000 },
38
+ { shape: 'balanced', rows: 10_000 },
39
+ ];
40
+
41
+ function parentIndex(shape: HierarchyShape, row: number): number {
42
+ if (row === 0) return -1;
43
+ return shape === 'wide' ? 0 : Math.floor((row - 1) / BRANCHING_FACTOR);
44
+ }
45
+
46
+ function spawnNode(world: World, parent: EntityHandle | undefined, localX: number): EntityHandle {
47
+ const transform = { component: Transform, data: { pos: [localX, 0, 0] } } as const;
48
+ if (parent === undefined) return world.spawn(transform).unwrap();
49
+ return world.spawn(transform, { component: ChildOf, data: { parent } }).unwrap();
50
+ }
51
+
52
+ function setDynamicLocals(
53
+ world: World,
54
+ entities: readonly EntityHandle[],
55
+ parents: Int32Array,
56
+ expectedWorldX: Float64Array,
57
+ frame: number,
58
+ ): void {
59
+ for (let row = 0; row < entities.length; row += 1) {
60
+ const value = ((row * 17 + frame * 31) % 97) + 1;
61
+ const entity = entities[row];
62
+ if (entity === undefined) throw new Error(`missing hierarchy entity ${row}`);
63
+ world.set(entity, Transform, { pos: [value, 0, 0] }).unwrap();
64
+ const parent = parents[row] ?? -1;
65
+ expectedWorldX[row] = parent < 0 ? value : (expectedWorldX[parent] ?? 0) + value;
66
+ }
67
+ }
68
+
69
+ function assertExactTranslationMatrices(
70
+ world: World,
71
+ entities: readonly EntityHandle[],
72
+ expectedWorldX: Float64Array,
73
+ ): void {
74
+ const expectedIdentity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
75
+ for (let row = 0; row < entities.length; row += 1) {
76
+ const entity = entities[row];
77
+ if (entity === undefined) throw new Error(`missing hierarchy entity ${row}`);
78
+ const matrix = world.get(entity, GlobalTransform).unwrap().world;
79
+ const expectedX = expectedWorldX[row] ?? 0;
80
+ for (let index = 0; index < 16; index += 1) {
81
+ const expected = index === 12 ? expectedX : (expectedIdentity[index] ?? 0);
82
+ expect(matrix[index], `matrix[${row}][${index}]`).toBe(expected);
83
+ }
84
+ }
85
+ }
86
+
87
+ function runWorkload(workload: Workload): Receipt {
88
+ const world = new World();
89
+ const entities: EntityHandle[] = [];
90
+ const parents = new Int32Array(workload.rows);
91
+ for (let row = 0; row < workload.rows; row += 1) {
92
+ const parent = parentIndex(workload.shape, row);
93
+ parents[row] = parent;
94
+ const parentEntity = parent < 0 ? undefined : entities[parent];
95
+ entities.push(spawnNode(world, parentEntity, row + 1));
96
+ }
97
+
98
+ const expectedWorldX = new Float64Array(workload.rows);
99
+ for (let frame = 0; frame < WARMUP_FRAMES; frame += 1) {
100
+ setDynamicLocals(world, entities, parents, expectedWorldX, frame);
101
+ propagateTransforms(world).unwrap();
102
+ }
103
+
104
+ setDynamicLocals(world, entities, parents, expectedWorldX, WARMUP_FRAMES);
105
+ const start = performance.now();
106
+ beginTransformPropagationTrace();
107
+ const result = propagateTransforms(world);
108
+ const propagationMs = Number((performance.now() - start).toFixed(3));
109
+ const trace = endTransformPropagationTrace();
110
+ expect(result.ok).toBe(true);
111
+ assertExactTranslationMatrices(world, entities, expectedWorldX);
112
+
113
+ const hierarchyRows = workload.rows - 1;
114
+ expect(trace.hierarchyRowsEvaluated).toBe(hierarchyRows);
115
+ expect(trace.hierarchyEdgesVisited).toBe(hierarchyRows);
116
+ expect(trace.hierarchyEntityLookups).toBeGreaterThan(0);
117
+ expect(trace.hierarchyEntityLookups).toBeLessThanOrEqual(workload.rows * 8);
118
+ expect(trace.hierarchyPublishedRows).toBe(hierarchyRows);
119
+ expect(trace.hierarchyPublishedRuns).toBeGreaterThan(0);
120
+ expect(trace.hierarchyPublishedRuns).toBeLessThanOrEqual(2);
121
+ expect(trace.hierarchyRootCursorAllocations).toBe(0);
122
+ expect(trace.hierarchyResidualParentProbes).toBe(0);
123
+
124
+ return {
125
+ shape: workload.shape,
126
+ rows: workload.rows,
127
+ fullyDynamicRows: workload.rows,
128
+ propagationMs,
129
+ trace,
130
+ };
131
+ }
132
+
133
+ describe('dynamic hierarchy propagation counters', () => {
134
+ for (const workload of WORKLOADS) {
135
+ it(`keeps ${workload.shape} ${workload.rows} fully dynamic rows linear`, () => {
136
+ const receipt = runWorkload(workload);
137
+ // biome-ignore lint/suspicious/noConsole: deterministic performance receipt.
138
+ console.info(JSON.stringify({ schemaVersion: 1, workload: receipt }));
139
+
140
+ expect(receipt.fullyDynamicRows).toBe(receipt.rows);
141
+ expect(receipt.trace.hierarchyRowsEvaluated).toBe(receipt.rows - 1);
142
+ expect(receipt.trace.hierarchyEdgesVisited).toBe(receipt.rows - 1);
143
+ expect(receipt.trace.hierarchyEntityLookups).toBeLessThanOrEqual(receipt.rows * 8);
144
+ expect(receipt.trace.hierarchyPublishedRows).toBe(receipt.rows - 1);
145
+ expect(receipt.trace.hierarchyPublishedRuns).toBeLessThanOrEqual(2);
146
+ expect(receipt.trace.hierarchyRootCursorAllocations).toBe(0);
147
+ }, 120_000);
148
+ }
149
+ });
@@ -4,6 +4,11 @@ import { ChangeEpochExhaustedError } from '../../../ecs/src/errors';
4
4
  import { worldInternal } from '../../../ecs/src/world-internal';
5
5
  import { ChildOf, GlobalTransform, propagateTransforms, Transform } from '../index';
6
6
  import { registerPropagateTransforms } from '../systems';
7
+ import {
8
+ beginTransformPropagationTrace,
9
+ endTransformPropagationTrace,
10
+ } from '../systems/propagate-transforms';
11
+ import { setMalformedParentEdge } from './fixtures/malformed-hierarchy-edge';
7
12
 
8
13
  function spawnTransform(world: World, pos: number[], parent?: EntityHandle) {
9
14
  const components = [{ component: Transform, data: { pos } }];
@@ -193,6 +198,39 @@ describe('scene propagation', () => {
193
198
  expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(101);
194
199
  });
195
200
 
201
+ it('recomputes a detached ChildOf entity as a flat root even when local data is unchanged', () => {
202
+ const world = new World();
203
+ const parent = spawnTransform(world, [10, 0, 0]);
204
+ const child = spawnTransform(world, [1, 0, 0], parent);
205
+ propagateTransforms(world).unwrap();
206
+
207
+ world.removeComponent(child, ChildOf).unwrap();
208
+ const result = propagateTransforms(world);
209
+
210
+ expect(result.ok).toBe(true);
211
+ expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(1);
212
+ });
213
+
214
+ it('reuses one hierarchy root cursor and reports linear traversal counters', () => {
215
+ const world = new World();
216
+ for (let index = 0; index < 32; index += 1) {
217
+ const root = spawnTransform(world, [index, 0, 0]);
218
+ spawnTransform(world, [1, 0, 0], root);
219
+ }
220
+ propagateTransforms(world).unwrap();
221
+
222
+ beginTransformPropagationTrace();
223
+ propagateTransforms(world).unwrap();
224
+ const trace = endTransformPropagationTrace();
225
+
226
+ expect(trace.hierarchyRootInvocations).toBeGreaterThan(0);
227
+ expect(trace.hierarchyRootCursorReuses).toBe(trace.hierarchyRootInvocations);
228
+ expect(trace.hierarchyRootCursorAllocations).toBe(0);
229
+ expect(trace.hierarchyRowsEvaluated).toBeGreaterThanOrEqual(32);
230
+ expect(trace.hierarchyEdgesVisited).toBeGreaterThanOrEqual(32);
231
+ expect(trace.hierarchyResidualParentProbes).toBe(0);
232
+ });
233
+
196
234
  it('publishes a local fallback before returning a stale-parent error', () => {
197
235
  const world = new World();
198
236
  const parent = spawnTransform(world, [10, 0, 0]);
@@ -202,7 +240,10 @@ describe('scene propagation', () => {
202
240
 
203
241
  const staleParent = spawnTransform(world, [50, 0, 0]);
204
242
  world.despawn(staleParent).unwrap();
205
- world.set(child, ChildOf, { parent: staleParent }).unwrap();
243
+ // Public relationship writes reject stale targets before changing either
244
+ // side. Corrupt the source only through the test-owned internal fixture
245
+ // to exercise propagation's explicit malformed-input fallback.
246
+ setMalformedParentEdge(world, child, staleParent);
206
247
  const fault = propagateTransforms(world);
207
248
 
208
249
  expect(fault.ok).toBe(false);
@@ -210,10 +251,6 @@ describe('scene propagation', () => {
210
251
  const fallback = world.get(child, GlobalTransform).unwrap().world;
211
252
  expect(fallback[12]).toBeCloseTo(1);
212
253
  expect(fallback[12]).not.toBeCloseTo(baseline[12] ?? Number.NaN);
213
-
214
- world.set(child, ChildOf, { parent }).unwrap();
215
- expect(propagateTransforms(world).ok).toBe(true);
216
- expect(world.get(child, GlobalTransform).unwrap().world[12]).toBeCloseTo(baseline[12] ?? 0);
217
254
  });
218
255
 
219
256
  it('cuts every member of a cycle to a finite local root fallback', () => {
@@ -221,7 +258,7 @@ describe('scene propagation', () => {
221
258
  const root = spawnTransform(world, [0, 0, 0]);
222
259
  const first = spawnTransform(world, [1, 0, 0], root);
223
260
  const second = spawnTransform(world, [2, 0, 0], first);
224
- world.set(first, ChildOf, { parent: second }).unwrap();
261
+ setMalformedParentEdge(world, first, second);
225
262
 
226
263
  const result = propagateTransforms(world);
227
264
 
@@ -233,6 +270,33 @@ describe('scene propagation', () => {
233
270
  }
234
271
  });
235
272
 
273
+ it('bounds rootless-cycle parent probes to one residual pass', () => {
274
+ const world = new World();
275
+ const root = spawnTransform(world, [0, 0, 0]);
276
+ const nodes: EntityHandle[] = [];
277
+ let parent = root;
278
+ for (let index = 0; index < 48; index += 1) {
279
+ const node = spawnTransform(world, [1, 0, 0], parent);
280
+ nodes.push(node);
281
+ parent = node;
282
+ }
283
+ propagateTransforms(world).unwrap();
284
+ const first = nodes[0];
285
+ const last = nodes[nodes.length - 1];
286
+ if (first === undefined || last === undefined) throw new Error('expected cycle nodes');
287
+ setMalformedParentEdge(world, first, last);
288
+
289
+ beginTransformPropagationTrace();
290
+ const result = propagateTransforms(world);
291
+ const trace = endTransformPropagationTrace();
292
+
293
+ expect(result.ok).toBe(false);
294
+ if (!result.ok) expect(result.error.code).toBe('hierarchy-cycle');
295
+ expect(trace.hierarchyResidualParentProbes).toBeGreaterThan(0);
296
+ expect(trace.hierarchyResidualParentProbes).toBeLessThanOrEqual(nodes.length + 1);
297
+ expect(trace.hierarchyEdgesVisited).toBeLessThanOrEqual(nodes.length + 1);
298
+ });
299
+
236
300
  it('expands a generic ChildOf mutation and updates descendants', () => {
237
301
  const world = new World();
238
302
  const firstParent = spawnTransform(world, [10, 0, 0]);
@@ -7,6 +7,8 @@
7
7
  // u32 against the live record on read; `world.get(child, ChildOf)` returns
8
8
  // the raw encoded Entity and consumers check liveness themselves (via
9
9
  // `world.get(parent, Entity)` — returns `err(stale-entity)` for despawned handles).
10
+ // Public source writes validate target liveness before committing; only
11
+ // controlled internal fixtures can create a stale source for diagnostics.
10
12
  //
11
13
  // feat-20260531-ecs-relationship-abstraction-bidirectional-sync M4 / t20:
12
14
  // ChildOf is declared as the holder side of a Bevy-style bidirectional
@@ -30,30 +32,30 @@
30
32
  // the D-1 default from false to true). When linkedSpawn is set to true,
31
33
  // world.despawn(parent) recursively despawns the entire subtree.
32
34
  //
33
- // The prior default (linkedSpawn: false) meant despawning the parent only
34
- // pruned the Children entry, leaving the child entity alive. That behavior
35
- // is still available by passing linkedSpawn: false explicitly.
35
+ // `ChildOf` uses linkedSpawn: true, so despawning a parent recursively
36
+ // despawns its linked subtree. A generic relationship may opt out with
37
+ // linkedSpawn: false, but that is not this scene hierarchy's lifecycle.
36
38
  //
37
- // Child despawn auto-detaches from the parent's Children list: the
39
+ // Child despawn auto-detaches from its parent's Children list: the
38
40
  // relationship `onRemove` hook fires on `world.despawn(child)` and prunes the
39
41
  // child from the parent's `Children.entities` (a write-path hook). Despawning
40
- // the PARENT does not auto-clean the child's ChildOf: the child's `parent`
41
- // column keeps the stale encoded Entity. The ECS does not bottom out dangling
42
- // references on read; the consumer is responsible for cleanup
43
- // (`world.removeComponent(child, ChildOf)` / `world.get(parent, Entity)` liveness check).
42
+ // the parent follows ChildOf's linkedSpawn cascade and retires the linked
43
+ // subtree. A stale ChildOf source is consequently only an explicitly malformed
44
+ // internal fixture (or a generic non-linked relationship); the consumer repairs
45
+ // it with `world.removeComponent(child, ChildOf)` after the structured error.
44
46
  //
45
- // propagateTransforms system (./systems/propagate-transforms.ts) consumes
46
- // this component by reading the archetype Uint32Array column directly
47
- // (query-backed hierarchy access). When a parent has been despawned
47
+ // propagateTransforms system (./systems/propagate-transforms.ts) consumes the
48
+ // ECS-maintained Children buffer for parent-first traversal and reads this
49
+ // source column only to validate the mirrored edge. When a parent has been despawned
48
50
  // but the child's stale ChildOf is left in place, the live-map lookup surfaces
49
- // `RhiError({ code: 'hierarchy-broken' })` - a deliberate per-frame fail-fast
51
+ // `SceneError({ code: 'hierarchy-broken' })` - a deliberate per-frame fail-fast
50
52
  // (the consumer is expected to despawn the subtree or remove the stale
51
53
  // ChildOf), kept as the explicit-failure surface (charter proposition 4).
52
54
  //
53
55
  // charter mapping: proposition 2 (industry analogy: Bevy ChildOf is the
54
56
  // post-0.15 rename of Parent for the same shape as glTF node parent index
55
57
  // + Three.js Object3D.parent) + proposition 4 (explicit failure: dangling
56
- // parent surfaces structured RhiError) + proposition 5
58
+ // parent surfaces structured SceneError) + proposition 5
57
59
  // (consistent abstraction: the schema-vocab 'entity' keyword is the SSOT
58
60
  // for entity-typed columns across the engine).
59
61
 
@@ -63,15 +65,16 @@ export { ChildOf } from './children';
63
65
  * Hierarchy back-reference: pointer from child entity to its parent.
64
66
  *
65
67
  * Store the parent `Entity` handle (returned by `world.spawn(...).unwrap()`)
66
- * in the `parent` field; `propagateTransforms` reads it each frame to
67
- * compose the child's derived `GlobalTransform.world` mat4 (child.world =
68
- * parent.world x child local). `Transform` declares `GlobalTransform` as a
68
+ * in the `parent` field; `propagateTransforms` follows the ECS-maintained
69
+ * `Children` list each frame to compose the child's derived
70
+ * `GlobalTransform.world` mat4 (child.world = parent.world x child local).
71
+ * `Transform` declares `GlobalTransform` as a
69
72
  * generic ECS requirement, so normal spawn/add/command paths materialize the
70
73
  * pair without scene-specific repair. Propagation still fails closed when an
71
- * owner explicitly removes one half. Despawning a child auto-detaches it from the
72
- * parent's Children list (relationship `onRemove` hook). Despawning the
73
- * parent does not auto-clean the child's ChildOf; the consumer removes the
74
- * stale ChildOf (`world.removeComponent`) or checks `world.get(parent, Entity)` for liveness.
74
+ * owner explicitly removes one half. Despawning a child auto-detaches it from
75
+ * its parent's Children list; despawning the parent follows ChildOf's
76
+ * linkedSpawn cascade, while a malformed internal fixture can still leave a
77
+ * stale source for Scene diagnostics.
75
78
  *
76
79
  * Because ChildOf declares a `relationship` mirror and a structural
77
80
  * `Transform` requirement, the engine keeps the parent's `Children.entities`
@@ -82,8 +85,9 @@ export { ChildOf } from './children';
82
85
  * prior OOS-10 contract is retired).
83
86
  * Reparenting is a plain re-add: `world.addComponent(child, { component:
84
87
  * ChildOf, data: { parent: newParent } })` on an entity that already carries
85
- * ChildOf auto-reparents (exclusive arm), or use `world.reparent(child,
86
- * newParent)`.
88
+ * ChildOf auto-reparents (exclusive arm). The generic convenience method has
89
+ * the same owner path and requires the relationship component plus its data:
90
+ * `world.reparent(child, newParent, ChildOf, { parent: newParent })`.
87
91
  *
88
92
  * @example Spawn a child entity referencing an already-spawned root:
89
93
  * const root = world.spawn(
@@ -1,28 +1,23 @@
1
1
  // @forgeax/engine-runtime - Children (forward-list of child entities).
2
2
  //
3
3
  // Schema: 1 array<entity> field `entities` (variable-length, ECS-managed via
4
- // the BufferPool slot column + sidecar count column allocated by the ECS M2
5
- // `world.push` / `world.pop` / `world.capacity` command surface).
4
+ // the BufferPool slot column + sidecar count column allocated by the ECS
5
+ // relationship owner).
6
6
  //
7
7
  // feat-20260515-buffer-array-vocab-collapse M3 / w17:
8
8
  // the legacy `VarArrayView<Entity>` value-shape wrapper was retired in
9
- // favour of a direct `TypedArray` snapshot returned by `world.get` plus the
10
- // three `world` commands. AI users mutate the list through:
11
- //
12
- // world.push(parent, Children, 'entities', child).unwrap();
13
- // world.pop(parent, Children, 'entities').unwrap();
14
- // world.capacity(parent, Children, 'entities').unwrap();
15
- //
16
- // And read through the read-only `Uint32Array` snapshot:
9
+ // favour of a direct `TypedArray` snapshot returned by `world.get`. AI users
10
+ // read the engine-maintained list through the read-only `Uint32Array` snapshot:
17
11
  //
18
12
  // const snap = world.get(parent, Children).unwrap().entities;
19
13
  // const liveCount = snap.length;
20
14
  // for (let i = 0; i < liveCount; i++) { const child = snap[i]; ... }
21
15
  //
22
16
  // Snapshot length equals the live element count (sidecar count column owned
23
- // by the ECS layer); the snapshot is rematerialised on every `world.get`
24
- // (D-4 no-cache); writes routed through the snapshot are undefined behaviour
25
- // (the contract is read-only, plan-strategy §2.2 D-R3).
17
+ // by the ECS layer); the public snapshot is detached and rematerialised on
18
+ // every `world.get` (D-4 no-cache), so `fill` or index writes cannot mutate the
19
+ // target. Internal relationship maintenance and Scene traversal borrow the
20
+ // live array through the package-internal zero-copy seams instead.
26
21
  //
27
22
  // feat-20260531-ecs-relationship-abstraction-bidirectional-sync M4 / t20:
28
23
  // Children is the MIRROR side of the ChildOf relationship. Its schema is
@@ -33,9 +28,8 @@
33
28
  // "AI users keep the two sides consistent themselves" contract is retired:
34
29
  // `world.addComponent(child, ChildOf{parent})` appends `child` to
35
30
  // `parent.Children.entities`, `world.removeComponent` / reparent prunes it.
36
- // AI users still MAY push/pop the list manually (the three `world` commands
37
- // below remain valid for non-ChildOf forward-lists), but for the ChildOf
38
- // hierarchy the engine owns consistency.
31
+ // For the ChildOf hierarchy the engine owns consistency; no public target
32
+ // write can diverge from the source relationship.
39
33
  //
40
34
  // feat-20260514-ecs-children-instances-managed-buffer-array M3 / w13 (kept
41
35
  // for context): migrated from the legacy `{ count: 'u32' }` advisory marker
@@ -43,14 +37,12 @@
43
37
  // - OOS-09 (prior loop): no `addChild` / `removeChild` / `removeChildren`
44
38
  // Commands API. Retired this feat: `world.addChild` / `world.removeChild`
45
39
  // / `world.reparent` ship in M3, plus the relationship hook above.
46
- // - OOS-01 (prior loop): no dangling-entity sweep on `array<entity>`. If a
47
- // child entity has been despawned, the stored u32 still occupies the
48
- // slot; AI users explicitly call the engine's entity-liveness check
49
- // before consuming a child id (see `world.get(child, ChildOf)` returns
50
- // `Result.err(...)` for a despawned entity --- the stored u32 surfaces
51
- // as a dead handle, not a silent zero). Charter proposition 4
52
- // (explicit failure): the engine does not silently drop dangling
53
- // entries.
40
+ // - Normal ChildOf child despawn invokes the source onRemove hook and
41
+ // removes the child before the row is retired; parent despawn follows the
42
+ // linkedSpawn cascade. A dangling u32 is therefore an explicitly malformed
43
+ // internal fixture or a non-linked generic relationship, not a normal
44
+ // ChildOf lifecycle result. Consumers still probe liveness before using a
45
+ // handle and receive the structured ECS error for malformed state.
54
46
  //
55
47
  // charter mapping: proposition 2 (Bevy ChildOf+Children pair, holder
56
48
  // perspective) + proposition 3 (machine-readable schema:
@@ -66,25 +58,26 @@ import { Transform } from './transform';
66
58
  * Hierarchy forward-list of child entities.
67
59
  *
68
60
  * `entities` is a variable-length `array<entity>` field; each element is
69
- * an `Entity` u32 the AI user pushed via
70
- * `world.push(parent, Children, 'entities', child)`. The value returned by
71
- * `world.get(parent, Children).unwrap().entities` is a read-only
61
+ * an `Entity` u32 the ECS relationship owner materialized. The value returned
62
+ * by `world.get(parent, Children).unwrap().entities` is a detached read-only
72
63
  * `Uint32Array` snapshot rematerialised fresh on every read (D-4 no-cache);
73
- * the snapshot's `length` equals the live element count.
64
+ * mutating the returned array cannot change ECS-owned storage, and the
65
+ * snapshot's `length` equals the live element count. Internal Scene/ECS paths
66
+ * use the package-internal zero-copy array seams instead of this snapshot.
74
67
  *
75
68
  * Invariants:
76
- * - Children is NOT consumed by `propagateTransforms` (which walks ChildOf
77
- * upward, D-P2). The forward list is for AI-user traversal / debug /
78
- * inspection.
69
+ * - `propagateTransforms` consumes Children from the ECS-owned materialized
70
+ * buffer and expands each root parent-first. The forward list is also
71
+ * available for AI-user traversal / debug / inspection.
79
72
  * - Children <-> ChildOf consistency is maintained by the engine via the
80
73
  * ChildOf `relationship` mirror hook (see ./child-of.ts): adding /
81
74
  * removing / reparenting ChildOf on a child auto-updates the parent's
82
75
  * `entities` list. AI users do not hand-sync the two sides for the
83
76
  * hierarchy.
84
- * - Stored entity u32s are NOT auto-cleared when the referenced entity is
85
- * despawned (OOS-01 above); a `world.get(despawnedChild, ...)` call
86
- * returns `Result.err(...)` so AI users discover the dangling state
87
- * through the engine's structured-error channel.
77
+ * - ChildOf's linked lifecycle keeps ordinary Children entries aligned: a
78
+ * child despawn prunes its source slot and a parent despawn cascades. A
79
+ * deliberately malformed/non-linked edge remains observable as a dead
80
+ * handle and must be diagnosed through the structured error channel.
88
81
  *
89
82
  * @example Spawn a parent and two children via ChildOf (engine maintains Children):
90
83
  * const parent = world.spawn({ component: Transform, data: identityXf() }).unwrap();
@@ -100,8 +93,8 @@ import { Transform } from './transform';
100
93
  * const snap = world.get(parent, Children).unwrap().entities;
101
94
  * for (let i = 0; i < snap.length; i++) {
102
95
  * const child = snap[i];
103
- * // ... consume; world.get(child, ...) surfaces a structured error
104
- * // if the child has been despawned (OOS-01 dangling-entity surface).
96
+ * // ... consume; probe the handle before using it when reading a
97
+ * // deliberately malformed/non-linked relationship.
105
98
  * }
106
99
  */
107
100
  export const { source: ChildOf, target: Children } = defineRelationship({
@@ -646,8 +646,8 @@ export function worldInstantiateSceneAsset(
646
646
  const mappingPlain: number[] = Array.from(mapping);
647
647
  // The synthetic root is the ChildOf parent of every owned root entity
648
648
  // (step 5 below) and may itself become a ChildOf parent of a caller-
649
- // supplied `parent` chain. propagateTransforms walks ChildOf parents
650
- // through the Transform liveMap and treats a parent missing Transform
649
+ // supplied `parent` chain. propagateTransforms expands the ECS-maintained
650
+ // Children lists parent-first and treats a parent missing Transform
651
651
  // as `hierarchy-broken`, so the synthetic root must carry Transform
652
652
  // (identity TRS via layer-2 defaults) when Transform is defined.
653
653
  const rootComponents: ComponentData[] = [
@@ -1048,7 +1048,7 @@ export function worldValidateMountOverrides(
1048
1048
  * chain `cube -> innerSyntheticRoot -> mountEntity -> outerSyntheticRoot`,
1049
1049
  * so it MUST carry Transform whenever Transform is registered (mirrors
1050
1050
  * the D-V-0 synthetic-root invariant). Otherwise propagateTransforms
1051
- * walking the chain hits a Transform-less parent and emits per-frame
1051
+ * expanding the chain hits a Transform-less parent and emits per-frame
1052
1052
  * `RhiError(hierarchy-broken)` (verify R1 root cause of the
1053
1053
  * hello-scene-nesting demo black frames).
1054
1054
  */
@@ -1064,7 +1064,7 @@ export function worldSpawnMountEntity(
1064
1064
  };
1065
1065
  const cdRes = worldBuildSceneEntityComponentDatas(world, fakeNode, mapping, diagnostics);
1066
1066
  if (!cdRes.ok) return cdRes;
1067
- // R2/B-1: ensure Transform is attached so propagateTransforms can walk
1067
+ // R2/B-1: ensure Transform is attached so propagateTransforms can expand
1068
1068
  // through this entity. Layer-2 defaults supply identity TRS; the
1069
1069
  // mount.components overlay (when present and including Transform) takes
1070
1070
  // precedence and is already in cdRes.value.