@a2f0/skyline 0.2.19 → 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/blue-cross-geographic.js +55 -20
- package/lib/models/colour-materials.d.ts +8 -1
- package/lib/models/colour-materials.js +40 -9
- 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/blue-cross-geographic.js +55 -20
- package/site/models/colour-materials.js +40 -9
- 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
|
@@ -38,7 +38,8 @@ const messageTopFloors = zones.map(([, , count], zone) => h.floors.slice(0, zone
|
|
|
38
38
|
+ Math.floor((count + 5) / 2) - 1).reverse().slice(0, 3);
|
|
39
39
|
export const blueCrossFloors = [...zones.flatMap(([from, to, count]) => Array.from({ length: count }, (_, i) => from + (to - from) * i / count)), h.occupied];
|
|
40
40
|
const spandrelBelow = 0.3, spandrelAbove = 0.9;
|
|
41
|
-
const
|
|
41
|
+
const infillFront = 0.52, emblemClearance = 0.1;
|
|
42
|
+
const transom = { height: 0.18, back: 0.17, front: 0.22, cornerClearance: 0.25 };
|
|
42
43
|
const color = (hex) => new THREE.Color(hex);
|
|
43
44
|
const glassTones = [0x414141, 0x464646, 0x3c3c3c, 0x4a4a4a].map(color);
|
|
44
45
|
const litGlass = color(0x8a8a8a), dimGlass = color(0x5c5c5c);
|
|
@@ -76,14 +77,29 @@ const rows = [];
|
|
|
76
77
|
push(h.roof, h.top, "screen", -1);
|
|
77
78
|
}
|
|
78
79
|
const rowsBetween = (lo, hi) => rows.filter((row) => row.lo >= lo - 1e-6 && row.hi <= hi + 1e-6);
|
|
80
|
+
// The end wings carry their curtain wall through the lower and upper bands.
|
|
81
|
+
// Only the middle level is an open setback. Subdivide the old band backing into
|
|
82
|
+
// normal glazing/spandrel courses on the wings and exposed short block returns.
|
|
83
|
+
// FID-BCBS-005: 2012 oblique photographs, docs/blue-cross-reference.md.
|
|
84
|
+
const glazedBandRows = (cuts) => cuts.flatMap((row) => {
|
|
85
|
+
if (row.kind !== "band")
|
|
86
|
+
return [row];
|
|
87
|
+
const band = h.bands.find(([, hi]) => hi === row.hi);
|
|
88
|
+
const count = band === h.bands[1] ? 3 : 2;
|
|
89
|
+
return Array.from({ length: count }, (_, i) => {
|
|
90
|
+
const lo = row.lo + (row.hi - row.lo) * i / count;
|
|
91
|
+
const hi = row.lo + (row.hi - row.lo) * (i + 1) / count;
|
|
92
|
+
return [{ lo, hi: lo + 0.8, kind: "spandrel", floor: -1 }, { lo: lo + 0.8, hi, kind: "glass", floor: -1 }];
|
|
93
|
+
}).flat();
|
|
94
|
+
});
|
|
79
95
|
export function createBlueCrossGeographicBuilding(record, projectPlan, offset) {
|
|
80
96
|
const kit = createBuilder(record.name, record.id);
|
|
81
97
|
const shell = kit.batch("Blue Cross · shell", kit.material(0xffffff, { vertexColors: true }));
|
|
82
98
|
const wall = kit.batch("Blue Cross · glass, spandrels and bands", kit.material(0xffffff, { vertexColors: true }));
|
|
83
99
|
const mullions = kit.batch("Blue Cross · mullions", kit.material(0x2c2c2c));
|
|
84
100
|
const bars = kit.batch("Blue Cross · band columns and emblems", kit.material(0xb4b4b4));
|
|
85
|
-
const infill = kit.batch("Blue Cross ·
|
|
86
|
-
const transoms = kit.batch("Blue Cross ·
|
|
101
|
+
const infill = kit.batch("Blue Cross · band infill", kit.material(0xffffff, { vertexColors: true }));
|
|
102
|
+
const transoms = kit.batch("Blue Cross · transoms", kit.material(0xb4b4b4));
|
|
87
103
|
const aluminium = color(0xb4b4b4);
|
|
88
104
|
const windows = [];
|
|
89
105
|
let windowColumns = 0;
|
|
@@ -134,10 +150,13 @@ export function createBlueCrossGeographicBuilding(record, projectPlan, offset) {
|
|
|
134
150
|
// every row, and mullions one module apart on the office floors. Each unbroken stretch of
|
|
135
151
|
// kept runs along a chain is one box, held 2 cm clear of its ends.
|
|
136
152
|
const skin = (corners, lo, hi, seed, keep, lift = 0) => {
|
|
137
|
-
const plan = planOf(corners)
|
|
138
|
-
const heights = [cuts[0].lo + lift, ...cuts.map((row) => row.hi)];
|
|
153
|
+
const plan = planOf(corners);
|
|
139
154
|
const top = Math.min(hi, h.roof) - 0.05;
|
|
140
155
|
chainsOf(plan).forEach((chain, index) => {
|
|
156
|
+
const shortFace = Math.abs(plan[chain.runs[0]].normal(0)[0]) > 0.9;
|
|
157
|
+
const sideGlazing = seed !== 0 || shortFace;
|
|
158
|
+
const cuts = sideGlazing ? glazedBandRows(rowsBetween(lo, hi)) : rowsBetween(lo, hi);
|
|
159
|
+
const heights = [cuts[0].lo + lift, ...cuts.map((row) => row.hi)];
|
|
141
160
|
const count = Math.max(1, Math.round(chain.length / h.module));
|
|
142
161
|
const stations = Array.from({ length: count + 1 }, (_, i) => chain.length * i / count);
|
|
143
162
|
const messageFace = seed === 0 && plan[chain.runs[0]].normal(0)[1] > 0.9 && chain.length > 50;
|
|
@@ -164,6 +183,21 @@ export function createBlueCrossGeographicBuilding(record, projectPlan, offset) {
|
|
|
164
183
|
return screen;
|
|
165
184
|
return spandrel;
|
|
166
185
|
}, spandrel);
|
|
186
|
+
if (sideGlazing) {
|
|
187
|
+
// Close each rail as a thin box following the mapped chain. Keep rails
|
|
188
|
+
// off the skin's top/bottom planes and ahead of its vertical mullions.
|
|
189
|
+
// Leave corner clearance for rails on adjoining/reentrant faces.
|
|
190
|
+
const railStart = s0 + transom.cornerClearance, railEnd = s1 - transom.cornerClearance;
|
|
191
|
+
const railK0 = chain.starts.findLastIndex((s) => s <= railStart);
|
|
192
|
+
const railK1 = chain.starts.findLastIndex((s) => s < railEnd);
|
|
193
|
+
for (const row of cuts.filter(({ kind }) => kind === "glass")) {
|
|
194
|
+
for (const [bottom, top] of [[row.lo - transom.height, row.lo], [row.hi, row.hi + transom.height]]) {
|
|
195
|
+
if (railEnd <= railStart || bottom <= heights[0] || top >= hi)
|
|
196
|
+
continue;
|
|
197
|
+
chainSkin(kit, transoms, plan, chain, railK0, railK1, railStart, railEnd, [], [bottom, top], transom.back, transom.front, () => aluminium, aluminium);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
167
201
|
// Mullions on each office run, whole on one run and held clear of its ends. Their
|
|
168
202
|
// backs stand a centimetre off the wall: on the block's side walls the middle band's
|
|
169
203
|
// mullions start just inside the end bays, whose closing walls lie in that plane and
|
|
@@ -207,7 +241,7 @@ export function createBlueCrossGeographicBuilding(record, projectPlan, offset) {
|
|
|
207
241
|
const emblem = (centre, t, n, y, shape) => {
|
|
208
242
|
// The corrected screen panels stand ahead of the old backing plane.
|
|
209
243
|
const point = ([u, v], depth) => [centre[0] + t[0] * u + n[0] * depth, y + v, centre[1] + t[1] * u + n[1] * depth];
|
|
210
|
-
const backDepth =
|
|
244
|
+
const backDepth = infillFront + emblemClearance, frontDepth = backDepth + 0.23;
|
|
211
245
|
const out = [n[0], 0, n[1]], back = [-n[0], 0, -n[1]];
|
|
212
246
|
shape.forEach((p, i) => {
|
|
213
247
|
const q = shape[(i + 1) % shape.length];
|
|
@@ -224,8 +258,8 @@ export function createBlueCrossGeographicBuilding(record, projectPlan, offset) {
|
|
|
224
258
|
const cross = [[0.7, 2.1], [0.7, 0.7], [2.1, 0.7], [2.1, -0.7], [0.7, -0.7], [0.7, -2.1],
|
|
225
259
|
[-0.7, -2.1], [-0.7, -0.7], [-2.1, -0.7], [-2.1, 0.7], [-0.7, 0.7], [-0.7, 2.1]];
|
|
226
260
|
const shield = [[-1.8, 2.1], [1.8, 2.1], [1.8, 0.3], [1.6, -0.7], [1.0, -1.5], [0, -2.1], [-1.0, -1.5], [-1.6, -0.7], [-1.8, 0.3]];
|
|
227
|
-
// The block's columns, exposed in each band and in the screen on its long faces:
|
|
228
|
-
//
|
|
261
|
+
// The block's columns, exposed in each band and in the screen on its long faces:
|
|
262
|
+
// eight south, six north. The emblems stand in the screen's first two south
|
|
229
263
|
// bays at the south face's west end, the cross west of the shield.
|
|
230
264
|
const faces = chainsOf(planOf(block)).filter(({ length }) => length > 50);
|
|
231
265
|
faces.forEach((chain) => {
|
|
@@ -233,16 +267,16 @@ export function createBlueCrossGeographicBuilding(record, projectPlan, offset) {
|
|
|
233
267
|
const a = run.at(0), b = last.at(last.length), n = run.normal(0), length = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
234
268
|
const t = [(b[0] - a[0]) / length, (b[1] - a[1]) / length];
|
|
235
269
|
const at = (s) => [a[0] + t[0] * s, a[1] + t[1] * s];
|
|
236
|
-
const pitch = length /
|
|
237
|
-
for (let i = 0; i <
|
|
270
|
+
const slotCount = n[1] > 0.9 ? 8 : 6, pitch = length / slotCount;
|
|
271
|
+
for (let i = 0; i < slotCount; i += 1) {
|
|
238
272
|
for (const [lo, hi] of [...h.bands, [h.roof + 0.4, h.top - 0.3]])
|
|
239
273
|
kit.box(bars, at((i + 0.5) * pitch), n, 0.45, 0.07, 0.4, lo + 0.1, hi - 0.1);
|
|
240
274
|
}
|
|
241
|
-
if (n[1] > 0.9) {
|
|
242
|
-
// FID-BCBS-002: the
|
|
275
|
+
if (Math.abs(n[1]) > 0.9) {
|
|
276
|
+
// FID-BCBS-002/005: the long elevations have narrow column slots,
|
|
243
277
|
// with curtain-wall panels between them, not an uninterrupted dark band.
|
|
244
|
-
//
|
|
245
|
-
//
|
|
278
|
+
// The northeast photograph resolves six on the north crown. The five
|
|
279
|
+
// internal north atrium bays alone do not establish facade spacing.
|
|
246
280
|
const panel = (target, s0, s1, levels, columns, back, front, paint, sides) => {
|
|
247
281
|
const k0 = chain.starts.findLastIndex((s) => s <= s0);
|
|
248
282
|
const k1 = chain.starts.findLastIndex((s) => s < s1);
|
|
@@ -270,21 +304,22 @@ export function createBlueCrossGeographicBuilding(record, projectPlan, offset) {
|
|
|
270
304
|
}
|
|
271
305
|
append(hi - 0.18, crown ? screen : glassTones[1]);
|
|
272
306
|
append(hi - 0.02, aluminium);
|
|
273
|
-
for (let bay = 0; bay <=
|
|
307
|
+
for (let bay = 0; bay <= slotCount; bay += 1) {
|
|
274
308
|
const s0 = bay === 0 ? 0.02 : (bay - 0.5) * pitch + 0.95;
|
|
275
|
-
const s1 = bay ===
|
|
276
|
-
panel(infill, s0, s1, levels, [s0, s0 + 0.12, s1 - 0.12, s1], 0.2,
|
|
309
|
+
const s1 = bay === slotCount ? chain.length - 0.02 : (bay + 0.5) * pitch - 0.95;
|
|
310
|
+
panel(infill, s0, s1, levels, [s0, s0 + 0.12, s1 - 0.12, s1], 0.2, infillFront, (cell, row) => cell === 1 || cell === 3 ? aluminium : paints[row], aluminium);
|
|
277
311
|
}
|
|
278
312
|
}
|
|
279
313
|
// Pale rails frame the office ribbons. They sit just ahead of the vertical
|
|
280
314
|
// mullions and never cross the exposed column slots or celebration panes.
|
|
281
315
|
for (const row of rows.filter(({ kind }) => kind === "glass")) {
|
|
282
|
-
for (const [lo, hi] of [[row.lo -
|
|
283
|
-
panel(transoms, 0.02, chain.length - 0.02, [lo, hi], [],
|
|
316
|
+
for (const [lo, hi] of [[row.lo - transom.height, row.lo], [row.hi, row.hi + transom.height]]) {
|
|
317
|
+
panel(transoms, 0.02, chain.length - 0.02, [lo, hi], [], transom.back, transom.front, () => aluminium, aluminium);
|
|
284
318
|
}
|
|
285
319
|
}
|
|
286
320
|
// The south face runs west to east; the emblems stand in its first two bays.
|
|
287
|
-
|
|
321
|
+
if (n[1] > 0.9)
|
|
322
|
+
[cross, shield].forEach((shape, i) => emblem(at((i + 1) * pitch), t, n, h.roof + 6.7, shape));
|
|
288
323
|
}
|
|
289
324
|
});
|
|
290
325
|
const model = kit.finish({ height: h.top, outlines: [shell, mullions], opacity: 0.16 });
|
|
@@ -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
|
|
@@ -117,8 +118,8 @@ export const colourMaterials = {
|
|
|
117
118
|
"glass, spandrels and bands": ["glass", { ...windows(0x8a8a8a, 0x5c5c5c), 0x383838: "opaque spandrel", 0x2a2a2a: "neutral", 0x3e3e3e: "screen" }],
|
|
118
119
|
mullions: ["dark metal"],
|
|
119
120
|
"band columns and emblems": ["band lights"],
|
|
120
|
-
"
|
|
121
|
-
|
|
121
|
+
"band infill": ["glass", { 0x383838: "opaque spandrel", 0x3e3e3e: "screen", 0xb4b4b4: "aluminium" }],
|
|
122
|
+
transoms: ["aluminium"],
|
|
122
123
|
},
|
|
123
124
|
// Blue-green glass, and the south face's white concrete frame.
|
|
124
125
|
"building-340-on-the-park": {
|
|
@@ -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) {
|