@forgeax/engine-scene 0.1.21 → 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.
Files changed (87) hide show
  1. package/README.md +82 -11
  2. package/dist/__tests__/fixtures/malformed-hierarchy-edge.d.ts +5 -3
  3. package/dist/__tests__/fixtures/malformed-hierarchy-edge.d.ts.map +1 -1
  4. package/dist/__tests__/flat-propagation.contract.test.d.ts +2 -0
  5. package/dist/__tests__/flat-propagation.contract.test.d.ts.map +1 -0
  6. package/dist/__tests__/flat-propagation.derived-writer.contract.test.d.ts +2 -0
  7. package/dist/__tests__/flat-propagation.derived-writer.contract.test.d.ts.map +1 -0
  8. package/dist/__tests__/flat-propagation.perf.test.d.ts +2 -0
  9. package/dist/__tests__/flat-propagation.perf.test.d.ts.map +1 -0
  10. package/dist/__tests__/hierarchy-propagation.perf.test.d.ts +2 -0
  11. package/dist/__tests__/hierarchy-propagation.perf.test.d.ts.map +1 -0
  12. package/dist/__tests__/scene-binding.integration.test.d.ts +2 -0
  13. package/dist/__tests__/scene-binding.integration.test.d.ts.map +1 -0
  14. package/dist/__tests__/schedule-order.contract.test.d.ts +2 -0
  15. package/dist/__tests__/schedule-order.contract.test.d.ts.map +1 -0
  16. package/dist/__tests__/structural.test.d.ts +2 -0
  17. package/dist/__tests__/structural.test.d.ts.map +1 -0
  18. package/dist/__tests__/transform-carrier-consumers.test-d.d.ts +2 -0
  19. package/dist/__tests__/transform-carrier-consumers.test-d.d.ts.map +1 -0
  20. package/dist/__tests__/transform-carrier-prototype.bench.d.ts +39 -0
  21. package/dist/__tests__/transform-carrier-prototype.bench.d.ts.map +1 -0
  22. package/dist/__tests__/transform-carrier-prototype.test-d.d.ts +2 -0
  23. package/dist/__tests__/transform-carrier-prototype.test-d.d.ts.map +1 -0
  24. package/dist/__tests__/transform-carrier-prototype.test.d.ts +2 -0
  25. package/dist/__tests__/transform-carrier-prototype.test.d.ts.map +1 -0
  26. package/dist/__tests__/transform-component-version.unit.test.d.ts +2 -0
  27. package/dist/__tests__/transform-component-version.unit.test.d.ts.map +1 -0
  28. package/dist/assets/scene-decoder.d.ts.map +1 -1
  29. package/dist/components/child-of.d.ts +23 -14
  30. package/dist/components/child-of.d.ts.map +1 -1
  31. package/dist/components/children.d.ts +14 -13
  32. package/dist/components/children.d.ts.map +1 -1
  33. package/dist/components/transform.d.ts +7 -43
  34. package/dist/components/transform.d.ts.map +1 -1
  35. package/dist/errors.d.ts +23 -2
  36. package/dist/errors.d.ts.map +1 -1
  37. package/dist/index.d.ts +3 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.mjs +1122 -358
  40. package/dist/index.mjs.map +1 -1
  41. package/dist/instances/binding.d.ts +29 -0
  42. package/dist/instances/binding.d.ts.map +1 -0
  43. package/dist/instances/externalization.d.ts.map +1 -1
  44. package/dist/instances/scene-instances.d.ts +6 -18
  45. package/dist/instances/scene-instances.d.ts.map +1 -1
  46. package/dist/instances/state.d.ts +28 -0
  47. package/dist/instances/state.d.ts.map +1 -0
  48. package/dist/plugin.d.ts.map +1 -1
  49. package/dist/systems/hierarchy-projection.d.ts.map +1 -1
  50. package/dist/systems/index.d.ts +1 -1
  51. package/dist/systems/index.d.ts.map +1 -1
  52. package/dist/systems/propagate-transforms.d.ts +22 -2
  53. package/dist/systems/propagate-transforms.d.ts.map +1 -1
  54. package/package.json +5 -5
  55. package/src/__tests__/components.unit.test.ts +34 -2
  56. package/src/__tests__/fixtures/malformed-hierarchy-edge.ts +26 -4
  57. package/src/__tests__/flat-propagation.contract.test.ts +128 -0
  58. package/src/__tests__/flat-propagation.derived-writer.contract.test.ts +39 -0
  59. package/src/__tests__/flat-propagation.perf.test.ts +336 -0
  60. package/src/__tests__/hierarchy-propagation.perf.test.ts +149 -0
  61. package/src/__tests__/plugin.integration.test.ts +2 -2
  62. package/src/__tests__/propagation.unit.test.ts +271 -91
  63. package/src/__tests__/scene-binding.integration.test.ts +124 -0
  64. package/src/__tests__/schedule-order.contract.test.ts +43 -0
  65. package/src/__tests__/structural.test.ts +23 -0
  66. package/src/__tests__/transform-carrier-consumers.test-d.ts +156 -0
  67. package/src/__tests__/transform-carrier-prototype.bench.ts +168 -0
  68. package/src/__tests__/transform-carrier-prototype.test-d.ts +71 -0
  69. package/src/__tests__/transform-carrier-prototype.test.ts +170 -0
  70. package/src/__tests__/{transform-change-journal.unit.test.ts → transform-component-version.unit.test.ts} +32 -39
  71. package/src/assets/scene-decoder.ts +6 -1
  72. package/src/components/child-of.ts +38 -27
  73. package/src/components/children.ts +36 -37
  74. package/src/components/transform.ts +23 -81
  75. package/src/errors.ts +26 -2
  76. package/src/index.ts +10 -1
  77. package/src/instances/binding.ts +77 -0
  78. package/src/instances/externalization.ts +7 -1
  79. package/src/instances/scene-instances.ts +51 -80
  80. package/src/instances/state.ts +62 -0
  81. package/src/plugin.ts +9 -2
  82. package/src/systems/hierarchy-projection.ts +25 -8
  83. package/src/systems/index.ts +3 -0
  84. package/src/systems/propagate-transforms.ts +1142 -240
  85. package/dist/.tsbuildinfo +0 -1
  86. package/dist/__tests__/transform-change-journal.unit.test.d.ts +0 -2
  87. package/dist/__tests__/transform-change-journal.unit.test.d.ts.map +0 -1
@@ -110,7 +110,11 @@ function resolveSceneWireRefs(payload: SceneAsset, refs: readonly string[]): Sce
110
110
  // component registry.
111
111
  components[componentName] = { ...(rawFields as Record<string, unknown>) };
112
112
  }
113
- entities.push({ localId: entity.localId, components });
113
+ entities.push({
114
+ localId: entity.localId,
115
+ ...(entity.bindingKey === undefined ? {} : { bindingKey: entity.bindingKey }),
116
+ components,
117
+ });
114
118
  }
115
119
 
116
120
  const mounts = resolveMounts(payload.mounts, refs);
@@ -125,6 +129,7 @@ function resolveSceneWireRefs(payload: SceneAsset, refs: readonly string[]): Sce
125
129
  ok: true,
126
130
  value: {
127
131
  kind: 'scene',
132
+ ...(payload.sourceKey === undefined ? {} : { sourceKey: payload.sourceKey }),
128
133
  entities,
129
134
  ...(mounts.value === undefined ? {} : { mounts: mounts.value }),
130
135
  ...(skinGuids.value === undefined ? {} : { skinGuids: skinGuids.value }),
@@ -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,25 +65,34 @@ 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 `Transform.world` mat4 (child.world =
68
- * parent.world x child local). Despawning a child auto-detaches it from the
69
- * parent's Children list (relationship `onRemove` hook). Despawning the
70
- * parent does not auto-clean the child's ChildOf; the consumer removes the
71
- * stale ChildOf (`world.removeComponent`) or checks `world.get(parent, Entity)` for liveness.
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
72
+ * generic ECS requirement, so normal spawn/add/command paths materialize the
73
+ * pair without scene-specific repair. Propagation still fails closed when an
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.
72
78
  *
73
- * Because ChildOf declares a `relationship` mirror, the engine keeps the
74
- * parent's `Children.entities` reverse list consistent automatically: adding
75
- * ChildOf appends the child to the parent's Children, removing it (or
76
- * reparenting via the `exclusive` arm) prunes the stale entry. AI users no
77
- * longer hand-maintain both sides (the prior OOS-10 contract is retired).
79
+ * Because ChildOf declares a `relationship` mirror and a structural
80
+ * `Transform` requirement, the engine keeps the parent's `Children.entities`
81
+ * reverse list consistent automatically and materializes the local/world pair
82
+ * when the child did not author one: adding ChildOf appends the child to the
83
+ * parent's Children, removing it (or reparenting via the `exclusive` arm)
84
+ * prunes the stale entry. AI users no longer hand-maintain both sides (the
85
+ * prior OOS-10 contract is retired).
78
86
  * Reparenting is a plain re-add: `world.addComponent(child, { component:
79
87
  * ChildOf, data: { parent: newParent } })` on an entity that already carries
80
- * ChildOf auto-reparents (exclusive arm), or use `world.reparent(child,
81
- * 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 })`.
82
91
  *
83
92
  * @example Spawn a child entity referencing an already-spawned root:
84
- * const root = world.spawn({ component: Transform, data: {...} }).unwrap();
93
+ * const root = world.spawn(
94
+ * { component: Transform, data: {...} },
95
+ * ).unwrap();
85
96
  * const child = world.spawn(
86
97
  * { component: Transform, data: {...} },
87
98
  * { component: ChildOf, data: { parent: root } },
@@ -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:
@@ -60,30 +52,32 @@
60
52
  // generic relationship-mirror shape, not a ChildOf special case).
61
53
 
62
54
  import { defineRelationship } from '@forgeax/engine-ecs';
55
+ import { Transform } from './transform';
63
56
 
64
57
  /**
65
58
  * Hierarchy forward-list of child entities.
66
59
  *
67
60
  * `entities` is a variable-length `array<entity>` field; each element is
68
- * an `Entity` u32 the AI user pushed via
69
- * `world.push(parent, Children, 'entities', child)`. The value returned by
70
- * `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
71
63
  * `Uint32Array` snapshot rematerialised fresh on every read (D-4 no-cache);
72
- * 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.
73
67
  *
74
68
  * Invariants:
75
- * - Children is NOT consumed by `propagateTransforms` (which walks ChildOf
76
- * upward, D-P2). The forward list is for AI-user traversal / debug /
77
- * 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.
78
72
  * - Children <-> ChildOf consistency is maintained by the engine via the
79
73
  * ChildOf `relationship` mirror hook (see ./child-of.ts): adding /
80
74
  * removing / reparenting ChildOf on a child auto-updates the parent's
81
75
  * `entities` list. AI users do not hand-sync the two sides for the
82
76
  * hierarchy.
83
- * - Stored entity u32s are NOT auto-cleared when the referenced entity is
84
- * despawned (OOS-01 above); a `world.get(despawnedChild, ...)` call
85
- * returns `Result.err(...)` so AI users discover the dangling state
86
- * 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.
87
81
  *
88
82
  * @example Spawn a parent and two children via ChildOf (engine maintains Children):
89
83
  * const parent = world.spawn({ component: Transform, data: identityXf() }).unwrap();
@@ -99,8 +93,8 @@ import { defineRelationship } from '@forgeax/engine-ecs';
99
93
  * const snap = world.get(parent, Children).unwrap().entities;
100
94
  * for (let i = 0; i < snap.length; i++) {
101
95
  * const child = snap[i];
102
- * // ... consume; world.get(child, ...) surfaces a structured error
103
- * // 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.
104
98
  * }
105
99
  */
106
100
  export const { source: ChildOf, target: Children } = defineRelationship({
@@ -108,6 +102,11 @@ export const { source: ChildOf, target: Children } = defineRelationship({
108
102
  sourceField: 'parent',
109
103
  targetName: 'Children',
110
104
  targetField: 'entities',
105
+ // Every scene hierarchy node is spatial. Adding ChildOf therefore
106
+ // materializes the local/derived transform pair at the same structural
107
+ // boundary, so render- and scene-authored children cannot enter a frame
108
+ // with an incomplete hierarchy node.
109
+ sourceRequires: [Transform],
111
110
  exclusive: true,
112
111
  linkedSpawn: true,
113
112
  });
@@ -1,89 +1,31 @@
1
- // @forgeax/engine-runtime - Transform (local TRS + world mat4).
2
- //
3
- // Schema: three local array<f32, N> columns -- pos (3-vec position), quat
4
- // (4-quat rotation, component order [x, y, z, w]), scale (3-vec scale) --
5
- // plus one `world: array<f32, 16>` field carrying the resolved world-space
6
- // mat4 (column-major 16 floats, written by the propagate kernel each frame).
7
- // Inline stride-N array columns (feat-20260602) store each row's N floats
8
- // contiguously, so per-row xyz locality is native to the column layout; the
9
- // former per-axis scalar decomposition (10 f32 columns) predates
10
- // inline array columns and was retired in feat-20260709 M2.
11
- //
12
- // The world column is the SSOT for the resolved world transform: a root's
13
- // world equals its local mat4; a child's world equals parent.world x local.
14
- // AI users author the local TRS arrays and read the derived world mat4 via
15
- // the ECS column-level array view (`world.get(e, Transform).world` -> live
16
- // Float32Array of 16 column-major floats). They MUST NOT hand-write the
17
- // world column -- it is overwritten by propagate.
18
- //
19
- // charter mapping: P1 (progressive disclosure via defaults map -- AI users
20
- // spawn with data: {} and get identity local TRS + identity world mat4),
21
- // F1 (context-limited: single-import barrel; 3 array keys replace 10 scalar
22
- // keys at every spawn call-site), P4 (consistent abstraction: pos / quat /
23
- // scale array columns follow the same access pattern as `world` -- learn
24
- // the flat column form once, apply it to every array<f32, N> field),
25
- // P3 (machine-readable schema > prose).
1
+ // @forgeax/engine-runtime - authored local transform and derived world output.
26
2
 
27
3
  import { defineComponent } from '@forgeax/engine-ecs';
28
4
 
29
- // Column-major identity mat4 (16 floats) used as the world-column default so
30
- // `spawn({ component: Transform, data: {} })` lands an identity world view
31
- // before the first propagate pass writes the resolved transform.
32
5
  const IDENTITY_MAT4 = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
33
6
 
7
+ /** Scene-owned derived world transform. Only TransformPropagation writes it. */
8
+ export const GlobalTransform = defineComponent(
9
+ 'GlobalTransform',
10
+ {
11
+ world: { type: 'array<f32, 16>', default: IDENTITY_MAT4 },
12
+ },
13
+ { transient: true },
14
+ );
15
+
34
16
  /**
35
- * Transform: local position `pos` (xyz), rotation `quat` (quaternion,
36
- * component order [x, y, z, w]), scale `scale` (xyz), plus the resolved
37
- * `world` mat4 (column-major 16 floats).
38
- *
39
- * Local TRS is stored as three inline stride-N array<f32, N> columns. The
40
- * propagate kernel composes each entity's local TRS into a mat4 and writes it
41
- * into the `world` column (root: world = local; child: world = parent.world x
42
- * local) using `@forgeax/engine-math` mat4 / vec3 / quat APIs (charter P4: do
43
- * not reinvent math). MVP recomposes every frame; dirty-flag optimization is
44
- * owned by feat-future-render-world.
45
- *
46
- * The `world` column is a fixed-capacity `array<f32, 16>` (feat-20260602):
47
- * the 16 contiguous floats live inline in a stride-16 column -- no BufferPool
48
- * slot. Read it via `world.get(e, Transform).world` which returns a live
49
- * `Float32Array` aliasing the column buffer. The view is transient: it aliases
50
- * the archetype column buffer and is valid only until the next structural
51
- * change (spawn / despawn / addComponent / removeComponent). Re-fetch the view
52
- * on every access; holding a view across a structural change is undefined
53
- * behaviour (the backing `ArrayBuffer` is detached on column growth, and
54
- * swap-remove at the same row index points to the wrong entity). All existing
55
- * hot paths (propagate / render-extract / pick) already comply -- see
56
- * `packages/ecs/README.md` Transient view contract section.
57
- *
58
- * All three local columns carry explicit layer-2 defaults (identity
59
- * transform): `pos: [0, 0, 0]`, `quat: [0, 0, 0, 1]` (identity quaternion,
60
- * [x, y, z, w]), `scale: [1, 1, 1]`. quat and scale MUST stay explicit: the
61
- * layer-3 fallback for array<f32, N> is all-zero, which would land an invalid
62
- * zero quaternion / zero scale. The `world` field defaults to the identity
63
- * mat4. AI users spawn with `data: {}` or with only the fields they need to
64
- * override.
65
- *
66
- * @example Minimal spawn (local defaulted to identity, world = identity mat4):
67
- * world.spawn({ component: Transform, data: {} });
68
- *
69
- * @example Override only the position, leaving rotation/scale at identity:
70
- * world.spawn({ component: Transform, data: { pos: [0, 6, 0] } });
17
+ * Authored local position, rotation and scale columns.
71
18
  *
72
- * @example Full explicit local form (defaults are opt-in):
73
- * world.spawn({ component: Transform, data: {
74
- * pos: [1, 2, 3],
75
- * quat: [0, 0, 0, 1], // [x, y, z, w]
76
- * scale: [1, 1, 1],
77
- * } });
19
+ * The ECS `requires` declaration is the generic structural invariant: callers
20
+ * add `Transform`, while `GlobalTransform` is materialized once at spawn/add.
78
21
  */
79
- export const Transform = defineComponent('Transform', {
80
- pos: { type: 'array<f32, 3>', default: new Float32Array([0, 0, 0]) },
81
- // Component order [x, y, z, w] end to end (glTF-aligned; E6).
82
- quat: { type: 'array<f32, 4>', default: new Float32Array([0, 0, 0, 1]) },
83
- scale: { type: 'array<f32, 3>', default: new Float32Array([1, 1, 1]) },
84
- // `world` is field-level transient (D-5): scene collect skips it. The resolved
85
- // world mat4 is derived by the propagate kernel from the persisted local TRS
86
- // each frame, so serializing it would store reconstructable data (SSOT: local
87
- // TRS). Round-trip re-derives an equivalent world on the first propagate pass.
88
- world: { type: 'array<f32, 16>', default: IDENTITY_MAT4, transient: true },
89
- });
22
+ export const Transform = defineComponent(
23
+ 'Transform',
24
+ {
25
+ pos: { type: 'array<f32, 3>', default: new Float32Array([0, 0, 0]) },
26
+ // Component order [x, y, z, w] is shared with glTF.
27
+ quat: { type: 'array<f32, 4>', default: new Float32Array([0, 0, 0, 1]) },
28
+ scale: { type: 'array<f32, 3>', default: new Float32Array([1, 1, 1]) },
29
+ },
30
+ { requires: [GlobalTransform] },
31
+ );
package/src/errors.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { EntityHandle } from '@forgeax/engine-ecs';
1
+ import type { EcsError, EntityHandle } from '@forgeax/engine-ecs';
2
2
 
3
3
  export type SceneErrorCode = 'hierarchy-broken' | 'hierarchy-cycle';
4
4
 
@@ -7,11 +7,35 @@ export type SceneInstanceErrorCode = 'component-not-defined' | 'scene-override-t
7
7
 
8
8
  export { ComponentNotDefinedError } from '@forgeax/engine-ecs/projection';
9
9
 
10
- export interface SceneErrorDetail {
10
+ /** The structured ECS failure retained by a Scene derived-write diagnostic. */
11
+ export interface SceneErrorCause {
12
+ readonly code: EcsError['code'];
13
+ readonly expected?: string;
14
+ readonly hint?: string;
15
+ readonly detail?: unknown;
16
+ }
17
+
18
+ /** Location detail shared by hierarchy diagnostics and derived-write errors. */
19
+ export interface SceneHierarchyErrorDetail {
20
+ readonly kind?: 'hierarchy';
11
21
  readonly entity: EntityHandle;
12
22
  readonly parent: EntityHandle;
13
23
  }
14
24
 
25
+ /** A flat derived publication failure with its original ECS error intact. */
26
+ export interface SceneDerivedWriteErrorDetail {
27
+ readonly kind: 'derived-write';
28
+ readonly entity: EntityHandle;
29
+ readonly parent: EntityHandle;
30
+ readonly bindingIndex: number;
31
+ readonly base: number;
32
+ readonly start: number;
33
+ readonly count: number;
34
+ readonly cause: SceneErrorCause;
35
+ }
36
+
37
+ export type SceneErrorDetail = SceneHierarchyErrorDetail | SceneDerivedWriteErrorDetail;
38
+
15
39
  export class SceneError extends Error {
16
40
  readonly code: SceneErrorCode;
17
41
  readonly expected: string;
package/src/index.ts CHANGED
@@ -4,13 +4,21 @@ export { ChildOf } from './components/child-of';
4
4
  export { Children } from './components/children';
5
5
  export { MorphWeights } from './components/morph-weights';
6
6
  export { Name } from './components/name';
7
- export { Transform } from './components/transform';
7
+ export { GlobalTransform, Transform } from './components/transform';
8
8
  export {
9
9
  ComponentNotDefinedError,
10
10
  SceneError,
11
11
  type SceneErrorCode,
12
12
  type SceneInstanceErrorCode,
13
13
  } from './errors';
14
+ export {
15
+ resolveSceneEntity,
16
+ type SceneBindingDeclarationError,
17
+ type SceneBindingError,
18
+ type SceneEntityRef,
19
+ sceneEntity,
20
+ validateSceneBindings,
21
+ } from './instances/binding';
14
22
  export { SCENE_COLLECT_PROFILE, type SceneCollectProfile } from './instances/collect-profile';
15
23
  export {
16
24
  type ExternalizedSceneAsset,
@@ -44,6 +52,7 @@ export {
44
52
  worldRemoveSceneOverride,
45
53
  worldResolveMountSource,
46
54
  worldResolveSceneAsset,
55
+ worldResolveSceneEntity,
47
56
  worldResolveSceneInstanceStatePayload,
48
57
  worldSetSceneAssetResolver,
49
58
  worldSetSceneOverride,
@@ -0,0 +1,77 @@
1
+ import type { EntityHandle } from '@forgeax/engine-ecs';
2
+ import type { SceneEntityRef } from '@forgeax/engine-types';
3
+ import { err, ok, type Result } from '@forgeax/engine-types';
4
+
5
+ export type { SceneEntityRef } from '@forgeax/engine-types';
6
+
7
+ export type SceneBindingError = {
8
+ readonly code: 'scene-binding-missing' | 'scene-binding-wrong-instance';
9
+ readonly expected: string;
10
+ readonly hint: string;
11
+ readonly detail: { readonly sceneSourceKey: string; readonly bindingKey: string };
12
+ };
13
+
14
+ export type SceneBindingDeclarationError = {
15
+ readonly code: 'scene-binding-duplicate' | 'scene-binding-source-missing';
16
+ readonly expected: string;
17
+ readonly hint: string;
18
+ readonly detail: { readonly sceneSourceKey?: string; readonly bindingKey?: string };
19
+ };
20
+
21
+ export function validateSceneBindings(
22
+ sceneSourceKey: string,
23
+ bindingKeys: readonly string[],
24
+ ): Result<readonly string[], SceneBindingDeclarationError> {
25
+ if (sceneSourceKey.length === 0) {
26
+ return err({
27
+ code: 'scene-binding-source-missing',
28
+ expected: 'a non-empty scene sourceKey',
29
+ hint: 'declare the scene sourceKey in the author inventory',
30
+ detail: {},
31
+ });
32
+ }
33
+ const seen = new Set<string>();
34
+ for (const bindingKey of bindingKeys) {
35
+ if (bindingKey.length === 0 || seen.has(bindingKey)) {
36
+ return err({
37
+ code: 'scene-binding-duplicate',
38
+ expected: 'unique non-empty bindingKey values within one scene',
39
+ hint: 'rename the duplicate bindingKey in the scene producer',
40
+ detail: { sceneSourceKey, bindingKey },
41
+ });
42
+ }
43
+ seen.add(bindingKey);
44
+ }
45
+ return ok([...bindingKeys]);
46
+ }
47
+
48
+ export function sceneEntity(sceneSourceKey: string, bindingKey: string): SceneEntityRef {
49
+ return { sceneSourceKey, bindingKey };
50
+ }
51
+
52
+ export function resolveSceneEntity(
53
+ ref: SceneEntityRef,
54
+ instance: {
55
+ readonly sceneSourceKey: string;
56
+ readonly bindings: ReadonlyMap<string, EntityHandle | number>;
57
+ },
58
+ ): Result<EntityHandle | number, SceneBindingError> {
59
+ if (ref.sceneSourceKey !== instance.sceneSourceKey) {
60
+ return err({
61
+ code: 'scene-binding-wrong-instance',
62
+ expected: `scene instance ${ref.sceneSourceKey}`,
63
+ hint: 'resolve the SceneEntityRef against its owning SceneInstance',
64
+ detail: { sceneSourceKey: ref.sceneSourceKey, bindingKey: ref.bindingKey },
65
+ });
66
+ }
67
+ const value = instance.bindings.get(ref.bindingKey);
68
+ if (value === undefined) {
69
+ return err({
70
+ code: 'scene-binding-missing',
71
+ expected: 'bindingKey declared by the scene producer',
72
+ hint: 'declare the bindingKey in the scene producer before consuming it',
73
+ detail: { sceneSourceKey: ref.sceneSourceKey, bindingKey: ref.bindingKey },
74
+ });
75
+ }
76
+ return ok(value);
77
+ }
@@ -91,7 +91,11 @@ export function externalizeSceneAsset(
91
91
  components[componentName] = fields;
92
92
  }
93
93
  }
94
- return { localId: entity.localId as number, components };
94
+ return {
95
+ localId: entity.localId as number,
96
+ ...(entity.bindingKey === undefined ? {} : { bindingKey: entity.bindingKey }),
97
+ components,
98
+ };
95
99
  });
96
100
 
97
101
  const mounts = scene.mounts?.map((mount) => {
@@ -126,6 +130,8 @@ export function externalizeSceneAsset(
126
130
  }
127
131
  return ok({
128
132
  payload: {
133
+ kind: 'scene',
134
+ ...(scene.sourceKey === undefined ? {} : { sourceKey: scene.sourceKey }),
129
135
  entities,
130
136
  ...(mounts === undefined || mounts.length === 0 ? {} : { mounts }),
131
137
  ...(scene.skinGuids === undefined