@jgengine/shell 0.11.0 → 0.12.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.
- package/CHANGELOG.md +26 -0
- package/dist/CombatPresentation.d.ts +5 -0
- package/dist/CombatPresentation.js +8 -0
- package/dist/GameHost.d.ts +25 -1
- package/dist/GameHost.js +60 -4
- package/dist/GamePlayer.d.ts +5 -0
- package/dist/GamePlayer.js +5 -0
- package/dist/GamePlayerShell.d.ts +5 -0
- package/dist/GamePlayerShell.js +48 -358
- package/dist/GameUiPreview.js +3 -0
- package/dist/Shell3dPresentation.d.ts +63 -0
- package/dist/Shell3dPresentation.js +302 -0
- package/dist/ShellChrome.d.ts +51 -0
- package/dist/ShellChrome.js +51 -0
- package/dist/ShellHudPresentation.d.ts +41 -0
- package/dist/ShellHudPresentation.js +22 -0
- package/dist/audio/audioEngine.d.ts +4 -0
- package/dist/audio/audioEngine.js +111 -26
- package/dist/audio/audioWire.d.ts +22 -4
- package/dist/audio/audioWire.js +46 -3
- package/dist/audio/loopParams.d.ts +20 -0
- package/dist/audio/loopParams.js +28 -0
- package/dist/camera/GameCameraRig.js +6 -1
- package/dist/camera/cameraRigs.d.ts +3 -1
- package/dist/camera/cameraRigs.js +21 -3
- package/dist/camera/rigMath.d.ts +20 -0
- package/dist/camera/rigMath.js +29 -0
- package/dist/defineGame.d.ts +15 -1
- package/dist/defineGame.js +26 -4
- package/dist/devtools/DevtoolsOverlay.d.ts +4 -4
- package/dist/devtools/DevtoolsOverlay.js +28 -12
- package/dist/devtools/agentBridge.js +4 -0
- package/dist/devtools/perfDiagnose.js +1 -1
- package/dist/diagnostics/RuntimeDiagnostics.d.ts +7 -0
- package/dist/diagnostics/RuntimeDiagnostics.js +23 -12
- package/dist/environment/EnvironmentScene.js +12 -3
- package/dist/environment/terrainGroundColor.d.ts +9 -0
- package/dist/environment/terrainGroundColor.js +22 -0
- package/dist/gameKit.d.ts +27 -0
- package/dist/gameKit.js +29 -0
- package/dist/map/MapMarkerBeacons.d.ts +3 -2
- package/dist/postfx/PostProcessing.d.ts +9 -1
- package/dist/postfx/PostProcessing.js +11 -4
- package/dist/presentationResolve.d.ts +35 -0
- package/dist/presentationResolve.js +51 -0
- package/dist/registry.d.ts +1 -0
- package/dist/render/assetBase.d.ts +17 -0
- package/dist/render/assetBase.js +35 -0
- package/dist/render/modelLoad.js +4 -1
- package/dist/render/resolveModel.d.ts +15 -0
- package/dist/render/resolveModel.js +37 -2
- package/dist/scatter/InstancedScatter.js +5 -0
- package/dist/scatter/scatterProxies.js +24 -0
- package/dist/scene/AuthoredScene.d.ts +3 -1
- package/dist/scene/AuthoredScene.js +7 -2
- package/dist/scene/CityRenderer.d.ts +2 -0
- package/dist/scene/CityRenderer.js +1031 -0
- package/dist/scene/GrassFieldRenderer.js +53 -5
- package/dist/scene/PoleLineRenderer.d.ts +2 -0
- package/dist/scene/PoleLineRenderer.js +287 -0
- package/dist/scene/SoilPatchRenderer.js +70 -7
- package/dist/scene/WaterRenderer.js +34 -10
- package/dist/scene/builtinSceneKindRenderers.js +4 -0
- package/dist/scene/cityWindowMaterial.d.ts +16 -0
- package/dist/scene/cityWindowMaterial.js +81 -0
- package/dist/scene/sceneKindRenderers.d.ts +2 -0
- package/dist/settings/appliedSettings.d.ts +2 -1
- package/dist/settings/appliedSettings.js +4 -2
- package/dist/structures/TransformGizmo.d.ts +11 -1
- package/dist/structures/TransformGizmo.js +9 -5
- package/dist/terrain/GrassField.d.ts +11 -3
- package/dist/terrain/GrassField.js +16 -6
- package/dist/terrain/grassGeometry.d.ts +33 -1
- package/dist/terrain/grassGeometry.js +83 -21
- package/dist/terrain/grassMaterial.d.ts +18 -0
- package/dist/terrain/grassMaterial.js +66 -13
- package/dist/terrain/heightfieldGeometry.d.ts +34 -0
- package/dist/terrain/heightfieldGeometry.js +109 -0
- package/dist/terrain/index.d.ts +1 -0
- package/dist/terrain/index.js +1 -0
- package/dist/terrain/soilPatchMaterial.d.ts +15 -2
- package/dist/terrain/soilPatchMaterial.js +46 -8
- package/dist/terrain/terrainDetailMaterial.js +15 -2
- package/dist/water/Ocean.d.ts +7 -1
- package/dist/water/Ocean.js +16 -3
- package/dist/water/OceanConfig.d.ts +9 -0
- package/dist/water/OceanConfig.js +6 -0
- package/dist/water/OceanMaterial.d.ts +12 -0
- package/dist/water/OceanMaterial.js +9 -0
- package/dist/water/OceanShader.d.ts +2 -2
- package/dist/water/OceanShader.js +78 -10
- package/dist/world/PlaceScene.d.ts +11 -0
- package/dist/world/PlaceScene.js +27 -0
- package/dist/world/WorldScene.js +29 -2
- package/dist/worldSky.d.ts +7 -1
- package/dist/worldSky.js +11 -1
- package/package.json +4 -4
- package/skills/game-design/SKILL.md +28 -0
- package/skills/game-design/agents/openai.yaml +4 -0
- package/skills/game-design/references/application-playbook.md +170 -0
- package/skills/game-design/references/field-guide.md +288 -0
- package/skills/jgengine/SKILL.md +7 -4
- package/skills/jgengine/api.md +172 -79
- package/skills/jgengine/capabilities.md +104 -2
- package/skills/jgengine/recipes/minimal-game.md +88 -0
- package/skills/jgengine-ui/SKILL.md +15 -10
- package/skills/jgengine-ui/api.md +202 -47
- package/skills/jgengine-ui/capabilities.md +71 -2
- package/skills/jgengine-ui/recipes/portable-minimap.md +150 -0
- package/skills/jgengine-ui/reference.md +20 -7
- package/skills/jgengine-ui/references/visual-scorecard.md +1 -1
- package/skills/jgengine-world/SKILL.md +13 -0
- package/skills/jgengine-world/api.md +419 -15
- package/skills/jgengine-world/capabilities.md +108 -2
- package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
- package/skills/jgengine-world/reference.md +43 -4
- package/skills/level-design/SKILL.md +54 -0
- package/skills/level-design/agents/openai.yaml +4 -0
- package/skills/level-design/references/application-playbook.md +192 -0
- package/skills/level-design/references/field-guide.md +337 -0
- package/skills/level-design/references/live-editor-loop.md +179 -0
- package/dist/cartridge/runtime.d.ts +0 -15
- package/dist/cartridge/runtime.js +0 -556
- package/dist/cartridge/spec.d.ts +0 -188
- package/dist/cartridge/spec.js +0 -19
- package/dist/cartridge/validate.d.ts +0 -2
- package/dist/cartridge/validate.js +0 -78
- package/dist/cartridge.d.ts +0 -145
- package/dist/cartridge.js +0 -246
- package/skills/jgengine/reference-cartridge.md +0 -72
|
@@ -0,0 +1,1031 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Runtime renderer for the `city` volume kind: everything `resolveCityObject` emits becomes a
|
|
4
|
+
* fixed, bounded set of merged and instanced draws — road ribbons split by surface with lane
|
|
5
|
+
* dashes, crosswalks, intersection patches and cul-de-sac bulbs; median-divided boulevards;
|
|
6
|
+
* sidewalks; styled bridges with railings, piers and abutments; massing pieces as five instanced
|
|
7
|
+
* primitive meshes (plain walls, window-banded walls via `createCityWindowMaterial`, gable roofs,
|
|
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
|
|
10
|
+
* vertex-colored parks with crop-row fields. Lots near the camera and flat enough swap their
|
|
11
|
+
* massing for full `generateBuilding` facade kits (windows, awnings, storefronts) batched per part
|
|
12
|
+
* kind — a bounded LOD that re-picks only when the camera crosses a coarse cell, never per frame.
|
|
13
|
+
* All geometry derives from the document volume, so editor sliders re-render the district live and
|
|
14
|
+
* games consume the same authored volume unchanged.
|
|
15
|
+
*/
|
|
16
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
17
|
+
import { useFrame } from "@react-three/fiber";
|
|
18
|
+
import * as THREE from "three";
|
|
19
|
+
import { roundPathCorners, buildRoadRibbon, dashSegments } from "@jgengine/core/world/roads";
|
|
20
|
+
import { offsetPath } from "@jgengine/core/world/streets";
|
|
21
|
+
import { triangulatePolygon, triangulateRingBand } from "@jgengine/core/world/cityGeometry";
|
|
22
|
+
import { resolveCityObject, CITY_KIND, CITY_ZONE_KIND, } from "@jgengine/core/world/cityKind";
|
|
23
|
+
import { generateBuilding, resolveBuildingPalette } from "@jgengine/core/world/buildings";
|
|
24
|
+
import { buildScatterProxy } from "../scatter/scatterProxies.js";
|
|
25
|
+
import { createCityWindowMaterial } from "./cityWindowMaterial.js";
|
|
26
|
+
import { registerSceneKindRenderer } from "./sceneKindRenderers.js";
|
|
27
|
+
const ASPHALT_COLOR = "#3d3f45";
|
|
28
|
+
const GRAVEL_COLOR = "#93815f";
|
|
29
|
+
const SIDEWALK_COLOR = "#98948c";
|
|
30
|
+
const MARKING_COLOR = "#e8e4d8";
|
|
31
|
+
const MEDIAN_COLOR = "#4d7a40";
|
|
32
|
+
const PAVEMENT_COLOR = "#a8a49a";
|
|
33
|
+
const CONCRETE_COLOR = "#b0aba1";
|
|
34
|
+
const STEEL_COLOR = "#4c505a";
|
|
35
|
+
const TUNNEL_COLOR = "#5b5754";
|
|
36
|
+
const CURB_COLOR = "#c4c0b6";
|
|
37
|
+
const HEDGE_COLOR = "#3d6631";
|
|
38
|
+
const PARK_COLORS = { plaza: "#b3ada0", green: "#4e7c41", meadow: "#6d8a4c", field: "#94805a", courtyard: "#5c8348", buffer: "#63734a" };
|
|
39
|
+
const CROP_COLORS = ["#5d7a35", "#7a6b41", "#6f7f3a"];
|
|
40
|
+
const SPECIES_PROXY = { broadleaf: "oak", conifer: "pine", palm: "palm", cypress: "cypress" };
|
|
41
|
+
const SPECIES_SCALE = { broadleaf: 2.7, conifer: 2.5, palm: 2.6, cypress: 2.3 };
|
|
42
|
+
/** Accumulates draped ribbons, patches, discs, and quads into one merged (optionally colored) buffer. */
|
|
43
|
+
class MeshAccumulator {
|
|
44
|
+
positions = [];
|
|
45
|
+
indices = [];
|
|
46
|
+
colors;
|
|
47
|
+
constructor(colored = false) {
|
|
48
|
+
this.colors = colored ? [] : null;
|
|
49
|
+
}
|
|
50
|
+
pushColor(count, color) {
|
|
51
|
+
if (this.colors === null || color === null)
|
|
52
|
+
return;
|
|
53
|
+
for (let i = 0; i < count; i += 1)
|
|
54
|
+
this.colors.push(color.r, color.g, color.b);
|
|
55
|
+
}
|
|
56
|
+
addRibbon(points, width, sample, elevation, color = null) {
|
|
57
|
+
if (points.length < 2)
|
|
58
|
+
return;
|
|
59
|
+
const ribbon = buildRoadRibbon(points, width, sample, { elevation, maxSegmentLength: 2 });
|
|
60
|
+
const offset = this.positions.length / 3;
|
|
61
|
+
for (let i = 0; i < ribbon.positions.length; i += 1)
|
|
62
|
+
this.positions.push(ribbon.positions[i]);
|
|
63
|
+
for (let i = 0; i < ribbon.indices.length; i += 1)
|
|
64
|
+
this.indices.push(ribbon.indices[i] + offset);
|
|
65
|
+
this.pushColor(ribbon.positions.length / 3, color);
|
|
66
|
+
}
|
|
67
|
+
/** A ground-draped rotated rectangle, grid-subdivided so large patches follow the relief. */
|
|
68
|
+
addPatch(center, size, rotationY, sample, elevation, color = null) {
|
|
69
|
+
const cos = Math.cos(rotationY);
|
|
70
|
+
const sin = Math.sin(rotationY);
|
|
71
|
+
const nx = Math.max(1, Math.ceil(size[0] / 8));
|
|
72
|
+
const nz = Math.max(1, Math.ceil(size[1] / 8));
|
|
73
|
+
const base = this.positions.length / 3;
|
|
74
|
+
for (let j = 0; j <= nz; j += 1) {
|
|
75
|
+
for (let i = 0; i <= nx; i += 1) {
|
|
76
|
+
const lx = (i / nx - 0.5) * size[0];
|
|
77
|
+
const lz = (j / nz - 0.5) * size[1];
|
|
78
|
+
const x = center[0] + lx * cos + lz * sin;
|
|
79
|
+
const z = center[1] - lx * sin + lz * cos;
|
|
80
|
+
this.positions.push(x, sample(x, z) + elevation, z);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (let j = 0; j < nz; j += 1) {
|
|
84
|
+
for (let i = 0; i < nx; i += 1) {
|
|
85
|
+
const a = base + j * (nx + 1) + i;
|
|
86
|
+
const b = a + 1;
|
|
87
|
+
const c = a + (nx + 1);
|
|
88
|
+
const d = c + 1;
|
|
89
|
+
this.indices.push(a, c, b, b, c, d);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
this.pushColor((nx + 1) * (nz + 1), color);
|
|
93
|
+
}
|
|
94
|
+
addDisc(center, radius, sample, elevation, color = null, segments = 14) {
|
|
95
|
+
const base = this.positions.length / 3;
|
|
96
|
+
this.positions.push(center[0], sample(center[0], center[1]) + elevation, center[1]);
|
|
97
|
+
for (let i = 0; i <= segments; i += 1) {
|
|
98
|
+
const a = (i / segments) * Math.PI * 2;
|
|
99
|
+
const x = center[0] + Math.cos(a) * radius;
|
|
100
|
+
const z = center[1] + Math.sin(a) * radius;
|
|
101
|
+
this.positions.push(x, sample(x, z) + elevation, z);
|
|
102
|
+
}
|
|
103
|
+
for (let i = 0; i < segments; i += 1)
|
|
104
|
+
this.indices.push(base, base + 1 + i, base + 2 + i);
|
|
105
|
+
this.pushColor(segments + 2, color);
|
|
106
|
+
}
|
|
107
|
+
/** A ground-draped polygon patch: ear-clipped, one terrain sample per ring vertex. */
|
|
108
|
+
addPolygon(ring, sample, elevation, color = null) {
|
|
109
|
+
const { positions, indices } = triangulatePolygon(ring);
|
|
110
|
+
if (indices.length === 0)
|
|
111
|
+
return;
|
|
112
|
+
const base = this.positions.length / 3;
|
|
113
|
+
for (const [x, z] of positions)
|
|
114
|
+
this.positions.push(x, sample(x, z) + elevation, z);
|
|
115
|
+
for (const index of indices)
|
|
116
|
+
this.indices.push(index + base);
|
|
117
|
+
this.pushColor(positions.length, color);
|
|
118
|
+
}
|
|
119
|
+
/** A ground-draped band between two nested rings (curb outer, land inner) — the sidewalk. */
|
|
120
|
+
addRingBand(outer, inner, sample, elevation, color = null) {
|
|
121
|
+
const { positions, indices } = triangulateRingBand(outer, inner);
|
|
122
|
+
if (indices.length === 0)
|
|
123
|
+
return;
|
|
124
|
+
const base = this.positions.length / 3;
|
|
125
|
+
for (const [x, z] of positions)
|
|
126
|
+
this.positions.push(x, sample(x, z) + elevation, z);
|
|
127
|
+
for (const index of indices)
|
|
128
|
+
this.indices.push(index + base);
|
|
129
|
+
this.pushColor(positions.length, color);
|
|
130
|
+
}
|
|
131
|
+
build() {
|
|
132
|
+
if (this.indices.length === 0)
|
|
133
|
+
return null;
|
|
134
|
+
const geometry = new THREE.BufferGeometry();
|
|
135
|
+
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(this.positions), 3));
|
|
136
|
+
if (this.colors !== null)
|
|
137
|
+
geometry.setAttribute("color", new THREE.BufferAttribute(new Float32Array(this.colors), 3));
|
|
138
|
+
geometry.setIndex(this.indices);
|
|
139
|
+
geometry.computeVertexNormals();
|
|
140
|
+
return geometry;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Split a polyline into runs that keep `pad` clearance from every intersection disc. */
|
|
144
|
+
function clipPolylineAtIntersections(points, intersections, pad) {
|
|
145
|
+
const blocked = (x, z) => {
|
|
146
|
+
for (const cross of intersections) {
|
|
147
|
+
if (Math.hypot(cross.x - x, cross.z - z) < cross.radius + pad)
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
};
|
|
152
|
+
const runs = [];
|
|
153
|
+
let current = [];
|
|
154
|
+
for (let i = 0; i + 1 < points.length; i += 1) {
|
|
155
|
+
const [ax, az] = points[i];
|
|
156
|
+
const [bx, bz] = points[i + 1];
|
|
157
|
+
const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, bz - az) / 2));
|
|
158
|
+
for (let s = 0; s < steps; s += 1) {
|
|
159
|
+
const t = s / steps;
|
|
160
|
+
const x = ax + (bx - ax) * t;
|
|
161
|
+
const z = az + (bz - az) * t;
|
|
162
|
+
if (blocked(x, z)) {
|
|
163
|
+
if (current.length >= 2)
|
|
164
|
+
runs.push(current);
|
|
165
|
+
current = [];
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
current.push([x, z]);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const last = points[points.length - 1];
|
|
173
|
+
if (last !== undefined && !blocked(last[0], last[1]))
|
|
174
|
+
current.push(last);
|
|
175
|
+
if (current.length >= 2)
|
|
176
|
+
runs.push(current);
|
|
177
|
+
return runs;
|
|
178
|
+
}
|
|
179
|
+
/** Andrew's monotone-chain convex hull of XZ points (CCW), degenerate-safe. */
|
|
180
|
+
function convexHull(points) {
|
|
181
|
+
const pts = points.map((p) => [p[0], p[1]]).sort((a, b) => (a[0] === b[0] ? a[1] - b[1] : a[0] - b[0]));
|
|
182
|
+
if (pts.length < 3)
|
|
183
|
+
return pts;
|
|
184
|
+
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
|
185
|
+
const lower = [];
|
|
186
|
+
for (const p of pts) {
|
|
187
|
+
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0)
|
|
188
|
+
lower.pop();
|
|
189
|
+
lower.push(p);
|
|
190
|
+
}
|
|
191
|
+
const upper = [];
|
|
192
|
+
for (let i = pts.length - 1; i >= 0; i -= 1) {
|
|
193
|
+
const p = pts[i];
|
|
194
|
+
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0)
|
|
195
|
+
upper.pop();
|
|
196
|
+
upper.push(p);
|
|
197
|
+
}
|
|
198
|
+
lower.pop();
|
|
199
|
+
upper.pop();
|
|
200
|
+
return lower.concat(upper);
|
|
201
|
+
}
|
|
202
|
+
/** Round every vertex of a CLOSED ring into a short arc — the curb-return radius that turns a hard
|
|
203
|
+
* polygon corner into a real street corner. Radius is clamped to each corner's shorter leg. */
|
|
204
|
+
function roundRing(ring, radius, segments = 5) {
|
|
205
|
+
if (ring.length < 3 || radius <= 0)
|
|
206
|
+
return ring.map((p) => [p[0], p[1]]);
|
|
207
|
+
const n = ring.length;
|
|
208
|
+
const out = [];
|
|
209
|
+
for (let i = 0; i < n; i += 1) {
|
|
210
|
+
const a = ring[(i - 1 + n) % n];
|
|
211
|
+
const v = ring[i];
|
|
212
|
+
const b = ring[(i + 1) % n];
|
|
213
|
+
const ax = a[0] - v[0];
|
|
214
|
+
const az = a[1] - v[1];
|
|
215
|
+
const bx = b[0] - v[0];
|
|
216
|
+
const bz = b[1] - v[1];
|
|
217
|
+
const la = Math.hypot(ax, az) || 1;
|
|
218
|
+
const lb = Math.hypot(bx, bz) || 1;
|
|
219
|
+
const r = Math.min(radius, la / 2, lb / 2);
|
|
220
|
+
const p0 = [v[0] + (ax / la) * r, v[1] + (az / la) * r];
|
|
221
|
+
const p1 = [v[0] + (bx / lb) * r, v[1] + (bz / lb) * r];
|
|
222
|
+
out.push(p0);
|
|
223
|
+
for (let s = 1; s < segments; s += 1) {
|
|
224
|
+
const t = s / segments;
|
|
225
|
+
const u = 1 - t;
|
|
226
|
+
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]]);
|
|
227
|
+
}
|
|
228
|
+
out.push(p1);
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The pavement outline of a junction: each arm's roadway extended to the crossing, hulled into one
|
|
234
|
+
* convex area, then corner-rounded. This reads as a real intersection (a squared crossing with
|
|
235
|
+
* rounded curb corners) instead of the old stamped circle. Returns `null` for a degenerate junction.
|
|
236
|
+
*/
|
|
237
|
+
function junctionOutline(cross, reachScale = 1.15, expand = 0) {
|
|
238
|
+
if (cross.arms.length < 2)
|
|
239
|
+
return null;
|
|
240
|
+
let maxHalf = 0;
|
|
241
|
+
for (const arm of cross.arms)
|
|
242
|
+
maxHalf = Math.max(maxHalf, arm.width / 2);
|
|
243
|
+
const reach = maxHalf * reachScale + expand;
|
|
244
|
+
const mouth = [];
|
|
245
|
+
for (const arm of cross.arms) {
|
|
246
|
+
const dir = [Math.sin(arm.angle), Math.cos(arm.angle)];
|
|
247
|
+
const perp = [-dir[1], dir[0]];
|
|
248
|
+
const hw = arm.width / 2 + expand;
|
|
249
|
+
mouth.push([cross.x + dir[0] * reach + perp[0] * hw, cross.z + dir[1] * reach + perp[1] * hw]);
|
|
250
|
+
mouth.push([cross.x + dir[0] * reach - perp[0] * hw, cross.z + dir[1] * reach - perp[1] * hw]);
|
|
251
|
+
}
|
|
252
|
+
const hull = convexHull(mouth);
|
|
253
|
+
if (hull.length < 3)
|
|
254
|
+
return null;
|
|
255
|
+
// Curb-return radius: a real corner, capped so a tight junction never over-rounds into a blob.
|
|
256
|
+
const cornerR = Math.min(maxHalf * 0.9 + expand, 5.5);
|
|
257
|
+
return roundRing(hull, cornerR, 6);
|
|
258
|
+
}
|
|
259
|
+
let unitGeometryCache = null;
|
|
260
|
+
/** Unit massing primitives shared by every district: box, gable prism (ridge along X), cylinder, dome. */
|
|
261
|
+
function unitGeometries() {
|
|
262
|
+
if (unitGeometryCache !== null)
|
|
263
|
+
return unitGeometryCache;
|
|
264
|
+
const box = new THREE.BoxGeometry(1, 1, 1);
|
|
265
|
+
const shape = new THREE.Shape();
|
|
266
|
+
shape.moveTo(-0.5, 0);
|
|
267
|
+
shape.lineTo(0.5, 0);
|
|
268
|
+
shape.lineTo(0, 1);
|
|
269
|
+
shape.closePath();
|
|
270
|
+
const gable = new THREE.ExtrudeGeometry(shape, { depth: 1, bevelEnabled: false });
|
|
271
|
+
gable.translate(0, 0, -0.5);
|
|
272
|
+
gable.rotateY(Math.PI / 2);
|
|
273
|
+
gable.computeVertexNormals();
|
|
274
|
+
const cylinder = new THREE.CylinderGeometry(0.5, 0.5, 1, 12);
|
|
275
|
+
cylinder.translate(0, 0.5, 0);
|
|
276
|
+
const dome = new THREE.SphereGeometry(0.5, 12, 6, 0, Math.PI * 2, 0, Math.PI / 2);
|
|
277
|
+
dome.scale(1, 2, 1);
|
|
278
|
+
unitGeometryCache = { box, gable, cylinder, dome };
|
|
279
|
+
return unitGeometryCache;
|
|
280
|
+
}
|
|
281
|
+
const ROLE_JITTER = { wall: 1, roof: 0.5, trim: 0.35, accent: 0.6 };
|
|
282
|
+
function roleColor(palette, role) {
|
|
283
|
+
if (role === "wall")
|
|
284
|
+
return palette.wall;
|
|
285
|
+
if (role === "roof")
|
|
286
|
+
return palette.roof;
|
|
287
|
+
if (role === "trim")
|
|
288
|
+
return palette.corner;
|
|
289
|
+
return palette.awning;
|
|
290
|
+
}
|
|
291
|
+
const DETAIL_RADIUS = 95;
|
|
292
|
+
const DETAIL_MAX = 12;
|
|
293
|
+
const DETAIL_CLASSES = new Set(["tower", "slab", "shop", "rowhouse"]);
|
|
294
|
+
/** Per-part-kind instanced batches for the near-LOD facade buildings (full lot yaw support). */
|
|
295
|
+
function DetailBuildings({ buildings, palette }) {
|
|
296
|
+
const meshes = useMemo(() => {
|
|
297
|
+
if (buildings.length === 0)
|
|
298
|
+
return null;
|
|
299
|
+
const buckets = new Map();
|
|
300
|
+
const partMatrix = new THREE.Matrix4();
|
|
301
|
+
const compose = new THREE.Matrix4();
|
|
302
|
+
for (const building of buildings) {
|
|
303
|
+
for (const part of building.parts) {
|
|
304
|
+
partMatrix.compose(new THREE.Vector3(part.position[0], part.position[1], part.position[2]), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), part.rotationY), new THREE.Vector3(Math.max(0.01, part.scale[0]), Math.max(0.01, part.scale[1]), Math.max(0.01, part.scale[2])));
|
|
305
|
+
compose.multiplyMatrices(building.matrix, partMatrix);
|
|
306
|
+
const bucket = buckets.get(part.kind);
|
|
307
|
+
if (bucket === undefined)
|
|
308
|
+
buckets.set(part.kind, [compose.clone()]);
|
|
309
|
+
else
|
|
310
|
+
bucket.push(compose.clone());
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const geometry = new THREE.BoxGeometry(1, 1, 1);
|
|
314
|
+
const out = [];
|
|
315
|
+
// Rooftop mechanical units read as debug cubes in the district's bright palette accent — force a
|
|
316
|
+
// muted metal grey so they look like real HVAC/plant, never placeholder geometry.
|
|
317
|
+
const ROOF_PROP_GREY = "#9a9ea3";
|
|
318
|
+
for (const [kind, matrices] of buckets) {
|
|
319
|
+
const color = kind === "roofProp" ? ROOF_PROP_GREY : palette[kind];
|
|
320
|
+
const material = kind === "window" || kind === "storefront"
|
|
321
|
+
? new THREE.MeshPhysicalMaterial({ color, roughness: 0.12, metalness: 0, transparent: true, opacity: 0.56 })
|
|
322
|
+
: kind === "storeSign"
|
|
323
|
+
? new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.6, roughness: 0.5 })
|
|
324
|
+
: new THREE.MeshStandardMaterial({ color, roughness: 0.88, metalness: 0 });
|
|
325
|
+
const mesh = new THREE.InstancedMesh(geometry, material, matrices.length);
|
|
326
|
+
matrices.forEach((matrix, i) => mesh.setMatrixAt(i, matrix));
|
|
327
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
328
|
+
mesh.castShadow = true;
|
|
329
|
+
mesh.receiveShadow = true;
|
|
330
|
+
mesh.frustumCulled = false;
|
|
331
|
+
out.push(mesh);
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
}, [buildings, palette]);
|
|
335
|
+
useEffect(() => () => {
|
|
336
|
+
if (meshes === null)
|
|
337
|
+
return;
|
|
338
|
+
for (const mesh of meshes) {
|
|
339
|
+
mesh.geometry.dispose();
|
|
340
|
+
mesh.material.dispose();
|
|
341
|
+
}
|
|
342
|
+
}, [meshes]);
|
|
343
|
+
if (meshes === null)
|
|
344
|
+
return null;
|
|
345
|
+
return (_jsx(_Fragment, { children: meshes.map((mesh, i) => (_jsx("primitive", { object: mesh }, `detail:${i}`))) }));
|
|
346
|
+
}
|
|
347
|
+
/** One authored city volume → the full merged/instanced district. */
|
|
348
|
+
function OneCity({ object, context }) {
|
|
349
|
+
const field = context.field;
|
|
350
|
+
const sample = useMemo(() => (x, z) => field.sampleHeight(x, z), [field]);
|
|
351
|
+
const zoneOverrides = useMemo(() => context.document.volumes
|
|
352
|
+
.filter((volume) => volume.kind === CITY_ZONE_KIND)
|
|
353
|
+
.map((volume) => ({
|
|
354
|
+
id: volume.id,
|
|
355
|
+
kind: volume.kind,
|
|
356
|
+
center: volume.center,
|
|
357
|
+
...(volume.halfExtents === undefined ? {} : { halfExtents: volume.halfExtents }),
|
|
358
|
+
...(volume.radius === undefined ? {} : { radius: volume.radius }),
|
|
359
|
+
...(volume.meta === undefined ? {} : { meta: volume.meta }),
|
|
360
|
+
})), [context.document.volumes]);
|
|
361
|
+
const resolved = useMemo(() => resolveCityObject(object, { sampleHeight: sample, zoneOverrides }), [object, sample, zoneOverrides]);
|
|
362
|
+
const palette = useMemo(() => resolveBuildingPalette(resolved?.rules.style ?? "generic"), [resolved]);
|
|
363
|
+
// --- Camera-coarse LOD cell: re-pick detail lots only when the camera crosses a 28 m cell. ---
|
|
364
|
+
const [detailKey, setDetailKey] = useState("");
|
|
365
|
+
const cameraRef = useRef([0, 0]);
|
|
366
|
+
const cellRef = useRef("");
|
|
367
|
+
useFrame(({ camera }) => {
|
|
368
|
+
const key = `${Math.round(camera.position.x / 28)}:${Math.round(camera.position.z / 28)}`;
|
|
369
|
+
if (key !== cellRef.current) {
|
|
370
|
+
cellRef.current = key;
|
|
371
|
+
cameraRef.current = [camera.position.x, camera.position.z];
|
|
372
|
+
setDetailKey(key);
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
const detail = useMemo(() => {
|
|
376
|
+
if (resolved === null || detailKey === "")
|
|
377
|
+
return { ids: new Set(), buildings: [] };
|
|
378
|
+
const [cx, cz] = cameraRef.current;
|
|
379
|
+
const candidates = resolved.lots
|
|
380
|
+
.filter((lot) => DETAIL_CLASSES.has(lot.class) && lot.floors <= 9)
|
|
381
|
+
.map((lot) => ({ lot, dist: Math.hypot(lot.center[0] - cx, lot.center[1] - cz) }))
|
|
382
|
+
.filter((entry) => entry.dist < DETAIL_RADIUS)
|
|
383
|
+
.sort((a, b) => a.dist - b.dist);
|
|
384
|
+
const ids = new Set();
|
|
385
|
+
const buildings = [];
|
|
386
|
+
for (const { lot } of candidates) {
|
|
387
|
+
if (buildings.length >= DETAIL_MAX)
|
|
388
|
+
break;
|
|
389
|
+
// Only flat-enough lots swap in the facade kit — it has no foundation to hide a slope.
|
|
390
|
+
const c = Math.cos(lot.rotationY);
|
|
391
|
+
const s = Math.sin(lot.rotationY);
|
|
392
|
+
const hw = lot.size[0] / 2;
|
|
393
|
+
const hd = lot.size[1] / 2;
|
|
394
|
+
let minY = Infinity;
|
|
395
|
+
let maxY = -Infinity;
|
|
396
|
+
for (const [dx, dz] of [
|
|
397
|
+
[hw, hd],
|
|
398
|
+
[hw, -hd],
|
|
399
|
+
[-hw, hd],
|
|
400
|
+
[-hw, -hd],
|
|
401
|
+
]) {
|
|
402
|
+
const y = sample(lot.center[0] + dx * c + dz * s, lot.center[1] - dx * s + dz * c);
|
|
403
|
+
minY = Math.min(minY, y);
|
|
404
|
+
maxY = Math.max(maxY, y);
|
|
405
|
+
}
|
|
406
|
+
if (maxY - minY > 1.4)
|
|
407
|
+
continue;
|
|
408
|
+
const baysWide = Math.min(8, Math.max(2, Math.round(lot.size[0] / 2.4)));
|
|
409
|
+
const baysDeep = Math.min(6, Math.max(2, Math.round(lot.size[1] / 2.4)));
|
|
410
|
+
const building = generateBuilding({
|
|
411
|
+
id: lot.id,
|
|
412
|
+
seed: lot.id,
|
|
413
|
+
center: [0, 0],
|
|
414
|
+
floors: Math.min(8, lot.floors),
|
|
415
|
+
baysWide,
|
|
416
|
+
baysDeep,
|
|
417
|
+
bayWidth: lot.size[0] / baysWide,
|
|
418
|
+
floorHeight: resolved.rules.floorHeight,
|
|
419
|
+
});
|
|
420
|
+
const matrix = new THREE.Matrix4().compose(new THREE.Vector3(lot.center[0], maxY, lot.center[1]), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), lot.rotationY), new THREE.Vector3(1, 1, 1));
|
|
421
|
+
ids.add(lot.id);
|
|
422
|
+
buildings.push({ lotId: lot.id, matrix, parts: building.parts });
|
|
423
|
+
}
|
|
424
|
+
return { ids, buildings };
|
|
425
|
+
}, [resolved, detailKey, sample]);
|
|
426
|
+
// --- Massing: five instanced primitive draws over every lot's pieces (minus detail-LOD lots). ---
|
|
427
|
+
const massing = useMemo(() => {
|
|
428
|
+
if (resolved === null || resolved.lots.length === 0)
|
|
429
|
+
return null;
|
|
430
|
+
const units = unitGeometries();
|
|
431
|
+
const buckets = {
|
|
432
|
+
box: { matrices: [], colors: [] },
|
|
433
|
+
banded: { matrices: [], colors: [], groundOffsets: [] },
|
|
434
|
+
gable: { matrices: [], colors: [] },
|
|
435
|
+
cylinder: { matrices: [], colors: [] },
|
|
436
|
+
dome: { matrices: [], colors: [] },
|
|
437
|
+
};
|
|
438
|
+
const color = new THREE.Color();
|
|
439
|
+
const roleBase = {
|
|
440
|
+
wall: new THREE.Color(roleColor(palette, "wall")),
|
|
441
|
+
roof: new THREE.Color(roleColor(palette, "roof")),
|
|
442
|
+
trim: new THREE.Color(roleColor(palette, "trim")),
|
|
443
|
+
accent: new THREE.Color(roleColor(palette, "accent")),
|
|
444
|
+
};
|
|
445
|
+
for (const lot of resolved.lots) {
|
|
446
|
+
if (detail.ids.has(lot.id))
|
|
447
|
+
continue;
|
|
448
|
+
const c = Math.cos(lot.rotationY);
|
|
449
|
+
const s = Math.sin(lot.rotationY);
|
|
450
|
+
const hw = lot.size[0] / 2;
|
|
451
|
+
const hd = lot.size[1] / 2;
|
|
452
|
+
let minY = Infinity;
|
|
453
|
+
let maxY = -Infinity;
|
|
454
|
+
for (const [dx, dz] of [
|
|
455
|
+
[hw, hd],
|
|
456
|
+
[hw, -hd],
|
|
457
|
+
[-hw, hd],
|
|
458
|
+
[-hw, -hd],
|
|
459
|
+
]) {
|
|
460
|
+
const y = sample(lot.center[0] + dx * c + dz * s, lot.center[1] - dx * s + dz * c);
|
|
461
|
+
minY = Math.min(minY, y);
|
|
462
|
+
maxY = Math.max(maxY, y);
|
|
463
|
+
}
|
|
464
|
+
const grade = maxY;
|
|
465
|
+
const foundationBase = minY - 0.5;
|
|
466
|
+
// Edge-of-town lots vary more; downtown stays composed.
|
|
467
|
+
const jitterAmp = lot.zone === "edge" ? 0.4 : lot.zone === "mid" ? 0.28 : 0.18;
|
|
468
|
+
for (const piece of lot.pieces) {
|
|
469
|
+
const px = lot.center[0] + piece.offset[0] * c + piece.offset[2] * s;
|
|
470
|
+
const pz = lot.center[1] - piece.offset[0] * s + piece.offset[2] * c;
|
|
471
|
+
const grounded = piece.grounded;
|
|
472
|
+
const baseY = grounded ? foundationBase : grade + piece.offset[1];
|
|
473
|
+
const height = grounded ? piece.size[1] + (grade - foundationBase) : piece.size[1];
|
|
474
|
+
const groundOffset = grounded ? grade - foundationBase : -piece.offset[1];
|
|
475
|
+
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])));
|
|
476
|
+
const amp = jitterAmp * ROLE_JITTER[piece.role];
|
|
477
|
+
color.copy(roleBase[piece.role]);
|
|
478
|
+
// Accent trim (awnings/cornices) is a saturated storefront color that reads as a stray red/pink
|
|
479
|
+
// strip at district scale — pull it partway to the wall so it stays a tasteful accent.
|
|
480
|
+
if (piece.role === "accent")
|
|
481
|
+
color.lerp(roleBase.wall, 0.5);
|
|
482
|
+
color.multiplyScalar(1 - amp / 2 + lot.jitter * amp);
|
|
483
|
+
// A whisper of hue shift so rows of houses never read as one paint bucket.
|
|
484
|
+
color.r *= 1 + (lot.jitter - 0.5) * 0.08;
|
|
485
|
+
color.b *= 1 + (0.5 - lot.jitter) * 0.08;
|
|
486
|
+
const bucket = piece.shape === "box" ? (piece.banded ? buckets.banded : buckets.box) : buckets[piece.shape];
|
|
487
|
+
bucket.matrices.push(matrix);
|
|
488
|
+
bucket.colors.push(color.clone());
|
|
489
|
+
bucket.groundOffsets?.push(groundOffset);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// Cylinders/gables/domes scale from their base (geometry roots at y=0), boxes from center —
|
|
493
|
+
// the matrices above already position each accordingly.
|
|
494
|
+
const makeMesh = (key, geometry, material) => {
|
|
495
|
+
const bucket = buckets[key];
|
|
496
|
+
if (bucket.matrices.length === 0)
|
|
497
|
+
return null;
|
|
498
|
+
const mesh = new THREE.InstancedMesh(geometry, material, bucket.matrices.length);
|
|
499
|
+
bucket.matrices.forEach((matrix, i) => mesh.setMatrixAt(i, matrix));
|
|
500
|
+
bucket.colors.forEach((tint, i) => mesh.setColorAt(i, tint));
|
|
501
|
+
if (bucket.groundOffsets !== undefined) {
|
|
502
|
+
mesh.geometry = geometry.clone();
|
|
503
|
+
mesh.geometry.setAttribute("instanceGroundOffset", new THREE.InstancedBufferAttribute(new Float32Array(bucket.groundOffsets), 1));
|
|
504
|
+
}
|
|
505
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
506
|
+
if (mesh.instanceColor !== null)
|
|
507
|
+
mesh.instanceColor.needsUpdate = true;
|
|
508
|
+
mesh.castShadow = true;
|
|
509
|
+
mesh.receiveShadow = true;
|
|
510
|
+
mesh.frustumCulled = false;
|
|
511
|
+
return mesh;
|
|
512
|
+
};
|
|
513
|
+
const plain = new THREE.MeshStandardMaterial({ roughness: 0.85, metalness: 0.04 });
|
|
514
|
+
const banded = createCityWindowMaterial({ floorHeight: resolved.rules.floorHeight, windowColor: palette.window });
|
|
515
|
+
const meshes = [
|
|
516
|
+
makeMesh("box", units.box, plain),
|
|
517
|
+
makeMesh("banded", units.box, banded),
|
|
518
|
+
makeMesh("gable", units.gable, plain),
|
|
519
|
+
makeMesh("cylinder", units.cylinder, plain),
|
|
520
|
+
makeMesh("dome", units.dome, plain),
|
|
521
|
+
].filter((mesh) => mesh !== null);
|
|
522
|
+
return { meshes, materials: [plain, banded] };
|
|
523
|
+
}, [resolved, sample, palette, detail.ids]);
|
|
524
|
+
useEffect(() => () => {
|
|
525
|
+
if (massing === null)
|
|
526
|
+
return;
|
|
527
|
+
for (const material of massing.materials)
|
|
528
|
+
material.dispose();
|
|
529
|
+
for (const mesh of massing.meshes)
|
|
530
|
+
if (mesh.geometry.getAttribute("instanceGroundOffset") !== undefined)
|
|
531
|
+
mesh.geometry.dispose();
|
|
532
|
+
}, [massing]);
|
|
533
|
+
// --- Ground network: roads by surface, sidewalks, medians, patches, markings, driveways, parks. ---
|
|
534
|
+
const ground = useMemo(() => {
|
|
535
|
+
if (resolved === null || resolved.streets.length === 0)
|
|
536
|
+
return null;
|
|
537
|
+
const asphalt = new MeshAccumulator();
|
|
538
|
+
const gravel = new MeshAccumulator();
|
|
539
|
+
const sidewalks = new MeshAccumulator();
|
|
540
|
+
const patches = new MeshAccumulator();
|
|
541
|
+
const markings = new MeshAccumulator();
|
|
542
|
+
const curbs = new MeshAccumulator();
|
|
543
|
+
const medians = new MeshAccumulator();
|
|
544
|
+
const pavementDrives = new MeshAccumulator();
|
|
545
|
+
const gravelDrives = new MeshAccumulator();
|
|
546
|
+
const parks = new MeshAccumulator(true);
|
|
547
|
+
const crops = new MeshAccumulator(true);
|
|
548
|
+
const parkColor = new THREE.Color();
|
|
549
|
+
// Sidewalks now come from block curb→land bands; only pre-block documents fall back to the old
|
|
550
|
+
// per-street widened ribbon.
|
|
551
|
+
const useBlockSidewalks = resolved.blocks.length > 0;
|
|
552
|
+
// Feature-aware height: because streets stay continuous across water and through ridges, the road
|
|
553
|
+
// must ride UP onto a bridge deck and hold FLAT through a tunnel bore rather than dive under the
|
|
554
|
+
// river or climb over the hill. Points off any feature fall back to the terrain sampler.
|
|
555
|
+
const bridgeDecks = resolved.bridges.map((bridge) => {
|
|
556
|
+
const first = bridge.points[0];
|
|
557
|
+
const last = bridge.points[bridge.points.length - 1];
|
|
558
|
+
const abx = last[0] - first[0];
|
|
559
|
+
const abz = last[1] - first[1];
|
|
560
|
+
const len2 = Math.max(1e-6, abx * abx + abz * abz);
|
|
561
|
+
return {
|
|
562
|
+
first,
|
|
563
|
+
abx,
|
|
564
|
+
abz,
|
|
565
|
+
len2,
|
|
566
|
+
span: Math.sqrt(len2),
|
|
567
|
+
hA: sample(first[0], first[1]) + 0.35,
|
|
568
|
+
hB: sample(last[0], last[1]) + 0.35,
|
|
569
|
+
style: bridge.style,
|
|
570
|
+
half: bridge.width / 2 + 1.2,
|
|
571
|
+
};
|
|
572
|
+
});
|
|
573
|
+
const tunnelFloors = resolved.tunnels.map((tunnel) => {
|
|
574
|
+
const first = tunnel.points[0];
|
|
575
|
+
const last = tunnel.points[tunnel.points.length - 1];
|
|
576
|
+
const abx = last[0] - first[0];
|
|
577
|
+
const abz = last[1] - first[1];
|
|
578
|
+
return { first, abx, abz, len2: Math.max(1e-6, abx * abx + abz * abz), floor: tunnel.bankHeight, half: tunnel.width / 2 + 0.8 };
|
|
579
|
+
});
|
|
580
|
+
const deckSampler = (x, z) => {
|
|
581
|
+
for (const d of bridgeDecks) {
|
|
582
|
+
const raw = ((x - d.first[0]) * d.abx + (z - d.first[1]) * d.abz) / d.len2;
|
|
583
|
+
if (raw < -0.03 || raw > 1.03)
|
|
584
|
+
continue;
|
|
585
|
+
const t = Math.max(0, Math.min(1, raw));
|
|
586
|
+
if (Math.hypot(x - (d.first[0] + d.abx * t), z - (d.first[1] + d.abz * t)) > d.half)
|
|
587
|
+
continue;
|
|
588
|
+
const arch = d.style === "arch" ? Math.sin(t * Math.PI) * Math.min(1.6, d.span * 0.03) : 0.25;
|
|
589
|
+
return d.hA + (d.hB - d.hA) * t + arch;
|
|
590
|
+
}
|
|
591
|
+
for (const tn of tunnelFloors) {
|
|
592
|
+
const raw = ((x - tn.first[0]) * tn.abx + (z - tn.first[1]) * tn.abz) / tn.len2;
|
|
593
|
+
if (raw < -0.03 || raw > 1.03)
|
|
594
|
+
continue;
|
|
595
|
+
const t = Math.max(0, Math.min(1, raw));
|
|
596
|
+
if (Math.hypot(x - (tn.first[0] + tn.abx * t), z - (tn.first[1] + tn.abz * t)) > tn.half)
|
|
597
|
+
continue;
|
|
598
|
+
return tn.floor;
|
|
599
|
+
}
|
|
600
|
+
return sample(x, z);
|
|
601
|
+
};
|
|
602
|
+
const addMarkQuad = (center, along, length, width) => {
|
|
603
|
+
const perp = [-along[1], along[0]];
|
|
604
|
+
const base = markings.positions.length / 3;
|
|
605
|
+
for (const [sa, sp] of [
|
|
606
|
+
[-0.5, -0.5],
|
|
607
|
+
[0.5, -0.5],
|
|
608
|
+
[-0.5, 0.5],
|
|
609
|
+
[0.5, 0.5],
|
|
610
|
+
]) {
|
|
611
|
+
const x = center[0] + along[0] * length * sa + perp[0] * width * sp;
|
|
612
|
+
const z = center[1] + along[1] * length * sa + perp[1] * width * sp;
|
|
613
|
+
markings.positions.push(x, deckSampler(x, z) + 0.26, z);
|
|
614
|
+
}
|
|
615
|
+
markings.indices.push(base, base + 2, base + 1, base + 1, base + 2, base + 3);
|
|
616
|
+
};
|
|
617
|
+
for (const street of resolved.streets) {
|
|
618
|
+
if (street.points.length < 2)
|
|
619
|
+
continue;
|
|
620
|
+
const rounded = roundPathCorners(street.points, Math.max(2.5, street.width * 1.15), 9);
|
|
621
|
+
const surface = street.surface === "gravel" ? gravel : asphalt;
|
|
622
|
+
surface.addRibbon(rounded, street.width, deckSampler, 0.16);
|
|
623
|
+
if (street.sidewalk && !useBlockSidewalks)
|
|
624
|
+
sidewalks.addRibbon(rounded, street.width + 3.6, sample, 0.1);
|
|
625
|
+
// Curbs + painted edge/lane lines make a flat ribbon read as a real carriageway. Both break at
|
|
626
|
+
// junctions (the crossing owns its own pavement), so clip the centerline first.
|
|
627
|
+
if (street.surface === "asphalt") {
|
|
628
|
+
const half = street.width / 2;
|
|
629
|
+
for (const run of clipPolylineAtIntersections(rounded, resolved.intersections, half + 0.5)) {
|
|
630
|
+
if (run.length < 2)
|
|
631
|
+
continue;
|
|
632
|
+
const left = offsetPath(run, half);
|
|
633
|
+
const right = offsetPath(run, -half);
|
|
634
|
+
curbs.addRibbon(left, 0.55, deckSampler, 0.24);
|
|
635
|
+
curbs.addRibbon(right, 0.55, deckSampler, 0.24);
|
|
636
|
+
if (street.level !== "lane") {
|
|
637
|
+
// White edge (fog) lines just inside the curb.
|
|
638
|
+
markings.addRibbon(offsetPath(run, half - 0.75), 0.16, deckSampler, 0.26);
|
|
639
|
+
markings.addRibbon(offsetPath(run, -(half - 0.75)), 0.16, deckSampler, 0.26);
|
|
640
|
+
// Avenue+ get dashed lane dividers either side of the centerline.
|
|
641
|
+
if (street.level === "avenue") {
|
|
642
|
+
for (const dash of dashSegments(offsetPath(run, half * 0.5), 2.4, 3.2))
|
|
643
|
+
markings.addRibbon(dash, 0.13, deckSampler, 0.26);
|
|
644
|
+
for (const dash of dashSegments(offsetPath(run, -half * 0.5), 2.4, 3.2))
|
|
645
|
+
markings.addRibbon(dash, 0.13, deckSampler, 0.26);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (street.bulb !== undefined) {
|
|
651
|
+
(street.surface === "gravel" ? gravel : asphalt).addDisc(street.bulb, street.width * 1.15, sample, 0.16);
|
|
652
|
+
if (street.sidewalk)
|
|
653
|
+
sidewalks.addDisc(street.bulb, street.width * 1.15 + 1.8, sample, 0.1);
|
|
654
|
+
}
|
|
655
|
+
if (street.surface === "asphalt" && street.level !== "lane") {
|
|
656
|
+
if (street.level === "boulevard") {
|
|
657
|
+
// Median strip instead of a center line, broken WELL clear of every junction — a median
|
|
658
|
+
// stub poking into a crossing reads as debris. Runs shorter than a car are dropped and
|
|
659
|
+
// ends get a rounded cap so each island reads designed.
|
|
660
|
+
for (const run of clipPolylineAtIntersections(rounded, resolved.intersections, 2.6)) {
|
|
661
|
+
let runLength = 0;
|
|
662
|
+
for (let i = 0; i + 1 < run.length; i += 1)
|
|
663
|
+
runLength += Math.hypot(run[i + 1][0] - run[i][0], run[i + 1][1] - run[i][1]);
|
|
664
|
+
if (runLength < 7)
|
|
665
|
+
continue;
|
|
666
|
+
const width = Math.max(1.3, street.width * 0.14);
|
|
667
|
+
medians.addRibbon(run, width, deckSampler, 0.3);
|
|
668
|
+
medians.addDisc(run[0], width / 2, deckSampler, 0.3, null, 8);
|
|
669
|
+
medians.addDisc(run[run.length - 1], width / 2, deckSampler, 0.3, null, 8);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
else {
|
|
673
|
+
for (const dash of dashSegments(rounded, 2.6, 3.6)) {
|
|
674
|
+
const [a, b] = [dash[0], dash[dash.length - 1]];
|
|
675
|
+
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
|
676
|
+
let inCross = false;
|
|
677
|
+
for (const cross of resolved.intersections) {
|
|
678
|
+
if (Math.hypot(cross.x - mid[0], cross.z - mid[1]) < cross.radius + 0.8) {
|
|
679
|
+
inCross = true;
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
if (!inCross)
|
|
684
|
+
markings.addRibbon(dash, 0.18, deckSampler, 0.26);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
// Sidewalk band per block: the region between the curb ring (outer) and the land ring (inner).
|
|
690
|
+
// Blocks whose two rings coincide (no sidewalk) drop out — every band triangle is degenerate,
|
|
691
|
+
// so `addRingBand` emits nothing.
|
|
692
|
+
if (useBlockSidewalks) {
|
|
693
|
+
for (const block of resolved.blocks) {
|
|
694
|
+
sidewalks.addRingBand(block.curb, block.polygon, sample, 0.1);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
// Junctions: corner curb-return apron (sidewalk ring peeking out under the asphalt patch), the
|
|
698
|
+
// patch itself, and a zebra crossing on every REAL arm — but only where the crossing warrants
|
|
699
|
+
// it. Avenue-and-up arms (or a boulevard/avenue junction) get zebra stripes and the apron;
|
|
700
|
+
// small residential crossings get just the asphalt patch.
|
|
701
|
+
for (const cross of resolved.intersections) {
|
|
702
|
+
const bigLevel = cross.level === "boulevard" || cross.level === "avenue";
|
|
703
|
+
const armZebra = (width) => bigLevel || width >= resolved.rules.streetWidth * 1.4;
|
|
704
|
+
const paved = resolved.rules.sidewalks && cross.level !== "lane";
|
|
705
|
+
// The junction pavement is the roads' union with rounded curb corners — a real crossing, not a
|
|
706
|
+
// stamped circle. A slightly larger apron underneath reads as the wrapped sidewalk/curb.
|
|
707
|
+
const outline = junctionOutline(cross);
|
|
708
|
+
if (outline !== null) {
|
|
709
|
+
if (paved) {
|
|
710
|
+
const apron = junctionOutline(cross, 1.15, resolved.rules.sidewalkWidth + 0.4);
|
|
711
|
+
if (apron !== null)
|
|
712
|
+
sidewalks.addPolygon(apron, sample, 0.1);
|
|
713
|
+
}
|
|
714
|
+
patches.addPolygon(outline, deckSampler, 0.18);
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
patches.addDisc([cross.x, cross.z], cross.radius, deckSampler, 0.18);
|
|
718
|
+
}
|
|
719
|
+
if (paved) {
|
|
720
|
+
const maxHalf = Math.max(...cross.arms.map((arm) => arm.width / 2));
|
|
721
|
+
for (const arm of cross.arms) {
|
|
722
|
+
if (!armZebra(arm.width))
|
|
723
|
+
continue;
|
|
724
|
+
const dir = [Math.sin(arm.angle), Math.cos(arm.angle)];
|
|
725
|
+
const perp = [-dir[1], dir[0]];
|
|
726
|
+
// Stop line across the approach, just outside the crossing box.
|
|
727
|
+
const stopDist = maxHalf * 1.15 + 0.55;
|
|
728
|
+
addMarkQuad([cross.x + dir[0] * stopDist, cross.z + dir[1] * stopDist], perp, arm.width * 0.82, 0.5);
|
|
729
|
+
for (let stripe = 0; stripe < 4; stripe += 1) {
|
|
730
|
+
const dist = maxHalf * 1.15 + 1.5 + stripe * 0.85;
|
|
731
|
+
const center = [cross.x + dir[0] * dist, cross.z + dir[1] * dist];
|
|
732
|
+
addMarkQuad(center, perp, arm.width * 0.8, 0.44);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
for (const drive of resolved.driveways) {
|
|
738
|
+
(drive.surface === "gravel" ? gravelDrives : pavementDrives).addRibbon(drive.points, drive.width, sample, 0.12);
|
|
739
|
+
}
|
|
740
|
+
for (const pad of resolved.parkingLots) {
|
|
741
|
+
pavementDrives.addPatch(pad.center, pad.size, pad.rotationY, sample, 0.13);
|
|
742
|
+
// Stall separators along the pad.
|
|
743
|
+
const cos = Math.cos(pad.rotationY);
|
|
744
|
+
const sin = Math.sin(pad.rotationY);
|
|
745
|
+
const stalls = Math.floor(pad.size[0] / 2.9);
|
|
746
|
+
for (let i = 1; i < stalls; i += 1) {
|
|
747
|
+
const lx = (i / stalls - 0.5) * pad.size[0];
|
|
748
|
+
const cx = pad.center[0] + lx * cos + (pad.size[1] * 0.24) * sin;
|
|
749
|
+
const cz = pad.center[1] - lx * sin + (pad.size[1] * 0.24) * cos;
|
|
750
|
+
addMarkQuad([cx, cz], [sin, cos], pad.size[1] * 0.42, 0.12);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
for (const park of resolved.parks) {
|
|
754
|
+
parkColor.set(PARK_COLORS[park.type]);
|
|
755
|
+
parkColor.multiplyScalar(0.92 + park.jitter * 0.16);
|
|
756
|
+
const elevation = park.type === "plaza" ? 0.14 : 0.12;
|
|
757
|
+
// Block-pipeline parks carry a real road-derived polygon; the rect is only a coarse proxy.
|
|
758
|
+
if (park.polygon !== undefined)
|
|
759
|
+
parks.addPolygon(park.polygon, sample, elevation, parkColor);
|
|
760
|
+
else
|
|
761
|
+
parks.addPatch(park.center, park.size, park.rotationY, sample, elevation, parkColor);
|
|
762
|
+
if (park.rows !== undefined) {
|
|
763
|
+
const crop = new THREE.Color(CROP_COLORS[Math.floor(park.jitter * CROP_COLORS.length) % CROP_COLORS.length]);
|
|
764
|
+
crop.multiplyScalar(0.9 + park.jitter * 0.2);
|
|
765
|
+
for (const row of park.rows)
|
|
766
|
+
crops.addRibbon(row, 1.05, sample, 0.2, crop);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return {
|
|
770
|
+
asphalt: asphalt.build(),
|
|
771
|
+
gravel: gravel.build(),
|
|
772
|
+
sidewalks: sidewalks.build(),
|
|
773
|
+
patches: patches.build(),
|
|
774
|
+
markings: markings.build(),
|
|
775
|
+
curbs: curbs.build(),
|
|
776
|
+
medians: medians.build(),
|
|
777
|
+
pavementDrives: pavementDrives.build(),
|
|
778
|
+
gravelDrives: gravelDrives.build(),
|
|
779
|
+
parks: parks.build(),
|
|
780
|
+
crops: crops.build(),
|
|
781
|
+
};
|
|
782
|
+
}, [resolved, sample]);
|
|
783
|
+
useEffect(() => () => {
|
|
784
|
+
if (ground === null)
|
|
785
|
+
return;
|
|
786
|
+
for (const geometry of Object.values(ground))
|
|
787
|
+
geometry?.dispose();
|
|
788
|
+
}, [ground]);
|
|
789
|
+
// --- Bridges: styled decks, railings/trusses, piers and abutments. ---
|
|
790
|
+
const bridges = useMemo(() => {
|
|
791
|
+
if (resolved === null || resolved.bridges.length === 0)
|
|
792
|
+
return null;
|
|
793
|
+
const decks = new MeshAccumulator();
|
|
794
|
+
const concrete = [];
|
|
795
|
+
const steel = [];
|
|
796
|
+
const up = new THREE.Vector3(0, 1, 0);
|
|
797
|
+
const xAxis = new THREE.Vector3(1, 0, 0);
|
|
798
|
+
const segmentBox = (a, b, thickness, height, into) => {
|
|
799
|
+
const dir = b.clone().sub(a);
|
|
800
|
+
const length = dir.length();
|
|
801
|
+
if (length < 0.05)
|
|
802
|
+
return;
|
|
803
|
+
const quat = new THREE.Quaternion().setFromUnitVectors(xAxis, dir.normalize());
|
|
804
|
+
into.push(new THREE.Matrix4().compose(a.clone().add(b).multiplyScalar(0.5), quat, new THREE.Vector3(length, height, thickness)));
|
|
805
|
+
};
|
|
806
|
+
for (const bridge of resolved.bridges) {
|
|
807
|
+
const first = bridge.points[0];
|
|
808
|
+
const last = bridge.points[bridge.points.length - 1];
|
|
809
|
+
const hA = sample(first[0], first[1]) + 0.35;
|
|
810
|
+
const hB = sample(last[0], last[1]) + 0.35;
|
|
811
|
+
const abx = last[0] - first[0];
|
|
812
|
+
const abz = last[1] - first[1];
|
|
813
|
+
const len2 = Math.max(1e-6, abx * abx + abz * abz);
|
|
814
|
+
const span = Math.sqrt(len2);
|
|
815
|
+
const deckY = (x, z) => {
|
|
816
|
+
const t = Math.max(0, Math.min(1, ((x - first[0]) * abx + (z - first[1]) * abz) / len2));
|
|
817
|
+
const arch = bridge.style === "arch" ? Math.sin(t * Math.PI) * Math.min(1.6, span * 0.03) : 0.25;
|
|
818
|
+
return hA + (hB - hA) * t + arch;
|
|
819
|
+
};
|
|
820
|
+
decks.addRibbon(bridge.points, bridge.width + 1.2, deckY, 0.12);
|
|
821
|
+
// Curb strips give the deck visible thickness from the side.
|
|
822
|
+
const tangent = [abx / span, abz / span];
|
|
823
|
+
const normal = [-tangent[1], tangent[0]];
|
|
824
|
+
const halfW = bridge.width / 2 + 0.55;
|
|
825
|
+
const postSpacing = 2.6;
|
|
826
|
+
for (const side of [1, -1]) {
|
|
827
|
+
let prevTop = null;
|
|
828
|
+
let prevBase = null;
|
|
829
|
+
for (let dist = 0; dist <= span; dist += postSpacing) {
|
|
830
|
+
const t = dist / span;
|
|
831
|
+
const x = first[0] + abx * t + normal[0] * halfW * side;
|
|
832
|
+
const z = first[1] + abz * t + normal[1] * halfW * side;
|
|
833
|
+
const y = deckY(x - normal[0] * halfW * side, z - normal[1] * halfW * side) + 0.12;
|
|
834
|
+
const base = new THREE.Vector3(x, y, z);
|
|
835
|
+
const postH = bridge.style === "truss" ? 2.7 : 1.05;
|
|
836
|
+
steel.push(new THREE.Matrix4().compose(new THREE.Vector3(x, y + postH / 2, z), new THREE.Quaternion(), new THREE.Vector3(0.18, postH, 0.18)));
|
|
837
|
+
const top = new THREE.Vector3(x, y + postH, z);
|
|
838
|
+
if (prevTop !== null && prevBase !== null) {
|
|
839
|
+
segmentBox(prevTop, top, 0.13, 0.16, steel);
|
|
840
|
+
if (bridge.style === "truss") {
|
|
841
|
+
segmentBox(prevBase, top, 0.09, 0.09, steel);
|
|
842
|
+
segmentBox(prevTop, base, 0.09, 0.09, steel);
|
|
843
|
+
}
|
|
844
|
+
else {
|
|
845
|
+
// Mid rail for arch/beam parapets.
|
|
846
|
+
const midA = prevBase.clone().lerp(prevTop, 0.55);
|
|
847
|
+
const midB = base.clone().lerp(top, 0.55);
|
|
848
|
+
segmentBox(midA, midB, 0.08, 0.08, steel);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
prevTop = top;
|
|
852
|
+
prevBase = base;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
// Piers + bank abutments.
|
|
856
|
+
const pierCount = Math.max(0, Math.floor(span / 16));
|
|
857
|
+
for (let p = 1; p <= pierCount; p += 1) {
|
|
858
|
+
const t = p / (pierCount + 1);
|
|
859
|
+
const x = first[0] + abx * t;
|
|
860
|
+
const z = first[1] + abz * t;
|
|
861
|
+
const top = deckY(x, z);
|
|
862
|
+
const bottom = sample(x, z) - 1.5;
|
|
863
|
+
concrete.push(new THREE.Matrix4().compose(new THREE.Vector3(x, (top + bottom) / 2, z), new THREE.Quaternion().setFromAxisAngle(up, Math.atan2(tangent[0], tangent[1])), new THREE.Vector3(1.4, Math.max(0.5, top - bottom), bridge.width * 0.7)));
|
|
864
|
+
}
|
|
865
|
+
for (const [ex, ez] of [first, last]) {
|
|
866
|
+
const groundY = sample(ex, ez);
|
|
867
|
+
concrete.push(new THREE.Matrix4().compose(new THREE.Vector3(ex, groundY - 0.6 + 0.9, ez), new THREE.Quaternion().setFromAxisAngle(up, Math.atan2(tangent[0], tangent[1])), new THREE.Vector3(2.4, 1.8, bridge.width + 2)));
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return { deck: decks.build(), concrete, steel };
|
|
871
|
+
}, [resolved, sample]);
|
|
872
|
+
useEffect(() => () => bridges?.deck?.dispose(), [bridges]);
|
|
873
|
+
// --- Tunnels: a bore roof over the road, side walls, and portal lintels at each mouth. ---
|
|
874
|
+
const tunnels = useMemo(() => {
|
|
875
|
+
if (resolved === null || resolved.tunnels.length === 0)
|
|
876
|
+
return null;
|
|
877
|
+
const roofs = new MeshAccumulator();
|
|
878
|
+
const stone = [];
|
|
879
|
+
const up = new THREE.Vector3(0, 1, 0);
|
|
880
|
+
const clearance = 5.5;
|
|
881
|
+
for (const tunnel of resolved.tunnels) {
|
|
882
|
+
const first = tunnel.points[0];
|
|
883
|
+
const last = tunnel.points[tunnel.points.length - 1];
|
|
884
|
+
const abx = last[0] - first[0];
|
|
885
|
+
const abz = last[1] - first[1];
|
|
886
|
+
const span = Math.hypot(abx, abz) || 1;
|
|
887
|
+
const tangent = [abx / span, abz / span];
|
|
888
|
+
const normal = [-tangent[1], tangent[0]];
|
|
889
|
+
const floor = tunnel.bankHeight;
|
|
890
|
+
const yaw = Math.atan2(tangent[0], tangent[1]);
|
|
891
|
+
// Roof slab held flat over the road at head clearance.
|
|
892
|
+
roofs.addRibbon(tunnel.points, tunnel.width + 1.2, () => floor + clearance, 0);
|
|
893
|
+
// Side walls running the length of the bore.
|
|
894
|
+
const halfW = tunnel.width / 2 + 0.5;
|
|
895
|
+
for (const side of [1, -1]) {
|
|
896
|
+
const wx = (first[0] + last[0]) / 2 + normal[0] * halfW * side;
|
|
897
|
+
const wz = (first[1] + last[1]) / 2 + normal[1] * halfW * side;
|
|
898
|
+
stone.push(new THREE.Matrix4().compose(new THREE.Vector3(wx, floor + clearance / 2, wz), new THREE.Quaternion().setFromAxisAngle(up, yaw), new THREE.Vector3(0.6, clearance, span)));
|
|
899
|
+
}
|
|
900
|
+
// Portal lintel across each mouth so the entrances read as tunnels from the air and the road.
|
|
901
|
+
for (const [ex, ez] of [first, last]) {
|
|
902
|
+
stone.push(new THREE.Matrix4().compose(new THREE.Vector3(ex, floor + clearance + 0.5, ez), new THREE.Quaternion().setFromAxisAngle(up, yaw), new THREE.Vector3(tunnel.width + 2.4, 1.2, 1.2)));
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
return { roof: roofs.build(), stone };
|
|
906
|
+
}, [resolved, sample]);
|
|
907
|
+
useEffect(() => () => tunnels?.roof?.dispose(), [tunnels]);
|
|
908
|
+
// --- Instanced furniture: trees per species, street lights, hedges. ---
|
|
909
|
+
const furniture = useMemo(() => {
|
|
910
|
+
if (resolved === null)
|
|
911
|
+
return null;
|
|
912
|
+
const meshes = [];
|
|
913
|
+
const disposables = [];
|
|
914
|
+
const bySpecies = new Map();
|
|
915
|
+
for (const tree of resolved.trees) {
|
|
916
|
+
const bucket = bySpecies.get(tree.species);
|
|
917
|
+
if (bucket === undefined)
|
|
918
|
+
bySpecies.set(tree.species, [tree]);
|
|
919
|
+
else
|
|
920
|
+
bucket.push(tree);
|
|
921
|
+
}
|
|
922
|
+
const color = new THREE.Color();
|
|
923
|
+
for (const [species, trees] of bySpecies) {
|
|
924
|
+
const geometry = buildScatterProxy(SPECIES_PROXY[species]);
|
|
925
|
+
const material = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.85, metalness: 0 });
|
|
926
|
+
const mesh = new THREE.InstancedMesh(geometry, material, trees.length);
|
|
927
|
+
const matrix = new THREE.Matrix4();
|
|
928
|
+
trees.forEach((tree, i) => {
|
|
929
|
+
const scale = tree.scale * SPECIES_SCALE[species];
|
|
930
|
+
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));
|
|
931
|
+
mesh.setMatrixAt(i, matrix);
|
|
932
|
+
color.setScalar(0.82 + tree.jitter * 0.36);
|
|
933
|
+
mesh.setColorAt(i, color);
|
|
934
|
+
});
|
|
935
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
936
|
+
if (mesh.instanceColor !== null)
|
|
937
|
+
mesh.instanceColor.needsUpdate = true;
|
|
938
|
+
mesh.castShadow = true;
|
|
939
|
+
mesh.receiveShadow = true;
|
|
940
|
+
mesh.frustumCulled = false;
|
|
941
|
+
meshes.push(mesh);
|
|
942
|
+
disposables.push(geometry, material);
|
|
943
|
+
}
|
|
944
|
+
if (resolved.lights.length > 0) {
|
|
945
|
+
const units = unitGeometries();
|
|
946
|
+
const poleMaterial = new THREE.MeshStandardMaterial({ color: "#3a3d42", roughness: 0.6, metalness: 0.5 });
|
|
947
|
+
const headMaterial = new THREE.MeshStandardMaterial({ color: "#ffe9b0", emissive: "#ffdf8a", emissiveIntensity: 0.9, roughness: 0.4 });
|
|
948
|
+
const poles = new THREE.InstancedMesh(units.cylinder, poleMaterial, resolved.lights.length);
|
|
949
|
+
const arms = new THREE.InstancedMesh(units.box, poleMaterial, resolved.lights.length);
|
|
950
|
+
const heads = new THREE.InstancedMesh(units.box, headMaterial, resolved.lights.length);
|
|
951
|
+
const matrix = new THREE.Matrix4();
|
|
952
|
+
const quat = new THREE.Quaternion();
|
|
953
|
+
const upAxis = new THREE.Vector3(0, 1, 0);
|
|
954
|
+
resolved.lights.forEach((light, i) => {
|
|
955
|
+
const y = sample(light.x, light.z);
|
|
956
|
+
const dir = [Math.sin(light.heading), Math.cos(light.heading)];
|
|
957
|
+
quat.setFromAxisAngle(upAxis, light.heading);
|
|
958
|
+
matrix.compose(new THREE.Vector3(light.x, y, light.z), quat, new THREE.Vector3(0.16, 4.9, 0.16));
|
|
959
|
+
poles.setMatrixAt(i, matrix);
|
|
960
|
+
matrix.compose(new THREE.Vector3(light.x + dir[0] * 0.7, y + 4.85, light.z + dir[1] * 0.7), quat, new THREE.Vector3(0.12, 0.12, 1.5));
|
|
961
|
+
arms.setMatrixAt(i, matrix);
|
|
962
|
+
matrix.compose(new THREE.Vector3(light.x + dir[0] * 1.35, y + 4.72, light.z + dir[1] * 1.35), quat, new THREE.Vector3(0.34, 0.18, 0.7));
|
|
963
|
+
heads.setMatrixAt(i, matrix);
|
|
964
|
+
});
|
|
965
|
+
for (const mesh of [poles, arms, heads]) {
|
|
966
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
967
|
+
mesh.castShadow = true;
|
|
968
|
+
mesh.frustumCulled = false;
|
|
969
|
+
meshes.push(mesh);
|
|
970
|
+
}
|
|
971
|
+
disposables.push(poleMaterial, headMaterial);
|
|
972
|
+
}
|
|
973
|
+
if (resolved.hedges.length > 0) {
|
|
974
|
+
const units = unitGeometries();
|
|
975
|
+
const hedgeMaterial = new THREE.MeshStandardMaterial({ color: HEDGE_COLOR, roughness: 0.9, metalness: 0 });
|
|
976
|
+
const hedges = new THREE.InstancedMesh(units.box, hedgeMaterial, resolved.hedges.length);
|
|
977
|
+
const matrix = new THREE.Matrix4();
|
|
978
|
+
const quat = new THREE.Quaternion();
|
|
979
|
+
const upAxis = new THREE.Vector3(0, 1, 0);
|
|
980
|
+
const color2 = new THREE.Color();
|
|
981
|
+
resolved.hedges.forEach((hedge, i) => {
|
|
982
|
+
const y = sample(hedge.center[0], hedge.center[1]);
|
|
983
|
+
quat.setFromAxisAngle(upAxis, hedge.rotationY);
|
|
984
|
+
matrix.compose(new THREE.Vector3(hedge.center[0], y + 0.62, hedge.center[1]), quat, new THREE.Vector3(Math.max(0.4, hedge.size[0]), 1.65, hedge.size[1]));
|
|
985
|
+
hedges.setMatrixAt(i, matrix);
|
|
986
|
+
color2.setScalar(0.85 + ((i * 37) % 10) * 0.03);
|
|
987
|
+
hedges.setColorAt(i, color2);
|
|
988
|
+
});
|
|
989
|
+
hedges.instanceMatrix.needsUpdate = true;
|
|
990
|
+
if (hedges.instanceColor !== null)
|
|
991
|
+
hedges.instanceColor.needsUpdate = true;
|
|
992
|
+
hedges.castShadow = true;
|
|
993
|
+
hedges.receiveShadow = true;
|
|
994
|
+
hedges.frustumCulled = false;
|
|
995
|
+
meshes.push(hedges);
|
|
996
|
+
disposables.push(hedgeMaterial);
|
|
997
|
+
}
|
|
998
|
+
return { meshes, disposables };
|
|
999
|
+
}, [resolved, sample]);
|
|
1000
|
+
useEffect(() => () => {
|
|
1001
|
+
if (furniture === null)
|
|
1002
|
+
return;
|
|
1003
|
+
for (const disposable of furniture.disposables)
|
|
1004
|
+
disposable.dispose();
|
|
1005
|
+
}, [furniture]);
|
|
1006
|
+
if (resolved === null)
|
|
1007
|
+
return null;
|
|
1008
|
+
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, children: _jsx("meshStandardMaterial", { color: MARKING_COLOR, roughness: 0.8, metalness: 0, side: THREE.DoubleSide }) })) : 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 })] }));
|
|
1009
|
+
}
|
|
1010
|
+
/** One instanced unit-box mesh from precomposed matrices (bridge parts). */
|
|
1011
|
+
function InstancedBoxes({ matrices, color }) {
|
|
1012
|
+
const mesh = useMemo(() => {
|
|
1013
|
+
const units = unitGeometries();
|
|
1014
|
+
const material = new THREE.MeshStandardMaterial({ color, roughness: 0.8, metalness: 0.15 });
|
|
1015
|
+
const instanced = new THREE.InstancedMesh(units.box, material, matrices.length);
|
|
1016
|
+
matrices.forEach((matrix, i) => instanced.setMatrixAt(i, matrix));
|
|
1017
|
+
instanced.instanceMatrix.needsUpdate = true;
|
|
1018
|
+
instanced.castShadow = true;
|
|
1019
|
+
instanced.receiveShadow = true;
|
|
1020
|
+
instanced.frustumCulled = false;
|
|
1021
|
+
return instanced;
|
|
1022
|
+
}, [matrices, color]);
|
|
1023
|
+
useEffect(() => () => {
|
|
1024
|
+
mesh.material.dispose();
|
|
1025
|
+
}, [mesh]);
|
|
1026
|
+
return _jsx("primitive", { object: mesh });
|
|
1027
|
+
}
|
|
1028
|
+
/** Registers the `city` volume runtime renderer. Called by `registerBuiltinSceneKindRenderers`. @internal */
|
|
1029
|
+
export function registerCityRenderer() {
|
|
1030
|
+
registerSceneKindRenderer(CITY_KIND, ({ objects, context }) => (_jsx(_Fragment, { children: objects.map((object) => (_jsx(OneCity, { object: object, context: context }, object.id))) })));
|
|
1031
|
+
}
|