@unson/brainbase-mcp 0.15.6 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,7 +25,6 @@ import {
25
25
  workspaceDetailEmpty,
26
26
  workspaceButton,
27
27
  } from '../../workspace-kit.js';
28
- import { organizationRecordAnchor, organizationRecordLink, organizationWebOrigin } from '../../graph-view-shared.js';
29
28
 
30
29
  export const WORLD_VIEW_CONTRACT_VERSION = 'brainbase.world-view.v0';
31
30
 
@@ -182,18 +181,154 @@ function canvasTexture(doc, width, height, draw) {
182
181
  return texture;
183
182
  }
184
183
 
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);
184
+ /** Smooth value noise in [0, 1]; used only to shade scenery (ground, sea, mountains) without repeating textures. */
185
+ function valueNoise(x, z, seed = 'n') {
186
+ const xi = Math.floor(x);
187
+ const zi = Math.floor(z);
188
+ const fx = x - xi;
189
+ const fz = z - zi;
190
+ const sx = fx * fx * (3 - 2 * fx);
191
+ const sz = fz * fz * (3 - 2 * fz);
192
+ const at = (i, j) => hashUnit(`${seed}${i},${j}`);
193
+ const near = at(xi, zi) + (at(xi + 1, zi) - at(xi, zi)) * sx;
194
+ const far = at(xi, zi + 1) + (at(xi + 1, zi + 1) - at(xi, zi + 1)) * sx;
195
+ return near + (far - near) * sz;
196
+ }
197
+
198
+ /** Colours a geometry per vertex from its position (x, y, z before any rotation). */
199
+ function paintVertices(geometry, colorAt) {
200
+ const position = geometry.attributes.position;
201
+ const colors = new Float32Array(position.count * 3);
202
+ const color = new THREE.Color();
203
+ for (let i = 0; i < position.count; i += 1) {
204
+ colorAt(color, position.getX(i), position.getY(i), position.getZ(i));
205
+ colors[i * 3] = color.r;
206
+ colors[i * 3 + 1] = color.g;
207
+ colors[i * 3 + 2] = color.b;
208
+ }
209
+ geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
210
+ return geometry;
211
+ }
212
+
213
+ /*
214
+ * Facades: one tile of a wall, drawn for a style and a wall colour. Windows are on the walls only (roofs
215
+ * get a plain material). Which panes are lit is fixed per pane, so the glow map matches the colour map;
216
+ * whether a building has lit windows at all still means "moved in the last 30 days".
217
+ */
218
+ 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] });
219
+
220
+ /** Tile size in pixels: glass and office tiles hold several floors so lit panes do not repeat as a pattern. */
221
+ const FACADE_SIZES = Object.freeze({ glass: 256, office: 256 });
222
+
223
+ function drawFacade(ctx, w, h, style, wall, lit, glowOnly) {
224
+ const wallColor = `#${new THREE.Color(wall).getHexString()}`;
225
+ const dark = `#${new THREE.Color(wall).multiplyScalar(0.72).getHexString()}`;
226
+ const light = `#${new THREE.Color(wall).lerp(new THREE.Color(0xffffff), 0.35).getHexString()}`;
227
+ ctx.fillStyle = glowOnly ? '#000000' : wallColor;
228
+ ctx.fillRect(0, 0, w, h);
229
+ const pane = (x, y, pw, ph, index) => {
230
+ const on = lit && hashUnit(`${style}pane${index}x`) < 0.55;
231
+ if (glowOnly) {
232
+ ctx.fillStyle = on ? '#ffffff' : '#000000';
233
+ ctx.fillRect(x, y, pw, ph);
234
+ return;
192
235
  }
193
- });
194
- texture.wrapS = THREE.RepeatWrapping;
195
- texture.wrapT = THREE.RepeatWrapping;
196
- return texture;
236
+ if (on) {
237
+ ctx.fillStyle = '#ffd98c';
238
+ ctx.fillRect(x, y, pw, ph);
239
+ ctx.fillStyle = 'rgba(255,244,214,0.55)';
240
+ ctx.fillRect(x, y, pw, ph * 0.3);
241
+ } else {
242
+ const glass = ctx.createLinearGradient(x, y, x + pw, y + ph);
243
+ glass.addColorStop(0, '#9fb3c4');
244
+ glass.addColorStop(0.5, '#56687a');
245
+ glass.addColorStop(1, '#3f4f60');
246
+ ctx.fillStyle = glass;
247
+ ctx.fillRect(x, y, pw, ph);
248
+ }
249
+ };
250
+ if (style === 'glass') {
251
+ // A curtain wall: four floors of four panes, thin mullions, a floor band under each floor.
252
+ const cw = w / 4;
253
+ const rh = h / 4;
254
+ for (let r = 0; r < 4; r += 1) {
255
+ if (!glowOnly) {
256
+ ctx.fillStyle = dark;
257
+ ctx.fillRect(0, r * rh + rh - 7, w, 7);
258
+ }
259
+ for (let c = 0; c < 4; c += 1) pane(c * cw + 4, r * rh + 5, cw - 8, rh - 15, r * 4 + c);
260
+ }
261
+ if (!glowOnly) {
262
+ ctx.fillStyle = light;
263
+ for (let c = 0; c <= 4; c += 1) ctx.fillRect(c * cw - 2, 0, 4, h);
264
+ }
265
+ } else if (style === 'office') {
266
+ // Ribbon windows: two floors, each a long band split by mullions over a concrete spandrel.
267
+ const rh = h / 2;
268
+ for (let r = 0; r < 2; r += 1) {
269
+ if (!glowOnly) {
270
+ ctx.fillStyle = light;
271
+ ctx.fillRect(0, r * rh, w, 22);
272
+ ctx.fillStyle = dark;
273
+ ctx.fillRect(0, r * rh + rh - 10, w, 6);
274
+ }
275
+ for (let c = 0; c < 8; c += 1) pane(4 + c * (w / 8), r * rh + 34, w / 8 - 6, rh - 54, r * 8 + c);
276
+ }
277
+ } else if (style === 'brick') {
278
+ if (!glowOnly) {
279
+ ctx.strokeStyle = 'rgba(0,0,0,0.07)';
280
+ ctx.lineWidth = 2;
281
+ for (let y = 16; y < h; y += 16) {
282
+ ctx.beginPath();
283
+ ctx.moveTo(0, y);
284
+ ctx.lineTo(w, y);
285
+ ctx.stroke();
286
+ }
287
+ }
288
+ for (let c = 0; c < 2; c += 1) {
289
+ const x = 18 + c * 58;
290
+ if (!glowOnly) {
291
+ ctx.fillStyle = '#f5f1e8';
292
+ ctx.fillRect(x - 5, 22, 44, 82);
293
+ }
294
+ pane(x, 26, 34, 74, c);
295
+ if (!glowOnly) {
296
+ ctx.fillStyle = '#f5f1e8';
297
+ ctx.fillRect(x + 15, 26, 4, 74);
298
+ ctx.fillRect(x, 58, 34, 4);
299
+ }
300
+ }
301
+ } else if (style === 'civic') {
302
+ // Pilasters and tall windows.
303
+ if (!glowOnly) {
304
+ ctx.fillStyle = light;
305
+ ctx.fillRect(0, 0, 18, h);
306
+ ctx.fillRect(w - 18, 0, 18, h);
307
+ }
308
+ pane(30, 14, 68, 96, 0);
309
+ if (!glowOnly) {
310
+ ctx.fillStyle = light;
311
+ ctx.fillRect(62, 14, 4, 96);
312
+ }
313
+ } else if (style === 'research') {
314
+ for (let c = 0; c < 2; c += 1) pane(20 + c * 60, 18, 28, 90, c);
315
+ } else if (style === 'house') {
316
+ // A plastered wall with two framed windows (doors are separate meshes).
317
+ for (let c = 0; c < 2; c += 1) {
318
+ const x = 22 + c * 52;
319
+ if (!glowOnly) {
320
+ ctx.fillStyle = '#ffffff';
321
+ ctx.fillRect(x - 4, 36, 40, 50);
322
+ }
323
+ pane(x, 40, 32, 42, c);
324
+ if (!glowOnly) {
325
+ ctx.fillStyle = '#ffffff';
326
+ ctx.fillRect(x + 14, 40, 4, 42);
327
+ ctx.fillStyle = dark;
328
+ ctx.fillRect(x - 6, 86, 44, 5);
329
+ }
330
+ }
331
+ }
197
332
  }
198
333
 
199
334
  function roundedPlate(width, depth, height, radius) {
@@ -289,6 +424,7 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
289
424
  sun.shadow.mapSize.set(2048, 2048);
290
425
  sun.shadow.radius = 4;
291
426
  sun.shadow.bias = -0.0004;
427
+ sun.shadow.normalBias = 0.03;
292
428
  Object.assign(sun.shadow.camera, { left: -150, right: 150, top: 150, bottom: -150, near: 1, far: 420 });
293
429
  scene.add(sun);
294
430
  const fill = new THREE.DirectionalLight(0xdfe9ff, 0.6);
@@ -298,9 +434,9 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
298
434
  // Lit windows and street lamps follow the sky (brightest at night); their meaning does not change.
299
435
  const litMaterials = new Set();
300
436
  const lampMaterials = [];
301
- const animated = { water: null, jets: [], sparkles: [] };
437
+ const animated = { water: null, jets: [], sparkles: [], clouds: [], foam: null };
438
+ const maxAnisotropy = renderer.capabilities.getMaxAnisotropy?.() ?? 1;
302
439
 
303
- const textures = { lit: windowTexture(doc, true), unlit: windowTexture(doc, false), glow: windowTexture(doc, true, true) };
304
440
  const pickables = [];
305
441
  const labels = [];
306
442
  const beacons = [];
@@ -327,13 +463,35 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
327
463
  return new THREE.MeshStandardMaterial({ color, roughness: 0.8, metalness: 0.03, ...options });
328
464
  }
329
465
 
330
- function texturedBlock(w, h, d, color, lit, options = {}) {
331
- const map = (lit ? textures.lit : textures.unlit).clone();
466
+ const facadeCache = new Map();
467
+ function facadeTiles(style, wall, lit) {
468
+ const key = `${style}|${new THREE.Color(wall).getHexString()}|${lit}`;
469
+ if (!facadeCache.has(key)) {
470
+ const make = (glowOnly) => {
471
+ const size = FACADE_SIZES[style] ?? 128;
472
+ const texture = canvasTexture(doc, size, size, (ctx, w, h) => drawFacade(ctx, w, h, style, wall, lit, glowOnly));
473
+ texture.wrapS = THREE.RepeatWrapping;
474
+ texture.wrapT = THREE.RepeatWrapping;
475
+ texture.anisotropy = maxAnisotropy;
476
+ return texture;
477
+ };
478
+ facadeCache.set(key, { map: make(false), glow: lit ? make(true) : null });
479
+ }
480
+ return facadeCache.get(key);
481
+ }
482
+
483
+ /** A wall material for a face `span` wide and `h` tall: windows on the wall, glowing if the building is lit. */
484
+ function facadeMaterial(style, wall, lit, span, h, options = {}) {
485
+ const tiles = facadeTiles(style, wall, lit);
486
+ const [unitW, unitH] = FACADE_UNITS[style] ?? [1, 1];
487
+ 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))];
488
+ const map = tiles.map.clone();
332
489
  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();
490
+ map.repeat.set(...repeat);
491
+ const glass = style === 'glass';
492
+ const material = standard(0xffffff, { map, roughness: glass ? 0.32 : 0.85, metalness: glass ? 0.18 : 0.02, ...options });
493
+ if (tiles.glow) {
494
+ const glow = tiles.glow.clone();
337
495
  glow.needsUpdate = true;
338
496
  glow.repeat.copy(map.repeat);
339
497
  material.emissive = new THREE.Color(0xffc867);
@@ -341,49 +499,103 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
341
499
  material.emissiveIntensity = sky.glow;
342
500
  litMaterials.add(material);
343
501
  }
344
- return mesh(new THREE.BoxGeometry(w, h, d), material, { y: h / 2 });
502
+ return material;
503
+ }
504
+
505
+ /** A block with facades on its four walls and a plain roof; stands on y = 0 of its parent. */
506
+ function block(w, h, d, { style = 'office', wall = 0xe9e4da, lit = false, roof = 0xc9c5bb, ...options } = {}) {
507
+ const alongX = facadeMaterial(style, wall, lit, d, h, options);
508
+ const alongZ = facadeMaterial(style, wall, lit, w, h, options);
509
+ const top = standard(roof, { roughness: 0.95, ...options });
510
+ return mesh(new THREE.BoxGeometry(w, h, d), [alongX, alongX, top, top, alongZ, alongZ], { y: h / 2 });
345
511
  }
346
512
 
347
513
  function register(group, data) {
348
514
  group.userData = data;
349
515
  pickables.push(group);
350
- const materials = [];
516
+ const materials = new Set();
351
517
  group.traverse((child) => {
352
- if (child.isMesh && child.material?.emissive) materials.push(child.material);
518
+ if (!child.isMesh) return;
519
+ for (const material of Array.isArray(child.material) ? child.material : [child.material]) {
520
+ if (material?.emissive) materials.add(material);
521
+ }
353
522
  });
354
- highlightable.set(group, materials.map((material) => ({ material, base: material.emissive.clone(), intensity: material.emissiveIntensity })));
523
+ highlightable.set(group, [...materials].map((material) => ({ material, base: material.emissive.clone(), intensity: material.emissiveIntensity })));
355
524
  }
356
525
 
526
+ const roadMaterials = {
527
+ asphalt: standard(0x8e938f, { roughness: 0.95 }),
528
+ walk: standard(0xe4dfd2, { roughness: 1 }),
529
+ };
530
+ const dashTexture = canvasTexture(doc, 8, 64, (ctx, w, h) => {
531
+ ctx.clearRect(0, 0, w, h);
532
+ ctx.fillStyle = '#ffffff';
533
+ ctx.fillRect(1, 0, w - 2, h * 0.55);
534
+ });
535
+ dashTexture.wrapT = THREE.RepeatWrapping;
536
+ dashTexture.anisotropy = maxAnisotropy;
357
537
  function road(from, to, width = 1.9) {
358
538
  const length = from.distanceTo(to);
359
539
  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);
540
+ const at = { x: (from.x + to.x) / 2, z: (from.z + to.z) / 2 };
541
+ const strip = (stripWidth, material, y, offset = 0) => {
542
+ const surface = mesh(new THREE.PlaneGeometry(stripWidth, length), material, { x: at.x, y, z: at.z, shadow: false });
543
+ surface.rotation.set(-Math.PI / 2, 0, angle);
544
+ if (offset !== 0) surface.translateX(offset);
545
+ scene.add(surface);
546
+ };
547
+ // Sidewalks on both sides, asphalt between, a dashed centre line.
548
+ strip(width + 0.7, roadMaterials.walk, 0.025);
549
+ strip(width, roadMaterials.asphalt, 0.04);
550
+ const dashes = dashTexture.clone();
551
+ dashes.needsUpdate = true;
552
+ dashes.repeat.set(1, Math.max(1, Math.round(length / 1.6)));
553
+ strip(0.1, new THREE.MeshBasicMaterial({ map: dashes, transparent: true, opacity: 0.85, color: 0xf6f3e6 }), 0.055);
366
554
  }
367
555
 
368
- // Trees are many; they are drawn as two instanced meshes (trunks, crowns) once the world is built.
556
+ // Trees are many; they are drawn as instanced meshes (trunks, conifers, broadleaves) once the world is built.
369
557
  const treePlacements = [];
370
- function tree(x, z, scale = 1) {
371
- treePlacements.push([x, z, scale]);
558
+ function tree(x, z, scale = 1, y = 0) {
559
+ treePlacements.push([x, z, scale, y]);
372
560
  }
373
561
  function plantTrees() {
374
562
  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);
563
+ const conifers = [];
564
+ const broadleaves = [];
565
+ for (const placement of treePlacements) (hashUnit(`${placement[0]},${placement[1]}k`) < 0.42 ? conifers : broadleaves).push(placement);
566
+ const foliage = (color) => standard(color, { roughness: 0.9, flatShading: true });
567
+ const trunks = new THREE.InstancedMesh(new THREE.CylinderGeometry(0.1, 0.15, 0.8, 6), standard(0x7d6249, { roughness: 1 }), treePlacements.length);
568
+ const lower = new THREE.InstancedMesh(new THREE.ConeGeometry(0.78, 1.3, 7), foliage(0xffffff), Math.max(conifers.length, 1));
569
+ const upper = new THREE.InstancedMesh(new THREE.ConeGeometry(0.55, 1.05, 7), foliage(0xffffff), Math.max(conifers.length, 1));
570
+ const crowns = new THREE.InstancedMesh(new THREE.IcosahedronGeometry(0.72, 0), foliage(0xffffff), Math.max(broadleaves.length, 1));
377
571
  const matrix = new THREE.Matrix4();
572
+ const rotation = new THREE.Quaternion();
378
573
  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));
574
+ const up = new THREE.Vector3(0, 1, 0);
575
+ const place = (instanced, index, x, y, z, sx, sy, sz, spin) => {
576
+ rotation.setFromAxisAngle(up, spin);
577
+ matrix.compose(new THREE.Vector3(x, y, z), rotation, new THREE.Vector3(sx, sy, sz));
578
+ instanced.setMatrixAt(index, matrix);
579
+ };
580
+ treePlacements.forEach(([x, z, scale, y], index) => place(trunks, index, x, y + 0.4 * scale, z, scale, scale, scale, 0));
581
+ conifers.forEach(([x, z, scale, y], index) => {
582
+ const spin = hashUnit(`${x}${z}s`) * Math.PI;
583
+ const tall = 0.9 + hashUnit(`${z}${x}t`) * 0.35;
584
+ place(lower, index, x, y + 1.15 * scale * tall, z, scale, scale * tall, scale, spin);
585
+ place(upper, index, x, y + 1.85 * scale * tall, z, scale, scale * tall, scale, spin);
586
+ const shade = tint.setHSL(0.37 + (hashUnit(`${x},${z}`) - 0.5) * 0.04, 0.34, 0.3 + hashUnit(`${z},${x}`) * 0.08);
587
+ lower.setColorAt(index, shade);
588
+ upper.setColorAt(index, shade.offsetHSL(0, 0, 0.04));
589
+ });
590
+ broadleaves.forEach(([x, z, scale, y], index) => {
591
+ const squash = 0.85 + hashUnit(`${x}${z}q`) * 0.25;
592
+ place(crowns, index, x, y + 1.25 * scale, z, scale * squash, scale, scale * (1.9 - squash), hashUnit(`${x}${z}s`) * Math.PI);
593
+ crowns.setColorAt(index, tint.setHSL(0.24 + hashUnit(`${x},${z}h`) * 0.08, 0.38, 0.4 + hashUnit(`${z},${x}l`) * 0.12));
385
594
  });
386
- for (const instanced of [trunks, crowns]) {
595
+ lower.count = conifers.length;
596
+ upper.count = conifers.length;
597
+ crowns.count = broadleaves.length;
598
+ for (const instanced of [trunks, lower, upper, crowns]) {
387
599
  instanced.castShadow = true;
388
600
  instanced.receiveShadow = true;
389
601
  scene.add(instanced);
@@ -392,52 +604,13 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
392
604
  }
393
605
 
394
606
  // --- 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
607
  function pavingTexture() {
435
- return canvasTexture(doc, 128, 128, (ctx, w, h) => {
608
+ const texture = canvasTexture(doc, 256, 256, (ctx, w, h) => {
436
609
  ctx.fillStyle = '#f1ece1';
437
610
  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) {
611
+ ctx.strokeStyle = 'rgba(176,164,140,0.5)';
612
+ ctx.lineWidth = 1.5;
613
+ for (let r = 16; r < w / 2; r += 18) {
441
614
  ctx.beginPath();
442
615
  ctx.arc(w / 2, h / 2, r, 0, Math.PI * 2);
443
616
  ctx.stroke();
@@ -445,67 +618,156 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
445
618
  for (let i = 0; i < 24; i += 1) {
446
619
  const a = (i / 24) * Math.PI * 2;
447
620
  ctx.beginPath();
448
- ctx.moveTo(w / 2 + Math.cos(a) * 8, h / 2 + Math.sin(a) * 8);
621
+ ctx.moveTo(w / 2 + Math.cos(a) * 16, h / 2 + Math.sin(a) * 16);
449
622
  ctx.lineTo(w / 2 + Math.cos(a) * w / 2, h / 2 + Math.sin(a) * h / 2);
450
623
  ctx.stroke();
451
624
  }
452
625
  });
626
+ texture.anisotropy = maxAnisotropy;
627
+ return texture;
453
628
  }
454
629
 
630
+ const grass = { low: new THREE.Color(0x9fbf8c), high: new THREE.Color(0xd3e4bd), warm: new THREE.Color(0xd9d6a6), sand: new THREE.Color(0xeadfc2) };
455
631
  /** Land, beach, sea and a far shore of mountains around the cities; scenery only. */
456
632
  function buildTerrain(extent) {
457
633
  const landRadius = extent + 22;
458
- const land = mesh(new THREE.CircleGeometry(landRadius, 96), standard(0xffffff, { map: grassTexture(), roughness: 1 }), { shadow: false });
634
+ // Land: gentle, non-repeating colour variation (a repeating texture shimmered into stripes).
635
+ const land = mesh(paintVertices(new THREE.RingGeometry(0.01, landRadius, 160, 48), (color, x, y) => {
636
+ const broad = valueNoise(x / 16, y / 16, 'land');
637
+ const fine = valueNoise(x / 4.5, y / 4.5, 'tuft');
638
+ color.copy(grass.low).lerp(grass.high, broad * 0.7 + fine * 0.3);
639
+ if (valueNoise(x / 22, y / 22, 'dry') > 0.68) color.lerp(grass.warm, 0.35);
640
+ const shore = Math.max(0, (Math.hypot(x, y) - (landRadius - 3)) / 3);
641
+ color.lerp(grass.sand, shore * 0.8);
642
+ }), standard(0xffffff, { vertexColors: true, roughness: 1 }), { shadow: false });
459
643
  land.rotation.x = -Math.PI / 2;
460
644
  land.receiveShadow = true;
461
645
  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 });
646
+ // Beach sloping into the water.
647
+ const beachGeometry = new THREE.RingGeometry(landRadius, landRadius + 5, 160, 3);
648
+ const beachPositions = beachGeometry.attributes.position;
649
+ for (let i = 0; i < beachPositions.count; i += 1) {
650
+ const t = (Math.hypot(beachPositions.getX(i), beachPositions.getY(i)) - landRadius) / 5;
651
+ beachPositions.setZ(i, -t * 0.42);
652
+ }
653
+ beachGeometry.computeVertexNormals();
654
+ 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
655
  beach.rotation.x = -Math.PI / 2;
464
- beach.receiveShadow = false;
465
656
  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 });
657
+ // Sea: shallow turquoise near the shore, deeper blue further out; a soft line of foam on the beach.
658
+ const sea = mesh(paintVertices(new THREE.RingGeometry(landRadius + 2.5, 760, 160, 24), (color, x, y) => {
659
+ const t = Math.min(1, Math.max(0, (Math.hypot(x, y) - landRadius - 2.5) / 140));
660
+ color.set(0x9ad3d3).lerp(new THREE.Color(0x3f7fa6), Math.sqrt(t));
661
+ color.lerp(new THREE.Color(0xb7dfe3), (valueNoise(x / 9, y / 9, 'swell') - 0.5) * 0.18);
662
+ }), standard(0xffffff, { vertexColors: true, roughness: 0.3, metalness: 0.08 }), { y: -0.36, shadow: false });
468
663
  sea.rotation.x = -Math.PI / 2;
469
664
  sea.receiveShadow = false;
470
665
  scene.add(sea);
471
- animated.water = water;
472
- // A far shore: low mountains across the water, faded by the fog.
666
+ 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 });
667
+ foam.rotation.x = -Math.PI / 2;
668
+ scene.add(foam);
669
+ animated.foam = foam;
670
+ // A far shore: low, faceted mountains across the water, faded by the fog.
671
+ const rock = new THREE.Color(0xa7a497);
672
+ const snow = new THREE.Color(0xf3f5f5);
473
673
  for (let i = 0; i < 22; i += 1) {
474
674
  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 });
675
+ const distance = landRadius + 115 + hashUnit(`m${i}d`) * 55;
676
+ const peak = 10 + hashUnit(`m${i}h`) * 18;
677
+ const radius = 11 + hashUnit(`m${i}r`) * 10;
678
+ const geometry = new THREE.IcosahedronGeometry(1, 2);
679
+ const positions = geometry.attributes.position;
680
+ for (let v = 0; v < positions.count; v += 1) {
681
+ const px = positions.getX(v);
682
+ const py = positions.getY(v);
683
+ const pz = positions.getZ(v);
684
+ const bump = 0.82 + valueNoise(px * 2.2 + i, pz * 2.2, 'ridge') * 0.36;
685
+ positions.setXYZ(v, px * radius * bump, Math.max(0, py) * peak * bump, pz * radius * bump);
686
+ }
687
+ geometry.computeVertexNormals();
688
+ paintVertices(geometry, (color, _x, y) => {
689
+ const t = y / peak;
690
+ color.set(0x86a383).lerp(rock, Math.min(1, t * 1.6));
691
+ if (peak > 20 && t > 0.72) color.lerp(snow, Math.min(1, (t - 0.72) * 5));
692
+ });
693
+ 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
694
  mountain.rotation.y = hashUnit(`m${i}y`) * Math.PI;
480
695
  mountain.receiveShadow = false;
481
696
  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);
697
+ }
698
+ // Clouds drifting over the far water, behind the island, so they never cover a city.
699
+ const cloudMaterial = new THREE.MeshLambertMaterial({ color: 0xffffff, transparent: true, opacity: 0.9, flatShading: true });
700
+ animated.cloudMaterial = cloudMaterial;
701
+ for (let i = 0; i < 7; i += 1) {
702
+ const cloud = new THREE.Group();
703
+ const puffs = 3 + Math.floor(hashUnit(`c${i}n`) * 3);
704
+ for (let p = 0; p < puffs; p += 1) {
705
+ const r = 2.6 + hashUnit(`c${i}p${p}`) * 2.4;
706
+ 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 });
707
+ puff.scale.y = 0.62;
708
+ puff.receiveShadow = false;
709
+ cloud.add(puff);
486
710
  }
711
+ 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 };
712
+ scene.add(cloud);
713
+ animated.clouds.push(cloud);
487
714
  }
715
+ placeClouds(0);
716
+ // Keep the sun's shadow map on the land so shadows stay crisp.
717
+ const reach = landRadius + 6;
718
+ Object.assign(sun.shadow.camera, { left: -reach, right: reach, top: reach, bottom: -reach });
719
+ sun.shadow.camera.updateProjectionMatrix();
488
720
  return landRadius;
489
721
  }
490
722
 
723
+ function placeClouds(time) {
724
+ for (const cloud of animated.clouds) {
725
+ const { angle, distance, height, speed, phase } = cloud.userData;
726
+ const a = angle + Math.sin(time * speed + phase) * 0.08;
727
+ cloud.position.set(Math.cos(a) * distance, height, Math.sin(a) * distance);
728
+ cloud.rotation.y = -a;
729
+ }
730
+ }
731
+
491
732
  const lampPlacements = [];
492
- function streetLamp(x, z) {
493
- lampPlacements.push([x, z]);
733
+ function streetLamp(x, z, y = 0) {
734
+ lampPlacements.push([x, z, y]);
494
735
  }
495
736
  function placeLamps() {
496
737
  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 });
738
+ 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);
739
+ const arms = new THREE.InstancedMesh(new THREE.BoxGeometry(0.42, 0.05, 0.06), standard(0x3f4844, { roughness: 0.6, metalness: 0.4 }), lampPlacements.length);
740
+ const bulbMaterial = standard(0xfff3d6, { emissive: new THREE.Color(0xffd27a), emissiveIntensity: sky.lamps ? 1.8 : 0 });
499
741
  lampMaterials.push(bulbMaterial);
500
- const bulbs = new THREE.InstancedMesh(new THREE.SphereGeometry(0.16, 10, 8), bulbMaterial, lampPlacements.length);
742
+ const bulbs = new THREE.InstancedMesh(new THREE.SphereGeometry(0.14, 10, 8), bulbMaterial, lampPlacements.length);
743
+ // A warm pool of light on the ground under each lamp, shown only when the lamps are on.
744
+ const falloff = canvasTexture(doc, 64, 64, (ctx, w, h) => {
745
+ const gradient = ctx.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, w / 2);
746
+ gradient.addColorStop(0, '#ffffff');
747
+ gradient.addColorStop(0.45, '#6b6b6b');
748
+ gradient.addColorStop(1, '#000000');
749
+ ctx.fillStyle = gradient;
750
+ ctx.fillRect(0, 0, w, h);
751
+ });
752
+ const poolMaterial = new THREE.MeshBasicMaterial({ color: 0xffc46a, map: falloff, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false });
753
+ animated.poolMaterial = poolMaterial;
754
+ const pools = new THREE.InstancedMesh(new THREE.PlaneGeometry(3.4, 3.4).rotateX(-Math.PI / 2), poolMaterial, lampPlacements.length);
501
755
  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));
756
+ lampPlacements.forEach(([x, z, y], index) => {
757
+ poles.setMatrixAt(index, matrix.makeTranslation(x, y + 0.8, z));
758
+ arms.setMatrixAt(index, matrix.makeTranslation(x + 0.18, y + 1.58, z));
759
+ bulbs.setMatrixAt(index, matrix.makeTranslation(x + 0.36, y + 1.5, z));
760
+ pools.setMatrixAt(index, matrix.makeTranslation(x + 0.36, y + 0.07, z));
505
761
  });
506
- scene.add(poles, bulbs);
762
+ poles.castShadow = true;
763
+ pools.renderOrder = 1;
764
+ scene.add(poles, arms, bulbs, pools);
765
+ applyLampPools();
507
766
  lampPlacements.length = 0;
508
767
  }
768
+ function applyLampPools() {
769
+ if (animated.poolMaterial) animated.poolMaterial.opacity = sky.lamps ? Math.min(0.55, 0.15 + sky.glow * 0.16) : 0;
770
+ }
509
771
 
510
772
  /** Trees and lamps along a road, kept off the plaza and the city plates. */
511
773
  function avenue(from, to, keepOut) {
@@ -570,7 +832,9 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
570
832
  hemisphere.intensity = sky.hemi;
571
833
  renderer.toneMappingExposure = sky.exposure;
572
834
  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;
835
+ for (const material of lampMaterials) material.emissiveIntensity = sky.lamps ? 1.8 : 0;
836
+ applyLampPools();
837
+ animated.cloudMaterial?.color.set(sky.phase === 'night' ? 0x5a6782 : sky.phase === 'dusk' ? 0xf4d6c6 : 0xffffff);
574
838
  }
575
839
  const highlightedMaterials = new Set();
576
840
 
@@ -613,12 +877,14 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
613
877
  const group = new THREE.Group();
614
878
  group.position.set(x, 0.7, z);
615
879
  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);
880
+ group.add(mesh(new THREE.BoxGeometry(2.7, 0.25, 2.7), standard(0xe6ded0), { y: 0.12 }));
881
+ const wall = new THREE.Color(color);
882
+ const body = block(2.1, height, 2.1, { style: 'civic', wall, lit: recent, roof: 0xd9d3c6 });
618
883
  body.position.y += 0.25;
619
884
  group.add(body);
620
- const cap = texturedBlock(1.4, 0.7, 1.4, color, recent);
621
- cap.position.y += 0.25 + height;
885
+ group.add(mesh(new THREE.BoxGeometry(2.3, 0.14, 2.3), standard(0xf3efe6), { y: 0.25 + height + 0.07 }));
886
+ const cap = block(1.4, 0.56, 1.4, { style: 'civic', wall, lit: recent, roof: 0xd9d3c6 });
887
+ cap.position.y += 0.25 + height + 0.14;
622
888
  group.add(cap);
623
889
  scene.add(group);
624
890
  register(group, { kind: 'judgment', row, items: rowItems.get(row.key) ?? [] });
@@ -646,6 +912,15 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
646
912
  }
647
913
 
648
914
  // --- cities ----------------------------------------------------------------
915
+ // The form says the business's kind; the height and the lit windows say its last 30 days. Everything
916
+ // else on a building (podium, cornice, roof plant, portico) is detail and means nothing on its own.
917
+ function rooftopPlant(group, y, span, options) {
918
+ const metal = standard(0xb7bcbc, { roughness: 0.6, metalness: 0.3, ...options });
919
+ 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 }));
920
+ 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 }));
921
+ 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 }));
922
+ }
923
+
649
924
  function landmark(form, color, height, status, active = true) {
650
925
  const group = new THREE.Group();
651
926
  const phase = statusPhase(status);
@@ -654,22 +929,60 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
654
929
  const lit = !concept && active;
655
930
  const tone = phase === 'maintenance' ? muted(color, 0.55) : new THREE.Color(color);
656
931
  const options = concept ? { transparent: true, opacity: 0.38 } : {};
932
+ const stone = phase === 'maintenance' ? muted(0xe7e2d6, 0.3) : new THREE.Color(0xe7e2d6);
933
+ const at = (object, y) => {
934
+ object.position.y += y;
935
+ group.add(object);
936
+ return object;
937
+ };
657
938
  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 }));
939
+ // A glass tower on a stone podium, set back once, with a crown and a mast (total height × 1.45).
940
+ const podium = Math.min(1.1, height * 0.2);
941
+ at(block(3.8, podium, 3.8, { style: 'office', wall: stone, lit, roof: 0xcfcac0, ...options }), 0);
942
+ at(block(3, height - podium, 3, { style: 'glass', wall: tone, lit, roof: 0xd5d8d6, roughness: 0.3, ...options }), podium);
943
+ at(block(2.2, height * 0.45, 2.2, { style: 'glass', wall: tone, lit, roof: 0xd5d8d6, roughness: 0.3, ...options }), height);
944
+ group.add(mesh(new THREE.BoxGeometry(2.45, 0.16, 2.45), standard(0xf1f1ee, options), { y: height * 1.45 + 0.08 }));
945
+ group.add(mesh(new THREE.BoxGeometry(3.15, 0.12, 3.15), standard(0xf1f1ee, options), { y: height + 0.06 }));
946
+ rooftopPlant(group, height + 0.12, 3, options);
947
+ 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 }));
948
+ 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
949
  } 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 });
950
+ // A brick hall with a pitched roof in the kind's colour and a columned entrance.
951
+ const bodyHeight = height * 0.55;
952
+ const wall = new THREE.Color(0xf0e6d6).lerp(tone, 0.28);
953
+ at(block(4.4, bodyHeight, 3, { style: 'brick', wall, lit, roof: 0x9b8f80, ...options }), 0);
954
+ group.add(mesh(new THREE.BoxGeometry(4.6, 0.14, 3.2), standard(0xf5f1e8, options), { y: bodyHeight + 0.07 }));
955
+ 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
956
  roof.rotation.y = Math.PI / 2;
666
- group.add(hall, roof);
957
+ group.add(roof);
958
+ 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 }));
959
+ // Portico on the side that faces the default camera.
960
+ const columnHeight = Math.max(0.9, bodyHeight * 0.72);
961
+ group.add(mesh(new THREE.BoxGeometry(3, 0.16, 1.1), standard(0xebe5d9, options), { y: 0.08, z: 2.05 }));
962
+ 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 }));
963
+ const pediment = mesh(gableRoof(3.1, 1.1, 0.55), standard(0xf7f4ee, options), { y: 0.16 + columnHeight, z: 2.05 });
964
+ group.add(pediment);
965
+ 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
966
  } 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 }));
967
+ // A research hall: a windowed drum under a faceted dome with a lantern, and a low wing.
968
+ const drumHeight = height * 0.5;
969
+ 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 });
970
+ group.add(drum);
971
+ group.add(mesh(new THREE.TorusGeometry(2.0, 0.12, 6, 40).rotateX(Math.PI / 2), standard(0xf3f1ea, options), { y: drumHeight }));
972
+ 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 }));
973
+ group.add(mesh(new THREE.CylinderGeometry(0.32, 0.36, 0.5, 12), standard(0xf3f1ea, options), { y: drumHeight + 1.95 + 0.2 }));
974
+ group.add(mesh(new THREE.ConeGeometry(0.42, 0.45, 12), standard(tone.clone().multiplyScalar(0.85), options), { y: drumHeight + 1.95 + 0.67 }));
975
+ const wing = at(block(2.4, Math.max(0.9, drumHeight * 0.45), 1.5, { style: 'office', wall: stone, lit, roof: 0xcfcac0, ...options }), 0);
976
+ wing.position.set(-1.9, wing.position.y, 1.2);
670
977
  } 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 }));
978
+ // An office: a main block and a lower wing, with a parapet and roof plant.
979
+ const mainHeight = height * 0.75;
980
+ const wall = new THREE.Color(0xeeeae2).lerp(tone, 0.35);
981
+ at(block(3.6, mainHeight, 2.8, { style: 'office', wall, lit, roof: 0xc4c0b6, ...options }), 0);
982
+ group.add(mesh(new THREE.BoxGeometry(3.72, 0.16, 2.92), standard(0xf3f1ec, options), { y: mainHeight + 0.08 }));
983
+ rooftopPlant(group, mainHeight + 0.16, 2.8, options);
984
+ const wing = at(block(1.9, Math.max(0.8, mainHeight * 0.5), 2.1, { style: 'office', wall: stone, lit, roof: 0xc4c0b6, ...options }), 0);
985
+ wing.position.set(1.95, wing.position.y, 1.0);
673
986
  }
674
987
  if (concept) {
675
988
  // Scaffold outline: a business that is still a concept.
@@ -680,25 +993,36 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
680
993
  return group;
681
994
  }
682
995
 
996
+ const HOUSE_WALLS = [0xf4efe4, 0xefe6d6, 0xf2ece6, 0xe9e4d6];
683
997
  function house(engagement, color) {
684
998
  const group = new THREE.Group();
685
999
  const status = engagement.status;
686
1000
  const phase = statusPhase(status);
687
1001
  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 }));
1002
+ // A finished engagement becomes a small memorial park: a lawn edged by hedges, a stone, a bench.
1003
+ group.add(mesh(new THREE.BoxGeometry(1.9, 0.14, 1.9), standard(0xa6c796, { roughness: 1 }), { y: 0.07 }));
1004
+ const hedge = standard(0x6f9a68, { roughness: 1 });
1005
+ 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 }));
1006
+ group.add(mesh(new THREE.BoxGeometry(0.5, 0.12, 0.5), standard(0xcfc8b8), { y: 0.2 }));
1007
+ group.add(mesh(new THREE.BoxGeometry(0.24, 0.85, 0.24), standard(0xdcd5c6), { y: 0.68 }));
1008
+ group.add(mesh(new THREE.ConeGeometry(0.18, 0.24, 4).rotateY(Math.PI / 4), standard(0xdcd5c6), { y: 1.22 }));
1009
+ group.add(mesh(new THREE.BoxGeometry(0.7, 0.08, 0.24), standard(0x9a7b5c), { x: 0.2, y: 0.32, z: 0.62 }));
1010
+ 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
1011
  group.userData.finished = true;
694
1012
  return group;
695
1013
  }
1014
+ // One house per engagement; the roof takes the kind's colour, lit windows mean the engagement is active.
696
1015
  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;
1016
+ const wall = phase === 'maintenance' ? muted(color, 0.6) : new THREE.Color(HOUSE_WALLS[Math.floor(hashUnit(`${engagement.id}w`) * HOUSE_WALLS.length)]);
1017
+ const turned = hashUnit(`${engagement.id}r`) > 0.5;
1018
+ group.add(block(1.6, h, 1.4, { style: 'house', wall, lit: phase === 'active', roof: 0xb9b2a5 }));
1019
+ const roofColor = phase === 'maintenance' ? muted(color, 0.6) : new THREE.Color(color).multiplyScalar(0.9);
1020
+ 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 });
1021
+ roof.rotation.y = turned ? Math.PI / 2 : 0;
701
1022
  group.add(roof);
1023
+ group.add(mesh(new THREE.BoxGeometry(0.22, 0.55, 0.22), standard(0x9c7a62), { x: 0.42, y: h + 0.45, z: -0.25 }));
1024
+ group.add(mesh(new THREE.BoxGeometry(0.34, 0.58, 0.05), standard(0x7a5a43), { y: 0.29, z: 0.72 }));
1025
+ group.add(mesh(new THREE.BoxGeometry(0.52, 0.05, 0.22), standard(0xe2ddd2), { y: 0.62, z: 0.8 }));
702
1026
  return group;
703
1027
  }
704
1028
 
@@ -738,8 +1062,11 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
738
1062
  avenue(start, new THREE.Vector3(x, 0, z), keepOut);
739
1063
  const group = new THREE.Group();
740
1064
  group.position.set(x, 0, z);
741
- const plateColor = new THREE.Color(0xf7f8f4).lerp(new THREE.Color(color), 0.1);
1065
+ // A city block: a curb step, a paved block in a hint of the kind's colour, lamps on its corners.
1066
+ group.add(mesh(roundedPlate(size + 0.9, size + 0.9, 0.18, 2.0), standard(0xcfc9bb, { roughness: 1 })));
1067
+ const plateColor = new THREE.Color(0xf3f1ea).lerp(new THREE.Color(color), 0.08);
742
1068
  group.add(mesh(roundedPlate(size, size, 0.45, 1.6), standard(plateColor, { roughness: 0.95 })));
1069
+ 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
1070
  const measures = cityMeasures(business, { isFinished, judgments: judgmentsByBusiness.get(business.code) ?? [] });
744
1071
  const towerHeight = measures.towerHeight;
745
1072
  group.add(landmark(entry.form, color, towerHeight, business.status, measures.lit));
@@ -782,7 +1109,7 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
782
1109
  const treeCount = 2 + Math.floor(hashUnit(business.id) * 3);
783
1110
  for (let t = 0; t < treeCount; t += 1) {
784
1111
  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);
1112
+ tree(x + Math.cos(a) * size * 0.42, z + Math.sin(a) * size * 0.42, 0.8, 0.45);
786
1113
  }
787
1114
  }
788
1115
  countryside(landRadius, cities, roads);
@@ -1037,7 +1364,8 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear }
1037
1364
  }
1038
1365
  }
1039
1366
  if (!reducedMotion) {
1040
- if (animated.water) animated.water.offset.set((time / 90000) % 1, (time / 140000) % 1);
1367
+ if (animated.clouds.length) placeClouds(time);
1368
+ if (animated.foam) animated.foam.material.opacity = 0.3 + 0.24 * (1 + Math.sin(time / 1400)) / 2;
1041
1369
  for (const jet of animated.jets) jet.scale.y = 0.85 + 0.2 * Math.sin(time / 260);
1042
1370
  const wave = (1 + Math.sin(time / 380)) / 2;
1043
1371
  for (const beam of beacons) beam.material.opacity = 0.35 + 0.3 * wave;
@@ -1102,7 +1430,7 @@ const JUDGMENT_UNAVAILABLE_TEXT = Object.freeze({
1102
1430
  * engagement opens in the host's プロジェクトと関係者. The local web opens `#projects?project=<id>`; an
1103
1431
  * organization web passes its own route.
1104
1432
  */
1105
- export function createWorldView({ root, rail, page, document: explicitDocument, fetcher, organizationGraph = null, projectHref = (project) => `#projects?project=${encodeURIComponent(project.id)}` }) {
1433
+ export function createWorldView({ root, rail, page, document: explicitDocument, fetcher, projectHref = (project) => `#projects?project=${encodeURIComponent(project.id)}` }) {
1106
1434
  const doc = explicitDocument ?? globalThis.document;
1107
1435
  const reducedMotion = Boolean(globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches);
1108
1436
  const wrap = el(doc, 'div', { className: 'bb-world' });
@@ -1173,22 +1501,9 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1173
1501
  return el(doc, 'a', { className: 'bb-world-rail-link', text: label, attrs: { href: projectHref(project) } });
1174
1502
  }
1175
1503
 
1176
- /**
1177
- * The same project in the organization web, where it is corrected (Inspector UX-20261004-01). Only when
1178
- * the host reads the organization Graph and was told where its web is.
1179
- */
1180
- function organizationLink(project) {
1181
- const origin = organizationWebOrigin(organizationGraph);
1182
- if (!origin) return null;
1183
- // A business without its own code carries its id as its code; that is not a code the organization web knows.
1184
- const code = project.code && project.code !== project.id ? project.code : null;
1185
- const target = organizationRecordLink(origin, 'projects', { id: project.id, type: 'project', metadata: { code } });
1186
- return target ? organizationRecordAnchor(doc, target, 'bb-world-rail-link') : null;
1187
- }
1188
-
1189
- /** 詳しく見る: プロジェクトと関係者 here, and the organization web when there is one. */
1504
+ /** 詳しく見る: the same project in プロジェクトと関係者. */
1190
1505
  function detailBlock(project, label) {
1191
- const content = [projectLink(project, label), organizationLink(project)].filter(Boolean);
1506
+ const content = [projectLink(project, label)];
1192
1507
  return workspaceRailBlock(doc, { title: '詳しく見る', content });
1193
1508
  }
1194
1509
 
@@ -1451,11 +1766,11 @@ export const screen = Object.freeze({
1451
1766
  label: '世界',
1452
1767
  usesGraph: false,
1453
1768
  rail: true,
1454
- source: '組織のGraph・判断journal(読み取りのみ)',
1769
+ source: '手元のGraph・判断journal(読み取りのみ)',
1455
1770
  mount(container, context) {
1456
1771
  const root = context.document.createElement('div');
1457
1772
  root.className = 'bb-shell-page';
1458
1773
  container.append(root);
1459
- return createWorldView({ root, rail: context.rail, page: context.page, document: context.document, fetcher: context.fetcher, organizationGraph: context.organizationGraph ?? null });
1774
+ return createWorldView({ root, rail: context.rail, page: context.page, document: context.document, fetcher: context.fetcher });
1460
1775
  },
1461
1776
  });