@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.
- package/dist/cli.js +6 -23
- package/dist/graph-web-http.d.ts +0 -7
- package/dist/graph-web-http.js +0 -14
- package/dist/graph-web.d.ts +2 -32
- package/dist/graph-web.js +1 -41
- package/dist/local-web-host.d.ts +0 -23
- package/dist/local-web-host.js +3 -15
- package/dist/organization-graph-web.d.ts +5 -27
- package/dist/organization-graph-web.js +4 -128
- package/dist/world-extension.d.ts +4 -19
- package/dist/world-extension.js +15 -75
- package/package.json +2 -2
- package/ui/graph-view-shared.js +0 -39
- package/ui/local-web-shell.js +3 -53
- package/ui/world/world-view.js +471 -156
package/ui/world/world-view.js
CHANGED
|
@@ -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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
331
|
-
|
|
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(
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
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
|
|
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
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
|
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
|
|
376
|
-
const
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
matrix.
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
439
|
-
ctx.lineWidth = 1;
|
|
440
|
-
for (let r =
|
|
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) *
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
467
|
-
const sea = mesh(new THREE.
|
|
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
|
-
|
|
472
|
-
|
|
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 +
|
|
476
|
-
const peak = 10 + hashUnit(`m${i}h`) *
|
|
477
|
-
const radius =
|
|
478
|
-
const
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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),
|
|
498
|
-
const
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
617
|
-
const
|
|
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
|
-
|
|
621
|
-
cap.
|
|
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
|
-
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
|
|
664
|
-
const
|
|
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(
|
|
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
|
-
|
|
669
|
-
|
|
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
|
-
|
|
672
|
-
|
|
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
|
|
689
|
-
group.add(mesh(new THREE.BoxGeometry(1.
|
|
690
|
-
|
|
691
|
-
group.add(mesh(new THREE.
|
|
692
|
-
|
|
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
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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)
|
|
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: '
|
|
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
|
|
1774
|
+
return createWorldView({ root, rail: context.rail, page: context.page, document: context.document, fetcher: context.fetcher });
|
|
1460
1775
|
},
|
|
1461
1776
|
});
|