@a2f0/skyline 0.2.20 → 0.2.22
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/lib/models/colour-materials.d.ts +8 -1
- package/lib/models/colour-materials.js +38 -7
- package/lib/models/colour-palette.d.ts +32 -0
- package/lib/models/colour-palette.js +11 -0
- package/lib/models/colour-reflections.d.ts +13 -1
- package/lib/models/colour-reflections.js +69 -10
- package/lib/models/night-windows.d.ts +13 -0
- package/lib/models/night-windows.js +76 -0
- package/lib/study-types.d.ts +2 -0
- package/package.json +1 -1
- package/site/building-detail.js +30 -7
- package/site/models/colour-materials.js +38 -7
- package/site/models/colour-palette.js +11 -0
- package/site/models/colour-reflections.js +69 -10
- package/site/models/night-windows.js +76 -0
- package/site/skyline-3d.html +6 -0
- package/site/skyline-3d.js +50 -13
- package/site/skyline-viewer.js +1 -1
- package/site/solar-time.js +53 -0
- package/site/study-loader.js +1 -1
- package/site/study-viewer.js +27 -0
- package/site/study.css +14 -2
- package/site/time-of-day.js +56 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { BuildingModel } from "./building-kit.js";
|
|
2
2
|
import type { Daylight } from "../study-types.js";
|
|
3
|
+
import { type ReflectionColours } from "./colour-reflections.js";
|
|
4
|
+
import { type NightLighting } from "./night-windows.js";
|
|
3
5
|
export declare const materialNames: readonly ["neutral", "glass", "green glass", "blue-green glass", "bronze glass", "lit window", "dim window", "lamp", "limestone", "marble", "white granite", "granite", "dark granite", "slate", "white terracotta", "pink terracotta", "green tile", "brick", "common brick", "orange brick", "brown brick", "buff brick", "glazed brick", "concrete", "precast", "copper", "maroon", "blue enamel", "bronze", "dark metal", "aluminium", "stainless", "opaque spandrel", "crown glass", "crown lights", "screen", "band lights", "sign board", "sign letters", "cap lights"];
|
|
4
6
|
export type MaterialName = (typeof materialNames)[number];
|
|
5
7
|
export type Tones = Readonly<Record<number, MaterialName>>;
|
|
@@ -30,9 +32,14 @@ export interface SkylineColour {
|
|
|
30
32
|
readonly enabled: boolean;
|
|
31
33
|
/** Shows the colours, or restores the greys exactly. */
|
|
32
34
|
set(enabled: boolean): void;
|
|
35
|
+
/** Updates reflection uniforms without rewriting geometry or recompiling shaders. */
|
|
36
|
+
setSky(colours: ReflectionColours | null): void;
|
|
37
|
+
/** Sets ordinary window activity and dims outlines for the time preview, or restores the preset. */
|
|
38
|
+
setNight(lighting: NightLighting | null): void;
|
|
33
39
|
/**
|
|
34
40
|
* Recolours vertices another writer has since rewritten in grey, such as window
|
|
35
|
-
* illumination resetting a facade for a celebration.
|
|
41
|
+
* illumination resetting a facade for a celebration. Re-reads celebration state
|
|
42
|
+
* so ordinary night windows yield to the message while architectural lights remain.
|
|
36
43
|
*/
|
|
37
44
|
refresh(): void;
|
|
38
45
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as THREE from "../vendor/three-r186.js";
|
|
2
2
|
import { daylightColours, measuredColours } from "./colour-palette.js";
|
|
3
|
-
import { glassReflection } from "./colour-reflections.js";
|
|
3
|
+
import { glassReflection, daylightReflections } from "./colour-reflections.js";
|
|
4
|
+
import { nightWindowMask } from "./night-windows.js";
|
|
4
5
|
// The colour trial (https://github.com/a2f0/skyline/issues/115): which named material each
|
|
5
6
|
// surface of each building is, and a layer that shows the colours measured for those materials
|
|
6
7
|
// (colour-palette.ts) over the greys the models build in, restoring the greys exactly when it is
|
|
@@ -358,9 +359,16 @@ export function createDaylight() {
|
|
|
358
359
|
// another batch already took, since the builder lets batches share them.
|
|
359
360
|
export function createSkylineColour(models) {
|
|
360
361
|
const painted = [], flat = [], taken = new Set();
|
|
361
|
-
const
|
|
362
|
+
const facades = [];
|
|
363
|
+
const outlines = [];
|
|
364
|
+
let night = null;
|
|
362
365
|
const glassMaterials = new Set(["glass", "green glass", "blue-green glass", "bronze glass", "crown glass", "lit window", "dim window"]);
|
|
363
366
|
for (const model of models) {
|
|
367
|
+
model.building.traverse((object) => {
|
|
368
|
+
const line = object;
|
|
369
|
+
if (line.isLineSegments && line.material instanceof THREE.LineBasicMaterial)
|
|
370
|
+
outlines.push({ material: line.material, opacity: line.material.opacity });
|
|
371
|
+
});
|
|
364
372
|
const buildingId = String(model.building.userData["buildingId"]);
|
|
365
373
|
if (!measuredColours[buildingId])
|
|
366
374
|
continue;
|
|
@@ -381,7 +389,13 @@ export function createSkylineColour(models) {
|
|
|
381
389
|
return;
|
|
382
390
|
const [base, tones = {}] = batch;
|
|
383
391
|
const material = mesh.material;
|
|
384
|
-
const
|
|
392
|
+
const names = new Map();
|
|
393
|
+
const materialOf = (grey) => {
|
|
394
|
+
let name = names.get(grey);
|
|
395
|
+
if (!name)
|
|
396
|
+
names.set(grey, name = tones[toneOf(grey)] ?? base);
|
|
397
|
+
return name;
|
|
398
|
+
};
|
|
385
399
|
const attribute = mesh.geometry.getAttribute("color");
|
|
386
400
|
if (material.vertexColors) {
|
|
387
401
|
if (!(attribute?.array instanceof Float32Array) || taken.has(attribute))
|
|
@@ -413,8 +427,9 @@ export function createSkylineColour(models) {
|
|
|
413
427
|
}
|
|
414
428
|
mask[vertex] = reflects;
|
|
415
429
|
}
|
|
416
|
-
|
|
417
|
-
|
|
430
|
+
const windows = nightWindowMask(geometry, buildingId, (vertex) => materialOf(colours ? colours.array[vertex * 3] : material.color.r));
|
|
431
|
+
if (mask.some((value) => value > 0) || windows.some((value) => value > 0))
|
|
432
|
+
facades.push({ light: glassReflection(material, geometry, mask, windows), model });
|
|
418
433
|
const colourOf = (grey) => {
|
|
419
434
|
const swatch = swatches.get(materialOf(grey));
|
|
420
435
|
return swatch ? paint(grey, swatch) : [grey, grey, grey];
|
|
@@ -435,6 +450,12 @@ export function createSkylineColour(models) {
|
|
|
435
450
|
}
|
|
436
451
|
}
|
|
437
452
|
let enabled = false;
|
|
453
|
+
function updateNight() {
|
|
454
|
+
for (const { light, model } of facades)
|
|
455
|
+
light.setNight(night, enabled, Boolean(model.illumination?.active));
|
|
456
|
+
for (const { material, opacity } of outlines)
|
|
457
|
+
material.opacity = opacity * (night?.outlineStrength ?? 1);
|
|
458
|
+
}
|
|
438
459
|
return {
|
|
439
460
|
get enabled() { return enabled; },
|
|
440
461
|
set(next) {
|
|
@@ -459,10 +480,20 @@ export function createSkylineColour(models) {
|
|
|
459
480
|
}
|
|
460
481
|
for (const { material, grey, colour } of flat)
|
|
461
482
|
material.color.copy(next ? colour : grey);
|
|
462
|
-
for (const
|
|
463
|
-
|
|
483
|
+
for (const { light } of facades)
|
|
484
|
+
light(next);
|
|
485
|
+
updateNight();
|
|
486
|
+
},
|
|
487
|
+
setSky(colours) {
|
|
488
|
+
for (const { light } of facades)
|
|
489
|
+
light.setSky(colours ?? daylightReflections);
|
|
490
|
+
},
|
|
491
|
+
setNight(lighting) {
|
|
492
|
+
night = lighting;
|
|
493
|
+
updateNight();
|
|
464
494
|
},
|
|
465
495
|
refresh() {
|
|
496
|
+
updateNight();
|
|
466
497
|
if (!enabled)
|
|
467
498
|
return;
|
|
468
499
|
for (const batch of painted) {
|
|
@@ -6,3 +6,35 @@ export declare const daylightColours: {
|
|
|
6
6
|
readonly horizon: 12898785;
|
|
7
7
|
readonly sun: 16774374;
|
|
8
8
|
};
|
|
9
|
+
export declare const nightColours: {
|
|
10
|
+
readonly ambient: 10596296;
|
|
11
|
+
readonly fill: 9281469;
|
|
12
|
+
readonly window: 16764817;
|
|
13
|
+
readonly windowWhite: 16772558;
|
|
14
|
+
};
|
|
15
|
+
export declare const timeOfDayColours: readonly [{
|
|
16
|
+
readonly elevation: -18;
|
|
17
|
+
readonly sky: 132108;
|
|
18
|
+
readonly horizon: 527643;
|
|
19
|
+
readonly sun: 16756855;
|
|
20
|
+
}, {
|
|
21
|
+
readonly elevation: -8;
|
|
22
|
+
readonly sky: 1319749;
|
|
23
|
+
readonly horizon: 5984878;
|
|
24
|
+
readonly sun: 16756086;
|
|
25
|
+
}, {
|
|
26
|
+
readonly elevation: -2;
|
|
27
|
+
readonly sky: 4283273;
|
|
28
|
+
readonly horizon: 15244153;
|
|
29
|
+
readonly sun: 16757879;
|
|
30
|
+
}, {
|
|
31
|
+
readonly elevation: 6;
|
|
32
|
+
readonly sky: 8560581;
|
|
33
|
+
readonly horizon: 15778720;
|
|
34
|
+
readonly sun: 16766120;
|
|
35
|
+
}, {
|
|
36
|
+
readonly sky: 10402779;
|
|
37
|
+
readonly horizon: 12898785;
|
|
38
|
+
readonly sun: 16774374;
|
|
39
|
+
readonly elevation: 25;
|
|
40
|
+
}];
|
|
@@ -290,3 +290,14 @@ export const measuredColours = {
|
|
|
290
290
|
// above the horizon, each the median of a clear box of the same panorama (scripts/sample-colours.ts,
|
|
291
291
|
// the day study's sky), and the sun's warm white, chosen.
|
|
292
292
|
export const daylightColours = { sky: 0x9ebbdb, horizon: 0xc4d1e1, sun: 0xfff4e6 };
|
|
293
|
+
// Art-directed night illumination: quiet cool facades and a mix of warm office lamps.
|
|
294
|
+
export const nightColours = { ambient: 0xa1afc8, fill: 0x8d9fbd, window: 0xffcf91, windowWhite: 0xffedce };
|
|
295
|
+
// Illustrated clear-sky time previews. Solar elevation chooses the stops; these are
|
|
296
|
+
// artistic colours, separate from the photograph-calibrated daytime palette above.
|
|
297
|
+
export const timeOfDayColours = [
|
|
298
|
+
{ elevation: -18, sky: 0x02040c, horizon: 0x080d1b, sun: 0xffb077 },
|
|
299
|
+
{ elevation: -8, sky: 0x142345, horizon: 0x5b526e, sun: 0xffad76 },
|
|
300
|
+
{ elevation: -2, sky: 0x415b89, horizon: 0xe89b79, sun: 0xffb477 },
|
|
301
|
+
{ elevation: 6, sky: 0x829fc5, horizon: 0xf0c3a0, sun: 0xffd4a8 },
|
|
302
|
+
{ elevation: 25, ...daylightColours },
|
|
303
|
+
];
|
|
@@ -1,2 +1,14 @@
|
|
|
1
1
|
import * as THREE from "../vendor/three-r186.js";
|
|
2
|
-
|
|
2
|
+
import type { NightLighting } from "./night-windows.js";
|
|
3
|
+
export interface ReflectionColours {
|
|
4
|
+
sky: number;
|
|
5
|
+
horizon: number;
|
|
6
|
+
ground: number;
|
|
7
|
+
}
|
|
8
|
+
export declare const daylightReflections: ReflectionColours;
|
|
9
|
+
export interface GlassReflection {
|
|
10
|
+
(enabled: boolean): void;
|
|
11
|
+
setSky(colours: ReflectionColours): void;
|
|
12
|
+
setNight(lighting: NightLighting | null, coloured: boolean, celebration: boolean): void;
|
|
13
|
+
}
|
|
14
|
+
export declare function glassReflection(material: THREE.MeshToonMaterial, geometry: THREE.BufferGeometry, mask: Float32Array, windows?: Float32Array): GlassReflection;
|
|
@@ -1,32 +1,51 @@
|
|
|
1
1
|
import * as THREE from "../vendor/three-r186.js";
|
|
2
|
-
import { daylightColours } from "./colour-palette.js";
|
|
2
|
+
import { daylightColours, nightColours } from "./colour-palette.js";
|
|
3
|
+
export const daylightReflections = { ...daylightColours, ground: 0x444444 };
|
|
3
4
|
// A small sky reflection over the measured glass colour. This is an illustration of an open
|
|
4
5
|
// sky, not a reflection of nearby buildings or a replacement for the toon renderer. The mask
|
|
5
6
|
// separates glass from masonry and mullions even when the builder batches them in one mesh.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
7
|
+
// The same shader adds night-window emission, independently of material colour. Restore the
|
|
8
|
+
// original shader outside colour/time previews. Keep attributes attached once used so Three.js
|
|
9
|
+
// releases their GPU buffers when it disposes the geometry.
|
|
10
|
+
export function glassReflection(material, geometry, mask, windows = new Float32Array(mask.length * 4)) {
|
|
9
11
|
const attribute = new THREE.Float32BufferAttribute(mask, 1);
|
|
12
|
+
const windowAttribute = new THREE.Float32BufferAttribute(windows, 4);
|
|
10
13
|
const originalCompile = material.onBeforeCompile, originalKey = material.customProgramCacheKey;
|
|
11
14
|
const key = originalKey.call(material);
|
|
12
15
|
const uniforms = {
|
|
13
16
|
skylineSky: { value: new THREE.Color(daylightColours.sky) },
|
|
14
17
|
skylineHorizon: { value: new THREE.Color(daylightColours.horizon) },
|
|
15
18
|
skylineGround: { value: new THREE.Color(0x444444) },
|
|
19
|
+
skylineReflecting: { value: 0 },
|
|
20
|
+
skylineDarkness: { value: 0 },
|
|
21
|
+
skylineActivity: { value: 0 },
|
|
22
|
+
skylineWindowsSuppressed: { value: 0 },
|
|
23
|
+
skylineWindowWarm: { value: new THREE.Color(nightColours.window) },
|
|
24
|
+
skylineWindowWhite: { value: new THREE.Color(nightColours.windowWhite) },
|
|
16
25
|
};
|
|
17
26
|
const compile = (shader, renderer) => {
|
|
18
27
|
originalCompile.call(material, shader, renderer);
|
|
19
28
|
Object.assign(shader.uniforms, uniforms);
|
|
20
29
|
shader.vertexShader = shader.vertexShader.replace("#include <common>", `#include <common>
|
|
21
30
|
attribute float skylineGlass;
|
|
22
|
-
|
|
23
|
-
|
|
31
|
+
attribute vec4 skylineWindow;
|
|
32
|
+
varying float vSkylineGlass;
|
|
33
|
+
varying vec4 vSkylineWindow;`).replace("#include <begin_vertex>", `#include <begin_vertex>
|
|
34
|
+
vSkylineGlass = skylineGlass;
|
|
35
|
+
vSkylineWindow = skylineWindow;`);
|
|
24
36
|
shader.fragmentShader = shader.fragmentShader.replace("#include <common>", `#include <common>
|
|
25
37
|
varying float vSkylineGlass;
|
|
38
|
+
varying vec4 vSkylineWindow;
|
|
26
39
|
uniform vec3 skylineSky;
|
|
27
40
|
uniform vec3 skylineHorizon;
|
|
28
|
-
uniform vec3 skylineGround
|
|
29
|
-
|
|
41
|
+
uniform vec3 skylineGround;
|
|
42
|
+
uniform float skylineReflecting;
|
|
43
|
+
uniform float skylineDarkness;
|
|
44
|
+
uniform float skylineActivity;
|
|
45
|
+
uniform float skylineWindowsSuppressed;
|
|
46
|
+
uniform vec3 skylineWindowWarm;
|
|
47
|
+
uniform vec3 skylineWindowWhite;`).replace("#include <opaque_fragment>", `
|
|
48
|
+
if (vSkylineGlass > 0.0 && skylineReflecting > 0.0) {
|
|
30
49
|
vec3 eye = vec3(0.0);
|
|
31
50
|
eye.z = 1.0;
|
|
32
51
|
if (!isOrthographic) eye = normalize(vViewPosition);
|
|
@@ -36,18 +55,58 @@ if (vSkylineGlass > 0.0) {
|
|
|
36
55
|
float grazing = pow(1.0 - clamp(dot(normal, eye), 0.0, 1.0), 5.0);
|
|
37
56
|
outgoingLight = mix(outgoingLight, environment, vSkylineGlass * (0.06 + 0.54 * grazing));
|
|
38
57
|
}
|
|
58
|
+
if (vSkylineWindow.z > 0.0 && skylineDarkness > 0.0) {
|
|
59
|
+
float occupied = 1.0 - smoothstep(skylineActivity - 0.05, skylineActivity + 0.05, vSkylineWindow.x);
|
|
60
|
+
float dusk = smoothstep(0.12 + 0.35 * vSkylineWindow.x, 0.45 + 0.35 * vSkylineWindow.x, skylineDarkness);
|
|
61
|
+
float light = vSkylineWindow.z > 1.5 ? skylineDarkness : occupied * dusk * (1.0 - skylineWindowsSuppressed);
|
|
62
|
+
vec3 lamp = mix(skylineWindowWarm, skylineWindowWhite, vSkylineWindow.w);
|
|
63
|
+
outgoingLight += lamp * vSkylineWindow.y * light;
|
|
64
|
+
}
|
|
39
65
|
#include <opaque_fragment>`);
|
|
40
66
|
};
|
|
41
|
-
|
|
67
|
+
let reflecting = false, night = false, installed = false;
|
|
68
|
+
function install() {
|
|
69
|
+
const enabled = reflecting || night;
|
|
70
|
+
if (enabled === installed)
|
|
71
|
+
return;
|
|
72
|
+
installed = enabled;
|
|
42
73
|
if (enabled) {
|
|
43
74
|
geometry.setAttribute("skylineGlass", attribute);
|
|
75
|
+
geometry.setAttribute("skylineWindow", windowAttribute);
|
|
44
76
|
material.onBeforeCompile = compile;
|
|
45
|
-
material.customProgramCacheKey = () => `${key}:skyline-
|
|
77
|
+
material.customProgramCacheKey = () => `${key}:skyline-facade-v2`;
|
|
46
78
|
}
|
|
47
79
|
else {
|
|
48
80
|
material.onBeforeCompile = originalCompile;
|
|
49
81
|
material.customProgramCacheKey = originalKey;
|
|
50
82
|
}
|
|
51
83
|
material.needsUpdate = true;
|
|
84
|
+
}
|
|
85
|
+
const set = (enabled) => {
|
|
86
|
+
reflecting = enabled;
|
|
87
|
+
uniforms.skylineReflecting.value = enabled ? 1 : 0;
|
|
88
|
+
install();
|
|
52
89
|
};
|
|
90
|
+
return Object.assign(set, {
|
|
91
|
+
setSky(colours) {
|
|
92
|
+
uniforms.skylineSky.value.setHex(colours.sky);
|
|
93
|
+
uniforms.skylineHorizon.value.setHex(colours.horizon);
|
|
94
|
+
uniforms.skylineGround.value.setHex(colours.ground);
|
|
95
|
+
},
|
|
96
|
+
setNight(lighting, coloured, celebration) {
|
|
97
|
+
night = lighting !== null;
|
|
98
|
+
// A deliberately composed celebration takes precedence over ordinary office rooms.
|
|
99
|
+
uniforms.skylineDarkness.value = lighting?.darkness ?? 0;
|
|
100
|
+
uniforms.skylineWindowsSuppressed.value = celebration ? 1 : 0;
|
|
101
|
+
uniforms.skylineActivity.value = lighting?.activity ?? 0;
|
|
102
|
+
for (const [uniform, hex] of [[uniforms.skylineWindowWarm, nightColours.window], [uniforms.skylineWindowWhite, nightColours.windowWhite]]) {
|
|
103
|
+
uniform.value.setHex(hex);
|
|
104
|
+
if (!coloured) {
|
|
105
|
+
const { r, g, b } = uniform.value, grey = r * 0.2126 + g * 0.7152 + b * 0.0722;
|
|
106
|
+
uniform.value.setRGB(grey, grey, grey);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
install();
|
|
110
|
+
},
|
|
111
|
+
});
|
|
53
112
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type * as THREE from "../vendor/three-r186.js";
|
|
2
|
+
import type { MaterialName } from "./colour-materials.js";
|
|
3
|
+
export interface NightLighting {
|
|
4
|
+
/** Sunset/dawn transition, from daylight (0) to full darkness (1). */
|
|
5
|
+
darkness: number;
|
|
6
|
+
/** Illustrative occupancy, not a record of actual tenants or working hours. */
|
|
7
|
+
activity: number;
|
|
8
|
+
/** Unlit drawing outlines must dim with the surrounding architecture. */
|
|
9
|
+
outlineStrength: number;
|
|
10
|
+
}
|
|
11
|
+
export declare const smooth: (low: number, high: number, value: number) => number;
|
|
12
|
+
export declare function windowActivity(hour: number): number;
|
|
13
|
+
export declare function nightWindowMask(geometry: THREE.BufferGeometry, buildingId: string, materialOf: (vertex: number) => MaterialName): Float32Array;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export const smooth = (low, high, value) => {
|
|
2
|
+
const t = Math.max(0, Math.min(1, (value - low) / (high - low)));
|
|
3
|
+
return t * t * (3 - 2 * t);
|
|
4
|
+
};
|
|
5
|
+
// A continuous daily cycle: busy evenings, a few overnight rooms, then early arrivals.
|
|
6
|
+
// Midnight shares the same curve from either side, including when scrubbing backwards.
|
|
7
|
+
export function windowActivity(hour) {
|
|
8
|
+
const late = hour < 12 ? hour + 24 : hour;
|
|
9
|
+
return 0.76 - 0.52 * smooth(21, 26, late) + 0.3 * smooth(29, 32, late) + (hour < 12 ? 0.22 * smooth(8, 12, hour) : 0);
|
|
10
|
+
}
|
|
11
|
+
function hash(value) {
|
|
12
|
+
let seed = 2166136261;
|
|
13
|
+
for (const character of value)
|
|
14
|
+
seed = Math.imul(seed ^ character.charCodeAt(0), 16777619);
|
|
15
|
+
// Avalanche adjacent room keys: plain FNV leaves trailing bay digits correlated,
|
|
16
|
+
// making a whole east-facing floor turn on together instead of small room groups.
|
|
17
|
+
seed = Math.imul(seed ^ (seed >>> 16), 0x85ebca6b);
|
|
18
|
+
seed = Math.imul(seed ^ (seed >>> 13), 0xc2b2ae35);
|
|
19
|
+
seed ^= seed >>> 16;
|
|
20
|
+
return (seed >>> 0) / 4294967296;
|
|
21
|
+
}
|
|
22
|
+
const architectural = new Set(["lamp", "crown lights", "band lights", "sign letters", "cap lights"]);
|
|
23
|
+
// The building kit emits panes as paired triangles, interspersed with standalone
|
|
24
|
+
// triangles for other features. Find shared edges rather than assuming quad alignment.
|
|
25
|
+
// Reuse those exact surfaces;
|
|
26
|
+
// never turn a generic glass shell, spandrel, masonry face or roof into a light source.
|
|
27
|
+
// Adjacent bays share a floor/room seed, so offices come on in small groups. Identity
|
|
28
|
+
// depends on local geometry and building ID, not load order, camera or slider direction.
|
|
29
|
+
export function nightWindowMask(geometry, buildingId, materialOf) {
|
|
30
|
+
const positions = geometry.getAttribute("position"), normals = geometry.getAttribute("normal");
|
|
31
|
+
const mask = new Float32Array(positions.count * 4);
|
|
32
|
+
const samePoint = (a, b) => positions.getX(a) === positions.getX(b) && positions.getY(a) === positions.getY(b) && positions.getZ(a) === positions.getZ(b);
|
|
33
|
+
for (let first = 0; first < positions.count;) {
|
|
34
|
+
const name = materialOf(first), crown = architectural.has(name);
|
|
35
|
+
const window = name === "lit window" || name === "dim window";
|
|
36
|
+
if (!crown && (!window || Math.abs(normals.getY(first)) > 0.5)) {
|
|
37
|
+
first += 3;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
let count = 3;
|
|
41
|
+
if (first + 6 <= positions.count && materialOf(first + 3) === name
|
|
42
|
+
&& normals.getX(first) === normals.getX(first + 3) && normals.getY(first) === normals.getY(first + 3) && normals.getZ(first) === normals.getZ(first + 3)) {
|
|
43
|
+
let shared = 0;
|
|
44
|
+
for (let a = first; a < first + 3; a += 1) {
|
|
45
|
+
for (let b = first + 3; b < first + 6; b += 1)
|
|
46
|
+
if (samePoint(a, b)) {
|
|
47
|
+
shared += 1;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (shared === 2)
|
|
52
|
+
count = 6;
|
|
53
|
+
}
|
|
54
|
+
const end = first + count;
|
|
55
|
+
let x = 0, y = 0, z = 0;
|
|
56
|
+
for (let vertex = first; vertex < end; vertex += 1) {
|
|
57
|
+
x += positions.getX(vertex);
|
|
58
|
+
y += positions.getY(vertex);
|
|
59
|
+
z += positions.getZ(vertex);
|
|
60
|
+
}
|
|
61
|
+
const floor = Math.round(y / count / 3.4), across = Math.floor(x / count / 7), depth = Math.floor(z / count / 7);
|
|
62
|
+
const room = hash(`${buildingId}:${floor}:${across}:${depth}`);
|
|
63
|
+
const brightness = hash(`${buildingId}:${floor}:${across}:${depth}:brightness`);
|
|
64
|
+
for (let vertex = first; vertex < end; vertex += 1) {
|
|
65
|
+
if (materialOf(vertex) !== name)
|
|
66
|
+
continue;
|
|
67
|
+
const rank = name === "lit window" ? 0.05 + room * 0.8 : 0.3 + room * 0.75;
|
|
68
|
+
mask[vertex * 4] = rank;
|
|
69
|
+
mask[vertex * 4 + 1] = crown ? 0.3 : 0.25 + brightness * 0.45;
|
|
70
|
+
mask[vertex * 4 + 2] = crown ? 2 : 1;
|
|
71
|
+
mask[vertex * 4 + 3] = brightness;
|
|
72
|
+
}
|
|
73
|
+
first = end;
|
|
74
|
+
}
|
|
75
|
+
return mask;
|
|
76
|
+
}
|
package/lib/study-types.d.ts
CHANGED
|
@@ -49,6 +49,8 @@ export interface StudyLabel {
|
|
|
49
49
|
}
|
|
50
50
|
export interface Daylight {
|
|
51
51
|
sky: THREE.Texture;
|
|
52
|
+
/** Unit vector toward the sun in world east/up/south coordinates. */
|
|
53
|
+
direction?: Vec3;
|
|
52
54
|
sun: readonly [colour: number, intensity: number];
|
|
53
55
|
ambient: readonly [colour: number, intensity: number];
|
|
54
56
|
fill: readonly [colour: number, intensity: number];
|
package/package.json
CHANGED
package/site/building-detail.js
CHANGED
|
@@ -2,7 +2,8 @@ import * as THREE from "./vendor/three-r186.js";
|
|
|
2
2
|
import { createBuildingStudy } from "./study-viewer.js";
|
|
3
3
|
import { skylineAzimuth } from "./skyline-comparison.js";
|
|
4
4
|
import { geographicBuildings } from "./models/skyline-geography-data.js";
|
|
5
|
-
import { createGeographicBuilding, footprintMetrics } from "./models/skyline-geography.js";
|
|
5
|
+
import { createGeographicBuilding, footprintMetrics, geographicOrigin } from "./models/skyline-geography.js";
|
|
6
|
+
import { createTimeLighting } from "./time-of-day.js";
|
|
6
7
|
import { createDaylight, createSkylineColour } from "./models/colour-materials.js";
|
|
7
8
|
// One mapped building of the 3D skyline, alone on its own platform, as the building study
|
|
8
9
|
// shows Crain: building-detail.html?building=<id>, and the panel the 3D skyline floats over
|
|
@@ -82,14 +83,30 @@ function showBuilding(record, root, frame, signal, initiallyColoured, onColourCh
|
|
|
82
83
|
});
|
|
83
84
|
const colour = createSkylineColour([model]), button = root.querySelector("#colour");
|
|
84
85
|
let daylight;
|
|
85
|
-
|
|
86
|
+
let selectedTime = null;
|
|
87
|
+
let timeLight;
|
|
88
|
+
signal.addEventListener("abort", () => { daylight?.sky.dispose(); timeLight?.dispose(); }, { once: true });
|
|
86
89
|
function setColour(enabled) {
|
|
87
90
|
colour.set(enabled);
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
if (selectedTime) {
|
|
92
|
+
// Each renderer owns its sky texture and disposal listeners. Sharing the parent's
|
|
93
|
+
// texture would retain closed detail renderers until the whole skyline is destroyed.
|
|
94
|
+
timeLight ??= createTimeLighting(geographicOrigin[1], geographicOrigin[0], false);
|
|
95
|
+
const lighting = timeLight.update(selectedTime, enabled);
|
|
96
|
+
colour.setSky(lighting.reflections);
|
|
97
|
+
colour.setNight(lighting.night);
|
|
98
|
+
viewer.setDaylight(lighting);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
if (enabled)
|
|
102
|
+
daylight ??= createDaylight();
|
|
103
|
+
colour.setSky(null);
|
|
104
|
+
colour.setNight(null);
|
|
105
|
+
viewer.setDaylight(enabled ? daylight : null);
|
|
106
|
+
}
|
|
91
107
|
button.setAttribute("aria-pressed", String(enabled));
|
|
92
|
-
button.title = enabled ? "
|
|
108
|
+
button.title = selectedTime ? (enabled ? "Show this time in greyscale" : "Show this time in colour")
|
|
109
|
+
: enabled ? "Restore the building's greyscale view" : "Show the building in sunny-day colour";
|
|
93
110
|
}
|
|
94
111
|
button.addEventListener("click", () => {
|
|
95
112
|
setColour(!colour.enabled);
|
|
@@ -97,7 +114,13 @@ function showBuilding(record, root, frame, signal, initiallyColoured, onColourCh
|
|
|
97
114
|
}, { signal });
|
|
98
115
|
if (initiallyColoured)
|
|
99
116
|
setColour(true);
|
|
100
|
-
return {
|
|
117
|
+
return {
|
|
118
|
+
setColour,
|
|
119
|
+
setTime(date, coloured) {
|
|
120
|
+
selectedTime = date;
|
|
121
|
+
setColour(coloured);
|
|
122
|
+
},
|
|
123
|
+
};
|
|
101
124
|
}
|
|
102
125
|
// Sources open beside the page, so a detail floated over the skyline stays where it is.
|
|
103
126
|
function link(document, href, text) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as THREE from "../vendor/three-r186.js";
|
|
2
2
|
import { daylightColours, measuredColours } from "./colour-palette.js";
|
|
3
|
-
import { glassReflection } from "./colour-reflections.js";
|
|
3
|
+
import { glassReflection, daylightReflections } from "./colour-reflections.js";
|
|
4
|
+
import { nightWindowMask } from "./night-windows.js";
|
|
4
5
|
// The colour trial (https://github.com/a2f0/skyline/issues/115): which named material each
|
|
5
6
|
// surface of each building is, and a layer that shows the colours measured for those materials
|
|
6
7
|
// (colour-palette.ts) over the greys the models build in, restoring the greys exactly when it is
|
|
@@ -358,9 +359,16 @@ export function createDaylight() {
|
|
|
358
359
|
// another batch already took, since the builder lets batches share them.
|
|
359
360
|
export function createSkylineColour(models) {
|
|
360
361
|
const painted = [], flat = [], taken = new Set();
|
|
361
|
-
const
|
|
362
|
+
const facades = [];
|
|
363
|
+
const outlines = [];
|
|
364
|
+
let night = null;
|
|
362
365
|
const glassMaterials = new Set(["glass", "green glass", "blue-green glass", "bronze glass", "crown glass", "lit window", "dim window"]);
|
|
363
366
|
for (const model of models) {
|
|
367
|
+
model.building.traverse((object) => {
|
|
368
|
+
const line = object;
|
|
369
|
+
if (line.isLineSegments && line.material instanceof THREE.LineBasicMaterial)
|
|
370
|
+
outlines.push({ material: line.material, opacity: line.material.opacity });
|
|
371
|
+
});
|
|
364
372
|
const buildingId = String(model.building.userData["buildingId"]);
|
|
365
373
|
if (!measuredColours[buildingId])
|
|
366
374
|
continue;
|
|
@@ -381,7 +389,13 @@ export function createSkylineColour(models) {
|
|
|
381
389
|
return;
|
|
382
390
|
const [base, tones = {}] = batch;
|
|
383
391
|
const material = mesh.material;
|
|
384
|
-
const
|
|
392
|
+
const names = new Map();
|
|
393
|
+
const materialOf = (grey) => {
|
|
394
|
+
let name = names.get(grey);
|
|
395
|
+
if (!name)
|
|
396
|
+
names.set(grey, name = tones[toneOf(grey)] ?? base);
|
|
397
|
+
return name;
|
|
398
|
+
};
|
|
385
399
|
const attribute = mesh.geometry.getAttribute("color");
|
|
386
400
|
if (material.vertexColors) {
|
|
387
401
|
if (!(attribute?.array instanceof Float32Array) || taken.has(attribute))
|
|
@@ -413,8 +427,9 @@ export function createSkylineColour(models) {
|
|
|
413
427
|
}
|
|
414
428
|
mask[vertex] = reflects;
|
|
415
429
|
}
|
|
416
|
-
|
|
417
|
-
|
|
430
|
+
const windows = nightWindowMask(geometry, buildingId, (vertex) => materialOf(colours ? colours.array[vertex * 3] : material.color.r));
|
|
431
|
+
if (mask.some((value) => value > 0) || windows.some((value) => value > 0))
|
|
432
|
+
facades.push({ light: glassReflection(material, geometry, mask, windows), model });
|
|
418
433
|
const colourOf = (grey) => {
|
|
419
434
|
const swatch = swatches.get(materialOf(grey));
|
|
420
435
|
return swatch ? paint(grey, swatch) : [grey, grey, grey];
|
|
@@ -435,6 +450,12 @@ export function createSkylineColour(models) {
|
|
|
435
450
|
}
|
|
436
451
|
}
|
|
437
452
|
let enabled = false;
|
|
453
|
+
function updateNight() {
|
|
454
|
+
for (const { light, model } of facades)
|
|
455
|
+
light.setNight(night, enabled, Boolean(model.illumination?.active));
|
|
456
|
+
for (const { material, opacity } of outlines)
|
|
457
|
+
material.opacity = opacity * (night?.outlineStrength ?? 1);
|
|
458
|
+
}
|
|
438
459
|
return {
|
|
439
460
|
get enabled() { return enabled; },
|
|
440
461
|
set(next) {
|
|
@@ -459,10 +480,20 @@ export function createSkylineColour(models) {
|
|
|
459
480
|
}
|
|
460
481
|
for (const { material, grey, colour } of flat)
|
|
461
482
|
material.color.copy(next ? colour : grey);
|
|
462
|
-
for (const
|
|
463
|
-
|
|
483
|
+
for (const { light } of facades)
|
|
484
|
+
light(next);
|
|
485
|
+
updateNight();
|
|
486
|
+
},
|
|
487
|
+
setSky(colours) {
|
|
488
|
+
for (const { light } of facades)
|
|
489
|
+
light.setSky(colours ?? daylightReflections);
|
|
490
|
+
},
|
|
491
|
+
setNight(lighting) {
|
|
492
|
+
night = lighting;
|
|
493
|
+
updateNight();
|
|
464
494
|
},
|
|
465
495
|
refresh() {
|
|
496
|
+
updateNight();
|
|
466
497
|
if (!enabled)
|
|
467
498
|
return;
|
|
468
499
|
for (const batch of painted) {
|
|
@@ -290,3 +290,14 @@ export const measuredColours = {
|
|
|
290
290
|
// above the horizon, each the median of a clear box of the same panorama (scripts/sample-colours.ts,
|
|
291
291
|
// the day study's sky), and the sun's warm white, chosen.
|
|
292
292
|
export const daylightColours = { sky: 0x9ebbdb, horizon: 0xc4d1e1, sun: 0xfff4e6 };
|
|
293
|
+
// Art-directed night illumination: quiet cool facades and a mix of warm office lamps.
|
|
294
|
+
export const nightColours = { ambient: 0xa1afc8, fill: 0x8d9fbd, window: 0xffcf91, windowWhite: 0xffedce };
|
|
295
|
+
// Illustrated clear-sky time previews. Solar elevation chooses the stops; these are
|
|
296
|
+
// artistic colours, separate from the photograph-calibrated daytime palette above.
|
|
297
|
+
export const timeOfDayColours = [
|
|
298
|
+
{ elevation: -18, sky: 0x02040c, horizon: 0x080d1b, sun: 0xffb077 },
|
|
299
|
+
{ elevation: -8, sky: 0x142345, horizon: 0x5b526e, sun: 0xffad76 },
|
|
300
|
+
{ elevation: -2, sky: 0x415b89, horizon: 0xe89b79, sun: 0xffb477 },
|
|
301
|
+
{ elevation: 6, sky: 0x829fc5, horizon: 0xf0c3a0, sun: 0xffd4a8 },
|
|
302
|
+
{ elevation: 25, ...daylightColours },
|
|
303
|
+
];
|
|
@@ -1,32 +1,51 @@
|
|
|
1
1
|
import * as THREE from "../vendor/three-r186.js";
|
|
2
|
-
import { daylightColours } from "./colour-palette.js";
|
|
2
|
+
import { daylightColours, nightColours } from "./colour-palette.js";
|
|
3
|
+
export const daylightReflections = { ...daylightColours, ground: 0x444444 };
|
|
3
4
|
// A small sky reflection over the measured glass colour. This is an illustration of an open
|
|
4
5
|
// sky, not a reflection of nearby buildings or a replacement for the toon renderer. The mask
|
|
5
6
|
// separates glass from masonry and mullions even when the builder batches them in one mesh.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
7
|
+
// The same shader adds night-window emission, independently of material colour. Restore the
|
|
8
|
+
// original shader outside colour/time previews. Keep attributes attached once used so Three.js
|
|
9
|
+
// releases their GPU buffers when it disposes the geometry.
|
|
10
|
+
export function glassReflection(material, geometry, mask, windows = new Float32Array(mask.length * 4)) {
|
|
9
11
|
const attribute = new THREE.Float32BufferAttribute(mask, 1);
|
|
12
|
+
const windowAttribute = new THREE.Float32BufferAttribute(windows, 4);
|
|
10
13
|
const originalCompile = material.onBeforeCompile, originalKey = material.customProgramCacheKey;
|
|
11
14
|
const key = originalKey.call(material);
|
|
12
15
|
const uniforms = {
|
|
13
16
|
skylineSky: { value: new THREE.Color(daylightColours.sky) },
|
|
14
17
|
skylineHorizon: { value: new THREE.Color(daylightColours.horizon) },
|
|
15
18
|
skylineGround: { value: new THREE.Color(0x444444) },
|
|
19
|
+
skylineReflecting: { value: 0 },
|
|
20
|
+
skylineDarkness: { value: 0 },
|
|
21
|
+
skylineActivity: { value: 0 },
|
|
22
|
+
skylineWindowsSuppressed: { value: 0 },
|
|
23
|
+
skylineWindowWarm: { value: new THREE.Color(nightColours.window) },
|
|
24
|
+
skylineWindowWhite: { value: new THREE.Color(nightColours.windowWhite) },
|
|
16
25
|
};
|
|
17
26
|
const compile = (shader, renderer) => {
|
|
18
27
|
originalCompile.call(material, shader, renderer);
|
|
19
28
|
Object.assign(shader.uniforms, uniforms);
|
|
20
29
|
shader.vertexShader = shader.vertexShader.replace("#include <common>", `#include <common>
|
|
21
30
|
attribute float skylineGlass;
|
|
22
|
-
|
|
23
|
-
|
|
31
|
+
attribute vec4 skylineWindow;
|
|
32
|
+
varying float vSkylineGlass;
|
|
33
|
+
varying vec4 vSkylineWindow;`).replace("#include <begin_vertex>", `#include <begin_vertex>
|
|
34
|
+
vSkylineGlass = skylineGlass;
|
|
35
|
+
vSkylineWindow = skylineWindow;`);
|
|
24
36
|
shader.fragmentShader = shader.fragmentShader.replace("#include <common>", `#include <common>
|
|
25
37
|
varying float vSkylineGlass;
|
|
38
|
+
varying vec4 vSkylineWindow;
|
|
26
39
|
uniform vec3 skylineSky;
|
|
27
40
|
uniform vec3 skylineHorizon;
|
|
28
|
-
uniform vec3 skylineGround
|
|
29
|
-
|
|
41
|
+
uniform vec3 skylineGround;
|
|
42
|
+
uniform float skylineReflecting;
|
|
43
|
+
uniform float skylineDarkness;
|
|
44
|
+
uniform float skylineActivity;
|
|
45
|
+
uniform float skylineWindowsSuppressed;
|
|
46
|
+
uniform vec3 skylineWindowWarm;
|
|
47
|
+
uniform vec3 skylineWindowWhite;`).replace("#include <opaque_fragment>", `
|
|
48
|
+
if (vSkylineGlass > 0.0 && skylineReflecting > 0.0) {
|
|
30
49
|
vec3 eye = vec3(0.0);
|
|
31
50
|
eye.z = 1.0;
|
|
32
51
|
if (!isOrthographic) eye = normalize(vViewPosition);
|
|
@@ -36,18 +55,58 @@ if (vSkylineGlass > 0.0) {
|
|
|
36
55
|
float grazing = pow(1.0 - clamp(dot(normal, eye), 0.0, 1.0), 5.0);
|
|
37
56
|
outgoingLight = mix(outgoingLight, environment, vSkylineGlass * (0.06 + 0.54 * grazing));
|
|
38
57
|
}
|
|
58
|
+
if (vSkylineWindow.z > 0.0 && skylineDarkness > 0.0) {
|
|
59
|
+
float occupied = 1.0 - smoothstep(skylineActivity - 0.05, skylineActivity + 0.05, vSkylineWindow.x);
|
|
60
|
+
float dusk = smoothstep(0.12 + 0.35 * vSkylineWindow.x, 0.45 + 0.35 * vSkylineWindow.x, skylineDarkness);
|
|
61
|
+
float light = vSkylineWindow.z > 1.5 ? skylineDarkness : occupied * dusk * (1.0 - skylineWindowsSuppressed);
|
|
62
|
+
vec3 lamp = mix(skylineWindowWarm, skylineWindowWhite, vSkylineWindow.w);
|
|
63
|
+
outgoingLight += lamp * vSkylineWindow.y * light;
|
|
64
|
+
}
|
|
39
65
|
#include <opaque_fragment>`);
|
|
40
66
|
};
|
|
41
|
-
|
|
67
|
+
let reflecting = false, night = false, installed = false;
|
|
68
|
+
function install() {
|
|
69
|
+
const enabled = reflecting || night;
|
|
70
|
+
if (enabled === installed)
|
|
71
|
+
return;
|
|
72
|
+
installed = enabled;
|
|
42
73
|
if (enabled) {
|
|
43
74
|
geometry.setAttribute("skylineGlass", attribute);
|
|
75
|
+
geometry.setAttribute("skylineWindow", windowAttribute);
|
|
44
76
|
material.onBeforeCompile = compile;
|
|
45
|
-
material.customProgramCacheKey = () => `${key}:skyline-
|
|
77
|
+
material.customProgramCacheKey = () => `${key}:skyline-facade-v2`;
|
|
46
78
|
}
|
|
47
79
|
else {
|
|
48
80
|
material.onBeforeCompile = originalCompile;
|
|
49
81
|
material.customProgramCacheKey = originalKey;
|
|
50
82
|
}
|
|
51
83
|
material.needsUpdate = true;
|
|
84
|
+
}
|
|
85
|
+
const set = (enabled) => {
|
|
86
|
+
reflecting = enabled;
|
|
87
|
+
uniforms.skylineReflecting.value = enabled ? 1 : 0;
|
|
88
|
+
install();
|
|
52
89
|
};
|
|
90
|
+
return Object.assign(set, {
|
|
91
|
+
setSky(colours) {
|
|
92
|
+
uniforms.skylineSky.value.setHex(colours.sky);
|
|
93
|
+
uniforms.skylineHorizon.value.setHex(colours.horizon);
|
|
94
|
+
uniforms.skylineGround.value.setHex(colours.ground);
|
|
95
|
+
},
|
|
96
|
+
setNight(lighting, coloured, celebration) {
|
|
97
|
+
night = lighting !== null;
|
|
98
|
+
// A deliberately composed celebration takes precedence over ordinary office rooms.
|
|
99
|
+
uniforms.skylineDarkness.value = lighting?.darkness ?? 0;
|
|
100
|
+
uniforms.skylineWindowsSuppressed.value = celebration ? 1 : 0;
|
|
101
|
+
uniforms.skylineActivity.value = lighting?.activity ?? 0;
|
|
102
|
+
for (const [uniform, hex] of [[uniforms.skylineWindowWarm, nightColours.window], [uniforms.skylineWindowWhite, nightColours.windowWhite]]) {
|
|
103
|
+
uniform.value.setHex(hex);
|
|
104
|
+
if (!coloured) {
|
|
105
|
+
const { r, g, b } = uniform.value, grey = r * 0.2126 + g * 0.7152 + b * 0.0722;
|
|
106
|
+
uniform.value.setRGB(grey, grey, grey);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
install();
|
|
110
|
+
},
|
|
111
|
+
});
|
|
53
112
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export const smooth = (low, high, value) => {
|
|
2
|
+
const t = Math.max(0, Math.min(1, (value - low) / (high - low)));
|
|
3
|
+
return t * t * (3 - 2 * t);
|
|
4
|
+
};
|
|
5
|
+
// A continuous daily cycle: busy evenings, a few overnight rooms, then early arrivals.
|
|
6
|
+
// Midnight shares the same curve from either side, including when scrubbing backwards.
|
|
7
|
+
export function windowActivity(hour) {
|
|
8
|
+
const late = hour < 12 ? hour + 24 : hour;
|
|
9
|
+
return 0.76 - 0.52 * smooth(21, 26, late) + 0.3 * smooth(29, 32, late) + (hour < 12 ? 0.22 * smooth(8, 12, hour) : 0);
|
|
10
|
+
}
|
|
11
|
+
function hash(value) {
|
|
12
|
+
let seed = 2166136261;
|
|
13
|
+
for (const character of value)
|
|
14
|
+
seed = Math.imul(seed ^ character.charCodeAt(0), 16777619);
|
|
15
|
+
// Avalanche adjacent room keys: plain FNV leaves trailing bay digits correlated,
|
|
16
|
+
// making a whole east-facing floor turn on together instead of small room groups.
|
|
17
|
+
seed = Math.imul(seed ^ (seed >>> 16), 0x85ebca6b);
|
|
18
|
+
seed = Math.imul(seed ^ (seed >>> 13), 0xc2b2ae35);
|
|
19
|
+
seed ^= seed >>> 16;
|
|
20
|
+
return (seed >>> 0) / 4294967296;
|
|
21
|
+
}
|
|
22
|
+
const architectural = new Set(["lamp", "crown lights", "band lights", "sign letters", "cap lights"]);
|
|
23
|
+
// The building kit emits panes as paired triangles, interspersed with standalone
|
|
24
|
+
// triangles for other features. Find shared edges rather than assuming quad alignment.
|
|
25
|
+
// Reuse those exact surfaces;
|
|
26
|
+
// never turn a generic glass shell, spandrel, masonry face or roof into a light source.
|
|
27
|
+
// Adjacent bays share a floor/room seed, so offices come on in small groups. Identity
|
|
28
|
+
// depends on local geometry and building ID, not load order, camera or slider direction.
|
|
29
|
+
export function nightWindowMask(geometry, buildingId, materialOf) {
|
|
30
|
+
const positions = geometry.getAttribute("position"), normals = geometry.getAttribute("normal");
|
|
31
|
+
const mask = new Float32Array(positions.count * 4);
|
|
32
|
+
const samePoint = (a, b) => positions.getX(a) === positions.getX(b) && positions.getY(a) === positions.getY(b) && positions.getZ(a) === positions.getZ(b);
|
|
33
|
+
for (let first = 0; first < positions.count;) {
|
|
34
|
+
const name = materialOf(first), crown = architectural.has(name);
|
|
35
|
+
const window = name === "lit window" || name === "dim window";
|
|
36
|
+
if (!crown && (!window || Math.abs(normals.getY(first)) > 0.5)) {
|
|
37
|
+
first += 3;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
let count = 3;
|
|
41
|
+
if (first + 6 <= positions.count && materialOf(first + 3) === name
|
|
42
|
+
&& normals.getX(first) === normals.getX(first + 3) && normals.getY(first) === normals.getY(first + 3) && normals.getZ(first) === normals.getZ(first + 3)) {
|
|
43
|
+
let shared = 0;
|
|
44
|
+
for (let a = first; a < first + 3; a += 1) {
|
|
45
|
+
for (let b = first + 3; b < first + 6; b += 1)
|
|
46
|
+
if (samePoint(a, b)) {
|
|
47
|
+
shared += 1;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (shared === 2)
|
|
52
|
+
count = 6;
|
|
53
|
+
}
|
|
54
|
+
const end = first + count;
|
|
55
|
+
let x = 0, y = 0, z = 0;
|
|
56
|
+
for (let vertex = first; vertex < end; vertex += 1) {
|
|
57
|
+
x += positions.getX(vertex);
|
|
58
|
+
y += positions.getY(vertex);
|
|
59
|
+
z += positions.getZ(vertex);
|
|
60
|
+
}
|
|
61
|
+
const floor = Math.round(y / count / 3.4), across = Math.floor(x / count / 7), depth = Math.floor(z / count / 7);
|
|
62
|
+
const room = hash(`${buildingId}:${floor}:${across}:${depth}`);
|
|
63
|
+
const brightness = hash(`${buildingId}:${floor}:${across}:${depth}:brightness`);
|
|
64
|
+
for (let vertex = first; vertex < end; vertex += 1) {
|
|
65
|
+
if (materialOf(vertex) !== name)
|
|
66
|
+
continue;
|
|
67
|
+
const rank = name === "lit window" ? 0.05 + room * 0.8 : 0.3 + room * 0.75;
|
|
68
|
+
mask[vertex * 4] = rank;
|
|
69
|
+
mask[vertex * 4 + 1] = crown ? 0.3 : 0.25 + brightness * 0.45;
|
|
70
|
+
mask[vertex * 4 + 2] = crown ? 2 : 1;
|
|
71
|
+
mask[vertex * 4 + 3] = brightness;
|
|
72
|
+
}
|
|
73
|
+
first = end;
|
|
74
|
+
}
|
|
75
|
+
return mask;
|
|
76
|
+
}
|
package/site/skyline-3d.html
CHANGED
|
@@ -56,6 +56,12 @@
|
|
|
56
56
|
<button type="button" id="colour" aria-pressed="false" title="Colour trial: a sunny day, each measured building in its photographed colours; the rest stay grey">colour</button>
|
|
57
57
|
<button type="button" id="turntable" aria-pressed="false" aria-describedby="motion-status">turntable</button>
|
|
58
58
|
<button type="button" id="show-original" title="Compare with the original skyline SVG" hidden>show original</button>
|
|
59
|
+
<div class="time-controls">
|
|
60
|
+
<label for="time-of-day"><span id="time-caption">Chicago time</span> <span id="time-date"></span></label>
|
|
61
|
+
<input id="time-of-day" type="range" min="0" max="1439" step="1" value="0" aria-labelledby="time-caption" />
|
|
62
|
+
<output id="time-value" for="time-of-day" aria-live="off">original night</output>
|
|
63
|
+
<button type="button" id="reset-light" disabled title="Restore the original lighting preset">reset light</button>
|
|
64
|
+
</div>
|
|
59
65
|
<div class="celebration-controls" id="celebration-controls" role="group" aria-label="Celebratory window lighting">
|
|
60
66
|
<span class="celebration-caption">lights</span>
|
|
61
67
|
<button type="button" data-celebration="bulls" aria-label="Bulls lighting" aria-pressed="false">
|
package/site/skyline-3d.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createBuildingStudy, frameBox } from "./study-viewer.js";
|
|
2
2
|
import { createGeographicSkyline } from "./skyline-scene.js";
|
|
3
|
-
import {
|
|
3
|
+
import { chicagoDay, chicagoTime } from "./solar-time.js";
|
|
4
|
+
import { createTimeLighting } from "./time-of-day.js";
|
|
5
|
+
import { createGeographicRoads, geographicOrigin } from "./models/skyline-geography.js";
|
|
4
6
|
import { celebrations } from "./models/celebrations.js";
|
|
5
7
|
import { createDaylight, createSkylineColour, daylightGround } from "./models/colour-materials.js";
|
|
6
8
|
import { loadMarkup, loadStylesheet } from "./markup.js";
|
|
@@ -109,26 +111,60 @@ export function startSkyline3d({ root, frame, assets, signal, colour: initiallyC
|
|
|
109
111
|
viewer.requestRender();
|
|
110
112
|
}, listening);
|
|
111
113
|
}
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
+
// Preserve the original grey/day presets until time is scrubbed. Once previewing,
|
|
115
|
+
// material colour and time are independent; reset light returns to the chosen preset.
|
|
114
116
|
const colourButton = root.querySelector("#colour");
|
|
115
117
|
const roadway = roads.material;
|
|
118
|
+
const timeInput = root.querySelector("#time-of-day");
|
|
119
|
+
const timeOutput = root.querySelector("#time-value");
|
|
120
|
+
const timeReset = root.querySelector("#reset-light");
|
|
121
|
+
const day = chicagoDay(new Date());
|
|
122
|
+
root.querySelector("#time-date").textContent = day.label;
|
|
123
|
+
timeInput.max = String(day.minutes - 1);
|
|
124
|
+
const timeLight = createTimeLighting(geographicOrigin[1], geographicOrigin[0], root instanceof ShadowRoot);
|
|
125
|
+
let selectedTime = null;
|
|
116
126
|
let daylight;
|
|
117
127
|
let detailView;
|
|
118
|
-
signal.addEventListener("abort", () => daylight?.sky.dispose(), { once: true });
|
|
128
|
+
signal.addEventListener("abort", () => { daylight?.sky.dispose(); timeLight.dispose(); }, { once: true });
|
|
119
129
|
function setColour(enabled) {
|
|
120
130
|
colour.set(enabled);
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
131
|
+
if (selectedTime) {
|
|
132
|
+
const lighting = timeLight.update(selectedTime, enabled);
|
|
133
|
+
colour.setSky(lighting.reflections);
|
|
134
|
+
colour.setNight(lighting.night);
|
|
135
|
+
roadway.color.setHex(lighting.roads);
|
|
136
|
+
viewer.setDaylight(lighting);
|
|
137
|
+
timeOutput.value = chicagoTime(selectedTime);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
if (enabled)
|
|
141
|
+
daylight ??= createDaylight();
|
|
142
|
+
colour.setSky(null);
|
|
143
|
+
colour.setNight(null);
|
|
144
|
+
roadway.color.setHex(enabled ? daylightGround.roads : nightRoads);
|
|
145
|
+
viewer.setDaylight(enabled ? daylight : null);
|
|
146
|
+
timeInput.value = enabled ? "720" : "0";
|
|
147
|
+
timeOutput.value = enabled ? "sunny day" : "original night";
|
|
148
|
+
}
|
|
149
|
+
timeReset.disabled = !selectedTime;
|
|
150
|
+
timeInput.setAttribute("aria-valuetext", `${timeOutput.value}, ${day.label}, Chicago${selectedTime ? "" : ", move to preview a time"}`);
|
|
151
|
+
colourButton.setAttribute("aria-pressed", String(enabled));
|
|
152
|
+
colourButton.title = selectedTime ? (enabled ? "Show this time in greyscale" : "Show this time in colour")
|
|
153
|
+
: enabled ? "Restore the greyscale skyline" : "Show the skyline in sunny-day colour";
|
|
154
|
+
detailView?.setTime(selectedTime, enabled);
|
|
128
155
|
}
|
|
156
|
+
timeInput.addEventListener("input", () => {
|
|
157
|
+
const firstPreview = selectedTime === null;
|
|
158
|
+
selectedTime = new Date(day.start + timeInput.valueAsNumber * 60_000);
|
|
159
|
+
setColour(firstPreview || colour.enabled);
|
|
160
|
+
}, listening);
|
|
161
|
+
timeReset.addEventListener("click", () => {
|
|
162
|
+
timeInput.focus({ preventScroll: true });
|
|
163
|
+
selectedTime = null;
|
|
164
|
+
setColour(colour.enabled);
|
|
165
|
+
}, listening);
|
|
129
166
|
colourButton.addEventListener("click", () => setColour(!colour.enabled), listening);
|
|
130
|
-
|
|
131
|
-
setColour(true);
|
|
167
|
+
setColour(initiallyColoured);
|
|
132
168
|
// The star in the middle of the bar opens and closes the toolbar. Each group unfolds from
|
|
133
169
|
// the star: clipped open from its `--fold` inset while it fades in, one beside the star
|
|
134
170
|
// slides out from it to its edge of the bar, and it folds back the same way before it hides. A toggle mid-way reverses the moving
|
|
@@ -412,6 +448,7 @@ export function startSkyline3d({ root, frame, assets, signal, colour: initiallyC
|
|
|
412
448
|
markup.classList.add("framed");
|
|
413
449
|
detailRoot.append(document.importNode(markup, true));
|
|
414
450
|
detailView = showBuildingDetail({ root: detailRoot, id, frame: detailRoot.querySelector(".detail-root"), signal: controller.signal, colour: colour.enabled, onColourChange: setColour });
|
|
451
|
+
detailView?.setTime(selectedTime, colour.enabled);
|
|
415
452
|
}).catch((error) => {
|
|
416
453
|
if (controller.signal.aborted)
|
|
417
454
|
return;
|
package/site/skyline-viewer.js
CHANGED
|
@@ -201,7 +201,7 @@ export function mount(root, { assets, signal, controls, colour, attribution, nav
|
|
|
201
201
|
// As the scene's own page says it: its controls stay, disabled, and the drawing one
|
|
202
202
|
// press away.
|
|
203
203
|
scene.querySelector("#loading").textContent = SCENE_FAILURE;
|
|
204
|
-
scene.querySelectorAll("button").forEach((
|
|
204
|
+
scene.querySelectorAll("button, input").forEach((control) => { control.disabled = true; });
|
|
205
205
|
throw error;
|
|
206
206
|
}
|
|
207
207
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const radians = Math.PI / 180;
|
|
2
|
+
const minute = 60_000;
|
|
3
|
+
const chicago = new Intl.DateTimeFormat("en-US", { timeZone: "America/Chicago", year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", hourCycle: "h23" });
|
|
4
|
+
const clock = new Intl.DateTimeFormat("en-US", { timeZone: "America/Chicago", hour: "numeric", minute: "2-digit", timeZoneName: "short" });
|
|
5
|
+
function parts(date) {
|
|
6
|
+
const values = Object.fromEntries(chicago.formatToParts(date).map(({ type, value }) => [type, Number(value)]));
|
|
7
|
+
return { year: values["year"], month: values["month"], day: values["day"], hour: values["hour"], minute: values["minute"] };
|
|
8
|
+
}
|
|
9
|
+
// Chicago's midnight is unambiguous even on clock-change dates. Resolve each end
|
|
10
|
+
// separately: those days contain 23 or 25 hours, and both repeated hours are reachable.
|
|
11
|
+
function midnight(year, month, day) {
|
|
12
|
+
const wall = Date.UTC(year, month - 1, day);
|
|
13
|
+
let instant = wall;
|
|
14
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
15
|
+
const p = parts(new Date(instant));
|
|
16
|
+
instant += wall - Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute);
|
|
17
|
+
}
|
|
18
|
+
return instant;
|
|
19
|
+
}
|
|
20
|
+
export function chicagoDay(now) {
|
|
21
|
+
const { year, month, day } = parts(now);
|
|
22
|
+
const start = midnight(year, month, day), end = midnight(year, month, day + 1);
|
|
23
|
+
return {
|
|
24
|
+
start,
|
|
25
|
+
minutes: (end - start) / minute,
|
|
26
|
+
label: new Intl.DateTimeFormat("en-US", { timeZone: "America/Chicago", month: "short", day: "numeric", year: "numeric" }).format(now),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export const chicagoTime = (date) => clock.format(date);
|
|
30
|
+
export const chicagoHour = (date) => {
|
|
31
|
+
const { hour, minute: minutes } = parts(date);
|
|
32
|
+
return hour + minutes / 60;
|
|
33
|
+
};
|
|
34
|
+
// NOAA's general solar-position equations (geometric elevation, no refraction):
|
|
35
|
+
// https://gml.noaa.gov/grad/solcalc/solareqns.PDF
|
|
36
|
+
// An absolute instant and east-positive longitude avoid browser time-zone/DST offsets.
|
|
37
|
+
export function solarPosition(date, latitude, longitude) {
|
|
38
|
+
const year = date.getUTCFullYear(), start = Date.UTC(year, 0, 1);
|
|
39
|
+
const days = (Date.UTC(year + 1, 0, 1) - start) / 86_400_000;
|
|
40
|
+
const fraction = 2 * Math.PI / days * ((date.getTime() - start) / 86_400_000 - 0.5);
|
|
41
|
+
const equation = 229.18 * (0.000075 + 0.001868 * Math.cos(fraction) - 0.032077 * Math.sin(fraction)
|
|
42
|
+
- 0.014615 * Math.cos(2 * fraction) - 0.040849 * Math.sin(2 * fraction));
|
|
43
|
+
const declination = 0.006918 - 0.399912 * Math.cos(fraction) + 0.070257 * Math.sin(fraction)
|
|
44
|
+
- 0.006758 * Math.cos(2 * fraction) + 0.000907 * Math.sin(2 * fraction)
|
|
45
|
+
- 0.002697 * Math.cos(3 * fraction) + 0.00148 * Math.sin(3 * fraction);
|
|
46
|
+
const utcMinutes = date.getUTCHours() * 60 + date.getUTCMinutes() + date.getUTCSeconds() / 60;
|
|
47
|
+
const hour = ((utcMinutes + equation + 4 * longitude) / 4 - 180) * radians;
|
|
48
|
+
const lat = latitude * radians;
|
|
49
|
+
const east = -Math.cos(declination) * Math.sin(hour);
|
|
50
|
+
const north = Math.cos(lat) * Math.sin(declination) - Math.sin(lat) * Math.cos(declination) * Math.cos(hour);
|
|
51
|
+
const up = Math.sin(lat) * Math.sin(declination) + Math.cos(lat) * Math.cos(declination) * Math.cos(hour);
|
|
52
|
+
return { elevation: Math.asin(Math.max(-1, Math.min(1, up))) / radians, direction: [east, up, -north] };
|
|
53
|
+
}
|
package/site/study-loader.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const entry = document.currentScript.dataset["study"];
|
|
4
4
|
function showStudyError(message) {
|
|
5
5
|
document.querySelector("#loading").textContent = message;
|
|
6
|
-
document.querySelectorAll("button").forEach((
|
|
6
|
+
document.querySelectorAll("button, input").forEach((control) => { control.disabled = true; });
|
|
7
7
|
}
|
|
8
8
|
if (location.protocol === "file:") {
|
|
9
9
|
const page = location.pathname.split("/").pop();
|
package/site/study-viewer.js
CHANGED
|
@@ -109,6 +109,7 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
109
109
|
const night = { sun: [0xffffff, 2.4], ambient: [0xffffff, 0.7], fill: [0xffffff, 0.55] };
|
|
110
110
|
const ambientLight = new THREE.AmbientLight(...night.ambient);
|
|
111
111
|
scene.add(ambientLight);
|
|
112
|
+
let lightBounds;
|
|
112
113
|
const keyLight = new THREE.DirectionalLight(...night.sun);
|
|
113
114
|
keyLight.position.fromArray(lightPosition);
|
|
114
115
|
keyLight.target.position.set(0, 80, 0);
|
|
@@ -489,6 +490,7 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
489
490
|
const next = { ...original, ...layouts?.[name] };
|
|
490
491
|
layout = name;
|
|
491
492
|
({ models, fit, target, platform, lightPosition, shadowCamera, clippingMargin } = next);
|
|
493
|
+
lightBounds = undefined;
|
|
492
494
|
extras = next.extras || [];
|
|
493
495
|
models.forEach((model) => { scene.add(model.building); model.setWireframe(wireframe); });
|
|
494
496
|
extras.forEach((object) => scene.add(object));
|
|
@@ -501,6 +503,7 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
501
503
|
base.position.set(platform.x || 0, -1.1, platform.z || 0);
|
|
502
504
|
baseEdges.position.copy(base.position);
|
|
503
505
|
keyLight.position.fromArray(lightPosition);
|
|
506
|
+
keyLight.target.position.set(0, 80, 0);
|
|
504
507
|
Object.assign(keyLight.shadow.camera, shadowCamera);
|
|
505
508
|
keyLight.shadow.camera.updateProjectionMatrix();
|
|
506
509
|
const nextView = previousView && previousView !== defaultView ? previousView : next.defaultView || defaultView;
|
|
@@ -586,6 +589,8 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
586
589
|
get groundShadows() { return base.receiveShadow; },
|
|
587
590
|
get activeView() { return activeView; },
|
|
588
591
|
get selectedBuilding() { return selectedModel?.building.userData["buildingId"] || null; },
|
|
592
|
+
get sunDirection() { return keyLight.position.clone().sub(keyLight.target.position).normalize().toArray(); },
|
|
593
|
+
get sunIntensity() { return keyLight.intensity; },
|
|
589
594
|
get shadowBounds() {
|
|
590
595
|
// Actual rendered geometry in the shadow camera's clip space, for coverage checks.
|
|
591
596
|
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
|
|
@@ -667,7 +672,29 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
|
|
|
667
672
|
Object.defineProperty(canvas, "__buildingStudy", { value: api, configurable: true });
|
|
668
673
|
onReady();
|
|
669
674
|
// Day or night: the transparent canvas shows the page's night behind it until a sky covers it.
|
|
675
|
+
// A sphere enclosing every caster and the platform fits the shadow camera at any
|
|
676
|
+
// solar azimuth/elevation. Cache it: dragging time never traverses model vertices.
|
|
670
677
|
function setDaylight(day) {
|
|
678
|
+
if (day?.direction) {
|
|
679
|
+
if (!lightBounds) {
|
|
680
|
+
const min = new THREE.Vector3(Infinity, Infinity, Infinity), max = new THREE.Vector3(-Infinity, -Infinity, -Infinity);
|
|
681
|
+
for (const bounds of [api.platformBounds, ...api.modelBounds]) {
|
|
682
|
+
min.min(new THREE.Vector3().fromArray(bounds.min));
|
|
683
|
+
max.max(new THREE.Vector3().fromArray(bounds.max));
|
|
684
|
+
}
|
|
685
|
+
lightBounds = { center: min.clone().add(max).multiplyScalar(0.5), radius: max.distanceTo(min) / 2 };
|
|
686
|
+
}
|
|
687
|
+
const radius = lightBounds.radius * 1.02;
|
|
688
|
+
keyLight.target.position.copy(lightBounds.center);
|
|
689
|
+
keyLight.position.fromArray(day.direction).multiplyScalar(2 * radius + 1).add(lightBounds.center);
|
|
690
|
+
Object.assign(keyLight.shadow.camera, { left: -radius, right: radius, top: radius, bottom: -radius, near: 1, far: 4 * radius + 2 });
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
keyLight.position.fromArray(lightPosition);
|
|
694
|
+
keyLight.target.position.set(0, 80, 0);
|
|
695
|
+
Object.assign(keyLight.shadow.camera, shadowCamera);
|
|
696
|
+
}
|
|
697
|
+
keyLight.shadow.camera.updateProjectionMatrix();
|
|
671
698
|
scene.background = day?.sky ?? null;
|
|
672
699
|
base.material.color.setHex(day?.ground ?? nightGround);
|
|
673
700
|
for (const [light, [colour, intensity]] of [[keyLight, day?.sun ?? night.sun], [ambientLight, day?.ambient ?? night.ambient], [fillLight, day?.fill ?? night.fill]]) {
|
package/site/study.css
CHANGED
|
@@ -162,6 +162,12 @@ body.skyline-3d { height: 100vh; height: 100dvh; }
|
|
|
162
162
|
.control-bar .button-group { gap: 4px; }
|
|
163
163
|
.control-bar .camera-views { grid-area: views; justify-self: start; --fold: inset(0 0 0 100%); }
|
|
164
164
|
.control-bar .model-display { grid-area: display; justify-self: end; justify-content: end; --fold: inset(0 100% 0 0); }
|
|
165
|
+
.time-controls { display: grid; grid-template-columns: auto minmax(60px, 1fr) auto auto; align-items: center; gap: 8px; flex-basis: 100%; width: 100%; font-size: 10px; }
|
|
166
|
+
.time-controls label { color: #bbb; }
|
|
167
|
+
.time-controls #time-date { display: block; margin-top: 2px; color: #888; font-size: 9px; }
|
|
168
|
+
.time-controls input { width: 100%; min-width: 0; height: 28px; margin: 0; accent-color: #bbb; cursor: pointer; }
|
|
169
|
+
.time-controls output { min-width: 88px; text-align: right; color: #ddd; font-variant-numeric: tabular-nums; }
|
|
170
|
+
.time-controls button:disabled { opacity: 0.45; cursor: default; }
|
|
165
171
|
.celebration-controls { display: flex; flex-wrap: wrap; align-items: center; justify-content: end; gap: 4px; flex-basis: 100%; }
|
|
166
172
|
.celebration-caption { margin-right: 4px; color: #888; font-size: 10px; }
|
|
167
173
|
.control-bar .celebration-controls button { display: grid; place-items: center; width: 36px; height: 36px; padding: 4px; }
|
|
@@ -179,8 +185,8 @@ body.skyline-3d { height: 100vh; height: 100dvh; }
|
|
|
179
185
|
.control-bar .menu-toggle:hover { color: #fff; background: #ffffff14; }
|
|
180
186
|
.menu-toggle svg { width: 20px; height: 20px; fill: currentColor; transition: rotate 0.35s cubic-bezier(0.2, 0.7, 0.2, 1); }
|
|
181
187
|
.menu-toggle[aria-expanded="true"] svg { rotate: 30deg; }
|
|
182
|
-
/*
|
|
183
|
-
|
|
188
|
+
/* Compact badges leave room for the time slider. Smaller screens retain 36px
|
|
189
|
+
touch targets. */
|
|
184
190
|
@container skyline (min-width: 1440px) {
|
|
185
191
|
.celebration-controls { flex-basis: auto; flex-wrap: nowrap; }
|
|
186
192
|
.celebration-caption { display: none; }
|
|
@@ -203,6 +209,12 @@ body.skyline-3d { height: 100vh; height: 100dvh; }
|
|
|
203
209
|
.skyline-trace-tip { display: none; animation: none; }
|
|
204
210
|
}
|
|
205
211
|
@container skyline (max-width: 600px) {
|
|
212
|
+
.time-controls { grid-template-columns: minmax(0, 1fr) auto; gap: 2px 8px; }
|
|
213
|
+
.time-controls label { grid-column: 1; grid-row: 1; }
|
|
214
|
+
.time-controls #time-date { display: inline; margin-left: 8px; }
|
|
215
|
+
.time-controls output { grid-column: 2; grid-row: 1; }
|
|
216
|
+
.time-controls input { grid-column: 1; grid-row: 2; height: 32px; }
|
|
217
|
+
.time-controls button { grid-column: 2; grid-row: 2; }
|
|
206
218
|
.skyline-trace { stroke-width: 8.4; }
|
|
207
219
|
.skyline-trace-tip { stroke-width: 12; }
|
|
208
220
|
.skyline-3d .hint { font-size: 9px; }
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as THREE from "./vendor/three-r186.js";
|
|
2
|
+
import { daylightGround, daylightIntensities } from "./models/colour-materials.js";
|
|
3
|
+
import { nightColours, timeOfDayColours } from "./models/colour-palette.js";
|
|
4
|
+
import { smooth, windowActivity } from "./models/night-windows.js";
|
|
5
|
+
import { chicagoHour, solarPosition } from "./solar-time.js";
|
|
6
|
+
// One tiny texture per viewer, updated in place while dragging and disposed at teardown.
|
|
7
|
+
// Transparent night sky reveals the viewer's existing stars behind opaque buildings.
|
|
8
|
+
export function createTimeLighting(latitude, longitude, transparentNight) {
|
|
9
|
+
const rows = 256, data = new Uint8Array(rows * 4);
|
|
10
|
+
const sky = new THREE.DataTexture(data, 1, rows);
|
|
11
|
+
sky.colorSpace = "srgb";
|
|
12
|
+
const a = new THREE.Color(), b = new THREE.Color(), pixel = new THREE.Color();
|
|
13
|
+
function blend(from, to, amount, coloured) {
|
|
14
|
+
a.setHex(from).lerp(b.setHex(to), amount);
|
|
15
|
+
if (!coloured) {
|
|
16
|
+
const grey = a.r * 0.2126 + a.g * 0.7152 + a.b * 0.0722;
|
|
17
|
+
a.setRGB(grey, grey, grey);
|
|
18
|
+
}
|
|
19
|
+
return a.getHex();
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
sky,
|
|
23
|
+
update(date, coloured) {
|
|
24
|
+
const { elevation, direction } = solarPosition(date, latitude, longitude);
|
|
25
|
+
const upper = timeOfDayColours.findIndex((stop) => stop.elevation > elevation);
|
|
26
|
+
const last = timeOfDayColours.length - 1;
|
|
27
|
+
const low = timeOfDayColours[upper < 0 ? last : Math.max(0, upper - 1)];
|
|
28
|
+
const high = timeOfDayColours[upper < 0 ? last : upper];
|
|
29
|
+
const amount = high === low ? 0 : smooth(low.elevation, high.elevation, elevation);
|
|
30
|
+
const top = blend(low.sky, high.sky, amount, coloured);
|
|
31
|
+
const horizon = blend(low.horizon, high.horizon, amount, coloured);
|
|
32
|
+
const sun = blend(low.sun, high.sun, amount, coloured);
|
|
33
|
+
const day = smooth(-6, 18, elevation);
|
|
34
|
+
const opacity = transparentNight ? smooth(-18, -2, elevation) : 1;
|
|
35
|
+
const topColour = new THREE.Color(top), bottomColour = new THREE.Color(horizon);
|
|
36
|
+
for (let row = 0; row < rows; row += 1) {
|
|
37
|
+
const hex = pixel.copy(bottomColour).lerp(topColour, row / (rows - 1)).getHex();
|
|
38
|
+
// The canvas is composited with premultiplied alpha. Background textures write
|
|
39
|
+
// directly to it, so premultiply the final sRGB channels here as well.
|
|
40
|
+
data.set([Math.round(((hex >> 16) & 255) * opacity), Math.round(((hex >> 8) & 255) * opacity), Math.round((hex & 255) * opacity), Math.round(255 * opacity)], row * 4);
|
|
41
|
+
}
|
|
42
|
+
sky.needsUpdate = true;
|
|
43
|
+
const ground = blend(0x161616, daylightGround.blocks, day, false);
|
|
44
|
+
return {
|
|
45
|
+
sky, direction, ground,
|
|
46
|
+
roads: blend(0x2c2c2c, daylightGround.roads, day, false),
|
|
47
|
+
sun: [sun, daylightIntensities.sun * smooth(-0.833, 8, elevation)],
|
|
48
|
+
ambient: [blend(nightColours.ambient, top, day, coloured), 0.16 + day * (daylightIntensities.ambient - 0.16)],
|
|
49
|
+
fill: [blend(nightColours.fill, horizon, day, coloured), 0.08 + day * (daylightIntensities.fill - 0.08)],
|
|
50
|
+
reflections: { sky: top, horizon, ground: blend(0x080808, 0x444444, day, false) },
|
|
51
|
+
night: { darkness: 1 - smooth(-6, 3, elevation), activity: windowActivity(chicagoHour(date)), outlineStrength: 0.05 + 0.95 * day },
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
dispose() { sky.dispose(); },
|
|
55
|
+
};
|
|
56
|
+
}
|