@unson/brainbase-mcp 0.15.5 → 0.15.7
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/package.json +2 -2
- package/ui/world/world-placement.js +40 -0
- package/ui/world/world-view.js +771 -78
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unson/brainbase-mcp",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.7",
|
|
4
4
|
"description": "Local-first Brainbase judgment OS that lets supported AI agents reuse a user's approved goals, priorities, decisions, and judgment criteria.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -358,5 +358,5 @@
|
|
|
358
358
|
"engines": {
|
|
359
359
|
"node": ">=20"
|
|
360
360
|
},
|
|
361
|
-
"gitHead": "
|
|
361
|
+
"gitHead": "9d594a6e3267d90308404d606a087cbd6191baec"
|
|
362
362
|
}
|
|
@@ -93,3 +93,43 @@ export function cityMeasures(business, { isFinished, judgments = [], now = Date.
|
|
|
93
93
|
lit: recent > 0,
|
|
94
94
|
};
|
|
95
95
|
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The sky for an hour of the day (scenery only; it represents no data). `mode` 'auto' follows the hour;
|
|
99
|
+
* 'day', 'dusk' and 'night' fix it. At night lit windows and street lamps glow the most, so the cities
|
|
100
|
+
* that moved lately read best then.
|
|
101
|
+
*/
|
|
102
|
+
export function skyAt(hour, mode = 'auto') {
|
|
103
|
+
const phase = mode !== 'auto' ? mode
|
|
104
|
+
: hour >= 5 && hour < 7 ? 'dawn'
|
|
105
|
+
: hour >= 7 && hour < 17 ? 'day'
|
|
106
|
+
: hour >= 17 && hour < 19 ? 'dusk'
|
|
107
|
+
: 'night';
|
|
108
|
+
const skies = {
|
|
109
|
+
dawn: { top: '#c9d6ef', middle: '#f3d9c4', bottom: '#f6ead8', fog: 0xf1e2d2, sun: 0xffd2a8, sunIntensity: 1.6, hemi: 0.95, exposure: 1.0, glow: 1.1, lamps: true, stars: false },
|
|
110
|
+
day: { top: '#bfdcf0', middle: '#e3f0f2', bottom: '#f4f1e6', fog: 0xe9f1ee, sun: 0xfff1dc, sunIntensity: 2.7, hemi: 1.25, exposure: 1.05, glow: 0.6, lamps: false, stars: false },
|
|
111
|
+
dusk: { top: '#5d6f9e', middle: '#e7a77e', bottom: '#f3cfa4', fog: 0xe6bf9c, sun: 0xffb27a, sunIntensity: 1.3, hemi: 0.75, exposure: 0.95, glow: 1.5, lamps: true, stars: false },
|
|
112
|
+
night: { top: '#0d1730', middle: '#1c2b4c', bottom: '#33456a', fog: 0x2a3a5a, sun: 0x9fb4ff, sunIntensity: 0.35, hemi: 0.35, exposure: 0.9, glow: 2.4, lamps: true, stars: true },
|
|
113
|
+
};
|
|
114
|
+
return { phase, ...skies[phase] };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* What moved since the viewer last opened the world: cities whose newest decision or a placed judgment
|
|
119
|
+
* is newer than `lastVisit`, and plaza buildings whose newest judgment is. Null on a first visit.
|
|
120
|
+
*/
|
|
121
|
+
export function changesSince(lastVisit, businesses, judgmentsByBusiness, rows) {
|
|
122
|
+
const since = Date.parse(lastVisit ?? '');
|
|
123
|
+
if (!Number.isFinite(since)) return null;
|
|
124
|
+
const newer = (at) => {
|
|
125
|
+
const time = Date.parse(at ?? '');
|
|
126
|
+
return Number.isFinite(time) && time > since;
|
|
127
|
+
};
|
|
128
|
+
const cities = [];
|
|
129
|
+
for (const business of businesses ?? []) {
|
|
130
|
+
const judgments = judgmentsByBusiness?.get?.(business.code) ?? [];
|
|
131
|
+
if (newer(business.activity?.latest_decision_at) || judgments.some((entry) => newer(entry?.item?.proof?.recorded_at))) cities.push(business.code);
|
|
132
|
+
}
|
|
133
|
+
const plaza = (rows ?? []).filter((row) => newer(row.latest_recorded_at)).map((row) => row.key);
|
|
134
|
+
return { since: new Date(since).toISOString(), cities, plaza };
|
|
135
|
+
}
|
package/ui/world/world-view.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { THREE, MapControls } from './world-vendor.js';
|
|
18
|
-
import { cityMeasures, districtLots, groupJudgmentPlaces, UNPLACED_REASON_TEXT } from './world-placement.js';
|
|
18
|
+
import { changesSince, cityMeasures, districtLots, groupJudgmentPlaces, skyAt, UNPLACED_REASON_TEXT } from './world-placement.js';
|
|
19
19
|
import {
|
|
20
20
|
makeWorkspaceElement as el,
|
|
21
21
|
workspacePageHeader,
|
|
@@ -182,18 +182,154 @@ function canvasTexture(doc, width, height, draw) {
|
|
|
182
182
|
return texture;
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
185
|
+
/** Smooth value noise in [0, 1]; used only to shade scenery (ground, sea, mountains) without repeating textures. */
|
|
186
|
+
function valueNoise(x, z, seed = 'n') {
|
|
187
|
+
const xi = Math.floor(x);
|
|
188
|
+
const zi = Math.floor(z);
|
|
189
|
+
const fx = x - xi;
|
|
190
|
+
const fz = z - zi;
|
|
191
|
+
const sx = fx * fx * (3 - 2 * fx);
|
|
192
|
+
const sz = fz * fz * (3 - 2 * fz);
|
|
193
|
+
const at = (i, j) => hashUnit(`${seed}${i},${j}`);
|
|
194
|
+
const near = at(xi, zi) + (at(xi + 1, zi) - at(xi, zi)) * sx;
|
|
195
|
+
const far = at(xi, zi + 1) + (at(xi + 1, zi + 1) - at(xi, zi + 1)) * sx;
|
|
196
|
+
return near + (far - near) * sz;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Colours a geometry per vertex from its position (x, y, z before any rotation). */
|
|
200
|
+
function paintVertices(geometry, colorAt) {
|
|
201
|
+
const position = geometry.attributes.position;
|
|
202
|
+
const colors = new Float32Array(position.count * 3);
|
|
203
|
+
const color = new THREE.Color();
|
|
204
|
+
for (let i = 0; i < position.count; i += 1) {
|
|
205
|
+
colorAt(color, position.getX(i), position.getY(i), position.getZ(i));
|
|
206
|
+
colors[i * 3] = color.r;
|
|
207
|
+
colors[i * 3 + 1] = color.g;
|
|
208
|
+
colors[i * 3 + 2] = color.b;
|
|
209
|
+
}
|
|
210
|
+
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
|
211
|
+
return geometry;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/*
|
|
215
|
+
* Facades: one tile of a wall, drawn for a style and a wall colour. Windows are on the walls only (roofs
|
|
216
|
+
* get a plain material). Which panes are lit is fixed per pane, so the glow map matches the colour map;
|
|
217
|
+
* whether a building has lit windows at all still means "moved in the last 30 days".
|
|
218
|
+
*/
|
|
219
|
+
const FACADE_UNITS = Object.freeze({ glass: [2.0, 2.3], office: [3.0, 2.0], brick: [1.3, 1.35], civic: [0.85, 1.5], research: [0.9, 1.2], house: [0, 0] });
|
|
220
|
+
|
|
221
|
+
/** Tile size in pixels: glass and office tiles hold several floors so lit panes do not repeat as a pattern. */
|
|
222
|
+
const FACADE_SIZES = Object.freeze({ glass: 256, office: 256 });
|
|
223
|
+
|
|
224
|
+
function drawFacade(ctx, w, h, style, wall, lit, glowOnly) {
|
|
225
|
+
const wallColor = `#${new THREE.Color(wall).getHexString()}`;
|
|
226
|
+
const dark = `#${new THREE.Color(wall).multiplyScalar(0.72).getHexString()}`;
|
|
227
|
+
const light = `#${new THREE.Color(wall).lerp(new THREE.Color(0xffffff), 0.35).getHexString()}`;
|
|
228
|
+
ctx.fillStyle = glowOnly ? '#000000' : wallColor;
|
|
229
|
+
ctx.fillRect(0, 0, w, h);
|
|
230
|
+
const pane = (x, y, pw, ph, index) => {
|
|
231
|
+
const on = lit && hashUnit(`${style}pane${index}x`) < 0.55;
|
|
232
|
+
if (glowOnly) {
|
|
233
|
+
ctx.fillStyle = on ? '#ffffff' : '#000000';
|
|
234
|
+
ctx.fillRect(x, y, pw, ph);
|
|
235
|
+
return;
|
|
192
236
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
237
|
+
if (on) {
|
|
238
|
+
ctx.fillStyle = '#ffd98c';
|
|
239
|
+
ctx.fillRect(x, y, pw, ph);
|
|
240
|
+
ctx.fillStyle = 'rgba(255,244,214,0.55)';
|
|
241
|
+
ctx.fillRect(x, y, pw, ph * 0.3);
|
|
242
|
+
} else {
|
|
243
|
+
const glass = ctx.createLinearGradient(x, y, x + pw, y + ph);
|
|
244
|
+
glass.addColorStop(0, '#9fb3c4');
|
|
245
|
+
glass.addColorStop(0.5, '#56687a');
|
|
246
|
+
glass.addColorStop(1, '#3f4f60');
|
|
247
|
+
ctx.fillStyle = glass;
|
|
248
|
+
ctx.fillRect(x, y, pw, ph);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
if (style === 'glass') {
|
|
252
|
+
// A curtain wall: four floors of four panes, thin mullions, a floor band under each floor.
|
|
253
|
+
const cw = w / 4;
|
|
254
|
+
const rh = h / 4;
|
|
255
|
+
for (let r = 0; r < 4; r += 1) {
|
|
256
|
+
if (!glowOnly) {
|
|
257
|
+
ctx.fillStyle = dark;
|
|
258
|
+
ctx.fillRect(0, r * rh + rh - 7, w, 7);
|
|
259
|
+
}
|
|
260
|
+
for (let c = 0; c < 4; c += 1) pane(c * cw + 4, r * rh + 5, cw - 8, rh - 15, r * 4 + c);
|
|
261
|
+
}
|
|
262
|
+
if (!glowOnly) {
|
|
263
|
+
ctx.fillStyle = light;
|
|
264
|
+
for (let c = 0; c <= 4; c += 1) ctx.fillRect(c * cw - 2, 0, 4, h);
|
|
265
|
+
}
|
|
266
|
+
} else if (style === 'office') {
|
|
267
|
+
// Ribbon windows: two floors, each a long band split by mullions over a concrete spandrel.
|
|
268
|
+
const rh = h / 2;
|
|
269
|
+
for (let r = 0; r < 2; r += 1) {
|
|
270
|
+
if (!glowOnly) {
|
|
271
|
+
ctx.fillStyle = light;
|
|
272
|
+
ctx.fillRect(0, r * rh, w, 22);
|
|
273
|
+
ctx.fillStyle = dark;
|
|
274
|
+
ctx.fillRect(0, r * rh + rh - 10, w, 6);
|
|
275
|
+
}
|
|
276
|
+
for (let c = 0; c < 8; c += 1) pane(4 + c * (w / 8), r * rh + 34, w / 8 - 6, rh - 54, r * 8 + c);
|
|
277
|
+
}
|
|
278
|
+
} else if (style === 'brick') {
|
|
279
|
+
if (!glowOnly) {
|
|
280
|
+
ctx.strokeStyle = 'rgba(0,0,0,0.07)';
|
|
281
|
+
ctx.lineWidth = 2;
|
|
282
|
+
for (let y = 16; y < h; y += 16) {
|
|
283
|
+
ctx.beginPath();
|
|
284
|
+
ctx.moveTo(0, y);
|
|
285
|
+
ctx.lineTo(w, y);
|
|
286
|
+
ctx.stroke();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
for (let c = 0; c < 2; c += 1) {
|
|
290
|
+
const x = 18 + c * 58;
|
|
291
|
+
if (!glowOnly) {
|
|
292
|
+
ctx.fillStyle = '#f5f1e8';
|
|
293
|
+
ctx.fillRect(x - 5, 22, 44, 82);
|
|
294
|
+
}
|
|
295
|
+
pane(x, 26, 34, 74, c);
|
|
296
|
+
if (!glowOnly) {
|
|
297
|
+
ctx.fillStyle = '#f5f1e8';
|
|
298
|
+
ctx.fillRect(x + 15, 26, 4, 74);
|
|
299
|
+
ctx.fillRect(x, 58, 34, 4);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
} else if (style === 'civic') {
|
|
303
|
+
// Pilasters and tall windows.
|
|
304
|
+
if (!glowOnly) {
|
|
305
|
+
ctx.fillStyle = light;
|
|
306
|
+
ctx.fillRect(0, 0, 18, h);
|
|
307
|
+
ctx.fillRect(w - 18, 0, 18, h);
|
|
308
|
+
}
|
|
309
|
+
pane(30, 14, 68, 96, 0);
|
|
310
|
+
if (!glowOnly) {
|
|
311
|
+
ctx.fillStyle = light;
|
|
312
|
+
ctx.fillRect(62, 14, 4, 96);
|
|
313
|
+
}
|
|
314
|
+
} else if (style === 'research') {
|
|
315
|
+
for (let c = 0; c < 2; c += 1) pane(20 + c * 60, 18, 28, 90, c);
|
|
316
|
+
} else if (style === 'house') {
|
|
317
|
+
// A plastered wall with two framed windows (doors are separate meshes).
|
|
318
|
+
for (let c = 0; c < 2; c += 1) {
|
|
319
|
+
const x = 22 + c * 52;
|
|
320
|
+
if (!glowOnly) {
|
|
321
|
+
ctx.fillStyle = '#ffffff';
|
|
322
|
+
ctx.fillRect(x - 4, 36, 40, 50);
|
|
323
|
+
}
|
|
324
|
+
pane(x, 40, 32, 42, c);
|
|
325
|
+
if (!glowOnly) {
|
|
326
|
+
ctx.fillStyle = '#ffffff';
|
|
327
|
+
ctx.fillRect(x + 14, 40, 4, 42);
|
|
328
|
+
ctx.fillStyle = dark;
|
|
329
|
+
ctx.fillRect(x - 6, 86, 44, 5);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
197
333
|
}
|
|
198
334
|
|
|
199
335
|
function roundedPlate(width, depth, height, radius) {
|
|
@@ -242,15 +378,29 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
242
378
|
stage.prepend(renderer.domElement);
|
|
243
379
|
|
|
244
380
|
const scene = new THREE.Scene();
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
381
|
+
// Scenery (sky, sea, land, mountains, trees, lamps) represents no data: it only sets the place.
|
|
382
|
+
let skyMode = 'auto';
|
|
383
|
+
let sky = skyAt(new Date().getHours(), skyMode);
|
|
384
|
+
function skyTexture(current) {
|
|
385
|
+
return canvasTexture(doc, 256, 256, (ctx, w, h) => {
|
|
386
|
+
const gradient = ctx.createLinearGradient(0, 0, 0, h);
|
|
387
|
+
gradient.addColorStop(0, current.top);
|
|
388
|
+
gradient.addColorStop(0.55, current.middle);
|
|
389
|
+
gradient.addColorStop(1, current.bottom);
|
|
390
|
+
ctx.fillStyle = gradient;
|
|
391
|
+
ctx.fillRect(0, 0, w, h);
|
|
392
|
+
if (current.stars) {
|
|
393
|
+
ctx.fillStyle = 'rgba(255,255,255,0.85)';
|
|
394
|
+
for (let i = 0; i < 90; i += 1) {
|
|
395
|
+
const sx = hashUnit(`star${i}x`) * w;
|
|
396
|
+
const sy = hashUnit(`star${i}y`) * h * 0.55;
|
|
397
|
+
ctx.fillRect(sx, sy, hashUnit(`star${i}s`) > 0.85 ? 1.6 : 0.9, hashUnit(`star${i}s`) > 0.85 ? 1.6 : 0.9);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
scene.background = skyTexture(sky);
|
|
403
|
+
scene.fog = new THREE.Fog(sky.fog, 170, 340);
|
|
254
404
|
|
|
255
405
|
const camera = new THREE.OrthographicCamera(-50, 50, 50, -50, 0.1, 1000);
|
|
256
406
|
const home = { position: new THREE.Vector3(90, 95, 90), target: new THREE.Vector3(0, 0, 0), zoom: 1 };
|
|
@@ -267,26 +417,27 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
267
417
|
controls.minPolarAngle = Math.PI / 6;
|
|
268
418
|
controls.target.copy(home.target);
|
|
269
419
|
|
|
270
|
-
|
|
271
|
-
|
|
420
|
+
const hemisphere = new THREE.HemisphereLight(0xf4f8ff, 0xd8ccb2, sky.hemi);
|
|
421
|
+
scene.add(hemisphere);
|
|
422
|
+
const sun = new THREE.DirectionalLight(sky.sun, sky.sunIntensity);
|
|
272
423
|
sun.position.set(70, 110, 40);
|
|
273
424
|
sun.castShadow = true;
|
|
274
425
|
sun.shadow.mapSize.set(2048, 2048);
|
|
275
426
|
sun.shadow.radius = 4;
|
|
276
427
|
sun.shadow.bias = -0.0004;
|
|
428
|
+
sun.shadow.normalBias = 0.03;
|
|
277
429
|
Object.assign(sun.shadow.camera, { left: -150, right: 150, top: 150, bottom: -150, near: 1, far: 420 });
|
|
278
430
|
scene.add(sun);
|
|
279
431
|
const fill = new THREE.DirectionalLight(0xdfe9ff, 0.6);
|
|
280
432
|
fill.position.set(-80, 60, -60);
|
|
281
433
|
scene.add(fill);
|
|
282
434
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
435
|
+
// Lit windows and street lamps follow the sky (brightest at night); their meaning does not change.
|
|
436
|
+
const litMaterials = new Set();
|
|
437
|
+
const lampMaterials = [];
|
|
438
|
+
const animated = { water: null, jets: [], sparkles: [], clouds: [], foam: null };
|
|
439
|
+
const maxAnisotropy = renderer.capabilities.getMaxAnisotropy?.() ?? 1;
|
|
288
440
|
|
|
289
|
-
const textures = { lit: windowTexture(doc, true), unlit: windowTexture(doc, false), glow: windowTexture(doc, true, true) };
|
|
290
441
|
const pickables = [];
|
|
291
442
|
const labels = [];
|
|
292
443
|
const beacons = [];
|
|
@@ -313,51 +464,381 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
313
464
|
return new THREE.MeshStandardMaterial({ color, roughness: 0.8, metalness: 0.03, ...options });
|
|
314
465
|
}
|
|
315
466
|
|
|
316
|
-
|
|
317
|
-
|
|
467
|
+
const facadeCache = new Map();
|
|
468
|
+
function facadeTiles(style, wall, lit) {
|
|
469
|
+
const key = `${style}|${new THREE.Color(wall).getHexString()}|${lit}`;
|
|
470
|
+
if (!facadeCache.has(key)) {
|
|
471
|
+
const make = (glowOnly) => {
|
|
472
|
+
const size = FACADE_SIZES[style] ?? 128;
|
|
473
|
+
const texture = canvasTexture(doc, size, size, (ctx, w, h) => drawFacade(ctx, w, h, style, wall, lit, glowOnly));
|
|
474
|
+
texture.wrapS = THREE.RepeatWrapping;
|
|
475
|
+
texture.wrapT = THREE.RepeatWrapping;
|
|
476
|
+
texture.anisotropy = maxAnisotropy;
|
|
477
|
+
return texture;
|
|
478
|
+
};
|
|
479
|
+
facadeCache.set(key, { map: make(false), glow: lit ? make(true) : null });
|
|
480
|
+
}
|
|
481
|
+
return facadeCache.get(key);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** A wall material for a face `span` wide and `h` tall: windows on the wall, glowing if the building is lit. */
|
|
485
|
+
function facadeMaterial(style, wall, lit, span, h, options = {}) {
|
|
486
|
+
const tiles = facadeTiles(style, wall, lit);
|
|
487
|
+
const [unitW, unitH] = FACADE_UNITS[style] ?? [1, 1];
|
|
488
|
+
const repeat = unitW === 0 ? [1, Math.max(1, Math.round(h / 1.1))] : [Math.max(1, Math.round(span / unitW)), Math.max(1, Math.round(h / unitH))];
|
|
489
|
+
const map = tiles.map.clone();
|
|
318
490
|
map.needsUpdate = true;
|
|
319
|
-
map.repeat.set(
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
491
|
+
map.repeat.set(...repeat);
|
|
492
|
+
const glass = style === 'glass';
|
|
493
|
+
const material = standard(0xffffff, { map, roughness: glass ? 0.32 : 0.85, metalness: glass ? 0.18 : 0.02, ...options });
|
|
494
|
+
if (tiles.glow) {
|
|
495
|
+
const glow = tiles.glow.clone();
|
|
323
496
|
glow.needsUpdate = true;
|
|
324
497
|
glow.repeat.copy(map.repeat);
|
|
325
498
|
material.emissive = new THREE.Color(0xffc867);
|
|
326
499
|
material.emissiveMap = glow;
|
|
327
|
-
material.emissiveIntensity =
|
|
500
|
+
material.emissiveIntensity = sky.glow;
|
|
501
|
+
litMaterials.add(material);
|
|
328
502
|
}
|
|
329
|
-
return
|
|
503
|
+
return material;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** A block with facades on its four walls and a plain roof; stands on y = 0 of its parent. */
|
|
507
|
+
function block(w, h, d, { style = 'office', wall = 0xe9e4da, lit = false, roof = 0xc9c5bb, ...options } = {}) {
|
|
508
|
+
const alongX = facadeMaterial(style, wall, lit, d, h, options);
|
|
509
|
+
const alongZ = facadeMaterial(style, wall, lit, w, h, options);
|
|
510
|
+
const top = standard(roof, { roughness: 0.95, ...options });
|
|
511
|
+
return mesh(new THREE.BoxGeometry(w, h, d), [alongX, alongX, top, top, alongZ, alongZ], { y: h / 2 });
|
|
330
512
|
}
|
|
331
513
|
|
|
332
514
|
function register(group, data) {
|
|
333
515
|
group.userData = data;
|
|
334
516
|
pickables.push(group);
|
|
335
|
-
const materials =
|
|
517
|
+
const materials = new Set();
|
|
336
518
|
group.traverse((child) => {
|
|
337
|
-
if (child.isMesh
|
|
519
|
+
if (!child.isMesh) return;
|
|
520
|
+
for (const material of Array.isArray(child.material) ? child.material : [child.material]) {
|
|
521
|
+
if (material?.emissive) materials.add(material);
|
|
522
|
+
}
|
|
338
523
|
});
|
|
339
|
-
highlightable.set(group, materials.map((material) => ({ material, base: material.emissive.clone(), intensity: material.emissiveIntensity })));
|
|
524
|
+
highlightable.set(group, [...materials].map((material) => ({ material, base: material.emissive.clone(), intensity: material.emissiveIntensity })));
|
|
340
525
|
}
|
|
341
526
|
|
|
527
|
+
const roadMaterials = {
|
|
528
|
+
asphalt: standard(0x8e938f, { roughness: 0.95 }),
|
|
529
|
+
walk: standard(0xe4dfd2, { roughness: 1 }),
|
|
530
|
+
};
|
|
531
|
+
const dashTexture = canvasTexture(doc, 8, 64, (ctx, w, h) => {
|
|
532
|
+
ctx.clearRect(0, 0, w, h);
|
|
533
|
+
ctx.fillStyle = '#ffffff';
|
|
534
|
+
ctx.fillRect(1, 0, w - 2, h * 0.55);
|
|
535
|
+
});
|
|
536
|
+
dashTexture.wrapT = THREE.RepeatWrapping;
|
|
537
|
+
dashTexture.anisotropy = maxAnisotropy;
|
|
342
538
|
function road(from, to, width = 1.9) {
|
|
343
539
|
const length = from.distanceTo(to);
|
|
344
540
|
const angle = -Math.atan2(to.z - from.z, to.x - from.x) + Math.PI / 2;
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
541
|
+
const at = { x: (from.x + to.x) / 2, z: (from.z + to.z) / 2 };
|
|
542
|
+
const strip = (stripWidth, material, y, offset = 0) => {
|
|
543
|
+
const surface = mesh(new THREE.PlaneGeometry(stripWidth, length), material, { x: at.x, y, z: at.z, shadow: false });
|
|
544
|
+
surface.rotation.set(-Math.PI / 2, 0, angle);
|
|
545
|
+
if (offset !== 0) surface.translateX(offset);
|
|
546
|
+
scene.add(surface);
|
|
547
|
+
};
|
|
548
|
+
// Sidewalks on both sides, asphalt between, a dashed centre line.
|
|
549
|
+
strip(width + 0.7, roadMaterials.walk, 0.025);
|
|
550
|
+
strip(width, roadMaterials.asphalt, 0.04);
|
|
551
|
+
const dashes = dashTexture.clone();
|
|
552
|
+
dashes.needsUpdate = true;
|
|
553
|
+
dashes.repeat.set(1, Math.max(1, Math.round(length / 1.6)));
|
|
554
|
+
strip(0.1, new THREE.MeshBasicMaterial({ map: dashes, transparent: true, opacity: 0.85, color: 0xf6f3e6 }), 0.055);
|
|
351
555
|
}
|
|
352
556
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
557
|
+
// Trees are many; they are drawn as instanced meshes (trunks, conifers, broadleaves) once the world is built.
|
|
558
|
+
const treePlacements = [];
|
|
559
|
+
function tree(x, z, scale = 1, y = 0) {
|
|
560
|
+
treePlacements.push([x, z, scale, y]);
|
|
561
|
+
}
|
|
562
|
+
function plantTrees() {
|
|
563
|
+
if (treePlacements.length === 0) return;
|
|
564
|
+
const conifers = [];
|
|
565
|
+
const broadleaves = [];
|
|
566
|
+
for (const placement of treePlacements) (hashUnit(`${placement[0]},${placement[1]}k`) < 0.42 ? conifers : broadleaves).push(placement);
|
|
567
|
+
const foliage = (color) => standard(color, { roughness: 0.9, flatShading: true });
|
|
568
|
+
const trunks = new THREE.InstancedMesh(new THREE.CylinderGeometry(0.1, 0.15, 0.8, 6), standard(0x7d6249, { roughness: 1 }), treePlacements.length);
|
|
569
|
+
const lower = new THREE.InstancedMesh(new THREE.ConeGeometry(0.78, 1.3, 7), foliage(0xffffff), Math.max(conifers.length, 1));
|
|
570
|
+
const upper = new THREE.InstancedMesh(new THREE.ConeGeometry(0.55, 1.05, 7), foliage(0xffffff), Math.max(conifers.length, 1));
|
|
571
|
+
const crowns = new THREE.InstancedMesh(new THREE.IcosahedronGeometry(0.72, 0), foliage(0xffffff), Math.max(broadleaves.length, 1));
|
|
572
|
+
const matrix = new THREE.Matrix4();
|
|
573
|
+
const rotation = new THREE.Quaternion();
|
|
574
|
+
const tint = new THREE.Color();
|
|
575
|
+
const up = new THREE.Vector3(0, 1, 0);
|
|
576
|
+
const place = (instanced, index, x, y, z, sx, sy, sz, spin) => {
|
|
577
|
+
rotation.setFromAxisAngle(up, spin);
|
|
578
|
+
matrix.compose(new THREE.Vector3(x, y, z), rotation, new THREE.Vector3(sx, sy, sz));
|
|
579
|
+
instanced.setMatrixAt(index, matrix);
|
|
580
|
+
};
|
|
581
|
+
treePlacements.forEach(([x, z, scale, y], index) => place(trunks, index, x, y + 0.4 * scale, z, scale, scale, scale, 0));
|
|
582
|
+
conifers.forEach(([x, z, scale, y], index) => {
|
|
583
|
+
const spin = hashUnit(`${x}${z}s`) * Math.PI;
|
|
584
|
+
const tall = 0.9 + hashUnit(`${z}${x}t`) * 0.35;
|
|
585
|
+
place(lower, index, x, y + 1.15 * scale * tall, z, scale, scale * tall, scale, spin);
|
|
586
|
+
place(upper, index, x, y + 1.85 * scale * tall, z, scale, scale * tall, scale, spin);
|
|
587
|
+
const shade = tint.setHSL(0.37 + (hashUnit(`${x},${z}`) - 0.5) * 0.04, 0.34, 0.3 + hashUnit(`${z},${x}`) * 0.08);
|
|
588
|
+
lower.setColorAt(index, shade);
|
|
589
|
+
upper.setColorAt(index, shade.offsetHSL(0, 0, 0.04));
|
|
590
|
+
});
|
|
591
|
+
broadleaves.forEach(([x, z, scale, y], index) => {
|
|
592
|
+
const squash = 0.85 + hashUnit(`${x}${z}q`) * 0.25;
|
|
593
|
+
place(crowns, index, x, y + 1.25 * scale, z, scale * squash, scale, scale * (1.9 - squash), hashUnit(`${x}${z}s`) * Math.PI);
|
|
594
|
+
crowns.setColorAt(index, tint.setHSL(0.24 + hashUnit(`${x},${z}h`) * 0.08, 0.38, 0.4 + hashUnit(`${z},${x}l`) * 0.12));
|
|
595
|
+
});
|
|
596
|
+
lower.count = conifers.length;
|
|
597
|
+
upper.count = conifers.length;
|
|
598
|
+
crowns.count = broadleaves.length;
|
|
599
|
+
for (const instanced of [trunks, lower, upper, crowns]) {
|
|
600
|
+
instanced.castShadow = true;
|
|
601
|
+
instanced.receiveShadow = true;
|
|
602
|
+
scene.add(instanced);
|
|
603
|
+
}
|
|
604
|
+
treePlacements.length = 0;
|
|
359
605
|
}
|
|
360
606
|
|
|
607
|
+
// --- scenery (no data) -----------------------------------------------------
|
|
608
|
+
function pavingTexture() {
|
|
609
|
+
const texture = canvasTexture(doc, 256, 256, (ctx, w, h) => {
|
|
610
|
+
ctx.fillStyle = '#f1ece1';
|
|
611
|
+
ctx.fillRect(0, 0, w, h);
|
|
612
|
+
ctx.strokeStyle = 'rgba(176,164,140,0.5)';
|
|
613
|
+
ctx.lineWidth = 1.5;
|
|
614
|
+
for (let r = 16; r < w / 2; r += 18) {
|
|
615
|
+
ctx.beginPath();
|
|
616
|
+
ctx.arc(w / 2, h / 2, r, 0, Math.PI * 2);
|
|
617
|
+
ctx.stroke();
|
|
618
|
+
}
|
|
619
|
+
for (let i = 0; i < 24; i += 1) {
|
|
620
|
+
const a = (i / 24) * Math.PI * 2;
|
|
621
|
+
ctx.beginPath();
|
|
622
|
+
ctx.moveTo(w / 2 + Math.cos(a) * 16, h / 2 + Math.sin(a) * 16);
|
|
623
|
+
ctx.lineTo(w / 2 + Math.cos(a) * w / 2, h / 2 + Math.sin(a) * h / 2);
|
|
624
|
+
ctx.stroke();
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
texture.anisotropy = maxAnisotropy;
|
|
628
|
+
return texture;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const grass = { low: new THREE.Color(0x9fbf8c), high: new THREE.Color(0xd3e4bd), warm: new THREE.Color(0xd9d6a6), sand: new THREE.Color(0xeadfc2) };
|
|
632
|
+
/** Land, beach, sea and a far shore of mountains around the cities; scenery only. */
|
|
633
|
+
function buildTerrain(extent) {
|
|
634
|
+
const landRadius = extent + 22;
|
|
635
|
+
// Land: gentle, non-repeating colour variation (a repeating texture shimmered into stripes).
|
|
636
|
+
const land = mesh(paintVertices(new THREE.RingGeometry(0.01, landRadius, 160, 48), (color, x, y) => {
|
|
637
|
+
const broad = valueNoise(x / 16, y / 16, 'land');
|
|
638
|
+
const fine = valueNoise(x / 4.5, y / 4.5, 'tuft');
|
|
639
|
+
color.copy(grass.low).lerp(grass.high, broad * 0.7 + fine * 0.3);
|
|
640
|
+
if (valueNoise(x / 22, y / 22, 'dry') > 0.68) color.lerp(grass.warm, 0.35);
|
|
641
|
+
const shore = Math.max(0, (Math.hypot(x, y) - (landRadius - 3)) / 3);
|
|
642
|
+
color.lerp(grass.sand, shore * 0.8);
|
|
643
|
+
}), standard(0xffffff, { vertexColors: true, roughness: 1 }), { shadow: false });
|
|
644
|
+
land.rotation.x = -Math.PI / 2;
|
|
645
|
+
land.receiveShadow = true;
|
|
646
|
+
scene.add(land);
|
|
647
|
+
// Beach sloping into the water.
|
|
648
|
+
const beachGeometry = new THREE.RingGeometry(landRadius, landRadius + 5, 160, 3);
|
|
649
|
+
const beachPositions = beachGeometry.attributes.position;
|
|
650
|
+
for (let i = 0; i < beachPositions.count; i += 1) {
|
|
651
|
+
const t = (Math.hypot(beachPositions.getX(i), beachPositions.getY(i)) - landRadius) / 5;
|
|
652
|
+
beachPositions.setZ(i, -t * 0.42);
|
|
653
|
+
}
|
|
654
|
+
beachGeometry.computeVertexNormals();
|
|
655
|
+
const beach = mesh(paintVertices(beachGeometry, (color, x, y) => color.set(0xeadfc2).lerp(new THREE.Color(0xd7c8a3), (Math.hypot(x, y) - landRadius) / 5)), standard(0xffffff, { vertexColors: true, roughness: 1 }), { shadow: false });
|
|
656
|
+
beach.rotation.x = -Math.PI / 2;
|
|
657
|
+
scene.add(beach);
|
|
658
|
+
// Sea: shallow turquoise near the shore, deeper blue further out; a soft line of foam on the beach.
|
|
659
|
+
const sea = mesh(paintVertices(new THREE.RingGeometry(landRadius + 2.5, 760, 160, 24), (color, x, y) => {
|
|
660
|
+
const t = Math.min(1, Math.max(0, (Math.hypot(x, y) - landRadius - 2.5) / 140));
|
|
661
|
+
color.set(0x9ad3d3).lerp(new THREE.Color(0x3f7fa6), Math.sqrt(t));
|
|
662
|
+
color.lerp(new THREE.Color(0xb7dfe3), (valueNoise(x / 9, y / 9, 'swell') - 0.5) * 0.18);
|
|
663
|
+
}), standard(0xffffff, { vertexColors: true, roughness: 0.3, metalness: 0.08 }), { y: -0.36, shadow: false });
|
|
664
|
+
sea.rotation.x = -Math.PI / 2;
|
|
665
|
+
sea.receiveShadow = false;
|
|
666
|
+
scene.add(sea);
|
|
667
|
+
const foam = mesh(new THREE.RingGeometry(landRadius + 4.0, landRadius + 4.7, 160, 1), new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.5, depthWrite: false }), { y: -0.35, shadow: false });
|
|
668
|
+
foam.rotation.x = -Math.PI / 2;
|
|
669
|
+
scene.add(foam);
|
|
670
|
+
animated.foam = foam;
|
|
671
|
+
// A far shore: low, faceted mountains across the water, faded by the fog.
|
|
672
|
+
const rock = new THREE.Color(0xa7a497);
|
|
673
|
+
const snow = new THREE.Color(0xf3f5f5);
|
|
674
|
+
for (let i = 0; i < 22; i += 1) {
|
|
675
|
+
const angle = Math.PI * 0.85 + (i / 21) * Math.PI * 0.95 + (hashUnit(`m${i}a`) - 0.5) * 0.08;
|
|
676
|
+
const distance = landRadius + 115 + hashUnit(`m${i}d`) * 55;
|
|
677
|
+
const peak = 10 + hashUnit(`m${i}h`) * 18;
|
|
678
|
+
const radius = 11 + hashUnit(`m${i}r`) * 10;
|
|
679
|
+
const geometry = new THREE.IcosahedronGeometry(1, 2);
|
|
680
|
+
const positions = geometry.attributes.position;
|
|
681
|
+
for (let v = 0; v < positions.count; v += 1) {
|
|
682
|
+
const px = positions.getX(v);
|
|
683
|
+
const py = positions.getY(v);
|
|
684
|
+
const pz = positions.getZ(v);
|
|
685
|
+
const bump = 0.82 + valueNoise(px * 2.2 + i, pz * 2.2, 'ridge') * 0.36;
|
|
686
|
+
positions.setXYZ(v, px * radius * bump, Math.max(0, py) * peak * bump, pz * radius * bump);
|
|
687
|
+
}
|
|
688
|
+
geometry.computeVertexNormals();
|
|
689
|
+
paintVertices(geometry, (color, _x, y) => {
|
|
690
|
+
const t = y / peak;
|
|
691
|
+
color.set(0x86a383).lerp(rock, Math.min(1, t * 1.6));
|
|
692
|
+
if (peak > 20 && t > 0.72) color.lerp(snow, Math.min(1, (t - 0.72) * 5));
|
|
693
|
+
});
|
|
694
|
+
const mountain = mesh(geometry, new THREE.MeshLambertMaterial({ vertexColors: true, flatShading: true }), { x: Math.cos(angle) * distance, y: -0.5, z: Math.sin(angle) * distance, shadow: false });
|
|
695
|
+
mountain.rotation.y = hashUnit(`m${i}y`) * Math.PI;
|
|
696
|
+
mountain.receiveShadow = false;
|
|
697
|
+
scene.add(mountain);
|
|
698
|
+
}
|
|
699
|
+
// Clouds drifting over the far water, behind the island, so they never cover a city.
|
|
700
|
+
const cloudMaterial = new THREE.MeshLambertMaterial({ color: 0xffffff, transparent: true, opacity: 0.9, flatShading: true });
|
|
701
|
+
animated.cloudMaterial = cloudMaterial;
|
|
702
|
+
for (let i = 0; i < 7; i += 1) {
|
|
703
|
+
const cloud = new THREE.Group();
|
|
704
|
+
const puffs = 3 + Math.floor(hashUnit(`c${i}n`) * 3);
|
|
705
|
+
for (let p = 0; p < puffs; p += 1) {
|
|
706
|
+
const r = 2.6 + hashUnit(`c${i}p${p}`) * 2.4;
|
|
707
|
+
const puff = mesh(new THREE.IcosahedronGeometry(r, 1), cloudMaterial, { x: (p - puffs / 2) * 2.8, y: hashUnit(`c${i}y${p}`) * 1.2, z: (hashUnit(`c${i}z${p}`) - 0.5) * 2.5, shadow: false });
|
|
708
|
+
puff.scale.y = 0.62;
|
|
709
|
+
puff.receiveShadow = false;
|
|
710
|
+
cloud.add(puff);
|
|
711
|
+
}
|
|
712
|
+
cloud.userData = { angle: Math.PI * (0.95 + (i / 6) * 0.6) + (hashUnit(`c${i}a`) - 0.5) * 0.1, distance: landRadius + 34 + hashUnit(`c${i}d`) * 60, height: 24 + hashUnit(`c${i}h`) * 10, speed: 0.00002 + hashUnit(`c${i}s`) * 0.00002, phase: hashUnit(`c${i}f`) * Math.PI * 2 };
|
|
713
|
+
scene.add(cloud);
|
|
714
|
+
animated.clouds.push(cloud);
|
|
715
|
+
}
|
|
716
|
+
placeClouds(0);
|
|
717
|
+
// Keep the sun's shadow map on the land so shadows stay crisp.
|
|
718
|
+
const reach = landRadius + 6;
|
|
719
|
+
Object.assign(sun.shadow.camera, { left: -reach, right: reach, top: reach, bottom: -reach });
|
|
720
|
+
sun.shadow.camera.updateProjectionMatrix();
|
|
721
|
+
return landRadius;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function placeClouds(time) {
|
|
725
|
+
for (const cloud of animated.clouds) {
|
|
726
|
+
const { angle, distance, height, speed, phase } = cloud.userData;
|
|
727
|
+
const a = angle + Math.sin(time * speed + phase) * 0.08;
|
|
728
|
+
cloud.position.set(Math.cos(a) * distance, height, Math.sin(a) * distance);
|
|
729
|
+
cloud.rotation.y = -a;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const lampPlacements = [];
|
|
734
|
+
function streetLamp(x, z, y = 0) {
|
|
735
|
+
lampPlacements.push([x, z, y]);
|
|
736
|
+
}
|
|
737
|
+
function placeLamps() {
|
|
738
|
+
if (lampPlacements.length === 0) return;
|
|
739
|
+
const poles = new THREE.InstancedMesh(new THREE.CylinderGeometry(0.05, 0.07, 1.6, 6), standard(0x3f4844, { roughness: 0.6, metalness: 0.4 }), lampPlacements.length);
|
|
740
|
+
const arms = new THREE.InstancedMesh(new THREE.BoxGeometry(0.42, 0.05, 0.06), standard(0x3f4844, { roughness: 0.6, metalness: 0.4 }), lampPlacements.length);
|
|
741
|
+
const bulbMaterial = standard(0xfff3d6, { emissive: new THREE.Color(0xffd27a), emissiveIntensity: sky.lamps ? 1.8 : 0 });
|
|
742
|
+
lampMaterials.push(bulbMaterial);
|
|
743
|
+
const bulbs = new THREE.InstancedMesh(new THREE.SphereGeometry(0.14, 10, 8), bulbMaterial, lampPlacements.length);
|
|
744
|
+
// A warm pool of light on the ground under each lamp, shown only when the lamps are on.
|
|
745
|
+
const falloff = canvasTexture(doc, 64, 64, (ctx, w, h) => {
|
|
746
|
+
const gradient = ctx.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, w / 2);
|
|
747
|
+
gradient.addColorStop(0, '#ffffff');
|
|
748
|
+
gradient.addColorStop(0.45, '#6b6b6b');
|
|
749
|
+
gradient.addColorStop(1, '#000000');
|
|
750
|
+
ctx.fillStyle = gradient;
|
|
751
|
+
ctx.fillRect(0, 0, w, h);
|
|
752
|
+
});
|
|
753
|
+
const poolMaterial = new THREE.MeshBasicMaterial({ color: 0xffc46a, map: falloff, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false });
|
|
754
|
+
animated.poolMaterial = poolMaterial;
|
|
755
|
+
const pools = new THREE.InstancedMesh(new THREE.PlaneGeometry(3.4, 3.4).rotateX(-Math.PI / 2), poolMaterial, lampPlacements.length);
|
|
756
|
+
const matrix = new THREE.Matrix4();
|
|
757
|
+
lampPlacements.forEach(([x, z, y], index) => {
|
|
758
|
+
poles.setMatrixAt(index, matrix.makeTranslation(x, y + 0.8, z));
|
|
759
|
+
arms.setMatrixAt(index, matrix.makeTranslation(x + 0.18, y + 1.58, z));
|
|
760
|
+
bulbs.setMatrixAt(index, matrix.makeTranslation(x + 0.36, y + 1.5, z));
|
|
761
|
+
pools.setMatrixAt(index, matrix.makeTranslation(x + 0.36, y + 0.07, z));
|
|
762
|
+
});
|
|
763
|
+
poles.castShadow = true;
|
|
764
|
+
pools.renderOrder = 1;
|
|
765
|
+
scene.add(poles, arms, bulbs, pools);
|
|
766
|
+
applyLampPools();
|
|
767
|
+
lampPlacements.length = 0;
|
|
768
|
+
}
|
|
769
|
+
function applyLampPools() {
|
|
770
|
+
if (animated.poolMaterial) animated.poolMaterial.opacity = sky.lamps ? Math.min(0.55, 0.15 + sky.glow * 0.16) : 0;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** Trees and lamps along a road, kept off the plaza and the city plates. */
|
|
774
|
+
function avenue(from, to, keepOut) {
|
|
775
|
+
const length = from.distanceTo(to);
|
|
776
|
+
const dir = to.clone().sub(from).normalize();
|
|
777
|
+
const side = new THREE.Vector3(-dir.z, 0, dir.x);
|
|
778
|
+
for (let d = 3; d < length - 2; d += 4.2) {
|
|
779
|
+
const point = from.clone().addScaledVector(dir, d);
|
|
780
|
+
if (keepOut(point)) continue;
|
|
781
|
+
const index = Math.round(d / 4.2);
|
|
782
|
+
for (const sign of [-1, 1]) {
|
|
783
|
+
const at = point.clone().addScaledVector(side, sign * 1.9);
|
|
784
|
+
if (index % 3 === 0 && sign === 1) streetLamp(at.x, at.z);
|
|
785
|
+
else tree(at.x, at.z, 0.55 + hashUnit(`${from.x}${d}${sign}`) * 0.2);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** Groves and fields between the cities; scenery only (no houses, which could read as engagements). */
|
|
791
|
+
function countryside(landRadius, cities, roads) {
|
|
792
|
+
const blocked = (x, z, margin) => {
|
|
793
|
+
if (Math.hypot(x, z) < PLAZA_RADIUS + 8) return true;
|
|
794
|
+
if (Math.hypot(x, z) > landRadius - 4) return true;
|
|
795
|
+
if (cities.some((city) => Math.hypot(x - city.x, z - city.z) < city.size * 0.8 + margin)) return true;
|
|
796
|
+
return roads.some(([a, b]) => {
|
|
797
|
+
const ab = b.clone().sub(a);
|
|
798
|
+
const t = Math.max(0, Math.min(1, new THREE.Vector3(x, 0, z).sub(a).dot(ab) / ab.lengthSq()));
|
|
799
|
+
return a.clone().addScaledVector(ab, t).distanceTo(new THREE.Vector3(x, 0, z)) < margin;
|
|
800
|
+
});
|
|
801
|
+
};
|
|
802
|
+
let groves = 0;
|
|
803
|
+
for (let i = 0; i < 90 && groves < 26; i += 1) {
|
|
804
|
+
const angle = hashUnit(`grove${i}a`) * Math.PI * 2;
|
|
805
|
+
const radius = PLAZA_RADIUS + 10 + hashUnit(`grove${i}r`) * (landRadius - PLAZA_RADIUS - 14);
|
|
806
|
+
const x = Math.cos(angle) * radius;
|
|
807
|
+
const z = Math.sin(angle) * radius;
|
|
808
|
+
if (blocked(x, z, 4)) continue;
|
|
809
|
+
groves += 1;
|
|
810
|
+
if (hashUnit(`grove${i}k`) > 0.7) {
|
|
811
|
+
// A field: a flat patch in a crop colour.
|
|
812
|
+
const field = mesh(new THREE.PlaneGeometry(6 + hashUnit(`f${i}w`) * 5, 4 + hashUnit(`f${i}h`) * 4), new THREE.MeshLambertMaterial({ color: hashUnit(`f${i}c`) > 0.5 ? 0xe7dca0 : 0xbfd69b }), { x, y: 0.02, z, shadow: false });
|
|
813
|
+
field.rotation.set(-Math.PI / 2, 0, hashUnit(`f${i}r`) * Math.PI);
|
|
814
|
+
scene.add(field);
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
const count = 4 + Math.floor(hashUnit(`grove${i}n`) * 6);
|
|
818
|
+
for (let t = 0; t < count; t += 1) {
|
|
819
|
+
const a = hashUnit(`grove${i}t${t}a`) * Math.PI * 2;
|
|
820
|
+
const r = hashUnit(`grove${i}t${t}r`) * 3.2;
|
|
821
|
+
tree(x + Math.cos(a) * r, z + Math.sin(a) * r, 0.6 + hashUnit(`grove${i}t${t}s`) * 0.5);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function applySky() {
|
|
827
|
+
sky = skyAt(new Date().getHours(), skyMode);
|
|
828
|
+
scene.background?.dispose?.();
|
|
829
|
+
scene.background = skyTexture(sky);
|
|
830
|
+
scene.fog.color.setHex(sky.fog);
|
|
831
|
+
sun.color.setHex(sky.sun);
|
|
832
|
+
sun.intensity = sky.sunIntensity;
|
|
833
|
+
hemisphere.intensity = sky.hemi;
|
|
834
|
+
renderer.toneMappingExposure = sky.exposure;
|
|
835
|
+
for (const material of litMaterials) if (!highlightedMaterials.has(material)) material.emissiveIntensity = sky.glow;
|
|
836
|
+
for (const material of lampMaterials) material.emissiveIntensity = sky.lamps ? 1.8 : 0;
|
|
837
|
+
applyLampPools();
|
|
838
|
+
animated.cloudMaterial?.color.set(sky.phase === 'night' ? 0x5a6782 : sky.phase === 'dusk' ? 0xf4d6c6 : 0xffffff);
|
|
839
|
+
}
|
|
840
|
+
const highlightedMaterials = new Set();
|
|
841
|
+
|
|
361
842
|
// --- plaza ---------------------------------------------------------------
|
|
362
843
|
function buildPlaza(rows, rowItems, waitingOf, now) {
|
|
363
844
|
const plaza = new THREE.Group();
|
|
@@ -365,6 +846,20 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
365
846
|
plaza.add(mesh(new THREE.CylinderGeometry(PLAZA_RADIUS, PLAZA_RADIUS + 0.4, 0.7, 64), standard(0xf3efe6, { roughness: 0.9 }), { y: 0.35 }));
|
|
366
847
|
plaza.add(mesh(new THREE.TorusGeometry(PLAZA_RADIUS - 0.3, 0.08, 6, 64), standard(0xd8cfbd), { y: 0.72 }));
|
|
367
848
|
plaza.children[2].rotation.x = Math.PI / 2;
|
|
849
|
+
const paving = mesh(new THREE.CircleGeometry(PLAZA_RADIUS - 0.05, 64), standard(0xffffff, { map: pavingTexture(), roughness: 0.95 }), { y: 0.705, shadow: false });
|
|
850
|
+
paving.rotation.x = -Math.PI / 2;
|
|
851
|
+
plaza.add(paving);
|
|
852
|
+
if (rows.length !== 1) {
|
|
853
|
+
// A fountain in the middle of the plaza (scenery; the kinds of judgment stand around it).
|
|
854
|
+
plaza.add(mesh(new THREE.CylinderGeometry(1.5, 1.7, 0.45, 32), standard(0xd9d1c1), { y: 0.92 }));
|
|
855
|
+
const pool = mesh(new THREE.CircleGeometry(1.3, 32), standard(0x9fd0de, { roughness: 0.2, metalness: 0.1 }), { y: 1.15, shadow: false });
|
|
856
|
+
pool.rotation.x = -Math.PI / 2;
|
|
857
|
+
plaza.add(pool);
|
|
858
|
+
plaza.add(mesh(new THREE.CylinderGeometry(0.18, 0.24, 0.9, 12), standard(0xd9d1c1), { y: 1.5 }));
|
|
859
|
+
const jet = mesh(new THREE.CylinderGeometry(0.05, 0.16, 1.2, 10), new THREE.MeshBasicMaterial({ color: 0xe8f6fb, transparent: true, opacity: 0.7 }), { y: 2.5, shadow: false });
|
|
860
|
+
plaza.add(jet);
|
|
861
|
+
animated.jets.push(jet);
|
|
862
|
+
}
|
|
368
863
|
scene.add(plaza);
|
|
369
864
|
register(plaza, { kind: 'plaza' });
|
|
370
865
|
addLabel('広場(判断)', new THREE.Vector3(0, 0.8, PLAZA_RADIUS + 2.8), 'is-plaza', { priority: LABEL_PRIORITY.plaza });
|
|
@@ -383,16 +878,19 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
383
878
|
const group = new THREE.Group();
|
|
384
879
|
group.position.set(x, 0.7, z);
|
|
385
880
|
const color = STATE_COLORS[row.state] ?? 0x929b95;
|
|
386
|
-
group.add(mesh(new THREE.BoxGeometry(2.
|
|
387
|
-
const
|
|
881
|
+
group.add(mesh(new THREE.BoxGeometry(2.7, 0.25, 2.7), standard(0xe6ded0), { y: 0.12 }));
|
|
882
|
+
const wall = new THREE.Color(color);
|
|
883
|
+
const body = block(2.1, height, 2.1, { style: 'civic', wall, lit: recent, roof: 0xd9d3c6 });
|
|
388
884
|
body.position.y += 0.25;
|
|
389
885
|
group.add(body);
|
|
390
|
-
|
|
391
|
-
cap.
|
|
886
|
+
group.add(mesh(new THREE.BoxGeometry(2.3, 0.14, 2.3), standard(0xf3efe6), { y: 0.25 + height + 0.07 }));
|
|
887
|
+
const cap = block(1.4, 0.56, 1.4, { style: 'civic', wall, lit: recent, roof: 0xd9d3c6 });
|
|
888
|
+
cap.position.y += 0.25 + height + 0.14;
|
|
392
889
|
group.add(cap);
|
|
393
890
|
scene.add(group);
|
|
394
891
|
register(group, { kind: 'judgment', row, items: rowItems.get(row.key) ?? [] });
|
|
395
892
|
const top = 0.7 + 0.25 + height + 0.7;
|
|
893
|
+
markers.plaza.set(row.key, new THREE.Vector3(x, top, z));
|
|
396
894
|
const waiting = waitingOf(row);
|
|
397
895
|
if (waiting > 0) {
|
|
398
896
|
const beam = mesh(new THREE.CylinderGeometry(0.22, 0.22, 16, 12), new THREE.MeshBasicMaterial({ color: 0xd92335, transparent: true, opacity: 0.55 }), { x, y: top + 8, z, shadow: false });
|
|
@@ -415,6 +913,15 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
415
913
|
}
|
|
416
914
|
|
|
417
915
|
// --- cities ----------------------------------------------------------------
|
|
916
|
+
// The form says the business's kind; the height and the lit windows say its last 30 days. Everything
|
|
917
|
+
// else on a building (podium, cornice, roof plant, portico) is detail and means nothing on its own.
|
|
918
|
+
function rooftopPlant(group, y, span, options) {
|
|
919
|
+
const metal = standard(0xb7bcbc, { roughness: 0.6, metalness: 0.3, ...options });
|
|
920
|
+
group.add(mesh(new THREE.BoxGeometry(span * 0.32, 0.32, span * 0.22), metal, { x: -span * 0.18, y: y + 0.16, z: span * 0.12 }));
|
|
921
|
+
group.add(mesh(new THREE.BoxGeometry(span * 0.18, 0.24, span * 0.18), metal, { x: span * 0.22, y: y + 0.12, z: -span * 0.2 }));
|
|
922
|
+
group.add(mesh(new THREE.CylinderGeometry(0.22, 0.22, 0.5, 12), standard(0xd8d4ca, options), { x: span * 0.24, y: y + 0.25, z: span * 0.2 }));
|
|
923
|
+
}
|
|
924
|
+
|
|
418
925
|
function landmark(form, color, height, status, active = true) {
|
|
419
926
|
const group = new THREE.Group();
|
|
420
927
|
const phase = statusPhase(status);
|
|
@@ -423,22 +930,60 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
423
930
|
const lit = !concept && active;
|
|
424
931
|
const tone = phase === 'maintenance' ? muted(color, 0.55) : new THREE.Color(color);
|
|
425
932
|
const options = concept ? { transparent: true, opacity: 0.38 } : {};
|
|
933
|
+
const stone = phase === 'maintenance' ? muted(0xe7e2d6, 0.3) : new THREE.Color(0xe7e2d6);
|
|
934
|
+
const at = (object, y) => {
|
|
935
|
+
object.position.y += y;
|
|
936
|
+
group.add(object);
|
|
937
|
+
return object;
|
|
938
|
+
};
|
|
426
939
|
if (form === 'tower') {
|
|
427
|
-
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
940
|
+
// A glass tower on a stone podium, set back once, with a crown and a mast (total height × 1.45).
|
|
941
|
+
const podium = Math.min(1.1, height * 0.2);
|
|
942
|
+
at(block(3.8, podium, 3.8, { style: 'office', wall: stone, lit, roof: 0xcfcac0, ...options }), 0);
|
|
943
|
+
at(block(3, height - podium, 3, { style: 'glass', wall: tone, lit, roof: 0xd5d8d6, roughness: 0.3, ...options }), podium);
|
|
944
|
+
at(block(2.2, height * 0.45, 2.2, { style: 'glass', wall: tone, lit, roof: 0xd5d8d6, roughness: 0.3, ...options }), height);
|
|
945
|
+
group.add(mesh(new THREE.BoxGeometry(2.45, 0.16, 2.45), standard(0xf1f1ee, options), { y: height * 1.45 + 0.08 }));
|
|
946
|
+
group.add(mesh(new THREE.BoxGeometry(3.15, 0.12, 3.15), standard(0xf1f1ee, options), { y: height + 0.06 }));
|
|
947
|
+
rooftopPlant(group, height + 0.12, 3, options);
|
|
948
|
+
group.add(mesh(new THREE.CylinderGeometry(0.04, 0.07, 1.6, 6), standard(0x5a6460, { metalness: 0.5, roughness: 0.4 }), { y: height * 1.45 + 0.96 }));
|
|
949
|
+
group.add(mesh(new THREE.SphereGeometry(0.09, 8, 6), standard(0xffffff, { emissive: new THREE.Color(0xffffff), emissiveIntensity: 0.4 }), { y: height * 1.45 + 1.8 }));
|
|
431
950
|
} else if (form === 'hall') {
|
|
432
|
-
|
|
433
|
-
const
|
|
951
|
+
// A brick hall with a pitched roof in the kind's colour and a columned entrance.
|
|
952
|
+
const bodyHeight = height * 0.55;
|
|
953
|
+
const wall = new THREE.Color(0xf0e6d6).lerp(tone, 0.28);
|
|
954
|
+
at(block(4.4, bodyHeight, 3, { style: 'brick', wall, lit, roof: 0x9b8f80, ...options }), 0);
|
|
955
|
+
group.add(mesh(new THREE.BoxGeometry(4.6, 0.14, 3.2), standard(0xf5f1e8, options), { y: bodyHeight + 0.07 }));
|
|
956
|
+
const roof = mesh(gableRoof(3.2, 4.6, 1.45), standard(tone.clone().multiplyScalar(0.82), { roughness: 0.75, ...options }), { y: bodyHeight + 0.14 });
|
|
434
957
|
roof.rotation.y = Math.PI / 2;
|
|
435
|
-
group.add(
|
|
958
|
+
group.add(roof);
|
|
959
|
+
group.add(mesh(new THREE.BoxGeometry(0.42, 0.9, 0.42), standard(0x9c7a62, options), { x: 1.2, y: bodyHeight + 0.9, z: -0.6 }));
|
|
960
|
+
// Portico on the side that faces the default camera.
|
|
961
|
+
const columnHeight = Math.max(0.9, bodyHeight * 0.72);
|
|
962
|
+
group.add(mesh(new THREE.BoxGeometry(3, 0.16, 1.1), standard(0xebe5d9, options), { y: 0.08, z: 2.05 }));
|
|
963
|
+
for (let c = 0; c < 4; c += 1) group.add(mesh(new THREE.CylinderGeometry(0.11, 0.13, columnHeight, 10), standard(0xf7f4ee, options), { x: -1.2 + c * 0.8, y: 0.16 + columnHeight / 2, z: 2.25 }));
|
|
964
|
+
const pediment = mesh(gableRoof(3.1, 1.1, 0.55), standard(0xf7f4ee, options), { y: 0.16 + columnHeight, z: 2.05 });
|
|
965
|
+
group.add(pediment);
|
|
966
|
+
group.add(mesh(new THREE.BoxGeometry(0.7, Math.min(1.1, columnHeight * 0.8), 0.06), standard(0x6e5442, options), { y: 0.16 + Math.min(1.1, columnHeight * 0.8) / 2, z: 1.52 }));
|
|
436
967
|
} else if (form === 'dome') {
|
|
437
|
-
|
|
438
|
-
|
|
968
|
+
// A research hall: a windowed drum under a faceted dome with a lantern, and a low wing.
|
|
969
|
+
const drumHeight = height * 0.5;
|
|
970
|
+
const drum = mesh(new THREE.CylinderGeometry(1.9, 2.05, drumHeight, 32, 1, true), facadeMaterial('research', tone, lit, 2 * Math.PI * 1.9, drumHeight, options), { y: drumHeight / 2 });
|
|
971
|
+
group.add(drum);
|
|
972
|
+
group.add(mesh(new THREE.TorusGeometry(2.0, 0.12, 6, 40).rotateX(Math.PI / 2), standard(0xf3f1ea, options), { y: drumHeight }));
|
|
973
|
+
group.add(mesh(new THREE.SphereGeometry(1.95, 18, 9, 0, Math.PI * 2, 0, Math.PI / 2), standard(0xeef2f4, { roughness: 0.28, metalness: 0.25, flatShading: true, ...options }), { y: drumHeight }));
|
|
974
|
+
group.add(mesh(new THREE.CylinderGeometry(0.32, 0.36, 0.5, 12), standard(0xf3f1ea, options), { y: drumHeight + 1.95 + 0.2 }));
|
|
975
|
+
group.add(mesh(new THREE.ConeGeometry(0.42, 0.45, 12), standard(tone.clone().multiplyScalar(0.85), options), { y: drumHeight + 1.95 + 0.67 }));
|
|
976
|
+
const wing = at(block(2.4, Math.max(0.9, drumHeight * 0.45), 1.5, { style: 'office', wall: stone, lit, roof: 0xcfcac0, ...options }), 0);
|
|
977
|
+
wing.position.set(-1.9, wing.position.y, 1.2);
|
|
439
978
|
} else {
|
|
440
|
-
|
|
441
|
-
|
|
979
|
+
// An office: a main block and a lower wing, with a parapet and roof plant.
|
|
980
|
+
const mainHeight = height * 0.75;
|
|
981
|
+
const wall = new THREE.Color(0xeeeae2).lerp(tone, 0.35);
|
|
982
|
+
at(block(3.6, mainHeight, 2.8, { style: 'office', wall, lit, roof: 0xc4c0b6, ...options }), 0);
|
|
983
|
+
group.add(mesh(new THREE.BoxGeometry(3.72, 0.16, 2.92), standard(0xf3f1ec, options), { y: mainHeight + 0.08 }));
|
|
984
|
+
rooftopPlant(group, mainHeight + 0.16, 2.8, options);
|
|
985
|
+
const wing = at(block(1.9, Math.max(0.8, mainHeight * 0.5), 2.1, { style: 'office', wall: stone, lit, roof: 0xc4c0b6, ...options }), 0);
|
|
986
|
+
wing.position.set(1.95, wing.position.y, 1.0);
|
|
442
987
|
}
|
|
443
988
|
if (concept) {
|
|
444
989
|
// Scaffold outline: a business that is still a concept.
|
|
@@ -449,22 +994,36 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
449
994
|
return group;
|
|
450
995
|
}
|
|
451
996
|
|
|
997
|
+
const HOUSE_WALLS = [0xf4efe4, 0xefe6d6, 0xf2ece6, 0xe9e4d6];
|
|
452
998
|
function house(engagement, color) {
|
|
453
999
|
const group = new THREE.Group();
|
|
454
1000
|
const status = engagement.status;
|
|
455
1001
|
const phase = statusPhase(status);
|
|
456
1002
|
if (phase === 'finished') {
|
|
457
|
-
// A finished engagement
|
|
458
|
-
group.add(mesh(new THREE.BoxGeometry(1.
|
|
1003
|
+
// A finished engagement becomes a small memorial park: a lawn edged by hedges, a stone, a bench.
|
|
1004
|
+
group.add(mesh(new THREE.BoxGeometry(1.9, 0.14, 1.9), standard(0xa6c796, { roughness: 1 }), { y: 0.07 }));
|
|
1005
|
+
const hedge = standard(0x6f9a68, { roughness: 1 });
|
|
1006
|
+
for (const [hx, hz, hw, hd] of [[0, -0.88, 1.9, 0.16], [-0.88, 0, 0.16, 1.9], [0.88, 0.35, 0.16, 1.2]]) group.add(mesh(new THREE.BoxGeometry(hw, 0.26, hd), hedge, { x: hx, y: 0.24, z: hz }));
|
|
1007
|
+
group.add(mesh(new THREE.BoxGeometry(0.5, 0.12, 0.5), standard(0xcfc8b8), { y: 0.2 }));
|
|
1008
|
+
group.add(mesh(new THREE.BoxGeometry(0.24, 0.85, 0.24), standard(0xdcd5c6), { y: 0.68 }));
|
|
1009
|
+
group.add(mesh(new THREE.ConeGeometry(0.18, 0.24, 4).rotateY(Math.PI / 4), standard(0xdcd5c6), { y: 1.22 }));
|
|
1010
|
+
group.add(mesh(new THREE.BoxGeometry(0.7, 0.08, 0.24), standard(0x9a7b5c), { x: 0.2, y: 0.32, z: 0.62 }));
|
|
1011
|
+
group.add(mesh(new THREE.IcosahedronGeometry(0.26, 0), standard(0x7fa877, { roughness: 1, flatShading: true }), { x: -0.5, y: 0.34, z: 0.45 }));
|
|
459
1012
|
group.userData.finished = true;
|
|
460
1013
|
return group;
|
|
461
1014
|
}
|
|
1015
|
+
// One house per engagement; the roof takes the kind's colour, lit windows mean the engagement is active.
|
|
462
1016
|
const h = 0.9 + hashUnit(engagement.id) * 1.1;
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
1017
|
+
const wall = phase === 'maintenance' ? muted(color, 0.6) : new THREE.Color(HOUSE_WALLS[Math.floor(hashUnit(`${engagement.id}w`) * HOUSE_WALLS.length)]);
|
|
1018
|
+
const turned = hashUnit(`${engagement.id}r`) > 0.5;
|
|
1019
|
+
group.add(block(1.6, h, 1.4, { style: 'house', wall, lit: phase === 'active', roof: 0xb9b2a5 }));
|
|
1020
|
+
const roofColor = phase === 'maintenance' ? muted(color, 0.6) : new THREE.Color(color).multiplyScalar(0.9);
|
|
1021
|
+
const roof = mesh(gableRoof(turned ? 1.4 : 1.6, turned ? 1.6 : 1.4, 0.8).scale(1.08, 1, 1.08), standard(roofColor, { roughness: 0.7 }), { y: h });
|
|
1022
|
+
roof.rotation.y = turned ? Math.PI / 2 : 0;
|
|
467
1023
|
group.add(roof);
|
|
1024
|
+
group.add(mesh(new THREE.BoxGeometry(0.22, 0.55, 0.22), standard(0x9c7a62), { x: 0.42, y: h + 0.45, z: -0.25 }));
|
|
1025
|
+
group.add(mesh(new THREE.BoxGeometry(0.34, 0.58, 0.05), standard(0x7a5a43), { y: 0.29, z: 0.72 }));
|
|
1026
|
+
group.add(mesh(new THREE.BoxGeometry(0.52, 0.05, 0.22), standard(0xe2ddd2), { y: 0.62, z: 0.8 }));
|
|
468
1027
|
return group;
|
|
469
1028
|
}
|
|
470
1029
|
|
|
@@ -483,8 +1042,13 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
483
1042
|
return group;
|
|
484
1043
|
}
|
|
485
1044
|
|
|
1045
|
+
const markers = { cities: new Map(), plaza: new Map() };
|
|
486
1046
|
function buildCities({ cities, sectors, extent }, judgmentsByBusiness = new Map()) {
|
|
487
1047
|
fitExtent(extent);
|
|
1048
|
+
const landRadius = buildTerrain(extent);
|
|
1049
|
+
const roads = [];
|
|
1050
|
+
const keepOut = (point) => Math.hypot(point.x, point.z) < PLAZA_RADIUS + 5
|
|
1051
|
+
|| cities.some((city) => Math.hypot(point.x - city.x, point.z - city.z) < city.size * 0.62 + 1.2);
|
|
488
1052
|
for (let i = 0; i < 12; i += 1) {
|
|
489
1053
|
const angle = (i / 12) * Math.PI * 2;
|
|
490
1054
|
road(new THREE.Vector3(Math.cos(angle) * (PLAZA_RADIUS + 4), 0, Math.sin(angle) * (PLAZA_RADIUS + 4)), new THREE.Vector3(Math.cos(angle + Math.PI / 6) * (PLAZA_RADIUS + 4), 0, Math.sin(angle + Math.PI / 6) * (PLAZA_RADIUS + 4)), 1.5);
|
|
@@ -495,16 +1059,22 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
495
1059
|
const color = kindColor(kind);
|
|
496
1060
|
const start = new THREE.Vector3(x, 0, z).setLength(PLAZA_RADIUS + 4);
|
|
497
1061
|
road(start, new THREE.Vector3(x, 0, z));
|
|
1062
|
+
roads.push([start, new THREE.Vector3(x, 0, z)]);
|
|
1063
|
+
avenue(start, new THREE.Vector3(x, 0, z), keepOut);
|
|
498
1064
|
const group = new THREE.Group();
|
|
499
1065
|
group.position.set(x, 0, z);
|
|
500
|
-
|
|
1066
|
+
// A city block: a curb step, a paved block in a hint of the kind's colour, lamps on its corners.
|
|
1067
|
+
group.add(mesh(roundedPlate(size + 0.9, size + 0.9, 0.18, 2.0), standard(0xcfc9bb, { roughness: 1 })));
|
|
1068
|
+
const plateColor = new THREE.Color(0xf3f1ea).lerp(new THREE.Color(color), 0.08);
|
|
501
1069
|
group.add(mesh(roundedPlate(size, size, 0.45, 1.6), standard(plateColor, { roughness: 0.95 })));
|
|
1070
|
+
for (const [cx, cz] of [[1, 1], [1, -1], [-1, 1], [-1, -1]]) streetLamp(x + cx * (size / 2 - 0.9), z + cz * (size / 2 - 0.9), 0.45);
|
|
502
1071
|
const measures = cityMeasures(business, { isFinished, judgments: judgmentsByBusiness.get(business.code) ?? [] });
|
|
503
1072
|
const towerHeight = measures.towerHeight;
|
|
504
1073
|
group.add(landmark(entry.form, color, towerHeight, business.status, measures.lit));
|
|
505
1074
|
scene.add(group);
|
|
506
1075
|
register(group, { kind: 'city', business });
|
|
507
1076
|
const top = entry.form === 'tower' ? towerHeight * 1.45 + 1.8 : towerHeight + 1;
|
|
1077
|
+
markers.cities.set(business.code, new THREE.Vector3(x, top, z));
|
|
508
1078
|
const cityLabel = addLabel(statusPhase(business.status) === 'concept' ? `${business.name}(${statusText(business.status)})` : business.name, new THREE.Vector3(x, top, z), 'is-city', { priority: LABEL_PRIORITY.city, owner: group });
|
|
509
1079
|
cityLabel.node.style.borderColor = `${entry.color}80`;
|
|
510
1080
|
cityLabel.business = business;
|
|
@@ -540,9 +1110,12 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
540
1110
|
const treeCount = 2 + Math.floor(hashUnit(business.id) * 3);
|
|
541
1111
|
for (let t = 0; t < treeCount; t += 1) {
|
|
542
1112
|
const a = hashUnit(`${business.id}${t}`) * Math.PI * 2;
|
|
543
|
-
tree(x + Math.cos(a) * size * 0.42, z + Math.sin(a) * size * 0.42, 0.8);
|
|
1113
|
+
tree(x + Math.cos(a) * size * 0.42, z + Math.sin(a) * size * 0.42, 0.8, 0.45);
|
|
544
1114
|
}
|
|
545
1115
|
}
|
|
1116
|
+
countryside(landRadius, cities, roads);
|
|
1117
|
+
plantTrees();
|
|
1118
|
+
placeLamps();
|
|
546
1119
|
for (const { kind, angle } of sectors) {
|
|
547
1120
|
addLabel(kindEntry(kind).label, new THREE.Vector3(Math.cos(angle) * 21, 0.2, Math.sin(angle) * 21), 'is-sector', { priority: LABEL_PRIORITY.sector });
|
|
548
1121
|
}
|
|
@@ -559,11 +1132,13 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
559
1132
|
function setHighlight(group, on) {
|
|
560
1133
|
for (const entry of highlightable.get(group) ?? []) {
|
|
561
1134
|
if (on) {
|
|
1135
|
+
highlightedMaterials.add(entry.material);
|
|
562
1136
|
entry.material.emissive.set(0x3d8f74);
|
|
563
1137
|
entry.material.emissiveIntensity = 0.35;
|
|
564
1138
|
} else {
|
|
1139
|
+
highlightedMaterials.delete(entry.material);
|
|
565
1140
|
entry.material.emissive.copy(entry.base);
|
|
566
|
-
entry.material.emissiveIntensity = entry.intensity;
|
|
1141
|
+
entry.material.emissiveIntensity = litMaterials.has(entry.material) ? sky.glow : entry.intensity;
|
|
567
1142
|
}
|
|
568
1143
|
}
|
|
569
1144
|
}
|
|
@@ -705,6 +1280,34 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
705
1280
|
}
|
|
706
1281
|
}
|
|
707
1282
|
|
|
1283
|
+
/** A short burst of rising lights over what moved since the last visit. */
|
|
1284
|
+
function markChanges(changes) {
|
|
1285
|
+
const points = [
|
|
1286
|
+
...(changes?.cities ?? []).map((code) => markers.cities.get(code)),
|
|
1287
|
+
...(changes?.plaza ?? []).map((key) => markers.plaza.get(key)),
|
|
1288
|
+
].filter(Boolean);
|
|
1289
|
+
for (const point of points) {
|
|
1290
|
+
for (let i = 0; i < 10; i += 1) {
|
|
1291
|
+
// Around the building and from its middle, so the labels above it never hide the sparks.
|
|
1292
|
+
const angle = (i / 10) * Math.PI * 2;
|
|
1293
|
+
const spark = mesh(new THREE.SphereGeometry(0.6, 10, 8), new THREE.MeshBasicMaterial({ color: 0xffb21a, transparent: true, opacity: 0.95, fog: false }), { x: point.x + Math.cos(angle) * 2.6, y: point.y * 0.35, z: point.z + Math.sin(angle) * 2.6, shadow: false });
|
|
1294
|
+
spark.userData = { base: Math.max(1.2, point.y * 0.35), rise: Math.max(4, point.y * 0.8), delay: i * 220 };
|
|
1295
|
+
scene.add(spark);
|
|
1296
|
+
animated.sparkles.push(spark);
|
|
1297
|
+
}
|
|
1298
|
+
// A ring on the ground that pulses with the sparks.
|
|
1299
|
+
const ring = mesh(new THREE.RingGeometry(2.8, 3.5, 48), new THREE.MeshBasicMaterial({ color: 0xffb21a, transparent: true, opacity: 0.8, side: THREE.DoubleSide, depthTest: false, fog: false }), { x: point.x, y: 0.9, z: point.z, shadow: false });
|
|
1300
|
+
ring.rotation.x = -Math.PI / 2;
|
|
1301
|
+
ring.renderOrder = 2;
|
|
1302
|
+
ring.userData = { ring: true, base: 0.9, delay: 0 };
|
|
1303
|
+
scene.add(ring);
|
|
1304
|
+
animated.sparkles.push(ring);
|
|
1305
|
+
}
|
|
1306
|
+
animated.sparkleStart = null;
|
|
1307
|
+
return points.length;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
let lastSkyMinute = -1;
|
|
708
1311
|
let running = true;
|
|
709
1312
|
function frame(time) {
|
|
710
1313
|
if (!running) return;
|
|
@@ -735,7 +1338,36 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
735
1338
|
renderer.domElement.style.cursor = hovered ? 'pointer' : '';
|
|
736
1339
|
}
|
|
737
1340
|
}
|
|
1341
|
+
const minute = Math.floor(Date.now() / 60000);
|
|
1342
|
+
if (skyMode === 'auto' && minute !== lastSkyMinute) {
|
|
1343
|
+
if (lastSkyMinute !== -1 && skyAt(new Date().getHours(), 'auto').phase !== sky.phase) applySky();
|
|
1344
|
+
lastSkyMinute = minute;
|
|
1345
|
+
}
|
|
1346
|
+
if (animated.sparkles.length) {
|
|
1347
|
+
// Timed by the clock, not the frame timestamp, which can lag behind on a slow renderer.
|
|
1348
|
+
const clock = performance.now();
|
|
1349
|
+
animated.sparkleStart ??= clock;
|
|
1350
|
+
const elapsed = clock - animated.sparkleStart;
|
|
1351
|
+
for (const spark of animated.sparkles) {
|
|
1352
|
+
const t = Math.max(0, elapsed - spark.userData.delay) / 2600;
|
|
1353
|
+
const cycle = t % 1;
|
|
1354
|
+
if (spark.userData.ring) {
|
|
1355
|
+
spark.scale.setScalar(reducedMotion ? 1 : 1 + cycle * 0.6);
|
|
1356
|
+
spark.material.opacity = elapsed > 9000 ? Math.max(0, 0.8 - (elapsed - 9000) / 1500) : reducedMotion ? 0.8 : 0.8 * (1 - cycle);
|
|
1357
|
+
continue;
|
|
1358
|
+
}
|
|
1359
|
+
spark.position.y = spark.userData.base + (reducedMotion ? 0.6 : cycle * spark.userData.rise);
|
|
1360
|
+
spark.material.opacity = elapsed > 9000 ? Math.max(0, 1 - (elapsed - 9000) / 1500) : reducedMotion ? 0.9 : 0.95 * (1 - cycle);
|
|
1361
|
+
}
|
|
1362
|
+
if (elapsed > 10500) {
|
|
1363
|
+
for (const spark of animated.sparkles) scene.remove(spark);
|
|
1364
|
+
animated.sparkles = [];
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
738
1367
|
if (!reducedMotion) {
|
|
1368
|
+
if (animated.clouds.length) placeClouds(time);
|
|
1369
|
+
if (animated.foam) animated.foam.material.opacity = 0.3 + 0.24 * (1 + Math.sin(time / 1400)) / 2;
|
|
1370
|
+
for (const jet of animated.jets) jet.scale.y = 0.85 + 0.2 * Math.sin(time / 260);
|
|
739
1371
|
const wave = (1 + Math.sin(time / 380)) / 2;
|
|
740
1372
|
for (const beam of beacons) beam.material.opacity = 0.35 + 0.3 * wave;
|
|
741
1373
|
for (const ring of glows) {
|
|
@@ -753,6 +1385,13 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
753
1385
|
return {
|
|
754
1386
|
buildPlaza,
|
|
755
1387
|
buildCities,
|
|
1388
|
+
markChanges,
|
|
1389
|
+
/** The sky: 'auto' follows the hour; 'day', 'dusk' or 'night' fix it. Returns the phase shown. */
|
|
1390
|
+
setSky(mode) {
|
|
1391
|
+
skyMode = mode;
|
|
1392
|
+
applySky();
|
|
1393
|
+
return sky.phase;
|
|
1394
|
+
},
|
|
756
1395
|
flyTo,
|
|
757
1396
|
resetView,
|
|
758
1397
|
clearSelection() {
|
|
@@ -799,12 +1438,26 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
|
|
|
799
1438
|
root.append(wrap);
|
|
800
1439
|
let scene = null;
|
|
801
1440
|
|
|
1441
|
+
// The sky follows the hour by default; the viewer can fix it to see the lit windows at night.
|
|
1442
|
+
const SKY_MODES = [['auto', '空:時刻'], ['day', '空:昼'], ['dusk', '空:夕方'], ['night', '空:夜']];
|
|
1443
|
+
let skyIndex = 0;
|
|
1444
|
+
const skyButton = workspaceButton(doc, {
|
|
1445
|
+
text: SKY_MODES[0][1],
|
|
1446
|
+
onClick: () => {
|
|
1447
|
+
skyIndex = (skyIndex + 1) % SKY_MODES.length;
|
|
1448
|
+
skyButton.textContent = SKY_MODES[skyIndex][1];
|
|
1449
|
+
scene?.setSky(SKY_MODES[skyIndex][0]);
|
|
1450
|
+
},
|
|
1451
|
+
});
|
|
802
1452
|
const header = workspacePageHeader(doc, {
|
|
803
1453
|
crumbs: page?.crumbs ?? ['あなたのBrainbase', '世界'],
|
|
804
1454
|
title: '世界',
|
|
805
1455
|
lead: '事業を都市、案件を区画、判断の種類を広場の建物として描いています。見るための画面で、ここからは何も書き換えません。',
|
|
806
1456
|
source: page?.source ?? null,
|
|
807
|
-
actions: [
|
|
1457
|
+
actions: [
|
|
1458
|
+
skyButton,
|
|
1459
|
+
workspaceButton(doc, { text: '全体に戻る', onClick: () => { scene?.clearSelection(); showEmptyRail(); scene?.resetView(); } }),
|
|
1460
|
+
],
|
|
808
1461
|
});
|
|
809
1462
|
const notices = el(doc, 'div', { className: 'bb-world-hud', attrs: { 'aria-live': 'polite' } });
|
|
810
1463
|
const legend = el(doc, 'div', { className: 'bb-world-legend', attrs: { 'aria-label': '凡例' } });
|
|
@@ -916,7 +1569,7 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
|
|
|
916
1569
|
['分類', kindText],
|
|
917
1570
|
['状態', statusText(business.status)],
|
|
918
1571
|
['コード', business.code],
|
|
919
|
-
['進行中の案件(都市の広さ)', `${measures.open}件${measures.finished ? `(完了・終了${measures.finished}
|
|
1572
|
+
['進行中の案件(都市の広さ)', `${measures.open}件${measures.finished ? `(完了・終了${measures.finished}件は記念公園)` : ''}`],
|
|
920
1573
|
['最近30日の動き(高さ・明かり)', `決定${measures.decisions}件${judgmentText}`],
|
|
921
1574
|
]) }));
|
|
922
1575
|
const ul = el(doc, 'ul', { className: 'bb-world-rail-list' });
|
|
@@ -1072,6 +1725,46 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
|
|
|
1072
1725
|
scene = createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear: showEmptyRail });
|
|
1073
1726
|
scene.buildPlaza(rows, rowItems, (row) => (rowItems.get(row.key) ?? []).filter((ref) => isWaiting(proofs.get(ref.decision_attempt_id))).length, Date.now());
|
|
1074
1727
|
scene.buildCities(layoutWorld(businesses), placement.byBusiness);
|
|
1728
|
+
showChangesSinceLastVisit(note, businesses, rows);
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
/**
|
|
1732
|
+
* What moved since this viewer last opened the world (a per-viewer convenience kept in this browser;
|
|
1733
|
+
* nothing is lost if the browser forgets it).
|
|
1734
|
+
*/
|
|
1735
|
+
function showChangesSinceLastVisit(note, businesses, rows) {
|
|
1736
|
+
const key = 'brainbase.world.lastVisit';
|
|
1737
|
+
let lastVisit = null;
|
|
1738
|
+
try {
|
|
1739
|
+
lastVisit = globalThis.localStorage?.getItem(key) ?? null;
|
|
1740
|
+
} catch {
|
|
1741
|
+
lastVisit = null;
|
|
1742
|
+
}
|
|
1743
|
+
const changes = changesSince(lastVisit, businesses, placement.byBusiness, rows);
|
|
1744
|
+
try {
|
|
1745
|
+
globalThis.localStorage?.setItem(key, new Date().toISOString());
|
|
1746
|
+
} catch {
|
|
1747
|
+
// Without storage the world simply shows no marks next time.
|
|
1748
|
+
}
|
|
1749
|
+
if (!changes) {
|
|
1750
|
+
note('前回から', '初めての表示です。次に開いたときから、前回から動きがあった所に光の印を出します。');
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
const names = new Map(businesses.map((business) => [business.code, business.name]));
|
|
1754
|
+
const cityNames = changes.cities.map((code) => names.get(code) ?? code);
|
|
1755
|
+
const last = new Date(changes.since);
|
|
1756
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
1757
|
+
const when = `${pad(last.getMonth() + 1)}/${pad(last.getDate())} ${pad(last.getHours())}:${pad(last.getMinutes())}`;
|
|
1758
|
+
if (cityNames.length === 0 && changes.plaza.length === 0) {
|
|
1759
|
+
note('前回から', `前回(${when})から、新しい決定や判断はありません。`);
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
const parts = [
|
|
1763
|
+
cityNames.length ? `都市:${cityNames.join('・')}` : null,
|
|
1764
|
+
changes.plaza.length ? `広場の判断の種類${changes.plaza.length}つ` : null,
|
|
1765
|
+
].filter(Boolean);
|
|
1766
|
+
note('前回から', `前回(${when})から動きがあった所に光の印を出しています。${parts.join('、')}。`, 'attention');
|
|
1767
|
+
scene?.markChanges(changes);
|
|
1075
1768
|
}
|
|
1076
1769
|
|
|
1077
1770
|
void load();
|