@a2f0/skyline 0.2.9 → 0.2.11

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/docs/package.md CHANGED
@@ -5,6 +5,11 @@ modules. Package builds emit ordinary ESM JavaScript and TypeScript declarations
5
5
  consumers of the published package do not compile repository sources or run installation
6
6
  scripts. Browser code and Node/Bun build helpers have separate entrypoints.
7
7
 
8
+ To start an embed in sunny-day colour, pass `colour: true` to `mountSkyline` alongside
9
+ `assetsUrl`. Its toolbar can return to greyscale, and building details inherit and share its mode.
10
+ Omitting `colour` keeps the existing greyscale default. The setting belongs to that instance;
11
+ it does not use storage or affect another embed. The standalone viewer accepts `?colour=1`.
12
+
8
13
  ## Install from npm
9
14
 
10
15
  ```sh
@@ -1,5 +1,6 @@
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
4
  // The colour trial (https://github.com/a2f0/skyline/issues/115): which named material each
4
5
  // surface of each building is, and a layer that shows the colours measured for those materials
5
6
  // (colour-palette.ts) over the greys the models build in, restoring the greys exactly when it is
@@ -61,7 +62,7 @@ export const colourMaterials = {
61
62
  "bronze mullions and columns": ["bronze"],
62
63
  },
63
64
  "building-michigan-plaza-south-tower": {
64
- "mapped tower shell": ["glass"],
65
+ "mapped tower shell": ["aluminium"],
65
66
  "mullions and bands": ["aluminium"],
66
67
  "lit glazing": ["lit window"],
67
68
  "dim glazing": ["dim window"],
@@ -281,11 +282,11 @@ export const toneOf = (grey) => new THREE.Color(grey, grey, grey).getHex();
281
282
  // 0.25 to 1.56 on their measured materials and 0.14 on Kemper's whole face, where the scene's sun
282
283
  // lights their faces differently from the photograph's. On the Michigan Avenue wall (FID-COL-005),
283
284
  // whose shaded fronts are estimated as if sunlit, the fronts render 0.81 to 1.21 of the
284
- // photograph's shaded fronts and the south faces 0.77 to 0.86 of its sunlit ones. The next nine
285
- // (FID-COL-006) render 0.79 to 0.98 on sunlit faces, 1.27 on 180 North Michigan's stone, 0.5 on
286
- // Swissôtel's half-shadowed sliver, and 0.41 to 0.51 of their calibrated sunlit colours on the
287
- // close-ups' shaded Michigan fronts. docs/viewer.md
288
- // and the audits give each.
285
+ // photograph's shaded fronts and the south faces 0.77 to 0.86 of its sunlit ones. The next eleven
286
+ // (FID-COL-006) render 0.79 to 0.98 on sunlit faces, 1.27 and 1.21 on 180 North Michigan's stone
287
+ // and River Plaza's top, 0.5 on Swissôtel's half-shadowed sliver, and 0.41 to 0.51 of their
288
+ // calibrated sunlit colours on the close-ups' shaded Michigan fronts. docs/viewer.md and the
289
+ // audits give each.
289
290
  export const exposure = 1.4;
290
291
  // A grey in its measured colour: the swatch's colour scaled by the grey's brightness against the
291
292
  // swatch's grey, both in linear light, so a darker surface of one material stays darker. A colour
@@ -353,6 +354,8 @@ export function createDaylight() {
353
354
  // another batch already took, since the builder lets batches share them.
354
355
  export function createSkylineColour(models) {
355
356
  const painted = [], flat = [], taken = new Set();
357
+ const reflections = [];
358
+ const glassMaterials = new Set(["glass", "green glass", "blue-green glass", "bronze glass", "crown glass", "lit window", "dim window"]);
356
359
  for (const model of models) {
357
360
  const buildingId = String(model.building.userData["buildingId"]);
358
361
  if (!measuredColours[buildingId])
@@ -384,16 +387,30 @@ export function createSkylineColour(models) {
384
387
  for (let vertex = 0; vertex < attribute.count; vertex += 1)
385
388
  vertices.set(attribute.array[vertex * 3], (vertices.get(attribute.array[vertex * 3]) ?? 0) + 1);
386
389
  vertices.forEach((n, grey) => count(materialOf(grey), grey, n));
387
- surfaces.push({ material, colours: { attribute, array: attribute.array }, materialOf });
390
+ surfaces.push({ geometry: mesh.geometry, material, colours: { attribute, array: attribute.array }, materialOf });
388
391
  }
389
392
  else if (!taken.has(material)) {
390
393
  taken.add(material);
391
394
  count(materialOf(material.color.r), material.color.r, mesh.geometry.getAttribute("position").count);
392
- surfaces.push({ material, colours: null, materialOf });
395
+ surfaces.push({ geometry: mesh.geometry, material, colours: null, materialOf });
393
396
  }
394
397
  });
395
398
  const swatches = swatchesOf(buildingId, greys);
396
- for (const { material, colours, materialOf } of surfaces) {
399
+ for (const { geometry, material, colours, materialOf } of surfaces) {
400
+ const mask = new Float32Array(geometry.getAttribute("position").count);
401
+ const reflecting = new Map();
402
+ for (let vertex = 0; vertex < mask.length; vertex += 1) {
403
+ const grey = colours ? colours.array[vertex * 3] : material.color.r;
404
+ let reflects = reflecting.get(grey);
405
+ if (reflects === undefined) {
406
+ const name = materialOf(grey);
407
+ reflects = glassMaterials.has(name) && swatches.get(name) ? 1 : 0;
408
+ reflecting.set(grey, reflects);
409
+ }
410
+ mask[vertex] = reflects;
411
+ }
412
+ if (mask.some((value) => value > 0))
413
+ reflections.push(glassReflection(material, geometry, mask));
397
414
  const colourOf = (grey) => {
398
415
  const swatch = swatches.get(materialOf(grey));
399
416
  return swatch ? paint(grey, swatch) : [grey, grey, grey];
@@ -438,6 +455,8 @@ export function createSkylineColour(models) {
438
455
  }
439
456
  for (const { material, grey, colour } of flat)
440
457
  material.color.copy(next ? colour : grey);
458
+ for (const reflect of reflections)
459
+ reflect(next);
441
460
  },
442
461
  refresh() {
443
462
  if (!enabled)
@@ -1,6 +1,18 @@
1
1
  // By day no office lights show: lit and dim windows are the glass, in its measured colour.
2
2
  const glass = (colour, material = "glass") => ({ [material]: colour, "lit window": colour, "dim window": colour });
3
3
  export const measuredColours = {
4
+ // FID-COL-007: provisional, direct 2014 daytime close-ups, WITHOUT the panorama's light
5
+ // calibration. These measure appearance (sky reflections included), not intrinsic albedo.
6
+ // docs/three-illinois-center-reference.md and docs/michigan-plaza-south-geographic-reference.md.
7
+ "building-three-illinois-center": {
8
+ bronze: 0x55585f,
9
+ ...glass(0x707d8e, "bronze glass"),
10
+ neutral: null,
11
+ },
12
+ "building-michigan-plaza-south-tower": {
13
+ aluminium: 0x1d2736,
14
+ ...glass(0x6385a2),
15
+ },
4
16
  // docs/railway-exchange-reference.md, 2026-10-09 — Daytime colours from Chicago.jpg.
5
17
  "building-railway-exchange": {
6
18
  "white terracotta": 0xc8c1b3,
@@ -166,7 +178,7 @@ export const measuredColours = {
166
178
  ...glass(0x3b3f42),
167
179
  neutral: null,
168
180
  },
169
- // FID-COL-006, four towers the panorama shows, measured on their sunlit south faces.
181
+ // FID-COL-006, six towers the panorama shows, measured on their sunlit south faces.
170
182
  // docs/buckingham-reference.md, 2026-10-10 — Daytime colours from Chicago.jpg.
171
183
  "building-the-buckingham": {
172
184
  concrete: 0xc0beb6,
@@ -195,6 +207,21 @@ export const measuredColours = {
195
207
  "dim window": null,
196
208
  neutral: null,
197
209
  },
210
+ // docs/river-plaza-two-illinois-center-reference.md, 2026-10-10 — Daytime colours from Chicago.jpg.
211
+ // Two Illinois Center's black curtain wall, its lighter and darker pixels apart, and River Plaza's
212
+ // white concrete, the top floors that show above it. Two Illinois Center's identification is
213
+ // provisional: its photographed top stands further above its model than the group's others.
214
+ "building-office-west-of-aon": {
215
+ "dark metal": 0x444c57,
216
+ ...glass(0x2e3641, "bronze glass"),
217
+ neutral: null,
218
+ },
219
+ "building-river-plaza": {
220
+ concrete: 0xe5dfd3,
221
+ glass: null,
222
+ "lit window": null,
223
+ "dim window": null,
224
+ },
198
225
  // Michigan Avenue fronts Grant Park's trees hide in the panorama: measured in a close-up under an
199
226
  // overcast sky and calibrated to the panorama through Willoughby Tower's limestone beside them,
200
227
  // its ratio in linear light between the two photographs (scripts/sample-colours.ts, the day
@@ -238,7 +265,8 @@ export const measuredColours = {
238
265
  neutral: null,
239
266
  },
240
267
  // The Hyatt Regency's west tower, a few pixels wide in the panorama, in an overcast close-up
241
- // calibrated through Aon's granite behind it.
268
+ // calibrated through Aon's granite behind it. Provisional: haze over Aon, and the panorama's
269
+ // brighter strip, say the brick may be up to twice as bright as this.
242
270
  // docs/hyatt-west-tower-reference.md, 2026-10-10 — Daytime colours from a close-up calibrated to Chicago.jpg.
243
271
  "building-hyatt-regency-west-tower": {
244
272
  "orange brick": 0x65554b,
@@ -0,0 +1,2 @@
1
+ import * as THREE from "../vendor/three-r186.js";
2
+ export declare function glassReflection(material: THREE.MeshToonMaterial, geometry: THREE.BufferGeometry, mask: Float32Array): (enabled: boolean) => void;
@@ -0,0 +1,53 @@
1
+ import * as THREE from "../vendor/three-r186.js";
2
+ import { daylightColours } from "./colour-palette.js";
3
+ // A small sky reflection over the measured glass colour. This is an illustration of an open
4
+ // sky, not a reflection of nearby buildings or a replacement for the toon renderer. The mask
5
+ // 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) {
9
+ const attribute = new THREE.Float32BufferAttribute(mask, 1);
10
+ const originalCompile = material.onBeforeCompile, originalKey = material.customProgramCacheKey;
11
+ const key = originalKey.call(material);
12
+ const uniforms = {
13
+ skylineSky: { value: new THREE.Color(daylightColours.sky) },
14
+ skylineHorizon: { value: new THREE.Color(daylightColours.horizon) },
15
+ skylineGround: { value: new THREE.Color(0x444444) },
16
+ };
17
+ const compile = (shader, renderer) => {
18
+ originalCompile.call(material, shader, renderer);
19
+ Object.assign(shader.uniforms, uniforms);
20
+ shader.vertexShader = shader.vertexShader.replace("#include <common>", `#include <common>
21
+ attribute float skylineGlass;
22
+ varying float vSkylineGlass;`).replace("#include <begin_vertex>", `#include <begin_vertex>
23
+ vSkylineGlass = skylineGlass;`);
24
+ shader.fragmentShader = shader.fragmentShader.replace("#include <common>", `#include <common>
25
+ varying float vSkylineGlass;
26
+ uniform vec3 skylineSky;
27
+ uniform vec3 skylineHorizon;
28
+ uniform vec3 skylineGround;`).replace("#include <opaque_fragment>", `
29
+ if (vSkylineGlass > 0.0) {
30
+ vec3 eye = vec3(0.0);
31
+ eye.z = 1.0;
32
+ if (!isOrthographic) eye = normalize(vViewPosition);
33
+ vec3 reflected = inverseTransformDirection(reflect(-eye, normal), viewMatrix);
34
+ vec3 sky = mix(skylineHorizon, skylineSky, smoothstep(0.0, 0.7, reflected.y));
35
+ vec3 environment = mix(skylineGround, sky, smoothstep(-0.12, 0.12, reflected.y));
36
+ float grazing = pow(1.0 - clamp(dot(normal, eye), 0.0, 1.0), 5.0);
37
+ outgoingLight = mix(outgoingLight, environment, vSkylineGlass * (0.06 + 0.54 * grazing));
38
+ }
39
+ #include <opaque_fragment>`);
40
+ };
41
+ return (enabled) => {
42
+ if (enabled) {
43
+ geometry.setAttribute("skylineGlass", attribute);
44
+ material.onBeforeCompile = compile;
45
+ material.customProgramCacheKey = () => `${key}:skyline-glass-v1`;
46
+ }
47
+ else {
48
+ material.onBeforeCompile = originalCompile;
49
+ material.customProgramCacheKey = originalKey;
50
+ }
51
+ material.needsUpdate = true;
52
+ };
53
+ }
@@ -16,6 +16,8 @@ export interface SkylineOptions {
16
16
  readonly title?: string;
17
17
  /** Start the 3D scene's control bar open, or folded behind its star. Closed by default. */
18
18
  readonly controls?: "open" | "closed";
19
+ /** Start in the opt-in sunny-day colour view. Greyscale by default. */
20
+ readonly colour?: boolean;
19
21
  /**
20
22
  * Show the OpenStreetMap credit with the 3D scene's controls. Shown by default. OpenStreetMap's
21
23
  * licence requires that credit wherever its data appears, so hide it only when the host page
@@ -36,4 +38,4 @@ export interface SkylineOptions {
36
38
  * size and placement: give it an explicit height. Call `destroy` when the hosting component
37
39
  * leaves.
38
40
  */
39
- export declare function mountSkyline(container: HTMLElement, { assetsUrl, title, controls, attribution, three }: SkylineOptions): SkylineInstance;
41
+ export declare function mountSkyline(container: HTMLElement, { assetsUrl, title, controls, colour, attribution, three }: SkylineOptions): SkylineInstance;
@@ -8,8 +8,10 @@ import { mountViewer } from "./skyline-shell.js";
8
8
  * size and placement: give it an explicit height. Call `destroy` when the hosting component
9
9
  * leaves.
10
10
  */
11
- export function mountSkyline(container, { assetsUrl, title = "Interactive Chicago skyline", controls = "closed", attribution = true, three }) {
11
+ export function mountSkyline(container, { assetsUrl, title = "Interactive Chicago skyline", controls = "closed", colour = false, attribution = true, three }) {
12
12
  if (controls !== "open" && controls !== "closed")
13
13
  throw new TypeError('Skyline controls must be "open" or "closed".');
14
- return mountViewer(container, { assetsUrl, title, controls, attribution, navigation: false, page: false, three });
14
+ if (typeof colour !== "boolean")
15
+ throw new TypeError("Skyline colour must be a boolean.");
16
+ return mountViewer(container, { assetsUrl, title, controls, colour, attribution, navigation: false, page: false, three });
15
17
  }
@@ -11,6 +11,8 @@ export interface ViewerContext {
11
11
  readonly signal: AbortSignal;
12
12
  /** Start the 3D scene's control bar open, rather than folded behind its star. */
13
13
  readonly controls: "open" | "closed";
14
+ /** Start the scene and its building details in sunny-day colour. */
15
+ readonly colour?: boolean;
14
16
  /** Show the OpenStreetMap credit with the scene's controls. */
15
17
  readonly attribution: boolean;
16
18
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2f0/skyline",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "Interactive Chicago skyline viewer and reusable Three.js building models",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -7,6 +7,7 @@ try {
7
7
  id: new URLSearchParams(location.search).get("building"),
8
8
  frame: document.querySelector(".detail-root"),
9
9
  signal: life.signal,
10
+ colour: new URLSearchParams(location.search).get("colour") === "1",
10
11
  });
11
12
  }
12
13
  catch (error) {
@@ -41,6 +41,7 @@
41
41
  </div>
42
42
  <div class="button-group" role="group" aria-label="Model display">
43
43
  <button type="button" id="wireframe" aria-pressed="false">wireframe</button>
44
+ <button type="button" id="colour" aria-pressed="false" title="Show the building in sunny-day colour">colour</button>
44
45
  <button type="button" id="turntable" aria-pressed="false" aria-describedby="motion-status">turntable</button>
45
46
  </div>
46
47
  </div>
@@ -3,19 +3,21 @@ 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
5
  import { createGeographicBuilding, footprintMetrics } from "./models/skyline-geography.js";
6
+ import { createDaylight, createSkylineColour } from "./models/colour-materials.js";
6
7
  // One mapped building of the 3D skyline, alone on its own platform, as the building study
7
8
  // shows Crain: building-detail.html?building=<id>, and the panel the 3D skyline floats over
8
9
  // the skyline from a building's context menu.
9
- export function showBuildingDetail({ root, id, frame, signal }) {
10
+ export function showBuildingDetail({ root, id, frame, signal, colour = false, onColourChange }) {
10
11
  const record = geographicBuildings.find((entry) => entry.id === id);
11
12
  if (record)
12
- showBuilding(record, root, frame, signal);
13
+ return showBuilding(record, root, frame, signal, colour, onColourChange);
13
14
  else {
14
15
  root.querySelector("#loading").textContent = "No mapped building by that name. Right-click a building on the 3D skyline to see its detail.";
15
16
  root.querySelectorAll("button").forEach((button) => { button.disabled = true; });
16
17
  }
18
+ return undefined;
17
19
  }
18
- function showBuilding(record, root, frame, signal) {
20
+ function showBuilding(record, root, frame, signal, initiallyColoured, onColourChange) {
19
21
  const footprint = footprintMetrics(record.footprint.coordinates);
20
22
  // Centred on its mapped outline: ground coordinates are east and north, the scene's x and -z.
21
23
  const model = createGeographicBuilding(record, [-footprint.center[0], footprint.center[1]]);
@@ -62,7 +64,7 @@ function showBuilding(record, root, frame, signal) {
62
64
  quarter: { azimuth: skylineAzimuth + 0.22, polar: Math.PI / 2 - 0.15, label: "three-quarter view" },
63
65
  side: { azimuth: skylineAzimuth + Math.PI / 2, polar: Math.PI / 2 - 0.12, label: "side view" },
64
66
  };
65
- createBuildingStudy({
67
+ const viewer = createBuildingStudy({
66
68
  root,
67
69
  frame,
68
70
  signal,
@@ -78,6 +80,24 @@ function showBuilding(record, root, frame, signal) {
78
80
  // button stops.
79
81
  turntable: true,
80
82
  });
83
+ const colour = createSkylineColour([model]), button = root.querySelector("#colour");
84
+ let daylight;
85
+ signal.addEventListener("abort", () => daylight?.sky.dispose(), { once: true });
86
+ function setColour(enabled) {
87
+ colour.set(enabled);
88
+ if (enabled)
89
+ daylight ??= createDaylight();
90
+ viewer.setDaylight(enabled ? daylight : null);
91
+ button.setAttribute("aria-pressed", String(enabled));
92
+ button.title = enabled ? "Restore the building's greyscale view" : "Show the building in sunny-day colour";
93
+ }
94
+ button.addEventListener("click", () => {
95
+ setColour(!colour.enabled);
96
+ onColourChange?.(colour.enabled);
97
+ }, { signal });
98
+ if (initiallyColoured)
99
+ setColour(true);
100
+ return { setColour };
81
101
  }
82
102
  // Sources open beside the page, so a detail floated over the skyline stays where it is.
83
103
  function link(document, href, text) {
@@ -1,5 +1,6 @@
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
4
  // The colour trial (https://github.com/a2f0/skyline/issues/115): which named material each
4
5
  // surface of each building is, and a layer that shows the colours measured for those materials
5
6
  // (colour-palette.ts) over the greys the models build in, restoring the greys exactly when it is
@@ -61,7 +62,7 @@ export const colourMaterials = {
61
62
  "bronze mullions and columns": ["bronze"],
62
63
  },
63
64
  "building-michigan-plaza-south-tower": {
64
- "mapped tower shell": ["glass"],
65
+ "mapped tower shell": ["aluminium"],
65
66
  "mullions and bands": ["aluminium"],
66
67
  "lit glazing": ["lit window"],
67
68
  "dim glazing": ["dim window"],
@@ -281,11 +282,11 @@ export const toneOf = (grey) => new THREE.Color(grey, grey, grey).getHex();
281
282
  // 0.25 to 1.56 on their measured materials and 0.14 on Kemper's whole face, where the scene's sun
282
283
  // lights their faces differently from the photograph's. On the Michigan Avenue wall (FID-COL-005),
283
284
  // whose shaded fronts are estimated as if sunlit, the fronts render 0.81 to 1.21 of the
284
- // photograph's shaded fronts and the south faces 0.77 to 0.86 of its sunlit ones. The next nine
285
- // (FID-COL-006) render 0.79 to 0.98 on sunlit faces, 1.27 on 180 North Michigan's stone, 0.5 on
286
- // Swissôtel's half-shadowed sliver, and 0.41 to 0.51 of their calibrated sunlit colours on the
287
- // close-ups' shaded Michigan fronts. docs/viewer.md
288
- // and the audits give each.
285
+ // photograph's shaded fronts and the south faces 0.77 to 0.86 of its sunlit ones. The next eleven
286
+ // (FID-COL-006) render 0.79 to 0.98 on sunlit faces, 1.27 and 1.21 on 180 North Michigan's stone
287
+ // and River Plaza's top, 0.5 on Swissôtel's half-shadowed sliver, and 0.41 to 0.51 of their
288
+ // calibrated sunlit colours on the close-ups' shaded Michigan fronts. docs/viewer.md and the
289
+ // audits give each.
289
290
  export const exposure = 1.4;
290
291
  // A grey in its measured colour: the swatch's colour scaled by the grey's brightness against the
291
292
  // swatch's grey, both in linear light, so a darker surface of one material stays darker. A colour
@@ -353,6 +354,8 @@ export function createDaylight() {
353
354
  // another batch already took, since the builder lets batches share them.
354
355
  export function createSkylineColour(models) {
355
356
  const painted = [], flat = [], taken = new Set();
357
+ const reflections = [];
358
+ const glassMaterials = new Set(["glass", "green glass", "blue-green glass", "bronze glass", "crown glass", "lit window", "dim window"]);
356
359
  for (const model of models) {
357
360
  const buildingId = String(model.building.userData["buildingId"]);
358
361
  if (!measuredColours[buildingId])
@@ -384,16 +387,30 @@ export function createSkylineColour(models) {
384
387
  for (let vertex = 0; vertex < attribute.count; vertex += 1)
385
388
  vertices.set(attribute.array[vertex * 3], (vertices.get(attribute.array[vertex * 3]) ?? 0) + 1);
386
389
  vertices.forEach((n, grey) => count(materialOf(grey), grey, n));
387
- surfaces.push({ material, colours: { attribute, array: attribute.array }, materialOf });
390
+ surfaces.push({ geometry: mesh.geometry, material, colours: { attribute, array: attribute.array }, materialOf });
388
391
  }
389
392
  else if (!taken.has(material)) {
390
393
  taken.add(material);
391
394
  count(materialOf(material.color.r), material.color.r, mesh.geometry.getAttribute("position").count);
392
- surfaces.push({ material, colours: null, materialOf });
395
+ surfaces.push({ geometry: mesh.geometry, material, colours: null, materialOf });
393
396
  }
394
397
  });
395
398
  const swatches = swatchesOf(buildingId, greys);
396
- for (const { material, colours, materialOf } of surfaces) {
399
+ for (const { geometry, material, colours, materialOf } of surfaces) {
400
+ const mask = new Float32Array(geometry.getAttribute("position").count);
401
+ const reflecting = new Map();
402
+ for (let vertex = 0; vertex < mask.length; vertex += 1) {
403
+ const grey = colours ? colours.array[vertex * 3] : material.color.r;
404
+ let reflects = reflecting.get(grey);
405
+ if (reflects === undefined) {
406
+ const name = materialOf(grey);
407
+ reflects = glassMaterials.has(name) && swatches.get(name) ? 1 : 0;
408
+ reflecting.set(grey, reflects);
409
+ }
410
+ mask[vertex] = reflects;
411
+ }
412
+ if (mask.some((value) => value > 0))
413
+ reflections.push(glassReflection(material, geometry, mask));
397
414
  const colourOf = (grey) => {
398
415
  const swatch = swatches.get(materialOf(grey));
399
416
  return swatch ? paint(grey, swatch) : [grey, grey, grey];
@@ -438,6 +455,8 @@ export function createSkylineColour(models) {
438
455
  }
439
456
  for (const { material, grey, colour } of flat)
440
457
  material.color.copy(next ? colour : grey);
458
+ for (const reflect of reflections)
459
+ reflect(next);
441
460
  },
442
461
  refresh() {
443
462
  if (!enabled)
@@ -1,6 +1,18 @@
1
1
  // By day no office lights show: lit and dim windows are the glass, in its measured colour.
2
2
  const glass = (colour, material = "glass") => ({ [material]: colour, "lit window": colour, "dim window": colour });
3
3
  export const measuredColours = {
4
+ // FID-COL-007: provisional, direct 2014 daytime close-ups, WITHOUT the panorama's light
5
+ // calibration. These measure appearance (sky reflections included), not intrinsic albedo.
6
+ // docs/three-illinois-center-reference.md and docs/michigan-plaza-south-geographic-reference.md.
7
+ "building-three-illinois-center": {
8
+ bronze: 0x55585f,
9
+ ...glass(0x707d8e, "bronze glass"),
10
+ neutral: null,
11
+ },
12
+ "building-michigan-plaza-south-tower": {
13
+ aluminium: 0x1d2736,
14
+ ...glass(0x6385a2),
15
+ },
4
16
  // docs/railway-exchange-reference.md, 2026-10-09 — Daytime colours from Chicago.jpg.
5
17
  "building-railway-exchange": {
6
18
  "white terracotta": 0xc8c1b3,
@@ -166,7 +178,7 @@ export const measuredColours = {
166
178
  ...glass(0x3b3f42),
167
179
  neutral: null,
168
180
  },
169
- // FID-COL-006, four towers the panorama shows, measured on their sunlit south faces.
181
+ // FID-COL-006, six towers the panorama shows, measured on their sunlit south faces.
170
182
  // docs/buckingham-reference.md, 2026-10-10 — Daytime colours from Chicago.jpg.
171
183
  "building-the-buckingham": {
172
184
  concrete: 0xc0beb6,
@@ -195,6 +207,21 @@ export const measuredColours = {
195
207
  "dim window": null,
196
208
  neutral: null,
197
209
  },
210
+ // docs/river-plaza-two-illinois-center-reference.md, 2026-10-10 — Daytime colours from Chicago.jpg.
211
+ // Two Illinois Center's black curtain wall, its lighter and darker pixels apart, and River Plaza's
212
+ // white concrete, the top floors that show above it. Two Illinois Center's identification is
213
+ // provisional: its photographed top stands further above its model than the group's others.
214
+ "building-office-west-of-aon": {
215
+ "dark metal": 0x444c57,
216
+ ...glass(0x2e3641, "bronze glass"),
217
+ neutral: null,
218
+ },
219
+ "building-river-plaza": {
220
+ concrete: 0xe5dfd3,
221
+ glass: null,
222
+ "lit window": null,
223
+ "dim window": null,
224
+ },
198
225
  // Michigan Avenue fronts Grant Park's trees hide in the panorama: measured in a close-up under an
199
226
  // overcast sky and calibrated to the panorama through Willoughby Tower's limestone beside them,
200
227
  // its ratio in linear light between the two photographs (scripts/sample-colours.ts, the day
@@ -238,7 +265,8 @@ export const measuredColours = {
238
265
  neutral: null,
239
266
  },
240
267
  // The Hyatt Regency's west tower, a few pixels wide in the panorama, in an overcast close-up
241
- // calibrated through Aon's granite behind it.
268
+ // calibrated through Aon's granite behind it. Provisional: haze over Aon, and the panorama's
269
+ // brighter strip, say the brick may be up to twice as bright as this.
242
270
  // docs/hyatt-west-tower-reference.md, 2026-10-10 — Daytime colours from a close-up calibrated to Chicago.jpg.
243
271
  "building-hyatt-regency-west-tower": {
244
272
  "orange brick": 0x65554b,
@@ -0,0 +1,53 @@
1
+ import * as THREE from "../vendor/three-r186.js";
2
+ import { daylightColours } from "./colour-palette.js";
3
+ // A small sky reflection over the measured glass colour. This is an illustration of an open
4
+ // sky, not a reflection of nearby buildings or a replacement for the toon renderer. The mask
5
+ // 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) {
9
+ const attribute = new THREE.Float32BufferAttribute(mask, 1);
10
+ const originalCompile = material.onBeforeCompile, originalKey = material.customProgramCacheKey;
11
+ const key = originalKey.call(material);
12
+ const uniforms = {
13
+ skylineSky: { value: new THREE.Color(daylightColours.sky) },
14
+ skylineHorizon: { value: new THREE.Color(daylightColours.horizon) },
15
+ skylineGround: { value: new THREE.Color(0x444444) },
16
+ };
17
+ const compile = (shader, renderer) => {
18
+ originalCompile.call(material, shader, renderer);
19
+ Object.assign(shader.uniforms, uniforms);
20
+ shader.vertexShader = shader.vertexShader.replace("#include <common>", `#include <common>
21
+ attribute float skylineGlass;
22
+ varying float vSkylineGlass;`).replace("#include <begin_vertex>", `#include <begin_vertex>
23
+ vSkylineGlass = skylineGlass;`);
24
+ shader.fragmentShader = shader.fragmentShader.replace("#include <common>", `#include <common>
25
+ varying float vSkylineGlass;
26
+ uniform vec3 skylineSky;
27
+ uniform vec3 skylineHorizon;
28
+ uniform vec3 skylineGround;`).replace("#include <opaque_fragment>", `
29
+ if (vSkylineGlass > 0.0) {
30
+ vec3 eye = vec3(0.0);
31
+ eye.z = 1.0;
32
+ if (!isOrthographic) eye = normalize(vViewPosition);
33
+ vec3 reflected = inverseTransformDirection(reflect(-eye, normal), viewMatrix);
34
+ vec3 sky = mix(skylineHorizon, skylineSky, smoothstep(0.0, 0.7, reflected.y));
35
+ vec3 environment = mix(skylineGround, sky, smoothstep(-0.12, 0.12, reflected.y));
36
+ float grazing = pow(1.0 - clamp(dot(normal, eye), 0.0, 1.0), 5.0);
37
+ outgoingLight = mix(outgoingLight, environment, vSkylineGlass * (0.06 + 0.54 * grazing));
38
+ }
39
+ #include <opaque_fragment>`);
40
+ };
41
+ return (enabled) => {
42
+ if (enabled) {
43
+ geometry.setAttribute("skylineGlass", attribute);
44
+ material.onBeforeCompile = compile;
45
+ material.customProgramCacheKey = () => `${key}:skyline-glass-v1`;
46
+ }
47
+ else {
48
+ material.onBeforeCompile = originalCompile;
49
+ material.customProgramCacheKey = originalKey;
50
+ }
51
+ material.needsUpdate = true;
52
+ };
53
+ }
@@ -3,7 +3,7 @@ import { startSkyline3d } from "./skyline-3d.js";
3
3
  // switch to. Its address's settings were applied before the scene's code arrived.
4
4
  const life = new AbortController();
5
5
  try {
6
- startSkyline3d({ root: document, frame: document.body, assets: new URL("./", location.href), signal: life.signal });
6
+ startSkyline3d({ root: document, frame: document.body, assets: new URL("./", location.href), signal: life.signal, colour: new URLSearchParams(location.search).get("colour") === "1" });
7
7
  }
8
8
  catch (error) {
9
9
  // A scene that fails part way releases what it started; study-loader.js says why.
@@ -8,7 +8,7 @@ import { loadMarkup, loadStylesheet } from "./markup.js";
8
8
  // drawing's own camera and framed as the viewer frames the drawing, so each tower stands
9
9
  // where its drawn one does until the camera moves. It renders into skyline-3d.html's own
10
10
  // document, or into the viewer's shadow root, from the same markup.
11
- export function startSkyline3d({ root, frame, assets, signal, onShowOriginal, onReady, onUnavailable }) {
11
+ export function startSkyline3d({ root, frame, assets, signal, colour: initiallyColoured = false, onShowOriginal, onReady, onUnavailable }) {
12
12
  const document = root instanceof Document ? root : root.ownerDocument;
13
13
  const listening = { signal };
14
14
  const { models, ground, settings, drawingView, comparisonViews, labels } = createGeographicSkyline();
@@ -112,14 +112,23 @@ export function startSkyline3d({ root, frame, assets, signal, onShowOriginal, on
112
112
  // The colour trial's toggle, pressed while the scene shows a sunny day, the measured buildings in
113
113
  // their colours over the greys; pressed again, it restores the night and every grey exactly.
114
114
  const colourButton = root.querySelector("#colour");
115
- const daylight = createDaylight(), roadway = roads.material;
116
- signal.addEventListener("abort", () => daylight.sky.dispose(), { once: true });
117
- colourButton.addEventListener("click", () => {
118
- colour.set(!colour.enabled);
115
+ const roadway = roads.material;
116
+ let daylight;
117
+ let detailView;
118
+ signal.addEventListener("abort", () => daylight?.sky.dispose(), { once: true });
119
+ function setColour(enabled) {
120
+ colour.set(enabled);
121
+ if (enabled)
122
+ daylight ??= createDaylight();
119
123
  roadway.color.setHex(colour.enabled ? daylightGround.roads : nightRoads);
120
124
  viewer.setDaylight(colour.enabled ? daylight : null);
121
125
  colourButton.setAttribute("aria-pressed", String(colour.enabled));
122
- }, listening);
126
+ colourButton.title = enabled ? "Restore the greyscale skyline" : "Show the skyline in sunny-day colour";
127
+ detailView?.setColour(enabled);
128
+ }
129
+ colourButton.addEventListener("click", () => setColour(!colour.enabled), listening);
130
+ if (initiallyColoured)
131
+ setColour(true);
123
132
  // The star in the middle of the bar opens and closes the toolbar. Each group unfolds from
124
133
  // the star: clipped open from its `--fold` inset while it fades in, one beside the star
125
134
  // 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
@@ -223,7 +232,7 @@ export function startSkyline3d({ root, frame, assets, signal, onShowOriginal, on
223
232
  function openMenu(building, x, y) {
224
233
  menuBuilding = building.building.userData["buildingId"];
225
234
  menuTitle.textContent = building.building.userData["geography"]?.name ?? building.building.name;
226
- detailLink.href = new URL(`building-detail.html?building=${encodeURIComponent(menuBuilding)}`, assets).href;
235
+ detailLink.href = new URL(`building-detail.html?building=${encodeURIComponent(menuBuilding)}${colour.enabled ? "&colour=1" : ""}`, assets).href;
227
236
  lightingMenu.replaceChildren();
228
237
  lightingMenu.hidden = !building.illumination;
229
238
  if (building.illumination) {
@@ -377,6 +386,7 @@ export function startSkyline3d({ root, frame, assets, signal, onShowOriginal, on
377
386
  function clearDetail() {
378
387
  detail?.abort();
379
388
  detail = null;
389
+ detailView = undefined;
380
390
  detailPanel.querySelector(".detail-host")?.remove();
381
391
  }
382
392
  function openDetail(id, name) {
@@ -401,7 +411,7 @@ export function startSkyline3d({ root, frame, assets, signal, onShowOriginal, on
401
411
  // The panel names the detail and closes it, so the detail leaves out its own links.
402
412
  markup.classList.add("framed");
403
413
  detailRoot.append(document.importNode(markup, true));
404
- showBuildingDetail({ root: detailRoot, id, frame: detailRoot.querySelector(".detail-root"), signal: controller.signal });
414
+ detailView = showBuildingDetail({ root: detailRoot, id, frame: detailRoot.querySelector(".detail-root"), signal: controller.signal, colour: colour.enabled, onColourChange: setColour });
405
415
  }).catch((error) => {
406
416
  if (controller.signal.aborted)
407
417
  return;
@@ -22,7 +22,7 @@ function element(document, tag, properties = {}, attributes = {}) {
22
22
  created.setAttribute(name, value);
23
23
  return created;
24
24
  }
25
- export function mount(root, { assets, signal, controls, attribution, navigation, page, three }) {
25
+ export function mount(root, { assets, signal, controls, colour, attribution, navigation, page, three }) {
26
26
  const document = root.ownerDocument;
27
27
  const host = root.host;
28
28
  const listening = { signal };
@@ -180,6 +180,7 @@ export function mount(root, { assets, signal, controls, attribution, navigation,
180
180
  frame: scene,
181
181
  assets,
182
182
  signal: sceneLife.signal,
183
+ colour,
183
184
  onShowOriginal: showOriginal,
184
185
  onReady() {
185
186
  sceneUnavailable = false;
@@ -14,6 +14,7 @@ mountViewer(document.querySelector("#viewer"), {
14
14
  title: "Chicago skyline",
15
15
  controls: controls === "open" || controls === "closed" ? controls : embedded ? "closed" : "open",
16
16
  attribution: settings.get("attribution") !== "hidden",
17
+ colour: settings.get("colour") === "1",
17
18
  navigation: !embedded,
18
19
  page: true,
19
20
  });