@forgeax/engine-scene 0.1.23 → 0.1.25
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.
- package/README.md +45 -2
- package/dist/__tests__/fixtures/malformed-hierarchy-edge.d.ts +5 -3
- package/dist/__tests__/fixtures/malformed-hierarchy-edge.d.ts.map +1 -1
- package/dist/__tests__/hierarchy-propagation.perf.test.d.ts +2 -0
- package/dist/__tests__/hierarchy-propagation.perf.test.d.ts.map +1 -0
- package/dist/components/child-of.d.ts +11 -9
- package/dist/components/child-of.d.ts.map +1 -1
- package/dist/components/children.d.ts +14 -13
- package/dist/components/children.d.ts.map +1 -1
- package/dist/index.mjs +677 -157
- package/dist/index.mjs.map +1 -1
- package/dist/instances/scene-instances.d.ts +1 -1
- package/dist/systems/propagate-transforms.d.ts +21 -0
- package/dist/systems/propagate-transforms.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/fixtures/malformed-hierarchy-edge.ts +26 -4
- package/src/__tests__/hierarchy-propagation.perf.test.ts +149 -0
- package/src/__tests__/propagation.unit.test.ts +70 -6
- package/src/components/child-of.ts +26 -22
- package/src/components/children.ts +30 -37
- package/src/instances/scene-instances.ts +4 -4
- package/src/systems/propagate-transforms.ts +966 -195
|
@@ -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
|
-
*
|
|
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,
|
|
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.
|
|
3
|
+
"version": "0.1.25",
|
|
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.
|
|
26
|
-
"@forgeax/engine-math": "0.1.
|
|
27
|
-
"@forgeax/engine-plugin": "0.1.
|
|
28
|
-
"@forgeax/engine-types": "0.1.
|
|
25
|
+
"@forgeax/engine-ecs": "0.1.25",
|
|
26
|
+
"@forgeax/engine-math": "0.1.25",
|
|
27
|
+
"@forgeax/engine-plugin": "0.1.25",
|
|
28
|
+
"@forgeax/engine-types": "0.1.25"
|
|
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.
|
|
6
|
-
* stale
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
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
|
|
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
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
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
|
-
//
|
|
47
|
-
//
|
|
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
|
-
// `
|
|
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
|
|
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`
|
|
67
|
-
* compose the child's derived
|
|
68
|
-
* parent.world x child local).
|
|
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
|
|
72
|
-
* parent's Children list
|
|
73
|
-
*
|
|
74
|
-
* stale
|
|
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)
|
|
86
|
-
*
|
|
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
|
|
5
|
-
//
|
|
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
|
|
10
|
-
//
|
|
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
|
|
24
|
-
// (D-4 no-cache)
|
|
25
|
-
//
|
|
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
|
-
//
|
|
37
|
-
//
|
|
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
|
-
// -
|
|
47
|
-
// child
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
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
|
|
70
|
-
* `world.
|
|
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
|
|
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
|
-
* -
|
|
77
|
-
*
|
|
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
|
-
* -
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* through the
|
|
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;
|
|
104
|
-
* //
|
|
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
|
|
650
|
-
//
|
|
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
|
-
*
|
|
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
|
|
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.
|