@jgengine/shell 0.18.0 → 0.19.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 (148) hide show
  1. package/CHANGELOG.md +265 -2
  2. package/dist/GamePlayerShell.js +27 -4
  3. package/dist/JoinGate.d.ts +17 -0
  4. package/dist/JoinGate.js +14 -0
  5. package/dist/Shell3dPresentation.d.ts +9 -8
  6. package/dist/Shell3dPresentation.js +57 -8
  7. package/dist/ShellChrome.d.ts +2 -0
  8. package/dist/ShellChrome.js +6 -0
  9. package/dist/ShellHudPresentation.d.ts +9 -1
  10. package/dist/ShellHudPresentation.js +21 -0
  11. package/dist/TerritoryOverlay.d.ts +21 -0
  12. package/dist/TerritoryOverlay.js +9 -0
  13. package/dist/audio/AudioComponents.js +13 -2
  14. package/dist/audio/audioEngine.d.ts +15 -1
  15. package/dist/audio/audioEngine.js +132 -11
  16. package/dist/audio/audioWire.d.ts +3 -0
  17. package/dist/audio/audioWire.js +7 -1
  18. package/dist/audio/loopParams.d.ts +39 -0
  19. package/dist/audio/loopParams.js +43 -0
  20. package/dist/camera/GameCameraRig.js +1 -1
  21. package/dist/camera/GameFirstPersonCamera.d.ts +4 -2
  22. package/dist/camera/GameFirstPersonCamera.js +52 -15
  23. package/dist/camera/Viewports.d.ts +34 -0
  24. package/dist/camera/Viewports.js +123 -0
  25. package/dist/camera/cameraRigs.js +77 -62
  26. package/dist/camera/orbitCameraMath.js +2 -2
  27. package/dist/camera/rigMath.d.ts +82 -4
  28. package/dist/camera/rigMath.js +133 -15
  29. package/dist/defineGame.d.ts +9 -0
  30. package/dist/defineGame.js +38 -6
  31. package/dist/devtools/DevtoolsOverlay.js +2 -0
  32. package/dist/devtools/KeysPanel.js +3 -3
  33. package/dist/devtools/agentBridge.d.ts +6 -0
  34. package/dist/devtools/agentBridge.js +15 -1
  35. package/dist/diagnostics/FpsCounter.d.ts +17 -0
  36. package/dist/diagnostics/FpsCounter.js +32 -0
  37. package/dist/drivers/FrameDriver.d.ts +5 -1
  38. package/dist/drivers/FrameDriver.js +74 -26
  39. package/dist/drivers/FrameRateLimiter.d.ts +9 -0
  40. package/dist/drivers/FrameRateLimiter.js +39 -0
  41. package/dist/drivers/HudOnlyDriver.js +10 -5
  42. package/dist/environment/DayNightSky.js +5 -5
  43. package/dist/environment/Daylight.d.ts +35 -1
  44. package/dist/environment/Daylight.js +98 -28
  45. package/dist/environment/EnvironmentScene.js +1 -1
  46. package/dist/environment/VolumetricClouds.js +20 -2
  47. package/dist/environment/daylightCycle.d.ts +20 -0
  48. package/dist/environment/daylightCycle.js +59 -7
  49. package/dist/environment/sunShadowMath.d.ts +45 -0
  50. package/dist/environment/sunShadowMath.js +50 -0
  51. package/dist/input/gamepadMerge.d.ts +10 -0
  52. package/dist/input/gamepadMerge.js +12 -0
  53. package/dist/input/gamepadPoll.d.ts +47 -0
  54. package/dist/input/gamepadPoll.js +142 -0
  55. package/dist/input/gamepadSource.d.ts +22 -0
  56. package/dist/input/gamepadSource.js +153 -0
  57. package/dist/input/mouseLook.js +2 -1
  58. package/dist/input/padHaptics.d.ts +17 -0
  59. package/dist/input/padHaptics.js +32 -0
  60. package/dist/input/pointerLock.d.ts +11 -0
  61. package/dist/input/pointerLock.js +19 -0
  62. package/dist/materialOverride.d.ts +4 -1
  63. package/dist/materialOverride.js +10 -1
  64. package/dist/postfx/PostProcessing.d.ts +11 -8
  65. package/dist/postfx/PostProcessing.js +38 -16
  66. package/dist/postfx/gradeShader.js +13 -0
  67. package/dist/postfx/stylizePass.d.ts +23 -0
  68. package/dist/postfx/stylizePass.js +216 -0
  69. package/dist/render/CascadedShadows.d.ts +11 -0
  70. package/dist/render/CascadedShadows.js +19 -8
  71. package/dist/render/EnvironmentLighting.d.ts +4 -1
  72. package/dist/render/EnvironmentLighting.js +45 -9
  73. package/dist/render/SceneLighting.d.ts +16 -2
  74. package/dist/render/SceneLighting.js +56 -6
  75. package/dist/render/SceneModels.js +45 -9
  76. package/dist/render/SkinnedInstances.d.ts +91 -0
  77. package/dist/render/SkinnedInstances.js +249 -0
  78. package/dist/render/TextureFiltering.d.ts +16 -0
  79. package/dist/render/TextureFiltering.js +64 -0
  80. package/dist/render/displayColor.d.ts +33 -0
  81. package/dist/render/displayColor.js +138 -0
  82. package/dist/render/modelLoad.d.ts +39 -0
  83. package/dist/render/modelLoad.js +89 -1
  84. package/dist/render/modelRender.d.ts +3 -0
  85. package/dist/render/modelRender.js +6 -2
  86. package/dist/render/resolveModel.js +4 -0
  87. package/dist/render/useFootIk.d.ts +58 -0
  88. package/dist/render/useFootIk.js +216 -0
  89. package/dist/render/useModelAnimation.d.ts +26 -0
  90. package/dist/render/useModelAnimation.js +184 -107
  91. package/dist/scene/AuthoredScene.d.ts +9 -0
  92. package/dist/scene/AuthoredScene.js +17 -1
  93. package/dist/scene/CityRenderer.js +666 -241
  94. package/dist/scene/cityWindowMaterial.d.ts +8 -6
  95. package/dist/scene/cityWindowMaterial.js +80 -21
  96. package/dist/settings/QuickControls.js +1 -1
  97. package/dist/settings/SettingsMenu.js +1 -1
  98. package/dist/settings/appliedSettings.d.ts +10 -2
  99. package/dist/settings/appliedSettings.js +9 -5
  100. package/dist/settings/settingsController.d.ts +3 -0
  101. package/dist/settings/settingsController.js +47 -3
  102. package/dist/structures/BuildingKitBatch.js +26 -22
  103. package/dist/structures/GeneratedBuilding.d.ts +3 -15
  104. package/dist/structures/GeneratedBuilding.js +21 -15
  105. package/dist/structures/buildingSurface.d.ts +21 -0
  106. package/dist/structures/buildingSurface.js +134 -0
  107. package/dist/terrain/terrainDetailMaterial.js +7 -3
  108. package/dist/useShellMultiplayerSync.d.ts +9 -2
  109. package/dist/useShellMultiplayerSync.js +30 -8
  110. package/dist/visibility/CullingProvider.d.ts +2 -1
  111. package/dist/visibility/CullingProvider.js +7 -2
  112. package/dist/world/GridWorldScene.js +4 -0
  113. package/dist/world/TileLayerRenderer.d.ts +9 -0
  114. package/dist/world/TileLayerRenderer.js +64 -0
  115. package/dist/world/WorldParticles.d.ts +31 -0
  116. package/dist/world/WorldParticles.js +128 -0
  117. package/dist/world/WorldScene.d.ts +2 -1
  118. package/dist/world/WorldScene.js +46 -4
  119. package/dist/world/entityPose.d.ts +4 -0
  120. package/dist/world/entityPose.js +9 -0
  121. package/dist/world/tileLayerInstances.d.ts +11 -0
  122. package/dist/world/tileLayerInstances.js +28 -0
  123. package/dist/worldSync.d.ts +5 -1
  124. package/dist/worldSync.js +25 -4
  125. package/package.json +4 -4
  126. package/skills/ce-handoff/SKILL.md +2 -2
  127. package/skills/game-design/SKILL.md +11 -1
  128. package/skills/jgengine/SKILL.md +9 -5
  129. package/skills/jgengine/api.md +205 -101
  130. package/skills/jgengine/capabilities.md +56 -0
  131. package/skills/jgengine/recipes/minimal-game.md +3 -0
  132. package/skills/jgengine-ui/SKILL.md +20 -15
  133. package/skills/jgengine-ui/api.md +538 -326
  134. package/skills/jgengine-ui/capabilities.md +37 -0
  135. package/skills/jgengine-ui/reference-primitives.md +89 -0
  136. package/skills/jgengine-ui/reference.md +2 -78
  137. package/skills/jgengine-world/SKILL.md +21 -3
  138. package/skills/jgengine-world/api.md +1172 -625
  139. package/skills/jgengine-world/capabilities.md +229 -3
  140. package/skills/jgengine-world/recipes/character-animation.md +39 -0
  141. package/skills/jgengine-world/recipes/controls.md +44 -0
  142. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  143. package/skills/jgengine-world/recipes/npc-senses.md +43 -0
  144. package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
  145. package/skills/jgengine-world/reference.md +31 -2
  146. package/skills/jgengine-world/visibility.md +112 -0
  147. package/skills/level-design/SKILL.md +1 -1
  148. package/skills/jgengine-ui/references/visual-scorecard.md +0 -73
@@ -6,7 +6,8 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
6
6
  * sidewalks; styled bridges with railings, piers and abutments; massing pieces as five instanced
7
7
  * primitive meshes (plain walls, window-banded walls via `createCityWindowMaterial`, gable roofs,
8
8
  * cylinders, domes) with palette-role coloring and per-lot jitter; species-mixed instanced trees
9
- * (shared scatter proxies), street lights, estate hedges, driveways, parking pads, and
9
+ * (shared scatter proxies), street lights, estate hedges, driveways, parking pads, curbside and
10
+ * pad-stall parked vehicles, mast-arm junction signals with sign posts, and
10
11
  * vertex-colored parks with crop-row fields. Lots near the camera and flat enough swap their
11
12
  * massing for full `generateBuilding` facade kits (windows, awnings, storefronts) batched per part
12
13
  * kind — a bounded LOD that re-picks only when the camera crosses a coarse cell, never per frame.
@@ -16,52 +17,85 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
16
17
  import { useEffect, useMemo, useRef, useState } from "react";
17
18
  import { useFrame } from "@react-three/fiber";
18
19
  import * as THREE from "three";
19
- import { roundPathCorners, buildJunctionSurface, buildRoadRibbon, dashSegments, GROUND_DECAL_LAYERS, trimPathAtJunctions, } from "@jgengine/core/world/roads";
20
+ import { roundPathCorners, buildIntersectionMarkings, buildRoadRibbon, buildTrimmedIntersections, dashSegments, GROUND_DECAL_LAYERS, } from "@jgengine/core/world/roads";
20
21
  import { offsetPath } from "@jgengine/core/world/streets";
21
22
  import { triangulatePolygon, triangulateRingBand } from "@jgengine/core/world/cityGeometry";
22
23
  import { resolveCityObject, CITY_KIND, CITY_ZONE_KIND, } from "@jgengine/core/world/cityKind";
23
- import { generateBuilding, resolveBuildingPalette } from "@jgengine/core/world/buildings";
24
+ import { zoneBand, zoneMetric } from "@jgengine/core/world/cityContent";
25
+ import { generateBuilding, buildingSurfaceColor, resolveBuildingPalette, resolveBuildingWallTones, } from "@jgengine/core/world/buildings";
26
+ import { hashString } from "@jgengine/core/random/rng";
24
27
  import { buildScatterProxy } from "../scatter/scatterProxies.js";
25
28
  import { InstancedBuildings } from "../structures/GeneratedBuilding.js";
26
29
  import { getBuildingKit } from "../structures/buildingKitRegistry.js";
27
30
  import { createCityWindowMaterial } from "./cityWindowMaterial.js";
28
31
  import { registerSceneKindRenderer } from "./sceneKindRenderers.js";
29
- const ASPHALT_COLOR = "#3d3f45";
32
+ const ASPHALT_COLOR = "#3a3b3d";
30
33
  const GRAVEL_COLOR = "#93815f";
31
- const SIDEWALK_COLOR = "#98948c";
32
- const MARKING_COLOR = "#e8e4d8";
34
+ const SIDEWALK_COLOR = "#867f74";
35
+ const MARKING_COLOR = "#ddd9cb";
33
36
  const MEDIAN_COLOR = "#4d7a40";
34
37
  const PAVEMENT_COLOR = "#a8a49a";
35
38
  const CONCRETE_COLOR = "#b0aba1";
36
39
  const STEEL_COLOR = "#4c505a";
37
40
  const TUNNEL_COLOR = "#5b5754";
38
- const CURB_COLOR = "#c4c0b6";
41
+ const CURB_COLOR = "#b8b4aa";
39
42
  const HEDGE_COLOR = "#3d6631";
40
- const PARK_COLORS = { plaza: "#b3ada0", green: "#4e7c41", meadow: "#6d8a4c", field: "#94805a", courtyard: "#5c8348", buffer: "#63734a" };
43
+ const VEHICLE_PAINTS = ["#b9bdc3", "#2c2f33", "#79838f", "#8d3330", "#27446e", "#eceae1", "#3c5c48", "#a8792f"];
44
+ const VEHICLE_GLASS_COLOR = "#23262b";
45
+ const SIGNAL_POST_COLOR = "#3b4046";
46
+ const SIGNAL_HOUSING_COLOR = "#1e2226";
47
+ const SIGN_FACE_COLOR = "#c8ccd2";
48
+ const SIGNAL_LAMP_COLORS = ["#d43a2c", "#dfa32b", "#33a558"];
49
+ const KERB_RED = new THREE.Color("#d23a34");
50
+ const KERB_WHITE = new THREE.Color("#e8e8ea");
51
+ const CHECK_DARK = new THREE.Color("#101015");
52
+ const CHECK_LIGHT = new THREE.Color("#e8e8ea");
53
+ const RACE_ASPHALT = new THREE.Color("#1a1c20");
54
+ const RACE_EDGE = new THREE.Color("#d8d4c8");
55
+ const PARK_COLORS = { plaza: "#b3ada0", green: "#4e7c41", meadow: "#6d8a4c", field: "#94805a", courtyard: "#6a655c", buffer: "#63734a" };
41
56
  const CROP_COLORS = ["#5d7a35", "#7a6b41", "#6f7f3a"];
57
+ /** Block-interior ground by zone band. A dense district keeps hard surface all the way to its rim;
58
+ * only a low-density (large-block) district lets the edge band go back to lawn. */
59
+ const BLOCK_SURFACE_COLORS = { core: "#5b5850", mid: "#6c6456", edge: "#78765c" };
60
+ 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";
63
+ /** Non-buildable block land takes its open-space colour so a park block never shows raw terrain. */
64
+ const BLOCK_OPEN_COLORS = { park: PARK_COLORS.green, plaza: PARK_COLORS.plaza, field: PARK_COLORS.field, buffer: PARK_COLORS.buffer };
42
65
  /**
43
66
  * City ground-decal stack. The single-owner {@link GROUND_DECAL_LAYERS} table fixes the authored-road
44
67
  * heights, but a generated district drapes many more surface classes (sidewalk/park/median/curb) and
45
68
  * wants more clearance over relief, so it keeps its own baseline while mirroring the table's ordering
46
69
  * (terrain < road == junction < marking) and its two hard contracts: the welded junction surface
47
70
  * shares the road layer (seam-welded, never stacked), and markings ride above the road with
48
- * `polygonOffset`/`renderOrder` on the material. Values below preserve the district's prior look.
71
+ * `polygonOffset`/`renderOrder` on the material. Everything behind the curb (block land, sidewalk,
72
+ * parks) rides a curb-height step ABOVE the carriageway, and the curb face is real vertical
73
+ * geometry between the two — a flat sidewalk under the road plane cannot read as a street.
49
74
  */
50
- const SIDEWALK_LAYER = 0.1;
51
- const PARK_LAYER = 0.12;
52
- const DRIVEWAY_LAYER = 0.12;
53
- const BRIDGE_DECK_LIFT = 0.12;
54
- const PARKING_LAYER = 0.13;
55
- const PLAZA_LAYER = 0.14;
75
+ const URBAN_LAYER = 0.06; // street-derived developed ground, under everything else
56
76
  const ROAD_LAYER = 0.16; // asphalt/gravel carriageway AND the welded junction surface (seam-shared)
57
- const CROP_LAYER = 0.2;
58
- const CURB_LAYER = 0.24;
59
- const MARKING_LAYER = 0.26;
77
+ const MARKING_LAYER = 0.24;
60
78
  const MEDIAN_LAYER = 0.3;
61
- /** Trim + weld tuning shared by {@link trimPathAtJunctions} and {@link buildJunctionSurface} for the district. */
62
- const JUNCTION_OPTS = { curbReturnRadius: 3, apronMargin: 0.6, filletSegments: 6, elevation: ROAD_LAYER, maxSegmentLength: 2 };
79
+ const BLOCK_LAND_LAYER = 0.3;
80
+ const CURB_LAYER = 0.32;
81
+ const SIDEWALK_LAYER = 0.32;
82
+ const PARK_LAYER = 0.34;
83
+ const DRIVEWAY_LAYER = 0.34;
84
+ const PARKING_LAYER = 0.35;
85
+ const PLAZA_LAYER = 0.36;
86
+ const CROP_LAYER = 0.42;
87
+ const RACE_SURFACE_LAYER = 0.28;
88
+ const RACE_PAINT_LAYER = 0.3;
89
+ const BRIDGE_DECK_LIFT = 0.12;
90
+ /** Compact carriageway-union — same seam as StreetGeometryPreview and playground `cityScene`. */
91
+ const JUNCTION_OPTS = { curbReturnRadius: 2, apronMargin: 0.25, filletSegments: 6, elevation: ROAD_LAYER, maxSegmentLength: 2 };
63
92
  const SPECIES_PROXY = { broadleaf: "oak", conifer: "pine", palm: "palm", cypress: "cypress" };
64
93
  const SPECIES_SCALE = { broadleaf: 2.7, conifer: 2.5, palm: 2.6, cypress: 2.3 };
94
+ const VEHICLE_SHAPES = {
95
+ car: { body: [1.82, 0.72, 4.42], bodyY: 0.62, cabin: [1.62, 0.62, 2.24], cabinY: 1.24, cabinAlong: -0.24, roof: [1.34, 0.12, 1.86], roofY: 1.58, roofAlong: -0.24 },
96
+ van: { body: [2.02, 1.0, 5.2], bodyY: 0.76, cabin: [1.82, 0.74, 3.2], cabinY: 1.58, cabinAlong: -0.42, roof: [1.56, 0.12, 2.8], roofY: 1.99, roofAlong: -0.42 },
97
+ truck: { body: [2.32, 1.46, 6.8], bodyY: 0.99, cabin: [2.1, 0.62, 0.54], cabinY: 1.32, cabinAlong: 3.18, roof: [1.94, 0.14, 6.1], roofY: 1.79, roofAlong: -0.2 },
98
+ };
65
99
  /** Accumulates draped ribbons, patches, discs, and quads into one merged (optionally colored) buffer. */
66
100
  class MeshAccumulator {
67
101
  positions = [];
@@ -162,6 +196,22 @@ class MeshAccumulator {
162
196
  this.indices.push(index + base);
163
197
  this.pushColor(positions.length, color);
164
198
  }
199
+ /** A vertical skirt around a closed ring — the curb face between carriageway and sidewalk. */
200
+ addRingWall(ring, sample, bottom, top, color = null) {
201
+ if (ring.length < 3)
202
+ return;
203
+ const base = this.positions.length / 3;
204
+ for (const [x, z] of ring) {
205
+ const y = sample(x, z);
206
+ this.positions.push(x, y + bottom, z, x, y + top, z);
207
+ }
208
+ for (let i = 0; i < ring.length; i += 1) {
209
+ const a = base + i * 2;
210
+ const b = base + ((i + 1) % ring.length) * 2;
211
+ this.indices.push(a, a + 1, b, b, a + 1, b + 1);
212
+ }
213
+ this.pushColor(ring.length * 2, color);
214
+ }
165
215
  build() {
166
216
  if (this.indices.length === 0)
167
217
  return null;
@@ -173,6 +223,48 @@ class MeshAccumulator {
173
223
  geometry.computeVertexNormals();
174
224
  return geometry;
175
225
  }
226
+ /** Flat draped quad in XZ, already lifted: corners in order around the face. */
227
+ addQuad(corners, color) {
228
+ if (this.colors === null)
229
+ return;
230
+ const base = this.positions.length / 3;
231
+ for (const corner of corners)
232
+ this.positions.push(corner[0], corner[1], corner[2]);
233
+ this.indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
234
+ this.pushColor(4, color);
235
+ }
236
+ /** Standing barrier brick: a box extruded along `across` and `forward`, striped by the caller. */
237
+ addSealBlock(x, z, y, height, halfAcross, halfDepth, across, forward, color) {
238
+ if (this.colors === null)
239
+ return;
240
+ const ax = across[0];
241
+ const az = across[1];
242
+ const fx = forward[0];
243
+ const fz = forward[1];
244
+ const corners = [
245
+ [x - ax * halfAcross - fx * halfDepth, y, z - az * halfAcross - fz * halfDepth],
246
+ [x + ax * halfAcross - fx * halfDepth, y, z + az * halfAcross - fz * halfDepth],
247
+ [x + ax * halfAcross + fx * halfDepth, y, z + az * halfAcross + fz * halfDepth],
248
+ [x - ax * halfAcross + fx * halfDepth, y, z - az * halfAcross + fz * halfDepth],
249
+ [x - ax * halfAcross - fx * halfDepth, y + height, z - az * halfAcross - fz * halfDepth],
250
+ [x + ax * halfAcross - fx * halfDepth, y + height, z + az * halfAcross - fz * halfDepth],
251
+ [x + ax * halfAcross + fx * halfDepth, y + height, z + az * halfAcross + fz * halfDepth],
252
+ [x - ax * halfAcross + fx * halfDepth, y + height, z - az * halfAcross + fz * halfDepth],
253
+ ];
254
+ const base = this.positions.length / 3;
255
+ for (const corner of corners)
256
+ this.positions.push(corner[0], corner[1], corner[2]);
257
+ const faces = [
258
+ [0, 1, 5, 4],
259
+ [1, 2, 6, 5],
260
+ [2, 3, 7, 6],
261
+ [3, 0, 4, 7],
262
+ [4, 5, 6, 7],
263
+ ];
264
+ for (const [a, b, c, d] of faces)
265
+ this.indices.push(base + a, base + b, base + c, base + a, base + c, base + d);
266
+ this.pushColor(8, color);
267
+ }
176
268
  }
177
269
  /** Split a polyline into runs that keep `pad` clearance from every intersection disc. */
178
270
  function clipPolylineAtIntersections(points, intersections, pad) {
@@ -210,86 +302,6 @@ function clipPolylineAtIntersections(points, intersections, pad) {
210
302
  runs.push(current);
211
303
  return runs;
212
304
  }
213
- /** Andrew's monotone-chain convex hull of XZ points (CCW), degenerate-safe. */
214
- function convexHull(points) {
215
- const pts = points.map((p) => [p[0], p[1]]).sort((a, b) => (a[0] === b[0] ? a[1] - b[1] : a[0] - b[0]));
216
- if (pts.length < 3)
217
- return pts;
218
- const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
219
- const lower = [];
220
- for (const p of pts) {
221
- while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0)
222
- lower.pop();
223
- lower.push(p);
224
- }
225
- const upper = [];
226
- for (let i = pts.length - 1; i >= 0; i -= 1) {
227
- const p = pts[i];
228
- while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0)
229
- upper.pop();
230
- upper.push(p);
231
- }
232
- lower.pop();
233
- upper.pop();
234
- return lower.concat(upper);
235
- }
236
- /** Round every vertex of a CLOSED ring into a short arc — the curb-return radius that turns a hard
237
- * polygon corner into a real street corner. Radius is clamped to each corner's shorter leg. */
238
- function roundRing(ring, radius, segments = 5) {
239
- if (ring.length < 3 || radius <= 0)
240
- return ring.map((p) => [p[0], p[1]]);
241
- const n = ring.length;
242
- const out = [];
243
- for (let i = 0; i < n; i += 1) {
244
- const a = ring[(i - 1 + n) % n];
245
- const v = ring[i];
246
- const b = ring[(i + 1) % n];
247
- const ax = a[0] - v[0];
248
- const az = a[1] - v[1];
249
- const bx = b[0] - v[0];
250
- const bz = b[1] - v[1];
251
- const la = Math.hypot(ax, az) || 1;
252
- const lb = Math.hypot(bx, bz) || 1;
253
- const r = Math.min(radius, la / 2, lb / 2);
254
- const p0 = [v[0] + (ax / la) * r, v[1] + (az / la) * r];
255
- const p1 = [v[0] + (bx / lb) * r, v[1] + (bz / lb) * r];
256
- out.push(p0);
257
- for (let s = 1; s < segments; s += 1) {
258
- const t = s / segments;
259
- const u = 1 - t;
260
- out.push([u * u * p0[0] + 2 * u * t * v[0] + t * t * p1[0], u * u * p0[1] + 2 * u * t * v[1] + t * t * p1[1]]);
261
- }
262
- out.push(p1);
263
- }
264
- return out;
265
- }
266
- /**
267
- * The pavement outline of a junction: each arm's roadway extended to the crossing, hulled into one
268
- * convex area, then corner-rounded. This reads as a real intersection (a squared crossing with
269
- * rounded curb corners) instead of the old stamped circle. Returns `null` for a degenerate junction.
270
- */
271
- function junctionOutline(cross, reachScale = 1.15, expand = 0) {
272
- if (cross.arms.length < 2)
273
- return null;
274
- let maxHalf = 0;
275
- for (const arm of cross.arms)
276
- maxHalf = Math.max(maxHalf, arm.width / 2);
277
- const reach = maxHalf * reachScale + expand;
278
- const mouth = [];
279
- for (const arm of cross.arms) {
280
- const dir = [Math.sin(arm.angle), Math.cos(arm.angle)];
281
- const perp = [-dir[1], dir[0]];
282
- const hw = arm.width / 2 + expand;
283
- mouth.push([cross.x + dir[0] * reach + perp[0] * hw, cross.z + dir[1] * reach + perp[1] * hw]);
284
- mouth.push([cross.x + dir[0] * reach - perp[0] * hw, cross.z + dir[1] * reach - perp[1] * hw]);
285
- }
286
- const hull = convexHull(mouth);
287
- if (hull.length < 3)
288
- return null;
289
- // Curb-return radius: a real corner, capped so a tight junction never over-rounds into a blob.
290
- const cornerR = Math.min(maxHalf * 0.9 + expand, 5.5);
291
- return roundRing(hull, cornerR, 6);
292
- }
293
305
  let unitGeometryCache = null;
294
306
  /** Unit massing primitives shared by every district: box, gable prism (ridge along X), cylinder, dome. */
295
307
  function unitGeometries() {
@@ -315,12 +327,12 @@ function unitGeometries() {
315
327
  const ROLE_JITTER = { wall: 1, roof: 0.5, trim: 0.35, accent: 0.6 };
316
328
  function roleColor(palette, role) {
317
329
  if (role === "wall")
318
- return palette.wall;
330
+ return buildingSurfaceColor(palette.wall);
319
331
  if (role === "roof")
320
- return palette.roof;
332
+ return buildingSurfaceColor(palette.roof);
321
333
  if (role === "trim")
322
- return palette.corner;
323
- return palette.awning;
334
+ return buildingSurfaceColor(palette.corner);
335
+ return buildingSurfaceColor(palette.awning);
324
336
  }
325
337
  const DETAIL_RADIUS = 95;
326
338
  const DETAIL_MAX = 12;
@@ -436,18 +448,20 @@ function OneCity({ object, context }) {
436
448
  const units = unitGeometries();
437
449
  const buckets = {
438
450
  box: { matrices: [], colors: [] },
439
- banded: { matrices: [], colors: [], groundOffsets: [] },
451
+ banded: { matrices: [], colors: [], groundOffsets: [], facades: [] },
440
452
  gable: { matrices: [], colors: [] },
441
453
  cylinder: { matrices: [], colors: [] },
442
454
  dome: { matrices: [], colors: [] },
443
455
  };
444
456
  const color = new THREE.Color();
457
+ const wallTones = resolveBuildingWallTones(resolved.rules.style).map((tone) => new THREE.Color(tone));
445
458
  const roleBase = {
446
459
  wall: new THREE.Color(roleColor(palette, "wall")),
447
460
  roof: new THREE.Color(roleColor(palette, "roof")),
448
461
  trim: new THREE.Color(roleColor(palette, "trim")),
449
462
  accent: new THREE.Color(roleColor(palette, "accent")),
450
463
  };
464
+ const lotWall = new THREE.Color();
451
465
  for (const lot of resolved.lots) {
452
466
  if (detail.ids.has(lot.id))
453
467
  continue;
@@ -471,6 +485,17 @@ function OneCity({ object, context }) {
471
485
  const foundationBase = minY - 0.5;
472
486
  // Edge-of-town lots vary more; downtown stays composed.
473
487
  const jitterAmp = lot.zone === "edge" ? 0.4 : lot.zone === "mid" ? 0.28 : 0.18;
488
+ // Each building draws its wall colour from the style's tone family, then shifts value/hue on
489
+ // top — one flat hue per style is what makes a whole skyline read as a single paint bucket.
490
+ const idHash = hashString(lot.id);
491
+ lotWall.copy(wallTones[idHash % wallTones.length]);
492
+ const shade = ((idHash >>> 8) % 1024) / 1024;
493
+ const hue = ((idHash >>> 19) % 512) / 512;
494
+ lotWall.offsetHSL((hue - 0.5) * 0.07, (hue - 0.5) * 0.24, (shade - 0.5) * 0.2);
495
+ const facadeSeed = ((idHash >>> 17) % 997) / 997;
496
+ // Parking structures are the one class whose "windows" are open deck slots, and the resolver
497
+ // can seat an interior-fill class here that `CityLotClass` does not name.
498
+ const facadeKind = lot.class === "garage" ? 2 : lot.floors >= 8 ? 1 : 0;
474
499
  for (const piece of lot.pieces) {
475
500
  const px = lot.center[0] + piece.offset[0] * c + piece.offset[2] * s;
476
501
  const pz = lot.center[1] - piece.offset[0] * s + piece.offset[2] * c;
@@ -480,19 +505,23 @@ function OneCity({ object, context }) {
480
505
  const groundOffset = grounded ? grade - foundationBase : -piece.offset[1];
481
506
  const matrix = new THREE.Matrix4().compose(new THREE.Vector3(px, piece.shape === "gable" || piece.shape === "cylinder" || piece.shape === "dome" ? baseY : baseY + height / 2, pz), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), lot.rotationY + piece.rotationY), new THREE.Vector3(Math.max(0.05, piece.size[0]), Math.max(0.05, height), Math.max(0.05, piece.size[2])));
482
507
  const amp = jitterAmp * ROLE_JITTER[piece.role];
483
- color.copy(roleBase[piece.role]);
508
+ color.copy(piece.role === "wall" ? lotWall : roleBase[piece.role]);
509
+ // Trim/roof/accent stay in the lot's own family so a building reads as one object, not as
510
+ // borrowed parts: pull each partway toward its wall tone before the role jitter.
511
+ if (piece.role === "trim")
512
+ color.lerp(lotWall, 0.45);
513
+ else if (piece.role === "roof")
514
+ color.lerp(lotWall, 0.2);
484
515
  // Accent trim (awnings/cornices) is a saturated storefront color that reads as a stray red/pink
485
516
  // strip at district scale — pull it partway to the wall so it stays a tasteful accent.
486
- if (piece.role === "accent")
487
- color.lerp(roleBase.wall, 0.5);
517
+ else if (piece.role === "accent")
518
+ color.lerp(lotWall, 0.5);
488
519
  color.multiplyScalar(1 - amp / 2 + lot.jitter * amp);
489
- // A whisper of hue shift so rows of houses never read as one paint bucket.
490
- color.r *= 1 + (lot.jitter - 0.5) * 0.08;
491
- color.b *= 1 + (0.5 - lot.jitter) * 0.08;
492
520
  const bucket = piece.shape === "box" ? (piece.banded ? buckets.banded : buckets.box) : buckets[piece.shape];
493
521
  bucket.matrices.push(matrix);
494
522
  bucket.colors.push(color.clone());
495
523
  bucket.groundOffsets?.push(groundOffset);
524
+ bucket.facades?.push(facadeSeed, facadeKind);
496
525
  }
497
526
  }
498
527
  // Cylinders/gables/domes scale from their base (geometry roots at y=0), boxes from center —
@@ -504,9 +533,10 @@ function OneCity({ object, context }) {
504
533
  const mesh = new THREE.InstancedMesh(geometry, material, bucket.matrices.length);
505
534
  bucket.matrices.forEach((matrix, i) => mesh.setMatrixAt(i, matrix));
506
535
  bucket.colors.forEach((tint, i) => mesh.setColorAt(i, tint));
507
- if (bucket.groundOffsets !== undefined) {
536
+ if (bucket.groundOffsets !== undefined && bucket.facades !== undefined) {
508
537
  mesh.geometry = geometry.clone();
509
538
  mesh.geometry.setAttribute("instanceGroundOffset", new THREE.InstancedBufferAttribute(new Float32Array(bucket.groundOffsets), 1));
539
+ mesh.geometry.setAttribute("instanceFacade", new THREE.InstancedBufferAttribute(new Float32Array(bucket.facades), 2));
510
540
  }
511
541
  mesh.instanceMatrix.needsUpdate = true;
512
542
  if (mesh.instanceColor !== null)
@@ -517,7 +547,7 @@ function OneCity({ object, context }) {
517
547
  return mesh;
518
548
  };
519
549
  const plain = new THREE.MeshStandardMaterial({ roughness: 0.85, metalness: 0.04 });
520
- const banded = createCityWindowMaterial({ floorHeight: resolved.rules.floorHeight, windowColor: palette.window });
550
+ const banded = createCityWindowMaterial({ floorHeight: resolved.rules.floorHeight, windowColor: buildingSurfaceColor(palette.window) });
521
551
  const meshes = [
522
552
  makeMesh("box", units.box, plain),
523
553
  makeMesh("banded", units.box, banded),
@@ -540,11 +570,13 @@ function OneCity({ object, context }) {
540
570
  const ground = useMemo(() => {
541
571
  if (resolved === null || resolved.streets.length === 0)
542
572
  return null;
543
- const asphalt = new MeshAccumulator();
573
+ const asphalt = new MeshAccumulator(true);
544
574
  const gravel = new MeshAccumulator();
545
575
  const sidewalks = new MeshAccumulator();
546
- const patches = new MeshAccumulator();
547
- const markings = new MeshAccumulator();
576
+ const urban = new MeshAccumulator(true);
577
+ const land = new MeshAccumulator(true);
578
+ const patches = new MeshAccumulator(true);
579
+ const markings = new MeshAccumulator(true);
548
580
  const curbs = new MeshAccumulator();
549
581
  const medians = new MeshAccumulator();
550
582
  const pavementDrives = new MeshAccumulator();
@@ -552,9 +584,14 @@ function OneCity({ object, context }) {
552
584
  const parks = new MeshAccumulator(true);
553
585
  const crops = new MeshAccumulator(true);
554
586
  const parkColor = new THREE.Color();
587
+ const wearColor = new THREE.Color();
588
+ const markColor = new THREE.Color();
589
+ const LANE_TINT = new THREE.Color(0.78, 0.77, 0.72);
590
+ const STALL_TINT = new THREE.Color(0.55, 0.54, 0.5);
555
591
  // Sidewalks now come from block curb→land bands; only pre-block documents fall back to the old
556
592
  // per-street widened ribbon.
557
593
  const useBlockSidewalks = resolved.blocks.length > 0;
594
+ const sidewalkW = Math.max(0, resolved.rules.sidewalkWidth);
558
595
  // Feature-aware height: because streets stay continuous across water and through ridges, the road
559
596
  // must ride UP onto a bridge deck and hold FLAT through a tunnel bore rather than dive under the
560
597
  // river or climb over the hill. Points off any feature fall back to the terrain sampler.
@@ -605,7 +642,7 @@ function OneCity({ object, context }) {
605
642
  }
606
643
  return sample(x, z);
607
644
  };
608
- const addMarkQuad = (center, along, length, width) => {
645
+ const addMarkQuad = (center, along, length, width, tint) => {
609
646
  const perp = [-along[1], along[0]];
610
647
  const base = markings.positions.length / 3;
611
648
  for (const [sa, sp] of [
@@ -617,106 +654,123 @@ function OneCity({ object, context }) {
617
654
  const x = center[0] + along[0] * length * sa + perp[0] * width * sp;
618
655
  const z = center[1] + along[1] * length * sa + perp[1] * width * sp;
619
656
  markings.positions.push(x, deckSampler(x, z) + MARKING_LAYER, z);
657
+ markings.colors?.push(tint.r, tint.g, tint.b);
620
658
  }
621
659
  markings.indices.push(base, base + 2, base + 1, base + 1, base + 2, base + 3);
622
660
  };
623
- // Trim carriageways back to their junction boundaries and weld one surface per crossing (no
624
- // overlapping ribbons hidden under a floating disc). Street endpoints coincide bitwise with
625
- // junction nodes, so `trimPathAtJunctions` cuts each rounded ribbon exactly at the crossing; the
626
- // terminal corners feed `buildJunctionSurface` per intersection. Any crossing that gets no trimmed
627
- // approach (e.g. sliced past the intersection cap) falls back to the old rounded outline below.
661
+ // Trim carriageways and weld one compact surface per crossing — the same
662
+ // `buildTrimmedIntersections` seam StreetGeometryPreview and playground cityScene consume.
663
+ // Do not stack plaza discs, hull aprons, or fat zebra paint on top of that union.
664
+ const roundedPaths = resolved.streets.map((street) => street.points.length < 2 ? street.points : roundPathCorners(street.points, Math.max(2.5, street.width * 1.15), 9));
628
665
  const intersectionInputs = resolved.intersections.map((cross) => ({
629
666
  x: cross.x,
630
667
  z: cross.z,
631
668
  arms: cross.arms.map((arm) => ({ angle: arm.angle, width: arm.width })),
632
669
  }));
633
- const approachesByCross = new Map();
634
- for (const street of resolved.streets) {
670
+ const intersectionStreets = resolved.streets.map((street, streetIndex) => {
671
+ const path = roundedPaths[streetIndex];
672
+ // Always declare sidewalk widths when sidewalks are on so junction curb-return aprons weld;
673
+ // per-street sidewalk ribbons are skipped below when block curb rings already own the walk.
674
+ const wantSidewalk = street.sidewalk && sidewalkW > 0 && resolved.rules.sidewalks;
675
+ const arterial = street.level === "boulevard" || street.level === "avenue";
676
+ return {
677
+ path,
678
+ width: street.width,
679
+ ...(wantSidewalk ? { sidewalks: { left: sidewalkW, right: sidewalkW } } : {}),
680
+ ...(arterial && street.surface === "asphalt"
681
+ ? {
682
+ markings: {
683
+ lines: [{ offset: 0, width: 0.16 }],
684
+ stopLine: true,
685
+ },
686
+ }
687
+ : {}),
688
+ };
689
+ });
690
+ const trimmed = buildTrimmedIntersections(intersectionStreets, intersectionInputs, deckSampler, JUNCTION_OPTS);
691
+ for (let i = 0; i < trimmed.ribbons.length; i += 1) {
692
+ const streetIndex = trimmed.trimmedStreetIndices[i];
693
+ const street = resolved.streets[streetIndex];
694
+ const wear = (hashString(`surface:${streetIndex}`) % 1024) / 1024;
695
+ wearColor.setRGB(0.86 + wear * 0.3, 0.87 + wear * 0.28, 0.9 + wear * 0.24);
696
+ const target = street.surface === "gravel" ? gravel : asphalt;
697
+ target.addMesh(trimmed.ribbons[i], street.surface === "gravel" ? null : wearColor);
698
+ }
699
+ if (!useBlockSidewalks) {
700
+ for (const sidewalk of trimmed.sidewalks)
701
+ sidewalks.addMesh(sidewalk);
702
+ for (const apron of trimmed.sidewalkAprons)
703
+ sidewalks.addMesh(apron);
704
+ }
705
+ for (let j = 0; j < trimmed.junctions.length; j += 1) {
706
+ const crossIndex = trimmed.junctionIndices[j];
707
+ const scuff = (hashString(`cross:${crossIndex}`) % 512) / 512;
708
+ wearColor.setRGB(0.76 + scuff * 0.18, 0.77 + scuff * 0.17, 0.8 + scuff * 0.16);
709
+ patches.addMesh(trimmed.junctions[j], wearColor);
710
+ }
711
+ for (const paint of buildIntersectionMarkings(trimmed, deckSampler, {
712
+ mouthClearance: 1.25,
713
+ dashLength: 2.6,
714
+ dashGap: 3.6,
715
+ elevation: MARKING_LAYER,
716
+ maxSegmentLength: 2,
717
+ })) {
718
+ markColor.copy(LANE_TINT);
719
+ markings.addMesh(paint, markColor);
720
+ }
721
+ resolved.streets.forEach((street, streetIndex) => {
635
722
  if (street.points.length < 2)
636
- continue;
637
- const rounded = roundPathCorners(street.points, Math.max(2.5, street.width * 1.15), 9);
638
- const surface = street.surface === "gravel" ? gravel : asphalt;
639
- for (const sub of trimPathAtJunctions(rounded, street.width, intersectionInputs, JUNCTION_OPTS)) {
640
- surface.addRibbon(sub.path, street.width, deckSampler, ROAD_LAYER);
641
- for (const cut of sub.cuts) {
642
- const approach = {
643
- center: cut.center,
644
- left: [cut.left[0], deckSampler(cut.left[0], cut.left[1]) + ROAD_LAYER, cut.left[1]],
645
- right: [cut.right[0], deckSampler(cut.right[0], cut.right[1]) + ROAD_LAYER, cut.right[1]],
646
- };
647
- const list = approachesByCross.get(cut.junctionIndex);
648
- if (list)
649
- list.push(approach);
650
- else
651
- approachesByCross.set(cut.junctionIndex, [approach]);
652
- }
653
- }
654
- if (street.sidewalk && !useBlockSidewalks)
723
+ return;
724
+ const rounded = roundedPaths[streetIndex];
725
+ const wear = (hashString(`surface:${streetIndex}`) % 1024) / 1024;
726
+ wearColor.setRGB(0.86 + wear * 0.3, 0.87 + wear * 0.28, 0.9 + wear * 0.24);
727
+ if (street.sidewalk && !useBlockSidewalks && sidewalkW <= 0) {
655
728
  sidewalks.addRibbon(rounded, street.width + 3.6, sample, SIDEWALK_LAYER);
656
- // Curbs + painted edge/lane lines make a flat ribbon read as a real carriageway. Both break at
657
- // junctions (the crossing owns its own pavement), so clip the centerline first.
729
+ }
658
730
  if (street.surface === "asphalt") {
659
731
  const half = street.width / 2;
660
732
  for (const run of clipPolylineAtIntersections(rounded, resolved.intersections, half + 0.5)) {
661
733
  if (run.length < 2)
662
734
  continue;
663
- const left = offsetPath(run, half);
664
- const right = offsetPath(run, -half);
665
- curbs.addRibbon(left, 0.55, deckSampler, CURB_LAYER);
666
- curbs.addRibbon(right, 0.55, deckSampler, CURB_LAYER);
667
- if (street.level !== "lane") {
668
- // White edge (fog) lines just inside the curb.
669
- markings.addRibbon(offsetPath(run, half - 0.75), 0.16, deckSampler, MARKING_LAYER);
670
- markings.addRibbon(offsetPath(run, -(half - 0.75)), 0.16, deckSampler, MARKING_LAYER);
671
- // Avenue+ get dashed lane dividers either side of the centerline.
735
+ if (!useBlockSidewalks) {
736
+ curbs.addRibbon(offsetPath(run, half), 0.55, deckSampler, CURB_LAYER);
737
+ curbs.addRibbon(offsetPath(run, -half), 0.55, deckSampler, CURB_LAYER);
738
+ }
739
+ if (street.level === "boulevard" || street.level === "avenue") {
740
+ markColor.copy(LANE_TINT);
741
+ markings.addRibbon(offsetPath(run, half - 0.8), 0.12, deckSampler, MARKING_LAYER, markColor);
742
+ markings.addRibbon(offsetPath(run, -(half - 0.8)), 0.12, deckSampler, MARKING_LAYER, markColor);
672
743
  if (street.level === "avenue") {
673
744
  for (const dash of dashSegments(offsetPath(run, half * 0.5), 2.4, 3.2))
674
- markings.addRibbon(dash, 0.13, deckSampler, MARKING_LAYER);
745
+ markings.addRibbon(dash, 0.12, deckSampler, MARKING_LAYER, markColor);
675
746
  for (const dash of dashSegments(offsetPath(run, -half * 0.5), 2.4, 3.2))
676
- markings.addRibbon(dash, 0.13, deckSampler, MARKING_LAYER);
747
+ markings.addRibbon(dash, 0.12, deckSampler, MARKING_LAYER, markColor);
677
748
  }
678
749
  }
679
750
  }
680
751
  }
681
752
  if (street.bulb !== undefined) {
682
- (street.surface === "gravel" ? gravel : asphalt).addDisc(street.bulb, street.width * 1.15, sample, ROAD_LAYER);
753
+ if (street.surface === "gravel")
754
+ gravel.addDisc(street.bulb, street.width * 1.15, sample, ROAD_LAYER);
755
+ else
756
+ asphalt.addDisc(street.bulb, street.width * 1.15, sample, ROAD_LAYER, wearColor);
683
757
  if (street.sidewalk)
684
758
  sidewalks.addDisc(street.bulb, street.width * 1.15 + 1.8, sample, SIDEWALK_LAYER);
685
759
  }
686
- if (street.surface === "asphalt" && street.level !== "lane") {
687
- if (street.level === "boulevard") {
688
- // Median strip instead of a center line, broken WELL clear of every junction — a median
689
- // stub poking into a crossing reads as debris. Runs shorter than a car are dropped and
690
- // ends get a rounded cap so each island reads designed.
691
- for (const run of clipPolylineAtIntersections(rounded, resolved.intersections, 2.6)) {
692
- let runLength = 0;
693
- for (let i = 0; i + 1 < run.length; i += 1)
694
- runLength += Math.hypot(run[i + 1][0] - run[i][0], run[i + 1][1] - run[i][1]);
695
- if (runLength < 7)
696
- continue;
697
- const width = Math.max(1.3, street.width * 0.14);
698
- medians.addRibbon(run, width, deckSampler, MEDIAN_LAYER);
699
- medians.addDisc(run[0], width / 2, deckSampler, MEDIAN_LAYER, null, 8);
700
- medians.addDisc(run[run.length - 1], width / 2, deckSampler, MEDIAN_LAYER, null, 8);
701
- }
702
- }
703
- else {
704
- for (const dash of dashSegments(rounded, 2.6, 3.6)) {
705
- const [a, b] = [dash[0], dash[dash.length - 1]];
706
- const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
707
- let inCross = false;
708
- for (const cross of resolved.intersections) {
709
- if (Math.hypot(cross.x - mid[0], cross.z - mid[1]) < cross.radius + 0.8) {
710
- inCross = true;
711
- break;
712
- }
713
- }
714
- if (!inCross)
715
- markings.addRibbon(dash, 0.18, deckSampler, MARKING_LAYER);
716
- }
760
+ if (street.surface === "asphalt" && street.level === "boulevard") {
761
+ for (const run of clipPolylineAtIntersections(rounded, resolved.intersections, 2.6)) {
762
+ let runLength = 0;
763
+ for (let i = 0; i + 1 < run.length; i += 1)
764
+ runLength += Math.hypot(run[i + 1][0] - run[i][0], run[i + 1][1] - run[i][1]);
765
+ if (runLength < 7)
766
+ continue;
767
+ const width = Math.max(1.3, street.width * 0.14);
768
+ medians.addRibbon(run, width, deckSampler, MEDIAN_LAYER);
769
+ medians.addDisc(run[0], width / 2, deckSampler, MEDIAN_LAYER, null, 8);
770
+ medians.addDisc(run[run.length - 1], width / 2, deckSampler, MEDIAN_LAYER, null, 8);
717
771
  }
718
772
  }
719
- }
773
+ });
720
774
  // NOTE: streetGenerator's `Street.sidewalks` offset polylines are NOT surfaced here — this renderer
721
775
  // consumes `resolveCityObject`'s `CityStreet` (which carries only a `sidewalk` boolean), and its
722
776
  // sidewalks come from richer block curb→land bands below. Wiring the raw offset polylines would be a
@@ -724,57 +778,133 @@ function OneCity({ object, context }) {
724
778
  // Sidewalk band per block: the region between the curb ring (outer) and the land ring (inner).
725
779
  // Blocks whose two rings coincide (no sidewalk) drop out — every band triangle is degenerate,
726
780
  // so `addRingBand` emits nothing.
781
+ const hx = Math.max(1e-6, resolved.size[0] / 2);
782
+ const hz = Math.max(1e-6, resolved.size[1] / 2);
783
+ const cosR = Math.cos(resolved.rotationY);
784
+ const sinR = Math.sin(resolved.rotationY);
785
+ const bandAt = (x, z, roll) => {
786
+ const dx = x - resolved.center[0];
787
+ const dz = z - resolved.center[2];
788
+ return zoneBand(zoneMetric(dx * cosR - dz * sinR, dx * sinR + dz * cosR, hx, hz), resolved.rules.profile, resolved.rules.coreExtent, resolved.rules.midExtent, roll);
789
+ };
790
+ const lawnEdge = !resolved.rules.sidewalks || resolved.rules.blockSize > 64;
791
+ // Developed ground. Where the street graph yields real block faces the land ring behind the
792
+ // sidewalk is filled exactly; but block extraction routinely recovers only a fraction of a
793
+ // wandered district's faces, and every face it misses is terrain grass under downtown. So the
794
+ // fabric is also stamped as a street-distance field: any cell within reach of a centerline is
795
+ // developed, shaded pavement near the kerb and yard/service surface deeper into the block.
796
+ if (resolved.rules.fabric) {
797
+ // Reach and paved band are measured OUTWARD FROM THE KERB, not from the centreline: a
798
+ // 20 m boulevard and a 5 m lane must not lay the same width of pavement beside them.
799
+ const reach = Math.min(40, Math.max(10, resolved.rules.blockSize * 0.55));
800
+ const pavedTo = Math.max(1.2, resolved.rules.sidewalkWidth + 0.3);
801
+ const cell = 2.5;
802
+ let minX = Infinity;
803
+ let minZ = Infinity;
804
+ let maxX = -Infinity;
805
+ let maxZ = -Infinity;
806
+ let widest = 0;
807
+ for (const street of resolved.streets) {
808
+ widest = Math.max(widest, street.width);
809
+ for (const [x, z] of street.points) {
810
+ minX = Math.min(minX, x);
811
+ maxX = Math.max(maxX, x);
812
+ minZ = Math.min(minZ, z);
813
+ maxZ = Math.max(maxZ, z);
814
+ }
815
+ }
816
+ const pad = reach + widest / 2;
817
+ const originX = minX - pad;
818
+ const originZ = minZ - pad;
819
+ const nx = Math.ceil((maxX + pad - originX) / cell) + 1;
820
+ const nz = Math.ceil((maxZ + pad - originZ) / cell) + 1;
821
+ if (Number.isFinite(minX) && nx > 1 && nz > 1 && nx * nz <= 160_000) {
822
+ const field = new Float32Array(nx * nz).fill(Infinity);
823
+ for (const street of resolved.streets) {
824
+ const half = street.width / 2;
825
+ for (let s = 0; s + 1 < street.points.length; s += 1) {
826
+ const [ax, az] = street.points[s];
827
+ const [bx, bz] = street.points[s + 1];
828
+ const vx = bx - ax;
829
+ const vz = bz - az;
830
+ const len2 = Math.max(1e-6, vx * vx + vz * vz);
831
+ const span = reach + half;
832
+ const i0 = Math.max(0, Math.floor((Math.min(ax, bx) - span - originX) / cell));
833
+ const i1 = Math.min(nx - 1, Math.ceil((Math.max(ax, bx) + span - originX) / cell));
834
+ const j0 = Math.max(0, Math.floor((Math.min(az, bz) - span - originZ) / cell));
835
+ const j1 = Math.min(nz - 1, Math.ceil((Math.max(az, bz) + span - originZ) / cell));
836
+ for (let j = j0; j <= j1; j += 1) {
837
+ const pz = originZ + j * cell;
838
+ for (let i = i0; i <= i1; i += 1) {
839
+ const px = originX + i * cell;
840
+ const t = Math.max(0, Math.min(1, ((px - ax) * vx + (pz - az) * vz) / len2));
841
+ const d = Math.hypot(px - (ax + vx * t), pz - (az + vz * t)) - half;
842
+ const idx = j * nx + i;
843
+ if (d < field[idx])
844
+ field[idx] = d;
845
+ }
846
+ }
847
+ }
848
+ }
849
+ const fringe = new THREE.Color(FRINGE_COLOR);
850
+ const fabricColor = new THREE.Color();
851
+ for (let j = 0; j + 1 < nz; j += 1) {
852
+ for (let i = 0; i + 1 < nx; i += 1) {
853
+ const d = (field[j * nx + i] + field[j * nx + i + 1] + field[(j + 1) * nx + i] + field[(j + 1) * nx + i + 1]) / 4;
854
+ if (d > reach)
855
+ 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);
873
+ }
874
+ }
875
+ }
876
+ }
877
+ // The land ring behind the sidewalk is the block INTERIOR — fill it with a surface read from
878
+ // the block's use and its zone band, varied per block so a district reads as many yards and
879
+ // service courts rather than one sheet.
727
880
  if (useBlockSidewalks) {
881
+ const landColor = new THREE.Color();
728
882
  for (const block of resolved.blocks) {
883
+ let cx = 0;
884
+ let cz = 0;
885
+ for (const [x, z] of block.polygon) {
886
+ cx += x;
887
+ cz += z;
888
+ }
889
+ cx /= Math.max(1, block.polygon.length);
890
+ cz /= Math.max(1, block.polygon.length);
891
+ const tone = (hashString(block.id) % 512) / 512;
892
+ const band = bandAt(cx, cz, tone);
893
+ landColor.set(block.kind !== "buildable"
894
+ ? BLOCK_OPEN_COLORS[block.kind]
895
+ : band === "edge" && lawnEdge
896
+ ? BLOCK_EDGE_GREEN
897
+ : BLOCK_SURFACE_COLORS[band]);
898
+ landColor.multiplyScalar(0.88 + tone * 0.24);
899
+ landColor.r *= 1 + (tone - 0.5) * 0.1;
900
+ landColor.b *= 1 + (0.5 - tone) * 0.12;
901
+ land.addPolygon(block.polygon, sample, BLOCK_LAND_LAYER, landColor);
729
902
  sidewalks.addRingBand(block.curb, block.polygon, sample, SIDEWALK_LAYER);
903
+ curbs.addRingWall(block.curb, sample, ROAD_LAYER - 0.02, CURB_LAYER);
730
904
  }
731
905
  }
732
- // Junctions: corner curb-return apron (sidewalk ring peeking out under the asphalt patch), the
733
- // patch itself, and a zebra crossing on every REAL arm — but only where the crossing warrants
734
- // it. Avenue-and-up arms (or a boulevard/avenue junction) get zebra stripes and the apron;
735
- // small residential crossings get just the asphalt patch.
736
- resolved.intersections.forEach((cross, crossIndex) => {
737
- const bigLevel = cross.level === "boulevard" || cross.level === "avenue";
738
- const armZebra = (width) => bigLevel || width >= resolved.rules.streetWidth * 1.4;
739
- const paved = resolved.rules.sidewalks && cross.level !== "lane";
740
- // A slightly larger apron underneath the crossing reads as the wrapped sidewalk/curb.
741
- if (paved) {
742
- const apron = junctionOutline(cross, 1.15, resolved.rules.sidewalkWidth + 0.4);
743
- if (apron !== null)
744
- sidewalks.addPolygon(apron, sample, SIDEWALK_LAYER);
745
- }
746
- // The junction pavement is welded onto the trimmed ribbon ends (seam-shared, no floating disc).
747
- // Crossings that got no trimmed approach fall back to the rounded outline / disc, so coverage
748
- // never regresses.
749
- const approaches = approachesByCross.get(crossIndex);
750
- if (approaches !== undefined && approaches.length > 0) {
751
- patches.addMesh(buildJunctionSurface({ x: cross.x, z: cross.z }, approaches, deckSampler, JUNCTION_OPTS));
752
- }
753
- else {
754
- const outline = junctionOutline(cross);
755
- if (outline !== null)
756
- patches.addPolygon(outline, deckSampler, ROAD_LAYER);
757
- else
758
- patches.addDisc([cross.x, cross.z], cross.radius, deckSampler, ROAD_LAYER);
759
- }
760
- if (paved) {
761
- const maxHalf = Math.max(...cross.arms.map((arm) => arm.width / 2));
762
- for (const arm of cross.arms) {
763
- if (!armZebra(arm.width))
764
- continue;
765
- const dir = [Math.sin(arm.angle), Math.cos(arm.angle)];
766
- const perp = [-dir[1], dir[0]];
767
- // Stop line across the approach, just outside the crossing box.
768
- const stopDist = maxHalf * 1.15 + 0.55;
769
- addMarkQuad([cross.x + dir[0] * stopDist, cross.z + dir[1] * stopDist], perp, arm.width * 0.82, 0.5);
770
- for (let stripe = 0; stripe < 4; stripe += 1) {
771
- const dist = maxHalf * 1.15 + 1.5 + stripe * 0.85;
772
- const center = [cross.x + dir[0] * dist, cross.z + dir[1] * dist];
773
- addMarkQuad(center, perp, arm.width * 0.8, 0.44);
774
- }
775
- }
776
- }
777
- });
906
+ // Junction pavement + curb-return sidewalk aprons already came from `buildTrimmedIntersections`
907
+ // above. No plaza discs, hull aprons, or fat zebra ladders on top of that union.
778
908
  for (const drive of resolved.driveways) {
779
909
  (drive.surface === "gravel" ? gravelDrives : pavementDrives).addRibbon(drive.points, drive.width, sample, DRIVEWAY_LAYER);
780
910
  }
@@ -788,7 +918,7 @@ function OneCity({ object, context }) {
788
918
  const lx = (i / stalls - 0.5) * pad.size[0];
789
919
  const cx = pad.center[0] + lx * cos + (pad.size[1] * 0.24) * sin;
790
920
  const cz = pad.center[1] - lx * sin + (pad.size[1] * 0.24) * cos;
791
- addMarkQuad([cx, cz], [sin, cos], pad.size[1] * 0.42, 0.12);
921
+ addMarkQuad([cx, cz], [sin, cos], pad.size[1] * 0.42, 0.1, STALL_TINT);
792
922
  }
793
923
  }
794
924
  for (const park of resolved.parks) {
@@ -811,6 +941,8 @@ function OneCity({ object, context }) {
811
941
  asphalt: asphalt.build(),
812
942
  gravel: gravel.build(),
813
943
  sidewalks: sidewalks.build(),
944
+ urban: urban.build(),
945
+ land: land.build(),
814
946
  patches: patches.build(),
815
947
  markings: markings.build(),
816
948
  curbs: curbs.build(),
@@ -946,7 +1078,7 @@ function OneCity({ object, context }) {
946
1078
  return { roof: roofs.build(), stone };
947
1079
  }, [resolved, sample]);
948
1080
  useEffect(() => () => tunnels?.roof?.dispose(), [tunnels]);
949
- // --- Instanced furniture: trees per species, street lights, hedges. ---
1081
+ // --- Instanced furniture: trees per species, street lights, hedges, parked cars, junction signals. ---
950
1082
  const furniture = useMemo(() => {
951
1083
  if (resolved === null)
952
1084
  return null;
@@ -967,10 +1099,14 @@ function OneCity({ object, context }) {
967
1099
  const mesh = new THREE.InstancedMesh(geometry, material, trees.length);
968
1100
  const matrix = new THREE.Matrix4();
969
1101
  trees.forEach((tree, i) => {
970
- const scale = tree.scale * SPECIES_SCALE[species];
971
- matrix.compose(new THREE.Vector3(tree.x, sample(tree.x, tree.z) - 0.05, tree.z), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), tree.jitter * Math.PI * 2), new THREE.Vector3(scale, scale * (0.92 + tree.jitter * 0.16), scale));
1102
+ // A second stream off the planting position: `tree.jitter` alone left every canopy the same
1103
+ // ball on a stick, so age (girth vs height) and leaf tone vary independently of it.
1104
+ const age = (hashString(`tree:${tree.x.toFixed(2)}:${tree.z.toFixed(2)}`) % 1024) / 1024;
1105
+ const scale = tree.scale * SPECIES_SCALE[species] * (0.66 + age * 0.78);
1106
+ const spread = 0.86 + tree.jitter * 0.34;
1107
+ matrix.compose(new THREE.Vector3(tree.x, sample(tree.x, tree.z) - 0.05, tree.z), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), tree.jitter * Math.PI * 2), new THREE.Vector3(scale * spread, scale * (0.8 + age * 0.55), scale * spread));
972
1108
  mesh.setMatrixAt(i, matrix);
973
- color.setScalar(0.82 + tree.jitter * 0.36);
1109
+ color.setRGB(0.74 + age * 0.42, 0.84 + tree.jitter * 0.3, 0.68 + ((age * 5) % 1) * 0.4);
974
1110
  mesh.setColorAt(i, color);
975
1111
  });
976
1112
  mesh.instanceMatrix.needsUpdate = true;
@@ -1036,6 +1172,108 @@ function OneCity({ object, context }) {
1036
1172
  meshes.push(hedges);
1037
1173
  disposables.push(hedgeMaterial);
1038
1174
  }
1175
+ if (resolved.vehicles.length > 0) {
1176
+ const units = unitGeometries();
1177
+ const paintMaterial = new THREE.MeshStandardMaterial({ roughness: 0.42, metalness: 0.34 });
1178
+ const glassMaterial = new THREE.MeshPhysicalMaterial({ color: VEHICLE_GLASS_COLOR, roughness: 0.16, metalness: 0.2 });
1179
+ const bodies = new THREE.InstancedMesh(units.box, paintMaterial, resolved.vehicles.length);
1180
+ const cabins = new THREE.InstancedMesh(units.box, glassMaterial, resolved.vehicles.length);
1181
+ const roofs = new THREE.InstancedMesh(units.box, paintMaterial, resolved.vehicles.length);
1182
+ const matrix = new THREE.Matrix4();
1183
+ const quat = new THREE.Quaternion();
1184
+ const upAxis = new THREE.Vector3(0, 1, 0);
1185
+ const paint = new THREE.Color();
1186
+ resolved.vehicles.forEach((vehicle, i) => {
1187
+ const shape = VEHICLE_SHAPES[vehicle.kind];
1188
+ const base = sample(vehicle.x, vehicle.z) + (vehicle.stall === "lot" ? PARKING_LAYER : ROAD_LAYER);
1189
+ const along = [Math.sin(vehicle.rotationY), Math.cos(vehicle.rotationY)];
1190
+ quat.setFromAxisAngle(upAxis, vehicle.rotationY);
1191
+ const place = (mesh, size, y, offset) => {
1192
+ matrix.compose(new THREE.Vector3(vehicle.x + along[0] * offset, base + y, vehicle.z + along[1] * offset), quat, new THREE.Vector3(size[0], size[1], size[2]));
1193
+ mesh.setMatrixAt(i, matrix);
1194
+ };
1195
+ place(bodies, shape.body, shape.bodyY, 0);
1196
+ place(cabins, shape.cabin, shape.cabinY, shape.cabinAlong);
1197
+ place(roofs, shape.roof, shape.roofY, shape.roofAlong);
1198
+ paint.set(VEHICLE_PAINTS[vehicle.paint % VEHICLE_PAINTS.length]);
1199
+ paint.multiplyScalar(0.88 + ((hashString(`car:${vehicle.x.toFixed(2)}:${vehicle.z.toFixed(2)}`) % 256) / 256) * 0.26);
1200
+ bodies.setColorAt(i, paint);
1201
+ roofs.setColorAt(i, paint);
1202
+ });
1203
+ for (const mesh of [bodies, cabins, roofs]) {
1204
+ mesh.instanceMatrix.needsUpdate = true;
1205
+ if (mesh.instanceColor !== null)
1206
+ mesh.instanceColor.needsUpdate = true;
1207
+ mesh.castShadow = true;
1208
+ mesh.receiveShadow = true;
1209
+ mesh.frustumCulled = false;
1210
+ meshes.push(mesh);
1211
+ }
1212
+ disposables.push(paintMaterial, glassMaterial);
1213
+ }
1214
+ if (resolved.signals.length > 0) {
1215
+ const units = unitGeometries();
1216
+ const mastCount = resolved.signals.filter((signal) => signal.kind === "signal").length;
1217
+ const postMaterial = new THREE.MeshStandardMaterial({ color: SIGNAL_POST_COLOR, roughness: 0.55, metalness: 0.55 });
1218
+ const headMaterial = new THREE.MeshStandardMaterial({ roughness: 0.62, metalness: 0.15 });
1219
+ const lampMaterial = new THREE.MeshBasicMaterial({ toneMapped: false });
1220
+ const posts = new THREE.InstancedMesh(units.cylinder, postMaterial, resolved.signals.length);
1221
+ const heads = new THREE.InstancedMesh(units.box, headMaterial, resolved.signals.length);
1222
+ const arms = mastCount > 0 ? new THREE.InstancedMesh(units.box, postMaterial, mastCount) : null;
1223
+ const lamps = mastCount > 0 ? new THREE.InstancedMesh(units.box, lampMaterial, mastCount * 3) : null;
1224
+ const matrix = new THREE.Matrix4();
1225
+ const quat = new THREE.Quaternion();
1226
+ const armQuat = new THREE.Quaternion();
1227
+ const upAxis = new THREE.Vector3(0, 1, 0);
1228
+ const tint = new THREE.Color();
1229
+ let mast = 0;
1230
+ resolved.signals.forEach((signal, i) => {
1231
+ const y = sample(signal.x, signal.z);
1232
+ const facing = [Math.sin(signal.heading), Math.cos(signal.heading)];
1233
+ // The mast arm reaches to the right of the facing direction — out over the lanes it controls.
1234
+ const overRoad = [facing[1], -facing[0]];
1235
+ quat.setFromAxisAngle(upAxis, signal.heading);
1236
+ const isSignal = signal.kind === "signal";
1237
+ const postHeight = isSignal ? 5.3 : 2.5;
1238
+ const thickness = isSignal ? 0.2 : 0.13;
1239
+ matrix.compose(new THREE.Vector3(signal.x, y, signal.z), quat, new THREE.Vector3(thickness, postHeight, thickness));
1240
+ posts.setMatrixAt(i, matrix);
1241
+ if (!isSignal) {
1242
+ matrix.compose(new THREE.Vector3(signal.x, y + postHeight - 0.2, signal.z), quat, new THREE.Vector3(0.72, 0.72, 0.07));
1243
+ heads.setMatrixAt(i, matrix);
1244
+ tint.set(SIGN_FACE_COLOR);
1245
+ heads.setColorAt(i, tint);
1246
+ return;
1247
+ }
1248
+ const hx = signal.x + overRoad[0] * signal.reach;
1249
+ const hz = signal.z + overRoad[1] * signal.reach;
1250
+ armQuat.setFromAxisAngle(upAxis, signal.heading + Math.PI / 2);
1251
+ matrix.compose(new THREE.Vector3(signal.x + overRoad[0] * signal.reach * 0.5, y + postHeight - 0.15, signal.z + overRoad[1] * signal.reach * 0.5), armQuat, new THREE.Vector3(0.14, 0.14, signal.reach));
1252
+ arms.setMatrixAt(mast, matrix);
1253
+ matrix.compose(new THREE.Vector3(hx, y + postHeight - 0.72, hz), quat, new THREE.Vector3(0.4, 1.14, 0.3));
1254
+ heads.setMatrixAt(i, matrix);
1255
+ tint.set(SIGNAL_HOUSING_COLOR);
1256
+ heads.setColorAt(i, tint);
1257
+ for (let lamp = 0; lamp < 3; lamp += 1) {
1258
+ matrix.compose(new THREE.Vector3(hx + facing[0] * 0.17, y + postHeight - 0.36 - lamp * 0.36, hz + facing[1] * 0.17), quat, new THREE.Vector3(0.22, 0.22, 0.08));
1259
+ lamps.setMatrixAt(mast * 3 + lamp, matrix);
1260
+ tint.set(SIGNAL_LAMP_COLORS[lamp]);
1261
+ lamps.setColorAt(mast * 3 + lamp, tint);
1262
+ }
1263
+ mast += 1;
1264
+ });
1265
+ for (const mesh of [posts, heads, arms, lamps]) {
1266
+ if (mesh === null)
1267
+ continue;
1268
+ mesh.instanceMatrix.needsUpdate = true;
1269
+ if (mesh.instanceColor !== null)
1270
+ mesh.instanceColor.needsUpdate = true;
1271
+ mesh.castShadow = mesh !== lamps;
1272
+ mesh.frustumCulled = false;
1273
+ meshes.push(mesh);
1274
+ }
1275
+ disposables.push(postMaterial, headMaterial, lampMaterial);
1276
+ }
1039
1277
  return { meshes, disposables };
1040
1278
  }, [resolved, sample]);
1041
1279
  useEffect(() => () => {
@@ -1044,9 +1282,196 @@ function OneCity({ object, context }) {
1044
1282
  for (const disposable of furniture.disposables)
1045
1283
  disposable.dispose();
1046
1284
  }, [furniture]);
1285
+ // Race dressing: resurface the lap, stripe its kerbs, paint start/finish + grid, and stand a
1286
+ // barrier across every sealed side street — the visual proof the race is a section of this city.
1287
+ const race = useMemo(() => {
1288
+ if (resolved?.race === undefined)
1289
+ return null;
1290
+ const route = resolved.race;
1291
+ const points = route.centerline;
1292
+ const n = points.length;
1293
+ if (n < 8)
1294
+ return null;
1295
+ const decals = new MeshAccumulator(true);
1296
+ const barriers = new MeshAccumulator(true);
1297
+ const normals = points.map((_, i) => {
1298
+ const before = points[(i - 1 + n) % n];
1299
+ const after = points[(i + 1) % n];
1300
+ const dx = after[0] - before[0];
1301
+ const dz = after[1] - before[1];
1302
+ const len = Math.hypot(dx, dz) || 1;
1303
+ return [dz / len, -dx / len];
1304
+ });
1305
+ const half = points.map((_, i) => Math.max(1.5, (route.widths[i] ?? 9) * 0.5 - 0.3));
1306
+ const surfaceY0 = points.map((p, i) => route.heights?.[i] ?? sample(p[0], p[1]));
1307
+ const rim = (i, off, lift) => [
1308
+ points[i][0] + normals[i][0] * off,
1309
+ surfaceY0[i] + lift,
1310
+ points[i][1] + normals[i][1] * off,
1311
+ ];
1312
+ for (let i = 0; i < n; i += 1) {
1313
+ const j = (i + 1) % n;
1314
+ const midX = (points[i][0] + points[j][0]) * 0.5;
1315
+ const midZ = (points[i][1] + points[j][1]) * 0.5;
1316
+ // Leave welded junction pavement visible — a continuous race ribbon over the mouth undoes the seam.
1317
+ let overJunction = false;
1318
+ for (const cross of resolved.intersections) {
1319
+ if (Math.hypot(cross.x - midX, cross.z - midZ) < cross.radius + 0.6) {
1320
+ overJunction = true;
1321
+ break;
1322
+ }
1323
+ }
1324
+ if (overJunction)
1325
+ continue;
1326
+ decals.addQuad([rim(i, -half[i], RACE_SURFACE_LAYER), rim(j, -half[j], RACE_SURFACE_LAYER), rim(j, half[j], RACE_SURFACE_LAYER), rim(i, half[i], RACE_SURFACE_LAYER)], RACE_ASPHALT);
1327
+ for (const side of [-1, 1]) {
1328
+ decals.addQuad([
1329
+ rim(i, side * half[i], RACE_PAINT_LAYER),
1330
+ rim(j, side * half[j], RACE_PAINT_LAYER),
1331
+ rim(j, side * (half[j] - 0.34), RACE_PAINT_LAYER),
1332
+ rim(i, side * (half[i] - 0.34), RACE_PAINT_LAYER),
1333
+ ], RACE_EDGE);
1334
+ }
1335
+ }
1336
+ for (const kerb of route.kerbs) {
1337
+ const run = kerb.points;
1338
+ if (run.length < 2)
1339
+ continue;
1340
+ const outward = run.map((_, i) => {
1341
+ const before = run[Math.max(0, i - 1)];
1342
+ const after = run[Math.min(run.length - 1, i + 1)];
1343
+ const dx = after[0] - before[0];
1344
+ const dz = after[1] - before[1];
1345
+ const len = Math.hypot(dx, dz) || 1;
1346
+ return [(dz / len) * kerb.side, (-dx / len) * kerb.side];
1347
+ });
1348
+ let travelled = 0;
1349
+ for (let i = 0; i + 1 < run.length; i += 1) {
1350
+ const a = run[i];
1351
+ const b = run[i + 1];
1352
+ const seg = Math.hypot(b[0] - a[0], b[1] - a[1]);
1353
+ if (seg < 1e-6)
1354
+ continue;
1355
+ const midX = (a[0] + b[0]) * 0.5;
1356
+ const midZ = (a[1] + b[1]) * 0.5;
1357
+ let overJunction = false;
1358
+ for (const cross of resolved.intersections) {
1359
+ if (Math.hypot(cross.x - midX, cross.z - midZ) < cross.radius + 0.4) {
1360
+ overJunction = true;
1361
+ break;
1362
+ }
1363
+ }
1364
+ if (overJunction) {
1365
+ travelled += seg;
1366
+ continue;
1367
+ }
1368
+ const oa = outward[i];
1369
+ const ob = outward[i + 1];
1370
+ let t = 0;
1371
+ while (t < 1 - 1e-6) {
1372
+ const here = travelled + t * seg;
1373
+ const band = Math.floor(here / kerb.stripe + 1e-6);
1374
+ const next = Math.min(1, Math.max(t + 1e-3, t + ((band + 1) * kerb.stripe - here) / seg));
1375
+ const along = (u) => [a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u];
1376
+ const away = (u) => [oa[0] + (ob[0] - oa[0]) * u, oa[1] + (ob[1] - oa[1]) * u];
1377
+ const [x0, z0] = along(t);
1378
+ const [x1, z1] = along(next);
1379
+ const [ox0, oz0] = away(t);
1380
+ const [ox1, oz1] = away(next);
1381
+ const drape = (x, z) => [x, sample(x, z) + RACE_PAINT_LAYER, z];
1382
+ decals.addQuad([drape(x0, z0), drape(x1, z1), drape(x1 + ox1 * kerb.width, z1 + oz1 * kerb.width), drape(x0 + ox0 * kerb.width, z0 + oz0 * kerb.width)], band % 2 === 0 ? KERB_RED : KERB_WHITE);
1383
+ t = next;
1384
+ }
1385
+ travelled += seg;
1386
+ }
1387
+ }
1388
+ const [lineA, lineB] = route.start.line;
1389
+ const acrossX = lineB[0] - lineA[0];
1390
+ const acrossZ = lineB[1] - lineA[1];
1391
+ const travelX = Math.sin(route.start.heading);
1392
+ const travelZ = Math.cos(route.start.heading);
1393
+ for (let r = 0; r < 2; r += 1) {
1394
+ for (let c = 0; c < 10; c += 1) {
1395
+ const cell = (u, v) => {
1396
+ const x = lineA[0] + acrossX * u + travelX * v;
1397
+ const z = lineA[1] + acrossZ * u + travelZ * v;
1398
+ return [x, sample(x, z) + RACE_PAINT_LAYER, z];
1399
+ };
1400
+ const v0 = -2.5 + (r / 2) * 5;
1401
+ const v1 = -2.5 + ((r + 1) / 2) * 5;
1402
+ decals.addQuad([cell(c / 10, v0), cell((c + 1) / 10, v0), cell((c + 1) / 10, v1), cell(c / 10, v1)], (c + r) % 2 === 0 ? CHECK_DARK : CHECK_LIGHT);
1403
+ }
1404
+ }
1405
+ for (const slot of route.grid) {
1406
+ const fx = Math.sin(slot.heading);
1407
+ const fz = Math.cos(slot.heading);
1408
+ const ax = Math.cos(slot.heading);
1409
+ const az = -Math.sin(slot.heading);
1410
+ const corner = (u, v) => {
1411
+ const x = slot.x + ax * u + fx * v;
1412
+ const z = slot.z + az * u + fz * v;
1413
+ return [x, sample(x, z) + RACE_PAINT_LAYER, z];
1414
+ };
1415
+ for (const [u0, u1, v0, v1] of [
1416
+ [-1.3, -1.12, -2.6, 2.6],
1417
+ [1.12, 1.3, -2.6, 2.6],
1418
+ [-1.3, 1.3, 2.42, 2.6],
1419
+ ]) {
1420
+ decals.addQuad([corner(u0, v0), corner(u1, v0), corner(u1, v1), corner(u0, v1)], RACE_EDGE);
1421
+ }
1422
+ }
1423
+ for (const seal of route.seals) {
1424
+ const fx = Math.sin(seal.heading);
1425
+ const fz = Math.cos(seal.heading);
1426
+ const ax = Math.cos(seal.heading);
1427
+ const az = -Math.sin(seal.heading);
1428
+ const span = seal.width + 3;
1429
+ const blocks = Math.max(3, Math.round(span / 1.4));
1430
+ const halfRun = span / blocks / 2;
1431
+ for (let s = 0; s < blocks; s += 1) {
1432
+ const u = -span / 2 + ((s + 0.5) / blocks) * span;
1433
+ const cx = seal.x + ax * u;
1434
+ const cz = seal.z + az * u;
1435
+ barriers.addSealBlock(cx, cz, sample(cx, cz), 1.05, halfRun, 0.28, [ax, az], [fx, fz], s % 2 === 0 ? KERB_RED : KERB_WHITE);
1436
+ }
1437
+ }
1438
+ const geometries = [];
1439
+ const meshes = [];
1440
+ const surface = decals.build();
1441
+ if (surface !== null) {
1442
+ geometries.push(surface);
1443
+ const mesh = new THREE.Mesh(surface, new THREE.MeshBasicMaterial({
1444
+ vertexColors: true,
1445
+ side: THREE.DoubleSide,
1446
+ polygonOffset: true,
1447
+ polygonOffsetFactor: -4,
1448
+ polygonOffsetUnits: -4,
1449
+ }));
1450
+ mesh.renderOrder = 4;
1451
+ mesh.frustumCulled = false;
1452
+ meshes.push(mesh);
1453
+ }
1454
+ const sealGeom = barriers.build();
1455
+ if (sealGeom !== null) {
1456
+ geometries.push(sealGeom);
1457
+ const mesh = new THREE.Mesh(sealGeom, new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.72, metalness: 0.04, side: THREE.DoubleSide }));
1458
+ mesh.castShadow = true;
1459
+ mesh.frustumCulled = false;
1460
+ meshes.push(mesh);
1461
+ }
1462
+ return { meshes, geometries, materials: meshes.map((mesh) => mesh.material) };
1463
+ }, [resolved, sample]);
1464
+ useEffect(() => () => {
1465
+ if (race === null)
1466
+ return;
1467
+ for (const geometry of race.geometries)
1468
+ geometry.dispose();
1469
+ for (const material of race.materials)
1470
+ material.dispose();
1471
+ }, [race]);
1047
1472
  if (resolved === null)
1048
1473
  return null;
1049
- return (_jsxs("group", { children: [ground?.asphalt != null ? (_jsx("mesh", { geometry: ground.asphalt, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: ASPHALT_COLOR, 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, 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, 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, _jsx(DetailBuildings, { buildings: detail.buildings, palette: palette, kitName: resolved?.rules.buildingKit ?? "" })] }));
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 ?? "" })] }));
1050
1475
  }
1051
1476
  /** One instanced unit-box mesh from precomposed matrices (bridge parts). */
1052
1477
  function InstancedBoxes({ matrices, color }) {