@jgengine/shell 0.19.0 → 0.22.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 (215) hide show
  1. package/CHANGELOG.md +364 -0
  2. package/dist/GameHost.d.ts +12 -1
  3. package/dist/GameHost.js +14 -1
  4. package/dist/GamePlayerShell.js +113 -15
  5. package/dist/Shell3dPresentation.d.ts +2 -2
  6. package/dist/Shell3dPresentation.js +49 -7
  7. package/dist/ShellChrome.d.ts +2 -1
  8. package/dist/ShellChrome.js +22 -6
  9. package/dist/ShellHudPresentation.d.ts +11 -1
  10. package/dist/ShellHudPresentation.js +20 -6
  11. package/dist/audio/audioEngine.d.ts +16 -0
  12. package/dist/audio/audioEngine.js +178 -89
  13. package/dist/audio/audioWire.js +2 -2
  14. package/dist/audio/synthEngine.d.ts +7 -1
  15. package/dist/audio/synthEngine.js +103 -38
  16. package/dist/camera/GameCameraRig.js +2 -1
  17. package/dist/camera/GameFirstPersonCamera.d.ts +2 -2
  18. package/dist/camera/GameFirstPersonCamera.js +17 -13
  19. package/dist/camera/cameraRigs.js +8 -2
  20. package/dist/camera/chaseHeading.d.ts +9 -0
  21. package/dist/camera/chaseHeading.js +10 -0
  22. package/dist/camera/customMuzzle.d.ts +14 -0
  23. package/dist/camera/customMuzzle.js +68 -0
  24. package/dist/camera/index.d.ts +1 -0
  25. package/dist/camera/index.js +1 -0
  26. package/dist/camera/rigResolve.d.ts +3 -0
  27. package/dist/camera/rigResolve.js +11 -0
  28. package/dist/combat/ProjectileModels.d.ts +31 -0
  29. package/dist/combat/ProjectileModels.js +70 -0
  30. package/dist/commandSink.d.ts +1 -2
  31. package/dist/commandSink.js +3 -2
  32. package/dist/defineGame.d.ts +12 -5
  33. package/dist/defineGame.js +25 -10
  34. package/dist/devtools/DevtoolsOverlay.d.ts +2 -69
  35. package/dist/devtools/DevtoolsOverlay.js +14 -168
  36. package/dist/devtools/DevtoolsRuntime.d.ts +7 -0
  37. package/dist/devtools/DevtoolsRuntime.js +61 -0
  38. package/dist/devtools/agentBridge.js +1 -1
  39. package/dist/devtools/devtoolsReports.d.ts +68 -0
  40. package/dist/devtools/devtoolsReports.js +100 -0
  41. package/dist/devtools/latencyInstrumentation.d.ts +3 -0
  42. package/dist/devtools/latencyInstrumentation.js +11 -0
  43. package/dist/devtools/playtestProbe.d.ts +4 -0
  44. package/dist/devtools/playtestProbe.js +22 -0
  45. package/dist/drivers/FrameDriver.js +25 -21
  46. package/dist/drivers/HudOnlyDriver.d.ts +6 -1
  47. package/dist/drivers/HudOnlyDriver.js +32 -14
  48. package/dist/environment/EnvironmentScene.js +19 -15
  49. package/dist/environment/terrainGroundColor.d.ts +5 -4
  50. package/dist/environment/terrainGroundColor.js +19 -8
  51. package/dist/inputSink.d.ts +6 -6
  52. package/dist/inputSink.js +60 -21
  53. package/dist/lazyShell3dPresentation.d.ts +9 -0
  54. package/dist/lazyShell3dPresentation.js +24 -0
  55. package/dist/materialOverride.d.ts +42 -0
  56. package/dist/materialOverride.js +95 -36
  57. package/dist/multiplayer.d.ts +33 -3
  58. package/dist/multiplayer.js +105 -22
  59. package/dist/pointer/pointerService.d.ts +2 -0
  60. package/dist/pointer/pointerService.js +12 -2
  61. package/dist/postfx/PostProcessing.d.ts +10 -0
  62. package/dist/postfx/PostProcessing.js +9 -41
  63. package/dist/postfx/gradeShader.d.ts +1 -1
  64. package/dist/postfx/gradeShader.js +30 -2
  65. package/dist/postfx/shadowPrepass.d.ts +3 -0
  66. package/dist/postfx/shadowPrepass.js +18 -0
  67. package/dist/presentationRecovery.d.ts +75 -0
  68. package/dist/presentationRecovery.js +204 -0
  69. package/dist/presentationRecovery3d.d.ts +4 -0
  70. package/dist/presentationRecovery3d.js +32 -0
  71. package/dist/presentationResolve.d.ts +8 -1
  72. package/dist/presentationResolve.js +5 -2
  73. package/dist/render/CascadedShadows.d.ts +7 -2
  74. package/dist/render/CascadedShadows.js +98 -20
  75. package/dist/render/EnvironmentLighting.js +74 -35
  76. package/dist/render/HairCards.d.ts +7 -0
  77. package/dist/render/HairCards.js +23 -0
  78. package/dist/render/SceneModels.js +51 -77
  79. package/dist/render/authoredSurfaceMaterial.d.ts +43 -0
  80. package/dist/render/authoredSurfaceMaterial.js +108 -0
  81. package/dist/render/materialAppearanceSignals.d.ts +23 -0
  82. package/dist/render/materialAppearanceSignals.js +58 -0
  83. package/dist/render/materialAsset.d.ts +98 -0
  84. package/dist/render/materialAsset.js +454 -0
  85. package/dist/render/measureBounds.js +16 -3
  86. package/dist/render/measureCollisionMesh.js +8 -1
  87. package/dist/render/modelAssets.d.ts +8 -0
  88. package/dist/render/modelAssets.js +43 -0
  89. package/dist/render/modelBindPose.d.ts +5 -0
  90. package/dist/render/modelBindPose.js +35 -0
  91. package/dist/render/modelLoad.d.ts +3 -0
  92. package/dist/render/modelLoad.js +9 -0
  93. package/dist/render/modelNodeVisibility.d.ts +6 -0
  94. package/dist/render/modelNodeVisibility.js +18 -0
  95. package/dist/render/modelRender.d.ts +18 -1
  96. package/dist/render/modelRender.js +81 -3
  97. package/dist/render/projectedOverdraw.d.ts +35 -0
  98. package/dist/render/projectedOverdraw.js +204 -0
  99. package/dist/render/resolveModel.d.ts +12 -2
  100. package/dist/render/resolveModel.js +21 -4
  101. package/dist/render/rigNode.d.ts +26 -0
  102. package/dist/render/rigNode.js +32 -0
  103. package/dist/render/spriteRender.d.ts +4 -0
  104. package/dist/render/spriteRender.js +5 -0
  105. package/dist/render/staticShapeInstances.d.ts +34 -0
  106. package/dist/render/staticShapeInstances.js +78 -0
  107. package/dist/render/useFootIk.js +76 -11
  108. package/dist/render/useModelAnimation.d.ts +28 -2
  109. package/dist/render/useModelAnimation.js +194 -69
  110. package/dist/render/useModelInstance.d.ts +25 -0
  111. package/dist/render/useModelInstance.js +43 -0
  112. package/dist/scatter/scatterModels.d.ts +3 -2
  113. package/dist/scatter/scatterModels.js +15 -7
  114. package/dist/scene/AuthoredScene.d.ts +14 -6
  115. package/dist/scene/AuthoredScene.js +30 -9
  116. package/dist/scene/GeneratedAssetRenderer.js +11 -42
  117. package/dist/scene/GrassFieldRenderer.js +20 -2
  118. package/dist/settings/SettingsMenu.js +11 -18
  119. package/dist/settings/SettingsRuntime.d.ts +5 -0
  120. package/dist/settings/SettingsRuntime.js +29 -2
  121. package/dist/shellControlSuspension.d.ts +18 -0
  122. package/dist/shellControlSuspension.js +28 -0
  123. package/dist/shellInputPublication.d.ts +28 -0
  124. package/dist/shellInputPublication.js +49 -0
  125. package/dist/shellPointerInput.d.ts +18 -0
  126. package/dist/shellPointerInput.js +49 -0
  127. package/dist/structures/BuildingKitBatch.d.ts +3 -1
  128. package/dist/structures/BuildingKitBatch.js +34 -59
  129. package/dist/structures/BuildingRenderBudget.d.ts +23 -0
  130. package/dist/structures/BuildingRenderBudget.js +54 -0
  131. package/dist/structures/GeneratedBuilding.js +13 -8
  132. package/dist/structures/buildingKitMaterials.d.ts +20 -0
  133. package/dist/structures/buildingKitMaterials.js +70 -0
  134. package/dist/structures/buildingSpatialBatch.d.ts +11 -0
  135. package/dist/structures/buildingSpatialBatch.js +45 -0
  136. package/dist/terrain/CarvedTerrain.d.ts +2 -1
  137. package/dist/terrain/CarvedTerrain.js +2 -2
  138. package/dist/terrain/GrassField.d.ts +3 -1
  139. package/dist/terrain/GrassField.js +61 -6
  140. package/dist/terrain/grassGeometry.d.ts +13 -0
  141. package/dist/terrain/grassGeometry.js +57 -1
  142. package/dist/terrain/grassMaterial.js +2 -2
  143. package/dist/terrain/index.d.ts +1 -0
  144. package/dist/terrain/index.js +1 -0
  145. package/dist/terrain/terrainDetailMaterial.d.ts +3 -3
  146. package/dist/terrain/terrainDetailMaterial.js +7 -7
  147. package/dist/terrain/terrainMath.d.ts +2 -0
  148. package/dist/terrain/terrainMath.js +2 -0
  149. package/dist/terrain/terrainPaintColor.d.ts +11 -0
  150. package/dist/terrain/terrainPaintColor.js +47 -0
  151. package/dist/touch/TouchControlsOverlay.js +4 -4
  152. package/dist/useShellMultiplayerSync.js +4 -0
  153. package/dist/vfx/ParticleField.d.ts +23 -20
  154. package/dist/vfx/ParticleField.js +168 -50
  155. package/dist/visibility/CullingProvider.js +2 -62
  156. package/dist/visibility/cullingDriver.d.ts +9 -0
  157. package/dist/visibility/cullingDriver.js +68 -0
  158. package/dist/weather/AuthoredWeatherLayer.d.ts +16 -0
  159. package/dist/weather/AuthoredWeatherLayer.js +31 -0
  160. package/dist/weather/DustField.d.ts +2 -1
  161. package/dist/weather/DustField.js +17 -3
  162. package/dist/weather/FireSpreadLayer.d.ts +4 -2
  163. package/dist/weather/FireSpreadLayer.js +5 -11
  164. package/dist/weather/LightningStrike.d.ts +4 -1
  165. package/dist/weather/LightningStrike.js +13 -17
  166. package/dist/weather/RainField.d.ts +2 -1
  167. package/dist/weather/RainField.js +17 -3
  168. package/dist/weather/RainImpactField.d.ts +16 -0
  169. package/dist/weather/RainImpactField.js +89 -0
  170. package/dist/weather/SnowField.d.ts +2 -1
  171. package/dist/weather/SnowField.js +17 -3
  172. package/dist/weather/WeatherLayer.d.ts +11 -2
  173. package/dist/weather/WeatherLayer.js +30 -6
  174. package/dist/weather/index.d.ts +2 -0
  175. package/dist/weather/index.js +2 -0
  176. package/dist/weather/weatherMath.d.ts +2 -0
  177. package/dist/weather/weatherMath.js +8 -0
  178. package/dist/weather/weatherShelter.d.ts +18 -0
  179. package/dist/weather/weatherShelter.js +75 -0
  180. package/dist/weather/weatherUniforms.d.ts +40 -1
  181. package/dist/weather/weatherUniforms.js +29 -9
  182. package/dist/world/AuthoredFlocks.d.ts +22 -0
  183. package/dist/world/AuthoredFlocks.js +87 -0
  184. package/dist/world/StaticObjectModels.d.ts +8 -0
  185. package/dist/world/StaticObjectModels.js +75 -0
  186. package/dist/world/WorldHud.d.ts +14 -6
  187. package/dist/world/WorldHud.js +25 -12
  188. package/dist/world/WorldParticles.d.ts +10 -23
  189. package/dist/world/WorldParticles.js +209 -61
  190. package/dist/world/WorldScene.d.ts +3 -1
  191. package/dist/world/WorldScene.js +41 -26
  192. package/dist/world/staticObjectBatches.d.ts +35 -0
  193. package/dist/world/staticObjectBatches.js +163 -0
  194. package/dist/world/worldBarSamples.d.ts +37 -33
  195. package/dist/world/worldBarSamples.js +117 -75
  196. package/package.json +4 -4
  197. package/skills/ce-handoff/SKILL.md +1 -1
  198. package/skills/game-design/SKILL.md +2 -0
  199. package/skills/jgengine/SKILL.md +23 -2
  200. package/skills/jgengine/api.md +28 -17
  201. package/skills/jgengine/recipes/minimal-game.md +7 -2
  202. package/skills/jgengine-ui/SKILL.md +3 -1
  203. package/skills/jgengine-ui/api.md +219 -70
  204. package/skills/jgengine-ui/capabilities.md +119 -1
  205. package/skills/jgengine-ui/reference-primitives.md +93 -3
  206. package/skills/jgengine-ui/reference.md +2 -0
  207. package/skills/jgengine-world/SKILL.md +12 -3
  208. package/skills/jgengine-world/api.md +201 -58
  209. package/skills/jgengine-world/capabilities.md +116 -3
  210. package/skills/jgengine-world/recipes/character-animation.md +31 -8
  211. package/skills/jgengine-world/recipes/controls.md +4 -0
  212. package/skills/jgengine-world/recipes/npc-senses.md +12 -0
  213. package/skills/jgengine-world/recipes/vehicle-feel.md +3 -1
  214. package/skills/jgengine-world/reference.md +167 -5
  215. package/skills/jgengine-world/visibility.md +28 -1
@@ -1,3 +1,4 @@
1
+ import { reportTextureLoadError } from "@jgengine/core/devtools/textureErrors";
1
2
  import { useThree } from "@react-three/fiber";
2
3
  import { useEffect } from "react";
3
4
  import * as THREE from "three";
@@ -20,6 +21,27 @@ export function EnvironmentLighting({ source, intensity = 0.35, skyColor = "#87b
20
21
  let target;
21
22
  let loadedTexture;
22
23
  let cancelled = false;
24
+ let appliedRotation;
25
+ let appliedRotationValue;
26
+ const previous = scene.environment;
27
+ const previousRotation = scene.environmentRotation;
28
+ const previousIntensity = scene.environmentIntensity;
29
+ const appliedIntensity = source?.intensity ?? intensity;
30
+ scene.environmentIntensity = appliedIntensity;
31
+ const release = () => {
32
+ if (cancelled)
33
+ return;
34
+ cancelled = true;
35
+ if (target !== undefined && scene.environment === target.texture)
36
+ scene.environment = previous;
37
+ if (appliedRotation !== undefined && scene.environmentRotation === appliedRotation && appliedRotation.equals(appliedRotationValue))
38
+ scene.environmentRotation = previousRotation;
39
+ if (scene.environmentIntensity === appliedIntensity)
40
+ scene.environmentIntensity = previousIntensity;
41
+ pmrem.dispose();
42
+ target?.dispose();
43
+ loadedTexture?.dispose();
44
+ };
23
45
  const effective = source?.kind === "gradient" ? source : undefined;
24
46
  const apply = (next, rotation) => {
25
47
  if (cancelled) {
@@ -28,44 +50,61 @@ export function EnvironmentLighting({ source, intensity = 0.35, skyColor = "#87b
28
50
  }
29
51
  target = next;
30
52
  scene.environment = next.texture;
31
- if (rotation !== undefined)
32
- scene.environmentRotation = new THREE.Euler(0, rotation, 0);
53
+ if (rotation !== undefined) {
54
+ appliedRotation = new THREE.Euler(0, rotation, 0);
55
+ appliedRotationValue = appliedRotation.clone();
56
+ scene.environmentRotation = appliedRotation;
57
+ }
33
58
  };
34
- const previous = scene.environment;
35
- const previousRotation = scene.environmentRotation;
36
- if (source?.kind === "hdri") {
37
- const Loader = /\.exr(?:$|\?)/i.test(source.url) ? EXRLoader : RGBELoader;
38
- new Loader().loadAsync(source.url).then((texture) => {
39
- loadedTexture = texture;
40
- texture.mapping = THREE.EquirectangularReflectionMapping;
41
- apply(pmrem.fromEquirectangular(texture), source.rotation);
42
- }).catch(() => undefined);
43
- }
44
- else if (source?.kind === "cube") {
45
- new THREE.CubeTextureLoader().loadAsync(source.urls).then((texture) => {
46
- loadedTexture = texture;
47
- apply(pmrem.fromCubemap(texture));
48
- }).catch(() => undefined);
59
+ try {
60
+ if (source?.kind === "hdri") {
61
+ const Loader = /\.exr(?:$|\?)/i.test(source.url) ? EXRLoader : RGBELoader;
62
+ new Loader().loadAsync(source.url).then((texture) => {
63
+ if (cancelled) {
64
+ texture.dispose();
65
+ return;
66
+ }
67
+ loadedTexture = texture;
68
+ texture.mapping = THREE.EquirectangularReflectionMapping;
69
+ apply(pmrem.fromEquirectangular(texture), source.rotation);
70
+ }).catch((error) => {
71
+ if (cancelled)
72
+ return;
73
+ reportTextureLoadError(source.url);
74
+ console.warn(`[jgengine] environment illumination failed: ${source.url}`, error);
75
+ });
76
+ }
77
+ else if (source?.kind === "cube") {
78
+ new THREE.CubeTextureLoader().loadAsync(source.urls).then((texture) => {
79
+ if (cancelled) {
80
+ texture.dispose();
81
+ return;
82
+ }
83
+ loadedTexture = texture;
84
+ apply(pmrem.fromCubemap(texture));
85
+ }).catch((error) => {
86
+ if (cancelled)
87
+ return;
88
+ for (const url of source.urls)
89
+ reportTextureLoadError(url);
90
+ console.warn(`[jgengine] cube environment illumination failed: ${source.urls.join(", ")}`, error);
91
+ });
92
+ }
93
+ else {
94
+ const probe = buildSkyProbeScene({ skyColor: effective?.sky ?? skyColor, groundColor: effective?.ground ?? groundColor, sunDirection, sunColor: effective?.sun ?? sunColor });
95
+ try {
96
+ apply(pmrem.fromScene(probe, 0.04));
97
+ }
98
+ finally {
99
+ disposeScene(probe);
100
+ }
101
+ }
49
102
  }
50
- else {
51
- const probe = buildSkyProbeScene({ skyColor: effective?.sky ?? skyColor, groundColor: effective?.ground ?? groundColor, sunDirection, sunColor: effective?.sun ?? sunColor });
52
- apply(pmrem.fromScene(probe, 0.04));
53
- disposeScene(probe);
103
+ catch (error) {
104
+ release();
105
+ throw error;
54
106
  }
55
- const sceneWithIntensity = scene;
56
- const previousIntensity = sceneWithIntensity.environmentIntensity;
57
- sceneWithIntensity.environmentIntensity = source?.intensity ?? intensity;
58
- return () => {
59
- cancelled = true;
60
- pmrem.dispose();
61
- target?.dispose();
62
- loadedTexture?.dispose();
63
- scene.environment = previous;
64
- if (previousRotation !== undefined)
65
- scene.environmentRotation = previousRotation;
66
- if (previousIntensity !== undefined)
67
- sceneWithIntensity.environmentIntensity = previousIntensity;
68
- };
107
+ return release;
69
108
  }, [gl, scene, source, intensity, skyColor, groundColor, sunColor, sunDirection?.[0], sunDirection?.[1], sunDirection?.[2]]);
70
109
  return null;
71
110
  }
@@ -0,0 +1,7 @@
1
+ import { type HairCardAuthoring } from "@jgengine/core/material/hairCards";
2
+ import type { MaterialAsset } from "@jgengine/core/material/materialAsset";
3
+ /** Render authored guide ribbons with explicit UVs and strand tangents; motion and collision remain caller-owned. */
4
+ export declare function HairCards({ cards, material }: {
5
+ cards: HairCardAuthoring;
6
+ material: MaterialAsset;
7
+ }): import("react").JSX.Element;
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useMemo } from "react";
3
+ import * as THREE from "three";
4
+ import { buildHairCards } from "@jgengine/core/material/hairCards";
5
+ import { MaterialAssetSurface } from "./materialAsset.js";
6
+ import { useDisposable } from "./useDisposable.js";
7
+ /** Render authored guide ribbons with explicit UVs and strand tangents; motion and collision remain caller-owned. */
8
+ export function HairCards({ cards, material }) {
9
+ const key = JSON.stringify(cards);
10
+ const mesh = useMemo(() => buildHairCards(cards), [key]);
11
+ const geometry = useDisposable(() => {
12
+ const next = new THREE.BufferGeometry();
13
+ next.setAttribute("position", new THREE.Float32BufferAttribute(mesh.positions, 3));
14
+ next.setAttribute("normal", new THREE.Float32BufferAttribute(mesh.normals, 3));
15
+ next.setAttribute("tangent", new THREE.Float32BufferAttribute(mesh.tangents, 4));
16
+ next.setAttribute("uv", new THREE.Float32BufferAttribute(mesh.uvs, 2));
17
+ next.setIndex(mesh.indices);
18
+ next.computeBoundingBox();
19
+ next.computeBoundingSphere();
20
+ return next;
21
+ }, [mesh]);
22
+ return _jsx("mesh", { geometry: geometry, castShadow: true, receiveShadow: true, children: _jsx(MaterialAssetSurface, { asset: material }) });
23
+ }
@@ -1,20 +1,25 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useFrame, useLoader } from "@react-three/fiber";
3
- import { useTexture } from "@react-three/drei";
4
- import { Component, Suspense, useEffect, useMemo, useRef } from "react";
2
+ import { useFrame, useLoader, useThree } from "@react-three/fiber";
3
+ import { Component, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
5
4
  import * as THREE from "three";
5
+ import { parseModelHiddenNodes } from "@jgengine/core/editor/modelNodeVisibility";
6
6
  import { createSpriteClipPlayer } from "@jgengine/core/render/sprite2d";
7
7
  import { reportFallbackSeam } from "@jgengine/core/devtools/fallbackSeams";
8
8
  import { useOptionalGameContext } from "@jgengine/react/provider";
9
- import { sharedGltfLoader } from "./modelLoad.js";
9
+ import { detectKtx2Support, sharedGltfLoader } from "./modelLoad.js";
10
+ import { modelAssetRequests } from "./modelAssets.js";
10
11
  import { measureLocalBounds, reportMeasuredBounds } from "./measureBounds.js";
11
12
  import { measureLocalCollisionTriangles, reportMeasuredCollisionMesh } from "./measureCollisionMesh.js";
12
- import { useModelAnimation } from "./useModelAnimation.js";
13
+ import { useModelInstance } from "./useModelInstance.js";
14
+ import { applyMaterialAssignments, useModelMaterialTextures, MaterialTextureLoader, validateMaterialAssignmentTargets } from "./materialAsset.js";
13
15
  import { useFootIk } from "./useFootIk.js";
16
+ import { resolveRigNode } from "./rigNode.js";
14
17
  import { PartMotionRig } from "./PartMotion.js";
18
+ import { syncSpriteFrame } from "./spriteRender.js";
15
19
  import { applyMaterialOverride } from "../materialOverride.js";
16
- import { applyPaintTextureToMaterials, cacheStandardMaterials, cloneModelScene, createPaintCanvas, disposeClonedMaterials, disposePaintCanvas, syncPaintCanvas, } from "./modelRender.js";
20
+ import { applyPaintTextureToMaterials, cacheStandardMaterials, cloneModelScene, createPaintCanvas, disposeModelScene, ownModelMaterial, disposePaintCanvas, syncPaintCanvas, } from "./modelRender.js";
17
21
  export function EntitySprite({ sprite }) {
22
+ const invalidate = useThree((state) => state.invalidate);
18
23
  const texture = useLoader(THREE.TextureLoader, sprite.clip?.atlas.image ?? sprite.url);
19
24
  const map = useMemo(() => texture.clone(), [texture]);
20
25
  const player = useMemo(() => (sprite.clip === undefined ? null : createSpriteClipPlayer(sprite.clip.atlas)), [sprite.clip]);
@@ -28,7 +33,11 @@ export function EntitySprite({ sprite }) {
28
33
  useEffect(() => {
29
34
  if (player !== null && animation !== undefined)
30
35
  player.play(animation);
31
- }, [player, animation]);
36
+ const frame = player?.frame();
37
+ if (frame !== undefined && sprite.clip !== undefined)
38
+ syncSpriteFrame(map, frame, sprite.clip.atlas.size);
39
+ invalidate();
40
+ }, [player, animation, map, sprite.clip, invalidate]);
32
41
  useFrame((_state, delta) => {
33
42
  if (player === null || sprite.clip === undefined)
34
43
  return;
@@ -36,10 +45,7 @@ export function EntitySprite({ sprite }) {
36
45
  const frame = player.frame();
37
46
  if (frame === undefined)
38
47
  return;
39
- const [atlasWidth, atlasHeight] = sprite.clip.atlas.size;
40
- map.repeat.set(frame.w / atlasWidth, frame.h / atlasHeight);
41
- map.offset.set(frame.x / atlasWidth, 1 - (frame.y + frame.h) / atlasHeight);
42
- map.needsUpdate = true;
48
+ syncSpriteFrame(map, frame, sprite.clip.atlas.size);
43
49
  });
44
50
  useEffect(() => {
45
51
  if (player === null) {
@@ -97,13 +103,14 @@ function BoneAttachment({ rig, model, slot, position, rotation, scale, }) {
97
103
  const rz = rotation?.[2] ?? 0;
98
104
  const s = scale ?? 1;
99
105
  useEffect(() => {
100
- const bone = rig.getObjectByName(slot);
101
- if (bone === undefined) {
106
+ const resolved = resolveRigNode(rig, slot);
107
+ if (resolved.node === undefined) {
102
108
  if (typeof console !== "undefined") {
103
- console.warn(`[jgengine] entityModels attachment: bone/slot "${slot}" not found on the rig`);
109
+ console.warn(`[jgengine] entityModels attachment: ${resolved.diagnostic.message}`);
104
110
  }
105
111
  return;
106
112
  }
113
+ const bone = resolved.node;
107
114
  weaponScene.position.set(px, py, pz);
108
115
  weaponScene.rotation.set(rx, ry, rz);
109
116
  weaponScene.scale.setScalar(s);
@@ -112,80 +119,47 @@ function BoneAttachment({ rig, model, slot, position, rotation, scale, }) {
112
119
  bone.remove(weaponScene);
113
120
  };
114
121
  }, [rig, weaponScene, slot, px, py, pz, rx, ry, rz, s]);
115
- useEffect(() => () => disposeClonedMaterials(weaponScene), [weaponScene]);
122
+ useEffect(() => () => disposeModelScene(weaponScene), [weaponScene]);
116
123
  return null;
117
124
  }
118
125
  /** 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. */
119
126
  function ModelPartGroup({ model, position, rotation, scale, }) {
120
127
  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 }) }) }));
121
128
  }
122
- function ModelMaterialMapsApplier({ scene, maps }) {
123
- const entries = useMemo(() => {
124
- const record = {};
125
- if (maps.color !== undefined)
126
- record.color = maps.color;
127
- if (maps.normal !== undefined)
128
- record.normal = maps.normal;
129
- if (maps.roughness !== undefined)
130
- record.roughness = maps.roughness;
131
- if (maps.ao !== undefined)
132
- record.ao = maps.ao;
133
- if (maps.metalness !== undefined)
134
- record.metalness = maps.metalness;
135
- if (maps.emissive !== undefined)
136
- record.emissive = maps.emissive;
137
- if (maps.height !== undefined)
138
- record.height = maps.height;
139
- return record;
140
- }, [maps.color, maps.normal, maps.roughness, maps.ao, maps.metalness, maps.emissive, maps.height]);
141
- const textures = useTexture(entries);
142
- useEffect(() => {
143
- if (textures.color !== undefined)
144
- textures.color.colorSpace = THREE.SRGBColorSpace;
145
- applyMaterialOverride(scene, {}, { clone: false, textures });
146
- }, [scene, textures]);
147
- return null;
148
- }
149
129
  export function EntityModel({ model, instanceId, measure, }) {
150
- const gltf = useLoader(sharedGltfLoader, model.url);
130
+ parseModelHiddenNodes(model.hiddenNodes);
131
+ const renderer = useThree(state => state.gl);
132
+ if (renderer.isWebGLRenderer)
133
+ detectKtx2Support(renderer);
134
+ const assets = modelAssetRequests(model);
135
+ for (const url of assets.models)
136
+ useLoader.preload(sharedGltfLoader, url);
151
137
  // Optional, not required: measured bounds and paint strokes are live-world extras, and a model
152
138
  // that threw without a running game could not be inspected outside one — which is how a broken
153
139
  // composition stayed undiagnosable in `EntityPreview` (#1588).
154
140
  const ctx = useOptionalGameContext();
155
141
  const material = model.material;
142
+ const materialAssetKey = JSON.stringify((model.materialAssets ?? []).filter(asset => model.materialAssignments?.some(assignment => assignment.materialId === asset.id)));
143
+ const materialAssets = useMemo(() => (model.materialAssets ?? []).filter(asset => model.materialAssignments?.some(assignment => assignment.materialId === asset.id)), [materialAssetKey]);
144
+ const materialAssignments = model.materialAssignments;
145
+ const assignmentSource = useLoader(sharedGltfLoader, materialAssignments?.length ? [model.url] : [])[0]?.scene;
146
+ const assignmentKey = JSON.stringify(materialAssignments);
147
+ useMemo(() => {
148
+ if (assignmentSource !== undefined)
149
+ validateMaterialAssignmentTargets(assignmentSource, materialAssets, materialAssignments, { baseAlphaMode: material?.alphaMode });
150
+ }, [assignmentSource, materialAssets, assignmentKey, material?.alphaMode]);
151
+ for (const urls of assets.textureGroups)
152
+ useLoader.preload(MaterialTextureLoader, urls);
153
+ const materialTextures = useModelMaterialTextures(model);
156
154
  const baseY = model.y ?? 0;
157
155
  const dims = model.dims;
158
- const shadows = model.shadows;
159
- const scene = useMemo(() => {
160
- const cloned = cloneModelScene(gltf.scene, { shadows });
156
+ const configure = useCallback((content) => {
161
157
  if (material !== undefined)
162
- applyMaterialOverride(cloned, material, { clone: false });
163
- return cloned;
164
- }, [gltf, material, shadows]);
165
- const measured = useMemo(() => {
166
- if (model.targetHeight === undefined)
167
- return null;
168
- const box = new THREE.Box3().setFromObject(scene);
169
- const height = box.max.y - box.min.y;
170
- if (!Number.isFinite(height) || height <= 0)
171
- return null;
172
- return {
173
- normalize: model.targetHeight / height,
174
- minY: box.min.y,
175
- centerX: (box.min.x + box.max.x) / 2,
176
- centerZ: (box.min.z + box.max.z) / 2,
177
- };
178
- }, [scene, model.targetHeight]);
179
- const scale = (model.scale ?? 1) * (measured?.normalize ?? 1);
180
- const centered = (model.anchor ?? "center") === "center" && dims !== undefined;
181
- const position = measured !== null
182
- ? [-scale * measured.centerX, baseY - scale * measured.minY, -scale * measured.centerZ]
183
- : centered
184
- ? [-scale * dims.center.x, baseY - scale * dims.minY, -scale * dims.center.z]
185
- : [0, baseY, 0];
186
- useEffect(() => () => {
187
- disposeClonedMaterials(scene);
188
- }, [scene]);
158
+ applyMaterialOverride(content, material, { clone: false, textures: materialTextures.global, ownMaterial: replacement => ownModelMaterial(content, replacement) });
159
+ if (materialAssignments !== undefined)
160
+ applyMaterialAssignments(content, materialAssets ?? [], materialAssignments, materialTextures.assets);
161
+ }, [material, materialTextures, materialAssets, materialAssignments]);
162
+ const { scene, scale, position } = useModelInstance(model, { instanceId, configure });
189
163
  // A model without index-measured dims can't drive the fitted-collider path, so report the live
190
164
  // measurement (the primitive mounts with only this uniform scale + position) instead of letting
191
165
  // the kind fall back to a fixed-size box. Index dims keep priority — skip when they exist.
@@ -193,7 +167,8 @@ export function EntityModel({ model, instanceId, measure, }) {
193
167
  const measureKey = measure?.key;
194
168
  const dimsMaxY = dims?.maxY;
195
169
  const [positionX, positionY, positionZ] = position;
196
- useEffect(() => {
170
+ // Measure before the instance hook's passive animation effects can move imported root nodes.
171
+ useLayoutEffect(() => {
197
172
  if (ctx === null || measureTarget === undefined || measureKey === undefined || dimsMaxY !== undefined)
198
173
  return;
199
174
  const raw = measureLocalBounds(scene);
@@ -207,7 +182,7 @@ export function EntityModel({ model, instanceId, measure, }) {
207
182
  }, [ctx, scene, scale, positionX, positionY, positionZ, measureTarget, measureKey, dimsMaxY]);
208
183
  // Runs with or without index dims: the loaded geometry upgrades whatever box the kind resolved
209
184
  // (fitted or measured) to a hitbox that raycasts the model's own triangles.
210
- useEffect(() => {
185
+ useLayoutEffect(() => {
211
186
  if (ctx === null || measureTarget === undefined || measureKey === undefined)
212
187
  return;
213
188
  const triangles = measureLocalCollisionTriangles(scene, {
@@ -217,7 +192,6 @@ export function EntityModel({ model, instanceId, measure, }) {
217
192
  if (triangles !== null)
218
193
  reportMeasuredCollisionMesh(ctx, measureTarget, measureKey, triangles);
219
194
  }, [ctx, scene, scale, positionX, positionY, positionZ, measureTarget, measureKey]);
220
- useModelAnimation(scene, gltf.animations, model.animation, instanceId);
221
195
  useFootIk(scene, model.ik, ctx, instanceId, baseY);
222
196
  const paintCanvasRef = useRef(null);
223
197
  const paintDrawnCountRef = useRef(0);
@@ -255,7 +229,7 @@ export function EntityModel({ model, instanceId, measure, }) {
255
229
  }
256
230
  paintDrawnCountRef.current = syncPaintCanvas(paintCanvasRef.current, cache.seedColor, strokes, paintDrawnCountRef.current);
257
231
  });
258
- 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}`)))] }));
232
+ const base = (_jsxs(_Fragment, { children: [_jsx("primitive", { object: scene, position: position, scale: [scale, scale, scale] }), (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}`)))] }));
259
233
  const parts = model.parts ?? [];
260
234
  // Any role-tagged part switches the composition onto the procedural part-motion rig:
261
235
  // root bob/flinch/topple plus per-role limb swing, driven from the entity's live state.
@@ -0,0 +1,43 @@
1
+ import * as THREE from "three";
2
+ import type { ModelMaterialOverride } from "@jgengine/core/game/playableGame";
3
+ import { type MaterialOverrideTextures } from "../materialOverride.js";
4
+ /** Serializable PBR settings for an authored primitive surface. Map roles use the model-material contract. */
5
+ export interface AuthoredSurfaceConfig extends Omit<ModelMaterialOverride, "anisotropy" | "normalScale"> {
6
+ /** Physical metres per texture tile on unit boxes/cylinders, including instance and parent scale. Omit to retain authored UVs. */
7
+ repeatMetres?: number;
8
+ /** Texture sampler policy on owned clones; defaults to repeat on both axes and anisotropy 1. */
9
+ wrapping?: "repeat" | "clamp" | "mirror";
10
+ anisotropy?: number;
11
+ /** Physical directional highlight strength [0,1]; `anisotropy` remains the legacy texture sampler setting. */
12
+ surfaceAnisotropy?: number;
13
+ normalScale?: readonly [number, number];
14
+ /** Normalized [0,1] height maps displace in local shape units (Three defaults: scale 1, bias 0). */
15
+ displacementScale?: number;
16
+ displacementBias?: number;
17
+ transparent?: boolean;
18
+ opacity?: number;
19
+ depthWrite?: boolean;
20
+ }
21
+ /** Primitive UV layout whose physical dimensions drive `repeatMetres`. */
22
+ export type SurfaceShape = "box" | "cylinder";
23
+ /** Stable material identity, independent of object/map property insertion order. @internal */
24
+ export declare function authoredSurfaceKey(config: AuthoredSurfaceConfig): string;
25
+ /** Configure owned map clones without changing textures retained in a loader cache. @internal */
26
+ export declare function authoredSurfaceTextures(entries: Record<string, string>, sources: readonly THREE.Texture[], config?: AuthoredSurfaceConfig): MaterialOverrideTextures;
27
+ /** Apply PBR settings and optional metre UVs to an owned standard material. @internal */
28
+ export declare function configureAuthoredSurface(material: THREE.MeshStandardMaterial, config: AuthoredSurfaceConfig, textures: MaterialOverrideTextures, shape: SurfaceShape): void;
29
+ /**
30
+ * Load declared surface-map roles and own the material/map clones (cached textures remain untouched).
31
+ * Colour, emissive, sheen-colour and specular-colour maps are sRGB; numeric maps are linear. Cylinder side metres use the RMS ellipse circumference approximation.
32
+ * Static batches and custom meshes share this lifecycle.
33
+ * @capability authored-surface-material render declared PBR maps with colour-space handling and metre-scale box/cylinder UVs
34
+ */
35
+ export declare function useAuthoredSurfaceMaterial(config: AuthoredSurfaceConfig, shape?: SurfaceShape): THREE.MeshStandardMaterial;
36
+ /**
37
+ * Mount an owned PBR surface material on a mesh.
38
+ * @capability authored-surface-material mount an owned PBR surface material on a mesh
39
+ */
40
+ export declare function AuthoredSurfaceMaterial({ surface, shape }: {
41
+ surface: AuthoredSurfaceConfig;
42
+ shape?: SurfaceShape;
43
+ }): import("react").JSX.Element;
@@ -0,0 +1,108 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useLoader } from "@react-three/fiber";
3
+ import { useMemo } from "react";
4
+ import * as THREE from "three";
5
+ import { MATERIAL_TEXTURE_SEMANTICS } from "@jgengine/core/material/materialAsset";
6
+ import { applyMaterialOverrideToMaterial, requiresPhysicalMaterial } from "../materialOverride.js";
7
+ import { modelMapEntries } from "./modelAssets.js";
8
+ import { useDisposable } from "./useDisposable.js";
9
+ /** Stable material identity, independent of object/map property insertion order. @internal */
10
+ export function authoredSurfaceKey(config) {
11
+ return JSON.stringify(Object.entries({ ...config, maps: modelMapEntries(config.maps ?? {}) }).sort(([a], [b]) => a.localeCompare(b)));
12
+ }
13
+ /** Configure owned map clones without changing textures retained in a loader cache. @internal */
14
+ export function authoredSurfaceTextures(entries, sources, config = {}) {
15
+ const wrapping = config.wrapping === "clamp" ? THREE.ClampToEdgeWrapping : config.wrapping === "mirror" ? THREE.MirroredRepeatWrapping : THREE.RepeatWrapping;
16
+ return Object.fromEntries(Object.keys(entries).map((role, index) => {
17
+ const texture = sources[index].clone();
18
+ texture.colorSpace = MATERIAL_TEXTURE_SEMANTICS[role]?.colorSpace === "srgb" ? THREE.SRGBColorSpace : THREE.NoColorSpace;
19
+ texture.wrapS = wrapping;
20
+ texture.wrapT = wrapping;
21
+ texture.anisotropy = config.anisotropy ?? 1;
22
+ texture.needsUpdate = true;
23
+ return [role, texture];
24
+ }));
25
+ }
26
+ /** Apply PBR settings and optional metre UVs to an owned standard material. @internal */
27
+ export function configureAuthoredSurface(material, config, textures, shape) {
28
+ const tile = config.repeatMetres;
29
+ if (tile !== undefined && (!Number.isFinite(tile) || tile <= 0))
30
+ throw new Error("repeatMetres must be finite and positive");
31
+ if (config.normalScale?.some(value => !Number.isFinite(value)))
32
+ throw new Error("normalScale must be finite");
33
+ if (config.anisotropy !== undefined && (!Number.isFinite(config.anisotropy) || config.anisotropy < 1))
34
+ throw new Error("anisotropy must be finite and >= 1");
35
+ if ([config.displacementScale, config.displacementBias].some(value => value !== undefined && !Number.isFinite(value)))
36
+ throw new Error("displacement settings must be finite");
37
+ const { anisotropy: _samplerAnisotropy, surfaceAnisotropy, ...surface } = config;
38
+ if (requiresPhysicalMaterial({ ...surface, normalScale: surface.normalScale === undefined ? undefined : [...surface.normalScale], anisotropy: surfaceAnisotropy }, textures) && !material.isMeshPhysicalMaterial)
39
+ throw new Error("Physical authored surface settings require MeshPhysicalMaterial");
40
+ applyMaterialOverrideToMaterial(material, { ...surface, normalScale: surface.normalScale === undefined ? undefined : [...surface.normalScale], anisotropy: surfaceAnisotropy }, false, textures);
41
+ if (config.displacementScale !== undefined)
42
+ material.displacementScale = config.displacementScale;
43
+ if (config.displacementBias !== undefined)
44
+ material.displacementBias = config.displacementBias;
45
+ if (config.transparent !== undefined)
46
+ material.transparent = config.transparent;
47
+ if (config.opacity !== undefined)
48
+ material.opacity = config.opacity;
49
+ if (config.depthWrite !== undefined)
50
+ material.depthWrite = config.depthWrite;
51
+ if (config.normalScale !== undefined)
52
+ material.normalScale.set(...config.normalScale);
53
+ if (tile === undefined)
54
+ return;
55
+ const previousCompile = material.onBeforeCompile;
56
+ const previousKey = material.customProgramCacheKey();
57
+ material.onBeforeCompile = (shader, renderer) => {
58
+ previousCompile.call(material, shader, renderer);
59
+ shader.vertexShader = shader.vertexShader.replace("#include <uv_vertex>", `#include <uv_vertex>
60
+ mat4 surfaceTransform = modelMatrix;
61
+ #ifdef USE_INSTANCING
62
+ surfaceTransform = modelMatrix * instanceMatrix;
63
+ #endif
64
+ vec3 surfaceExtent = vec3(length(surfaceTransform[0].xyz), length(surfaceTransform[1].xyz), length(surfaceTransform[2].xyz));
65
+ vec2 surfaceRepeat = ${shape === "box"
66
+ ? "abs(normal.x) > 0.5 ? surfaceExtent.zy : abs(normal.y) > 0.5 ? surfaceExtent.xz : surfaceExtent.xy"
67
+ : "abs(normal.y) > 0.5 ? surfaceExtent.xz * 2.0 : vec2(6.28318530718 * sqrt((surfaceExtent.x * surfaceExtent.x + surfaceExtent.z * surfaceExtent.z) * 0.5), surfaceExtent.y)"};
68
+ surfaceRepeat /= ${tile.toExponential(8)};
69
+ ${Object.entries({ MAP: "vMapUv", NORMALMAP: "vNormalMapUv", ROUGHNESSMAP: "vRoughnessMapUv", AOMAP: "vAoMapUv", METALNESSMAP: "vMetalnessMapUv", EMISSIVEMAP: "vEmissiveMapUv", DISPLACEMENTMAP: "vDisplacementMapUv", ALPHAMAP: "vAlphaMapUv", SHEENCOLORMAP: "vSheenColorMapUv", SHEENROUGHNESSMAP: "vSheenRoughnessMapUv", ANISOTROPYMAP: "vAnisotropyMapUv", CLEARCOATMAP: "vClearcoatMapUv", CLEARCOATROUGHNESSMAP: "vClearcoatRoughnessMapUv", CLEARCOATNORMALMAP: "vClearcoatNormalMapUv", SPECULARINTENSITYMAP: "vSpecularIntensityMapUv", SPECULARCOLORMAP: "vSpecularColorMapUv", TRANSMISSIONMAP: "vTransmissionMapUv", THICKNESSMAP: "vThicknessMapUv", IRIDESCENCEMAP: "vIridescenceMapUv", IRIDESCENCETHICKNESSMAP: "vIridescenceThicknessMapUv" }).map(([role, name]) => {
70
+ return `#ifdef USE_${role}\n ${name} *= surfaceRepeat;\n#endif`;
71
+ }).join("\n")}`);
72
+ };
73
+ material.customProgramCacheKey = () => `${previousKey}|authored-surface:${shape}:${tile}`;
74
+ }
75
+ /**
76
+ * Load declared surface-map roles and own the material/map clones (cached textures remain untouched).
77
+ * Colour, emissive, sheen-colour and specular-colour maps are sRGB; numeric maps are linear. Cylinder side metres use the RMS ellipse circumference approximation.
78
+ * Static batches and custom meshes share this lifecycle.
79
+ * @capability authored-surface-material render declared PBR maps with colour-space handling and metre-scale box/cylinder UVs
80
+ */
81
+ export function useAuthoredSurfaceMaterial(config, shape = "box") {
82
+ const key = authoredSurfaceKey(config);
83
+ const entries = useMemo(() => modelMapEntries(config.maps ?? {}), [key]);
84
+ const loaded = useLoader(THREE.TextureLoader, Object.values(entries));
85
+ const resources = useDisposable(() => {
86
+ const textures = authoredSurfaceTextures(entries, loaded, config);
87
+ const { anisotropy: _samplerAnisotropy, surfaceAnisotropy, ...surface } = config;
88
+ const material = requiresPhysicalMaterial({ ...surface, normalScale: surface.normalScale === undefined ? undefined : [...surface.normalScale], anisotropy: surfaceAnisotropy }, textures) ? new THREE.MeshPhysicalMaterial() : new THREE.MeshStandardMaterial();
89
+ try {
90
+ configureAuthoredSurface(material, config, textures, shape);
91
+ }
92
+ catch (error) {
93
+ material.dispose();
94
+ Object.values(textures).forEach(texture => texture.dispose());
95
+ throw error;
96
+ }
97
+ return [material, ...Object.values(textures)];
98
+ }, [key, entries, loaded, shape]);
99
+ return resources[0];
100
+ }
101
+ /**
102
+ * Mount an owned PBR surface material on a mesh.
103
+ * @capability authored-surface-material mount an owned PBR surface material on a mesh
104
+ */
105
+ export function AuthoredSurfaceMaterial({ surface, shape = "box" }) {
106
+ const material = useAuthoredSurfaceMaterial(surface, shape);
107
+ return _jsx("primitive", { object: material, attach: "material" });
108
+ }
@@ -0,0 +1,23 @@
1
+ import * as THREE from "three";
2
+ import { type MaterialAppearanceBaseline, type MaterialAppearancePolicy, type MaterialAppearanceResponse } from "@jgengine/core/material/appearanceSignals";
3
+ import type { EnvironmentSample } from "@jgengine/core/world/envField";
4
+ /** Immutable resolved baseline for one caller-owned assigned material, including its original card exposure. */
5
+ export interface RenderMaterialAppearanceBaseline extends MaterialAppearanceBaseline {
6
+ cardBacklightExposure: number;
7
+ }
8
+ /**
9
+ * Capture once after assignment; recapture after an authored change. Never capture an already wet material.
10
+ * @capability material-appearance-signals capture immutable resolved dry values from one owned assigned material
11
+ */
12
+ export declare function captureMaterialAppearanceBaseline(material: THREE.MeshStandardMaterial): RenderMaterialAppearanceBaseline;
13
+ /**
14
+ * Apply one sample to an already owned material. Imported/cache materials must be cloned before use.
15
+ * Allocates no textures or materials; clearcoat/sheen require an existing physical material and opt-in policy.
16
+ * @capability material-appearance-signals update one owned material from wetness and local card exposure without global lighting edits
17
+ */
18
+ export declare function applyMaterialAppearanceSignals(material: THREE.MeshStandardMaterial, baseline: MaterialAppearanceBaseline, sample: EnvironmentSample, policy?: MaterialAppearancePolicy): MaterialAppearanceResponse;
19
+ /**
20
+ * Restore the dry baseline when removing an appearance binding; maps and unrelated imported features remain borrowed.
21
+ * @capability material-appearance-signals restore dry material response when removing an environment appearance binding
22
+ */
23
+ export declare function restoreMaterialAppearanceBaseline(material: THREE.MeshStandardMaterial, baseline: RenderMaterialAppearanceBaseline): void;
@@ -0,0 +1,58 @@
1
+ import * as THREE from "three";
2
+ import { resolveMaterialAppearance } from "@jgengine/core/material/appearanceSignals";
3
+ import { setHairCardLightExposure } from "./materialAsset.js";
4
+ /**
5
+ * Capture once after assignment; recapture after an authored change. Never capture an already wet material.
6
+ * @capability material-appearance-signals capture immutable resolved dry values from one owned assigned material
7
+ */
8
+ export function captureMaterialAppearanceBaseline(material) {
9
+ const physical = material;
10
+ return Object.freeze({
11
+ roughness: material.roughness,
12
+ colorLinear: Object.freeze([material.color.r, material.color.g, material.color.b]),
13
+ ...(physical.isMeshPhysicalMaterial ? { sheen: physical.sheen, clearcoat: physical.clearcoat, clearcoatRoughness: physical.clearcoatRoughness } : {}),
14
+ cardBacklightExposure: material.userData.jgHairLightExposure ?? 1,
15
+ });
16
+ }
17
+ /**
18
+ * Apply one sample to an already owned material. Imported/cache materials must be cloned before use.
19
+ * Allocates no textures or materials; clearcoat/sheen require an existing physical material and opt-in policy.
20
+ * @capability material-appearance-signals update one owned material from wetness and local card exposure without global lighting edits
21
+ */
22
+ export function applyMaterialAppearanceSignals(material, baseline, sample, policy = {}) {
23
+ if ((policy.wetness?.sheen !== undefined || policy.wetness?.clearcoat !== undefined) && !material.isMeshPhysicalMaterial)
24
+ throw new TypeError("Wet sheen or clearcoat requires an owned MeshPhysicalMaterial; declare the capability when assigning it.");
25
+ const response = resolveMaterialAppearance(sample, baseline, policy);
26
+ const physical = material;
27
+ if (response.surface.roughness !== undefined)
28
+ material.roughness = response.surface.roughness;
29
+ if (response.colorLinear !== undefined)
30
+ material.color.setRGB(...response.colorLinear, THREE.LinearSRGBColorSpace);
31
+ if (response.surface.sheen !== undefined)
32
+ physical.sheen = response.surface.sheen;
33
+ if (response.surface.clearcoat !== undefined)
34
+ physical.clearcoat = response.surface.clearcoat;
35
+ if (response.surface.clearcoatRoughness !== undefined)
36
+ physical.clearcoatRoughness = response.surface.clearcoatRoughness;
37
+ if (response.backlightExposure !== undefined)
38
+ setHairCardLightExposure(material, response.backlightExposure);
39
+ return response;
40
+ }
41
+ /**
42
+ * Restore the dry baseline when removing an appearance binding; maps and unrelated imported features remain borrowed.
43
+ * @capability material-appearance-signals restore dry material response when removing an environment appearance binding
44
+ */
45
+ export function restoreMaterialAppearanceBaseline(material, baseline) {
46
+ if ((baseline.sheen !== undefined || baseline.clearcoat !== undefined) && !material.isMeshPhysicalMaterial)
47
+ throw new TypeError("The appearance baseline belongs to a physical material.");
48
+ material.roughness = baseline.roughness;
49
+ material.color.setRGB(...baseline.colorLinear, THREE.LinearSRGBColorSpace);
50
+ const physical = material;
51
+ if (baseline.sheen !== undefined)
52
+ physical.sheen = baseline.sheen;
53
+ if (baseline.clearcoat !== undefined)
54
+ physical.clearcoat = baseline.clearcoat;
55
+ if (baseline.clearcoatRoughness !== undefined)
56
+ physical.clearcoatRoughness = baseline.clearcoatRoughness;
57
+ setHairCardLightExposure(material, baseline.cardBacklightExposure);
58
+ }