@forgeax/engine-scene 0.1.20 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +37 -9
  2. package/dist/__tests__/flat-propagation.contract.test.d.ts +2 -0
  3. package/dist/__tests__/flat-propagation.contract.test.d.ts.map +1 -0
  4. package/dist/__tests__/flat-propagation.derived-writer.contract.test.d.ts +2 -0
  5. package/dist/__tests__/flat-propagation.derived-writer.contract.test.d.ts.map +1 -0
  6. package/dist/__tests__/flat-propagation.perf.test.d.ts +2 -0
  7. package/dist/__tests__/flat-propagation.perf.test.d.ts.map +1 -0
  8. package/dist/__tests__/scene-binding.integration.test.d.ts +2 -0
  9. package/dist/__tests__/scene-binding.integration.test.d.ts.map +1 -0
  10. package/dist/__tests__/schedule-order.contract.test.d.ts +2 -0
  11. package/dist/__tests__/schedule-order.contract.test.d.ts.map +1 -0
  12. package/dist/__tests__/structural.test.d.ts +2 -0
  13. package/dist/__tests__/structural.test.d.ts.map +1 -0
  14. package/dist/__tests__/transform-carrier-consumers.test-d.d.ts +2 -0
  15. package/dist/__tests__/transform-carrier-consumers.test-d.d.ts.map +1 -0
  16. package/dist/__tests__/transform-carrier-prototype.bench.d.ts +39 -0
  17. package/dist/__tests__/transform-carrier-prototype.bench.d.ts.map +1 -0
  18. package/dist/__tests__/transform-carrier-prototype.test-d.d.ts +2 -0
  19. package/dist/__tests__/transform-carrier-prototype.test-d.d.ts.map +1 -0
  20. package/dist/__tests__/transform-carrier-prototype.test.d.ts +2 -0
  21. package/dist/__tests__/transform-carrier-prototype.test.d.ts.map +1 -0
  22. package/dist/__tests__/transform-component-version.unit.test.d.ts +2 -0
  23. package/dist/__tests__/transform-component-version.unit.test.d.ts.map +1 -0
  24. package/dist/assets/scene-decoder.d.ts.map +1 -1
  25. package/dist/components/child-of.d.ts +15 -8
  26. package/dist/components/child-of.d.ts.map +1 -1
  27. package/dist/components/children.d.ts.map +1 -1
  28. package/dist/components/transform.d.ts +7 -43
  29. package/dist/components/transform.d.ts.map +1 -1
  30. package/dist/errors.d.ts +23 -2
  31. package/dist/errors.d.ts.map +1 -1
  32. package/dist/index.d.ts +3 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.mjs +616 -372
  35. package/dist/index.mjs.map +1 -1
  36. package/dist/instances/binding.d.ts +29 -0
  37. package/dist/instances/binding.d.ts.map +1 -0
  38. package/dist/instances/externalization.d.ts.map +1 -1
  39. package/dist/instances/scene-instances.d.ts +5 -17
  40. package/dist/instances/scene-instances.d.ts.map +1 -1
  41. package/dist/instances/state.d.ts +28 -0
  42. package/dist/instances/state.d.ts.map +1 -0
  43. package/dist/plugin.d.ts.map +1 -1
  44. package/dist/systems/hierarchy-projection.d.ts.map +1 -1
  45. package/dist/systems/index.d.ts +1 -1
  46. package/dist/systems/index.d.ts.map +1 -1
  47. package/dist/systems/propagate-transforms.d.ts +1 -2
  48. package/dist/systems/propagate-transforms.d.ts.map +1 -1
  49. package/package.json +5 -5
  50. package/src/__tests__/components.unit.test.ts +34 -2
  51. package/src/__tests__/flat-propagation.contract.test.ts +128 -0
  52. package/src/__tests__/flat-propagation.derived-writer.contract.test.ts +39 -0
  53. package/src/__tests__/flat-propagation.perf.test.ts +336 -0
  54. package/src/__tests__/plugin.integration.test.ts +2 -2
  55. package/src/__tests__/propagation.unit.test.ts +207 -91
  56. package/src/__tests__/scene-binding.integration.test.ts +124 -0
  57. package/src/__tests__/schedule-order.contract.test.ts +43 -0
  58. package/src/__tests__/structural.test.ts +23 -0
  59. package/src/__tests__/transform-carrier-consumers.test-d.ts +156 -0
  60. package/src/__tests__/transform-carrier-prototype.bench.ts +168 -0
  61. package/src/__tests__/transform-carrier-prototype.test-d.ts +71 -0
  62. package/src/__tests__/transform-carrier-prototype.test.ts +170 -0
  63. package/src/__tests__/{transform-change-journal.unit.test.ts → transform-component-version.unit.test.ts} +32 -39
  64. package/src/assets/scene-decoder.ts +6 -1
  65. package/src/components/child-of.ts +15 -8
  66. package/src/components/children.ts +6 -0
  67. package/src/components/transform.ts +23 -81
  68. package/src/errors.ts +26 -2
  69. package/src/index.ts +10 -1
  70. package/src/instances/binding.ts +77 -0
  71. package/src/instances/externalization.ts +7 -1
  72. package/src/instances/scene-instances.ts +47 -76
  73. package/src/instances/state.ts +62 -0
  74. package/src/plugin.ts +9 -2
  75. package/src/systems/hierarchy-projection.ts +25 -8
  76. package/src/systems/index.ts +3 -0
  77. package/src/systems/propagate-transforms.ts +376 -245
  78. package/dist/.tsbuildinfo +0 -1
  79. package/dist/__tests__/transform-change-journal.unit.test.d.ts +0 -2
  80. package/dist/__tests__/transform-change-journal.unit.test.d.ts.map +0 -1
@@ -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 }),
@@ -64,24 +64,31 @@ export { ChildOf } from './children';
64
64
  *
65
65
  * Store the parent `Entity` handle (returned by `world.spawn(...).unwrap()`)
66
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
67
+ * compose the child's derived `GlobalTransform.world` mat4 (child.world =
68
+ * parent.world x child local). `Transform` declares `GlobalTransform` as a
69
+ * generic ECS requirement, so normal spawn/add/command paths materialize the
70
+ * pair without scene-specific repair. Propagation still fails closed when an
71
+ * owner explicitly removes one half. Despawning a child auto-detaches it from the
69
72
  * parent's Children list (relationship `onRemove` hook). Despawning the
70
73
  * parent does not auto-clean the child's ChildOf; the consumer removes the
71
74
  * stale ChildOf (`world.removeComponent`) or checks `world.get(parent, Entity)` for liveness.
72
75
  *
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).
76
+ * Because ChildOf declares a `relationship` mirror and a structural
77
+ * `Transform` requirement, the engine keeps the parent's `Children.entities`
78
+ * reverse list consistent automatically and materializes the local/world pair
79
+ * when the child did not author one: adding ChildOf appends the child to the
80
+ * parent's Children, removing it (or reparenting via the `exclusive` arm)
81
+ * prunes the stale entry. AI users no longer hand-maintain both sides (the
82
+ * prior OOS-10 contract is retired).
78
83
  * Reparenting is a plain re-add: `world.addComponent(child, { component:
79
84
  * ChildOf, data: { parent: newParent } })` on an entity that already carries
80
85
  * ChildOf auto-reparents (exclusive arm), or use `world.reparent(child,
81
86
  * newParent)`.
82
87
  *
83
88
  * @example Spawn a child entity referencing an already-spawned root:
84
- * const root = world.spawn({ component: Transform, data: {...} }).unwrap();
89
+ * const root = world.spawn(
90
+ * { component: Transform, data: {...} },
91
+ * ).unwrap();
85
92
  * const child = world.spawn(
86
93
  * { component: Transform, data: {...} },
87
94
  * { component: ChildOf, data: { parent: root } },
@@ -60,6 +60,7 @@
60
60
  // generic relationship-mirror shape, not a ChildOf special case).
61
61
 
62
62
  import { defineRelationship } from '@forgeax/engine-ecs';
63
+ import { Transform } from './transform';
63
64
 
64
65
  /**
65
66
  * Hierarchy forward-list of child entities.
@@ -108,6 +109,11 @@ export const { source: ChildOf, target: Children } = defineRelationship({
108
109
  sourceField: 'parent',
109
110
  targetName: 'Children',
110
111
  targetField: 'entities',
112
+ // Every scene hierarchy node is spatial. Adding ChildOf therefore
113
+ // materializes the local/derived transform pair at the same structural
114
+ // boundary, so render- and scene-authored children cannot enter a frame
115
+ // with an incomplete hierarchy node.
116
+ sourceRequires: [Transform],
111
117
  exclusive: true,
112
118
  linkedSpawn: true,
113
119
  });
@@ -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
@@ -21,6 +21,7 @@ import type {
21
21
  PackErrorCode,
22
22
  PackErrorDetail,
23
23
  SceneAsset,
24
+ SceneEntityRef,
24
25
  SceneInstanceMount,
25
26
  } from '@forgeax/engine-types';
26
27
  import {
@@ -32,6 +33,16 @@ import {
32
33
  unwrapHandle,
33
34
  } from '@forgeax/engine-types';
34
35
  import { ComponentNotDefinedError } from '../errors';
36
+ import { resolveSceneEntity, validateSceneBindings } from './binding.js';
37
+ import {
38
+ isPrimitiveScalarFieldType,
39
+ mountOverrideStateKey,
40
+ primitiveJsType,
41
+ type SceneInstanceStatePayload,
42
+ sceneWorldState,
43
+ } from './state.js';
44
+
45
+ export type { SceneInstanceStatePayload } from './state.js';
35
46
 
36
47
  const entityIndex = (entity: EntityHandle): number => (entity as number) & 0x00ffffff;
37
48
  const entityGeneration = (entity: EntityHandle): number => ((entity as number) >>> 24) & 0xff;
@@ -136,21 +147,6 @@ export type SceneAssetResolver = (
136
147
  parentHandle: Handle<'SceneAsset', 'shared'>,
137
148
  ) => Result<Handle<'SceneAsset', 'shared'>, unknown>;
138
149
 
139
- interface SceneWorldState {
140
- resolver: SceneAssetResolver | null;
141
- readonly statePayloads: Map<number, unknown>;
142
- }
143
-
144
- const sceneWorldStates = new WeakMap<World, SceneWorldState>();
145
-
146
- function sceneWorldState(world: World): SceneWorldState {
147
- const current = sceneWorldStates.get(world);
148
- if (current !== undefined) return current;
149
- const created = { resolver: null, statePayloads: new Map<number, unknown>() };
150
- sceneWorldStates.set(world, created);
151
- return created;
152
- }
153
-
154
150
  /** @internal */
155
151
  export function worldSetSceneAssetResolver(world: World, resolver: SceneAssetResolver): void {
156
152
  sceneWorldState(world).resolver = resolver;
@@ -158,7 +154,7 @@ export function worldSetSceneAssetResolver(world: World, resolver: SceneAssetRes
158
154
 
159
155
  /** @internal */
160
156
  export function worldGetSceneAssetResolver(world: World): SceneAssetResolver | null {
161
- return sceneWorldState(world).resolver;
157
+ return sceneWorldState(world).resolver as SceneAssetResolver | null;
162
158
  }
163
159
 
164
160
  /**
@@ -339,6 +335,15 @@ export function worldSpawnSceneMembers(
339
335
 
340
336
  const ownEntities = asset.entities;
341
337
  const ownMounts = asset.mounts ?? [];
338
+ const bindingKeys = ownEntities.flatMap((entity) =>
339
+ entity.bindingKey === undefined ? [] : [entity.bindingKey],
340
+ );
341
+ if (bindingKeys.length > 0) {
342
+ const bindingCheck = validateSceneBindings(asset.sourceKey ?? '', bindingKeys);
343
+ if (!bindingCheck.ok) {
344
+ return err(bindingCheck.error as unknown as EcsError);
345
+ }
346
+ }
342
347
  const memberSum = ownMounts.reduce((s, m) => s + m.memberCount, 0);
343
348
  const countBaseline = ownEntities.length + ownMounts.length + memberSum;
344
349
  // C-R1 (studio-issues #6): mapping table must be sized to maxLocalId+1,
@@ -701,8 +706,18 @@ export function worldInstantiateSceneAsset(
701
706
  }
702
707
 
703
708
  const detached = new Set<LocalEntityId>();
709
+ const bindings = new Map<string, EntityHandle>();
710
+ for (const entity of asset.entities) {
711
+ if (entity.bindingKey === undefined) continue;
712
+ const live = mapping[entity.localId as unknown as number];
713
+ if (live !== undefined && live !== ENTITY_NULL_RAW) {
714
+ bindings.set(entity.bindingKey, live as unknown as EntityHandle);
715
+ }
716
+ }
704
717
  const state: Record<string, unknown> = {
705
718
  source: handle,
719
+ sceneSourceKey: asset.sourceKey,
720
+ bindings,
706
721
  entityToLocalId,
707
722
  detachedLocalIds: detached,
708
723
  // Convert overrides Map<LocalEntityId, Map<string, MountOverride>>
@@ -1179,6 +1194,22 @@ export function worldGetSceneInstanceState(
1179
1194
  ): Result<SceneInstanceStatePayload, EcsError> {
1180
1195
  return worldResolveSceneInstanceStatePayload(world, root);
1181
1196
  }
1197
+
1198
+ /** Resolve a generated SceneEntityRef against one concrete SceneInstance. */
1199
+ export function worldResolveSceneEntity(
1200
+ world: World,
1201
+ root: EntityHandle,
1202
+ ref: SceneEntityRef,
1203
+ ): Result<EntityHandle, EcsError> {
1204
+ const state = worldResolveSceneInstanceStatePayload(world, root);
1205
+ if (!state.ok) return state;
1206
+ const resolved = resolveSceneEntity(ref, {
1207
+ sceneSourceKey: state.value.sceneSourceKey ?? ref.sceneSourceKey,
1208
+ bindings: state.value.bindings,
1209
+ });
1210
+ if (!resolved.ok) return err(resolved.error as unknown as EcsError);
1211
+ return ok(resolved.value as EntityHandle);
1212
+ }
1182
1213
  /**
1183
1214
  * Despawn a SceneInstance root + all its members. `opts.keepDetached`
1184
1215
  * preserves members marked via `worldDetachSceneMember` (plan-strategy
@@ -1452,27 +1483,6 @@ export function worldGetSceneAssetForInstance(
1452
1483
  return ok(stateRes.value.source);
1453
1484
  }
1454
1485
 
1455
- // SceneInstanceStatePayload — internal echo of the runtime
1456
- // `SceneInstanceState` interface for ECS-side consumption (engine-ecs cannot
1457
- // value-import engine-runtime by AC-29; structural shape only).
1458
- // ────────────────────────────────────────────────────────────────────────────
1459
-
1460
- /** @internal Structural payload behind `SceneInstance.state` ref column. */
1461
- export interface SceneInstanceStatePayload {
1462
- readonly source: Handle<'SceneAsset', 'shared'>;
1463
- readonly entityToLocalId: Map<EntityHandle, LocalEntityId>;
1464
- readonly detachedLocalIds: Set<LocalEntityId>;
1465
- readonly overrides: Map<
1466
- LocalEntityId,
1467
- Map<string, { readonly comp: string; readonly field?: string; readonly value: unknown }>
1468
- >;
1469
- readonly rootEntities: EntityHandle[];
1470
- /** Synthetic roots of recursively mounted SceneAssets owned by this instance. */
1471
- readonly mountRoots: EntityHandle[];
1472
- readonly totalSlots: number;
1473
- readonly mountTimeOverrides: readonly MountOverride[];
1474
- }
1475
-
1476
1486
  /**
1477
1487
  * Topological sort over the implicit ChildOf graph (parents before children).
1478
1488
  * Cycle-free input always covers all n nodes; cyclic input emits whatever was
@@ -1523,42 +1533,3 @@ function sceneTopoSort(
1523
1533
  }
1524
1534
  return order;
1525
1535
  }
1526
-
1527
- /**
1528
- * @internal feat-20260713 M2 / w8: SceneInstanceState map key for a
1529
- * MountOverride. Field-patch form keys by `comp:field` (one entry per patched
1530
- * field); component-add form keys by `comp` (one entry per added component). The
1531
- * two key shapes cannot collide because a field-patch always carries a `:field`
1532
- * suffix. Later array entries for the same key overwrite earlier ones, matching
1533
- * the array-order apply semantics.
1534
- */
1535
- function mountOverrideStateKey(ov: MountOverride): string {
1536
- return ov.field !== undefined ? `${ov.comp}:${ov.field}` : ov.comp;
1537
- }
1538
-
1539
- /** Schema field types that are JS primitives (typeof checkable). */
1540
- function isPrimitiveScalarFieldType(fieldType: string): boolean {
1541
- if (
1542
- fieldType === 'f32' ||
1543
- fieldType === 'f64' ||
1544
- fieldType === 'u32' ||
1545
- fieldType === 'i32' ||
1546
- fieldType === 'u8' ||
1547
- fieldType === 'i8' ||
1548
- fieldType === 'u16' ||
1549
- fieldType === 'i16' ||
1550
- fieldType === 'bool' ||
1551
- fieldType === 'string'
1552
- ) {
1553
- return true;
1554
- }
1555
- if (fieldType.startsWith('enum<')) return true;
1556
- return false;
1557
- }
1558
-
1559
- /** Map a primitive scalar field type to the runtime `typeof` it should narrow to. */
1560
- function primitiveJsType(fieldType: string): string {
1561
- if (fieldType === 'bool') return 'boolean';
1562
- if (fieldType === 'string') return 'string';
1563
- return 'number';
1564
- }
@@ -0,0 +1,62 @@
1
+ import type { EntityHandle, World } from '@forgeax/engine-ecs';
2
+ import type { Handle, LocalEntityId, MountOverride } from '@forgeax/engine-types';
3
+
4
+ /** Internal state retained by a SceneInstance root. */
5
+ export interface SceneInstanceStatePayload {
6
+ readonly source: Handle<'SceneAsset', 'shared'>;
7
+ readonly sceneSourceKey?: string;
8
+ readonly bindings: Map<string, EntityHandle>;
9
+ readonly entityToLocalId: Map<EntityHandle, LocalEntityId>;
10
+ readonly detachedLocalIds: Set<LocalEntityId>;
11
+ readonly overrides: Map<
12
+ LocalEntityId,
13
+ Map<string, { readonly comp: string; readonly field?: string; readonly value: unknown }>
14
+ >;
15
+ readonly rootEntities: EntityHandle[];
16
+ readonly mountRoots: EntityHandle[];
17
+ readonly totalSlots: number;
18
+ readonly mountTimeOverrides: readonly MountOverride[];
19
+ }
20
+
21
+ export interface SceneWorldState {
22
+ resolver: unknown;
23
+ readonly statePayloads: Map<number, unknown>;
24
+ }
25
+
26
+ const sceneWorldStates = new WeakMap<World, SceneWorldState>();
27
+
28
+ export function sceneWorldState(world: World): SceneWorldState {
29
+ const current = sceneWorldStates.get(world);
30
+ if (current !== undefined) return current;
31
+ const created: SceneWorldState = { resolver: null, statePayloads: new Map<number, unknown>() };
32
+ sceneWorldStates.set(world, created);
33
+ return created;
34
+ }
35
+
36
+ export function mountOverrideStateKey(ov: MountOverride): string {
37
+ return ov.field !== undefined ? `${ov.comp}:${ov.field}` : ov.comp;
38
+ }
39
+
40
+ export function isPrimitiveScalarFieldType(fieldType: string): boolean {
41
+ if (
42
+ fieldType === 'f32' ||
43
+ fieldType === 'f64' ||
44
+ fieldType === 'u32' ||
45
+ fieldType === 'i32' ||
46
+ fieldType === 'u8' ||
47
+ fieldType === 'i8' ||
48
+ fieldType === 'u16' ||
49
+ fieldType === 'i16' ||
50
+ fieldType === 'bool' ||
51
+ fieldType === 'string'
52
+ ) {
53
+ return true;
54
+ }
55
+ return fieldType.startsWith('enum<');
56
+ }
57
+
58
+ export function primitiveJsType(fieldType: string): string {
59
+ if (fieldType === 'bool') return 'boolean';
60
+ if (fieldType === 'string') return 'string';
61
+ return 'number';
62
+ }
package/src/plugin.ts CHANGED
@@ -4,10 +4,17 @@ import { ChildOf } from './components/child-of';
4
4
  import { Children } from './components/children';
5
5
  import { MorphWeights } from './components/morph-weights';
6
6
  import { Name } from './components/name';
7
- import { Transform } from './components/transform';
7
+ import { GlobalTransform, Transform } from './components/transform';
8
8
  import { registerPropagateTransforms } from './systems/propagate-transforms';
9
9
 
10
- const SCENE_COMPONENTS: readonly Component[] = [ChildOf, Children, MorphWeights, Name, Transform];
10
+ const SCENE_COMPONENTS: readonly Component[] = [
11
+ ChildOf,
12
+ Children,
13
+ MorphWeights,
14
+ Name,
15
+ Transform,
16
+ GlobalTransform,
17
+ ];
11
18
 
12
19
  function registerSceneComponents(world: World): () => void {
13
20
  const leases = SCENE_COMPONENTS.map((component) => world.components.register(component).unwrap());