@vgai/engine 0.2.0

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 (147) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +35 -0
  3. package/package.json +55 -0
  4. package/src/adapter/authoring.ts +402 -0
  5. package/src/adapter/colyseus-networking-adapter.ts +72 -0
  6. package/src/adapter/first-party-systems.ts +103 -0
  7. package/src/adapter/game-adapter.ts +151 -0
  8. package/src/adapter/host-context.ts +77 -0
  9. package/src/adapter/index.ts +85 -0
  10. package/src/adapter/ingest/game-contract.ts +59 -0
  11. package/src/adapter/ingest/overlay-applier.ts +207 -0
  12. package/src/adapter/ingest/overlay-apply.ts +124 -0
  13. package/src/adapter/ingest/overlay-file.ts +126 -0
  14. package/src/adapter/ingest/overlay-report.ts +176 -0
  15. package/src/adapter/ingest/scene-capture.ts +307 -0
  16. package/src/adapter/ingest/upstream-pin.ts +52 -0
  17. package/src/adapter/loop-gate-report.ts +54 -0
  18. package/src/adapter/rapier-physics-adapter.ts +56 -0
  19. package/src/adapter/system-adapter.ts +154 -0
  20. package/src/adapter/transform.ts +18 -0
  21. package/src/adapter/vgai-scene-game-adapter.ts +886 -0
  22. package/src/adapter/world-kind.ts +34 -0
  23. package/src/ai/navigation.ts +164 -0
  24. package/src/animation/anim-graph-types.ts +56 -0
  25. package/src/animation/anim-graph.ts +406 -0
  26. package/src/animation/anim-system.ts +28 -0
  27. package/src/animation/blend-node.ts +119 -0
  28. package/src/animation/property-track.ts +178 -0
  29. package/src/animation/schema.ts +204 -0
  30. package/src/assets.ts +80 -0
  31. package/src/audio/ambient.ts +300 -0
  32. package/src/audio/impacts.ts +212 -0
  33. package/src/audio/index.ts +7 -0
  34. package/src/audio/movement.ts +140 -0
  35. package/src/audio/musical.ts +200 -0
  36. package/src/audio/ui-sounds.ts +171 -0
  37. package/src/audio/vehicle.ts +235 -0
  38. package/src/audio/weapons.ts +152 -0
  39. package/src/core/game-loop.ts +127 -0
  40. package/src/core/system-runner.ts +298 -0
  41. package/src/core/types.ts +58 -0
  42. package/src/dev/console-bridge.ts +83 -0
  43. package/src/dev/debug-draw.ts +80 -0
  44. package/src/dev/logger.ts +119 -0
  45. package/src/ecs/component-manager.ts +748 -0
  46. package/src/ecs/game-component.ts +147 -0
  47. package/src/ecs/hmr-swap-report.ts +65 -0
  48. package/src/input/input-manager.ts +439 -0
  49. package/src/input/input-types.ts +19 -0
  50. package/src/input/schema.ts +129 -0
  51. package/src/loader.ts +70 -0
  52. package/src/manifest/index.ts +24 -0
  53. package/src/manifest/load-file.ts +16 -0
  54. package/src/manifest/load.ts +378 -0
  55. package/src/manifest/schema.ts +375 -0
  56. package/src/physics/collision-system.ts +76 -0
  57. package/src/physics/physics-registry.ts +83 -0
  58. package/src/physics/transform-writer.ts +41 -0
  59. package/src/physics/trigger-dispatch.ts +97 -0
  60. package/src/react/game-state.tsx +172 -0
  61. package/src/render/auto-batcher.ts +169 -0
  62. package/src/render/render-batch-system.ts +268 -0
  63. package/src/render/render-features.ts +146 -0
  64. package/src/render/render-settings.ts +72 -0
  65. package/src/runtime/create-runtime.ts +1152 -0
  66. package/src/runtime/frame-selector-cache.ts +81 -0
  67. package/src/runtime/game.ts +1003 -0
  68. package/src/runtime/input-router.ts +213 -0
  69. package/src/runtime/mount-game.ts +269 -0
  70. package/src/runtime/mount-manifest.ts +361 -0
  71. package/src/runtime/scene-ui-bridge.ts +86 -0
  72. package/src/runtime/scene-ui-data.ts +119 -0
  73. package/src/runtime/state-bridge.ts +79 -0
  74. package/src/runtime/types.ts +196 -0
  75. package/src/scene/asset-loaders.ts +195 -0
  76. package/src/scene/asset-paths.ts +123 -0
  77. package/src/scene/asset-registry.ts +67 -0
  78. package/src/scene/collider-dimensions.ts +125 -0
  79. package/src/scene/component-registry.ts +40 -0
  80. package/src/scene/defaults.ts +164 -0
  81. package/src/scene/geometries/index.ts +7 -0
  82. package/src/scene/geometries/terrain.ts +42 -0
  83. package/src/scene/geometry-registry.ts +42 -0
  84. package/src/scene/instance-registry.ts +84 -0
  85. package/src/scene/instancers/grid.ts +38 -0
  86. package/src/scene/instancers/index.ts +7 -0
  87. package/src/scene/light-camera-factory.ts +97 -0
  88. package/src/scene/material-factory.ts +211 -0
  89. package/src/scene/material-registry.ts +73 -0
  90. package/src/scene/materials/index.ts +7 -0
  91. package/src/scene/materials/water.ts +56 -0
  92. package/src/scene/parse.ts +71 -0
  93. package/src/scene/particles-factory.ts +383 -0
  94. package/src/scene/scene-apply.ts +356 -0
  95. package/src/scene/scene-diff-schema.ts +115 -0
  96. package/src/scene/scene-diff-types.ts +29 -0
  97. package/src/scene/scene-loader.ts +1533 -0
  98. package/src/scene/scene-query.ts +63 -0
  99. package/src/scene/scene-types.ts +34 -0
  100. package/src/scene/scene-version.ts +40 -0
  101. package/src/scene/schema/animation.ts +95 -0
  102. package/src/scene/schema/audio.ts +25 -0
  103. package/src/scene/schema/camera.ts +21 -0
  104. package/src/scene/schema/collider.ts +69 -0
  105. package/src/scene/schema/entity-ref.ts +78 -0
  106. package/src/scene/schema/entity.ts +169 -0
  107. package/src/scene/schema/environment.ts +384 -0
  108. package/src/scene/schema/index.ts +95 -0
  109. package/src/scene/schema/instances.ts +35 -0
  110. package/src/scene/schema/joint.ts +26 -0
  111. package/src/scene/schema/light.ts +38 -0
  112. package/src/scene/schema/material.ts +113 -0
  113. package/src/scene/schema/mesh.ts +108 -0
  114. package/src/scene/schema/particles.ts +398 -0
  115. package/src/scene/schema/physics.ts +49 -0
  116. package/src/scene/schema/scene-file.ts +299 -0
  117. package/src/scene/schema/shadow.ts +24 -0
  118. package/src/scene/schema/spline.ts +21 -0
  119. package/src/scene/schema/tuples.ts +21 -0
  120. package/src/scene/schema/ui.ts +602 -0
  121. package/src/scene/user-data.ts +203 -0
  122. package/src/setup/setup-audio.ts +60 -0
  123. package/src/setup/setup-particles.ts +23 -0
  124. package/src/setup/setup-physics.ts +67 -0
  125. package/src/setup/setup-renderer.ts +529 -0
  126. package/src/types-n8ao.d.ts +37 -0
  127. package/src/types-realism-effects.d.ts +61 -0
  128. package/src/world2d/authoring-2d.ts +208 -0
  129. package/src/world2d/capture-to-scene2d.ts +52 -0
  130. package/src/world2d/collision-2d.ts +106 -0
  131. package/src/world2d/components-2d.ts +86 -0
  132. package/src/world2d/index.ts +66 -0
  133. package/src/world2d/ingest-iframe-2d.ts +255 -0
  134. package/src/world2d/ingest2d.ts +131 -0
  135. package/src/world2d/physics2d-registry.ts +49 -0
  136. package/src/world2d/pixi-game-adapter.ts +325 -0
  137. package/src/world2d/pixi-surface.ts +78 -0
  138. package/src/world2d/scene-capture-2d.ts +117 -0
  139. package/src/world2d/scene2d-loader.ts +308 -0
  140. package/src/world2d/schema/entity2d.ts +145 -0
  141. package/src/world2d/schema/physics2d.ts +53 -0
  142. package/src/world2d/schema/sprite.ts +71 -0
  143. package/src/world2d/schema/tilemap.ts +22 -0
  144. package/src/world2d/schema/tuples2d.ts +25 -0
  145. package/src/world2d/system-adapters-2d.ts +49 -0
  146. package/src/world2d/transform-writer-2d.ts +24 -0
  147. package/src/world2d/types.ts +55 -0
@@ -0,0 +1,196 @@
1
+ import type RAPIER from '@dimforge/rapier3d-compat';
2
+ import type { EffectComposer } from 'postprocessing';
3
+ import type * as THREE from 'three';
4
+ import type { SystemAdapters } from '../adapter/system-adapter';
5
+ import type { AnimGraph } from '../animation/anim-graph';
6
+ import type { AssetCache } from '../assets';
7
+ import type { createSystemRunner } from '../core/system-runner';
8
+ import type { createDebugDraw } from '../dev/debug-draw';
9
+ import type { ComponentManager } from '../ecs/component-manager';
10
+ import type { InputManager } from '../input/input-manager';
11
+ import type { CollisionSystem } from '../physics/collision-system';
12
+ import type { PhysicsRegistry } from '../physics/physics-registry';
13
+ import type { SceneFile } from '../scene/scene-types';
14
+ import type { AudioContext as GameAudio } from '../setup/setup-audio';
15
+ import type { ParticlesContext } from '../setup/setup-particles';
16
+ import type { Game, WorldInstance } from './game';
17
+ import type { SceneUIGameServices } from './scene-ui-bridge';
18
+
19
+ /**
20
+ * All engine subsystems, passed to every game `setup(ctx)` and to each
21
+ * GameComponent lifecycle method (`init`/`update`/`dispose`/trigger callbacks).
22
+ *
23
+ * The surface is intentionally split in two:
24
+ *
25
+ * - **Raw libraries** — the actual Three.js / Rapier / postprocessing objects.
26
+ * Call their documented APIs directly; do NOT build wrappers around them.
27
+ * - **Engine handles** — thin VGAI-owned managers that bridge those libraries
28
+ * into the phase-ordered game loop. Reach for these when you need behavior the
29
+ * raw library doesn't provide (physics↔Object3D mapping, system phases, etc.).
30
+ */
31
+ export interface GameContext {
32
+ // ─── Raw libraries (the real library objects — call them directly) ───
33
+
34
+ /** Three.js scene graph root. Add/remove any `Object3D` (meshes, lights,
35
+ * groups) here. This is THE scene — there is no separate engine scene. */
36
+ scene: THREE.Scene;
37
+ /** The single render camera. Move/aim it directly, or (preferred) drive it
38
+ * from a camera GameComponent — see the camera-precedence note below. */
39
+ camera: THREE.PerspectiveCamera;
40
+ /** The Rapier physics `World`. Step/query it, create bodies & colliders via
41
+ * `ctx.rapier`. One shared world for the whole scene. */
42
+ rapierWorld: RAPIER.World;
43
+ /** The Rapier module namespace itself (`RigidBodyDesc`, `ColliderDesc`,
44
+ * `Vector3`, enums, …). Use to construct physics descriptors. */
45
+ rapier: typeof RAPIER;
46
+ /** The `postprocessing` EffectComposer driving the final render. Use to
47
+ * add/remove passes (bloom, vignette, …) or reach the underlying
48
+ * `WebGLRenderer` via `composer.getRenderer()`. */
49
+ composer: EffectComposer;
50
+
51
+ // ─── Engine handles (VGAI managers — use when the raw lib isn't enough) ───
52
+
53
+ /** Keyboard/mouse/gamepad input. Polled once per frame in the `input` phase;
54
+ * read action/axis state inside your `update`. */
55
+ input: InputManager;
56
+ /** Rapier collision-event dispatcher. Register handlers with
57
+ * `collisions.onCollision(...)`; it is drained each `postPhysics`. Prefer a
58
+ * GameComponent's `onTriggerEnter/Exit` for per-entity sensor logic. */
59
+ collisions: CollisionSystem;
60
+ /** `Object3D ↔ Rapier body/collider` registry (+ collider→Object3D reverse
61
+ * index). Use to find an entity's body/collider, or the entity behind a
62
+ * raw collision. Inside a component, `this.rigidBody`/`this.collider` are
63
+ * pre-resolved from here. */
64
+ physics: PhysicsRegistry;
65
+ /** Procedural audio: master gain, listener, and one-shot/impact helpers.
66
+ * Use for SFX and music; the listener tracks the camera. */
67
+ audio: GameAudio;
68
+ /** three.quarks particle system: the batched renderer + spawn helpers. Use
69
+ * to emit runtime particle effects; declarative scene particles register
70
+ * here automatically. */
71
+ particles: ParticlesContext;
72
+ /** Immediate-mode debug drawing (`line`/`box`/`sphere`/`arrow`). Drawn
73
+ * objects persist until you call `debugDraw.clear()` yourself (the
74
+ * adapter also clears on scene dispose/hot-reload) — nothing clears it
75
+ * automatically per frame. Dev-only visualization — not for shipped
76
+ * visuals. */
77
+ debugDraw: ReturnType<typeof createDebugDraw>;
78
+ /** Active animation graphs, keyed by their `Object3D`; ticked in the
79
+ * `animation` phase. Look up an entity's `AnimGraph` to set params/triggers.
80
+ * GLTF/scene-defined graphs register here automatically. */
81
+ animGraphs: Map<THREE.Object3D, AnimGraph>;
82
+ /** Shared GLTF/texture cache. Use `assets.loadGLTF(...)` etc. to load+cache
83
+ * models at runtime so repeated loads reuse geometry/material. */
84
+ assets: AssetCache;
85
+ /** The phase-ordered system runner. `systems.add(phase, fn)` registers a
86
+ * per-frame callback. Reserve this for non-gameplay infra (audio mixers,
87
+ * network send); per-entity GAMEPLAY belongs in a GameComponent. */
88
+ systems: ReturnType<typeof createSystemRunner>;
89
+ /** Manager for all live GameComponent instances (attach/detach/HMR). The
90
+ * scene loader drives this; gameplay code rarely touches it directly. */
91
+ components: ComponentManager;
92
+ /** DOM element overlaying the canvas for game UI (React via `mountUI`, or
93
+ * vanilla). `pointer-events:none` by default — set `auto` on interactive
94
+ * elements. Cleared on scene switch / hot-reload. */
95
+ uiContainer: HTMLDivElement;
96
+ /** Register a game-owned `SystemAdapters` capability (networking,
97
+ * navigation, …) on the mounted game's adapter surface, so the editor's
98
+ * introspection panels (via `getActiveSystems()`) can see it. The engine
99
+ * wires the first-party physics/input/assets/animation entries itself;
100
+ * capabilities the GAME owns (a Colyseus connection, a NavMeshManager)
101
+ * are registered here by the game's setup. Optional capability — absent
102
+ * in contexts that expose no adapter surface (some test harnesses);
103
+ * call as `ctx.registerSystemAdapter?.(…)`.
104
+ *
105
+ * Ordering contract: **game registrations always win** — the engine seeds
106
+ * its first-party entries (physics/input/assets/animation) BEFORE setup()
107
+ * runs, so a game that registers any of those same kinds during its own
108
+ * setup overrides the engine's entry, not the other way around. This also
109
+ * holds across a warm restart (`hotReload`): every kind the outgoing game
110
+ * registered is stripped back to the engine's entry (or absent, for
111
+ * game-only kinds like networking/navigation) before the incoming game's
112
+ * setup runs, so a disposed game's adapter is never left stale on the
113
+ * mounted surface. */
114
+ registerSystemAdapter?<K extends keyof SystemAdapters>(kind: K, adapter: SystemAdapters[K]): void;
115
+
116
+ /**
117
+ * Register (or replace) this game's scene-UI game-time services — the data
118
+ * source, custom onEvent handler, and/or worldProjector overrides a scene's
119
+ * authored UI mounts against (design/24-scene-ui.md D2/D7). Optional, same
120
+ * precedent as `registerSystemAdapter`: absent in contexts with no scene-UI
121
+ * surface at all (bare test harnesses).
122
+ *
123
+ * Timing is safe by construction: the scene loader awaits
124
+ * `componentManager.initAll()` before `loadScene`/`loadSceneFromData`
125
+ * return, and the adapter mounts scene UI only after that — so calling this
126
+ * from a `GameComponent.init()` always beats the mount (its services are
127
+ * folded into the FIRST render, not a late update). A call AFTER the mount
128
+ * (e.g. from a click handler) updates the live mount in place instead
129
+ * (`SceneUIHandle.update`, or a dispose+remount if the renderer has none).
130
+ *
131
+ * Per-key merge (D2): a key set here always wins over both the project's
132
+ * registration-time registry and the engine's own D4/D5/D6 defaults. Hot
133
+ * reload strips every game-registered key back to none before the incoming
134
+ * game's `init()` runs — same contract as `registerSystemAdapter`'s re-seed.
135
+ */
136
+ setSceneUIServices?(services: SceneUIGameServices): void;
137
+
138
+ // ─── Game root (T7.1 slice 1 — GAME-ROOT-DESIGN.md D6) ───
139
+
140
+ /** The Game root — the loop, the world registry, and game-scoped handles.
141
+ * Absent only in bare test harnesses that construct a partial context (the
142
+ * host always sets it in `createGameRuntime`). Optional so every existing
143
+ * harness that builds a hand-rolled `ctx` object keeps compiling
144
+ * unmodified — the same precedent as `registerSystemAdapter`. */
145
+ game?: Game | undefined;
146
+ /** Alias for `game.worlds` — the live, declaration-ordered world registry.
147
+ * Same optionality/absence rule as `game` above. */
148
+ worlds?: ReadonlyArray<WorldInstance> | undefined;
149
+ }
150
+
151
+ /** Return value from a game setup function. */
152
+ export interface GameCleanup {
153
+ dispose: () => void;
154
+ }
155
+
156
+ /** Context passed to setup() when launched from the editor's play mode. */
157
+ export interface EditorPreview {
158
+ /** Path of the .vscn.json file the user has open (null if unsaved). */
159
+ scenePath: string | null;
160
+ /** The editor's in-memory scene document, including unsaved edits. */
161
+ sceneData: SceneFile;
162
+ /** Editor viewport camera transform — use this as the initial game camera viewpoint. */
163
+ viewportCamera?: {
164
+ position: [number, number, number];
165
+ quaternion: [number, number, number, number];
166
+ };
167
+ /**
168
+ * Which bundled example the static/browser build's project picker wants to
169
+ * run (docs/EXAMPLES-AS-PROJECTS-DESIGN.md §4 — Adjudication H). Only
170
+ * meaningful to `packages/editor/src/browser-play-entry.ts`'s dispatch over
171
+ * the generated `SETUP_BY_EXAMPLE_ID` table; every other `GameSetupFn`
172
+ * consumer ignores it. Undefined ⇒ the generic scene-loader fallback
173
+ * (today's only reachable behavior — no caller sets this yet, since the
174
+ * picker itself is a later wave).
175
+ */
176
+ exampleId?: string | undefined;
177
+ /**
178
+ * Asset prefix for the static/browser build's GENERIC scene-loader fallback,
179
+ * set INSTEAD of `exampleId` when the editor has a non-default scene of a
180
+ * bundled example open (hosted `?project=<id>&scene=<project-relative>` —
181
+ * Track H): the open scene plays via `sceneData`, but its relative asset
182
+ * refs still live under the example's staged `/examples/<id>/` public root.
183
+ * Ignored whenever `exampleId` is set; undefined ⇒ root prefix `/`.
184
+ */
185
+ assetPrefix?: string | undefined;
186
+ }
187
+
188
+ /** A game setup function: receives engine context, returns cleanup.
189
+ *
190
+ * This is the MOUNT body of the FIRST-PARTY implementer. The host no longer
191
+ * depends on it — the host depends on `GameAdapter` (`@engine/adapter`), and
192
+ * `GameSetupFn` is wrapped into a `VgaiSceneGameAdapter` via `fromSetup`. See
193
+ * docs/ADAPTER-ARCHITECTURE.md. (The former `GameAdapter` `{ setup, components }`
194
+ * interface lived here; it is superseded by the real adapter interfaces in
195
+ * `@engine/adapter`.) */
196
+ export type GameSetupFn = (ctx: GameContext, editor?: EditorPreview) => Promise<GameCleanup>;
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Canonical GLTF / texture / animgraph load + cache layer (A2).
3
+ *
4
+ * ONE place that owns the URL-keyed caches and the clone-safety contract, shared
5
+ * by BOTH the engine runtime (scene-loader, create-runtime) AND the editor
6
+ * (entity-factory, scene-sync). Previously this logic was duplicated across six
7
+ * sites with three separate GLTF caches and four GLTFLoader instances, so fixes
8
+ * (DRACO wiring, the `__sharedGeometry` clone-safety tag, skybox handling) did
9
+ * not propagate. Everything funnels through the shared loaders in `../loader`
10
+ * (`gltfLoader` is DRACO-wired — CB2 — and `textureLoader` shares the engine
11
+ * LoadingManager / asset-prefix rewrite).
12
+ *
13
+ * Cache lifetime / P1.8: these caches are intentionally module-level and persist
14
+ * ACROSS hot-reloads / scene switches (they are NOT cleared on every scene load).
15
+ * This is deliberate: (1) it makes repeated Play→Stop→Play cheap, and (2) it
16
+ * underpins the `__sharedGeometry` contract from P0.2 — GLTF clones share the
17
+ * cached source geometry, so clearing mid-session would orphan live clones. The
18
+ * caches are keyed by URL, so loading the SAME asset N times is bounded (one
19
+ * entry per distinct URL, not per load). They are released only on full runtime
20
+ * teardown via {@link clearAssetCaches}.
21
+ */
22
+
23
+ import * as THREE from 'three';
24
+ import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
25
+ import type { AnimGraphFile } from '../animation/anim-graph-types';
26
+ import { AnimGraphFileSchema } from '../animation/schema';
27
+ import { gltfLoader, resolveUrl, textureLoader } from '../loader';
28
+ import { SceneParseError } from './parse';
29
+ import { setUserData } from './user-data';
30
+
31
+ const textureCache = new Map<string, THREE.Texture>();
32
+ const gltfCache = new Map<
33
+ string,
34
+ Promise<{ scene: THREE.Group; animations: THREE.AnimationClip[] }>
35
+ >();
36
+ const animGraphCache = new Map<string, Promise<AnimGraphFile>>();
37
+
38
+ /** Load (and cache) a texture via the shared, prefix-aware TextureLoader. */
39
+ export function loadTexture(url: string): THREE.Texture {
40
+ const cached = textureCache.get(url);
41
+ if (cached) return cached;
42
+ const tex = textureLoader.load(url);
43
+ textureCache.set(url, tex);
44
+ return tex;
45
+ }
46
+
47
+ /**
48
+ * Load a GLTF/GLB and return a fresh, disposal-safe clone (+ the source's
49
+ * animation clips). DRACO decoding is handled by the shared `gltfLoader`.
50
+ *
51
+ * Clone-safety contract (P0.2 — load-bearing, tested):
52
+ * `SkeletonUtils.clone` shares geometry AND materials with the cached source.
53
+ * We give each clone its OWN materials (so scene cleanup can dispose them
54
+ * without corrupting the cache) and tag every mesh `userData.__sharedGeometry`
55
+ * so cleanup (engine create-runtime fullCleanup + editor scene-sync
56
+ * disposeObject3D) skips disposing the shared geometry buffers.
57
+ */
58
+ export function loadGLTF(
59
+ url: string,
60
+ ): Promise<{ scene: THREE.Group; animations: THREE.AnimationClip[] }> {
61
+ let cached = gltfCache.get(url);
62
+ if (!cached) {
63
+ cached = new Promise((resolve, reject) => {
64
+ gltfLoader.load(
65
+ url,
66
+ (gltf) => resolve({ scene: gltf.scene, animations: gltf.animations }),
67
+ undefined,
68
+ (err) => reject(err),
69
+ );
70
+ });
71
+ gltfCache.set(url, cached);
72
+ }
73
+ return cached.then((entry) => {
74
+ const scene = SkeletonUtils.clone(entry.scene) as THREE.Group;
75
+ scene.traverse((child) => {
76
+ if (child instanceof THREE.Mesh) {
77
+ child.material = Array.isArray(child.material)
78
+ ? child.material.map((m) => m.clone())
79
+ : child.material.clone();
80
+ setUserData(child, '__sharedGeometry', true);
81
+ }
82
+ });
83
+ return { scene, animations: entry.animations };
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Resolve a single named node inside an already-loaded glTF scene graph (F4,
89
+ * docs/VSCN-STRUCTURAL-GAPS-DESIGN.md `mesh.node`). Pure and headlessly
90
+ * testable — no fetch, no cache lookups; callers pass the `scene` they already
91
+ * got from {@link loadGLTF}.
92
+ *
93
+ * The returned node is the SAME object (and shares the SAME geometry) as
94
+ * found in `scene` — nothing is cloned or copied out. `scene` is already a
95
+ * fresh `SkeletonUtils.clone()` per {@link loadGLTF}'s clone-safety contract,
96
+ * so mutating/detaching the returned node is safe.
97
+ *
98
+ * Atomic-subtree guard: a `SkinnedMesh` cannot be lifted out of its armature
99
+ * (its skeleton/bind matrices reference sibling bone nodes elsewhere in the
100
+ * hierarchy), so resolving a node that IS a SkinnedMesh, or that CONTAINS one,
101
+ * throws a loud `SceneParseError` instead of silently producing broken skinning.
102
+ */
103
+ export function resolveGltfNode(
104
+ scene: THREE.Object3D,
105
+ nodeName: string,
106
+ src: string,
107
+ ): THREE.Object3D {
108
+ const node = scene.getObjectByName(nodeName);
109
+ if (!node) {
110
+ throw new SceneParseError(
111
+ [
112
+ {
113
+ code: 'custom',
114
+ path: ['mesh', 'node'],
115
+ message: `glTF "${src}" has no node named "${nodeName}" (getObjectByName found nothing)`,
116
+ },
117
+ ],
118
+ src,
119
+ );
120
+ }
121
+
122
+ const isSkinned = (o: THREE.Object3D) => (o as THREE.SkinnedMesh).isSkinnedMesh;
123
+ let containsSkinned = isSkinned(node);
124
+ if (!containsSkinned) {
125
+ node.traverse((child) => {
126
+ if (isSkinned(child)) containsSkinned = true;
127
+ });
128
+ }
129
+ if (containsSkinned) {
130
+ throw new SceneParseError(
131
+ [
132
+ {
133
+ code: 'custom',
134
+ path: ['mesh', 'node'],
135
+ message:
136
+ `glTF "${src}" node "${nodeName}" is (or contains) a SkinnedMesh — a skinned mesh ` +
137
+ 'cannot be lifted out of its armature (atomic-subtree rule). Reference the whole ' +
138
+ 'file instead of a `node`, or bake/export a non-skinned sub-asset.',
139
+ },
140
+ ],
141
+ src,
142
+ );
143
+ }
144
+
145
+ return node;
146
+ }
147
+
148
+ /**
149
+ * Fetch (and cache) an animgraph JSON file via the shared prefix-aware fetch.
150
+ * Validated via `AnimGraphFileSchema` (T4.6) — a malformed `.animgraph.json`
151
+ * throws a `SceneParseError` naming the file, not a deep TypeError once the
152
+ * bad data reaches `AnimGraph`/`LayerRuntime`.
153
+ */
154
+ export function loadAnimGraphData(src: string): Promise<AnimGraphFile> {
155
+ let cached = animGraphCache.get(src);
156
+ if (!cached) {
157
+ cached = fetch(resolveUrl(src))
158
+ .then((r) => {
159
+ if (!r.ok) throw new Error(`Failed to load animgraph: ${src}`);
160
+ return r.json();
161
+ })
162
+ .then((json) => {
163
+ const result = AnimGraphFileSchema.safeParse(json);
164
+ if (!result.success) throw new SceneParseError(result.error.issues, src);
165
+ return result.data;
166
+ });
167
+ animGraphCache.set(src, cached);
168
+ }
169
+ return cached;
170
+ }
171
+
172
+ /**
173
+ * Clear the module-level GLTF / texture / animgraph caches. Call on full runtime
174
+ * teardown (create-runtime fullCleanup / hot-reload) to release cached GPU
175
+ * resources and avoid leaking across editor Play sessions. See the lifetime note
176
+ * at the top of this file for why these are NOT cleared per scene load.
177
+ *
178
+ * NOTE: the IBL/skybox env-map cache lives in scene-loader (it needs a
179
+ * WebGLRenderer for PMREM); scene-loader's `clearAssetCaches` wraps this and
180
+ * also clears that env cache.
181
+ */
182
+ export function clearAssetCaches(): void {
183
+ textureCache.clear();
184
+ gltfCache.clear();
185
+ animGraphCache.clear();
186
+ }
187
+
188
+ /**
189
+ * Test-only: current sizes of the module-level caches. Used by the P1.8 headless
190
+ * test to assert caches stay bounded (one entry per distinct asset URL) across
191
+ * repeated loads of the same scene, rather than growing per load.
192
+ */
193
+ export function assetCacheSizes(): { textures: number; gltf: number; animGraphs: number } {
194
+ return { textures: textureCache.size, gltf: gltfCache.size, animGraphs: animGraphCache.size };
195
+ }
@@ -0,0 +1,123 @@
1
+ import type { SceneEntity, SceneEnvironment, SceneFile } from './scene-types';
2
+ import type { SceneMaterial } from './schema/material';
3
+
4
+ /**
5
+ * Every material field that holds a texture-file path, as [group, key] pairs —
6
+ * group `null` for top-level fields, otherwise the physical feature group
7
+ * (clearcoat/transmission/sheen/iridescence) the key nests under. One table
8
+ * drives both collect and rename so the two can never disagree;
9
+ * `asset-paths.test.ts` derives the expected set from SceneMaterialSchema's
10
+ * `.describe()` strings so this list can't silently fall behind the schema.
11
+ */
12
+ const MATERIAL_TEXTURE_FIELDS: ReadonlyArray<readonly [string | null, string]> = [
13
+ [null, 'map'],
14
+ [null, 'normalMap'],
15
+ [null, 'emissiveMap'],
16
+ [null, 'aoMap'],
17
+ [null, 'lightMap'],
18
+ [null, 'roughnessMap'],
19
+ [null, 'metalnessMap'],
20
+ [null, 'displacementMap'],
21
+ ['clearcoat', 'clearcoatMap'],
22
+ ['clearcoat', 'clearcoatRoughnessMap'],
23
+ ['transmission', 'transmissionMap'],
24
+ ['sheen', 'sheenColorMap'],
25
+ ['sheen', 'sheenRoughnessMap'],
26
+ ['iridescence', 'iridescenceMap'],
27
+ ['iridescence', 'iridescenceThicknessMap'],
28
+ ];
29
+
30
+ /** Visit every authored texture path on a material (inline or override). */
31
+ function forEachMaterialTexturePath(
32
+ material: Partial<SceneMaterial>,
33
+ fn: (holder: Record<string, unknown>, key: string, value: string) => void,
34
+ ): void {
35
+ for (const [group, key] of MATERIAL_TEXTURE_FIELDS) {
36
+ const holder = group
37
+ ? (material as Record<string, unknown>)[group]
38
+ : (material as Record<string, unknown>);
39
+ if (!holder || typeof holder !== 'object') continue;
40
+ const value = (holder as Record<string, unknown>)[key];
41
+ if (typeof value === 'string') fn(holder as Record<string, unknown>, key, value);
42
+ }
43
+ }
44
+
45
+ function isFilePrefab(value: string): boolean {
46
+ return value.includes('/');
47
+ }
48
+
49
+ function walkEntities(entities: SceneEntity[], fn: (e: SceneEntity) => void): void {
50
+ for (const e of entities) {
51
+ fn(e);
52
+ if (e.children) walkEntities(e.children, fn);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Collect all external asset paths referenced by a scene's entities and environment.
58
+ */
59
+ export function collectAssetPaths(
60
+ entities: SceneEntity[],
61
+ environment?: SceneEnvironment,
62
+ ): Set<string> {
63
+ const paths = new Set<string>();
64
+
65
+ walkEntities(entities, (e) => {
66
+ if (e.mesh?.type === 'gltf' && e.mesh.src) paths.add(e.mesh.src);
67
+ if (e.mesh?.instances) paths.add(e.mesh.instances);
68
+
69
+ if (e.material) {
70
+ forEachMaterialTexturePath(e.material, (_holder, _key, value) => paths.add(value));
71
+ }
72
+
73
+ if (e.materialRef) paths.add(e.materialRef);
74
+
75
+ if (e.particles?.material?.map) paths.add(e.particles.material.map);
76
+
77
+ if (e.audio?.src) paths.add(e.audio.src);
78
+ if (e.animation?.animGraph) paths.add(e.animation.animGraph);
79
+ if (e.prefab && isFilePrefab(e.prefab)) paths.add(e.prefab);
80
+ });
81
+
82
+ if (environment?.skybox) paths.add(environment.skybox);
83
+
84
+ return paths;
85
+ }
86
+
87
+ /** Replace oldPath with newPath in a single entity's asset fields. Mutates in place. */
88
+ function renameInEntity(e: SceneEntity, oldPath: string, newPath: string): void {
89
+ if (e.mesh?.type === 'gltf' && e.mesh.src === oldPath) {
90
+ e.mesh.src = newPath;
91
+ }
92
+ if (e.mesh?.instances === oldPath) e.mesh.instances = newPath;
93
+
94
+ if (e.material) {
95
+ forEachMaterialTexturePath(e.material, (holder, key, value) => {
96
+ if (value === oldPath) holder[key] = newPath;
97
+ });
98
+ }
99
+
100
+ if (e.materialRef === oldPath) e.materialRef = newPath;
101
+
102
+ if (e.particles?.material?.map === oldPath) e.particles.material.map = newPath;
103
+
104
+ if (e.audio?.src === oldPath) e.audio.src = newPath;
105
+ if (e.animation?.animGraph === oldPath) e.animation.animGraph = newPath;
106
+ if (e.prefab === oldPath) e.prefab = newPath;
107
+ }
108
+
109
+ /**
110
+ * Deep-clone a scene document and replace all occurrences of oldPath with newPath
111
+ * in asset reference fields. Returns the new document.
112
+ */
113
+ export function applyAssetRename(doc: SceneFile, oldPath: string, newPath: string): SceneFile {
114
+ const cloned: SceneFile = JSON.parse(JSON.stringify(doc));
115
+
116
+ walkEntities(cloned.entities, (e) => renameInEntity(e, oldPath, newPath));
117
+
118
+ if (cloned.environment?.skybox === oldPath) {
119
+ cloned.environment.skybox = newPath;
120
+ }
121
+
122
+ return cloned;
123
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Central asset registry — stores per-asset metadata (import corrections, etc.).
3
+ *
4
+ * Loaded once via fetch(), cached in memory. Both the runtime scene loader
5
+ * and editor entity factory import from this module.
6
+ */
7
+
8
+ import { resolveUrl } from '../loader';
9
+
10
+ export interface ImportCorrection {
11
+ /** Uniform scale factor applied to the GLTF clone before entity transform. */
12
+ scale?: number;
13
+ /** Euler rotation in degrees [x, y, z] applied to the GLTF clone before entity transform. */
14
+ rotation?: [number, number, number];
15
+ }
16
+
17
+ export interface AssetMeta {
18
+ importCorrection?: ImportCorrection;
19
+ }
20
+
21
+ type AssetRegistry = Record<string, AssetMeta>;
22
+
23
+ let registryCache: AssetRegistry | null = null;
24
+ let registryPromise: Promise<AssetRegistry> | null = null;
25
+
26
+ /** Normalize asset path for registry lookup (strip leading slash). */
27
+ function normalize(path: string): string {
28
+ return path.startsWith('/') ? path.slice(1) : path;
29
+ }
30
+
31
+ /**
32
+ * Fetch and cache the asset registry. Safe to call multiple times —
33
+ * returns the cached result after the first successful load.
34
+ */
35
+ export function loadRegistry(): Promise<AssetRegistry> {
36
+ if (registryCache) return Promise.resolve(registryCache);
37
+ if (registryPromise) return registryPromise;
38
+
39
+ registryPromise = fetch(resolveUrl('asset-registry.json'))
40
+ .then((res) => {
41
+ if (!res.ok) return {};
42
+ return res.json() as Promise<AssetRegistry>;
43
+ })
44
+ .catch(() => ({}) as AssetRegistry)
45
+ .then((data) => {
46
+ registryCache = data;
47
+ return data;
48
+ });
49
+
50
+ return registryPromise;
51
+ }
52
+
53
+ /** Get metadata for an asset path, or undefined if not in the registry. */
54
+ export function getAssetMeta(path: string): AssetMeta | undefined {
55
+ if (!registryCache) return undefined;
56
+ // Try exact match first, then normalized
57
+ return registryCache[path] ?? registryCache[normalize(path)];
58
+ }
59
+
60
+ /**
61
+ * Update the in-memory cache directly (used by the editor after saving
62
+ * import settings, to avoid a re-fetch).
63
+ */
64
+ export function setRegistryCache(data: AssetRegistry): void {
65
+ registryCache = data;
66
+ registryPromise = null;
67
+ }