@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.
@@ -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 southInfillFront = 0.52, emblemClearance = 0.1;
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 · south band infill", kit.material(0xffffff, { vertexColors: true }));
86
- const transoms = kit.batch("Blue Cross · south transoms", kit.material(0xb4b4b4));
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), cuts = rowsBetween(lo, hi);
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 = southInfillFront + emblemClearance, frontDepth = backDepth + 0.23;
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: eight
228
- // across each, one to a structural bay. The emblems stand in the screen's first two
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 / 8;
237
- for (let i = 0; i < 8; i += 1) {
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 2010 south elevation has eight narrow column slots,
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
- // Restrict this reconstruction to the photographed face: the north atrium
245
- // has a different structural rhythm. Keep the mapped chain's small bends.
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 <= 8; bay += 1) {
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 === 8 ? chain.length - 0.02 : (bay + 0.5) * pitch - 0.95;
276
- panel(infill, s0, s1, levels, [s0, s0 + 0.12, s1 - 0.12, s1], 0.2, southInfillFront, (cell, row) => cell === 1 || cell === 3 ? aluminium : paints[row], aluminium);
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 - 0.18, row.lo], [row.hi, row.hi + 0.18]]) {
283
- panel(transoms, 0.02, chain.length - 0.02, [lo, hi], [], 0.17, 0.22, () => aluminium, aluminium);
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
- [cross, shield].forEach((shape, i) => emblem(at((i + 1) * pitch), t, n, h.roof + 6.7, shape));
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,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
- "south band infill": ["glass", { 0x383838: "opaque spandrel", 0x3e3e3e: "screen", 0xb4b4b4: "aluminium" }],
121
- "south transoms": ["aluminium"],
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 reflections = [];
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 materialOf = (grey) => tones[toneOf(grey)] ?? base;
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
- if (mask.some((value) => value > 0))
417
- reflections.push(glassReflection(material, geometry, mask));
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 reflect of reflections)
463
- reflect(next);
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
- // Install only in colour mode, and restore the original shader on exit. Keep the mask attached
7
- // once used so Three.js releases its GPU buffer when it disposes the geometry.
8
- export function glassReflection(material, geometry, mask) {
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
- varying float vSkylineGlass;`).replace("#include <begin_vertex>", `#include <begin_vertex>
23
- vSkylineGlass = skylineGlass;`);
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;`).replace("#include <opaque_fragment>", `
29
- if (vSkylineGlass > 0.0) {
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
- return (enabled) => {
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-glass-v1`;
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
+ }
@@ -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">
@@ -1,6 +1,8 @@
1
1
  import { createBuildingStudy, frameBox } from "./study-viewer.js";
2
2
  import { createGeographicSkyline } from "./skyline-scene.js";
3
- import { createGeographicRoads } from "./models/skyline-geography.js";
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
- // The colour trial's toggle, pressed while the scene shows a sunny day, the measured buildings in
113
- // their colours over the greys; pressed again, it restores the night and every grey exactly.
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 (enabled)
122
- daylight ??= createDaylight();
123
- roadway.color.setHex(colour.enabled ? daylightGround.roads : nightRoads);
124
- viewer.setDaylight(colour.enabled ? daylight : null);
125
- colourButton.setAttribute("aria-pressed", String(colour.enabled));
126
- colourButton.title = enabled ? "Restore the greyscale skyline" : "Show the skyline in sunny-day colour";
127
- detailView?.setColour(enabled);
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
- if (initiallyColoured)
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;
@@ -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((button) => { button.disabled = true; });
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
+ }