@jgengine/shell 0.14.0 → 0.15.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.
- package/CHANGELOG.md +500 -1
- package/dist/GamePlayerShell.js +25 -5
- package/dist/Shell3dPresentation.d.ts +6 -6
- package/dist/Shell3dPresentation.js +4 -4
- package/dist/audio/synthEngine.js +12 -2
- package/dist/camera/ControllerCameraShake.d.ts +31 -0
- package/dist/camera/ControllerCameraShake.js +34 -0
- package/dist/camera/GameCameraRig.js +10 -5
- package/dist/camera/GameOrbitCamera.js +1 -10
- package/dist/camera/cameraRigs.js +12 -4
- package/dist/camera/index.d.ts +1 -0
- package/dist/camera/index.js +1 -0
- package/dist/camera/rigMath.d.ts +2 -0
- package/dist/camera/rigMath.js +7 -0
- package/dist/devtools/CollisionDebugWorld.js +2 -2
- package/dist/devtools/DevtoolsOverlay.js +2 -0
- package/dist/drivers/FrameDriver.d.ts +4 -1
- package/dist/drivers/FrameDriver.js +5 -3
- package/dist/environment/DayNightSky.d.ts +33 -0
- package/dist/environment/DayNightSky.js +51 -0
- package/dist/environment/Daylight.d.ts +3 -1
- package/dist/environment/Daylight.js +7 -3
- package/dist/environment/EnvironmentScene.js +6 -6
- package/dist/environment/RoadJunctions.d.ts +8 -4
- package/dist/environment/RoadJunctions.js +16 -15
- package/dist/environment/RoadRibbons.d.ts +42 -6
- package/dist/environment/RoadRibbons.js +150 -27
- package/dist/environment/VolumetricClouds.js +3 -3
- package/dist/environment/index.d.ts +1 -0
- package/dist/environment/index.js +1 -0
- package/dist/gameKit.d.ts +7 -4
- package/dist/gameKit.js +8 -5
- package/dist/inputSink.js +14 -0
- package/dist/materialOverride.js +30 -0
- package/dist/postfx/PostProcessing.d.ts +3 -2
- package/dist/postfx/PostProcessing.js +11 -3
- package/dist/postfx/ScreenEffectsOverlay.d.ts +32 -0
- package/dist/postfx/ScreenEffectsOverlay.js +70 -0
- package/dist/presentationResolve.d.ts +3 -2
- package/dist/presentationResolve.js +11 -6
- package/dist/render/CascadedShadows.d.ts +13 -0
- package/dist/render/CascadedShadows.js +96 -0
- package/dist/render/EnvironmentLighting.d.ts +23 -7
- package/dist/render/EnvironmentLighting.js +69 -10
- package/dist/render/PartMotion.js +4 -4
- package/dist/render/SceneLighting.js +7 -0
- package/dist/render/SceneModels.d.ts +37 -1
- package/dist/render/SceneModels.js +40 -11
- package/dist/render/modelLoad.d.ts +41 -5
- package/dist/render/modelLoad.js +85 -18
- package/dist/render/sceneCapture.d.ts +27 -0
- package/dist/render/sceneCapture.js +43 -0
- package/dist/render/useDisposable.d.ts +16 -0
- package/dist/render/useDisposable.js +24 -0
- package/dist/render/useModelAnimation.js +47 -4
- package/dist/scene/AuthoredScene.js +14 -24
- package/dist/scene/CityRenderer.js +103 -36
- package/dist/scene/GrassFieldRenderer.js +4 -1
- package/dist/scene/PoleLineRenderer.js +7 -16
- package/dist/settings/QuickControls.js +5 -3
- package/dist/settings/SettingsMenu.js +6 -3
- package/dist/settings/appliedSettings.d.ts +3 -1
- package/dist/settings/appliedSettings.js +17 -5
- package/dist/settings/settingsController.js +11 -5
- package/dist/shellMovement.d.ts +15 -1
- package/dist/shellMovement.js +15 -1
- package/dist/structures/GeneratedBuilding.js +4 -5
- package/dist/structures/TransformGizmo.js +6 -5
- package/dist/terrain/CarvedTerrain.js +3 -7
- package/dist/terrain/GrassField.js +2 -2
- package/dist/terrain/ProceduralGround.js +3 -5
- package/dist/terrain/grassMaterial.d.ts +8 -2
- package/dist/terrain/grassMaterial.js +24 -5
- package/dist/terrain/index.d.ts +1 -1
- package/dist/terrain/terrainDetailMaterial.js +11 -2
- package/dist/terrain/terrainMath.d.ts +38 -1
- package/dist/terrain/terrainMath.js +86 -64
- package/dist/touch/TouchControlsOverlay.d.ts +6 -2
- package/dist/touch/TouchControlsOverlay.js +136 -17
- package/dist/vfx/ParticleField.d.ts +32 -0
- package/dist/vfx/ParticleField.js +91 -0
- package/dist/vfx/WorldFloatingText.d.ts +32 -0
- package/dist/vfx/WorldFloatingText.js +32 -0
- package/dist/water/Ocean.js +3 -6
- package/dist/weather/LightningStrike.js +3 -9
- package/dist/weather/RainField.js +3 -7
- package/dist/weather/SnowField.js +3 -7
- package/dist/world/DataObjects.js +3 -6
- package/dist/world/GridWorldScene.js +3 -6
- package/dist/world/InstancedBodies.js +3 -6
- package/dist/world/InstancedJoints.js +2 -2
- package/dist/world/SpriteBatch.js +3 -6
- package/dist/world/WorldEntityFrames.d.ts +47 -0
- package/dist/world/WorldEntityFrames.js +75 -0
- package/dist/world/WorldHud.js +7 -5
- package/dist/world/WorldPings.d.ts +26 -0
- package/dist/world/WorldPings.js +68 -0
- package/dist/world/WorldScene.js +9 -4
- package/dist/world/pingPulse.d.ts +21 -0
- package/dist/world/pingPulse.js +17 -0
- package/package.json +5 -5
- package/skills/ce-handoff/SKILL.md +149 -0
- package/skills/game-design/SKILL.md +2 -0
- package/skills/jgengine/SKILL.md +3 -1
- package/skills/jgengine/api.md +17 -7
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine/recipes/minimal-game.md +7 -4
- package/skills/jgengine-ui/SKILL.md +5 -3
- package/skills/jgengine-ui/api.md +708 -19
- package/skills/jgengine-ui/capabilities.md +362 -1
- package/skills/jgengine-ui/recipes/overhead-entity-frames.md +136 -0
- package/skills/jgengine-ui/reference.md +7 -4
- package/skills/jgengine-world/api.md +397 -11
- package/skills/jgengine-world/capabilities.md +123 -2
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* `
|
|
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 {
|
package/dist/render/modelLoad.js
CHANGED
|
@@ -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
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* `
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
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 =
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
}, [
|
|
47
|
-
|
|
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
|
-
|
|
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();
|