@jgengine/shell 0.26.0 → 0.28.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 (91) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/dist/GamePlayerShell.d.ts +8 -0
  3. package/dist/GamePlayerShell.js +15 -1
  4. package/dist/GameUiPreview.js +3 -1
  5. package/dist/Shell3dPresentation.js +6 -4
  6. package/dist/ShellChrome.js +3 -1
  7. package/dist/camera/GameCameraRig.js +2 -2
  8. package/dist/camera/GameFirstPersonCamera.js +39 -2
  9. package/dist/camera/cameraCollision.d.ts +66 -1
  10. package/dist/camera/cameraCollision.js +72 -1
  11. package/dist/camera/index.d.ts +1 -1
  12. package/dist/camera/index.js +1 -1
  13. package/dist/camera/rigResolve.d.ts +13 -0
  14. package/dist/camera/rigResolve.js +16 -0
  15. package/dist/environment/EnvironmentScene.js +118 -10
  16. package/dist/postfx/shadowPrepass.d.ts +8 -1
  17. package/dist/postfx/shadowPrepass.js +18 -2
  18. package/dist/postfx/stylizePass.js +2 -1
  19. package/dist/render/CascadedShadows.d.ts +31 -1
  20. package/dist/render/CascadedShadows.js +71 -5
  21. package/dist/render/CharacterCrowd.d.ts +20 -0
  22. package/dist/render/CharacterCrowd.js +176 -0
  23. package/dist/render/SceneLighting.js +27 -3
  24. package/dist/render/SceneWarmupGate.d.ts +20 -0
  25. package/dist/render/SceneWarmupGate.js +48 -0
  26. package/dist/render/characterLod.d.ts +62 -0
  27. package/dist/render/characterLod.js +165 -0
  28. package/dist/render/crowdRegistry.d.ts +48 -0
  29. package/dist/render/crowdRegistry.js +97 -0
  30. package/dist/render/decalGeometry.d.ts +26 -0
  31. package/dist/render/decalGeometry.js +54 -0
  32. package/dist/render/decalTexture.d.ts +13 -0
  33. package/dist/render/decalTexture.js +114 -0
  34. package/dist/render/materialAsset.js +2 -1
  35. package/dist/render/modelLoad.js +3 -0
  36. package/dist/render/modelMeshMerge.d.ts +49 -0
  37. package/dist/render/modelMeshMerge.js +269 -0
  38. package/dist/render/modelWind.d.ts +28 -0
  39. package/dist/render/modelWind.js +103 -0
  40. package/dist/render/shaderWarmup.d.ts +69 -0
  41. package/dist/render/shaderWarmup.js +120 -0
  42. package/dist/render/shadowCasters.d.ts +28 -0
  43. package/dist/render/shadowCasters.js +95 -0
  44. package/dist/render/staticTransforms.d.ts +20 -0
  45. package/dist/render/staticTransforms.js +32 -0
  46. package/dist/render/useFootIk.d.ts +11 -3
  47. package/dist/render/useFootIk.js +35 -7
  48. package/dist/render/useModelAnimation.d.ts +16 -2
  49. package/dist/render/useModelAnimation.js +73 -4
  50. package/dist/render/useModelInstance.d.ts +1 -1
  51. package/dist/render/useModelInstance.js +26 -1
  52. package/dist/render/useRagdoll.d.ts +32 -0
  53. package/dist/render/useRagdoll.js +105 -0
  54. package/dist/scatter/scatterModels.js +2 -0
  55. package/dist/scene/DecalRenderer.d.ts +2 -0
  56. package/dist/scene/DecalRenderer.js +74 -0
  57. package/dist/scene/GrassFieldRenderer.js +3 -2
  58. package/dist/scene/builtinSceneKindRenderers.js +2 -0
  59. package/dist/settings/SettingsMenu.d.ts +1 -1
  60. package/dist/settings/SettingsMenu.js +10 -79
  61. package/dist/settings/settingsController.js +3 -0
  62. package/dist/terrain/GrassCover.d.ts +21 -0
  63. package/dist/terrain/GrassCover.js +84 -0
  64. package/dist/terrain/grassCoverGeometry.d.ts +40 -0
  65. package/dist/terrain/grassCoverGeometry.js +112 -0
  66. package/dist/terrain/terrainDetailMaterial.d.ts +12 -0
  67. package/dist/terrain/terrainDetailMaterial.js +54 -1
  68. package/dist/terrain/terrainSurfaces.d.ts +6 -0
  69. package/dist/terrain/terrainSurfaces.js +10 -0
  70. package/dist/world/StaticObjectModels.d.ts +13 -0
  71. package/dist/world/StaticObjectModels.js +28 -3
  72. package/dist/world/WorldParticles.d.ts +2 -1
  73. package/dist/world/WorldParticles.js +29 -10
  74. package/dist/world/WorldScene.js +52 -14
  75. package/dist/world/entityPose.d.ts +17 -0
  76. package/dist/world/entityPose.js +42 -0
  77. package/dist/world/staticObjectBatches.d.ts +33 -5
  78. package/dist/world/staticObjectBatches.js +40 -6
  79. package/package.json +4 -4
  80. package/skills/jgengine/api.md +2 -2
  81. package/skills/jgengine-ui/SKILL.md +1 -0
  82. package/skills/jgengine-ui/api.md +231 -9
  83. package/skills/jgengine-ui/capabilities.md +107 -1
  84. package/skills/jgengine-ui/reference-primitives.md +2 -0
  85. package/skills/jgengine-ui/reference.md +8 -0
  86. package/skills/jgengine-world/SKILL.md +8 -0
  87. package/skills/jgengine-world/api.md +243 -11
  88. package/skills/jgengine-world/capabilities.md +61 -1
  89. package/skills/jgengine-world/recipes/character-animation.md +25 -2
  90. package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
  91. package/skills/jgengine-world/reference.md +1 -1
@@ -3,10 +3,20 @@ 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 { ANIM_PARAMS_KEY, createAnimGraphRuntime, } from "@jgengine/core/anim/animGraph";
6
- import { animGraphFromConfig, LOCOMOTION_CROUCHED_PARAM, LOCOMOTION_GROUNDED_PARAM, LOCOMOTION_SPEED_PARAM, LOCOMOTION_VERTICAL_SPEED_PARAM } from "@jgengine/core/anim/locomotionGraph";
6
+ import { animGraphFromConfig, LOCOMOTION_CROUCHED_PARAM, LOCOMOTION_DIRECTION_PARAM, LOCOMOTION_FACING_PARAM, LOCOMOTION_GROUNDED_PARAM, LOCOMOTION_SPEED_PARAM, LOCOMOTION_TURN_RATE_PARAM, LOCOMOTION_VERTICAL_SPEED_PARAM, } from "@jgengine/core/anim/locomotionGraph";
7
7
  import { playerMovementTelemetry } from "@jgengine/core/movement/playerMovement";
8
8
  import { seededRng } from "@jgengine/core/random/rng";
9
9
  import { useOptionalGameContext } from "@jgengine/react/provider";
10
+ import { characterLodInterval, characterLodTierOf, noteAnimationPhase, noteCharacterPose } from "./characterLod.js";
11
+ /** Wraps a yaw change into `(-pi, pi]`, so a turn across the wrap boundary is not a full spin. */
12
+ function shortestAngle(radians) {
13
+ const turn = radians % (Math.PI * 2);
14
+ if (turn > Math.PI)
15
+ return turn - Math.PI * 2;
16
+ if (turn < -Math.PI)
17
+ return turn + Math.PI * 2;
18
+ return turn;
19
+ }
10
20
  function graphClipNames(graph) {
11
21
  const names = new Set();
12
22
  for (const layer of graph.layers) {
@@ -102,11 +112,14 @@ function warnAnimationDiagnostics(scene, animation, clips) {
102
112
  /**
103
113
  * Refreshes a caller-owned graph parameter dictionary from authored extras and known physical movement.
104
114
  * Grounded, verticalSpeed and crouched use the shared motor's resolved state when available; custom movers
105
- * retain their authored values. Speed always uses the supplied measured ground speed. Does not write game state.
115
+ * retain their authored values. Speed always uses the supplied measured ground speed. Passing `step`
116
+ * derives the directional parameters from the character's own motion: `direction` is the motion's share
117
+ * along the character's right axis, `facing` its share along the facing axis, and `turnRate` its yaw
118
+ * change per second. Derived values override conflicting authored extras. Does not write game state.
106
119
  *
107
120
  * @capability animation-motion-params read resolved player movement into reusable animation graph parameters
108
121
  */
109
- export function readModelAnimationParams(ctx, instanceId, speed, out) {
122
+ export function readModelAnimationParams(ctx, instanceId, speed, out, step) {
110
123
  const extra = ctx.scene.entity.blackboard.get(instanceId, ANIM_PARAMS_KEY);
111
124
  const motion = playerMovementTelemetry(ctx, instanceId);
112
125
  if (out === extra || out === motion)
@@ -122,6 +135,22 @@ export function readModelAnimationParams(ctx, instanceId, speed, out) {
122
135
  out[LOCOMOTION_CROUCHED_PARAM] = motion.crouching;
123
136
  }
124
137
  out[LOCOMOTION_SPEED_PARAM] = speed;
138
+ if (step !== undefined) {
139
+ const yaw = ctx.scene.entity.get(instanceId)?.rotationY ?? 0;
140
+ const planar = Math.hypot(step.dx, step.dz);
141
+ if (planar > 1e-4) {
142
+ const mx = step.dx / planar;
143
+ const mz = step.dz / planar;
144
+ // The rig faces its own +Z turned by `rotationY`, so its right axis is +X turned the same way.
145
+ out[LOCOMOTION_FACING_PARAM] = mx * Math.sin(yaw) + mz * Math.cos(yaw);
146
+ out[LOCOMOTION_DIRECTION_PARAM] = mx * Math.cos(yaw) - mz * Math.sin(yaw);
147
+ }
148
+ else {
149
+ out[LOCOMOTION_FACING_PARAM] = 0;
150
+ out[LOCOMOTION_DIRECTION_PARAM] = 0;
151
+ }
152
+ out[LOCOMOTION_TURN_RATE_PARAM] = step.seconds > 0 ? step.dyaw / step.seconds : 0;
153
+ }
125
154
  return out;
126
155
  }
127
156
  /** Per-layer action key: a masked or additive layer needs its own clip variant even for a clip another layer plays. */
@@ -157,6 +186,15 @@ function applyGraphClips(actions, clips) {
157
186
  action.time = entry.time;
158
187
  }
159
188
  }
189
+ /** The clip with the largest weight in one graph output: the pose the rig is mostly showing. */
190
+ function dominantPhase(clips) {
191
+ let best;
192
+ for (const entry of clips) {
193
+ if (entry.weight > 0 && (best === undefined || entry.weight > best.weight))
194
+ best = entry;
195
+ }
196
+ return best === undefined ? undefined : { clip: best.clip, time: best.time };
197
+ }
160
198
  /**
161
199
  * Binds a graph's clips to a rig exactly as `useModelAnimation` does (masked layers get filtered
162
200
  * clips, additive layers additive ones) so a host that runs its own `createAnimGraphRuntime`, such as
@@ -241,6 +279,7 @@ function buildGraphPlayback(scene, mixer, graph, clips, rng) {
241
279
  rootBone,
242
280
  rootBindPosition: rootBone?.position.clone() ?? null,
243
281
  lastPos: null,
282
+ lastYaw: null,
244
283
  smoothedSpeed: 0,
245
284
  params: {},
246
285
  };
@@ -274,6 +313,8 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
274
313
  const animationPausedRef = useRef(false);
275
314
  const graphRef = useRef(null);
276
315
  const advancedSecondsRef = useRef(undefined);
316
+ const pendingRef = useRef(0);
317
+ const frameRef = useRef(0);
277
318
  const states = animation?.states;
278
319
  const oneShots = animation?.oneShots;
279
320
  const authoredGraph = animation?.graph;
@@ -393,6 +434,17 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
393
434
  return;
394
435
  playbackDelta = advancedSeconds - previousSeconds;
395
436
  }
437
+ // Distance tiers pose a far rig on fewer frames; the accumulated time keeps the clip at real
438
+ // speed, so a half-rate character walks the same distance, it just reaches its poses less often.
439
+ const interval = characterLodInterval(characterLodTierOf(instanceId));
440
+ if (interval === 0)
441
+ return;
442
+ pendingRef.current += playbackDelta;
443
+ frameRef.current += 1;
444
+ if (interval > 1 && frameRef.current % interval !== 0)
445
+ return;
446
+ playbackDelta = pendingRef.current;
447
+ pendingRef.current = 0;
396
448
  const playback = graphRef.current;
397
449
  if (playback !== null && mixerRef.current !== null) {
398
450
  const params = playback.params;
@@ -400,6 +452,8 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
400
452
  const entity = ctx.scene.entity.get(instanceId);
401
453
  if (entity !== null && playbackDelta > 0) {
402
454
  const [x, , z] = entity.position;
455
+ const yaw = entity.rotationY ?? 0;
456
+ let step;
403
457
  if (playback.lastPos !== null) {
404
458
  const dx = x - playback.lastPos[0];
405
459
  const dy = entity.position[1] - playback.lastPos[1];
@@ -411,11 +465,16 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
411
465
  else {
412
466
  const instantSpeed = Math.hypot(dx, dz) / playbackDelta;
413
467
  playback.smoothedSpeed += (instantSpeed - playback.smoothedSpeed) * Math.min(1, playbackDelta * 12);
468
+ step = { dx, dz, dyaw: playback.lastYaw === null ? 0 : shortestAngle(yaw - playback.lastYaw), seconds: playbackDelta };
414
469
  }
415
470
  }
416
471
  playback.lastPos = [x, entity.position[1], z];
472
+ playback.lastYaw = yaw;
473
+ readModelAnimationParams(ctx, instanceId, playback.smoothedSpeed, params, step);
474
+ }
475
+ else {
476
+ readModelAnimationParams(ctx, instanceId, playback.smoothedSpeed, params);
417
477
  }
418
- readModelAnimationParams(ctx, instanceId, playback.smoothedSpeed, params);
419
478
  }
420
479
  else {
421
480
  for (const key in params)
@@ -426,6 +485,12 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
426
485
  const out = playback.runtime.advance(playbackDelta * (animation?.timeScale ?? 1), params, playback.durations);
427
486
  applyGraphClips(playback.actions, out.clips);
428
487
  mixerRef.current.update(0);
488
+ if (instanceId !== undefined) {
489
+ noteCharacterPose(instanceId);
490
+ const phase = dominantPhase(out.clips);
491
+ if (phase !== undefined)
492
+ noteAnimationPhase(instanceId, phase.clip, phase.time);
493
+ }
429
494
  if (out.rootMotion === true && playback.rootBone !== null && playback.rootBindPosition !== null) {
430
495
  playback.rootBone.position.x = playback.rootBindPosition.x;
431
496
  playback.rootBone.position.z = playback.rootBindPosition.z;
@@ -438,6 +503,10 @@ export function useModelAnimation(scene, clips, animationInput, instanceId) {
438
503
  }
439
504
  if (mixerRef.current !== null && actionRef.current?.isRunning()) {
440
505
  mixerRef.current.update(playbackDelta);
506
+ if (instanceId !== undefined) {
507
+ noteCharacterPose(instanceId);
508
+ noteAnimationPhase(instanceId, actionRef.current.getClip().name, actionRef.current.time);
509
+ }
441
510
  }
442
511
  });
443
512
  }
@@ -1,7 +1,7 @@
1
1
  import { Group, type Object3D } from "three";
2
2
  import type { ModelConfig } from "@jgengine/core/game/playableGame";
3
3
  /** Model loading, placement, shadows and animation for a custom renderer. */
4
- export type ModelInstanceConfig = Pick<ModelConfig, "url" | "scale" | "targetHeight" | "y" | "anchor" | "dims" | "shadows" | "animation" | "hiddenNodes" | "surfaceDetail">;
4
+ export type ModelInstanceConfig = Pick<ModelConfig, "url" | "scale" | "targetHeight" | "y" | "anchor" | "dims" | "shadows" | "animation" | "hiddenNodes" | "surfaceDetail" | "wind" | "lod">;
5
5
  /** Caller-owned styling runs on the isolated clone, never the loader cache. */
6
6
  export interface ModelInstanceOptions {
7
7
  instanceId?: string;
@@ -2,9 +2,11 @@ import { useLoader, useThree } from "@react-three/fiber";
2
2
  import { useEffect, useMemo } from "react";
3
3
  import { Group } from "three";
4
4
  import { parseModelHiddenNodes } from "@jgengine/core/editor/modelNodeVisibility";
5
+ import { registerCharacterRig, unregisterCharacterRig } from "./characterLod.js";
5
6
  import { measureLocalBounds } from "./measureBounds.js";
6
7
  import { detectKtx2Support, sharedGltfLoader } from "./modelLoad.js";
7
8
  import { cloneModelScene, disposeModelScene, modelPlacementTransform } from "./modelRender.js";
9
+ import { applyModelWindToScene } from "./modelWind.js";
8
10
  import { useModelAnimation } from "./useModelAnimation.js";
9
11
  import { applyModelNodeVisibility, modelHiddenNodesKey } from "./modelNodeVisibility.js";
10
12
  /**
@@ -26,6 +28,7 @@ export function useModelInstance(model, options = {}) {
26
28
  const content = cloneModelScene(gltf.scene, { shadows: model.shadows, surfaceDetail: model.surfaceDetail });
27
29
  try {
28
30
  configure?.(content);
31
+ applyModelWindToScene(content, model.wind, model.wind === false || model.wind === undefined ? 4 : model.wind.height ?? 4);
29
32
  for (const diagnostic of applyModelNodeVisibility(content, hiddenNodes))
30
33
  console.warn(`Model ${model.url} hiddenNodes: ${diagnostic.message}`);
31
34
  const scene = new Group().add(content);
@@ -35,9 +38,31 @@ export function useModelInstance(model, options = {}) {
35
38
  disposeModelScene(content);
36
39
  throw error;
37
40
  }
38
- }, [gltf.scene, model.shadows, model.surfaceDetail, configure, hiddenNodesKey]);
41
+ }, [gltf.scene, model.shadows, model.surfaceDetail, model.wind, configure, hiddenNodesKey]);
39
42
  const placement = useMemo(() => modelPlacementTransform(scene, model, bindBounds), [scene, bindBounds, model.scale, model.targetHeight, model.y, model.anchor, model.dims]);
40
43
  useEffect(() => () => disposeModelScene(content), [content]);
44
+ const lodKey = JSON.stringify(model.lod);
45
+ useEffect(() => {
46
+ if (instanceId === undefined)
47
+ return;
48
+ const [parts, bones] = countRig(content);
49
+ if (parts === 0)
50
+ return;
51
+ registerCharacterRig(instanceId, parts, bones);
52
+ return () => unregisterCharacterRig(instanceId);
53
+ }, [content, instanceId, lodKey]);
41
54
  useModelAnimation(content, gltf.animations, model.animation, instanceId);
42
55
  return { content, scene, ...placement };
43
56
  }
57
+ /** Skinned parts and bones a mounted character renders, for the character-LOD probe. */
58
+ function countRig(root) {
59
+ let parts = 0;
60
+ let bones = 0;
61
+ root.traverse((node) => {
62
+ if (node.isSkinnedMesh === true) {
63
+ parts += 1;
64
+ bones = Math.max(bones, node.skeleton.bones.length);
65
+ }
66
+ });
67
+ return [parts, bones];
68
+ }
@@ -0,0 +1,32 @@
1
+ import * as THREE from "three";
2
+ import type { SkeletonMap } from "@jgengine/core/anim/skeletonStandard";
3
+ import type { PhysicsWorld } from "@jgengine/core/physics/physicsWorld";
4
+ /** What {@link useRagdoll} needs: the rig's bone names, the physics world and what arms it. */
5
+ export interface RagdollOptions {
6
+ /** The rig's bone names by standard slot, from `mapRigToStandard` on the loaded scene's bones. */
7
+ map: SkeletonMap;
8
+ /**
9
+ * The physics world the bodies live in. It is stepped by this hook, so give the ragdoll its own
10
+ * world (a private one per dead character) rather than a world shared with live physics. Its
11
+ * bounds' lower Y is the floor the body lands on.
12
+ */
13
+ world: PhysicsWorld;
14
+ /** Arm the ragdoll. On the false → true edge the rig's current world poses become the bodies. */
15
+ active: boolean;
16
+ /** Character height in metres, so body shapes scale. Default 1.8. */
17
+ height?: number;
18
+ }
19
+ /**
20
+ * Turns a rigged character into a physics ragdoll when `active` goes true: the bones' world poses at
21
+ * that moment become box bodies joined by distance links in `world`, and every frame after, each
22
+ * bone's world position and aim come from its body. The physics bodies carry translation only, so
23
+ * each bone is oriented by rotating its death-pose direction-to-child onto the current one, which is
24
+ * what gives the body a skeleton instead of a pile of boxes.
25
+ *
26
+ * While the ragdoll is active it owns the mapped bones, so stop the model's IK for the same
27
+ * character (`ModelConfig.ik` unset) or the two write the same joints. Deactivating removes the
28
+ * bodies and leaves the rig where the ragdoll put it.
29
+ *
30
+ * @capability animation turn a killed rigged character into a physics ragdoll on death
31
+ */
32
+ export declare function useRagdoll(scene: THREE.Object3D, options: RagdollOptions): void;
@@ -0,0 +1,105 @@
1
+ import { useFrame } from "@react-three/fiber";
2
+ import { useEffect, useRef } from "react";
3
+ import * as THREE from "three";
4
+ import { ragdollBoneOrientations, ragdollConfigFromPoses } from "@jgengine/core/anim/ragdollRig";
5
+ import { createRagdoll } from "@jgengine/core/physics/ragdoll";
6
+ import { resolveRigNode } from "./rigNode.js";
7
+ const REFERENCE_HEIGHT = 1.8;
8
+ /**
9
+ * Turns a rigged character into a physics ragdoll when `active` goes true: the bones' world poses at
10
+ * that moment become box bodies joined by distance links in `world`, and every frame after, each
11
+ * bone's world position and aim come from its body. The physics bodies carry translation only, so
12
+ * each bone is oriented by rotating its death-pose direction-to-child onto the current one, which is
13
+ * what gives the body a skeleton instead of a pile of boxes.
14
+ *
15
+ * While the ragdoll is active it owns the mapped bones, so stop the model's IK for the same
16
+ * character (`ModelConfig.ik` unset) or the two write the same joints. Deactivating removes the
17
+ * bodies and leaves the rig where the ragdoll put it.
18
+ *
19
+ * @capability animation turn a killed rigged character into a physics ragdoll on death
20
+ */
21
+ export function useRagdoll(scene, options) {
22
+ const { map, world, active, height = REFERENCE_HEIGHT } = options;
23
+ const ragdollRef = useRef(null);
24
+ const posesRef = useRef([]);
25
+ const bonesRef = useRef({});
26
+ useEffect(() => {
27
+ const bones = {};
28
+ for (const slot of Object.keys(map.slots)) {
29
+ const name = map.slots[slot];
30
+ if (name === undefined)
31
+ continue;
32
+ const node = resolveRigNode(scene, name).node;
33
+ if (node !== undefined)
34
+ bones[slot] = node;
35
+ }
36
+ bonesRef.current = bones;
37
+ return () => {
38
+ bonesRef.current = {};
39
+ };
40
+ }, [scene, map]);
41
+ useEffect(() => {
42
+ if (!active)
43
+ return;
44
+ scene.updateMatrixWorld(true);
45
+ const positions = [];
46
+ const worldPosition = new THREE.Vector3();
47
+ const worldQuaternion = new THREE.Quaternion();
48
+ for (const slot of Object.keys(bonesRef.current)) {
49
+ const bone = bonesRef.current[slot];
50
+ if (bone === undefined)
51
+ continue;
52
+ bone.getWorldPosition(worldPosition);
53
+ bone.getWorldQuaternion(worldQuaternion);
54
+ positions.push({
55
+ slot,
56
+ position: [worldPosition.x, worldPosition.y, worldPosition.z],
57
+ orientation: [worldQuaternion.x, worldQuaternion.y, worldQuaternion.z, worldQuaternion.w],
58
+ });
59
+ }
60
+ if (positions.length < 2)
61
+ return;
62
+ posesRef.current = positions;
63
+ ragdollRef.current = createRagdoll(world, ragdollConfigFromPoses(map, positions, { scale: height / REFERENCE_HEIGHT }));
64
+ return () => {
65
+ ragdollRef.current?.remove();
66
+ for (const id of Object.values(ragdollRef.current?.bones ?? {}))
67
+ world.removeBody(id);
68
+ ragdollRef.current = null;
69
+ posesRef.current = [];
70
+ };
71
+ }, [active, scene, map, world, height]);
72
+ useFrame((_state, delta) => {
73
+ const ragdoll = ragdollRef.current;
74
+ if (ragdoll === null || delta <= 0)
75
+ return;
76
+ world.step(delta);
77
+ const positions = {};
78
+ for (const [slot, id] of Object.entries(ragdoll.bones)) {
79
+ positions[slot] = [world.posX[id], world.posY[id], world.posZ[id]];
80
+ }
81
+ const orientations = ragdollBoneOrientations(posesRef.current, positions);
82
+ const worldPosition = new THREE.Vector3();
83
+ const worldQuaternion = new THREE.Quaternion();
84
+ const parentQuaternion = new THREE.Quaternion();
85
+ for (const [slot, id] of Object.entries(ragdoll.bones)) {
86
+ const bone = bonesRef.current[slot];
87
+ const orientation = orientations[slot];
88
+ if (bone === undefined || orientation === undefined)
89
+ continue;
90
+ worldPosition.set(world.posX[id], world.posY[id], world.posZ[id]);
91
+ worldQuaternion.set(orientation[0], orientation[1], orientation[2], orientation[3]);
92
+ const parent = bone.parent;
93
+ if (parent === null) {
94
+ bone.position.copy(worldPosition);
95
+ bone.quaternion.copy(worldQuaternion);
96
+ }
97
+ else {
98
+ parent.updateWorldMatrix(true, false);
99
+ bone.position.copy(parent.worldToLocal(worldPosition));
100
+ bone.quaternion.copy(parent.getWorldQuaternion(parentQuaternion).invert().multiply(worldQuaternion));
101
+ }
102
+ bone.updateMatrixWorld(true);
103
+ }
104
+ });
105
+ }
@@ -3,6 +3,7 @@ import { parseModelHiddenNodes } from "@jgengine/core/editor/modelNodeVisibility
3
3
  import { applyMaterialOverride } from "../materialOverride.js";
4
4
  import { cloneModelScene, disposeModelScene, ownModelMaterial } from "../render/modelRender.js";
5
5
  import { applyModelSurface } from "../render/modelSurface.js";
6
+ import { applyModelWindToScene } from "../render/modelWind.js";
6
7
  /** The scale + position offset a `ModelConfig` applies before any per-instance transform — same anchor/targetHeight rules as the single-instance entity renderer. */
7
8
  function scatterModelBaseTransform(scene, model) {
8
9
  let scale = model.scale ?? 1;
@@ -43,6 +44,7 @@ export function buildScatterModelSources(gltfScene, model) {
43
44
  throw new Error("Instanced model override maps require an owned texture loader; use EntityModel for authored maps");
44
45
  applyModelSurface(gltfScene);
45
46
  const root = cloneModelScene(gltfScene, { shadows: model.shadows, surfaceDetail: model.surfaceDetail });
47
+ applyModelWindToScene(root, model.wind, model.dims?.maxY ?? model.targetHeight ?? 4);
46
48
  if (model.material !== undefined)
47
49
  applyMaterialOverride(root, model.material, { clone: false, ownMaterial: material => ownModelMaterial(root, material) });
48
50
  root.updateMatrixWorld(true);
@@ -0,0 +1,2 @@
1
+ /** Registers the `decal` marker renderer. Called by `registerBuiltinSceneKindRenderers`. @internal */
2
+ export declare function registerDecalRenderer(): void;
@@ -0,0 +1,74 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useMemo } from "react";
3
+ import * as THREE from "three";
4
+ import { resolveDecalObject } from "@jgengine/core/world/decalKind";
5
+ import { createDecalDrapeGeometry } from "../render/decalGeometry.js";
6
+ import { createDecalTexture } from "../render/decalTexture.js";
7
+ import { useDisposable } from "../render/useDisposable.js";
8
+ import { registerSceneKindRenderer } from "./sceneKindRenderers.js";
9
+ /** Tilts at or above this stand the decal up on a wall instead of draping it on the ground. */
10
+ const WALL_TILT = 45;
11
+ function dataTexture(style, seed) {
12
+ const baked = createDecalTexture(style, seed);
13
+ const texture = new THREE.DataTexture(baked.data, baked.size, baked.size, THREE.RGBAFormat, THREE.UnsignedByteType);
14
+ texture.colorSpace = THREE.SRGBColorSpace;
15
+ texture.magFilter = THREE.LinearFilter;
16
+ texture.minFilter = THREE.LinearMipmapLinearFilter;
17
+ texture.generateMipmaps = true;
18
+ texture.wrapS = THREE.ClampToEdgeWrapping;
19
+ texture.wrapT = THREE.ClampToEdgeWrapping;
20
+ texture.needsUpdate = true;
21
+ return texture;
22
+ }
23
+ function OneDecal({ object, context }) {
24
+ const resolved = useMemo(() => resolveDecalObject(object), [object]);
25
+ const rules = resolved?.rules;
26
+ const style = rules?.style ?? "dirt";
27
+ const seed = rules?.seed ?? "";
28
+ const texture = useDisposable(() => dataTexture(style, seed), [style, seed]);
29
+ const wall = (rules?.tilt ?? 0) >= WALL_TILT;
30
+ const geometry = useDisposable(() => {
31
+ if (resolved === null || rules === undefined)
32
+ return new THREE.BufferGeometry();
33
+ if (wall)
34
+ return new THREE.PlaneGeometry(rules.size, rules.size);
35
+ const draped = createDecalDrapeGeometry({
36
+ size: rules.size,
37
+ rotationY: resolved.rotationY,
38
+ heightAt: context.field.sampleHeight,
39
+ lift: rules.lift,
40
+ });
41
+ const next = new THREE.BufferGeometry();
42
+ next.setAttribute("position", new THREE.BufferAttribute(draped.positions, 3));
43
+ next.setAttribute("normal", new THREE.BufferAttribute(draped.normals, 3));
44
+ next.setAttribute("uv", new THREE.BufferAttribute(draped.uvs, 2));
45
+ next.setIndex(new THREE.BufferAttribute(draped.indices, 1));
46
+ next.computeBoundingSphere();
47
+ return next;
48
+ }, [resolved, rules, wall, context.field]);
49
+ const material = useDisposable(() => new THREE.MeshStandardMaterial({
50
+ map: texture,
51
+ transparent: true,
52
+ depthWrite: false,
53
+ opacity: rules?.opacity ?? 1,
54
+ roughness: 0.92,
55
+ metalness: 0,
56
+ polygonOffset: true,
57
+ polygonOffsetFactor: -4,
58
+ polygonOffsetUnits: -4,
59
+ }), [texture, rules?.opacity]);
60
+ if (resolved === null || rules === undefined)
61
+ return null;
62
+ const [px, py, pz] = resolved.position;
63
+ if (!wall) {
64
+ return _jsx("mesh", { geometry: geometry, material: material, position: [px, py, pz], receiveShadow: true, frustumCulled: true });
65
+ }
66
+ // A standing decal is pushed out along its own normal so it clears the wall it describes.
67
+ const normalX = Math.sin(resolved.rotationY);
68
+ const normalZ = Math.cos(resolved.rotationY);
69
+ return (_jsx("mesh", { geometry: geometry, material: material, position: [px + normalX * rules.offset, py + rules.lift, pz + normalZ * rules.offset], rotation: [THREE.MathUtils.degToRad(rules.tilt - 90), resolved.rotationY, 0], receiveShadow: true, frustumCulled: true }));
70
+ }
71
+ /** Registers the `decal` marker renderer. Called by `registerBuiltinSceneKindRenderers`. @internal */
72
+ export function registerDecalRenderer() {
73
+ registerSceneKindRenderer("decal", ({ objects, context }) => (_jsx(_Fragment, { children: objects.map((object) => (_jsx(OneDecal, { object: object, context: context }, object.id))) })));
74
+ }
@@ -1,9 +1,10 @@
1
- import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useMemo } from "react";
3
3
  import { resolveGrassObject } from "@jgengine/core/world/grassKind";
4
4
  import { clearanceMasksFrom, DEFAULT_CLEARANCE_KINDS } from "@jgengine/core/world/scatterRegion";
5
5
  import { masksInfluence } from "@jgengine/core/world/geometry";
6
6
  import { GrassField } from "../terrain/GrassField.js";
7
+ import { GrassCover } from "../terrain/GrassCover.js";
7
8
  import { registerSceneKindRenderer } from "./sceneKindRenderers.js";
8
9
  /** Volume kinds turf yields to — blades never grow through cracked soil or out of open water. */
9
10
  const GRASS_YIELDS_TO = new Set(["soil", "water"]);
@@ -86,7 +87,7 @@ function OneGrass({ object, context }) {
86
87
  // footprint, and the fade only thins how many *draw* at range in the vertex shader — so a deeper
87
88
  // meadow reads expansive without raising the instance ceiling.
88
89
  const instanceCount = Math.max(1500, Math.min(250000, Math.ceil(rules.density * area[0] * area[1])));
89
- return (_jsx(GrassField, { position: [center[0], center[1], center[2]], area: area, count: instanceCount, density: rules.density, bladeHeight: bladeHeight, bladeWidth: bladeWidth, bladeBend: bladeBend, tuftRadius: rules.tuftRadius, normalLift: rules.normalLift, colorBase: rules.colorBase, colorTip: rules.colorTip, colorGround: context.groundColorAt?.(center[0], center[2]), colorVariation: rules.colorVariation, roughness: rules.roughness, seed: hashSeed(rules.seed.length > 0 ? rules.seed : object.id), heightAt: heightAt, coverageAt: coverageAt, wind: wind, edgeFeather: 2.2, exclude: exclude }));
90
+ return (_jsxs(_Fragment, { children: [_jsx(GrassCover, { position: [center[0], center[1], center[2]], area: area, seed: hashSeed(rules.seed.length > 0 ? rules.seed : object.id), colorBase: rules.colorBase, colorTip: rules.colorTip, strength: rules.groundCover, edgeFeather: 2.2, heightAt: heightAt, coverageAt: coverageAt }), _jsx(GrassField, { position: [center[0], center[1], center[2]], area: area, count: instanceCount, density: rules.density, bladeHeight: bladeHeight, bladeWidth: bladeWidth, bladeBend: bladeBend, tuftRadius: rules.tuftRadius, normalLift: rules.normalLift, colorBase: rules.colorBase, colorTip: rules.colorTip, colorGround: context.groundColorAt?.(center[0], center[2]), colorVariation: rules.colorVariation, roughness: rules.roughness, seed: hashSeed(rules.seed.length > 0 ? rules.seed : object.id), heightAt: heightAt, coverageAt: coverageAt, wind: wind, edgeFeather: 2.2, exclude: exclude })] }));
90
91
  }
91
92
  /** Registers the `grass_field` volume renderer. Called by `registerBuiltinSceneKindRenderers`. @internal */
92
93
  export function registerGrassFieldRenderer() {
@@ -11,6 +11,7 @@ import { registerWaterRenderer } from "./WaterRenderer.js";
11
11
  import { registerSoilPatchRenderer } from "./SoilPatchRenderer.js";
12
12
  import { registerCityRenderer } from "./CityRenderer.js";
13
13
  import { registerPoleLineRenderer } from "./PoleLineRenderer.js";
14
+ import { registerDecalRenderer } from "./DecalRenderer.js";
14
15
  let registered = false;
15
16
  /** Registers the engine's built-in environment scene-kind renderers. Called by `AuthoredScene`. @internal */
16
17
  export function registerBuiltinSceneKindRenderers() {
@@ -23,4 +24,5 @@ export function registerBuiltinSceneKindRenderers() {
23
24
  registerSoilPatchRenderer();
24
25
  registerCityRenderer();
25
26
  registerPoleLineRenderer();
27
+ registerDecalRenderer();
26
28
  }
@@ -1,4 +1,4 @@
1
- import { type SettingsController } from "./settingsController.js";
1
+ import type { SettingsController } from "./settingsController.js";
2
2
  export declare function SettingsMenu({ controller, onClose, initialTab, }: {
3
3
  controller: SettingsController;
4
4
  onClose: () => void;
@@ -1,64 +1,23 @@
1
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useState } from "react";
3
- import { ScrollRail } from "@jgengine/react/scrollRail";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
4
3
  import { useDialogBehavior } from "@jgengine/react/dialogBehavior";
5
- import { useDebouncedCommit } from "@jgengine/react/useDebouncedCommit";
6
- import {} from "./settingsController.js";
4
+ import { ScrollRail } from "@jgengine/react/scrollRail";
5
+ import { SettingsSection } from "@jgengine/react/settingsBlocks";
7
6
  const surface = "var(--jg-surface, #16161d)";
8
7
  const surfaceDeep = "var(--jg-surface-deep, #0b0b11)";
9
8
  const edge = "var(--jg-edge, #2b2b35)";
10
9
  const text = "var(--jg-text, #ececef)";
11
10
  const textDim = "var(--jg-text-dim, #9494a0)";
12
11
  const accent = "var(--jg-accent, #6ee7a8)";
13
- const accentGlow = "var(--jg-accent-glow, rgba(110,231,168,0.35))";
14
12
  const onAccent = "var(--jg-surface-deep, #0b0b0f)";
15
13
  const danger = "var(--jg-danger, #e0574e)";
16
14
  const fontDisplay = "var(--jg-font-display, inherit)";
17
- const rowStyle = { background: "rgba(255,255,255,0.045)", border: "1px solid rgba(255,255,255,0.08)" };
18
- const badgeStyle = { background: surfaceDeep, border: "1px solid rgba(255,255,255,0.12)", color: text };
19
15
  function GlyphClose() {
20
16
  return (_jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", className: "h-4 w-4", "aria-hidden": true, children: _jsx("path", { d: "M6 6l12 12M18 6L6 18" }) }));
21
17
  }
22
- function GlyphReset() {
23
- return (_jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", className: "h-3.5 w-3.5", "aria-hidden": true, children: [_jsx("path", { d: "M3 12a9 9 0 1 0 3-6.7L3 8" }), _jsx("path", { d: "M3 3v5h5" })] }));
24
- }
25
18
  function CloseButton({ onClose }) {
26
19
  return (_jsx("button", { type: "button", onClick: onClose, "aria-label": "Close settings", className: "flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition hover:bg-white/10", style: { color: textDim, border: "1px solid rgba(255,255,255,0.1)" }, children: _jsx(GlyphClose, {}) }));
27
20
  }
28
- function RowShell({ label, control }) {
29
- return (_jsxs("div", { className: "flex items-center justify-between gap-4 rounded-xl px-3.5 py-3", style: rowStyle, children: [_jsx("span", { className: "text-sm", style: { color: text }, children: label }), control] }));
30
- }
31
- function SliderRow({ row }) {
32
- // Mirror the thumb + readout locally; debounce the persisting `set` so a drag doesn't storm the
33
- // settings store (and any graphics rebuild it drives), flushing on release/blur (#1372).
34
- const { value: local, onInput, flush } = useDebouncedCommit(Number(row.value), (next) => row.set(next));
35
- const readout = row.format?.(local) ?? String(local);
36
- return (_jsx(RowShell, { label: row.label, control: _jsxs("div", { className: "flex flex-1 items-center gap-3 pl-4", children: [_jsx("input", { type: "range", min: row.min, max: row.max, step: row.step, value: local, "aria-label": row.label, className: "h-1.5 flex-1 cursor-pointer", style: { accentColor: accent }, onChange: (event) => onInput(Number(event.target.value)), onPointerUp: flush, onKeyUp: flush, onBlur: flush }), _jsx("span", { className: "w-12 shrink-0 text-right text-sm tabular-nums", style: { color: text }, children: readout })] }) }));
37
- }
38
- function ToggleRow({ row }) {
39
- const on = Boolean(row.value);
40
- return (_jsx(RowShell, { label: row.label, control: _jsx("button", { type: "button", role: "switch", "aria-checked": on, "aria-label": row.label, onClick: () => row.set(!on), className: "relative h-7 w-12 shrink-0 rounded-full transition", style: { background: on ? accent : "rgba(255,255,255,0.16)", boxShadow: on ? `0 0 12px ${accentGlow}` : "none" }, children: _jsx("span", { className: `absolute top-1 h-5 w-5 rounded-full bg-white shadow transition-all ${on ? "left-6" : "left-1"}` }) }) }));
41
- }
42
- function SelectRow({ row }) {
43
- const options = row.options ?? [];
44
- const current = String(row.value);
45
- return (_jsx(RowShell, { label: row.label, control: _jsx("div", { role: "group", "aria-label": row.label, className: "flex flex-wrap justify-end gap-1 rounded-lg p-1", style: { background: surfaceDeep }, children: options.map((option) => {
46
- const selected = option.value === current;
47
- return (_jsx("button", { type: "button", onClick: () => row.set(option.value), "aria-pressed": selected, className: "min-h-[34px] rounded-md px-3 text-sm font-medium transition", style: selected ? { background: accent, color: onAccent } : { color: textDim }, children: option.label }, option.value));
48
- }) }) }));
49
- }
50
- function RowControl({ row }) {
51
- if (row.kind === "slider")
52
- return _jsx(SliderRow, { row: row });
53
- if (row.kind === "toggle")
54
- return _jsx(ToggleRow, { row: row });
55
- return _jsx(SelectRow, { row: row });
56
- }
57
- function KeybindRowView({ row, capturing, onCapture, }) {
58
- return (_jsxs("div", { className: "flex items-center justify-between gap-3 rounded-xl px-3.5 py-3", style: rowStyle, children: [_jsx("span", { className: "text-sm", style: { color: text }, children: row.label }), _jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [_jsx("button", { type: "button", onClick: onCapture, disabled: capturing, title: "Click to rebind \u2014 Esc to cancel", className: "inline-flex min-h-[34px] min-w-[3.75rem] items-center justify-center rounded-lg px-3 font-mono text-sm transition hover:brightness-125", style: capturing
59
- ? { background: accentGlow, color: accent, border: `1px solid ${accent}` }
60
- : badgeStyle, children: capturing ? "Press…" : row.bindingLabel }), _jsx("button", { type: "button", onClick: () => row.reset(), "aria-label": `Reset ${row.label}`, title: "Reset to default", className: `flex h-8 w-8 items-center justify-center rounded-lg transition hover:bg-white/10 ${row.isDefault ? "invisible" : ""}`, style: { color: textDim }, children: _jsx(GlyphReset, {}) })] })] }));
61
- }
62
21
  function ActionRow({ action, onClose }) {
63
22
  const isDanger = action.kind === "danger";
64
23
  return (_jsxs("button", { type: "button", onClick: () => {
@@ -72,10 +31,10 @@ function ActionRow({ action, onClose }) {
72
31
  function TabButton({ tab, selected, orientation, onSelect }) {
73
32
  return (_jsx("button", { type: "button", onClick: onSelect, className: `shrink-0 min-h-[44px] whitespace-nowrap rounded-lg px-4 text-sm font-medium transition ${orientation === "col" ? "w-full text-left" : ""} ${selected ? "" : "hover:bg-white/5"}`, style: selected ? { background: accent, color: onAccent } : { color: textDim }, children: tab.label }));
74
33
  }
75
- function Body({ tab, actions, capturing, setCapturing, onClose }) {
34
+ function Body({ tab, actions, onClose }) {
76
35
  return (_jsx("div", { className: "jg-settings-scroll min-h-0 flex-1 space-y-2 overflow-y-auto p-4", children: tab.category === null
77
36
  ? actions.map((action) => _jsx(ActionRow, { action: action, onClose: onClose }, action.id))
78
- : (_jsxs(_Fragment, { children: [tab.category.rows.map((row) => (_jsx(RowControl, { row: row }, row.id))), tab.category.keybinds.map((row) => (_jsx(KeybindRowView, { row: row, capturing: capturing?.action === row.action, onCapture: () => setCapturing(row) }, row.action)))] })) }));
37
+ : _jsx(SettingsSection, { category: tab.category, heading: false }) }));
79
38
  }
80
39
  const panelStyle = {
81
40
  background: surface,
@@ -95,41 +54,13 @@ export function SettingsMenu({ controller, onClose, initialTab, }) {
95
54
  ...controller.categories.map((category) => ({ id: category.id, label: category.label, category })),
96
55
  ];
97
56
  const [activeId, setActiveId] = useState(initialTab !== undefined && tabs.some((tab) => tab.id === initialTab) ? initialTab : (tabs[0]?.id ?? ""));
98
- const [capturing, setCapturing] = useState(null);
99
57
  const active = tabs.find((tab) => tab.id === activeId) ?? tabs[0] ?? null;
100
- const dialogRef = useDialogBehavior({ open: active !== null, onClose: capturing === null ? onClose : undefined });
101
- useEffect(() => {
102
- if (capturing === null)
103
- return;
104
- const onKey = (event) => {
105
- event.preventDefault();
106
- event.stopPropagation();
107
- if (event.code === "Escape") {
108
- setCapturing(null);
109
- return;
110
- }
111
- capturing.rebind(event.code);
112
- setCapturing(null);
113
- };
114
- const onPointer = (event) => {
115
- event.preventDefault();
116
- event.stopPropagation();
117
- capturing.rebind(`mouse${event.button}`);
118
- setCapturing(null);
119
- };
120
- const onContext = (event) => event.preventDefault();
121
- window.addEventListener("keydown", onKey, true);
122
- window.addEventListener("pointerdown", onPointer, true);
123
- window.addEventListener("contextmenu", onContext, true);
124
- return () => {
125
- window.removeEventListener("keydown", onKey, true);
126
- window.removeEventListener("pointerdown", onPointer, true);
127
- window.removeEventListener("contextmenu", onContext, true);
128
- };
129
- }, [capturing]);
58
+ // A keybind capture consumes Escape at the window, before the dialog's document listener, so close
59
+ // stays available without waiting for the capture state to re-render.
60
+ const dialogRef = useDialogBehavior({ open: active !== null, onClose });
130
61
  if (active === null)
131
62
  return null;
132
- const bodyProps = { tab: active, actions: controller.actions, capturing, setCapturing, onClose };
63
+ const bodyProps = { tab: active, actions: controller.actions, onClose };
133
64
  const title = (_jsx("h2", { className: "text-base font-semibold tracking-wide", style: { color: text }, children: "Settings" }));
134
65
  if (controller.variant === "fullscreen") {
135
66
  return (_jsx("div", { ref: dialogRef, role: "dialog", "aria-modal": "true", "aria-label": "Settings", tabIndex: -1, className: "pointer-events-auto absolute inset-0 z-40 flex flex-col", style: { background: surface, color: text, fontFamily: fontDisplay }, children: _jsxs("div", { className: "mx-auto flex h-full w-full max-w-2xl flex-col px-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-3 pb-4 pt-6", children: [_jsx("h2", { className: "text-2xl font-bold tracking-wide", style: { color: text }, children: "Settings" }), _jsx(CloseButton, { onClose: onClose })] }), _jsx(ScrollRail, { label: "settings categories", style: { paddingBottom: 16 }, children: tabs.map((tab) => (_jsx(TabButton, { tab: tab, selected: tab.id === active.id, orientation: "row", onSelect: () => setActiveId(tab.id) }, tab.id))) }), _jsx(Body, { ...bodyProps })] }) }));