@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.
- package/LICENSE +202 -0
- package/README.md +55 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/asset-owner.integration.test.d.ts +2 -0
- package/dist/__tests__/asset-owner.integration.test.d.ts.map +1 -0
- package/dist/__tests__/components.unit.test.d.ts +2 -0
- package/dist/__tests__/components.unit.test.d.ts.map +1 -0
- package/dist/__tests__/diagnostic-type-surface.test-d.d.ts +3 -0
- package/dist/__tests__/diagnostic-type-surface.test-d.d.ts.map +1 -0
- package/dist/__tests__/direct-child-of-import.unit.test.d.ts +2 -0
- package/dist/__tests__/direct-child-of-import.unit.test.d.ts.map +1 -0
- package/dist/__tests__/fixtures/malformed-hierarchy-edge.d.ts +8 -0
- package/dist/__tests__/fixtures/malformed-hierarchy-edge.d.ts.map +1 -0
- package/dist/__tests__/hierarchy-projection-contract.unit.test.d.ts +2 -0
- package/dist/__tests__/hierarchy-projection-contract.unit.test.d.ts.map +1 -0
- package/dist/__tests__/hierarchy-projection.unit.test.d.ts +2 -0
- package/dist/__tests__/hierarchy-projection.unit.test.d.ts.map +1 -0
- package/dist/__tests__/plugin.integration.test.d.ts +2 -0
- package/dist/__tests__/plugin.integration.test.d.ts.map +1 -0
- package/dist/__tests__/propagation.unit.test.d.ts +2 -0
- package/dist/__tests__/propagation.unit.test.d.ts.map +1 -0
- package/dist/__tests__/transform-change-journal.unit.test.d.ts +2 -0
- package/dist/__tests__/transform-change-journal.unit.test.d.ts.map +1 -0
- package/dist/assets/scene-decoder.d.ts +8 -0
- package/dist/assets/scene-decoder.d.ts.map +1 -0
- package/dist/collect-subtree.d.ts +4 -0
- package/dist/collect-subtree.d.ts.map +1 -0
- package/dist/components/child-of.d.ts +31 -0
- package/dist/components/child-of.d.ts.map +1 -0
- package/dist/components/children.d.ts +48 -0
- package/dist/components/children.d.ts.map +1 -0
- package/dist/components/morph-weights.d.ts +5 -0
- package/dist/components/morph-weights.d.ts.map +1 -0
- package/dist/components/name.d.ts +4 -0
- package/dist/components/name.d.ts.map +1 -0
- package/dist/components/transform.d.ts +52 -0
- package/dist/components/transform.d.ts.map +1 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1731 -0
- package/dist/index.mjs.map +1 -0
- package/dist/instances/collect-profile.d.ts +7 -0
- package/dist/instances/collect-profile.d.ts.map +1 -0
- package/dist/instances/externalization.d.ts +14 -0
- package/dist/instances/externalization.d.ts.map +1 -0
- package/dist/instances/scene-instances.d.ts +350 -0
- package/dist/instances/scene-instances.d.ts.map +1 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/systems/hierarchy-projection.d.ts +16 -0
- package/dist/systems/hierarchy-projection.d.ts.map +1 -0
- package/dist/systems/index.d.ts +3 -0
- package/dist/systems/index.d.ts.map +1 -0
- package/dist/systems/propagate-transforms.d.ts +15 -0
- package/dist/systems/propagate-transforms.d.ts.map +1 -0
- package/package.json +58 -0
- package/src/__tests__/asset-owner.integration.test.ts +57 -0
- package/src/__tests__/components.unit.test.ts +26 -0
- package/src/__tests__/diagnostic-type-surface.test-d.ts +11 -0
- package/src/__tests__/direct-child-of-import.unit.test.ts +13 -0
- package/src/__tests__/fixtures/malformed-hierarchy-edge.ts +15 -0
- package/src/__tests__/hierarchy-projection-contract.unit.test.ts +45 -0
- package/src/__tests__/hierarchy-projection.unit.test.ts +92 -0
- package/src/__tests__/plugin.integration.test.ts +23 -0
- package/src/__tests__/propagation.unit.test.ts +163 -0
- package/src/__tests__/transform-change-journal.unit.test.ts +105 -0
- package/src/assets/scene-decoder.ts +153 -0
- package/src/collect-subtree.ts +30 -0
- package/src/components/child-of.ts +90 -0
- package/src/components/children.ts +113 -0
- package/src/components/morph-weights.ts +6 -0
- package/src/components/name.ts +22 -0
- package/src/components/transform.ts +89 -0
- package/src/errors.ts +34 -0
- package/src/index.ts +65 -0
- package/src/instances/collect-profile.ts +10 -0
- package/src/instances/externalization.ts +137 -0
- package/src/instances/scene-instances.ts +1564 -0
- package/src/plugin.ts +28 -0
- package/src/systems/hierarchy-projection.ts +134 -0
- package/src/systems/index.ts +11 -0
- 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,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
|
+
}
|