@jgengine/shell 0.14.0 → 0.15.0-next.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 (114) hide show
  1. package/CHANGELOG.md +483 -1
  2. package/dist/GamePlayerShell.js +25 -5
  3. package/dist/Shell3dPresentation.d.ts +6 -6
  4. package/dist/Shell3dPresentation.js +4 -4
  5. package/dist/camera/ControllerCameraShake.d.ts +31 -0
  6. package/dist/camera/ControllerCameraShake.js +34 -0
  7. package/dist/camera/GameCameraRig.js +10 -5
  8. package/dist/camera/GameOrbitCamera.js +1 -10
  9. package/dist/camera/cameraRigs.js +12 -4
  10. package/dist/camera/index.d.ts +1 -0
  11. package/dist/camera/index.js +1 -0
  12. package/dist/camera/rigMath.d.ts +2 -0
  13. package/dist/camera/rigMath.js +7 -0
  14. package/dist/devtools/CollisionDebugWorld.js +2 -2
  15. package/dist/devtools/DevtoolsOverlay.js +2 -0
  16. package/dist/drivers/FrameDriver.d.ts +4 -1
  17. package/dist/drivers/FrameDriver.js +5 -3
  18. package/dist/environment/DayNightSky.d.ts +33 -0
  19. package/dist/environment/DayNightSky.js +51 -0
  20. package/dist/environment/Daylight.d.ts +3 -1
  21. package/dist/environment/Daylight.js +7 -3
  22. package/dist/environment/EnvironmentScene.js +6 -6
  23. package/dist/environment/RoadJunctions.d.ts +8 -4
  24. package/dist/environment/RoadJunctions.js +16 -15
  25. package/dist/environment/RoadRibbons.d.ts +42 -6
  26. package/dist/environment/RoadRibbons.js +150 -27
  27. package/dist/environment/VolumetricClouds.js +3 -3
  28. package/dist/environment/index.d.ts +1 -0
  29. package/dist/environment/index.js +1 -0
  30. package/dist/gameKit.d.ts +7 -4
  31. package/dist/gameKit.js +8 -5
  32. package/dist/inputSink.js +14 -0
  33. package/dist/materialOverride.js +30 -0
  34. package/dist/postfx/PostProcessing.d.ts +3 -2
  35. package/dist/postfx/PostProcessing.js +11 -3
  36. package/dist/postfx/ScreenEffectsOverlay.d.ts +32 -0
  37. package/dist/postfx/ScreenEffectsOverlay.js +70 -0
  38. package/dist/presentationResolve.d.ts +3 -2
  39. package/dist/presentationResolve.js +11 -6
  40. package/dist/render/CascadedShadows.d.ts +13 -0
  41. package/dist/render/CascadedShadows.js +96 -0
  42. package/dist/render/EnvironmentLighting.d.ts +23 -7
  43. package/dist/render/EnvironmentLighting.js +69 -10
  44. package/dist/render/PartMotion.js +4 -4
  45. package/dist/render/SceneLighting.js +7 -0
  46. package/dist/render/SceneModels.d.ts +37 -1
  47. package/dist/render/SceneModels.js +40 -11
  48. package/dist/render/modelLoad.d.ts +41 -5
  49. package/dist/render/modelLoad.js +85 -18
  50. package/dist/render/sceneCapture.d.ts +27 -0
  51. package/dist/render/sceneCapture.js +43 -0
  52. package/dist/render/useDisposable.d.ts +16 -0
  53. package/dist/render/useDisposable.js +24 -0
  54. package/dist/render/useModelAnimation.js +47 -4
  55. package/dist/scene/AuthoredScene.js +14 -24
  56. package/dist/scene/CityRenderer.js +103 -36
  57. package/dist/scene/GrassFieldRenderer.js +4 -1
  58. package/dist/scene/PoleLineRenderer.js +7 -16
  59. package/dist/settings/QuickControls.js +5 -3
  60. package/dist/settings/SettingsMenu.js +6 -3
  61. package/dist/settings/appliedSettings.d.ts +3 -1
  62. package/dist/settings/appliedSettings.js +17 -5
  63. package/dist/settings/settingsController.js +11 -5
  64. package/dist/shellMovement.d.ts +15 -1
  65. package/dist/shellMovement.js +15 -1
  66. package/dist/structures/GeneratedBuilding.js +4 -5
  67. package/dist/structures/TransformGizmo.js +6 -5
  68. package/dist/terrain/CarvedTerrain.js +3 -7
  69. package/dist/terrain/GrassField.js +2 -2
  70. package/dist/terrain/ProceduralGround.js +3 -5
  71. package/dist/terrain/grassMaterial.d.ts +8 -2
  72. package/dist/terrain/grassMaterial.js +24 -5
  73. package/dist/terrain/index.d.ts +1 -1
  74. package/dist/terrain/terrainDetailMaterial.js +11 -2
  75. package/dist/terrain/terrainMath.d.ts +38 -1
  76. package/dist/terrain/terrainMath.js +86 -64
  77. package/dist/touch/TouchControlsOverlay.d.ts +6 -2
  78. package/dist/touch/TouchControlsOverlay.js +136 -17
  79. package/dist/vfx/ParticleField.d.ts +32 -0
  80. package/dist/vfx/ParticleField.js +91 -0
  81. package/dist/vfx/WorldFloatingText.d.ts +32 -0
  82. package/dist/vfx/WorldFloatingText.js +32 -0
  83. package/dist/water/Ocean.js +3 -6
  84. package/dist/weather/LightningStrike.js +3 -9
  85. package/dist/weather/RainField.js +3 -7
  86. package/dist/weather/SnowField.js +3 -7
  87. package/dist/world/DataObjects.js +3 -6
  88. package/dist/world/GridWorldScene.js +3 -6
  89. package/dist/world/InstancedBodies.js +3 -6
  90. package/dist/world/InstancedJoints.js +2 -2
  91. package/dist/world/SpriteBatch.js +3 -6
  92. package/dist/world/WorldEntityFrames.d.ts +47 -0
  93. package/dist/world/WorldEntityFrames.js +75 -0
  94. package/dist/world/WorldHud.js +7 -5
  95. package/dist/world/WorldPings.d.ts +26 -0
  96. package/dist/world/WorldPings.js +68 -0
  97. package/dist/world/WorldScene.js +9 -4
  98. package/dist/world/pingPulse.d.ts +21 -0
  99. package/dist/world/pingPulse.js +17 -0
  100. package/package.json +5 -5
  101. package/skills/ce-handoff/SKILL.md +149 -0
  102. package/skills/game-design/SKILL.md +2 -0
  103. package/skills/jgengine/SKILL.md +3 -1
  104. package/skills/jgengine/api.md +17 -7
  105. package/skills/jgengine/capabilities.md +12 -0
  106. package/skills/jgengine/recipes/minimal-game.md +7 -4
  107. package/skills/jgengine-ui/SKILL.md +5 -3
  108. package/skills/jgengine-ui/api.md +708 -19
  109. package/skills/jgengine-ui/capabilities.md +362 -1
  110. package/skills/jgengine-ui/recipes/overhead-entity-frames.md +136 -0
  111. package/skills/jgengine-ui/reference.md +7 -4
  112. package/skills/jgengine-world/api.md +378 -11
  113. package/skills/jgengine-world/capabilities.md +123 -2
  114. package/skills/jgengine-world/reference.md +15 -1
@@ -3,7 +3,8 @@ import { useFrame, useLoader } from "@react-three/fiber";
3
3
  import { useTexture } from "@react-three/drei";
4
4
  import { Component, Suspense, useEffect, useMemo, useRef } from "react";
5
5
  import * as THREE from "three";
6
- import { useGameContext } from "@jgengine/react/provider";
6
+ import { reportFallbackSeam } from "@jgengine/core/devtools/fallbackSeams";
7
+ import { useOptionalGameContext } from "@jgengine/react/provider";
7
8
  import { sharedGltfLoader } from "./modelLoad.js";
8
9
  import { measureLocalBounds, reportMeasuredBounds } from "./measureBounds.js";
9
10
  import { measureLocalCollisionTriangles, reportMeasuredCollisionMesh } from "./measureCollisionMesh.js";
@@ -20,17 +21,42 @@ export function EntitySprite({ sprite }) {
20
21
  }, [texture]);
21
22
  return (_jsx("sprite", { "position-y": sprite.y, scale: [sprite.width, sprite.height, 1], children: _jsx("spriteMaterial", { map: texture, transparent: true, alphaTest: 0.08, depthWrite: false }) }));
22
23
  }
23
- class ModelFallbackBoundary extends Component {
24
+ /**
25
+ * Catches a render throw from a model subtree and swaps in `fallback` — but never silently: the
26
+ * failed state re-reports to the fallback-seam probe on every render (matching the per-seam frame
27
+ * boundary in `WorldScene`) and the catch warns once with the model url and the error. Without
28
+ * that, a throw anywhere in a composition erases the actor with an empty `probes.fallbacks`.
29
+ * @internal
30
+ */
31
+ export class ModelFallbackBoundary extends Component {
24
32
  state = { failed: false };
25
33
  static getDerivedStateFromError() {
26
34
  return { failed: true };
27
35
  }
36
+ componentDidCatch(error) {
37
+ if (typeof console === "undefined")
38
+ return;
39
+ console.warn(`[jgengine] model "${this.props.url}" threw while rendering and was replaced by its fallback ` +
40
+ `(nothing, unless one was supplied). Fix the throw below — the actor is invisible until then.`, error);
41
+ }
28
42
  render() {
29
- return this.state.failed ? this.props.fallback : this.props.children;
43
+ if (!this.state.failed)
44
+ return this.props.children;
45
+ reportFallbackSeam(this.props.seam ?? "entity", "renderError");
46
+ return this.props.fallback;
30
47
  }
31
48
  }
49
+ /**
50
+ * One model subtree behind its own error boundary and Suspense boundary, so a throwing or
51
+ * still-loading piece cannot erase its siblings. Every nested piece of a composition — parts,
52
+ * attachments, material maps — mounts through this.
53
+ * @internal
54
+ */
55
+ export function IsolatedModelPart({ model, seam, children, }) {
56
+ return (_jsx(ModelFallbackBoundary, { fallback: null, url: model.url, seam: seam, children: _jsx(Suspense, { fallback: null, children: children }) }));
57
+ }
32
58
  export function IsolatedEntityModel({ model, instanceId, measure, fallback, }) {
33
- return (_jsx(ModelFallbackBoundary, { fallback: fallback ?? null, children: _jsx(Suspense, { fallback: null, children: _jsx(EntityModel, { model: model, instanceId: instanceId, measure: measure }) }) }));
59
+ return (_jsx(ModelFallbackBoundary, { fallback: fallback ?? null, url: model.url, seam: measure?.target, children: _jsx(Suspense, { fallback: null, children: _jsx(EntityModel, { model: model, instanceId: instanceId, measure: measure }) }) }));
34
60
  }
35
61
  function BoneAttachment({ rig, model, slot, position, rotation, scale, }) {
36
62
  const gltf = useLoader(sharedGltfLoader, model.url);
@@ -63,7 +89,7 @@ function BoneAttachment({ rig, model, slot, position, rotation, scale, }) {
63
89
  }
64
90
  /** Renders a static kit-of-parts child at a fixed local offset under the parent model — no bone/rig lookup, unlike `BoneAttachment`. The nested `EntityModel` still applies its own `dims`/anchor centering, so scale/pivot sanity carries over per part. */
65
91
  function ModelPartGroup({ model, position, rotation, scale, }) {
66
- return (_jsx("group", { position: position ?? [0, 0, 0], rotation: rotation ?? [0, 0, 0], scale: scale ?? 1, children: _jsx(EntityModel, { model: model }) }));
92
+ return (_jsx("group", { position: position ?? [0, 0, 0], rotation: rotation ?? [0, 0, 0], scale: scale ?? 1, children: _jsx(IsolatedModelPart, { model: model, children: _jsx(EntityModel, { model: model }) }) }));
67
93
  }
68
94
  function ModelMaterialMapsApplier({ scene, maps }) {
69
95
  const entries = useMemo(() => {
@@ -88,7 +114,10 @@ function ModelMaterialMapsApplier({ scene, maps }) {
88
114
  }
89
115
  export function EntityModel({ model, instanceId, measure, }) {
90
116
  const gltf = useLoader(sharedGltfLoader, model.url);
91
- const ctx = useGameContext();
117
+ // Optional, not required: measured bounds and paint strokes are live-world extras, and a model
118
+ // that threw without a running game could not be inspected outside one — which is how a broken
119
+ // composition stayed undiagnosable in `EntityPreview` (#1588).
120
+ const ctx = useOptionalGameContext();
92
121
  const material = model.material;
93
122
  const baseY = model.y ?? 0;
94
123
  const dims = model.dims;
@@ -130,7 +159,7 @@ export function EntityModel({ model, instanceId, measure, }) {
130
159
  const dimsMaxY = dims?.maxY;
131
160
  const [positionX, positionY, positionZ] = position;
132
161
  useEffect(() => {
133
- if (measureTarget === undefined || measureKey === undefined || dimsMaxY !== undefined)
162
+ if (ctx === null || measureTarget === undefined || measureKey === undefined || dimsMaxY !== undefined)
134
163
  return;
135
164
  const raw = measureLocalBounds(scene);
136
165
  if (raw === null)
@@ -144,7 +173,7 @@ export function EntityModel({ model, instanceId, measure, }) {
144
173
  // Runs with or without index dims: the loaded geometry upgrades whatever box the kind resolved
145
174
  // (fitted or measured) to a hitbox that raycasts the model's own triangles.
146
175
  useEffect(() => {
147
- if (measureTarget === undefined || measureKey === undefined)
176
+ if (ctx === null || measureTarget === undefined || measureKey === undefined)
148
177
  return;
149
178
  const triangles = measureLocalCollisionTriangles(scene, {
150
179
  scale,
@@ -168,7 +197,7 @@ export function EntityModel({ model, instanceId, measure, }) {
168
197
  materialCacheRef.current = null;
169
198
  }, [scene]);
170
199
  useFrame(() => {
171
- if (instanceId === undefined)
200
+ if (ctx === null || instanceId === undefined)
172
201
  return;
173
202
  const paint = ctx.scene.entity.paint;
174
203
  const version = paint.version(instanceId);
@@ -190,12 +219,12 @@ export function EntityModel({ model, instanceId, measure, }) {
190
219
  }
191
220
  paintDrawnCountRef.current = syncPaintCanvas(paintCanvasRef.current, cache.seedColor, strokes, paintDrawnCountRef.current);
192
221
  });
193
- const base = (_jsxs(_Fragment, { children: [_jsx("primitive", { object: scene, position: position, scale: [scale, scale, scale] }), material?.maps !== undefined ? _jsx(ModelMaterialMapsApplier, { scene: scene, maps: material.maps }) : null, (model.attachments ?? []).map((attachment, index) => typeof attachment.model === "string" ? null : (_jsx(BoneAttachment, { rig: scene, model: attachment.model, slot: attachment.slot, position: attachment.position, rotation: attachment.rotation, scale: attachment.scale }, `${attachment.slot}-${index}`)))] }));
222
+ const base = (_jsxs(_Fragment, { children: [_jsx("primitive", { object: scene, position: position, scale: [scale, scale, scale] }), material?.maps !== undefined ? (_jsx(IsolatedModelPart, { model: model, children: _jsx(ModelMaterialMapsApplier, { scene: scene, maps: material.maps }) })) : null, (model.attachments ?? []).map((attachment, index) => typeof attachment.model === "string" ? null : (_jsx(IsolatedModelPart, { model: attachment.model, children: _jsx(BoneAttachment, { rig: scene, model: attachment.model, slot: attachment.slot, position: attachment.position, rotation: attachment.rotation, scale: attachment.scale }) }, `${attachment.slot}-${index}`)))] }));
194
223
  const parts = model.parts ?? [];
195
224
  // Any role-tagged part switches the composition onto the procedural part-motion rig:
196
225
  // root bob/flinch/topple plus per-role limb swing, driven from the entity's live state.
197
226
  if (parts.some((part) => part.role !== undefined)) {
198
- return (_jsx(PartMotionRig, { parts: parts, model: model, instanceId: instanceId, renderPart: (part) => (typeof part.model === "string" ? null : _jsx(EntityModel, { model: part.model })), children: base }));
227
+ return (_jsx(PartMotionRig, { parts: parts, model: model, instanceId: instanceId, renderPart: (part) => typeof part.model === "string" ? null : (_jsx(IsolatedModelPart, { model: part.model, children: _jsx(EntityModel, { model: part.model }) })), children: base }));
199
228
  }
200
229
  return (_jsxs(_Fragment, { children: [base, parts.map((part, index) => typeof part.model === "string" ? null : (_jsx(ModelPartGroup, { model: part.model, position: part.position, rotation: part.rotation, scale: part.scale }, index)))] }));
201
230
  }
@@ -1,5 +1,14 @@
1
1
  import { GLTFLoader, type GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
2
2
  import { type AssetLoadDiagnosis } from "@jgengine/core/scene/assetDiagnostics";
3
+ /**
4
+ * The shared GLB {@link THREE.LoadingManager}'s `onError` sink. It fires for EVERY item the manager
5
+ * loads — the GLB container AND each texture/image a `GLTFLoader` fetches for it. The container's own
6
+ * failure is already surfaced by the model-fallback probe (it resolves to a placeholder), so this records
7
+ * only the sub-resource (texture) failures the fallback probe cannot see — a model that resolves but whose
8
+ * textures 404. Split out and exported so the wiring is unit-testable without a GL context.
9
+ * @internal
10
+ */
11
+ export declare function recordManagerLoadError(url: string): void;
3
12
  /**
4
13
  * Re-fetch a model URL that just failed to load and classify what actually came
5
14
  * back — a 404, a dev-server HTML fallback, a corrupt or non-model body — so the
@@ -10,11 +19,38 @@ import { type AssetLoadDiagnosis } from "@jgengine/core/scene/assetDiagnostics";
10
19
  */
11
20
  export declare function probeModelUrl(url: string, logicalId?: string, fetchImpl?: typeof fetch): Promise<AssetLoadDiagnosis>;
12
21
  /**
13
- * A {@link GLTFLoader} whose success path is unchanged but whose failures are
14
- * enriched: when a load errors, it probes the URL and rejects with an
15
- * {@link AssetLoadDiagnosis}-derived message (missing / HTML fallback / corrupt /
16
- * unsupported) instead of the raw parse error. Drop-in for the shared loader, so
17
- * `useLoader(sharedGltfLoader, url)` benefits with no call-site change.
22
+ * Build a stand-in {@link GLTF} whose scene is a single magenta placeholder box,
23
+ * returned in place of a model that could not be loaded. Consumers clone and
24
+ * mount `gltf.scene` exactly as they would a real model, so one broken asset
25
+ * degrades to a visible primitive instead of taking down the scene. The optional
26
+ * `diagnosis` is stashed on `userData.jgengineDiagnosis` for debugging.
27
+ * @internal
28
+ */
29
+ export declare function createFallbackModel(diagnosis?: AssetLoadDiagnosis): GLTF;
30
+ /**
31
+ * The failure seam for {@link DiagnosticGLTFLoader}: re-probe the URL a load just
32
+ * failed on and decide what the loader should hand back. A diagnosed broken asset
33
+ * (missing / HTML dev-server fallback / corrupt / unsupported) resolves via
34
+ * `onLoad` to a {@link createFallbackModel} primitive so the failure is contained
35
+ * at the load seam for **every** consumer — no reliance on a React error boundary
36
+ * catching a rejected Suspense promise, which does not fire reliably for async
37
+ * loader rejections inside the react-three-fiber reconciler. Only a genuine parse
38
+ * error over bytes that still look like a valid model (`diagnosis.ok`) is surfaced
39
+ * through `onError`. Split out from the loader so it is unit-testable without a GL
40
+ * context; `overrides` inject the probe and warning sink in tests.
41
+ * @internal
42
+ */
43
+ export declare function handleModelLoadFailure(resolvedUrl: string, originalEvent: unknown, onLoad: (gltf: GLTF) => void, onError: (event: unknown) => void, overrides?: {
44
+ probe?: typeof probeModelUrl;
45
+ warn?: (message: string) => void;
46
+ }): Promise<void>;
47
+ /**
48
+ * A {@link GLTFLoader} whose success path is unchanged but whose failures degrade
49
+ * gracefully: when a load errors, it probes the URL and — for a diagnosed broken
50
+ * asset (missing / HTML fallback / corrupt / unsupported) — resolves to a
51
+ * {@link createFallbackModel} placeholder primitive instead of rejecting, so the
52
+ * one broken model never bubbles a throw past its mount. Drop-in for the shared
53
+ * loader, so `useLoader(sharedGltfLoader, url)` benefits with no call-site change.
18
54
  * @internal
19
55
  */
20
56
  export declare class DiagnosticGLTFLoader extends GLTFLoader {
@@ -2,7 +2,23 @@ import * as THREE from "three";
2
2
  import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
3
3
  import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
4
4
  import { classifyAssetResponse, } from "@jgengine/core/scene/assetDiagnostics";
5
+ import { reportTextureLoadError } from "@jgengine/core/devtools/textureErrors";
5
6
  import { resolveAssetBaseUrl } from "./assetBase.js";
7
+ /** Model containers already covered by the whole-model fallback probe; not "texture" errors. @internal */
8
+ const MODEL_URL_RE = /\.(glb|gltf)(\?|#|$)/i;
9
+ /**
10
+ * The shared GLB {@link THREE.LoadingManager}'s `onError` sink. It fires for EVERY item the manager
11
+ * loads — the GLB container AND each texture/image a `GLTFLoader` fetches for it. The container's own
12
+ * failure is already surfaced by the model-fallback probe (it resolves to a placeholder), so this records
13
+ * only the sub-resource (texture) failures the fallback probe cannot see — a model that resolves but whose
14
+ * textures 404. Split out and exported so the wiring is unit-testable without a GL context.
15
+ * @internal
16
+ */
17
+ export function recordManagerLoadError(url) {
18
+ if (MODEL_URL_RE.test(url))
19
+ return;
20
+ reportTextureLoadError(url);
21
+ }
6
22
  /**
7
23
  * Dedicated LoadingManager for every GLB load instead of THREE.DefaultLoadingManager.
8
24
  * The shared default manager is process-wide; under repeated dev-server navigations its
@@ -12,6 +28,9 @@ import { resolveAssetBaseUrl } from "./assetBase.js";
12
28
  */
13
29
  const modelLoadingManager = new THREE.LoadingManager();
14
30
  modelLoadingManager.setURLModifier(resolveAssetBaseUrl);
31
+ // Capture GLTF texture/image load failures that never reach the top-level model onError (the model still
32
+ // resolves). Feeds the `textureErrors` probe so a texture-404'd scene is visible in debug_snapshot.
33
+ modelLoadingManager.onError = recordManagerLoadError;
15
34
  /** How many leading bytes to read when probing a failed model URL for its signature. @internal */
16
35
  const PROBE_BYTE_LIMIT = 64;
17
36
  /**
@@ -42,11 +61,71 @@ export async function probeModelUrl(url, logicalId, fetchImpl = fetch) {
42
61
  }
43
62
  }
44
63
  /**
45
- * A {@link GLTFLoader} whose success path is unchanged but whose failures are
46
- * enriched: when a load errors, it probes the URL and rejects with an
47
- * {@link AssetLoadDiagnosis}-derived message (missing / HTML fallback / corrupt /
48
- * unsupported) instead of the raw parse error. Drop-in for the shared loader, so
49
- * `useLoader(sharedGltfLoader, url)` benefits with no call-site change.
64
+ * Build a stand-in {@link GLTF} whose scene is a single magenta placeholder box,
65
+ * returned in place of a model that could not be loaded. Consumers clone and
66
+ * mount `gltf.scene` exactly as they would a real model, so one broken asset
67
+ * degrades to a visible primitive instead of taking down the scene. The optional
68
+ * `diagnosis` is stashed on `userData.jgengineDiagnosis` for debugging.
69
+ * @internal
70
+ */
71
+ export function createFallbackModel(diagnosis) {
72
+ const scene = new THREE.Group();
73
+ scene.name = "jgengine-missing-model";
74
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial({ color: 0xd63384, roughness: 0.7, metalness: 0 }));
75
+ mesh.name = "jgengine-missing-model-box";
76
+ mesh.castShadow = true;
77
+ mesh.receiveShadow = true;
78
+ scene.add(mesh);
79
+ const userData = { jgengineFallback: true };
80
+ if (diagnosis !== undefined)
81
+ userData.jgengineDiagnosis = diagnosis;
82
+ return {
83
+ scene,
84
+ scenes: [scene],
85
+ animations: [],
86
+ cameras: [],
87
+ asset: { generator: "jgengine-fallback-model" },
88
+ // The fallback never runs a GLTF parser; consumers only read `scene`/`animations`.
89
+ parser: undefined,
90
+ userData,
91
+ };
92
+ }
93
+ function warnMissingModel(message) {
94
+ if (typeof console !== "undefined") {
95
+ console.warn(`[jgengine] ${message} Rendering a placeholder primitive in its place.`);
96
+ }
97
+ }
98
+ /**
99
+ * The failure seam for {@link DiagnosticGLTFLoader}: re-probe the URL a load just
100
+ * failed on and decide what the loader should hand back. A diagnosed broken asset
101
+ * (missing / HTML dev-server fallback / corrupt / unsupported) resolves via
102
+ * `onLoad` to a {@link createFallbackModel} primitive so the failure is contained
103
+ * at the load seam for **every** consumer — no reliance on a React error boundary
104
+ * catching a rejected Suspense promise, which does not fire reliably for async
105
+ * loader rejections inside the react-three-fiber reconciler. Only a genuine parse
106
+ * error over bytes that still look like a valid model (`diagnosis.ok`) is surfaced
107
+ * through `onError`. Split out from the loader so it is unit-testable without a GL
108
+ * context; `overrides` inject the probe and warning sink in tests.
109
+ * @internal
110
+ */
111
+ export async function handleModelLoadFailure(resolvedUrl, originalEvent, onLoad, onError, overrides = {}) {
112
+ const probe = overrides.probe ?? probeModelUrl;
113
+ const diagnosis = await probe(resolvedUrl);
114
+ if (diagnosis.ok) {
115
+ // The bytes look like a real model on re-fetch — surface the original parse error unchanged.
116
+ onError(originalEvent);
117
+ return;
118
+ }
119
+ (overrides.warn ?? warnMissingModel)(diagnosis.message);
120
+ onLoad(createFallbackModel(diagnosis));
121
+ }
122
+ /**
123
+ * A {@link GLTFLoader} whose success path is unchanged but whose failures degrade
124
+ * gracefully: when a load errors, it probes the URL and — for a diagnosed broken
125
+ * asset (missing / HTML fallback / corrupt / unsupported) — resolves to a
126
+ * {@link createFallbackModel} placeholder primitive instead of rejecting, so the
127
+ * one broken model never bubbles a throw past its mount. Drop-in for the shared
128
+ * loader, so `useLoader(sharedGltfLoader, url)` benefits with no call-site change.
50
129
  * @internal
51
130
  */
52
131
  export class DiagnosticGLTFLoader extends GLTFLoader {
@@ -55,19 +134,7 @@ export class DiagnosticGLTFLoader extends GLTFLoader {
55
134
  if (onError === undefined)
56
135
  return;
57
136
  // Probe the URL the loader actually fetched (base-resolved), not the raw input.
58
- void probeModelUrl(this.manager.resolveURL(url))
59
- .then((diagnosis) => {
60
- if (diagnosis.ok) {
61
- // The bytes look fine on re-fetch — surface the original parse error unchanged.
62
- onError(event);
63
- return;
64
- }
65
- const enriched = new Error(`[jgengine] ${diagnosis.message}`);
66
- enriched.diagnosis = diagnosis;
67
- enriched.cause = event;
68
- onError(enriched);
69
- })
70
- .catch(() => onError(event));
137
+ void handleModelLoadFailure(this.manager.resolveURL(url), event, onLoad, onError).catch(() => onError(event));
71
138
  });
72
139
  }
73
140
  }
@@ -0,0 +1,27 @@
1
+ /** The bit of a WebGL renderer scene capture needs — its backing `<canvas>`. */
2
+ export interface CaptureRenderer {
3
+ domElement: {
4
+ toDataURL(type?: string): string;
5
+ };
6
+ }
7
+ /**
8
+ * Read the current frame to a PNG data URL. Requires the R3F `<Canvas>` to have
9
+ * been created with `gl={{ preserveDrawingBuffer: true }}` (the shell's game
10
+ * canvas already is); returns null if the backing canvas can't be read.
11
+ *
12
+ * @capability capture-canvas read the live R3F frame to a PNG data URL (needs preserveDrawingBuffer)
13
+ */
14
+ export declare function captureCanvas(gl: CaptureRenderer): string | null;
15
+ /** Trigger a browser download of an image data URL (the photo-mode "save" action). */
16
+ export declare function downloadImage(dataUrl: string, filename?: string): void;
17
+ /**
18
+ * In-`<Canvas>` binder that hands the scene-capture function out to HUD code
19
+ * living outside the reconciler (a photo-mode Capture button). Mount it inside
20
+ * the game's `WorldOverlay`; call `bind` receives a `() => string | null` that
21
+ * grabs the current frame. Renders nothing.
22
+ *
23
+ * @capability scene-capture-binding expose the in-Canvas scene-capture function to outside-Canvas HUD (photo mode)
24
+ */
25
+ export declare function SceneCaptureBinding({ bind }: {
26
+ bind: (capture: () => string | null) => void;
27
+ }): null;
@@ -0,0 +1,43 @@
1
+ import { useThree } from "@react-three/fiber";
2
+ import { useEffect } from "react";
3
+ /**
4
+ * Read the current frame to a PNG data URL. Requires the R3F `<Canvas>` to have
5
+ * been created with `gl={{ preserveDrawingBuffer: true }}` (the shell's game
6
+ * canvas already is); returns null if the backing canvas can't be read.
7
+ *
8
+ * @capability capture-canvas read the live R3F frame to a PNG data URL (needs preserveDrawingBuffer)
9
+ */
10
+ export function captureCanvas(gl) {
11
+ try {
12
+ const url = gl.domElement.toDataURL("image/png");
13
+ return url.startsWith("data:image/png") ? url : null;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ /** Trigger a browser download of an image data URL (the photo-mode "save" action). */
20
+ export function downloadImage(dataUrl, filename = "screenshot.png") {
21
+ if (typeof document === "undefined")
22
+ return;
23
+ const anchor = document.createElement("a");
24
+ anchor.href = dataUrl;
25
+ anchor.download = filename;
26
+ anchor.click();
27
+ }
28
+ /**
29
+ * In-`<Canvas>` binder that hands the scene-capture function out to HUD code
30
+ * living outside the reconciler (a photo-mode Capture button). Mount it inside
31
+ * the game's `WorldOverlay`; call `bind` receives a `() => string | null` that
32
+ * grabs the current frame. Renders nothing.
33
+ *
34
+ * @capability scene-capture-binding expose the in-Canvas scene-capture function to outside-Canvas HUD (photo mode)
35
+ */
36
+ export function SceneCaptureBinding({ bind }) {
37
+ const gl = useThree((state) => state.gl);
38
+ useEffect(() => {
39
+ bind(() => captureCanvas(gl));
40
+ return () => bind(() => null);
41
+ }, [gl, bind]);
42
+ return null;
43
+ }
@@ -0,0 +1,16 @@
1
+ import { type DependencyList } from "react";
2
+ /** Anything exposing dispose() — three.js geometries, materials, textures, render targets. */
3
+ export interface Disposable {
4
+ dispose(): void;
5
+ }
6
+ /** Dispose a single resource or every resource in a tuple. */
7
+ export declare function disposeAll(value: Disposable | readonly Disposable[]): void;
8
+ /**
9
+ * Memoize a three.js resource (or tuple of resources) and dispose it when `deps` change or the
10
+ * component unmounts. Owns the useMemo + dispose-effect pair that otherwise repeats at every
11
+ * GPU-resource call site. The factory must return objects exposing `dispose()` (geometries,
12
+ * materials, textures, render targets).
13
+ *
14
+ * @capability disposable-resource memoize a three.js GPU resource (or tuple) with automatic dispose on change/unmount — no hand-rolled useMemo + dispose-effect pairs
15
+ */
16
+ export declare function useDisposable<T extends Disposable | readonly Disposable[]>(create: () => T, deps: DependencyList): T;
@@ -0,0 +1,24 @@
1
+ import { useEffect, useMemo } from "react";
2
+ /** Dispose a single resource or every resource in a tuple. */
3
+ export function disposeAll(value) {
4
+ if (Array.isArray(value)) {
5
+ for (const item of value)
6
+ item.dispose();
7
+ return;
8
+ }
9
+ value.dispose();
10
+ }
11
+ /**
12
+ * Memoize a three.js resource (or tuple of resources) and dispose it when `deps` change or the
13
+ * component unmounts. Owns the useMemo + dispose-effect pair that otherwise repeats at every
14
+ * GPU-resource call site. The factory must return objects exposing `dispose()` (geometries,
15
+ * materials, textures, render targets).
16
+ *
17
+ * @capability disposable-resource memoize a three.js GPU resource (or tuple) with automatic dispose on change/unmount — no hand-rolled useMemo + dispose-effect pairs
18
+ */
19
+ export function useDisposable(create, deps) {
20
+ // eslint-disable-next-line react-hooks/exhaustive-deps
21
+ const value = useMemo(create, deps);
22
+ useEffect(() => () => disposeAll(value), [value]);
23
+ return value;
24
+ }
@@ -3,7 +3,46 @@ import { useEffect, useMemo, useRef } from "react";
3
3
  import * as THREE from "three";
4
4
  import { resolveAnimationConfig } from "@jgengine/core/game/clipRoles";
5
5
  import { resolveOneShotClip } from "@jgengine/core/game/modelAnimation";
6
- import { useGameContext } from "@jgengine/react/provider";
6
+ import { useOptionalGameContext } from "@jgengine/react/provider";
7
+ /**
8
+ * An explicit `animation` config always wins over `"auto"`, and both the state machine and the
9
+ * one-shot table degrade quietly when a named clip is absent: states fall back to `clips[0]` (often
10
+ * an attack clip, so an actor "idles" mid-swing) and one-shots are dropped, so deaths and attacks
11
+ * never play. Neither leaves a trace, which makes a mistyped or wrong-pack clip name one of the
12
+ * hardest render bugs to see — the model loads, animates, and is simply wrong forever.
13
+ *
14
+ * Warn once per mount with the names that missed and the names the rig actually has, so the fix
15
+ * (usually deleting the hand-written config and letting `"auto"` derive it) is obvious.
16
+ */
17
+ function warnMissingClips(animation, clips) {
18
+ if (typeof console === "undefined")
19
+ return;
20
+ const available = new Set(clips.map((clip) => clip.name));
21
+ const missing = new Set();
22
+ const { states, oneShots } = animation;
23
+ if (states !== undefined) {
24
+ for (const name of [states.idle, states.walk, states.run]) {
25
+ if (name !== undefined && !available.has(name))
26
+ missing.add(name);
27
+ }
28
+ }
29
+ if (oneShots !== undefined) {
30
+ for (const spec of Object.values(oneShots)) {
31
+ for (const name of typeof spec === "string" ? [spec] : spec) {
32
+ if (!available.has(name))
33
+ missing.add(name);
34
+ }
35
+ }
36
+ }
37
+ if (animation.clip !== undefined && !available.has(animation.clip))
38
+ missing.add(animation.clip);
39
+ if (missing.size === 0)
40
+ return;
41
+ console.warn(`[jgengine] model animation: clip(s) ${[...missing].map((name) => `"${name}"`).join(", ")} ` +
42
+ `not found on this rig. Available: ${clips.map((clip) => clip.name).join(", ")}. ` +
43
+ `States fall back to the first clip and one-shots are skipped — set animation: "auto" to ` +
44
+ `derive states/one-shots from the rig's own clip names.`);
45
+ }
7
46
  /**
8
47
  * The engine's model animation driver as a standalone hook — the same mixer `EntityModel` runs,
9
48
  * for games that render a cloned scene themselves (custom materials, procedural composition).
@@ -12,7 +51,10 @@ import { useGameContext } from "@jgengine/react/provider";
12
51
  * `entity.animation` / `combat.hitReaction` / `entity.died`, held poses, and the death clamp.
13
52
  */
14
53
  export function useModelAnimation(scene, clips, animationInput, instanceId) {
15
- const ctx = useGameContext();
54
+ // Optional: a model must still animate its bind pose / auto clip in a preview or inspector that
55
+ // has no running game — a hard context requirement made every part composition unviewable outside
56
+ // the world, which is half of why #1588 took a session to see.
57
+ const ctx = useOptionalGameContext();
16
58
  // "auto" (stamped by catalog resolution, or set inline) derives states/one-shots from the
17
59
  // loaded GLB's actual clip names; "none" and absent render the bind pose.
18
60
  const animation = useMemo(() => resolveAnimationConfig(animationInput, clips.map((clip) => clip.name)), [animationInput, clips]);
@@ -29,6 +71,7 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
29
71
  stateActionsRef.current = null;
30
72
  return;
31
73
  }
74
+ warnMissingClips(animation, clips);
32
75
  const mixer = new THREE.AnimationMixer(scene);
33
76
  if (states !== undefined) {
34
77
  const clipFor = (name) => THREE.AnimationClip.findByName(clips, name) ?? clips[0];
@@ -138,7 +181,7 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
138
181
  oneShots,
139
182
  ]);
140
183
  useEffect(() => {
141
- if (instanceId === undefined || oneShots === undefined)
184
+ if (ctx === null || instanceId === undefined || oneShots === undefined)
142
185
  return;
143
186
  const fire = (event) => oneShotPlayRef.current?.(event);
144
187
  const offAnimation = ctx.game.events.on("entity.animation", (event) => {
@@ -161,7 +204,7 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
161
204
  }, [ctx, instanceId, oneShots]);
162
205
  useFrame((_state, delta) => {
163
206
  const stateMachine = stateActionsRef.current;
164
- if (stateMachine !== null && states !== undefined && instanceId !== undefined && delta > 0) {
207
+ if (stateMachine !== null && states !== undefined && instanceId !== undefined && delta > 0 && ctx !== null) {
165
208
  const entity = ctx.scene.entity.get(instanceId);
166
209
  if (entity !== null) {
167
210
  const [x, , z] = entity.position;
@@ -1,9 +1,9 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Fragment, useEffect, useMemo, useState } from "react";
2
+ import { Fragment, useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
3
3
  import * as THREE from "three";
4
4
  import { getDocumentLiveSync, subscribeDocumentLiveSyncInstall, } from "@jgengine/core/editor/index";
5
5
  import { placeAuthoredObjects, resolveAuthoredObjects, } from "@jgengine/core/world/authoredObjects";
6
- import { buildRoadRibbon, roundPathCorners } from "@jgengine/core/world/roads";
6
+ import { buildRoadRibbon, GROUND_DECAL_LAYERS, roundPathCorners } from "@jgengine/core/world/roads";
7
7
  import { isScatterPath, resolveScatter } from "@jgengine/core/world/scatterRegion";
8
8
  import { getAssetGenerator } from "@jgengine/core/scene/assetGenerator";
9
9
  import { useGameContext } from "@jgengine/react/provider";
@@ -12,6 +12,7 @@ import { InstancedScatter } from "../scatter/InstancedScatter.js";
12
12
  import { GeneratedAssetInstance } from "./GeneratedAssetRenderer.js";
13
13
  import { registerBuiltinSceneKindRenderers } from "./builtinSceneKindRenderers.js";
14
14
  import { getSceneKindRenderer } from "./sceneKindRenderers.js";
15
+ const noopSubscribe = () => () => undefined;
15
16
  /**
16
17
  * Resolves the document AuthoredScene renders: when a {@link getDocumentLiveSync} bus is
17
18
  * installed (editor host live), subscribe to document patches; otherwise use the prop.
@@ -19,32 +20,20 @@ import { getSceneKindRenderer } from "./sceneKindRenderers.js";
19
20
  * @internal
20
21
  */
21
22
  export function useLiveEditorDocument(document, live = true) {
22
- const [resolved, setResolved] = useState(document);
23
- useEffect(() => {
24
- if (!live) {
25
- setResolved(document);
26
- return;
27
- }
28
- let unsubDoc;
29
- const attach = () => {
23
+ const subscribeLive = useCallback((onStoreChange) => {
24
+ let unsubDoc = getDocumentLiveSync()?.subscribeDocument(onStoreChange);
25
+ const unsubInstall = subscribeDocumentLiveSyncInstall(() => {
30
26
  unsubDoc?.();
31
- unsubDoc = undefined;
32
- const sync = getDocumentLiveSync();
33
- if (sync === null) {
34
- setResolved(document);
35
- return;
36
- }
37
- setResolved(sync.getDocument());
38
- unsubDoc = sync.subscribeDocument((event) => setResolved(event.document));
39
- };
40
- attach();
41
- const unsubInstall = subscribeDocumentLiveSyncInstall(attach);
27
+ unsubDoc = getDocumentLiveSync()?.subscribeDocument(onStoreChange);
28
+ onStoreChange();
29
+ });
42
30
  return () => {
43
31
  unsubInstall();
44
32
  unsubDoc?.();
45
33
  };
46
- }, [document, live]);
47
- return live ? resolved : document;
34
+ }, []);
35
+ const getDocument = useCallback(() => (live ? (getDocumentLiveSync()?.getDocument() ?? document) : document), [live, document]);
36
+ return useSyncExternalStore(live ? subscribeLive : noopSubscribe, getDocument, getDocument);
48
37
  }
49
38
  registerBuiltinSceneKindRenderers();
50
39
  function markerToKindObject(marker) {
@@ -106,7 +95,8 @@ function DrapedPath({ points, width, color, field, }) {
106
95
  // Fillet sharp turns first so bends read as smooth arcs, not overlapping rectangles.
107
96
  const rounded = roundPathCorners(points, Math.max(1.5, width * 0.9), 6);
108
97
  const ribbon = buildRoadRibbon(rounded, width, (x, z) => field.sampleHeight(x, z), {
109
- elevation: 0.18,
98
+ // Generic authored paths (dirt roads, routes, corridors) drape on the shared road decal layer.
99
+ elevation: GROUND_DECAL_LAYERS.road,
110
100
  maxSegmentLength: 1,
111
101
  });
112
102
  const geo = new THREE.BufferGeometry();