@jgengine/shell 0.22.0 → 0.23.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 (90) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/dist/GamePlayerShell.js +18 -3
  3. package/dist/GameUiPreview.js +2 -1
  4. package/dist/Shell3dPresentation.js +13 -8
  5. package/dist/ShellChrome.d.ts +1 -1
  6. package/dist/ShellChrome.js +4 -2
  7. package/dist/ShellHudPresentation.js +2 -1
  8. package/dist/ShellNotices.d.ts +26 -0
  9. package/dist/ShellNotices.js +51 -0
  10. package/dist/audio/AudioComponents.d.ts +3 -1
  11. package/dist/audio/AudioComponents.js +5 -2
  12. package/dist/audio/listenerAnchor.d.ts +15 -0
  13. package/dist/audio/listenerAnchor.js +17 -0
  14. package/dist/audio/playableAudio.d.ts +9 -0
  15. package/dist/audio/playableAudio.js +11 -0
  16. package/dist/commandSink.d.ts +1 -1
  17. package/dist/commandSink.js +8 -3
  18. package/dist/defineGame.js +3 -1
  19. package/dist/devtools/DevtoolsOverlay.js +1 -0
  20. package/dist/devtools/agentBridge.d.ts +21 -0
  21. package/dist/devtools/agentBridge.js +95 -3
  22. package/dist/devtools/devtoolsReports.d.ts +32 -0
  23. package/dist/devtools/devtoolsReports.js +26 -0
  24. package/dist/devtools/simStepper.d.ts +21 -0
  25. package/dist/devtools/simStepper.js +37 -0
  26. package/dist/drivers/FrameDriver.js +26 -14
  27. package/dist/drivers/HudOnlyDriver.js +16 -5
  28. package/dist/environment/EnvironmentScene.js +19 -7
  29. package/dist/environment/RoadJunctions.d.ts +3 -1
  30. package/dist/environment/RoadJunctions.js +9 -2
  31. package/dist/environment/RoadRibbons.d.ts +3 -1
  32. package/dist/environment/RoadRibbons.js +16 -3
  33. package/dist/environment/terrainGroundColor.js +3 -3
  34. package/dist/materialOverride.js +4 -2
  35. package/dist/playSurfaceFocus.d.ts +16 -0
  36. package/dist/playSurfaceFocus.js +42 -0
  37. package/dist/postfx/PostProcessing.d.ts +3 -1
  38. package/dist/postfx/PostProcessing.js +14 -6
  39. package/dist/registry.d.ts +5 -1
  40. package/dist/render/CascadedShadows.d.ts +2 -0
  41. package/dist/render/CascadedShadows.js +4 -0
  42. package/dist/render/EntityPreview.js +1 -1
  43. package/dist/render/EnvironmentLighting.d.ts +26 -7
  44. package/dist/render/EnvironmentLighting.js +40 -27
  45. package/dist/render/SceneLighting.js +2 -2
  46. package/dist/render/modelLoad.d.ts +7 -0
  47. package/dist/render/modelLoad.js +13 -0
  48. package/dist/render/modelRender.d.ts +1 -0
  49. package/dist/render/modelRender.js +3 -0
  50. package/dist/render/modelSurface.d.ts +39 -0
  51. package/dist/render/modelSurface.js +206 -0
  52. package/dist/render/useModelInstance.d.ts +1 -1
  53. package/dist/render/useModelInstance.js +2 -2
  54. package/dist/scatter/scatterModels.js +3 -1
  55. package/dist/scene/AuthoredScene.d.ts +11 -3
  56. package/dist/scene/AuthoredScene.js +17 -9
  57. package/dist/scene/CityRenderer.js +86 -24
  58. package/dist/scene/cityJunctionSurface.d.ts +7 -0
  59. package/dist/scene/cityJunctionSurface.js +34 -0
  60. package/dist/settings/settingsController.js +1 -1
  61. package/dist/terrain/index.d.ts +4 -0
  62. package/dist/terrain/index.js +4 -0
  63. package/dist/terrain/surfaceRibbonMaterial.d.ts +62 -0
  64. package/dist/terrain/surfaceRibbonMaterial.js +227 -0
  65. package/dist/terrain/terrainDetailMaterial.d.ts +33 -10
  66. package/dist/terrain/terrainDetailMaterial.js +149 -63
  67. package/dist/terrain/terrainPaintColor.d.ts +12 -3
  68. package/dist/terrain/terrainPaintColor.js +46 -1
  69. package/dist/terrain/terrainQuality.d.ts +5 -0
  70. package/dist/terrain/terrainQuality.js +7 -0
  71. package/dist/terrain/terrainSurfaceShader.d.ts +16 -0
  72. package/dist/terrain/terrainSurfaceShader.js +266 -0
  73. package/dist/terrain/terrainSurfaces.d.ts +59 -0
  74. package/dist/terrain/terrainSurfaces.js +199 -0
  75. package/dist/world/StaticObjectModels.d.ts +1 -1
  76. package/dist/world/StaticObjectModels.js +1 -1
  77. package/dist/world/WorldScene.js +1 -1
  78. package/dist/world/staticObjectBatches.d.ts +5 -1
  79. package/dist/world/staticObjectBatches.js +9 -5
  80. package/dist/world/worldBarSamples.d.ts +13 -1
  81. package/dist/world/worldBarSamples.js +40 -8
  82. package/package.json +5 -5
  83. package/skills/jgengine/api.md +18 -18
  84. package/skills/jgengine-ui/SKILL.md +4 -0
  85. package/skills/jgengine-ui/api.md +233 -37
  86. package/skills/jgengine-ui/capabilities.md +52 -0
  87. package/skills/jgengine-ui/reference-primitives.md +4 -4
  88. package/skills/jgengine-world/api.md +27 -14
  89. package/skills/jgengine-world/capabilities.md +4 -0
  90. package/skills/jgengine-world/reference.md +23 -8
@@ -0,0 +1,206 @@
1
+ import * as THREE from "three";
2
+ const ORIGINAL_KEY = "jgSurfaceOriginal";
3
+ const OPT_OUT_KEY = "jgSurfaceDetail";
4
+ let activeLevel = "full";
5
+ let gameEnabled = true;
6
+ const compiled = new Set();
7
+ const strengthUniform = { value: 1 };
8
+ const VERTEX_PARS = /* glsl */ `
9
+ varying vec3 vJgSurfPos;`;
10
+ // Captured before skinning so the pattern rides the bind pose instead of swimming through animated limbs;
11
+ // the scale terms convert bind/object units to roughly world metres so one frequency suits every pack.
12
+ const VERTEX_MAIN = /* glsl */ `
13
+ vec3 jgSurfScale = vec3(length(modelMatrix[0].xyz), length(modelMatrix[1].xyz), length(modelMatrix[2].xyz));
14
+ #ifdef USE_SKINNING
15
+ jgSurfScale *= length((bindMatrixInverse * boneMatX * bindMatrix)[0].xyz);
16
+ #endif
17
+ vJgSurfPos = transformed * jgSurfScale;
18
+ #ifdef USE_INSTANCING
19
+ vJgSurfPos *= vec3(length(instanceMatrix[0].xyz), length(instanceMatrix[1].xyz), length(instanceMatrix[2].xyz));
20
+ vJgSurfPos += mod(instanceMatrix[3].xyz, 97.0);
21
+ #endif
22
+ #include <skinning_vertex>`;
23
+ const FRAGMENT_PARS = /* glsl */ `
24
+ varying vec3 vJgSurfPos;
25
+ uniform float jgSurfStrength;
26
+ float jgSurfHash(vec3 p) {
27
+ p = fract(p * 0.1031);
28
+ p += dot(p, p.zyx + 31.32);
29
+ return fract((p.x + p.y) * p.z);
30
+ }
31
+ float jgSurfNoise(vec3 x) {
32
+ vec3 i = floor(x);
33
+ vec3 f = fract(x);
34
+ vec3 u = f * f * (3.0 - 2.0 * f);
35
+ return mix(
36
+ mix(mix(jgSurfHash(i), jgSurfHash(i + vec3(1, 0, 0)), u.x), mix(jgSurfHash(i + vec3(0, 1, 0)), jgSurfHash(i + vec3(1, 1, 0)), u.x), u.y),
37
+ mix(mix(jgSurfHash(i + vec3(0, 0, 1)), jgSurfHash(i + vec3(1, 0, 1)), u.x), mix(jgSurfHash(i + vec3(0, 1, 1)), jgSurfHash(i + vec3(1, 1, 1)), u.x), u.y),
38
+ u.z);
39
+ }`;
40
+ // Three octaves of object-space value noise, each faded once a pixel spans its cell so distance never
41
+ // shimmers: broad mottling, brush-scale variation and fine speckle. Wear lifts raised spots paler and drier.
42
+ const COLOR_MAIN = /* glsl */ `
43
+ #include <color_fragment>
44
+ float jgFootprint = max(length(dFdx(vJgSurfPos)), length(dFdy(vJgSurfPos)));
45
+ float jgBroad = jgSurfNoise(vJgSurfPos * 1.6);
46
+ float jgMid = mix(0.5, jgSurfNoise(vJgSurfPos * 4.5 + 7.0), 1.0 - smoothstep(0.12, 0.4, jgFootprint * 4.5));
47
+ float jgFine = mix(0.5, jgSurfNoise(vJgSurfPos * 12.0 + 17.0), 1.0 - smoothstep(0.12, 0.4, jgFootprint * 12.0));
48
+ float jgWear = smoothstep(0.56, 0.8, jgBroad * 0.45 + jgMid * 0.4 + jgFine * 0.15) * jgSurfStrength;
49
+ float jgLuma = dot(diffuseColor.rgb, vec3(0.299, 0.587, 0.114));
50
+ diffuseColor.rgb *= 1.0 + ((jgBroad - 0.5) * 0.2 + (jgMid - 0.5) * 0.16 + (jgFine - 0.5) * 0.1) * jgSurfStrength;
51
+ diffuseColor.rgb = mix(diffuseColor.rgb, mix(vec3(jgLuma), diffuseColor.rgb, 0.7) * 1.18, jgWear * 0.5);`;
52
+ const ROUGHNESS_MAIN = /* glsl */ `
53
+ #include <roughnessmap_fragment>
54
+ roughnessFactor = clamp(roughnessFactor + ((jgMid - 0.5) * 0.24 + (jgFine - 0.5) * 0.12 + jgWear * 0.08) * jgSurfStrength, 0.04, 1.0);`;
55
+ // Mikkelsen's screen-space bump with unnormalized frame vectors, so the height is in metres and the
56
+ // slope reads the same at every distance; fades before the grain aliases.
57
+ const NORMAL_MAIN = /* glsl */ `
58
+ #include <normal_fragment_maps>
59
+ {
60
+ float jgGrainFade = 1.0 - smoothstep(0.12, 0.4, jgFootprint * 34.0);
61
+ float jgHeight = (jgMid * 0.012 + jgFine * 0.006 + jgSurfNoise(vJgSurfPos * 34.0 + 41.0) * 0.003 * jgGrainFade) * jgSurfStrength;
62
+ vec3 jgDpdx = dFdx(-vViewPosition);
63
+ vec3 jgDpdy = dFdy(-vViewPosition);
64
+ vec3 jgR1 = cross(jgDpdy, normal);
65
+ vec3 jgR2 = cross(normal, jgDpdx);
66
+ float jgDet = dot(jgDpdx, jgR1) * faceDirection;
67
+ vec3 jgGrad = sign(jgDet) * (dFdx(jgHeight) * jgR1 + dFdy(jgHeight) * jgR2);
68
+ vec3 jgBumped = abs(jgDet) * normal - jgGrad;
69
+ float jgLength2 = dot(jgBumped, jgBumped);
70
+ // A degenerate pixel frame (det 0, edge-on or helper lanes) would normalize zero into NaN, which bloom spreads across the frame.
71
+ if (jgLength2 > 1e-30 && jgLength2 < 1e30) normal = jgBumped * inversesqrt(jgLength2);
72
+ }`;
73
+ // Cheap cavity: down-facing and mottled-dark spots receive less sky, which grounds a form without an AO pass.
74
+ const OCCLUSION_MAIN = /* glsl */ `
75
+ #include <aomap_fragment>
76
+ {
77
+ float jgUp = inverseTransformDirection(normal, viewMatrix).y * 0.5 + 0.5;
78
+ float jgCavity = mix(1.0, mix(0.7, 1.0, jgUp) * (0.88 + 0.12 * jgBroad), jgSurfStrength);
79
+ reflectedLight.indirectDiffuse *= jgCavity;
80
+ reflectedLight.indirectSpecular *= jgCavity;
81
+ }`;
82
+ function isStandard(material) {
83
+ return material.isMeshStandardMaterial === true;
84
+ }
85
+ /**
86
+ * Whether the procedural surface pass suits this material: an opaque standard/physical surface without
87
+ * its own normal map (flat colour, vertex colour, or a palette-atlas texture like KayKit's).
88
+ * @internal
89
+ */
90
+ export function modelSurfaceEligible(material) {
91
+ return isStandard(material) && material.normalMap === null && !material.transparent && material.alphaTest === 0;
92
+ }
93
+ /** The level a material compiles with right now: the global tier unless the material opted out. @internal */
94
+ export function materialSurfaceLevel(material) {
95
+ if (material.userData[OPT_OUT_KEY] === false || !modelSurfaceEligible(material))
96
+ return "off";
97
+ return activeLevel;
98
+ }
99
+ function surfaceOnBeforeCompile(shader) {
100
+ if (!compiled.has(this)) {
101
+ compiled.add(this);
102
+ const material = this;
103
+ material.addEventListener("dispose", function release() {
104
+ material.removeEventListener("dispose", release);
105
+ compiled.delete(material);
106
+ });
107
+ }
108
+ const level = materialSurfaceLevel(this);
109
+ if (level === "off")
110
+ return;
111
+ shader.uniforms.jgSurfStrength = strengthUniform;
112
+ shader.vertexShader = shader.vertexShader
113
+ .replace("#include <common>", `#include <common>${VERTEX_PARS}`)
114
+ .replace("#include <skinning_vertex>", VERTEX_MAIN);
115
+ let fragment = shader.fragmentShader
116
+ .replace("#include <common>", `#include <common>${FRAGMENT_PARS}`)
117
+ .replace("#include <color_fragment>", COLOR_MAIN)
118
+ .replace("#include <roughnessmap_fragment>", ROUGHNESS_MAIN)
119
+ .replace("#include <aomap_fragment>", OCCLUSION_MAIN);
120
+ if (level === "full")
121
+ fragment = fragment.replace("#include <normal_fragment_maps>", NORMAL_MAIN);
122
+ shader.fragmentShader = fragment;
123
+ }
124
+ function surfaceCacheKey() {
125
+ return `jg-surface:v1:${materialSurfaceLevel(this)}`;
126
+ }
127
+ /**
128
+ * Re-tune a glTF material's exported finish into the painted range: glossy 0.4–0.5 roughness reads as
129
+ * plastic, and metallic 1 / roughness 1 with no map is an exporter default that renders near-black.
130
+ * @internal
131
+ */
132
+ export function normalizeModelMaterial(material) {
133
+ if (material.userData[ORIGINAL_KEY] !== undefined)
134
+ return;
135
+ const original = { roughness: material.roughness, metalness: material.metalness, envMapIntensity: material.envMapIntensity };
136
+ material.userData[ORIGINAL_KEY] = original;
137
+ if (material.roughnessMap === null)
138
+ material.roughness = 0.6 + 0.35 * THREE.MathUtils.clamp(material.roughness, 0, 1);
139
+ if (material.metalnessMap === null) {
140
+ material.metalness = original.metalness >= 0.99 && original.roughness >= 0.99 ? 0.1 : Math.min(material.metalness, 0.85);
141
+ }
142
+ material.envMapIntensity = Math.min(material.envMapIntensity, 0.8);
143
+ }
144
+ /**
145
+ * Give every eligible material in a freshly loaded model the engine's default painted finish: exported
146
+ * PBR params normalized and a shared, tier-aware procedural detail shader (object-space mottling, wear,
147
+ * roughness break-up, micro-normal and cavity). Idempotent; materials with their own normal maps, cutout
148
+ * or transparent materials and non-standard materials are left untouched.
149
+ * @internal
150
+ */
151
+ export function applyModelSurface(root) {
152
+ if (!gameEnabled)
153
+ return;
154
+ const seen = new Set();
155
+ root.traverse((node) => {
156
+ const mesh = node;
157
+ if (!mesh.isMesh)
158
+ return;
159
+ for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) {
160
+ if (seen.has(material) || !modelSurfaceEligible(material))
161
+ continue;
162
+ seen.add(material);
163
+ normalizeModelMaterial(material);
164
+ if (material.onBeforeCompile === surfaceOnBeforeCompile)
165
+ continue;
166
+ if (material.onBeforeCompile !== THREE.Material.prototype.onBeforeCompile)
167
+ continue;
168
+ material.onBeforeCompile = surfaceOnBeforeCompile;
169
+ material.customProgramCacheKey = surfaceCacheKey;
170
+ }
171
+ });
172
+ }
173
+ /**
174
+ * Opt one cloned material out of the default surface: restore its exported params and compile it
175
+ * without the detail shader. Used for a model whose `surfaceDetail` is `false`.
176
+ * @internal
177
+ */
178
+ export function optOutModelSurface(material) {
179
+ const original = material.userData[ORIGINAL_KEY];
180
+ material.userData[OPT_OUT_KEY] = false;
181
+ if (original !== undefined && isStandard(material)) {
182
+ material.roughness = original.roughness;
183
+ material.metalness = original.metalness;
184
+ material.envMapIntensity = original.envMapIntensity;
185
+ }
186
+ material.needsUpdate = true;
187
+ }
188
+ /**
189
+ * Select the surface-detail level every patched model material compiles with, and whether models loaded
190
+ * from now on get the default finish at all (a game's `surfaceDetail: false`). Changing the level
191
+ * recompiles only materials already drawn; programs are shared per level, so the count stays bounded.
192
+ * @internal
193
+ */
194
+ export function configureModelSurface(enabled, level) {
195
+ gameEnabled = enabled;
196
+ const next = enabled ? level : "off";
197
+ if (next === activeLevel)
198
+ return;
199
+ activeLevel = next;
200
+ for (const material of compiled)
201
+ material.needsUpdate = true;
202
+ }
203
+ /** Current model surface-detail level. @internal */
204
+ export function modelSurfaceLevel() {
205
+ return activeLevel;
206
+ }
@@ -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">;
4
+ export type ModelInstanceConfig = Pick<ModelConfig, "url" | "scale" | "targetHeight" | "y" | "anchor" | "dims" | "shadows" | "animation" | "hiddenNodes" | "surfaceDetail">;
5
5
  /** Caller-owned styling runs on the isolated clone, never the loader cache. */
6
6
  export interface ModelInstanceOptions {
7
7
  instanceId?: string;
@@ -23,7 +23,7 @@ export function useModelInstance(model, options = {}) {
23
23
  const { configure, instanceId } = options;
24
24
  const hiddenNodesKey = modelHiddenNodesKey(hiddenNodes);
25
25
  const { content, scene, bindBounds } = useMemo(() => {
26
- const content = cloneModelScene(gltf.scene, { shadows: model.shadows });
26
+ const content = cloneModelScene(gltf.scene, { shadows: model.shadows, surfaceDetail: model.surfaceDetail });
27
27
  try {
28
28
  configure?.(content);
29
29
  for (const diagnostic of applyModelNodeVisibility(content, hiddenNodes))
@@ -35,7 +35,7 @@ export function useModelInstance(model, options = {}) {
35
35
  disposeModelScene(content);
36
36
  throw error;
37
37
  }
38
- }, [gltf.scene, model.shadows, configure, hiddenNodesKey]);
38
+ }, [gltf.scene, model.shadows, model.surfaceDetail, configure, hiddenNodesKey]);
39
39
  const placement = useMemo(() => modelPlacementTransform(scene, model, bindBounds), [scene, bindBounds, model.scale, model.targetHeight, model.y, model.anchor, model.dims]);
40
40
  useEffect(() => () => disposeModelScene(content), [content]);
41
41
  useModelAnimation(content, gltf.animations, model.animation, instanceId);
@@ -2,6 +2,7 @@ import * as THREE from "three";
2
2
  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
+ import { applyModelSurface } from "../render/modelSurface.js";
5
6
  /** The scale + position offset a `ModelConfig` applies before any per-instance transform — same anchor/targetHeight rules as the single-instance entity renderer. */
6
7
  function scatterModelBaseTransform(scene, model) {
7
8
  let scale = model.scale ?? 1;
@@ -40,7 +41,8 @@ export function buildScatterModelSources(gltfScene, model) {
40
41
  throw new Error("Instanced model material assignments require the individual model renderer; use EntityModel for authored slots");
41
42
  if (model.material?.maps !== undefined)
42
43
  throw new Error("Instanced model override maps require an owned texture loader; use EntityModel for authored maps");
43
- const root = cloneModelScene(gltfScene, { shadows: model.shadows });
44
+ applyModelSurface(gltfScene);
45
+ const root = cloneModelScene(gltfScene, { shadows: model.shadows, surfaceDetail: model.surfaceDetail });
44
46
  if (model.material !== undefined)
45
47
  applyMaterialOverride(root, model.material, { clone: false, ownMaterial: material => ownModelMaterial(root, material) });
46
48
  root.updateMatrixWorld(true);
@@ -16,13 +16,19 @@ export interface AuthoredPathsProps {
16
16
  field: TerrainField;
17
17
  /** Only render these path kinds; default renders every non-scatter path. */
18
18
  kinds?: readonly string[];
19
+ /**
20
+ * Shade paths with the terrain's procedural surfaces (dirt, gravel, paving), wear and soft edges.
21
+ * Default true; false keeps flat-colour ribbons. One path opts out with `meta.surfaceDetail: false`.
22
+ */
23
+ surfaceDetail?: boolean;
19
24
  }
20
25
  /**
21
26
  * Renders a document's non-scatter paths (roads, routes, corridors) as ground-draped ribbons — the
22
27
  * editor authors the polyline, the engine drapes it over the live terrain at runtime. Width comes
23
- * from `path.width`, color from `path.meta.color`/`path.color`. A game never hand-rolls path meshes.
28
+ * from `path.width`, color from `path.meta.color`/`path.color`, and the procedural surface from
29
+ * `path.meta.surface` or the kind (see `resolvePathSurface`). A game never hand-rolls path meshes.
24
30
  */
25
- export declare function AuthoredPaths({ document, field, kinds }: AuthoredPathsProps): import("react").JSX.Element;
31
+ export declare function AuthoredPaths({ document, field, kinds, surfaceDetail }: AuthoredPathsProps): import("react").JSX.Element;
26
32
  /** Props for {@link AuthoredObjects}: document, ground field, and optional lift / onExisting. */
27
33
  export interface AuthoredObjectsProps extends ResolveAuthoredObjectsOptions {
28
34
  document: EditorDocument;
@@ -60,6 +66,8 @@ export interface AuthoredSceneProps {
60
66
  field: TerrainField;
61
67
  /** Restrict rendered path kinds; default renders every non-scatter path. */
62
68
  pathKinds?: readonly string[];
69
+ /** Procedural surface, wear and soft edges on paths (default true); see {@link AuthoredPathsProps.surfaceDetail}. */
70
+ pathSurfaceDetail?: boolean;
63
71
  /**
64
72
  * Scatter palette item id → model asset id or `ModelConfig`; a mapped item GPU-instances the real
65
73
  * catalog GLB instead of the stylized proxy. Keyed by scatter `item` (not entity name — pass a
@@ -98,4 +106,4 @@ export interface AuthoredSceneProps {
98
106
  * place markers with your own entity spawns. Pass `scatterModels`+`assets` to resolve palette items to
99
107
  * real catalog GLBs; unmapped items keep the stylized proxy.
100
108
  */
101
- export declare function AuthoredScene({ document, diagnostics, field, pathKinds, scatterModels, flockModels, assets, live, placeObjects, groundColorAt, }: AuthoredSceneProps): import("react").JSX.Element;
109
+ export declare function AuthoredScene({ document, diagnostics, field, pathKinds, pathSurfaceDetail, scatterModels, flockModels, assets, live, placeObjects, groundColorAt, }: AuthoredSceneProps): import("react").JSX.Element;
@@ -10,6 +10,8 @@ import { buildRoadRibbon, GROUND_DECAL_LAYERS, roundPathCorners } from "@jgengin
10
10
  import { isScatterPath, resolveScatter } from "@jgengine/core/world/scatterRegion";
11
11
  import { getAssetGenerator } from "@jgengine/core/scene/assetGenerator";
12
12
  import { useGameContext } from "@jgengine/react/provider";
13
+ import { resolvePathSurface, RIBBON_FRAME_ATTRIBUTE, ribbonEdgeMargin, ribbonSurfaceFrame, useSurfaceRibbonMaterial, } from "../terrain/surfaceRibbonMaterial.js";
14
+ import { useTerrainQuality } from "../terrain/terrainQuality.js";
13
15
  import { createModelMapResolver } from "../render/resolveModel.js";
14
16
  import { InstancedScatter } from "../scatter/InstancedScatter.js";
15
17
  import { GeneratedAssetInstance } from "./GeneratedAssetRenderer.js";
@@ -93,26 +95,31 @@ function AuthoredStudios({ document, context }) {
93
95
  }
94
96
  const DEFAULT_PATH_COLOR = "#7a6444";
95
97
  /** One editor path drawn as a ground-draped ribbon — subdivided and sampled against the live field. */
96
- function DrapedPath({ points, width, color, field, }) {
98
+ function DrapedPath({ points, width, color, field, surface, }) {
99
+ const quality = useTerrainQuality();
100
+ const margin = surface === null ? 0 : ribbonEdgeMargin(surface);
97
101
  const geometry = useMemo(() => {
98
102
  // Fillet sharp turns first so bends read as smooth arcs, not overlapping rectangles.
99
103
  const rounded = roundPathCorners(points, Math.max(1.5, width * 0.9), 6);
100
- const ribbon = buildRoadRibbon(rounded, width, (x, z) => field.sampleHeight(x, z), {
104
+ // A soft edge needs the ribbon wider than authored so the fade straddles the authored edge.
105
+ const ribbon = buildRoadRibbon(rounded, width + margin, (x, z) => field.sampleHeight(x, z), {
101
106
  // Generic authored paths (dirt roads, routes, corridors) drape on the shared road decal layer.
102
107
  elevation: GROUND_DECAL_LAYERS.road,
103
108
  maxSegmentLength: 1,
104
109
  });
105
110
  const geo = new THREE.BufferGeometry();
106
111
  geo.setAttribute("position", new THREE.BufferAttribute(ribbon.positions, 3));
112
+ geo.setAttribute(RIBBON_FRAME_ATTRIBUTE, new THREE.BufferAttribute(ribbonSurfaceFrame([ribbon.positions]), 3));
107
113
  geo.setIndex(new THREE.BufferAttribute(ribbon.indices, 1));
108
114
  geo.computeVertexNormals();
109
115
  return geo;
110
116
  // eslint-disable-next-line react-hooks/exhaustive-deps
111
- }, [points, width, field]);
117
+ }, [points, width, margin, field]);
112
118
  useEffect(() => () => geometry.dispose(), [geometry]);
119
+ const material = useSurfaceRibbonMaterial(surface === null ? null : { ...surface, color, halfWidth: width / 2, quality });
113
120
  if (points.length < 2)
114
121
  return null;
115
- return (_jsx("mesh", { geometry: geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: color, roughness: 1, metalness: 0, side: THREE.DoubleSide }) }));
122
+ return (_jsx("mesh", { geometry: geometry, receiveShadow: true, ...(material === null ? {} : { material }), children: material === null ? _jsx("meshStandardMaterial", { color: color, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null }));
116
123
  }
117
124
  function pathColor(meta, color) {
118
125
  if (typeof meta?.["color"] === "string")
@@ -122,11 +129,12 @@ function pathColor(meta, color) {
122
129
  /**
123
130
  * Renders a document's non-scatter paths (roads, routes, corridors) as ground-draped ribbons — the
124
131
  * editor authors the polyline, the engine drapes it over the live terrain at runtime. Width comes
125
- * from `path.width`, color from `path.meta.color`/`path.color`. A game never hand-rolls path meshes.
132
+ * from `path.width`, color from `path.meta.color`/`path.color`, and the procedural surface from
133
+ * `path.meta.surface` or the kind (see `resolvePathSurface`). A game never hand-rolls path meshes.
126
134
  */
127
- export function AuthoredPaths({ document, field, kinds }) {
135
+ export function AuthoredPaths({ document, field, kinds, surfaceDetail = true }) {
128
136
  const paths = document.paths.filter((path) => !isScatterPath(path) && getSceneKindRenderer(path.kind) === undefined && (kinds === undefined || kinds.includes(path.kind)));
129
- return (_jsx(_Fragment, { children: paths.map((path) => (_jsx(DrapedPath, { points: path.points.map((point) => [point.x, point.z]), width: path.width ?? 4, color: pathColor(path.meta, path.color), field: field }, path.id))) }));
137
+ return (_jsx(_Fragment, { children: paths.map((path) => (_jsx(DrapedPath, { points: path.points.map((point) => [point.x, point.z]), width: path.width ?? 4, color: pathColor(path.meta, path.color), field: field, surface: surfaceDetail ? resolvePathSurface(path.kind, path.width ?? 4, path.meta) : null }, path.id))) }));
130
138
  }
131
139
  /**
132
140
  * Places every editor marker with a catalog id into `ctx.scene.object`, grounded on `field`.
@@ -186,7 +194,7 @@ export function AuthoredSolids({ document, field }) {
186
194
  * place markers with your own entity spawns. Pass `scatterModels`+`assets` to resolve palette items to
187
195
  * real catalog GLBs; unmapped items keep the stylized proxy.
188
196
  */
189
- export function AuthoredScene({ document, diagnostics = false, field, pathKinds, scatterModels, flockModels, assets, live = true, placeObjects, groundColorAt, }) {
197
+ export function AuthoredScene({ document, diagnostics = false, field, pathKinds, pathSurfaceDetail = true, scatterModels, flockModels, assets, live = true, placeObjects, groundColorAt, }) {
190
198
  const ctx = useGameContext();
191
199
  const liveDocument = useLiveEditorDocument(document, live);
192
200
  // Authoring updates configuration only. Physics remains owned by the fixed game-time stages.
@@ -196,7 +204,7 @@ export function AuthoredScene({ document, diagnostics = false, field, pathKinds,
196
204
  const shouldPlaceObjects = placeObjects === true || (typeof placeObjects === "object" && placeObjects !== null);
197
205
  const objectVerticalOffset = typeof placeObjects === "object" && placeObjects !== null ? (placeObjects.verticalOffset ?? 0) : 0;
198
206
  const objectExcludeKinds = typeof placeObjects === "object" && placeObjects !== null ? placeObjects.excludeKinds : undefined;
199
- return (_jsxs(_Fragment, { children: [_jsx(AuthoredWeatherLayer, { document: liveDocument, heightAt: field.sampleHeight, diagnostics: diagnostics }), flockModels === undefined ? null : _jsx(AuthoredFlocks, { models: flockModels, assets: assets }), _jsx(AuthoredPaths, { document: liveDocument, field: field, ...(pathKinds === undefined ? {} : { kinds: pathKinds }) }), _jsx(InstancedScatter, { instances: instances, ...(resolveItem === undefined ? {} : { resolveItem }) }), _jsx(AuthoredStudios, { document: liveDocument, context: {
207
+ return (_jsxs(_Fragment, { children: [_jsx(AuthoredWeatherLayer, { document: liveDocument, heightAt: field.sampleHeight, diagnostics: diagnostics }), flockModels === undefined ? null : _jsx(AuthoredFlocks, { models: flockModels, assets: assets }), _jsx(AuthoredPaths, { document: liveDocument, field: field, surfaceDetail: pathSurfaceDetail, ...(pathKinds === undefined ? {} : { kinds: pathKinds }) }), _jsx(InstancedScatter, { instances: instances, ...(resolveItem === undefined ? {} : { resolveItem }) }), _jsx(AuthoredStudios, { document: liveDocument, context: {
200
208
  document: liveDocument,
201
209
  field,
202
210
  ...(assets === undefined ? {} : { assets }),
@@ -24,11 +24,15 @@ import { resolveCityObject, CITY_KIND, CITY_ZONE_KIND, } from "@jgengine/core/wo
24
24
  import { zoneBand, zoneMetric } from "@jgengine/core/world/cityContent";
25
25
  import { generateBuilding, buildingSurfaceColor, resolveBuildingPalette, resolveBuildingWallTones, } from "@jgengine/core/world/buildings";
26
26
  import { hashString } from "@jgengine/core/random/rng";
27
+ import { valueNoise } from "@jgengine/core/world/terrain";
27
28
  import { buildScatterProxy } from "../scatter/scatterProxies.js";
28
29
  import { InstancedBuildings } from "../structures/GeneratedBuilding.js";
29
30
  import { getBuildingKit } from "../structures/buildingKitRegistry.js";
30
31
  import { createCityWindowMaterial } from "./cityWindowMaterial.js";
32
+ import { createSurfaceRibbonMaterial } from "../terrain/surfaceRibbonMaterial.js";
33
+ import { useTerrainQuality } from "../terrain/terrainQuality.js";
31
34
  import { registerSceneKindRenderer } from "./sceneKindRenderers.js";
35
+ import { junctionSurface } from "./cityJunctionSurface.js";
32
36
  const ASPHALT_COLOR = "#3a3b3d";
33
37
  const GRAVEL_COLOR = "#93815f";
34
38
  const SIDEWALK_COLOR = "#867f74";
@@ -58,8 +62,8 @@ const CROP_COLORS = ["#5d7a35", "#7a6b41", "#6f7f3a"];
58
62
  * only a low-density (large-block) district lets the edge band go back to lawn. */
59
63
  const BLOCK_SURFACE_COLORS = { core: "#5b5850", mid: "#6c6456", edge: "#78765c" };
60
64
  const BLOCK_EDGE_GREEN = "#6a7f4a";
61
- /** The developed ground fades into this at its outer reach so the fabric has no stamped rim. */
62
- const FRINGE_COLOR = "#5f7444";
65
+ /** Width (m) over which the developed ground fades out at its outer reach, so it has no stamped rim. */
66
+ const FABRIC_FADE = 9;
63
67
  /** Non-buildable block land takes its open-space colour so a park block never shows raw terrain. */
64
68
  const BLOCK_OPEN_COLORS = { park: PARK_COLORS.green, plaza: PARK_COLORS.plaza, field: PARK_COLORS.field, buffer: PARK_COLORS.buffer };
65
69
  /**
@@ -573,7 +577,7 @@ function OneCity({ object, context }) {
573
577
  const asphalt = new MeshAccumulator(true);
574
578
  const gravel = new MeshAccumulator();
575
579
  const sidewalks = new MeshAccumulator();
576
- const urban = new MeshAccumulator(true);
580
+ let urban = null;
577
581
  const land = new MeshAccumulator(true);
578
582
  const patches = new MeshAccumulator(true);
579
583
  const markings = new MeshAccumulator(true);
@@ -706,7 +710,11 @@ function OneCity({ object, context }) {
706
710
  const crossIndex = trimmed.junctionIndices[j];
707
711
  const scuff = (hashString(`cross:${crossIndex}`) % 512) / 512;
708
712
  wearColor.setRGB(0.76 + scuff * 0.18, 0.77 + scuff * 0.17, 0.8 + scuff * 0.16);
709
- patches.addMesh(trimmed.junctions[j], wearColor);
713
+ const cross = resolved.intersections[crossIndex];
714
+ if (cross !== undefined && junctionSurface(cross, resolved.streets) === "gravel")
715
+ gravel.addMesh(trimmed.junctions[j]);
716
+ else
717
+ patches.addMesh(trimmed.junctions[j], wearColor);
710
718
  }
711
719
  for (const paint of buildIntersectionMarkings(trimmed, deckSampler, {
712
720
  mouthClearance: 1.25,
@@ -846,32 +854,59 @@ function OneCity({ object, context }) {
846
854
  }
847
855
  }
848
856
  }
849
- const fringe = new THREE.Color(FRINGE_COLOR);
857
+ // One shared-vertex sheet coloured per vertex from smooth noise: a per-cell roll flipped
858
+ // neighbouring cells between zone bands and drew a hard checkerboard over the district.
850
859
  const fabricColor = new THREE.Color();
860
+ const seed = hashString(`fabric:${resolved.center[0]}:${resolved.center[2]}`);
861
+ const vertexIndex = new Int32Array(nx * nz).fill(-1);
862
+ const fabric = { positions: [], indices: [], colors: [] };
863
+ const vertexAt = (i, j) => {
864
+ const idx = j * nx + i;
865
+ const existing = vertexIndex[idx];
866
+ if (existing >= 0)
867
+ return existing;
868
+ const x = originX + i * cell;
869
+ const z = originZ + j * cell;
870
+ const d = field[idx];
871
+ const tone = valueNoise(x / 9, z / 9, seed) * 0.5 + 0.5;
872
+ if (d < pavedTo) {
873
+ fabricColor.set(SIDEWALK_COLOR);
874
+ }
875
+ else {
876
+ const band = bandAt(x, z, tone);
877
+ fabricColor.set(band === "edge" && lawnEdge ? BLOCK_EDGE_GREEN : BLOCK_SURFACE_COLORS[band]);
878
+ // A block-sized tone over the fine grain: yards, courts and lots, not one poured apron.
879
+ fabricColor.multiplyScalar(0.8 + (valueNoise(x / 22, z / 22, seed + 1) * 0.5 + 0.5) * 0.42);
880
+ }
881
+ fabricColor.multiplyScalar(0.95 + tone * 0.1);
882
+ // Fade to transparent over the outer reach so the sheet melts into whatever terrain lies
883
+ // beyond it, instead of a hard stair-stepped rim in a fixed fringe colour.
884
+ const t = Math.max(0, Math.min(1, (reach - d) / FABRIC_FADE));
885
+ const next = fabric.positions.length / 3;
886
+ fabric.positions.push(x, sample(x, z) + URBAN_LAYER, z);
887
+ fabric.colors.push(fabricColor.r, fabricColor.g, fabricColor.b, t * t * (3 - 2 * t));
888
+ vertexIndex[idx] = next;
889
+ return next;
890
+ };
851
891
  for (let j = 0; j + 1 < nz; j += 1) {
852
892
  for (let i = 0; i + 1 < nx; i += 1) {
853
893
  const d = (field[j * nx + i] + field[j * nx + i + 1] + field[(j + 1) * nx + i] + field[(j + 1) * nx + i + 1]) / 4;
854
894
  if (d > reach)
855
895
  continue;
856
- const x = originX + (i + 0.5) * cell;
857
- const z = originZ + (j + 0.5) * cell;
858
- const tone = (hashString(`fabric:${i}:${j}`) % 256) / 256;
859
- if (d < pavedTo) {
860
- fabricColor.set(SIDEWALK_COLOR);
861
- }
862
- else {
863
- const band = bandAt(x, z, tone);
864
- fabricColor.set(band === "edge" && lawnEdge ? BLOCK_EDGE_GREEN : BLOCK_SURFACE_COLORS[band]);
865
- // A block-sized patch tone over the fine per-cell grain: yards, courts and lots,
866
- // not one poured apron.
867
- fabricColor.multiplyScalar(0.8 + ((hashString(`yard:${Math.floor(x / 22)}:${Math.floor(z / 22)}`) % 256) / 256) * 0.42);
868
- }
869
- fabricColor.multiplyScalar(0.95 + tone * 0.1);
870
- if (d > reach - 7)
871
- fabricColor.lerp(fringe, Math.min(1, (d - (reach - 7)) / 7));
872
- urban.addPatch([x, z], [cell, cell], 0, sample, URBAN_LAYER, fabricColor);
896
+ const a = vertexAt(i, j);
897
+ const b = vertexAt(i + 1, j);
898
+ const c = vertexAt(i, j + 1);
899
+ const e = vertexAt(i + 1, j + 1);
900
+ fabric.indices.push(a, c, b, b, c, e);
873
901
  }
874
902
  }
903
+ if (fabric.indices.length > 0) {
904
+ urban = new THREE.BufferGeometry();
905
+ urban.setAttribute("position", new THREE.BufferAttribute(new Float32Array(fabric.positions), 3));
906
+ urban.setAttribute("color", new THREE.BufferAttribute(new Float32Array(fabric.colors), 4));
907
+ urban.setIndex(fabric.indices);
908
+ urban.computeVertexNormals();
909
+ }
875
910
  }
876
911
  }
877
912
  // The land ring behind the sidewalk is the block INTERIOR — fill it with a surface read from
@@ -941,7 +976,7 @@ function OneCity({ object, context }) {
941
976
  asphalt: asphalt.build(),
942
977
  gravel: gravel.build(),
943
978
  sidewalks: sidewalks.build(),
944
- urban: urban.build(),
979
+ urban,
945
980
  land: land.build(),
946
981
  patches: patches.build(),
947
982
  markings: markings.build(),
@@ -1469,9 +1504,36 @@ function OneCity({ object, context }) {
1469
1504
  for (const material of race.materials)
1470
1505
  material.dispose();
1471
1506
  }, [race]);
1507
+ const quality = useTerrainQuality();
1508
+ const surfaceDetail = object.meta?.["surfaceDetail"] !== false;
1509
+ const groundMaterials = useMemo(() => {
1510
+ if (!surfaceDetail)
1511
+ return null;
1512
+ const make = (surface, color, vertexColors, roughness, scale = 1) => createSurfaceRibbonMaterial({ surface, color, vertexColors, roughness, scale, halfWidth: 1, quality });
1513
+ const urban = make("dirt", "#ffffff", true, 1);
1514
+ urban.transparent = true;
1515
+ urban.depthWrite = false;
1516
+ return {
1517
+ urban,
1518
+ land: make("dirt", "#ffffff", true, 1),
1519
+ asphalt: make("road", ASPHALT_COLOR, true, 0.96),
1520
+ gravel: make("gravel", GRAVEL_COLOR, false, 1),
1521
+ sidewalks: make("pavement", SIDEWALK_COLOR, false, 1, 1.6),
1522
+ patches: make("road", ASPHALT_COLOR, true, 0.96),
1523
+ medians: make("grass", MEDIAN_COLOR, false, 1),
1524
+ pavementDrives: make("concrete", PAVEMENT_COLOR, false, 1, 1.6),
1525
+ gravelDrives: make("gravel", GRAVEL_COLOR, false, 1),
1526
+ parks: make("ground", "#ffffff", true, 1),
1527
+ crops: make("dirt", "#ffffff", true, 1),
1528
+ };
1529
+ }, [surfaceDetail, quality]);
1530
+ useEffect(() => () => {
1531
+ for (const material of Object.values(groundMaterials ?? {}))
1532
+ material.dispose();
1533
+ }, [groundMaterials]);
1472
1534
  if (resolved === null)
1473
1535
  return null;
1474
- return (_jsxs("group", { children: [ground?.urban != null ? (_jsx("mesh", { geometry: ground.urban, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.land != null ? (_jsx("mesh", { geometry: ground.land, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.asphalt != null ? (_jsx("mesh", { geometry: ground.asphalt, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: ASPHALT_COLOR, vertexColors: true, roughness: 0.96, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.gravel != null ? (_jsx("mesh", { geometry: ground.gravel, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: GRAVEL_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.sidewalks != null ? (_jsx("mesh", { geometry: ground.sidewalks, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: SIDEWALK_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.patches != null ? (_jsx("mesh", { geometry: ground.patches, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: ASPHALT_COLOR, vertexColors: true, roughness: 0.96, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.curbs != null ? (_jsx("mesh", { geometry: ground.curbs, receiveShadow: true, castShadow: true, children: _jsx("meshStandardMaterial", { color: CURB_COLOR, roughness: 0.9, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.markings != null ? (_jsx("mesh", { geometry: ground.markings, renderOrder: 1, children: _jsx("meshStandardMaterial", { color: MARKING_COLOR, vertexColors: true, roughness: 0.8, metalness: 0, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) })) : null, ground?.medians != null ? (_jsx("mesh", { geometry: ground.medians, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: MEDIAN_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.pavementDrives != null ? (_jsx("mesh", { geometry: ground.pavementDrives, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: PAVEMENT_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.gravelDrives != null ? (_jsx("mesh", { geometry: ground.gravelDrives, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: GRAVEL_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.parks != null ? (_jsx("mesh", { geometry: ground.parks, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.crops != null ? (_jsx("mesh", { geometry: ground.crops, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) })) : null, bridges?.deck != null ? (_jsx("mesh", { geometry: bridges.deck, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: CONCRETE_COLOR, roughness: 0.85, metalness: 0.05, side: THREE.DoubleSide }) })) : null, bridges !== null && bridges.concrete.length > 0 ? (_jsx(InstancedBoxes, { matrices: bridges.concrete, color: CONCRETE_COLOR })) : null, bridges !== null && bridges.steel.length > 0 ? _jsx(InstancedBoxes, { matrices: bridges.steel, color: STEEL_COLOR }) : null, tunnels?.roof != null ? (_jsx("mesh", { geometry: tunnels.roof, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: TUNNEL_COLOR, roughness: 0.95, metalness: 0, side: THREE.DoubleSide }) })) : null, tunnels !== null && tunnels.stone.length > 0 ? _jsx(InstancedBoxes, { matrices: tunnels.stone, color: TUNNEL_COLOR }) : null, massing !== null ? massing.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `massing:${i}`)) : null, furniture !== null ? furniture.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `furniture:${i}`)) : null, race !== null ? race.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `race:${i}`)) : null, _jsx(DetailBuildings, { buildings: detail.buildings, palette: palette, kitName: resolved?.rules.buildingKit ?? "" })] }));
1536
+ return (_jsxs("group", { children: [ground?.urban != null ? (_jsx("mesh", { geometry: ground.urban, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.urban }), children: groundMaterials === null ? (_jsx("meshStandardMaterial", { vertexColors: true, transparent: true, depthWrite: false, roughness: 1, metalness: 0, side: THREE.DoubleSide })) : null })) : null, ground?.land != null ? (_jsx("mesh", { geometry: ground.land, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.land }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.asphalt != null ? (_jsx("mesh", { geometry: ground.asphalt, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.asphalt }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { color: ASPHALT_COLOR, vertexColors: true, roughness: 0.96, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.gravel != null ? (_jsx("mesh", { geometry: ground.gravel, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.gravel }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { color: GRAVEL_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.sidewalks != null ? (_jsx("mesh", { geometry: ground.sidewalks, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.sidewalks }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { color: SIDEWALK_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.patches != null ? (_jsx("mesh", { geometry: ground.patches, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.patches }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { color: ASPHALT_COLOR, vertexColors: true, roughness: 0.96, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.curbs != null ? (_jsx("mesh", { geometry: ground.curbs, receiveShadow: true, castShadow: true, children: _jsx("meshStandardMaterial", { color: CURB_COLOR, roughness: 0.9, metalness: 0, side: THREE.DoubleSide }) })) : null, ground?.markings != null ? (_jsx("mesh", { geometry: ground.markings, renderOrder: 1, children: _jsx("meshStandardMaterial", { color: MARKING_COLOR, vertexColors: true, roughness: 0.8, metalness: 0, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) })) : null, ground?.medians != null ? (_jsx("mesh", { geometry: ground.medians, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.medians }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { color: MEDIAN_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.pavementDrives != null ? (_jsx("mesh", { geometry: ground.pavementDrives, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.pavementDrives }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { color: PAVEMENT_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.gravelDrives != null ? (_jsx("mesh", { geometry: ground.gravelDrives, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.gravelDrives }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { color: GRAVEL_COLOR, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.parks != null ? (_jsx("mesh", { geometry: ground.parks, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.parks }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, ground?.crops != null ? (_jsx("mesh", { geometry: ground.crops, receiveShadow: true, ...(groundMaterials === null ? {} : { material: groundMaterials.crops }), children: groundMaterials === null ? _jsx("meshStandardMaterial", { vertexColors: true, roughness: 1, metalness: 0, side: THREE.DoubleSide }) : null })) : null, bridges?.deck != null ? (_jsx("mesh", { geometry: bridges.deck, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: CONCRETE_COLOR, roughness: 0.85, metalness: 0.05, side: THREE.DoubleSide }) })) : null, bridges !== null && bridges.concrete.length > 0 ? (_jsx(InstancedBoxes, { matrices: bridges.concrete, color: CONCRETE_COLOR })) : null, bridges !== null && bridges.steel.length > 0 ? _jsx(InstancedBoxes, { matrices: bridges.steel, color: STEEL_COLOR }) : null, tunnels?.roof != null ? (_jsx("mesh", { geometry: tunnels.roof, castShadow: true, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: TUNNEL_COLOR, roughness: 0.95, metalness: 0, side: THREE.DoubleSide }) })) : null, tunnels !== null && tunnels.stone.length > 0 ? _jsx(InstancedBoxes, { matrices: tunnels.stone, color: TUNNEL_COLOR }) : null, massing !== null ? massing.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `massing:${i}`)) : null, furniture !== null ? furniture.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `furniture:${i}`)) : null, race !== null ? race.meshes.map((mesh, i) => _jsx("primitive", { object: mesh }, `race:${i}`)) : null, _jsx(DetailBuildings, { buildings: detail.buildings, palette: palette, kitName: resolved?.rules.buildingKit ?? "" })] }));
1475
1537
  }
1476
1538
  /** One instanced unit-box mesh from precomposed matrices (bridge parts). */
1477
1539
  function InstancedBoxes({ matrices, color }) {
@@ -0,0 +1,7 @@
1
+ import type { CityIntersection, CityStreet } from "@jgengine/core/world/cityKind";
2
+ /**
3
+ * Surface for a crossing's welded patch: gravel only when every street running through the crossing
4
+ * is gravel, so an unpaved hamlet's junctions stay gravel while a lane meeting a paved main stays asphalt.
5
+ * @internal
6
+ */
7
+ export declare function junctionSurface(cross: Pick<CityIntersection, "x" | "z" | "radius">, streets: readonly Pick<CityStreet, "points" | "width" | "surface">[]): CityStreet["surface"];
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Surface for a crossing's welded patch: gravel only when every street running through the crossing
3
+ * is gravel, so an unpaved hamlet's junctions stay gravel while a lane meeting a paved main stays asphalt.
4
+ * @internal
5
+ */
6
+ export function junctionSurface(cross, streets) {
7
+ let touched = false;
8
+ for (const street of streets) {
9
+ const reach = cross.radius + street.width * 0.5 + 0.5;
10
+ if (distanceToPath(cross.x, cross.z, street.points) > reach)
11
+ continue;
12
+ if (street.surface !== "gravel")
13
+ return "asphalt";
14
+ touched = true;
15
+ }
16
+ return touched ? "gravel" : "asphalt";
17
+ }
18
+ function distanceToPath(x, z, points) {
19
+ if (points.length === 0)
20
+ return Infinity;
21
+ if (points.length === 1)
22
+ return Math.hypot(x - points[0][0], z - points[0][1]);
23
+ let best = Infinity;
24
+ for (let i = 1; i < points.length; i += 1) {
25
+ const [ax, az] = points[i - 1];
26
+ const [bx, bz] = points[i];
27
+ const dx = bx - ax;
28
+ const dz = bz - az;
29
+ const len2 = dx * dx + dz * dz;
30
+ const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((x - ax) * dx + (z - az) * dz) / len2));
31
+ best = Math.min(best, Math.hypot(x - (ax + dx * t), z - (az + dz * t)));
32
+ }
33
+ return best;
34
+ }
@@ -51,7 +51,7 @@ export function useSettingsCategories(config) {
51
51
  const id = busVolumeSettingId(bus.id);
52
52
  return {
53
53
  id,
54
- label: `${bus.id.charAt(0).toUpperCase()}${bus.id.slice(1)} volume`,
54
+ label: bus.id === "ui" ? "UI volume" : `${bus.id.charAt(0).toUpperCase()}${bus.id.slice(1)} volume`,
55
55
  kind: "slider",
56
56
  value: store.get(id, bus.gain ?? 1),
57
57
  min: 0,
@@ -10,3 +10,7 @@ export { createSeededRandom, hashNoise2, seedToUint32, type TerrainSeed } from "
10
10
  export { createFieldGroundGeometry, createProceduralGroundGeometry, createProceduralTerrainSampler, normalizeHeightBlend, resolveTerrainSegments, resolveTerrainSize, toNoiseFieldConfig, type FieldGroundOptions, type ProceduralTerrainConfig, type ResolvedTerrainSegments, type ResolvedTerrainSize, type TerrainArea, type TerrainHeightSampler, type TerrainSurfaceColorOptions, type TerrainVertexColorOptions, } from "./terrainMath.js";
11
11
  export { arenaField, flatField, fractalNoise, noiseField, resolveGroundStep, resolveTerrainField, valueNoise, withNormal, type FractalNoiseConfig, type NoiseFieldConfig, type TerrainField, type TerrainNormal, } from "@jgengine/core/world/terrain";
12
12
  export { createTerrainPaintColorSampler, type TerrainPaintColorSampler, type TerrainPaintSource } from "./terrainPaintColor.js";
13
+ export { ALL_TERRAIN_SURFACE_CLASSES, createTerrainDetailMaterial, type TerrainDetailMaterialHandle, type TerrainDetailMaterialOptions, type TerrainDetailMaterialTextures, } from "./terrainDetailMaterial.js";
14
+ export { createTerrainPaintTextures, MAX_TERRAIN_PAINT_LAYERS, packTerrainPaintWeights, terrainSurfaceClass, terrainSurfaceClassIndex, terrainSurfaceRoughness, type TerrainPaintSlot, type TerrainPaintTextures, type TerrainPaintWeights, type TerrainSurfaceClass, } from "./terrainSurfaces.js";
15
+ export { TerrainQualityContext, useTerrainQuality } from "./terrainQuality.js";
16
+ export { createSurfaceRibbonMaterial, resolvePathSurface, RIBBON_FRAME_ATTRIBUTE, ribbonEdgeMargin, ribbonSurfaceFrame, useSurfaceRibbonMaterial, type RibbonSurfaceStyle, type SurfaceRibbonMaterialOptions, } from "./surfaceRibbonMaterial.js";