@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.
Files changed (2) hide show
  1. package/package.json +2 -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.6",
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": "94fabc0ef66f6296d9f68fb5e4e54d87a63fc65b"
361
+ "gitHead": "9d594a6e3267d90308404d606a087cbd6191baec"
362
362
  }
@@ -182,18 +182,154 @@ function canvasTexture(doc, width, height, draw) {
182
182
  return texture;
183
183
  }
184
184
 
185
- function windowTexture(doc, lit, glowOnly = false) {
186
- const texture = canvasTexture(doc, 64, 64, (ctx, w, h) => {
187
- ctx.fillStyle = glowOnly ? '#000000' : '#ffffff';
188
- ctx.fillRect(0, 0, w, h);
189
- ctx.fillStyle = glowOnly ? '#ffffff' : lit ? '#ffe2a0' : '#c6d3de';
190
- for (let y = 8; y < h; y += 16) {
191
- for (let x = 6; x < w; x += 14) ctx.fillRect(x, y, 8, 8);
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
- texture.wrapS = THREE.RepeatWrapping;
195
- texture.wrapT = THREE.RepeatWrapping;
196
- return texture;
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
- function texturedBlock(w, h, d, color, lit, options = {}) {
331
- const map = (lit ? textures.lit : textures.unlit).clone();
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(Math.max(1, Math.round(w / 1.4)), Math.max(1, Math.round(h / 1.2)));
334
- const material = standard(color, { map, ...options });
335
- if (lit) {
336
- const glow = textures.glow.clone();
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 mesh(new THREE.BoxGeometry(w, h, d), material, { y: h / 2 });
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 && child.material?.emissive) materials.push(child.material);
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 surface = mesh(new THREE.PlaneGeometry(width, length), standard(0xd3d9d1, { roughness: 1 }), { x: (from.x + to.x) / 2, y: 0.03, z: (from.z + to.z) / 2, shadow: false });
361
- surface.rotation.set(-Math.PI / 2, 0, angle);
362
- scene.add(surface);
363
- const line = mesh(new THREE.PlaneGeometry(0.12, length), new THREE.MeshBasicMaterial({ color: 0xf8faf7 }), { x: (from.x + to.x) / 2, y: 0.05, z: (from.z + to.z) / 2, shadow: false });
364
- line.rotation.set(-Math.PI / 2, 0, angle);
365
- scene.add(line);
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 two instanced meshes (trunks, crowns) once the world is built.
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 trunks = new THREE.InstancedMesh(new THREE.CylinderGeometry(0.12, 0.16, 0.7, 6), standard(0x8a6d4e), treePlacements.length);
376
- const crowns = new THREE.InstancedMesh(new THREE.ConeGeometry(0.7, 1.6, 7), standard(0x6f9a72, { roughness: 0.95 }), treePlacements.length);
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
- treePlacements.forEach(([x, z, scale], index) => {
380
- matrix.makeScale(scale, scale, scale).setPosition(x, 0.35 * scale, z);
381
- trunks.setMatrixAt(index, matrix);
382
- matrix.makeScale(scale, scale, scale).setPosition(x, 1.4 * scale, z);
383
- crowns.setMatrixAt(index, matrix);
384
- crowns.setColorAt(index, tint.setHSL(0.33 + (hashUnit(`${x},${z}`) - 0.5) * 0.05, 0.22, 0.45 + (hashUnit(`${z},${x}`) - 0.5) * 0.1));
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
- for (const instanced of [trunks, crowns]) {
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
- return canvasTexture(doc, 128, 128, (ctx, w, h) => {
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.55)';
439
- ctx.lineWidth = 1;
440
- for (let r = 8; r < w / 2; r += 9) {
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) * 8, h / 2 + Math.sin(a) * 8);
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
- const land = mesh(new THREE.CircleGeometry(landRadius, 96), standard(0xffffff, { map: grassTexture(), roughness: 1 }), { shadow: false });
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
- const beach = mesh(new THREE.RingGeometry(landRadius, landRadius + 4.5, 96), new THREE.MeshLambertMaterial({ color: 0xeadfc4 }), { y: -0.05, shadow: false });
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
- const water = waterTexture();
467
- const sea = mesh(new THREE.PlaneGeometry(900, 900), new THREE.MeshLambertMaterial({ map: water }), { y: -0.35, shadow: false });
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
- animated.water = water;
472
- // A far shore: low mountains across the water, faded by the fog.
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 + 110 + hashUnit(`m${i}d`) * 50;
476
- const peak = 10 + hashUnit(`m${i}h`) * 16;
477
- const radius = 10 + hashUnit(`m${i}r`) * 9;
478
- const mountain = mesh(new THREE.ConeGeometry(radius, peak, 7), new THREE.MeshLambertMaterial({ color: 0xb4c4bc, flatShading: true }), { x: Math.cos(angle) * distance, y: peak / 2 - 0.4, z: Math.sin(angle) * distance, shadow: false });
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
- if (peak > 21) {
483
- const cap = mesh(new THREE.ConeGeometry(radius * 0.32, peak * 0.3, 7), new THREE.MeshLambertMaterial({ color: 0xf4f6f6, flatShading: true }), { x: mountain.position.x, y: peak - 0.4 - peak * 0.15, z: mountain.position.z, shadow: false });
484
- cap.rotation.y = mountain.rotation.y;
485
- scene.add(cap);
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), new THREE.MeshLambertMaterial({ color: 0x4b5550 }), lampPlacements.length);
498
- const bulbMaterial = standard(0xfff3d6, { emissive: new THREE.Color(0xffd27a), emissiveIntensity: sky.lamps ? 1.6 : 0 });
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.16, 10, 8), bulbMaterial, lampPlacements.length);
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
- bulbs.setMatrixAt(index, matrix.makeTranslation(x, 1.68, z));
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
- scene.add(poles, bulbs);
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.6 : 0;
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.6, 0.25, 2.6), standard(0xe6ded0), { y: 0.12 }));
617
- const body = texturedBlock(2.1, height, 2.1, color, recent);
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
- const cap = texturedBlock(1.4, 0.7, 1.4, color, recent);
621
- cap.position.y += 0.25 + height;
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
- const base = texturedBlock(3, height, 3, tone, lit, { roughness: 0.4, ...options });
659
- const mid = texturedBlock(2.2, height * 0.45, 2.2, tone, lit, { roughness: 0.4, ...options });
660
- mid.position.y += height;
661
- group.add(base, mid, mesh(new THREE.CylinderGeometry(0.05, 0.05, 1.6, 6), standard(0x46524a), { y: height * 1.45 + 0.8 }));
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
- const hall = texturedBlock(4.4, height * 0.55, 3, tone, lit, options);
664
- const roof = mesh(gableRoof(4.4, 3, 1.4), standard(muted(color, 0.15).multiplyScalar(0.85), options), { y: height * 0.55 });
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(hall, roof);
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
- group.add(mesh(new THREE.CylinderGeometry(1.9, 2.1, height * 0.5, 24), standard(tone, options), { y: height * 0.25 }));
669
- group.add(mesh(new THREE.SphereGeometry(1.9, 24, 12, 0, Math.PI * 2, 0, Math.PI / 2), standard(0xf3f6f8, { roughness: 0.3, ...options }), { y: height * 0.5 }));
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
- group.add(texturedBlock(3.6, height * 0.75, 2.8, tone, lit, options));
672
- group.add(mesh(new THREE.BoxGeometry(1.2, 0.6, 1), standard(0xb8c2bb), { y: height * 0.75 + 0.3 }));
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 and two bushes.
689
- group.add(mesh(new THREE.BoxGeometry(1.8, 0.16, 1.8), standard(0xa9c99a, { roughness: 1 }), { y: 0.08 }));
690
- group.add(mesh(new THREE.BoxGeometry(0.28, 0.9, 0.28), standard(0xdcd5c6), { y: 0.61 }));
691
- group.add(mesh(new THREE.ConeGeometry(0.2, 0.25, 4), standard(0xdcd5c6), { y: 1.18 }));
692
- for (const [bx, bz] of [[-0.55, 0.45], [0.55, -0.4]]) group.add(mesh(new THREE.SphereGeometry(0.28, 8, 6), standard(0x7fa877, { roughness: 1 }), { x: bx, y: 0.3, z: bz }));
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 tone = phase === 'maintenance' ? muted(color, 0.6) : new THREE.Color(0xf2efe8);
698
- group.add(texturedBlock(1.6, h, 1.4, tone, phase === 'active'));
699
- const roof = mesh(gableRoof(1.6, 1.4, 0.8), standard(phase === 'maintenance' ? muted(color, 0.6) : color), { y: h });
700
- roof.rotation.y = hashUnit(`${engagement.id}r`) > 0.5 ? Math.PI / 2 : 0;
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
- const plateColor = new THREE.Color(0xf7f8f4).lerp(new THREE.Color(color), 0.1);
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.water) animated.water.offset.set((time / 90000) % 1, (time / 140000) % 1);
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;