@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.
- package/README.md +37 -9
- package/dist/__tests__/flat-propagation.contract.test.d.ts +2 -0
- package/dist/__tests__/flat-propagation.contract.test.d.ts.map +1 -0
- package/dist/__tests__/flat-propagation.derived-writer.contract.test.d.ts +2 -0
- package/dist/__tests__/flat-propagation.derived-writer.contract.test.d.ts.map +1 -0
- package/dist/__tests__/flat-propagation.perf.test.d.ts +2 -0
- package/dist/__tests__/flat-propagation.perf.test.d.ts.map +1 -0
- package/dist/__tests__/scene-binding.integration.test.d.ts +2 -0
- package/dist/__tests__/scene-binding.integration.test.d.ts.map +1 -0
- package/dist/__tests__/schedule-order.contract.test.d.ts +2 -0
- package/dist/__tests__/schedule-order.contract.test.d.ts.map +1 -0
- package/dist/__tests__/structural.test.d.ts +2 -0
- package/dist/__tests__/structural.test.d.ts.map +1 -0
- package/dist/__tests__/transform-carrier-consumers.test-d.d.ts +2 -0
- package/dist/__tests__/transform-carrier-consumers.test-d.d.ts.map +1 -0
- package/dist/__tests__/transform-carrier-prototype.bench.d.ts +39 -0
- package/dist/__tests__/transform-carrier-prototype.bench.d.ts.map +1 -0
- package/dist/__tests__/transform-carrier-prototype.test-d.d.ts +2 -0
- package/dist/__tests__/transform-carrier-prototype.test-d.d.ts.map +1 -0
- package/dist/__tests__/transform-carrier-prototype.test.d.ts +2 -0
- package/dist/__tests__/transform-carrier-prototype.test.d.ts.map +1 -0
- package/dist/__tests__/transform-component-version.unit.test.d.ts +2 -0
- package/dist/__tests__/transform-component-version.unit.test.d.ts.map +1 -0
- package/dist/assets/scene-decoder.d.ts.map +1 -1
- package/dist/components/child-of.d.ts +15 -8
- package/dist/components/child-of.d.ts.map +1 -1
- package/dist/components/children.d.ts.map +1 -1
- package/dist/components/transform.d.ts +7 -43
- package/dist/components/transform.d.ts.map +1 -1
- package/dist/errors.d.ts +23 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +616 -372
- package/dist/index.mjs.map +1 -1
- package/dist/instances/binding.d.ts +29 -0
- package/dist/instances/binding.d.ts.map +1 -0
- package/dist/instances/externalization.d.ts.map +1 -1
- package/dist/instances/scene-instances.d.ts +5 -17
- package/dist/instances/scene-instances.d.ts.map +1 -1
- package/dist/instances/state.d.ts +28 -0
- package/dist/instances/state.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/systems/hierarchy-projection.d.ts.map +1 -1
- package/dist/systems/index.d.ts +1 -1
- package/dist/systems/index.d.ts.map +1 -1
- package/dist/systems/propagate-transforms.d.ts +1 -2
- package/dist/systems/propagate-transforms.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/components.unit.test.ts +34 -2
- package/src/__tests__/flat-propagation.contract.test.ts +128 -0
- package/src/__tests__/flat-propagation.derived-writer.contract.test.ts +39 -0
- package/src/__tests__/flat-propagation.perf.test.ts +336 -0
- package/src/__tests__/plugin.integration.test.ts +2 -2
- package/src/__tests__/propagation.unit.test.ts +207 -91
- package/src/__tests__/scene-binding.integration.test.ts +124 -0
- package/src/__tests__/schedule-order.contract.test.ts +43 -0
- package/src/__tests__/structural.test.ts +23 -0
- package/src/__tests__/transform-carrier-consumers.test-d.ts +156 -0
- package/src/__tests__/transform-carrier-prototype.bench.ts +168 -0
- package/src/__tests__/transform-carrier-prototype.test-d.ts +71 -0
- package/src/__tests__/transform-carrier-prototype.test.ts +170 -0
- package/src/__tests__/{transform-change-journal.unit.test.ts → transform-component-version.unit.test.ts} +32 -39
- package/src/assets/scene-decoder.ts +6 -1
- package/src/components/child-of.ts +15 -8
- package/src/components/children.ts +6 -0
- package/src/components/transform.ts +23 -81
- package/src/errors.ts +26 -2
- package/src/index.ts +10 -1
- package/src/instances/binding.ts +77 -0
- package/src/instances/externalization.ts +7 -1
- package/src/instances/scene-instances.ts +47 -76
- package/src/instances/state.ts +62 -0
- package/src/plugin.ts +9 -2
- package/src/systems/hierarchy-projection.ts +25 -8
- package/src/systems/index.ts +3 -0
- package/src/systems/propagate-transforms.ts +376 -245
- package/dist/.tsbuildinfo +0 -1
- package/dist/__tests__/transform-change-journal.unit.test.d.ts +0 -2
- 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({
|
|
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 `
|
|
68
|
-
* parent.world x child local).
|
|
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
|
|
74
|
-
* parent's `Children.entities`
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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(
|
|
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 -
|
|
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
|
-
*
|
|
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
|
-
*
|
|
73
|
-
*
|
|
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(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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 {
|
|
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[] = [
|
|
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());
|