@a2f0/skyline 0.2.10 → 0.2.12

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,6 +1,6 @@
1
1
  import type { BuildingModel } from "./building-kit.js";
2
2
  import type { Daylight } from "../study-types.js";
3
- 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", "crown glass", "crown lights", "screen", "band lights", "sign board", "sign letters", "cap lights"];
3
+ 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
4
  export type MaterialName = (typeof materialNames)[number];
5
5
  export type Tones = Readonly<Record<number, MaterialName>>;
6
6
  export type Batch = readonly [material: MaterialName, tones?: Tones];
@@ -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
@@ -11,7 +12,7 @@ export const materialNames = [
11
12
  "neutral", "glass", "green glass", "blue-green glass", "bronze glass", "lit window", "dim window", "lamp",
12
13
  "limestone", "marble", "white granite", "granite", "dark granite", "slate",
13
14
  "white terracotta", "pink terracotta", "green tile", "brick", "common brick", "orange brick", "brown brick", "buff brick", "glazed brick",
14
- "concrete", "precast", "copper", "maroon", "blue enamel", "bronze", "dark metal", "aluminium", "stainless",
15
+ "concrete", "precast", "copper", "maroon", "blue enamel", "bronze", "dark metal", "aluminium", "stainless", "opaque spandrel",
15
16
  // Parts the photographs show on their own, named as the night first showed them: crown glass
16
17
  // and crown lights, an emblem screen and the lights in its bands, a sign's board and letters,
17
18
  // and a lit cap.
@@ -34,6 +35,7 @@ export const colourMaterials = {
34
35
  "building-kemper": {
35
36
  "marble shell": ["marble"],
36
37
  "mullions and bands": ["marble"],
38
+ "opaque spandrels": ["opaque spandrel"],
37
39
  "crown glazing": ["glass"],
38
40
  "crown fins": ["aluminium"],
39
41
  "roof cap": ["marble"],
@@ -61,7 +63,7 @@ export const colourMaterials = {
61
63
  "bronze mullions and columns": ["bronze"],
62
64
  },
63
65
  "building-michigan-plaza-south-tower": {
64
- "mapped tower shell": ["glass"],
66
+ "mapped tower shell": ["aluminium"],
65
67
  "mullions and bands": ["aluminium"],
66
68
  "lit glazing": ["lit window"],
67
69
  "dim glazing": ["dim window"],
@@ -353,6 +355,8 @@ export function createDaylight() {
353
355
  // another batch already took, since the builder lets batches share them.
354
356
  export function createSkylineColour(models) {
355
357
  const painted = [], flat = [], taken = new Set();
358
+ const reflections = [];
359
+ const glassMaterials = new Set(["glass", "green glass", "blue-green glass", "bronze glass", "crown glass", "lit window", "dim window"]);
356
360
  for (const model of models) {
357
361
  const buildingId = String(model.building.userData["buildingId"]);
358
362
  if (!measuredColours[buildingId])
@@ -384,16 +388,30 @@ export function createSkylineColour(models) {
384
388
  for (let vertex = 0; vertex < attribute.count; vertex += 1)
385
389
  vertices.set(attribute.array[vertex * 3], (vertices.get(attribute.array[vertex * 3]) ?? 0) + 1);
386
390
  vertices.forEach((n, grey) => count(materialOf(grey), grey, n));
387
- surfaces.push({ material, colours: { attribute, array: attribute.array }, materialOf });
391
+ surfaces.push({ geometry: mesh.geometry, material, colours: { attribute, array: attribute.array }, materialOf });
388
392
  }
389
393
  else if (!taken.has(material)) {
390
394
  taken.add(material);
391
395
  count(materialOf(material.color.r), material.color.r, mesh.geometry.getAttribute("position").count);
392
- surfaces.push({ material, colours: null, materialOf });
396
+ surfaces.push({ geometry: mesh.geometry, material, colours: null, materialOf });
393
397
  }
394
398
  });
395
399
  const swatches = swatchesOf(buildingId, greys);
396
- for (const { material, colours, materialOf } of surfaces) {
400
+ for (const { geometry, material, colours, materialOf } of surfaces) {
401
+ const mask = new Float32Array(geometry.getAttribute("position").count);
402
+ const reflecting = new Map();
403
+ for (let vertex = 0; vertex < mask.length; vertex += 1) {
404
+ const grey = colours ? colours.array[vertex * 3] : material.color.r;
405
+ let reflects = reflecting.get(grey);
406
+ if (reflects === undefined) {
407
+ const name = materialOf(grey);
408
+ reflects = glassMaterials.has(name) && swatches.get(name) ? 1 : 0;
409
+ reflecting.set(grey, reflects);
410
+ }
411
+ mask[vertex] = reflects;
412
+ }
413
+ if (mask.some((value) => value > 0))
414
+ reflections.push(glassReflection(material, geometry, mask));
397
415
  const colourOf = (grey) => {
398
416
  const swatch = swatches.get(materialOf(grey));
399
417
  return swatch ? paint(grey, swatch) : [grey, grey, grey];
@@ -438,6 +456,8 @@ export function createSkylineColour(models) {
438
456
  }
439
457
  for (const { material, grey, colour } of flat)
440
458
  material.color.copy(next ? colour : grey);
459
+ for (const reflect of reflections)
460
+ reflect(next);
441
461
  },
442
462
  refresh() {
443
463
  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,
@@ -77,6 +89,7 @@ export const measuredColours = {
77
89
  marble: 0xc3c0b6,
78
90
  ...glass(0x535455),
79
91
  aluminium: null,
92
+ "opaque spandrel": null,
80
93
  },
81
94
  // docs/north-wabash-reference.md, 2026-10-09 — Daytime colours from Chicago.jpg.
82
95
  "building-330-north-wabash": {
@@ -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
+ }
@@ -4,7 +4,8 @@ import { createBuilder, inside, line, polygonOf } from "./building-kit.js";
4
4
  // outlines; the 159 m top is OSM's height tag (522 ft in the 1962 record).
5
5
  // The marble shell, window bays, raised mullions, dark crown band with light
6
6
  // fins, and projecting cap reuse the fitted model's vocabulary at meter scale.
7
- // Window-row, bay, and crown spacings are estimates; see
7
+ // Window-column and pane proportions follow a 2012 photograph; bay counts,
8
+ // window-row and crown spacings remain estimates. See
8
9
  // docs/kemper-geographic-reference.md. Units are meters; +x is east, +z south.
9
10
  export const kemperGeographicLevels = Object.freeze({
10
11
  podium: 7.8, // OSM way 685494067: two floors at the tower's average floor height
@@ -25,6 +26,7 @@ export function createKemperGeographicBuilding(record, projectPlan, offset) {
25
26
  // with scattered lit units, pale mullions and fins.
26
27
  const marble = kit.batch("Kemper · marble shell", kit.material(0x929292));
27
28
  const mullions = kit.batch("Kemper · mullions and bands", kit.material(0x9b9b9b));
29
+ const spandrels = kit.batch("Kemper · opaque spandrels", kit.material(0x626262));
28
30
  const crownGlass = kit.batch("Kemper · crown glazing", kit.material(0x1c1c1c));
29
31
  const crownFins = kit.batch("Kemper · crown fins", kit.material(0xa3a3a3));
30
32
  const cap = kit.batch("Kemper · roof cap", kit.material(0xa8a8a8));
@@ -58,21 +60,25 @@ export function createKemperGeographicBuilding(record, projectPlan, offset) {
58
60
  return;
59
61
  kit.box(batch, run.at((from + to) / 2), run.normal(0), (to - from) / 2, back, front, y0, y1);
60
62
  };
61
- // Tower facade: one pane per bay per row between full-height mullions, with
62
- // a spandrel at each floor line. Mullions stand proudest of the tower detail.
63
+ // FID-KEM-002: the 2012 north-face photograph gives openings about 48% of
64
+ // each bay and glass about 52% of each floor pitch. Opaque panels continue
65
+ // the narrow columns between windows; the marble piers stay uninterrupted.
63
66
  tower.forEach((run, side) => {
64
67
  const bays = Math.max(1, Math.round(run.length / 3.1));
65
68
  const width = run.length / bays;
69
+ const inset = width * 0.26;
70
+ for (let bay = 0; bay < bays; bay += 1) {
71
+ strip(spandrels, run, bay * width + inset, (bay + 1) * width - inset, h.podium, crownBase, 0.01, 0.04);
72
+ }
66
73
  for (let floor = 3; floor <= 39; floor += 1) {
67
74
  const bottom = floorHeight(floor), top = floorHeight(floor + 1);
68
75
  for (let bay = 0; bay < bays; bay += 1) {
69
- strip(paneTone(floor, bay, side), run, bay * width + 0.16, (bay + 1) * width - 0.16, bottom + 0.26, top - 0.24, 0.02, 0.07);
76
+ strip(paneTone(floor, bay, side), run, bay * width + inset + 0.01, (bay + 1) * width - inset - 0.01, bottom + pitch * 0.24, top - pitch * 0.24, 0.02, 0.07);
70
77
  }
71
- strip(mullions, run, 0.015, run.length - 0.015, top - 0.15, top + 0.05, 0.03, 0.12);
72
78
  }
73
79
  for (let i = 0; i <= bays; i += 1) {
74
80
  const s = Math.max(0.18, Math.min(run.length - 0.18, i * width));
75
- strip(mullions, run, s - 0.06, s + 0.06, h.podium + 0.02, crownBase - 0.05, 0.05, 0.16);
81
+ strip(mullions, run, s - inset, s + inset, h.podium + 0.02, crownBase - 0.05, 0.05, 0.16);
76
82
  }
77
83
  strip(mullions, run, 0.015, run.length - 0.015, h.podium - 0.05, h.podium + 0.35, 0.06, 0.18);
78
84
  });
@@ -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.10",
3
+ "version": "0.2.12",
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
@@ -11,7 +12,7 @@ export const materialNames = [
11
12
  "neutral", "glass", "green glass", "blue-green glass", "bronze glass", "lit window", "dim window", "lamp",
12
13
  "limestone", "marble", "white granite", "granite", "dark granite", "slate",
13
14
  "white terracotta", "pink terracotta", "green tile", "brick", "common brick", "orange brick", "brown brick", "buff brick", "glazed brick",
14
- "concrete", "precast", "copper", "maroon", "blue enamel", "bronze", "dark metal", "aluminium", "stainless",
15
+ "concrete", "precast", "copper", "maroon", "blue enamel", "bronze", "dark metal", "aluminium", "stainless", "opaque spandrel",
15
16
  // Parts the photographs show on their own, named as the night first showed them: crown glass
16
17
  // and crown lights, an emblem screen and the lights in its bands, a sign's board and letters,
17
18
  // and a lit cap.
@@ -34,6 +35,7 @@ export const colourMaterials = {
34
35
  "building-kemper": {
35
36
  "marble shell": ["marble"],
36
37
  "mullions and bands": ["marble"],
38
+ "opaque spandrels": ["opaque spandrel"],
37
39
  "crown glazing": ["glass"],
38
40
  "crown fins": ["aluminium"],
39
41
  "roof cap": ["marble"],
@@ -61,7 +63,7 @@ export const colourMaterials = {
61
63
  "bronze mullions and columns": ["bronze"],
62
64
  },
63
65
  "building-michigan-plaza-south-tower": {
64
- "mapped tower shell": ["glass"],
66
+ "mapped tower shell": ["aluminium"],
65
67
  "mullions and bands": ["aluminium"],
66
68
  "lit glazing": ["lit window"],
67
69
  "dim glazing": ["dim window"],
@@ -353,6 +355,8 @@ export function createDaylight() {
353
355
  // another batch already took, since the builder lets batches share them.
354
356
  export function createSkylineColour(models) {
355
357
  const painted = [], flat = [], taken = new Set();
358
+ const reflections = [];
359
+ const glassMaterials = new Set(["glass", "green glass", "blue-green glass", "bronze glass", "crown glass", "lit window", "dim window"]);
356
360
  for (const model of models) {
357
361
  const buildingId = String(model.building.userData["buildingId"]);
358
362
  if (!measuredColours[buildingId])
@@ -384,16 +388,30 @@ export function createSkylineColour(models) {
384
388
  for (let vertex = 0; vertex < attribute.count; vertex += 1)
385
389
  vertices.set(attribute.array[vertex * 3], (vertices.get(attribute.array[vertex * 3]) ?? 0) + 1);
386
390
  vertices.forEach((n, grey) => count(materialOf(grey), grey, n));
387
- surfaces.push({ material, colours: { attribute, array: attribute.array }, materialOf });
391
+ surfaces.push({ geometry: mesh.geometry, material, colours: { attribute, array: attribute.array }, materialOf });
388
392
  }
389
393
  else if (!taken.has(material)) {
390
394
  taken.add(material);
391
395
  count(materialOf(material.color.r), material.color.r, mesh.geometry.getAttribute("position").count);
392
- surfaces.push({ material, colours: null, materialOf });
396
+ surfaces.push({ geometry: mesh.geometry, material, colours: null, materialOf });
393
397
  }
394
398
  });
395
399
  const swatches = swatchesOf(buildingId, greys);
396
- for (const { material, colours, materialOf } of surfaces) {
400
+ for (const { geometry, material, colours, materialOf } of surfaces) {
401
+ const mask = new Float32Array(geometry.getAttribute("position").count);
402
+ const reflecting = new Map();
403
+ for (let vertex = 0; vertex < mask.length; vertex += 1) {
404
+ const grey = colours ? colours.array[vertex * 3] : material.color.r;
405
+ let reflects = reflecting.get(grey);
406
+ if (reflects === undefined) {
407
+ const name = materialOf(grey);
408
+ reflects = glassMaterials.has(name) && swatches.get(name) ? 1 : 0;
409
+ reflecting.set(grey, reflects);
410
+ }
411
+ mask[vertex] = reflects;
412
+ }
413
+ if (mask.some((value) => value > 0))
414
+ reflections.push(glassReflection(material, geometry, mask));
397
415
  const colourOf = (grey) => {
398
416
  const swatch = swatches.get(materialOf(grey));
399
417
  return swatch ? paint(grey, swatch) : [grey, grey, grey];
@@ -438,6 +456,8 @@ export function createSkylineColour(models) {
438
456
  }
439
457
  for (const { material, grey, colour } of flat)
440
458
  material.color.copy(next ? colour : grey);
459
+ for (const reflect of reflections)
460
+ reflect(next);
441
461
  },
442
462
  refresh() {
443
463
  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,
@@ -77,6 +89,7 @@ export const measuredColours = {
77
89
  marble: 0xc3c0b6,
78
90
  ...glass(0x535455),
79
91
  aluminium: null,
92
+ "opaque spandrel": null,
80
93
  },
81
94
  // docs/north-wabash-reference.md, 2026-10-09 — Daytime colours from Chicago.jpg.
82
95
  "building-330-north-wabash": {
@@ -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
+ }
@@ -4,7 +4,8 @@ import { createBuilder, inside, line, polygonOf } from "./building-kit.js";
4
4
  // outlines; the 159 m top is OSM's height tag (522 ft in the 1962 record).
5
5
  // The marble shell, window bays, raised mullions, dark crown band with light
6
6
  // fins, and projecting cap reuse the fitted model's vocabulary at meter scale.
7
- // Window-row, bay, and crown spacings are estimates; see
7
+ // Window-column and pane proportions follow a 2012 photograph; bay counts,
8
+ // window-row and crown spacings remain estimates. See
8
9
  // docs/kemper-geographic-reference.md. Units are meters; +x is east, +z south.
9
10
  export const kemperGeographicLevels = Object.freeze({
10
11
  podium: 7.8, // OSM way 685494067: two floors at the tower's average floor height
@@ -25,6 +26,7 @@ export function createKemperGeographicBuilding(record, projectPlan, offset) {
25
26
  // with scattered lit units, pale mullions and fins.
26
27
  const marble = kit.batch("Kemper · marble shell", kit.material(0x929292));
27
28
  const mullions = kit.batch("Kemper · mullions and bands", kit.material(0x9b9b9b));
29
+ const spandrels = kit.batch("Kemper · opaque spandrels", kit.material(0x626262));
28
30
  const crownGlass = kit.batch("Kemper · crown glazing", kit.material(0x1c1c1c));
29
31
  const crownFins = kit.batch("Kemper · crown fins", kit.material(0xa3a3a3));
30
32
  const cap = kit.batch("Kemper · roof cap", kit.material(0xa8a8a8));
@@ -58,21 +60,25 @@ export function createKemperGeographicBuilding(record, projectPlan, offset) {
58
60
  return;
59
61
  kit.box(batch, run.at((from + to) / 2), run.normal(0), (to - from) / 2, back, front, y0, y1);
60
62
  };
61
- // Tower facade: one pane per bay per row between full-height mullions, with
62
- // a spandrel at each floor line. Mullions stand proudest of the tower detail.
63
+ // FID-KEM-002: the 2012 north-face photograph gives openings about 48% of
64
+ // each bay and glass about 52% of each floor pitch. Opaque panels continue
65
+ // the narrow columns between windows; the marble piers stay uninterrupted.
63
66
  tower.forEach((run, side) => {
64
67
  const bays = Math.max(1, Math.round(run.length / 3.1));
65
68
  const width = run.length / bays;
69
+ const inset = width * 0.26;
70
+ for (let bay = 0; bay < bays; bay += 1) {
71
+ strip(spandrels, run, bay * width + inset, (bay + 1) * width - inset, h.podium, crownBase, 0.01, 0.04);
72
+ }
66
73
  for (let floor = 3; floor <= 39; floor += 1) {
67
74
  const bottom = floorHeight(floor), top = floorHeight(floor + 1);
68
75
  for (let bay = 0; bay < bays; bay += 1) {
69
- strip(paneTone(floor, bay, side), run, bay * width + 0.16, (bay + 1) * width - 0.16, bottom + 0.26, top - 0.24, 0.02, 0.07);
76
+ strip(paneTone(floor, bay, side), run, bay * width + inset + 0.01, (bay + 1) * width - inset - 0.01, bottom + pitch * 0.24, top - pitch * 0.24, 0.02, 0.07);
70
77
  }
71
- strip(mullions, run, 0.015, run.length - 0.015, top - 0.15, top + 0.05, 0.03, 0.12);
72
78
  }
73
79
  for (let i = 0; i <= bays; i += 1) {
74
80
  const s = Math.max(0.18, Math.min(run.length - 0.18, i * width));
75
- strip(mullions, run, s - 0.06, s + 0.06, h.podium + 0.02, crownBase - 0.05, 0.05, 0.16);
81
+ strip(mullions, run, s - inset, s + inset, h.podium + 0.02, crownBase - 0.05, 0.05, 0.16);
76
82
  }
77
83
  strip(mullions, run, 0.015, run.length - 0.015, h.podium - 0.05, h.podium + 0.35, 0.06, 0.18);
78
84
  });
@@ -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
  });