@jgengine/shell 0.13.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 (124) hide show
  1. package/CHANGELOG.md +529 -1
  2. package/dist/CombatPresentation.js +2 -1
  3. package/dist/GamePlayerShell.js +25 -5
  4. package/dist/Shell3dPresentation.d.ts +6 -6
  5. package/dist/Shell3dPresentation.js +4 -4
  6. package/dist/camera/ControllerCameraShake.d.ts +31 -0
  7. package/dist/camera/ControllerCameraShake.js +34 -0
  8. package/dist/camera/GameCameraRig.js +10 -5
  9. package/dist/camera/GameOrbitCamera.js +1 -10
  10. package/dist/camera/cameraRigs.js +16 -4
  11. package/dist/camera/index.d.ts +1 -0
  12. package/dist/camera/index.js +1 -0
  13. package/dist/camera/rigMath.d.ts +2 -0
  14. package/dist/camera/rigMath.js +7 -0
  15. package/dist/devtools/CollisionDebugWorld.js +15 -1
  16. package/dist/devtools/DevtoolsOverlay.js +2 -0
  17. package/dist/devtools/collisionDebugMath.d.ts +8 -0
  18. package/dist/devtools/collisionDebugMath.js +14 -5
  19. package/dist/drivers/FrameDriver.d.ts +4 -1
  20. package/dist/drivers/FrameDriver.js +5 -3
  21. package/dist/environment/DayNightSky.d.ts +33 -0
  22. package/dist/environment/DayNightSky.js +51 -0
  23. package/dist/environment/Daylight.d.ts +3 -1
  24. package/dist/environment/Daylight.js +7 -3
  25. package/dist/environment/EnvironmentScene.js +6 -6
  26. package/dist/environment/RoadJunctions.d.ts +8 -4
  27. package/dist/environment/RoadJunctions.js +16 -15
  28. package/dist/environment/RoadRibbons.d.ts +42 -6
  29. package/dist/environment/RoadRibbons.js +150 -27
  30. package/dist/environment/VolumetricClouds.js +3 -3
  31. package/dist/environment/index.d.ts +1 -0
  32. package/dist/environment/index.js +1 -0
  33. package/dist/gameKit.d.ts +7 -4
  34. package/dist/gameKit.js +8 -5
  35. package/dist/inputSink.js +14 -0
  36. package/dist/materialOverride.js +30 -0
  37. package/dist/postfx/PostProcessing.d.ts +3 -2
  38. package/dist/postfx/PostProcessing.js +38 -5
  39. package/dist/postfx/ScreenEffectsOverlay.d.ts +32 -0
  40. package/dist/postfx/ScreenEffectsOverlay.js +70 -0
  41. package/dist/postfx/postfxOverlay.d.ts +24 -0
  42. package/dist/postfx/postfxOverlay.js +35 -0
  43. package/dist/presentationResolve.d.ts +3 -2
  44. package/dist/presentationResolve.js +11 -6
  45. package/dist/render/CascadedShadows.d.ts +13 -0
  46. package/dist/render/CascadedShadows.js +96 -0
  47. package/dist/render/EntityPreview.d.ts +53 -0
  48. package/dist/render/EntityPreview.js +23 -0
  49. package/dist/render/EnvironmentLighting.d.ts +23 -7
  50. package/dist/render/EnvironmentLighting.js +69 -10
  51. package/dist/render/PartMotion.js +4 -4
  52. package/dist/render/SceneLighting.js +7 -0
  53. package/dist/render/SceneModels.d.ts +37 -1
  54. package/dist/render/SceneModels.js +52 -10
  55. package/dist/render/measureBounds.js +4 -0
  56. package/dist/render/measureCollisionMesh.d.ts +26 -0
  57. package/dist/render/measureCollisionMesh.js +108 -0
  58. package/dist/render/modelLoad.d.ts +41 -5
  59. package/dist/render/modelLoad.js +85 -18
  60. package/dist/render/sceneCapture.d.ts +27 -0
  61. package/dist/render/sceneCapture.js +43 -0
  62. package/dist/render/useDisposable.d.ts +16 -0
  63. package/dist/render/useDisposable.js +24 -0
  64. package/dist/render/useModelAnimation.js +47 -4
  65. package/dist/scene/AuthoredScene.js +14 -24
  66. package/dist/scene/CityRenderer.js +103 -36
  67. package/dist/scene/GrassFieldRenderer.js +4 -1
  68. package/dist/scene/PoleLineRenderer.js +7 -16
  69. package/dist/settings/QuickControls.js +5 -3
  70. package/dist/settings/SettingsMenu.js +6 -3
  71. package/dist/settings/appliedSettings.d.ts +3 -1
  72. package/dist/settings/appliedSettings.js +17 -5
  73. package/dist/settings/settingsController.js +11 -5
  74. package/dist/shellMovement.d.ts +15 -1
  75. package/dist/shellMovement.js +15 -1
  76. package/dist/structures/GeneratedBuilding.js +4 -5
  77. package/dist/structures/TransformGizmo.js +6 -5
  78. package/dist/terrain/CarvedTerrain.js +3 -7
  79. package/dist/terrain/GrassField.js +2 -2
  80. package/dist/terrain/ProceduralGround.js +3 -5
  81. package/dist/terrain/grassMaterial.d.ts +8 -2
  82. package/dist/terrain/grassMaterial.js +24 -5
  83. package/dist/terrain/index.d.ts +1 -1
  84. package/dist/terrain/terrainDetailMaterial.js +11 -2
  85. package/dist/terrain/terrainMath.d.ts +38 -1
  86. package/dist/terrain/terrainMath.js +86 -64
  87. package/dist/touch/TouchControlsOverlay.d.ts +6 -2
  88. package/dist/touch/TouchControlsOverlay.js +136 -17
  89. package/dist/vfx/ParticleField.d.ts +32 -0
  90. package/dist/vfx/ParticleField.js +91 -0
  91. package/dist/vfx/WorldFloatingText.d.ts +32 -0
  92. package/dist/vfx/WorldFloatingText.js +32 -0
  93. package/dist/water/Ocean.js +3 -6
  94. package/dist/weather/LightningStrike.js +3 -9
  95. package/dist/weather/RainField.js +3 -7
  96. package/dist/weather/SnowField.js +3 -7
  97. package/dist/world/DataObjects.js +3 -6
  98. package/dist/world/GridWorldScene.js +3 -6
  99. package/dist/world/InstancedBodies.js +3 -6
  100. package/dist/world/InstancedJoints.js +2 -2
  101. package/dist/world/SpriteBatch.js +3 -6
  102. package/dist/world/WorldEntityFrames.d.ts +47 -0
  103. package/dist/world/WorldEntityFrames.js +75 -0
  104. package/dist/world/WorldHud.js +7 -5
  105. package/dist/world/WorldPings.d.ts +26 -0
  106. package/dist/world/WorldPings.js +68 -0
  107. package/dist/world/WorldScene.js +9 -4
  108. package/dist/world/pingPulse.d.ts +21 -0
  109. package/dist/world/pingPulse.js +17 -0
  110. package/package.json +5 -5
  111. package/skills/ce-handoff/SKILL.md +149 -0
  112. package/skills/game-design/SKILL.md +2 -0
  113. package/skills/jgengine/SKILL.md +7 -3
  114. package/skills/jgengine/api.md +17 -7
  115. package/skills/jgengine/capabilities.md +12 -0
  116. package/skills/jgengine/recipes/minimal-game.md +7 -4
  117. package/skills/jgengine-ui/SKILL.md +8 -3
  118. package/skills/jgengine-ui/api.md +732 -23
  119. package/skills/jgengine-ui/capabilities.md +371 -2
  120. package/skills/jgengine-ui/recipes/overhead-entity-frames.md +136 -0
  121. package/skills/jgengine-ui/reference.md +7 -4
  122. package/skills/jgengine-world/api.md +408 -12
  123. package/skills/jgengine-world/capabilities.md +127 -2
  124. package/skills/jgengine-world/reference.md +15 -1
@@ -1,27 +1,28 @@
1
1
  import { useThree } from "@react-three/fiber";
2
2
  import { useEffect } from "react";
3
3
  import * as THREE from "three";
4
- import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
5
4
  /**
6
- * Sets a neutral, network-free image-based-lighting environment on the scene so every
7
- * `MeshStandardMaterial` — models and un-modeled primitive fallbacks alike — picks up soft
8
- * reflections and fill instead of rendering dead-flat. A game's own sky/lighting still layers on
9
- * top; this only supplies the reflective ambient the shell never had. Opt out with `look: "flat"`.
5
+ * Image-based lighting from a procedural sky/ground probe (not three's stock
6
+ * RoomEnvironment gray studio box). Metals and dielectrics pick up sky color and
7
+ * an optional sun glint; re-render when sky/sun props change so a daylight cycle
8
+ * regenerates the environment map with the authored world.
9
+ *
10
+ * Opt out of cinematic IBL with `look: "flat"` on the shell presentation.
10
11
  * @internal shell-internal default lighting; games never import it.
11
12
  */
12
- export function EnvironmentLighting({ intensity = 0.28 }) {
13
+ export function EnvironmentLighting({ intensity = 0.35, skyColor = "#87b5e0", groundColor = "#3d4a38", sunDirection, sunColor = "#fff2d6", }) {
13
14
  const gl = useThree((state) => state.gl);
14
15
  const scene = useThree((state) => state.scene);
15
16
  useEffect(() => {
16
17
  const pmrem = new THREE.PMREMGenerator(gl);
17
- const room = new RoomEnvironment();
18
- const target = pmrem.fromScene(room, 0.04);
18
+ const probe = buildSkyProbeScene({ skyColor, groundColor, sunDirection, sunColor });
19
+ const target = pmrem.fromScene(probe, 0.04);
19
20
  const previous = scene.environment;
20
21
  scene.environment = target.texture;
21
22
  const sceneWithIntensity = scene;
22
23
  const previousIntensity = sceneWithIntensity.environmentIntensity;
23
24
  sceneWithIntensity.environmentIntensity = intensity;
24
- room.dispose?.();
25
+ disposeScene(probe);
25
26
  pmrem.dispose();
26
27
  return () => {
27
28
  target.dispose();
@@ -29,6 +30,64 @@ export function EnvironmentLighting({ intensity = 0.28 }) {
29
30
  if (previousIntensity !== undefined)
30
31
  sceneWithIntensity.environmentIntensity = previousIntensity;
31
32
  };
32
- }, [gl, scene, intensity]);
33
+ }, [gl, scene, intensity, skyColor, groundColor, sunColor, sunDirection?.[0], sunDirection?.[1], sunDirection?.[2]]);
33
34
  return null;
34
35
  }
36
+ function buildSkyProbeScene(options) {
37
+ const probe = new THREE.Scene();
38
+ // Inward-facing sphere: sky above, ground below, so PMREM samples a sky dome not a studio box.
39
+ const geo = new THREE.SphereGeometry(50, 32, 16);
40
+ const mat = new THREE.ShaderMaterial({
41
+ side: THREE.BackSide,
42
+ depthWrite: false,
43
+ uniforms: {
44
+ sky: { value: new THREE.Color(options.skyColor) },
45
+ ground: { value: new THREE.Color(options.groundColor) },
46
+ },
47
+ vertexShader: /* glsl */ `
48
+ varying vec3 vDir;
49
+ void main() {
50
+ vDir = normalize(position);
51
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
52
+ }
53
+ `,
54
+ fragmentShader: /* glsl */ `
55
+ uniform vec3 sky;
56
+ uniform vec3 ground;
57
+ varying vec3 vDir;
58
+ void main() {
59
+ float t = clamp(vDir.y * 0.5 + 0.5, 0.0, 1.0);
60
+ // Soft horizon blend so the equator is not a hard band.
61
+ float w = smoothstep(0.0, 1.0, t);
62
+ gl_FragColor = vec4(mix(ground, sky, w), 1.0);
63
+ }
64
+ `,
65
+ });
66
+ probe.add(new THREE.Mesh(geo, mat));
67
+ if (options.sunDirection !== undefined) {
68
+ const dir = new THREE.Vector3(options.sunDirection[0], options.sunDirection[1], options.sunDirection[2]);
69
+ if (dir.lengthSq() > 1e-8) {
70
+ dir.normalize();
71
+ const sunGeo = new THREE.SphereGeometry(3.5, 16, 12);
72
+ const sunMat = new THREE.MeshBasicMaterial({ color: options.sunColor });
73
+ const sun = new THREE.Mesh(sunGeo, sunMat);
74
+ // Place the disc outside the sky sphere sample so it stays a compact highlight.
75
+ sun.position.copy(dir.multiplyScalar(42));
76
+ probe.add(sun);
77
+ }
78
+ }
79
+ return probe;
80
+ }
81
+ function disposeScene(scene) {
82
+ scene.traverse((obj) => {
83
+ const mesh = obj;
84
+ if (mesh.isMesh) {
85
+ mesh.geometry?.dispose();
86
+ const material = mesh.material;
87
+ if (Array.isArray(material))
88
+ material.forEach((m) => m.dispose());
89
+ else
90
+ material?.dispose();
91
+ }
92
+ });
93
+ }
@@ -3,7 +3,7 @@ import { useFrame } from "@react-three/fiber";
3
3
  import { useEffect, useMemo, useRef } from "react";
4
4
  import * as THREE from "three";
5
5
  import { createPartPose, partMotionPhase, sampleBodyPose, samplePartPose, } from "@jgengine/core/game/partAnimation";
6
- import { useGameContext } from "@jgengine/react/provider";
6
+ import { useOptionalGameContext } from "@jgengine/react/provider";
7
7
  const FLINCH_RELEASE_SEC = 0.35;
8
8
  const DEATH_RAMP_PER_SEC = 2.5;
9
9
  const SPEED_SMOOTHING = 12;
@@ -16,7 +16,7 @@ const SPEED_SMOOTHING = 12;
16
16
  * as static kit pieces. Children are the base model content (primitive, attachments).
17
17
  */
18
18
  export function PartMotionRig({ parts, model, instanceId, renderPart, children, }) {
19
- const ctx = useGameContext();
19
+ const ctx = useOptionalGameContext();
20
20
  const rootRef = useRef(null);
21
21
  const partRefs = useRef([]);
22
22
  const motionRef = useRef({
@@ -27,7 +27,7 @@ export function PartMotionRig({ parts, model, instanceId, renderPart, children,
27
27
  });
28
28
  const phase = useMemo(() => partMotionPhase(instanceId ?? model.url), [instanceId, model.url]);
29
29
  useEffect(() => {
30
- if (instanceId === undefined)
30
+ if (ctx === null || instanceId === undefined)
31
31
  return;
32
32
  const offHit = ctx.game.events.on("combat.hitReaction", (event) => {
33
33
  if (event.instanceId === instanceId)
@@ -46,7 +46,7 @@ export function PartMotionRig({ parts, model, instanceId, renderPart, children,
46
46
  const pose = useMemo(() => createPartPose(), []);
47
47
  useFrame((frameState, delta) => {
48
48
  const motion = motionRef.current;
49
- if (delta > 0 && instanceId !== undefined) {
49
+ if (delta > 0 && instanceId !== undefined && ctx !== null) {
50
50
  const entity = ctx.scene.entity.get(instanceId);
51
51
  if (entity !== null) {
52
52
  const [x, , z] = entity.position;
@@ -1,6 +1,13 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { CascadedShadows } from "./CascadedShadows.js";
2
3
  const DEFAULT_BACKDROP_FOG_COLOR = "#1a1c22";
4
+ function usesCascades(entry) {
5
+ return (entry.castShadow ?? false) && (entry.cascades ?? 1) > 1;
6
+ }
3
7
  function DirectionalShadowLight({ entry }) {
8
+ if (usesCascades(entry)) {
9
+ return _jsx(CascadedShadows, { entry: entry });
10
+ }
4
11
  const size = entry.shadowCameraSize ?? 40;
5
12
  return (_jsx("directionalLight", { position: [entry.position[0], entry.position[1], entry.position[2]], intensity: entry.intensity ?? 1.3, color: entry.color, castShadow: entry.castShadow ?? false, "shadow-mapSize-width": entry.shadowMapSize ?? 1024, "shadow-mapSize-height": entry.shadowMapSize ?? 1024, "shadow-camera-left": -size, "shadow-camera-right": size, "shadow-camera-top": size, "shadow-camera-bottom": -size, "shadow-camera-near": 0.5, "shadow-camera-far": Math.max(200, size * 6), "shadow-bias": entry.shadowBias ?? -0.0004, "shadow-normalBias": entry.shadowNormalBias ?? 0.02 }));
6
13
  }
@@ -1,8 +1,44 @@
1
- import { type ReactNode } from "react";
1
+ import { Component, type ReactNode } from "react";
2
2
  import type { EntitySpriteConfig, ModelConfig } from "@jgengine/core/game/playableGame";
3
+ import { type FallbackSeam } from "@jgengine/core/devtools/fallbackSeams";
3
4
  export declare function EntitySprite({ sprite }: {
4
5
  sprite: EntitySpriteConfig;
5
6
  }): import("react").JSX.Element;
7
+ /**
8
+ * Catches a render throw from a model subtree and swaps in `fallback` — but never silently: the
9
+ * failed state re-reports to the fallback-seam probe on every render (matching the per-seam frame
10
+ * boundary in `WorldScene`) and the catch warns once with the model url and the error. Without
11
+ * that, a throw anywhere in a composition erases the actor with an empty `probes.fallbacks`.
12
+ * @internal
13
+ */
14
+ export declare class ModelFallbackBoundary extends Component<{
15
+ fallback: ReactNode;
16
+ children: ReactNode;
17
+ url: string;
18
+ seam?: FallbackSeam;
19
+ }, {
20
+ failed: boolean;
21
+ }> {
22
+ state: {
23
+ failed: boolean;
24
+ };
25
+ static getDerivedStateFromError(): {
26
+ failed: boolean;
27
+ };
28
+ componentDidCatch(error: unknown): void;
29
+ render(): ReactNode;
30
+ }
31
+ /**
32
+ * One model subtree behind its own error boundary and Suspense boundary, so a throwing or
33
+ * still-loading piece cannot erase its siblings. Every nested piece of a composition — parts,
34
+ * attachments, material maps — mounts through this.
35
+ * @internal
36
+ */
37
+ export declare function IsolatedModelPart({ model, seam, children, }: {
38
+ model: ModelConfig;
39
+ seam?: FallbackSeam;
40
+ children: ReactNode;
41
+ }): import("react").JSX.Element;
6
42
  export declare function IsolatedEntityModel({ model, instanceId, measure, fallback, }: {
7
43
  model: ModelConfig;
8
44
  instanceId?: string;
@@ -3,9 +3,11 @@ 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";
10
+ import { measureLocalCollisionTriangles, reportMeasuredCollisionMesh } from "./measureCollisionMesh.js";
9
11
  import { useModelAnimation } from "./useModelAnimation.js";
10
12
  import { PartMotionRig } from "./PartMotion.js";
11
13
  import { applyMaterialOverride } from "../materialOverride.js";
@@ -19,17 +21,42 @@ export function EntitySprite({ sprite }) {
19
21
  }, [texture]);
20
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 }) }));
21
23
  }
22
- 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 {
23
32
  state = { failed: false };
24
33
  static getDerivedStateFromError() {
25
34
  return { failed: true };
26
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
+ }
27
42
  render() {
28
- 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;
29
47
  }
30
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
+ }
31
58
  export function IsolatedEntityModel({ model, instanceId, measure, fallback, }) {
32
- 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 }) }) }));
33
60
  }
34
61
  function BoneAttachment({ rig, model, slot, position, rotation, scale, }) {
35
62
  const gltf = useLoader(sharedGltfLoader, model.url);
@@ -62,7 +89,7 @@ function BoneAttachment({ rig, model, slot, position, rotation, scale, }) {
62
89
  }
63
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. */
64
91
  function ModelPartGroup({ model, position, rotation, scale, }) {
65
- 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 }) }) }));
66
93
  }
67
94
  function ModelMaterialMapsApplier({ scene, maps }) {
68
95
  const entries = useMemo(() => {
@@ -87,7 +114,10 @@ function ModelMaterialMapsApplier({ scene, maps }) {
87
114
  }
88
115
  export function EntityModel({ model, instanceId, measure, }) {
89
116
  const gltf = useLoader(sharedGltfLoader, model.url);
90
- 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();
91
121
  const material = model.material;
92
122
  const baseY = model.y ?? 0;
93
123
  const dims = model.dims;
@@ -129,7 +159,7 @@ export function EntityModel({ model, instanceId, measure, }) {
129
159
  const dimsMaxY = dims?.maxY;
130
160
  const [positionX, positionY, positionZ] = position;
131
161
  useEffect(() => {
132
- if (measureTarget === undefined || measureKey === undefined || dimsMaxY !== undefined)
162
+ if (ctx === null || measureTarget === undefined || measureKey === undefined || dimsMaxY !== undefined)
133
163
  return;
134
164
  const raw = measureLocalBounds(scene);
135
165
  if (raw === null)
@@ -140,6 +170,18 @@ export function EntityModel({ model, instanceId, measure, }) {
140
170
  meshCount: raw.meshCount,
141
171
  });
142
172
  }, [ctx, scene, scale, positionX, positionY, positionZ, measureTarget, measureKey, dimsMaxY]);
173
+ // Runs with or without index dims: the loaded geometry upgrades whatever box the kind resolved
174
+ // (fitted or measured) to a hitbox that raycasts the model's own triangles.
175
+ useEffect(() => {
176
+ if (ctx === null || measureTarget === undefined || measureKey === undefined)
177
+ return;
178
+ const triangles = measureLocalCollisionTriangles(scene, {
179
+ scale,
180
+ offset: [positionX, positionY, positionZ],
181
+ });
182
+ if (triangles !== null)
183
+ reportMeasuredCollisionMesh(ctx, measureTarget, measureKey, triangles);
184
+ }, [ctx, scene, scale, positionX, positionY, positionZ, measureTarget, measureKey]);
143
185
  useModelAnimation(scene, gltf.animations, model.animation, instanceId);
144
186
  const paintCanvasRef = useRef(null);
145
187
  const paintDrawnCountRef = useRef(0);
@@ -155,7 +197,7 @@ export function EntityModel({ model, instanceId, measure, }) {
155
197
  materialCacheRef.current = null;
156
198
  }, [scene]);
157
199
  useFrame(() => {
158
- if (instanceId === undefined)
200
+ if (ctx === null || instanceId === undefined)
159
201
  return;
160
202
  const paint = ctx.scene.entity.paint;
161
203
  const version = paint.version(instanceId);
@@ -177,12 +219,12 @@ export function EntityModel({ model, instanceId, measure, }) {
177
219
  }
178
220
  paintDrawnCountRef.current = syncPaintCanvas(paintCanvasRef.current, cache.seedColor, strokes, paintDrawnCountRef.current);
179
221
  });
180
- 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}`)))] }));
181
223
  const parts = model.parts ?? [];
182
224
  // Any role-tagged part switches the composition onto the procedural part-motion rig:
183
225
  // root bob/flinch/topple plus per-role limb swing, driven from the entity's live state.
184
226
  if (parts.some((part) => part.role !== undefined)) {
185
- 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 }));
186
228
  }
187
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)))] }));
188
230
  }
@@ -3,6 +3,7 @@ import { useFrame } from "@react-three/fiber";
3
3
  import { useRef } from "react";
4
4
  import * as THREE from "three";
5
5
  import { useGameContext } from "@jgengine/react/provider";
6
+ import { measureLocalCollisionTriangles, reportMeasuredCollisionMesh } from "./measureCollisionMesh.js";
6
7
  /** Subtrees flagged with this userData key are excluded from bounds measurement — debug gizmos,
7
8
  * selection quads, effect billboards that should not inflate the hitbox. Sprites and invisible
8
9
  * subtrees are always excluded. */
@@ -122,6 +123,9 @@ export function MeasuredBoundsGroup({ target, measureKey, children, }) {
122
123
  return;
123
124
  lastMeshCountRef.current = measured.meshCount;
124
125
  reportMeasuredBounds(ctx, target, measureKey, measured);
126
+ const triangles = measureLocalCollisionTriangles(group);
127
+ if (triangles !== null)
128
+ reportMeasuredCollisionMesh(ctx, target, measureKey, triangles);
125
129
  });
126
130
  return _jsx("group", { ref: groupRef, children: children });
127
131
  }
@@ -0,0 +1,26 @@
1
+ import * as THREE from "three";
2
+ import type { CollisionMeshSource } from "@jgengine/core/scene/collisionMesh";
3
+ import type { GameContext } from "@jgengine/core/runtime/gameContext";
4
+ /** Entity-local triangle soup of the meshes under a measured root, plus the contributing mesh count
5
+ * (same async fill-in detection as measured bounds). Satisfies {@link CollisionMeshSource}. */
6
+ export interface MeasuredCollisionTriangles extends CollisionMeshSource {
7
+ positions: Float32Array;
8
+ indices: Uint32Array;
9
+ triangleCount: number;
10
+ meshCount: number;
11
+ }
12
+ /**
13
+ * Collect the triangle soup of the meshes under `root` in `root`'s own frame — the mesh-accurate
14
+ * counterpart to `measureLocalBounds`, with the same visibility/sprite/{@link MEASURE_EXCLUDE_KEY}
15
+ * exclusions (skinned meshes contribute their bind pose). An optional uniform `scale` + `offset`
16
+ * maps the result into entity-local space when the caller mounts the root scaled/translated (the
17
+ * model-primitive path). Returns `null` when nothing is measurable or the soup exceeds the
18
+ * triangle budget — callers keep their box fallback.
19
+ */
20
+ export declare function measureLocalCollisionTriangles(root: THREE.Object3D, transform?: {
21
+ scale: number;
22
+ offset: readonly [number, number, number];
23
+ }): MeasuredCollisionTriangles | null;
24
+ /** Forward measured triangles into the context (entity kind or object catalog id), deduping repeat
25
+ * reports of the same soup so remounts and multi-instance kinds don't rebuild the BVH. */
26
+ export declare function reportMeasuredCollisionMesh(ctx: GameContext, target: "entity" | "object", key: string, triangles: MeasuredCollisionTriangles): void;
@@ -0,0 +1,108 @@
1
+ import * as THREE from "three";
2
+ import { MEASURE_EXCLUDE_KEY } from "./measureBounds.js";
3
+ /** Above this the model is too dense to double as its own hitbox — keep the fitted box instead of
4
+ * paying BVH build + per-ray traversal on a hero-detail mesh. Game-ready low-poly assets sit far under it. */
5
+ const MAX_COLLISION_TRIANGLES = 60_000;
6
+ const IDENTITY = new THREE.Matrix4();
7
+ const tmpVec = new THREE.Vector3();
8
+ function collectTriangles(object, parentMatrix, sink) {
9
+ if (sink.overflow)
10
+ return;
11
+ if (parentMatrix !== null) {
12
+ if (!object.visible)
13
+ return;
14
+ if (object.isSprite === true)
15
+ return;
16
+ if (object.userData[MEASURE_EXCLUDE_KEY] === true)
17
+ return;
18
+ }
19
+ let matrix = null;
20
+ if (parentMatrix !== null) {
21
+ if (object.matrixAutoUpdate)
22
+ object.updateMatrix();
23
+ matrix = new THREE.Matrix4().multiplyMatrices(parentMatrix, object.matrix);
24
+ }
25
+ const mesh = object;
26
+ if (mesh.isMesh === true && mesh.geometry !== undefined) {
27
+ const geometry = mesh.geometry;
28
+ const positionAttribute = geometry.getAttribute("position");
29
+ if (positionAttribute !== undefined && positionAttribute.itemSize === 3 && positionAttribute.count >= 3) {
30
+ const base = sink.positions.length / 3;
31
+ const applied = matrix ?? IDENTITY;
32
+ for (let v = 0; v < positionAttribute.count; v += 1) {
33
+ tmpVec.fromBufferAttribute(positionAttribute, v).applyMatrix4(applied);
34
+ sink.positions.push(tmpVec.x, tmpVec.y, tmpVec.z);
35
+ }
36
+ const index = geometry.getIndex();
37
+ const triangleCount = index !== null ? Math.floor(index.count / 3) : Math.floor(positionAttribute.count / 3);
38
+ if (sink.triangleCount + triangleCount > MAX_COLLISION_TRIANGLES) {
39
+ sink.overflow = true;
40
+ return;
41
+ }
42
+ if (index !== null) {
43
+ for (let i = 0; i + 2 < index.count; i += 3) {
44
+ sink.indices.push(base + index.getX(i), base + index.getX(i + 1), base + index.getX(i + 2));
45
+ }
46
+ }
47
+ else {
48
+ for (let i = 0; i + 2 < positionAttribute.count; i += 3) {
49
+ sink.indices.push(base + i, base + i + 1, base + i + 2);
50
+ }
51
+ }
52
+ sink.triangleCount += triangleCount;
53
+ sink.meshCount += 1;
54
+ }
55
+ }
56
+ const childMatrix = matrix ?? IDENTITY;
57
+ for (const child of object.children)
58
+ collectTriangles(child, childMatrix, sink);
59
+ }
60
+ /**
61
+ * Collect the triangle soup of the meshes under `root` in `root`'s own frame — the mesh-accurate
62
+ * counterpart to `measureLocalBounds`, with the same visibility/sprite/{@link MEASURE_EXCLUDE_KEY}
63
+ * exclusions (skinned meshes contribute their bind pose). An optional uniform `scale` + `offset`
64
+ * maps the result into entity-local space when the caller mounts the root scaled/translated (the
65
+ * model-primitive path). Returns `null` when nothing is measurable or the soup exceeds the
66
+ * triangle budget — callers keep their box fallback.
67
+ */
68
+ export function measureLocalCollisionTriangles(root, transform) {
69
+ const sink = { positions: [], indices: [], meshCount: 0, triangleCount: 0, overflow: false };
70
+ collectTriangles(root, null, sink);
71
+ if (sink.overflow || sink.meshCount === 0 || sink.triangleCount === 0)
72
+ return null;
73
+ const positions = new Float32Array(sink.positions);
74
+ if (transform !== undefined) {
75
+ const { scale, offset } = transform;
76
+ for (let v = 0; v < positions.length; v += 3) {
77
+ positions[v] = positions[v] * scale + offset[0];
78
+ positions[v + 1] = positions[v + 1] * scale + offset[1];
79
+ positions[v + 2] = positions[v + 2] * scale + offset[2];
80
+ }
81
+ }
82
+ return {
83
+ positions,
84
+ indices: new Uint32Array(sink.indices),
85
+ triangleCount: sink.triangleCount,
86
+ meshCount: sink.meshCount,
87
+ };
88
+ }
89
+ const reportedByContext = new WeakMap();
90
+ /** Forward measured triangles into the context (entity kind or object catalog id), deduping repeat
91
+ * reports of the same soup so remounts and multi-instance kinds don't rebuild the BVH. */
92
+ export function reportMeasuredCollisionMesh(ctx, target, key, triangles) {
93
+ let map = reportedByContext.get(ctx);
94
+ if (map === undefined) {
95
+ map = new Map();
96
+ reportedByContext.set(ctx, map);
97
+ }
98
+ const registryKey = `${target}:${key}`;
99
+ const prev = map.get(registryKey);
100
+ if (prev !== undefined && prev.meshCount === triangles.meshCount && prev.triangleCount === triangles.triangleCount) {
101
+ return;
102
+ }
103
+ const accepted = target === "entity"
104
+ ? ctx.scene.entity.reportCollisionMesh(key, triangles)
105
+ : ctx.scene.object.reportCollisionMesh(key, triangles);
106
+ if (accepted)
107
+ map.set(registryKey, { meshCount: triangles.meshCount, triangleCount: triangles.triangleCount });
108
+ }
@@ -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 {