@forgeax/engine-scene 0.0.0-dev.8d955ade1c79

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 (84) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +55 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/asset-owner.integration.test.d.ts +2 -0
  5. package/dist/__tests__/asset-owner.integration.test.d.ts.map +1 -0
  6. package/dist/__tests__/components.unit.test.d.ts +2 -0
  7. package/dist/__tests__/components.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/diagnostic-type-surface.test-d.d.ts +3 -0
  9. package/dist/__tests__/diagnostic-type-surface.test-d.d.ts.map +1 -0
  10. package/dist/__tests__/direct-child-of-import.unit.test.d.ts +2 -0
  11. package/dist/__tests__/direct-child-of-import.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/fixtures/malformed-hierarchy-edge.d.ts +8 -0
  13. package/dist/__tests__/fixtures/malformed-hierarchy-edge.d.ts.map +1 -0
  14. package/dist/__tests__/hierarchy-projection-contract.unit.test.d.ts +2 -0
  15. package/dist/__tests__/hierarchy-projection-contract.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/hierarchy-projection.unit.test.d.ts +2 -0
  17. package/dist/__tests__/hierarchy-projection.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/plugin.integration.test.d.ts +2 -0
  19. package/dist/__tests__/plugin.integration.test.d.ts.map +1 -0
  20. package/dist/__tests__/propagation.unit.test.d.ts +2 -0
  21. package/dist/__tests__/propagation.unit.test.d.ts.map +1 -0
  22. package/dist/__tests__/transform-change-journal.unit.test.d.ts +2 -0
  23. package/dist/__tests__/transform-change-journal.unit.test.d.ts.map +1 -0
  24. package/dist/assets/scene-decoder.d.ts +8 -0
  25. package/dist/assets/scene-decoder.d.ts.map +1 -0
  26. package/dist/collect-subtree.d.ts +4 -0
  27. package/dist/collect-subtree.d.ts.map +1 -0
  28. package/dist/components/child-of.d.ts +31 -0
  29. package/dist/components/child-of.d.ts.map +1 -0
  30. package/dist/components/children.d.ts +48 -0
  31. package/dist/components/children.d.ts.map +1 -0
  32. package/dist/components/morph-weights.d.ts +5 -0
  33. package/dist/components/morph-weights.d.ts.map +1 -0
  34. package/dist/components/name.d.ts +4 -0
  35. package/dist/components/name.d.ts.map +1 -0
  36. package/dist/components/transform.d.ts +52 -0
  37. package/dist/components/transform.d.ts.map +1 -0
  38. package/dist/errors.d.ts +22 -0
  39. package/dist/errors.d.ts.map +1 -0
  40. package/dist/index.d.ts +15 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.mjs +1731 -0
  43. package/dist/index.mjs.map +1 -0
  44. package/dist/instances/collect-profile.d.ts +7 -0
  45. package/dist/instances/collect-profile.d.ts.map +1 -0
  46. package/dist/instances/externalization.d.ts +14 -0
  47. package/dist/instances/externalization.d.ts.map +1 -0
  48. package/dist/instances/scene-instances.d.ts +350 -0
  49. package/dist/instances/scene-instances.d.ts.map +1 -0
  50. package/dist/plugin.d.ts +3 -0
  51. package/dist/plugin.d.ts.map +1 -0
  52. package/dist/systems/hierarchy-projection.d.ts +16 -0
  53. package/dist/systems/hierarchy-projection.d.ts.map +1 -0
  54. package/dist/systems/index.d.ts +3 -0
  55. package/dist/systems/index.d.ts.map +1 -0
  56. package/dist/systems/propagate-transforms.d.ts +15 -0
  57. package/dist/systems/propagate-transforms.d.ts.map +1 -0
  58. package/package.json +58 -0
  59. package/src/__tests__/asset-owner.integration.test.ts +57 -0
  60. package/src/__tests__/components.unit.test.ts +26 -0
  61. package/src/__tests__/diagnostic-type-surface.test-d.ts +11 -0
  62. package/src/__tests__/direct-child-of-import.unit.test.ts +13 -0
  63. package/src/__tests__/fixtures/malformed-hierarchy-edge.ts +15 -0
  64. package/src/__tests__/hierarchy-projection-contract.unit.test.ts +45 -0
  65. package/src/__tests__/hierarchy-projection.unit.test.ts +92 -0
  66. package/src/__tests__/plugin.integration.test.ts +23 -0
  67. package/src/__tests__/propagation.unit.test.ts +163 -0
  68. package/src/__tests__/transform-change-journal.unit.test.ts +105 -0
  69. package/src/assets/scene-decoder.ts +153 -0
  70. package/src/collect-subtree.ts +30 -0
  71. package/src/components/child-of.ts +90 -0
  72. package/src/components/children.ts +113 -0
  73. package/src/components/morph-weights.ts +6 -0
  74. package/src/components/name.ts +22 -0
  75. package/src/components/transform.ts +89 -0
  76. package/src/errors.ts +34 -0
  77. package/src/index.ts +65 -0
  78. package/src/instances/collect-profile.ts +10 -0
  79. package/src/instances/externalization.ts +137 -0
  80. package/src/instances/scene-instances.ts +1564 -0
  81. package/src/plugin.ts +28 -0
  82. package/src/systems/hierarchy-projection.ts +134 -0
  83. package/src/systems/index.ts +11 -0
  84. package/src/systems/propagate-transforms.ts +376 -0
@@ -0,0 +1,90 @@
1
+ // @forgeax/engine-runtime - ChildOf (hierarchy back-reference; Bevy-aligned).
2
+ //
3
+ // Schema (M5 / w18): `{ parent: 'entity' }`.
4
+ // Migrated from `'ref'` (raw u32 column) to the schema-vocab `'entity'`
5
+ // keyword. The on-disk column is a Uint32Array carrying the encoded Entity
6
+ // (24-bit index + 8-bit generation). The ECS does not validate the stored
7
+ // u32 against the live record on read; `world.get(child, ChildOf)` returns
8
+ // the raw encoded Entity and consumers check liveness themselves (via
9
+ // `world.get(parent, Entity)` — returns `err(stale-entity)` for despawned handles).
10
+ //
11
+ // feat-20260531-ecs-relationship-abstraction-bidirectional-sync M4 / t20:
12
+ // ChildOf is declared as the holder side of a Bevy-style bidirectional
13
+ // relationship via the `relationship` metadata block (one-cut breaking
14
+ // migration, AGENTS.md Change stance - no v1/v2 dual-path, component name +
15
+ // schema shape + holder-perspective naming all unchanged). The engine
16
+ // auto-maintains the reverse `Children.entities` list on the parent entity at
17
+ // `world.addComponent` / `world.removeComponent` / `world.despawn` time
18
+ // (M2 bidirectional-sync hook), retiring the old OOS-10 "AI user keeps the two
19
+ // sides consistent themselves" contract.
20
+ // - mirror: 'Children' -- the reverse-list component name (string, not a
21
+ // type reference, so engine-ecs never imports the runtime ChildOf/Children
22
+ // types; AC-29). Relationship roles are resolved by the scene owner.
23
+ // - field: 'entities' -- the `array<entity>` field on Children holding the
24
+ // reverse list. Validated to be exactly `'array<entity>'` at defineComponent time.
25
+ // - exclusive: true -- re-adding ChildOf with a new parent auto-reparents
26
+ // (prunes the old parent's Children entry, appends to the new) instead of
27
+ // returning ComponentAlreadyPresentError (single-parent hierarchy, AC-12).
28
+ // - linkedSpawn: true -- despawning the parent cascade-despawns all
29
+ // child entities that hold this ChildOf (AC-08; the human gate flipped
30
+ // the D-1 default from false to true). When linkedSpawn is set to true,
31
+ // world.despawn(parent) recursively despawns the entire subtree.
32
+ //
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.
36
+ //
37
+ // Child despawn auto-detaches from the parent's Children list: the
38
+ // relationship `onRemove` hook fires on `world.despawn(child)` and prunes the
39
+ // 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).
44
+ //
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
48
+ // 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
50
+ // (the consumer is expected to despawn the subtree or remove the stale
51
+ // ChildOf), kept as the explicit-failure surface (charter proposition 4).
52
+ //
53
+ // charter mapping: proposition 2 (industry analogy: Bevy ChildOf is the
54
+ // post-0.15 rename of Parent for the same shape as glTF node parent index
55
+ // + Three.js Object3D.parent) + proposition 4 (explicit failure: dangling
56
+ // parent surfaces structured RhiError) + proposition 5
57
+ // (consistent abstraction: the schema-vocab 'entity' keyword is the SSOT
58
+ // for entity-typed columns across the engine).
59
+
60
+ export { ChildOf } from './children';
61
+
62
+ /**
63
+ * Hierarchy back-reference: pointer from child entity to its parent.
64
+ *
65
+ * 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.
72
+ *
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).
78
+ * Reparenting is a plain re-add: `world.addComponent(child, { component:
79
+ * ChildOf, data: { parent: newParent } })` on an entity that already carries
80
+ * ChildOf auto-reparents (exclusive arm), or use `world.reparent(child,
81
+ * newParent)`.
82
+ *
83
+ * @example Spawn a child entity referencing an already-spawned root:
84
+ * const root = world.spawn({ component: Transform, data: {...} }).unwrap();
85
+ * const child = world.spawn(
86
+ * { component: Transform, data: {...} },
87
+ * { component: ChildOf, data: { parent: root } },
88
+ * ).unwrap();
89
+ * // root now carries Children with [child] in its `entities` list.
90
+ */
@@ -0,0 +1,113 @@
1
+ // @forgeax/engine-runtime - Children (forward-list of child entities).
2
+ //
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).
6
+ //
7
+ // feat-20260515-buffer-array-vocab-collapse M3 / w17:
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:
17
+ //
18
+ // const snap = world.get(parent, Children).unwrap().entities;
19
+ // const liveCount = snap.length;
20
+ // for (let i = 0; i < liveCount; i++) { const child = snap[i]; ... }
21
+ //
22
+ // 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).
26
+ //
27
+ // feat-20260531-ecs-relationship-abstraction-bidirectional-sync M4 / t20:
28
+ // Children is the MIRROR side of the ChildOf relationship. Its schema is
29
+ // unchanged (the `entities: 'array<entity>'` shape is exactly what the
30
+ // relationship mirror contract requires), but the engine now maintains this
31
+ // list automatically whenever ChildOf is added / removed / reparented on a
32
+ // child entity (M2 bidirectional-sync hook on ChildOf). The prior OOS-10
33
+ // "AI users keep the two sides consistent themselves" contract is retired:
34
+ // `world.addComponent(child, ChildOf{parent})` appends `child` to
35
+ // `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.
39
+ //
40
+ // feat-20260514-ecs-children-instances-managed-buffer-array M3 / w13 (kept
41
+ // for context): migrated from the legacy `{ count: 'u32' }` advisory marker
42
+ // to the real variable-length entity-array storage path.
43
+ // - OOS-09 (prior loop): no `addChild` / `removeChild` / `removeChildren`
44
+ // Commands API. Retired this feat: `world.addChild` / `world.removeChild`
45
+ // / `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.
54
+ //
55
+ // charter mapping: proposition 2 (Bevy ChildOf+Children pair, holder
56
+ // perspective) + proposition 3 (machine-readable schema:
57
+ // `componentSchema(Children).entities === 'array<entity>'`) + proposition 4 (explicit
58
+ // failure: dangling entries surface to the AI user via `world.get(parent, Entity)` liveness probe,
59
+ // not silent drop) + proposition 5 (consistent abstraction: Children is the
60
+ // generic relationship-mirror shape, not a ChildOf special case).
61
+
62
+ import { defineRelationship } from '@forgeax/engine-ecs';
63
+
64
+ /**
65
+ * Hierarchy forward-list of child entities.
66
+ *
67
+ * `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
71
+ * `Uint32Array` snapshot rematerialised fresh on every read (D-4 no-cache);
72
+ * the snapshot's `length` equals the live element count.
73
+ *
74
+ * 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.
78
+ * - Children <-> ChildOf consistency is maintained by the engine via the
79
+ * ChildOf `relationship` mirror hook (see ./child-of.ts): adding /
80
+ * removing / reparenting ChildOf on a child auto-updates the parent's
81
+ * `entities` list. AI users do not hand-sync the two sides for the
82
+ * 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.
87
+ *
88
+ * @example Spawn a parent and two children via ChildOf (engine maintains Children):
89
+ * const parent = world.spawn({ component: Transform, data: identityXf() }).unwrap();
90
+ * const a = world.spawn(
91
+ * { component: Transform, data: identityXf() },
92
+ * { component: ChildOf, data: { parent } },
93
+ * ).unwrap();
94
+ * const b = world.spawn(
95
+ * { component: Transform, data: identityXf() },
96
+ * { component: ChildOf, data: { parent } },
97
+ * ).unwrap();
98
+ * // Read back via the read-only snapshot - engine appended a, b:
99
+ * const snap = world.get(parent, Children).unwrap().entities;
100
+ * for (let i = 0; i < snap.length; i++) {
101
+ * 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).
104
+ * }
105
+ */
106
+ export const { source: ChildOf, target: Children } = defineRelationship({
107
+ sourceName: 'ChildOf',
108
+ sourceField: 'parent',
109
+ targetName: 'Children',
110
+ targetField: 'entities',
111
+ exclusive: true,
112
+ linkedSpawn: true,
113
+ });
@@ -0,0 +1,6 @@
1
+ import { defineComponent } from '@forgeax/engine-ecs';
2
+
3
+ /** Per-entity morph weights; length is validated against the mesh target count. */
4
+ export const MorphWeights = defineComponent('MorphWeights', {
5
+ weights: { type: 'array<f32>' },
6
+ });
@@ -0,0 +1,22 @@
1
+ // @forgeax/engine-runtime --- Name component (built-in identifier).
2
+ //
3
+ // Single-field minimal skeleton: { value: 'string' }. Bare 'string' schema
4
+ // vocab keyword routes through ECS UniqueRefStore (D-R3 single-arm managed
5
+ // dispatch); the read shape is a native JS string.
6
+ //
7
+ // Lives in `runtime` rather than `ecs` because Name is a built-in *component*,
8
+ // not part of the ECS framework itself (it does not participate in archetype /
9
+ // query / world mechanics like the essential `Entity` component does). Mirrors
10
+ // Bevy's split: `Entity` lives in `bevy_ecs`; `Name` lives in `bevy_core`.
11
+ //
12
+ // Migrated from packages/ecs/src/name.ts by tweak-20260612-ecs-concept-compression
13
+ // (architecture-principles.md §1 SSOT: Name's authoritative location is the
14
+ // runtime built-in components surface, not the ECS framework barrel).
15
+ //
16
+ // Naming follows the Bevy-aligned convention locked by feat-20260513:
17
+ // single-semantic component drops the 'Component' suffix (Transform / Camera
18
+ // / DirectionalLight / Name).
19
+
20
+ import { defineComponent } from '@forgeax/engine-ecs';
21
+
22
+ export const Name = defineComponent('Name', { value: { type: 'string' } });
@@ -0,0 +1,89 @@
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).
26
+
27
+ import { defineComponent } from '@forgeax/engine-ecs';
28
+
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
+ const IDENTITY_MAT4 = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
33
+
34
+ /**
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] } });
71
+ *
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
+ * } });
78
+ */
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
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { EntityHandle } from '@forgeax/engine-ecs';
2
+
3
+ export type SceneErrorCode = 'hierarchy-broken' | 'hierarchy-cycle';
4
+
5
+ /** Scene-instantiation failures owned by the scene package. */
6
+ export type SceneInstanceErrorCode = 'component-not-defined' | 'scene-override-type-mismatch';
7
+
8
+ export { ComponentNotDefinedError } from '@forgeax/engine-ecs/projection';
9
+
10
+ export interface SceneErrorDetail {
11
+ readonly entity: EntityHandle;
12
+ readonly parent: EntityHandle;
13
+ }
14
+
15
+ export class SceneError extends Error {
16
+ readonly code: SceneErrorCode;
17
+ readonly expected: string;
18
+ readonly hint: string;
19
+ readonly detail: SceneErrorDetail | undefined;
20
+
21
+ constructor(args: {
22
+ code: SceneErrorCode;
23
+ expected: string;
24
+ hint: string;
25
+ detail?: SceneErrorDetail;
26
+ }) {
27
+ super(`[SceneError ${args.code}] expected: ${args.expected}; hint: ${args.hint}`);
28
+ this.name = 'SceneError';
29
+ this.code = args.code;
30
+ this.expected = args.expected;
31
+ this.hint = args.hint;
32
+ this.detail = args.detail;
33
+ }
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,65 @@
1
+ export { sceneAssetContribution } from './assets/scene-decoder';
2
+ export { collectSubtree } from './collect-subtree';
3
+ export { ChildOf } from './components/child-of';
4
+ export { Children } from './components/children';
5
+ export { MorphWeights } from './components/morph-weights';
6
+ export { Name } from './components/name';
7
+ export { Transform } from './components/transform';
8
+ export {
9
+ ComponentNotDefinedError,
10
+ SceneError,
11
+ type SceneErrorCode,
12
+ type SceneInstanceErrorCode,
13
+ } from './errors';
14
+ export { SCENE_COLLECT_PROFILE, type SceneCollectProfile } from './instances/collect-profile';
15
+ export {
16
+ type ExternalizedSceneAsset,
17
+ externalizeSceneAsset,
18
+ type SceneComponentSchemaResolver,
19
+ type SceneExternalizationError,
20
+ } from './instances/externalization';
21
+ export {
22
+ type SceneAssetResolver,
23
+ type SceneInstanceStatePayload,
24
+ type SceneInstantiateDiagnostic,
25
+ type SceneInstantiateFlatOk,
26
+ type SceneInstantiateOk,
27
+ type SceneMembersSpawn,
28
+ worldApplyMountOverride,
29
+ worldBuildSceneEntityComponentDatas,
30
+ worldDespawnDescendants,
31
+ worldDespawnScene,
32
+ worldDetachSceneMember,
33
+ worldGetSceneAssetForInstance,
34
+ worldGetSceneAssetResolver,
35
+ worldGetSceneInstanceState,
36
+ worldInstantiateScene,
37
+ worldInstantiateSceneAsset,
38
+ worldInstantiateSceneAssetFlat,
39
+ worldInstantiateSceneFlat,
40
+ worldInstantiateScenePayload,
41
+ worldInstantiateSceneRec,
42
+ worldMountOverridesToStateMap,
43
+ worldReattachSceneMember,
44
+ worldRemoveSceneOverride,
45
+ worldResolveMountSource,
46
+ worldResolveSceneAsset,
47
+ worldResolveSceneInstanceStatePayload,
48
+ worldSetSceneAssetResolver,
49
+ worldSetSceneOverride,
50
+ worldSpawnMountEntity,
51
+ worldSpawnSceneMembers,
52
+ worldValidateMountOverrides,
53
+ } from './instances/scene-instances';
54
+ export { scenePlugin } from './plugin';
55
+ export {
56
+ projectHierarchy,
57
+ type SceneHierarchyDiagnostic,
58
+ type SceneHierarchySnapshot,
59
+ } from './systems/hierarchy-projection';
60
+ export {
61
+ PROPAGATE_TRANSFORMS_SYSTEM,
62
+ propagateTransforms,
63
+ registerPropagateTransforms,
64
+ TransformSet,
65
+ } from './systems/propagate-transforms';
@@ -0,0 +1,10 @@
1
+ /** Immutable Scene collection policy shared by runtime collectors. */
2
+ export interface SceneCollectProfile {
3
+ readonly includeComponent: (componentName: string, transient: boolean) => boolean;
4
+ readonly includeField: (componentName: string, fieldName: string, transient: boolean) => boolean;
5
+ }
6
+
7
+ export const SCENE_COLLECT_PROFILE: SceneCollectProfile = Object.freeze({
8
+ includeComponent: (_componentName: string, transient: boolean) => !transient,
9
+ includeField: (_componentName: string, _fieldName: string, transient: boolean) => !transient,
10
+ });
@@ -0,0 +1,137 @@
1
+ import type { AssetRef, MountOverride, SceneAsset } from '@forgeax/engine-types';
2
+ import { err, ok, type Result } from '@forgeax/engine-types';
3
+
4
+ export type SceneComponentSchemaResolver = (
5
+ componentName: string,
6
+ ) => Readonly<Record<string, string>> | undefined;
7
+
8
+ export interface SceneExternalizationError {
9
+ readonly field: string;
10
+ readonly value: unknown;
11
+ }
12
+
13
+ export interface ExternalizedSceneAsset {
14
+ readonly payload: Record<string, unknown>;
15
+ readonly refs: readonly AssetRef[];
16
+ }
17
+
18
+ function sharedKind(type: string | undefined): 'one' | 'many' | undefined {
19
+ if (type?.startsWith('shared<')) return 'one';
20
+ if (type?.startsWith('array<shared<')) return 'many';
21
+ return undefined;
22
+ }
23
+
24
+ function overrideGuids(
25
+ override: MountOverride,
26
+ resolveSchema: SceneComponentSchemaResolver,
27
+ ): readonly { field: string; guid: string }[] {
28
+ const schema = resolveSchema(override.comp);
29
+ const values =
30
+ override.field !== undefined
31
+ ? [[override.field, override.value] as const]
32
+ : override.value !== null &&
33
+ typeof override.value === 'object' &&
34
+ !Array.isArray(override.value)
35
+ ? Object.entries(override.value as Record<string, unknown>)
36
+ : [];
37
+ return values.flatMap(([field, value]) => {
38
+ const kind = sharedKind(schema?.[field]);
39
+ if (kind === 'one' && typeof value === 'string') return [{ field, guid: value }];
40
+ if (kind === 'many' && Array.isArray(value)) {
41
+ return value.flatMap((item) => (typeof item === 'string' ? [{ field, guid: item }] : []));
42
+ }
43
+ return [];
44
+ });
45
+ }
46
+
47
+ /** Project a SceneAsset's shared asset fields into a payload plus indexed refs. */
48
+ export function externalizeSceneAsset(
49
+ scene: SceneAsset,
50
+ resolveSchema: SceneComponentSchemaResolver,
51
+ ): Result<ExternalizedSceneAsset, SceneExternalizationError> {
52
+ const refs: AssetRef[] = [];
53
+ const indexByGuid = new Map<string, number>();
54
+ const addRef = (
55
+ guid: string,
56
+ sourceField: NonNullable<AssetRef['sourceField']>,
57
+ sceneEntityId?: number,
58
+ ): number => {
59
+ const prior = indexByGuid.get(guid);
60
+ if (prior !== undefined) return prior;
61
+ const index = refs.length;
62
+ refs.push({ guid, sourceField, ...(sceneEntityId === undefined ? {} : { sceneEntityId }) });
63
+ indexByGuid.set(guid, index);
64
+ return index;
65
+ };
66
+
67
+ const entities = scene.entities.map((entity) => {
68
+ const components: Record<string, Record<string, unknown>> = {};
69
+ for (const componentName of Object.keys(entity.components)) {
70
+ const schema = resolveSchema(componentName);
71
+ const source = entity.components[componentName] as Record<string, unknown> | undefined;
72
+ if (source === undefined) continue;
73
+ const fields: Record<string, unknown> = {};
74
+ for (const fieldName of Object.keys(source)) {
75
+ const value = source[fieldName];
76
+ if (value === undefined) continue;
77
+ const kind = sharedKind(schema?.[fieldName]);
78
+ if (kind === 'one' && typeof value === 'string') {
79
+ fields[fieldName] = addRef(value, { componentName, fieldName }, entity.localId as number);
80
+ } else if (kind === 'many' && Array.isArray(value)) {
81
+ fields[fieldName] = value.map((item, arrayIndex) =>
82
+ typeof item === 'string'
83
+ ? addRef(item, { componentName, fieldName, arrayIndex }, entity.localId as number)
84
+ : item,
85
+ );
86
+ } else {
87
+ fields[fieldName] = value;
88
+ }
89
+ }
90
+ if (Object.keys(fields).length > 0 || Object.keys(schema ?? {}).length === 0) {
91
+ components[componentName] = fields;
92
+ }
93
+ }
94
+ return { localId: entity.localId as number, components };
95
+ });
96
+
97
+ const mounts = scene.mounts?.map((mount) => {
98
+ const source =
99
+ typeof mount.source === 'string'
100
+ ? addRef(
101
+ mount.source,
102
+ { componentName: 'SceneInstance', fieldName: 'source' },
103
+ mount.localId as number,
104
+ )
105
+ : (mount.source as number);
106
+ for (const { field, guid } of (mount.overrides ?? []).flatMap((override) =>
107
+ overrideGuids(override, resolveSchema),
108
+ )) {
109
+ addRef(guid, { componentName: 'SceneInstance', fieldName: `overrides.${field}` });
110
+ }
111
+ return {
112
+ localId: mount.localId as number,
113
+ source,
114
+ memberFirst: mount.memberFirst as number,
115
+ memberCount: mount.memberCount,
116
+ ...(mount.parent === undefined ? {} : { parent: mount.parent as number }),
117
+ ...(mount.publicationFence === undefined ? {} : { publicationFence: mount.publicationFence }),
118
+ ...(mount.overrides === undefined
119
+ ? {}
120
+ : { overrides: mount.overrides.map((item) => ({ ...item })) }),
121
+ };
122
+ });
123
+ for (const [arrayIndex, guid] of (scene.skinGuids ?? []).entries()) {
124
+ if (typeof guid !== 'string') return err({ field: 'skinGuids', value: guid });
125
+ addRef(guid, { componentName: '<scene>', fieldName: 'skinGuids', arrayIndex });
126
+ }
127
+ return ok({
128
+ payload: {
129
+ entities,
130
+ ...(mounts === undefined || mounts.length === 0 ? {} : { mounts }),
131
+ ...(scene.skinGuids === undefined
132
+ ? {}
133
+ : { skinGuids: scene.skinGuids.map((guid) => indexByGuid.get(guid) as number) }),
134
+ },
135
+ refs,
136
+ });
137
+ }