@unson/brainbase-mcp 0.15.6 → 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-view.js +466 -137
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
|
}
|
package/ui/world/world-view.js
CHANGED
|
@@ -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) {
|
|
@@ -289,6 +425,7 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
289
425
|
sun.shadow.mapSize.set(2048, 2048);
|
|
290
426
|
sun.shadow.radius = 4;
|
|
291
427
|
sun.shadow.bias = -0.0004;
|
|
428
|
+
sun.shadow.normalBias = 0.03;
|
|
292
429
|
Object.assign(sun.shadow.camera, { left: -150, right: 150, top: 150, bottom: -150, near: 1, far: 420 });
|
|
293
430
|
scene.add(sun);
|
|
294
431
|
const fill = new THREE.DirectionalLight(0xdfe9ff, 0.6);
|
|
@@ -298,9 +435,9 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
298
435
|
// Lit windows and street lamps follow the sky (brightest at night); their meaning does not change.
|
|
299
436
|
const litMaterials = new Set();
|
|
300
437
|
const lampMaterials = [];
|
|
301
|
-
const animated = { water: null, jets: [], sparkles: [] };
|
|
438
|
+
const animated = { water: null, jets: [], sparkles: [], clouds: [], foam: null };
|
|
439
|
+
const maxAnisotropy = renderer.capabilities.getMaxAnisotropy?.() ?? 1;
|
|
302
440
|
|
|
303
|
-
const textures = { lit: windowTexture(doc, true), unlit: windowTexture(doc, false), glow: windowTexture(doc, true, true) };
|
|
304
441
|
const pickables = [];
|
|
305
442
|
const labels = [];
|
|
306
443
|
const beacons = [];
|
|
@@ -327,13 +464,35 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
327
464
|
return new THREE.MeshStandardMaterial({ color, roughness: 0.8, metalness: 0.03, ...options });
|
|
328
465
|
}
|
|
329
466
|
|
|
330
|
-
|
|
331
|
-
|
|
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();
|
|
332
490
|
map.needsUpdate = true;
|
|
333
|
-
map.repeat.set(
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
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();
|
|
337
496
|
glow.needsUpdate = true;
|
|
338
497
|
glow.repeat.copy(map.repeat);
|
|
339
498
|
material.emissive = new THREE.Color(0xffc867);
|
|
@@ -341,49 +500,103 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
341
500
|
material.emissiveIntensity = sky.glow;
|
|
342
501
|
litMaterials.add(material);
|
|
343
502
|
}
|
|
344
|
-
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 });
|
|
345
512
|
}
|
|
346
513
|
|
|
347
514
|
function register(group, data) {
|
|
348
515
|
group.userData = data;
|
|
349
516
|
pickables.push(group);
|
|
350
|
-
const materials =
|
|
517
|
+
const materials = new Set();
|
|
351
518
|
group.traverse((child) => {
|
|
352
|
-
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
|
+
}
|
|
353
523
|
});
|
|
354
|
-
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 })));
|
|
355
525
|
}
|
|
356
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;
|
|
357
538
|
function road(from, to, width = 1.9) {
|
|
358
539
|
const length = from.distanceTo(to);
|
|
359
540
|
const angle = -Math.atan2(to.z - from.z, to.x - from.x) + Math.PI / 2;
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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);
|
|
366
555
|
}
|
|
367
556
|
|
|
368
|
-
// Trees are many; they are drawn as
|
|
557
|
+
// Trees are many; they are drawn as instanced meshes (trunks, conifers, broadleaves) once the world is built.
|
|
369
558
|
const treePlacements = [];
|
|
370
|
-
function tree(x, z, scale = 1) {
|
|
371
|
-
treePlacements.push([x, z, scale]);
|
|
559
|
+
function tree(x, z, scale = 1, y = 0) {
|
|
560
|
+
treePlacements.push([x, z, scale, y]);
|
|
372
561
|
}
|
|
373
562
|
function plantTrees() {
|
|
374
563
|
if (treePlacements.length === 0) return;
|
|
375
|
-
const
|
|
376
|
-
const
|
|
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));
|
|
377
572
|
const matrix = new THREE.Matrix4();
|
|
573
|
+
const rotation = new THREE.Quaternion();
|
|
378
574
|
const tint = new THREE.Color();
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
matrix.
|
|
383
|
-
|
|
384
|
-
|
|
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));
|
|
385
595
|
});
|
|
386
|
-
|
|
596
|
+
lower.count = conifers.length;
|
|
597
|
+
upper.count = conifers.length;
|
|
598
|
+
crowns.count = broadleaves.length;
|
|
599
|
+
for (const instanced of [trunks, lower, upper, crowns]) {
|
|
387
600
|
instanced.castShadow = true;
|
|
388
601
|
instanced.receiveShadow = true;
|
|
389
602
|
scene.add(instanced);
|
|
@@ -392,52 +605,13 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
392
605
|
}
|
|
393
606
|
|
|
394
607
|
// --- scenery (no data) -----------------------------------------------------
|
|
395
|
-
function grassTexture() {
|
|
396
|
-
const texture = canvasTexture(doc, 256, 256, (ctx, w, h) => {
|
|
397
|
-
ctx.fillStyle = '#cfe0c2';
|
|
398
|
-
ctx.fillRect(0, 0, w, h);
|
|
399
|
-
for (let i = 0; i < 900; i += 1) {
|
|
400
|
-
const tone = hashUnit(`g${i}t`);
|
|
401
|
-
ctx.fillStyle = tone > 0.66 ? 'rgba(150,186,132,0.35)' : tone > 0.33 ? 'rgba(214,230,196,0.45)' : 'rgba(176,204,152,0.3)';
|
|
402
|
-
ctx.beginPath();
|
|
403
|
-
ctx.arc(hashUnit(`g${i}x`) * w, hashUnit(`g${i}y`) * h, 2 + hashUnit(`g${i}r`) * 7, 0, Math.PI * 2);
|
|
404
|
-
ctx.fill();
|
|
405
|
-
}
|
|
406
|
-
});
|
|
407
|
-
texture.wrapS = THREE.RepeatWrapping;
|
|
408
|
-
texture.wrapT = THREE.RepeatWrapping;
|
|
409
|
-
texture.repeat.set(18, 18);
|
|
410
|
-
return texture;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
function waterTexture() {
|
|
414
|
-
const texture = canvasTexture(doc, 128, 128, (ctx, w, h) => {
|
|
415
|
-
ctx.fillStyle = '#8fc0d2';
|
|
416
|
-
ctx.fillRect(0, 0, w, h);
|
|
417
|
-
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
|
|
418
|
-
ctx.lineWidth = 1.2;
|
|
419
|
-
for (let i = 0; i < 26; i += 1) {
|
|
420
|
-
const x = hashUnit(`w${i}x`) * w;
|
|
421
|
-
const y = hashUnit(`w${i}y`) * h;
|
|
422
|
-
ctx.beginPath();
|
|
423
|
-
ctx.moveTo(x, y);
|
|
424
|
-
ctx.quadraticCurveTo(x + 6, y - 3, x + 12, y);
|
|
425
|
-
ctx.stroke();
|
|
426
|
-
}
|
|
427
|
-
});
|
|
428
|
-
texture.wrapS = THREE.RepeatWrapping;
|
|
429
|
-
texture.wrapT = THREE.RepeatWrapping;
|
|
430
|
-
texture.repeat.set(40, 40);
|
|
431
|
-
return texture;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
608
|
function pavingTexture() {
|
|
435
|
-
|
|
609
|
+
const texture = canvasTexture(doc, 256, 256, (ctx, w, h) => {
|
|
436
610
|
ctx.fillStyle = '#f1ece1';
|
|
437
611
|
ctx.fillRect(0, 0, w, h);
|
|
438
|
-
ctx.strokeStyle = 'rgba(176,164,140,0.
|
|
439
|
-
ctx.lineWidth = 1;
|
|
440
|
-
for (let r =
|
|
612
|
+
ctx.strokeStyle = 'rgba(176,164,140,0.5)';
|
|
613
|
+
ctx.lineWidth = 1.5;
|
|
614
|
+
for (let r = 16; r < w / 2; r += 18) {
|
|
441
615
|
ctx.beginPath();
|
|
442
616
|
ctx.arc(w / 2, h / 2, r, 0, Math.PI * 2);
|
|
443
617
|
ctx.stroke();
|
|
@@ -445,67 +619,156 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
445
619
|
for (let i = 0; i < 24; i += 1) {
|
|
446
620
|
const a = (i / 24) * Math.PI * 2;
|
|
447
621
|
ctx.beginPath();
|
|
448
|
-
ctx.moveTo(w / 2 + Math.cos(a) *
|
|
622
|
+
ctx.moveTo(w / 2 + Math.cos(a) * 16, h / 2 + Math.sin(a) * 16);
|
|
449
623
|
ctx.lineTo(w / 2 + Math.cos(a) * w / 2, h / 2 + Math.sin(a) * h / 2);
|
|
450
624
|
ctx.stroke();
|
|
451
625
|
}
|
|
452
626
|
});
|
|
627
|
+
texture.anisotropy = maxAnisotropy;
|
|
628
|
+
return texture;
|
|
453
629
|
}
|
|
454
630
|
|
|
631
|
+
const grass = { low: new THREE.Color(0x9fbf8c), high: new THREE.Color(0xd3e4bd), warm: new THREE.Color(0xd9d6a6), sand: new THREE.Color(0xeadfc2) };
|
|
455
632
|
/** Land, beach, sea and a far shore of mountains around the cities; scenery only. */
|
|
456
633
|
function buildTerrain(extent) {
|
|
457
634
|
const landRadius = extent + 22;
|
|
458
|
-
|
|
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 });
|
|
459
644
|
land.rotation.x = -Math.PI / 2;
|
|
460
645
|
land.receiveShadow = true;
|
|
461
646
|
scene.add(land);
|
|
462
|
-
|
|
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 });
|
|
463
656
|
beach.rotation.x = -Math.PI / 2;
|
|
464
|
-
beach.receiveShadow = false;
|
|
465
657
|
scene.add(beach);
|
|
466
|
-
|
|
467
|
-
const sea = mesh(new THREE.
|
|
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 });
|
|
468
664
|
sea.rotation.x = -Math.PI / 2;
|
|
469
665
|
sea.receiveShadow = false;
|
|
470
666
|
scene.add(sea);
|
|
471
|
-
|
|
472
|
-
|
|
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);
|
|
473
674
|
for (let i = 0; i < 22; i += 1) {
|
|
474
675
|
const angle = Math.PI * 0.85 + (i / 21) * Math.PI * 0.95 + (hashUnit(`m${i}a`) - 0.5) * 0.08;
|
|
475
|
-
const distance = landRadius +
|
|
476
|
-
const peak = 10 + hashUnit(`m${i}h`) *
|
|
477
|
-
const radius =
|
|
478
|
-
const
|
|
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 });
|
|
479
695
|
mountain.rotation.y = hashUnit(`m${i}y`) * Math.PI;
|
|
480
696
|
mountain.receiveShadow = false;
|
|
481
697
|
scene.add(mountain);
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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);
|
|
486
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);
|
|
487
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();
|
|
488
721
|
return landRadius;
|
|
489
722
|
}
|
|
490
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
|
+
|
|
491
733
|
const lampPlacements = [];
|
|
492
|
-
function streetLamp(x, z) {
|
|
493
|
-
lampPlacements.push([x, z]);
|
|
734
|
+
function streetLamp(x, z, y = 0) {
|
|
735
|
+
lampPlacements.push([x, z, y]);
|
|
494
736
|
}
|
|
495
737
|
function placeLamps() {
|
|
496
738
|
if (lampPlacements.length === 0) return;
|
|
497
|
-
const poles = new THREE.InstancedMesh(new THREE.CylinderGeometry(0.05, 0.07, 1.6, 6),
|
|
498
|
-
const
|
|
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 });
|
|
499
742
|
lampMaterials.push(bulbMaterial);
|
|
500
|
-
const bulbs = new THREE.InstancedMesh(new THREE.SphereGeometry(0.
|
|
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);
|
|
501
756
|
const matrix = new THREE.Matrix4();
|
|
502
|
-
lampPlacements.forEach(([x, z], index) => {
|
|
503
|
-
poles.setMatrixAt(index, matrix.makeTranslation(x, 0.8, z));
|
|
504
|
-
|
|
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));
|
|
505
762
|
});
|
|
506
|
-
|
|
763
|
+
poles.castShadow = true;
|
|
764
|
+
pools.renderOrder = 1;
|
|
765
|
+
scene.add(poles, arms, bulbs, pools);
|
|
766
|
+
applyLampPools();
|
|
507
767
|
lampPlacements.length = 0;
|
|
508
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
|
+
}
|
|
509
772
|
|
|
510
773
|
/** Trees and lamps along a road, kept off the plaza and the city plates. */
|
|
511
774
|
function avenue(from, to, keepOut) {
|
|
@@ -570,7 +833,9 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
570
833
|
hemisphere.intensity = sky.hemi;
|
|
571
834
|
renderer.toneMappingExposure = sky.exposure;
|
|
572
835
|
for (const material of litMaterials) if (!highlightedMaterials.has(material)) material.emissiveIntensity = sky.glow;
|
|
573
|
-
for (const material of lampMaterials) material.emissiveIntensity = sky.lamps ? 1.
|
|
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);
|
|
574
839
|
}
|
|
575
840
|
const highlightedMaterials = new Set();
|
|
576
841
|
|
|
@@ -613,12 +878,14 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
613
878
|
const group = new THREE.Group();
|
|
614
879
|
group.position.set(x, 0.7, z);
|
|
615
880
|
const color = STATE_COLORS[row.state] ?? 0x929b95;
|
|
616
|
-
group.add(mesh(new THREE.BoxGeometry(2.
|
|
617
|
-
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 });
|
|
618
884
|
body.position.y += 0.25;
|
|
619
885
|
group.add(body);
|
|
620
|
-
|
|
621
|
-
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;
|
|
622
889
|
group.add(cap);
|
|
623
890
|
scene.add(group);
|
|
624
891
|
register(group, { kind: 'judgment', row, items: rowItems.get(row.key) ?? [] });
|
|
@@ -646,6 +913,15 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
646
913
|
}
|
|
647
914
|
|
|
648
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
|
+
|
|
649
925
|
function landmark(form, color, height, status, active = true) {
|
|
650
926
|
const group = new THREE.Group();
|
|
651
927
|
const phase = statusPhase(status);
|
|
@@ -654,22 +930,60 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
654
930
|
const lit = !concept && active;
|
|
655
931
|
const tone = phase === 'maintenance' ? muted(color, 0.55) : new THREE.Color(color);
|
|
656
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
|
+
};
|
|
657
939
|
if (form === 'tower') {
|
|
658
|
-
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
|
|
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 }));
|
|
662
950
|
} else if (form === 'hall') {
|
|
663
|
-
|
|
664
|
-
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 });
|
|
665
957
|
roof.rotation.y = Math.PI / 2;
|
|
666
|
-
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 }));
|
|
667
967
|
} else if (form === 'dome') {
|
|
668
|
-
|
|
669
|
-
|
|
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);
|
|
670
978
|
} else {
|
|
671
|
-
|
|
672
|
-
|
|
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);
|
|
673
987
|
}
|
|
674
988
|
if (concept) {
|
|
675
989
|
// Scaffold outline: a business that is still a concept.
|
|
@@ -680,25 +994,36 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
680
994
|
return group;
|
|
681
995
|
}
|
|
682
996
|
|
|
997
|
+
const HOUSE_WALLS = [0xf4efe4, 0xefe6d6, 0xf2ece6, 0xe9e4d6];
|
|
683
998
|
function house(engagement, color) {
|
|
684
999
|
const group = new THREE.Group();
|
|
685
1000
|
const status = engagement.status;
|
|
686
1001
|
const phase = statusPhase(status);
|
|
687
1002
|
if (phase === 'finished') {
|
|
688
|
-
// A finished engagement becomes a small memorial park: a lawn, a stone
|
|
689
|
-
group.add(mesh(new THREE.BoxGeometry(1.
|
|
690
|
-
|
|
691
|
-
group.add(mesh(new THREE.
|
|
692
|
-
|
|
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 }));
|
|
693
1012
|
group.userData.finished = true;
|
|
694
1013
|
return group;
|
|
695
1014
|
}
|
|
1015
|
+
// One house per engagement; the roof takes the kind's colour, lit windows mean the engagement is active.
|
|
696
1016
|
const h = 0.9 + hashUnit(engagement.id) * 1.1;
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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;
|
|
701
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 }));
|
|
702
1027
|
return group;
|
|
703
1028
|
}
|
|
704
1029
|
|
|
@@ -738,8 +1063,11 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
738
1063
|
avenue(start, new THREE.Vector3(x, 0, z), keepOut);
|
|
739
1064
|
const group = new THREE.Group();
|
|
740
1065
|
group.position.set(x, 0, z);
|
|
741
|
-
|
|
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);
|
|
742
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);
|
|
743
1071
|
const measures = cityMeasures(business, { isFinished, judgments: judgmentsByBusiness.get(business.code) ?? [] });
|
|
744
1072
|
const towerHeight = measures.towerHeight;
|
|
745
1073
|
group.add(landmark(entry.form, color, towerHeight, business.status, measures.lit));
|
|
@@ -782,7 +1110,7 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
782
1110
|
const treeCount = 2 + Math.floor(hashUnit(business.id) * 3);
|
|
783
1111
|
for (let t = 0; t < treeCount; t += 1) {
|
|
784
1112
|
const a = hashUnit(`${business.id}${t}`) * Math.PI * 2;
|
|
785
|
-
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);
|
|
786
1114
|
}
|
|
787
1115
|
}
|
|
788
1116
|
countryside(landRadius, cities, roads);
|
|
@@ -1037,7 +1365,8 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
|
|
|
1037
1365
|
}
|
|
1038
1366
|
}
|
|
1039
1367
|
if (!reducedMotion) {
|
|
1040
|
-
if (animated.
|
|
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;
|
|
1041
1370
|
for (const jet of animated.jets) jet.scale.y = 0.85 + 0.2 * Math.sin(time / 260);
|
|
1042
1371
|
const wave = (1 + Math.sin(time / 380)) / 2;
|
|
1043
1372
|
for (const beam of beacons) beam.material.opacity = 0.35 + 0.3 * wave;
|