@a2f0/skyline 0.2.3 → 0.2.4

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.
@@ -1,13 +1,30 @@
1
1
  import type { BuildingModel } from "./building-kit.js";
2
- import type { MaterialName } from "./colour-palette.js";
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"];
4
+ export type MaterialName = (typeof materialNames)[number];
3
5
  export type Tones = Readonly<Record<number, MaterialName>>;
4
6
  export type Batch = readonly [material: MaterialName, tones?: Tones];
5
7
  export type BuildingMaterials = Readonly<Record<string, Batch>>;
6
8
  export declare const colourMaterials: Readonly<Record<string, BuildingMaterials>>;
7
9
  export declare function batchOf(buildingId: string, meshName: string): Batch | undefined;
8
10
  export declare const toneOf: (grey: number) => number;
9
- export declare function toOklab(r: number, g: number, b: number): [number, number, number];
10
- export declare function tint(grey: number, material: MaterialName): [number, number, number];
11
+ export declare const exposure = 1.4;
12
+ export interface Swatch {
13
+ colour: readonly [number, number, number];
14
+ reference: number;
15
+ }
16
+ export declare function paint(grey: number, { colour: [r, g, b], reference }: Swatch): [number, number, number];
17
+ export declare function swatchesOf(buildingId: string, greys: ReadonlyMap<MaterialName, ReadonlyMap<number, number>>): Map<MaterialName, Swatch | null>;
18
+ export declare const daylightIntensities: {
19
+ readonly sun: 3.4;
20
+ readonly ambient: 0.9;
21
+ readonly fill: 0.5;
22
+ };
23
+ export declare const daylightGround: {
24
+ readonly blocks: 7237230;
25
+ readonly roads: 4868682;
26
+ };
27
+ export declare function createDaylight(): Daylight;
11
28
  export interface SkylineColour {
12
29
  /** Whether the buildings show their materials' colours. */
13
30
  readonly enabled: boolean;
@@ -1,5 +1,22 @@
1
1
  import * as THREE from "../vendor/three-r186.js";
2
- import { colourPalette } from "./colour-palette.js";
2
+ import { daylightColours, measuredColours } from "./colour-palette.js";
3
+ // The colour trial (https://github.com/a2f0/skyline/issues/115): which named material each
4
+ // surface of each building is, and a layer that shows the colours measured for those materials
5
+ // (colour-palette.ts) over the greys the models build in, restoring the greys exactly when it is
6
+ // turned off. The models are unchanged; this reads a built model's meshes by its building ID
7
+ // and each mesh's batch, the part of its name after the last " · ", or that without a trailing
8
+ // number, so "glazing 3" reads as "glazing". A surface is its batch's material unless its grey
9
+ // names another.
10
+ export const materialNames = [
11
+ "neutral", "glass", "green glass", "blue-green glass", "bronze glass", "lit window", "dim window", "lamp",
12
+ "limestone", "marble", "white granite", "granite", "dark granite", "slate",
13
+ "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
+ // Parts the photographs show on their own, named as the night first showed them: crown glass
16
+ // and crown lights, an emblem screen and the lights in its bands, a sign's board and letters,
17
+ // and a lit cap.
18
+ "crown glass", "crown lights", "screen", "band lights", "sign board", "sign letters", "cap lights",
19
+ ];
3
20
  // A batch's lit and dim office windows.
4
21
  const windows = (lit, dim) => ({ [lit]: "lit window", [dim]: "dim window" });
5
22
  export const colourMaterials = {
@@ -28,8 +45,8 @@ export const colourMaterials = {
28
45
  "building-crain-communications": {
29
46
  "aluminum spandrels": ["aluminium"],
30
47
  "ribbon glazing": ["glass", { ...windows(0x8a8a8a, 0x565656), 0x585858: "aluminium" }],
31
- "sloped glazing": ["glass"],
32
- "glazing grid": ["aluminium"],
48
+ "sloped glazing": ["crown glass"],
49
+ "glazing grid": ["crown glass"],
33
50
  "diamond outline lights": ["lamp"],
34
51
  coping: ["aluminium"],
35
52
  "slot floor": ["neutral"],
@@ -58,26 +75,28 @@ export const colourMaterials = {
58
75
  "crown mullions": ["stainless"],
59
76
  spire: ["stainless"],
60
77
  },
61
- // Limestone piers and aluminium spandrels; the sign's white letters stay white.
78
+ // Limestone piers and aluminium spandrels, under the sign's board and letters.
62
79
  "building-one-prudential-plaza": {
63
80
  shell: ["limestone", { 0x242424: "dark metal", 0x4c4c4c: "neutral", 0x8e8e8e: "aluminium" }],
64
81
  "windows and spandrels": ["glass", { ...windows(0x7c7c7c, 0x4e4e4e), 0x8a8a8a: "lit window", 0x242424: "dark metal", 0x8e8e8e: "aluminium" }],
65
82
  "limestone piers": ["limestone"],
66
83
  "limestone courses": ["limestone"],
67
- "penthouse and sign": ["limestone", { 0x2e2e2e: "dark metal", 0x383838: "dark metal", 0x4c4c4c: "neutral", 0xb0b0b0: "neutral", 0xc4c4c4: "neutral" }],
84
+ "penthouse and sign": ["limestone", { 0x2e2e2e: "sign board", 0x383838: "dark metal", 0x4c4c4c: "neutral", 0xb0b0b0: "sign letters", 0xc4c4c4: "sign letters" }],
68
85
  "screen louvers": ["aluminium"],
69
86
  "antenna mast": ["stainless"],
70
87
  },
71
- // Stone faces and gables, and the dark glass strips between them.
88
+ // Stone faces and gables, and the dark glass strips between them; the crown's risers and
89
+ // sloped glass, and the white bands and ribs on its ridges.
72
90
  "building-two-prudential-plaza": {
73
91
  "limestone, glass and crown": ["granite", {
74
- 0x353535: "glass", 0x3a3a3a: "glass", 0x3f3f3f: "glass", 0x404040: "glass", 0x454545: "glass", 0x4a4a4a: "glass", 0x565656: "glass",
92
+ 0x353535: "glass", 0x3a3a3a: "glass", 0x404040: "glass", 0x454545: "glass", 0x4a4a4a: "glass",
93
+ 0x3f3f3f: "crown glass", 0x565656: "crown glass", 0xcdcdcd: "crown lights",
75
94
  ...windows(0x8e8e8e, 0x5e5e5e),
76
95
  }],
77
96
  piers: ["granite"],
78
97
  copings: ["granite"],
79
98
  "strip mullions": ["aluminium"],
80
- "crown ribs": ["granite"],
99
+ "crown ribs": ["crown lights"],
81
100
  spire: ["stainless"],
82
101
  "spire inset panels": ["glass"],
83
102
  lobby: ["glass"],
@@ -87,15 +106,15 @@ export const colourMaterials = {
87
106
  "tube shell": ["white granite", { 0x202020: "neutral", 0x4c4c4c: "neutral" }],
88
107
  "window ribbons": ["glass", windows(0x7c7c7c, 0x4e4e4e)],
89
108
  "granite piers": ["white granite"],
90
- "granite cap": ["white granite"],
109
+ "granite cap": ["cap lights"],
91
110
  "rooftop enclosure": ["neutral", { 0x9c9c9c: "aluminium" }],
92
111
  "antenna mast": ["stainless"],
93
112
  },
94
113
  "building-blue-cross-blue-shield": {
95
114
  shell: ["glass", { 0x3a3a3a: "neutral" }],
96
- "glass, spandrels and bands": ["glass", { ...windows(0x8a8a8a, 0x5c5c5c), 0x2a2a2a: "neutral", 0x3e3e3e: "aluminium" }],
115
+ "glass, spandrels and bands": ["glass", { ...windows(0x8a8a8a, 0x5c5c5c), 0x2a2a2a: "neutral", 0x3e3e3e: "screen" }],
97
116
  mullions: ["dark metal"],
98
- "band columns and emblems": ["aluminium"],
117
+ "band columns and emblems": ["band lights"],
99
118
  },
100
119
  // Blue-green glass, and the south face's white concrete frame.
101
120
  "building-340-on-the-park": {
@@ -250,91 +269,141 @@ export function batchOf(buildingId, meshName) {
250
269
  }
251
270
  // A grey's tone: the hex the model wrote it as, from its linear channel.
252
271
  export const toneOf = (grey) => new THREE.Color(grey, grey, grey).getHex();
253
- // OKLab from linear sRGB, and back, after Björn Ottosson's matrices.
254
- export function toOklab(r, g, b) {
255
- const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
256
- const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
257
- const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
258
- return [
259
- 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
260
- 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
261
- 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
262
- ];
272
+ // The measured colours scale to the scene by this factor in linear light, so the skyline view's
273
+ // faces show about the photograph's brightness under the day's lights. At 1.4 the skyline view's
274
+ // 1600 x 900 render, its pixels chosen by each photograph sample's rule, reaches 0.97 (Two
275
+ // Prudential's stone), 0.96 (Blue Cross's glass), 0.86 (Aon's granite), 0.80 (One Prudential's
276
+ // limestone) and 0.66 (Crain's aluminium) of the photograph's brightness in linear light. Aon's
277
+ // granite and Crain's aluminium pass white at their own greys here, so a higher factor would
278
+ // brighten only the darker materials. The Railway Exchange is left out: the view shows its shaded
279
+ // Michigan front, not the sunlit Jackson front its colour is measured on; that front renders 0.82
280
+ // of the photograph's shaded Michigan front.
281
+ export const exposure = 1.4;
282
+ // A grey in its measured colour: the swatch's colour scaled by the grey's brightness against the
283
+ // swatch's grey, both in linear light, so a darker surface of one material stays darker. A colour
284
+ // pushed past white keeps its hue at its brightest, so a bright material's lightest greys can
285
+ // meet there, as Aon's granite and Crain's aluminium do.
286
+ export function paint(grey, { colour: [r, g, b], reference }) {
287
+ const scale = exposure * (reference > 0 ? grey / reference : 1);
288
+ const fit = scale / Math.max(1, r * scale, g * scale, b * scale);
289
+ return [r * fit, g * fit, b * fit];
263
290
  }
264
- function fromOklab(lightness, a, b) {
265
- const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
266
- const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
267
- const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3;
268
- return [
269
- 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
270
- -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
271
- -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
272
- ];
273
- }
274
- const inGamut = (rgb) => rgb.every((channel) => channel >= -1e-7 && channel <= 1 + 1e-7);
275
- // A grey in a material's colour: the grey's own lightness, with the material's hue and chroma,
276
- // less of it where the grey is darker than the material, and less again where sRGB cannot
277
- // show it. A material with no hue leaves the grey exactly as it was.
278
- export function tint(grey, material) {
279
- const swatch = new THREE.Color(colourPalette[material]);
280
- const [swatchLightness, a, b] = toOklab(swatch.r, swatch.g, swatch.b);
281
- if (Math.hypot(a, b) < 1e-4)
282
- return [grey, grey, grey];
283
- const [lightness] = toOklab(grey, grey, grey);
284
- const at = (scale) => fromOklab(lightness, a * scale, b * scale);
285
- let scale = Math.min(1, lightness / swatchLightness);
286
- if (!inGamut(at(scale))) {
287
- let low = 0;
288
- for (let step = 0; step < 20; step += 1) {
289
- const middle = (low + scale) / 2;
290
- if (inGamut(at(middle)))
291
- low = middle;
292
- else
293
- scale = middle;
291
+ // Every measured material's swatch for a building, from how many vertices of each linear grey
292
+ // its surfaces hold: a material naming another takes that one's colour and grey, and a null one
293
+ // stays grey.
294
+ export function swatchesOf(buildingId, greys) {
295
+ const measured = measuredColours[buildingId];
296
+ const swatches = new Map();
297
+ if (!measured)
298
+ return swatches;
299
+ // The most common grey of a material, by vertices; the lighter of a tie.
300
+ const common = (material) => [...greys.get(material) ?? []].reduce((best, entry) => !best || entry[1] > best[1] || (entry[1] === best[1] && entry[0] > best[0]) ? entry : best, null)?.[0];
301
+ for (const material of greys.keys()) {
302
+ const entry = measured[material];
303
+ const source = typeof entry === "string" ? entry : material;
304
+ const value = typeof entry === "string" ? measured[entry] : entry;
305
+ const reference = common(source);
306
+ if (typeof value !== "number" || reference === undefined) {
307
+ swatches.set(material, null);
308
+ continue;
294
309
  }
295
- scale = low;
310
+ const { r, g, b } = new THREE.Color(value);
311
+ swatches.set(material, { colour: [r, g, b], reference });
312
+ }
313
+ return swatches;
314
+ }
315
+ // The sun's, the shade's and the fill light's intensities on the sunny day: the sun and the shade
316
+ // brighter than the night's 2.4 and 0.7, the fill a little dimmer than its 0.55. And the ground's
317
+ // greys in the sun: the platform's blocks, and the roads' darker asphalt.
318
+ export const daylightIntensities = { sun: 3.4, ambient: 0.9, fill: 0.5 };
319
+ export const daylightGround = { blocks: 0x6e6e6e, roads: 0x4a4a4a };
320
+ // The sunny day the colour toggle shows the buildings in: a sky fading from the photograph's blue
321
+ // at the top of the view to its haze at the horizon, blended in linear light, the warm sun, the
322
+ // sky's blue for the light in the shade, and its haze for the fill.
323
+ export function createDaylight() {
324
+ const rows = 256, data = new Uint8Array(rows * 4);
325
+ const top = new THREE.Color(daylightColours.sky), bottom = new THREE.Color(daylightColours.horizon);
326
+ for (let row = 0; row < rows; row += 1) {
327
+ const hex = bottom.clone().lerp(top, row / (rows - 1)).getHex();
328
+ data.set([(hex >> 16) & 255, (hex >> 8) & 255, hex & 255, 255], row * 4);
296
329
  }
297
- return at(scale).map((channel) => Math.min(1, Math.max(0, channel)));
330
+ // A data texture's first row is the bottom of the view.
331
+ const sky = new THREE.DataTexture(data, 1, rows);
332
+ sky.colorSpace = "srgb";
333
+ sky.needsUpdate = true;
334
+ return {
335
+ sky,
336
+ sun: [daylightColours.sun, daylightIntensities.sun],
337
+ ambient: [daylightColours.sky, daylightIntensities.ambient],
338
+ fill: [daylightColours.horizon, daylightIntensities.fill],
339
+ ground: daylightGround.blocks,
340
+ };
298
341
  }
299
- // The colour layer for a skyline's models. A building or batch the trial does not know stays
300
- // in grey, as does a mesh without a single toon material, a vertex-coloured batch whose
301
- // colours are not plain floats, and a material or colour buffer another batch already took,
302
- // since the builder lets batches share them.
342
+ // The colour layer for a skyline's models. A building the trial has no colours for stays in
343
+ // grey, as does a batch it does not know, a mesh without a single toon material, a
344
+ // vertex-coloured batch whose colours are not plain floats, and a material or colour buffer
345
+ // another batch already took, since the builder lets batches share them.
303
346
  export function createSkylineColour(models) {
304
347
  const painted = [], flat = [], taken = new Set();
305
348
  for (const model of models) {
306
349
  const buildingId = String(model.building.userData["buildingId"]);
350
+ if (!measuredColours[buildingId])
351
+ continue;
352
+ // The building's batches, and how many vertices of each grey each material holds.
353
+ const surfaces = [];
354
+ const greys = new Map();
355
+ const count = (material, grey, vertices) => {
356
+ const held = greys.get(material) ?? new Map();
357
+ held.set(grey, (held.get(grey) ?? 0) + vertices);
358
+ greys.set(material, held);
359
+ };
307
360
  model.building.traverse((object) => {
308
361
  const mesh = object;
309
362
  if (!mesh.isMesh)
310
363
  return;
311
364
  const batch = batchOf(buildingId, mesh.name);
312
- if (!batch)
365
+ if (!batch || Array.isArray(mesh.material) || !mesh.material.isMeshToonMaterial)
313
366
  return;
314
367
  const [base, tones = {}] = batch;
315
- if (Array.isArray(mesh.material) || !mesh.material.isMeshToonMaterial)
316
- return;
317
368
  const material = mesh.material;
318
- const colourOf = (grey) => tint(grey, tones[toneOf(grey)] ?? base);
369
+ const materialOf = (grey) => tones[toneOf(grey)] ?? base;
319
370
  const attribute = mesh.geometry.getAttribute("color");
320
371
  if (material.vertexColors) {
321
372
  if (!(attribute?.array instanceof Float32Array) || taken.has(attribute))
322
373
  return;
323
374
  taken.add(attribute);
375
+ const vertices = new Map();
376
+ for (let vertex = 0; vertex < attribute.count; vertex += 1)
377
+ vertices.set(attribute.array[vertex * 3], (vertices.get(attribute.array[vertex * 3]) ?? 0) + 1);
378
+ vertices.forEach((n, grey) => count(materialOf(grey), grey, n));
379
+ surfaces.push({ material, colours: { attribute, array: attribute.array }, materialOf });
380
+ }
381
+ else if (!taken.has(material)) {
382
+ taken.add(material);
383
+ count(materialOf(material.color.r), material.color.r, mesh.geometry.getAttribute("position").count);
384
+ surfaces.push({ material, colours: null, materialOf });
385
+ }
386
+ });
387
+ const swatches = swatchesOf(buildingId, greys);
388
+ for (const { material, colours, materialOf } of surfaces) {
389
+ const colourOf = (grey) => {
390
+ const swatch = swatches.get(materialOf(grey));
391
+ return swatch ? paint(grey, swatch) : [grey, grey, grey];
392
+ };
393
+ if (colours) {
324
394
  const cache = new Map();
325
- const paint = (grey) => {
395
+ const paintGrey = (grey) => {
326
396
  let colour = cache.get(grey);
327
397
  if (!colour)
328
398
  cache.set(grey, colour = Float32Array.from(colourOf(grey)));
329
399
  return colour;
330
400
  };
331
- painted.push({ attribute, array: attribute.array, paint, greys: null });
401
+ painted.push({ ...colours, paint: paintGrey, greys: null });
332
402
  }
333
- else if (!taken.has(material)) {
334
- taken.add(material);
403
+ else {
335
404
  flat.push({ material, grey: material.color.clone(), colour: new THREE.Color(...colourOf(material.color.r)) });
336
405
  }
337
- });
406
+ }
338
407
  }
339
408
  let enabled = false;
340
409
  return {
@@ -1,35 +1,8 @@
1
- export declare const colourPalette: {
2
- readonly neutral: 8421504;
3
- readonly glass: 5398118;
4
- readonly "green glass": 4157036;
5
- readonly "blue-green glass": 4883088;
6
- readonly "bronze glass": 5917502;
7
- readonly "lit window": 16044954;
8
- readonly "dim window": 12098418;
9
- readonly lamp: 16773330;
10
- readonly limestone: 14207656;
11
- readonly marble: 14999508;
12
- readonly "white granite": 14473165;
13
- readonly granite: 12366504;
14
- readonly "dark granite": 6444111;
15
- readonly slate: 5001824;
16
- readonly "white terracotta": 15326916;
17
- readonly "pink terracotta": 14198936;
18
- readonly "green tile": 6196326;
19
- readonly brick: 10243128;
20
- readonly "common brick": 9202778;
21
- readonly "orange brick": 10771002;
22
- readonly "brown brick": 8147522;
23
- readonly "buff brick": 13216640;
24
- readonly "glazed brick": 14999503;
25
- readonly concrete: 13617595;
26
- readonly precast: 14338210;
27
- readonly copper: 7316623;
28
- readonly maroon: 7612463;
29
- readonly "blue enamel": 4088212;
30
- readonly bronze: 7098420;
31
- readonly "dark metal": 3158842;
32
- readonly aluminium: 12567753;
33
- readonly stainless: 11844806;
1
+ import type { MaterialName } from "./colour-materials.js";
2
+ export type Measurement = number | MaterialName | null;
3
+ export declare const measuredColours: Readonly<Record<string, Readonly<Partial<Record<MaterialName, Measurement>>>>>;
4
+ export declare const daylightColours: {
5
+ readonly sky: 10402779;
6
+ readonly horizon: 12898785;
7
+ readonly sun: 16774374;
34
8
  };
35
- export type MaterialName = keyof typeof colourPalette;
@@ -1,48 +1,62 @@
1
- // The colour trial's named materials (https://github.com/a2f0/skyline/issues/115): the one file
2
- // in the repository allowed colours, which tests/grayscale.test.ts exempts by name. Each colour
3
- // gives a material's hue and how strongly it shows; the lightness every surface keeps is its
4
- // model's own grey, so colour mode adds hue to the skyline without changing its values. A
5
- // material darker than its colour here carries proportionally less of it. These are hand-picked
6
- // from the materials and colours each building's reference audit records, not measured from
7
- // photographs; a material with no hue stays grey.
8
- export const colourPalette = {
9
- neutral: 0x808080,
10
- // Vision glass: the default cool tint, the teal of 340 on the Park and the Heritage, Swissôtel's
11
- // reflective blue-green, and the bronze tint of Illinois Center and its neighbours.
12
- glass: 0x525e66,
13
- "green glass": 0x3f6e6c,
14
- "blue-green glass": 0x4a8290,
15
- "bronze glass": 0x5a4b3e,
16
- // Lit offices, and the dimmer ones further in.
17
- "lit window": 0xf4d39a,
18
- "dim window": 0xb89b72,
19
- lamp: 0xfff0d2,
20
- // Stone.
21
- limestone: 0xd8caa8,
22
- marble: 0xe4dfd4,
23
- "white granite": 0xdcd7cd,
24
- granite: 0xbcb2a8,
25
- "dark granite": 0x62544f,
26
- slate: 0x4c5260,
27
- // Clay.
28
- "white terracotta": 0xe9dec4,
29
- "pink terracotta": 0xd8a898,
30
- "green tile": 0x5e8c66,
31
- brick: 0x9c4c38,
32
- "common brick": 0x8c6c5a,
33
- "orange brick": 0xa45a3a,
34
- "brown brick": 0x7c5242,
35
- "buff brick": 0xc9ab80,
36
- "glazed brick": 0xe4dfcf,
37
- // Concrete.
38
- concrete: 0xcfc9bb,
39
- precast: 0xdac8a2,
40
- // Metal and paint.
41
- copper: 0x6fa48f,
42
- maroon: 0x74282f,
43
- "blue enamel": 0x3e6194,
44
- bronze: 0x6c5034,
45
- "dark metal": 0x30333a,
46
- aluminium: 0xbfc4c9,
47
- stainless: 0xb4bcc6,
1
+ // By day no office lights show: lit and dim windows are the glass, in its measured colour.
2
+ const glass = (colour) => ({ glass: colour, "lit window": colour, "dim window": colour });
3
+ export const measuredColours = {
4
+ // docs/railway-exchange-reference.md, 2026-10-09 — Daytime colours from Chicago.jpg.
5
+ "building-railway-exchange": {
6
+ "white terracotta": 0xc8c1b3,
7
+ ...glass(0x3d4243),
8
+ copper: null,
9
+ "common brick": null,
10
+ neutral: null,
11
+ },
12
+ // docs/crain-reference.md, 2026-10-09 — Daytime colours from Chicago.jpg.
13
+ "building-crain-communications": {
14
+ aluminium: 0xfbfcfa,
15
+ ...glass(0x1e2425),
16
+ "crown glass": 0x79828c,
17
+ lamp: "aluminium",
18
+ "dark metal": null,
19
+ neutral: null,
20
+ },
21
+ // docs/one-prudential-reference.md, 2026-10-09 — Daytime colours from Chicago.jpg.
22
+ "building-one-prudential-plaza": {
23
+ limestone: 0xc2bcb4,
24
+ aluminium: "limestone",
25
+ ...glass(0x625e59),
26
+ "sign board": 0xc4c0b9,
27
+ "sign letters": 0x6684a0,
28
+ "dark metal": null,
29
+ stainless: null,
30
+ neutral: null,
31
+ },
32
+ // docs/two-prudential-reference.md, 2026-10-09 — Daytime colours from Chicago.jpg.
33
+ "building-two-prudential-plaza": {
34
+ granite: 0xb1b3b0,
35
+ ...glass(0x424c55),
36
+ "crown glass": 0x5e7686,
37
+ "crown lights": 0xb6bbbb,
38
+ stainless: null,
39
+ aluminium: null,
40
+ },
41
+ // docs/aon-reference.md, 2026-10-09 — Daytime colours from Chicago.jpg.
42
+ layer3: {
43
+ "white granite": 0xdfdfdd,
44
+ ...glass(0x424240),
45
+ "cap lights": "white granite",
46
+ aluminium: null,
47
+ stainless: null,
48
+ neutral: null,
49
+ },
50
+ // docs/blue-cross-reference.md, 2026-10-09 — Daytime colours from Chicago.jpg and a close-up.
51
+ "building-blue-cross-blue-shield": {
52
+ ...glass(0x7d8992),
53
+ screen: 0x334a60,
54
+ "band lights": 0xb0bdcd,
55
+ "dark metal": null,
56
+ neutral: null,
57
+ },
48
58
  };
59
+ // The sunny day the colour toggle shows: the sky's blue at the top of the view and its haze just
60
+ // above the horizon, each the median of a clear box of the same panorama (scripts/sample-colours.ts,
61
+ // the day study's sky), and the sun's warm white, chosen.
62
+ export const daylightColours = { sky: 0x9ebbdb, horizon: 0xc4d1e1, sun: 0xfff4e6 };
@@ -47,3 +47,11 @@ export interface StudyLabel {
47
47
  text: string;
48
48
  placement?: string;
49
49
  }
50
+ export interface Daylight {
51
+ sky: THREE.Texture;
52
+ sun: readonly [colour: number, intensity: number];
53
+ ambient: readonly [colour: number, intensity: number];
54
+ fill: readonly [colour: number, intensity: number];
55
+ /** The platform's grey in sunlight. */
56
+ ground: number;
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2f0/skyline",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Interactive Chicago skyline viewer and reusable Three.js building models",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -1,5 +1,22 @@
1
1
  import * as THREE from "../vendor/three-r186.js";
2
- import { colourPalette } from "./colour-palette.js";
2
+ import { daylightColours, measuredColours } from "./colour-palette.js";
3
+ // The colour trial (https://github.com/a2f0/skyline/issues/115): which named material each
4
+ // surface of each building is, and a layer that shows the colours measured for those materials
5
+ // (colour-palette.ts) over the greys the models build in, restoring the greys exactly when it is
6
+ // turned off. The models are unchanged; this reads a built model's meshes by its building ID
7
+ // and each mesh's batch, the part of its name after the last " · ", or that without a trailing
8
+ // number, so "glazing 3" reads as "glazing". A surface is its batch's material unless its grey
9
+ // names another.
10
+ export const materialNames = [
11
+ "neutral", "glass", "green glass", "blue-green glass", "bronze glass", "lit window", "dim window", "lamp",
12
+ "limestone", "marble", "white granite", "granite", "dark granite", "slate",
13
+ "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
+ // Parts the photographs show on their own, named as the night first showed them: crown glass
16
+ // and crown lights, an emblem screen and the lights in its bands, a sign's board and letters,
17
+ // and a lit cap.
18
+ "crown glass", "crown lights", "screen", "band lights", "sign board", "sign letters", "cap lights",
19
+ ];
3
20
  // A batch's lit and dim office windows.
4
21
  const windows = (lit, dim) => ({ [lit]: "lit window", [dim]: "dim window" });
5
22
  export const colourMaterials = {
@@ -28,8 +45,8 @@ export const colourMaterials = {
28
45
  "building-crain-communications": {
29
46
  "aluminum spandrels": ["aluminium"],
30
47
  "ribbon glazing": ["glass", { ...windows(0x8a8a8a, 0x565656), 0x585858: "aluminium" }],
31
- "sloped glazing": ["glass"],
32
- "glazing grid": ["aluminium"],
48
+ "sloped glazing": ["crown glass"],
49
+ "glazing grid": ["crown glass"],
33
50
  "diamond outline lights": ["lamp"],
34
51
  coping: ["aluminium"],
35
52
  "slot floor": ["neutral"],
@@ -58,26 +75,28 @@ export const colourMaterials = {
58
75
  "crown mullions": ["stainless"],
59
76
  spire: ["stainless"],
60
77
  },
61
- // Limestone piers and aluminium spandrels; the sign's white letters stay white.
78
+ // Limestone piers and aluminium spandrels, under the sign's board and letters.
62
79
  "building-one-prudential-plaza": {
63
80
  shell: ["limestone", { 0x242424: "dark metal", 0x4c4c4c: "neutral", 0x8e8e8e: "aluminium" }],
64
81
  "windows and spandrels": ["glass", { ...windows(0x7c7c7c, 0x4e4e4e), 0x8a8a8a: "lit window", 0x242424: "dark metal", 0x8e8e8e: "aluminium" }],
65
82
  "limestone piers": ["limestone"],
66
83
  "limestone courses": ["limestone"],
67
- "penthouse and sign": ["limestone", { 0x2e2e2e: "dark metal", 0x383838: "dark metal", 0x4c4c4c: "neutral", 0xb0b0b0: "neutral", 0xc4c4c4: "neutral" }],
84
+ "penthouse and sign": ["limestone", { 0x2e2e2e: "sign board", 0x383838: "dark metal", 0x4c4c4c: "neutral", 0xb0b0b0: "sign letters", 0xc4c4c4: "sign letters" }],
68
85
  "screen louvers": ["aluminium"],
69
86
  "antenna mast": ["stainless"],
70
87
  },
71
- // Stone faces and gables, and the dark glass strips between them.
88
+ // Stone faces and gables, and the dark glass strips between them; the crown's risers and
89
+ // sloped glass, and the white bands and ribs on its ridges.
72
90
  "building-two-prudential-plaza": {
73
91
  "limestone, glass and crown": ["granite", {
74
- 0x353535: "glass", 0x3a3a3a: "glass", 0x3f3f3f: "glass", 0x404040: "glass", 0x454545: "glass", 0x4a4a4a: "glass", 0x565656: "glass",
92
+ 0x353535: "glass", 0x3a3a3a: "glass", 0x404040: "glass", 0x454545: "glass", 0x4a4a4a: "glass",
93
+ 0x3f3f3f: "crown glass", 0x565656: "crown glass", 0xcdcdcd: "crown lights",
75
94
  ...windows(0x8e8e8e, 0x5e5e5e),
76
95
  }],
77
96
  piers: ["granite"],
78
97
  copings: ["granite"],
79
98
  "strip mullions": ["aluminium"],
80
- "crown ribs": ["granite"],
99
+ "crown ribs": ["crown lights"],
81
100
  spire: ["stainless"],
82
101
  "spire inset panels": ["glass"],
83
102
  lobby: ["glass"],
@@ -87,15 +106,15 @@ export const colourMaterials = {
87
106
  "tube shell": ["white granite", { 0x202020: "neutral", 0x4c4c4c: "neutral" }],
88
107
  "window ribbons": ["glass", windows(0x7c7c7c, 0x4e4e4e)],
89
108
  "granite piers": ["white granite"],
90
- "granite cap": ["white granite"],
109
+ "granite cap": ["cap lights"],
91
110
  "rooftop enclosure": ["neutral", { 0x9c9c9c: "aluminium" }],
92
111
  "antenna mast": ["stainless"],
93
112
  },
94
113
  "building-blue-cross-blue-shield": {
95
114
  shell: ["glass", { 0x3a3a3a: "neutral" }],
96
- "glass, spandrels and bands": ["glass", { ...windows(0x8a8a8a, 0x5c5c5c), 0x2a2a2a: "neutral", 0x3e3e3e: "aluminium" }],
115
+ "glass, spandrels and bands": ["glass", { ...windows(0x8a8a8a, 0x5c5c5c), 0x2a2a2a: "neutral", 0x3e3e3e: "screen" }],
97
116
  mullions: ["dark metal"],
98
- "band columns and emblems": ["aluminium"],
117
+ "band columns and emblems": ["band lights"],
99
118
  },
100
119
  // Blue-green glass, and the south face's white concrete frame.
101
120
  "building-340-on-the-park": {
@@ -250,91 +269,141 @@ export function batchOf(buildingId, meshName) {
250
269
  }
251
270
  // A grey's tone: the hex the model wrote it as, from its linear channel.
252
271
  export const toneOf = (grey) => new THREE.Color(grey, grey, grey).getHex();
253
- // OKLab from linear sRGB, and back, after Björn Ottosson's matrices.
254
- export function toOklab(r, g, b) {
255
- const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
256
- const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
257
- const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
258
- return [
259
- 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
260
- 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
261
- 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
262
- ];
272
+ // The measured colours scale to the scene by this factor in linear light, so the skyline view's
273
+ // faces show about the photograph's brightness under the day's lights. At 1.4 the skyline view's
274
+ // 1600 x 900 render, its pixels chosen by each photograph sample's rule, reaches 0.97 (Two
275
+ // Prudential's stone), 0.96 (Blue Cross's glass), 0.86 (Aon's granite), 0.80 (One Prudential's
276
+ // limestone) and 0.66 (Crain's aluminium) of the photograph's brightness in linear light. Aon's
277
+ // granite and Crain's aluminium pass white at their own greys here, so a higher factor would
278
+ // brighten only the darker materials. The Railway Exchange is left out: the view shows its shaded
279
+ // Michigan front, not the sunlit Jackson front its colour is measured on; that front renders 0.82
280
+ // of the photograph's shaded Michigan front.
281
+ export const exposure = 1.4;
282
+ // A grey in its measured colour: the swatch's colour scaled by the grey's brightness against the
283
+ // swatch's grey, both in linear light, so a darker surface of one material stays darker. A colour
284
+ // pushed past white keeps its hue at its brightest, so a bright material's lightest greys can
285
+ // meet there, as Aon's granite and Crain's aluminium do.
286
+ export function paint(grey, { colour: [r, g, b], reference }) {
287
+ const scale = exposure * (reference > 0 ? grey / reference : 1);
288
+ const fit = scale / Math.max(1, r * scale, g * scale, b * scale);
289
+ return [r * fit, g * fit, b * fit];
263
290
  }
264
- function fromOklab(lightness, a, b) {
265
- const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
266
- const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
267
- const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3;
268
- return [
269
- 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
270
- -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
271
- -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
272
- ];
273
- }
274
- const inGamut = (rgb) => rgb.every((channel) => channel >= -1e-7 && channel <= 1 + 1e-7);
275
- // A grey in a material's colour: the grey's own lightness, with the material's hue and chroma,
276
- // less of it where the grey is darker than the material, and less again where sRGB cannot
277
- // show it. A material with no hue leaves the grey exactly as it was.
278
- export function tint(grey, material) {
279
- const swatch = new THREE.Color(colourPalette[material]);
280
- const [swatchLightness, a, b] = toOklab(swatch.r, swatch.g, swatch.b);
281
- if (Math.hypot(a, b) < 1e-4)
282
- return [grey, grey, grey];
283
- const [lightness] = toOklab(grey, grey, grey);
284
- const at = (scale) => fromOklab(lightness, a * scale, b * scale);
285
- let scale = Math.min(1, lightness / swatchLightness);
286
- if (!inGamut(at(scale))) {
287
- let low = 0;
288
- for (let step = 0; step < 20; step += 1) {
289
- const middle = (low + scale) / 2;
290
- if (inGamut(at(middle)))
291
- low = middle;
292
- else
293
- scale = middle;
291
+ // Every measured material's swatch for a building, from how many vertices of each linear grey
292
+ // its surfaces hold: a material naming another takes that one's colour and grey, and a null one
293
+ // stays grey.
294
+ export function swatchesOf(buildingId, greys) {
295
+ const measured = measuredColours[buildingId];
296
+ const swatches = new Map();
297
+ if (!measured)
298
+ return swatches;
299
+ // The most common grey of a material, by vertices; the lighter of a tie.
300
+ const common = (material) => [...greys.get(material) ?? []].reduce((best, entry) => !best || entry[1] > best[1] || (entry[1] === best[1] && entry[0] > best[0]) ? entry : best, null)?.[0];
301
+ for (const material of greys.keys()) {
302
+ const entry = measured[material];
303
+ const source = typeof entry === "string" ? entry : material;
304
+ const value = typeof entry === "string" ? measured[entry] : entry;
305
+ const reference = common(source);
306
+ if (typeof value !== "number" || reference === undefined) {
307
+ swatches.set(material, null);
308
+ continue;
294
309
  }
295
- scale = low;
310
+ const { r, g, b } = new THREE.Color(value);
311
+ swatches.set(material, { colour: [r, g, b], reference });
312
+ }
313
+ return swatches;
314
+ }
315
+ // The sun's, the shade's and the fill light's intensities on the sunny day: the sun and the shade
316
+ // brighter than the night's 2.4 and 0.7, the fill a little dimmer than its 0.55. And the ground's
317
+ // greys in the sun: the platform's blocks, and the roads' darker asphalt.
318
+ export const daylightIntensities = { sun: 3.4, ambient: 0.9, fill: 0.5 };
319
+ export const daylightGround = { blocks: 0x6e6e6e, roads: 0x4a4a4a };
320
+ // The sunny day the colour toggle shows the buildings in: a sky fading from the photograph's blue
321
+ // at the top of the view to its haze at the horizon, blended in linear light, the warm sun, the
322
+ // sky's blue for the light in the shade, and its haze for the fill.
323
+ export function createDaylight() {
324
+ const rows = 256, data = new Uint8Array(rows * 4);
325
+ const top = new THREE.Color(daylightColours.sky), bottom = new THREE.Color(daylightColours.horizon);
326
+ for (let row = 0; row < rows; row += 1) {
327
+ const hex = bottom.clone().lerp(top, row / (rows - 1)).getHex();
328
+ data.set([(hex >> 16) & 255, (hex >> 8) & 255, hex & 255, 255], row * 4);
296
329
  }
297
- return at(scale).map((channel) => Math.min(1, Math.max(0, channel)));
330
+ // A data texture's first row is the bottom of the view.
331
+ const sky = new THREE.DataTexture(data, 1, rows);
332
+ sky.colorSpace = "srgb";
333
+ sky.needsUpdate = true;
334
+ return {
335
+ sky,
336
+ sun: [daylightColours.sun, daylightIntensities.sun],
337
+ ambient: [daylightColours.sky, daylightIntensities.ambient],
338
+ fill: [daylightColours.horizon, daylightIntensities.fill],
339
+ ground: daylightGround.blocks,
340
+ };
298
341
  }
299
- // The colour layer for a skyline's models. A building or batch the trial does not know stays
300
- // in grey, as does a mesh without a single toon material, a vertex-coloured batch whose
301
- // colours are not plain floats, and a material or colour buffer another batch already took,
302
- // since the builder lets batches share them.
342
+ // The colour layer for a skyline's models. A building the trial has no colours for stays in
343
+ // grey, as does a batch it does not know, a mesh without a single toon material, a
344
+ // vertex-coloured batch whose colours are not plain floats, and a material or colour buffer
345
+ // another batch already took, since the builder lets batches share them.
303
346
  export function createSkylineColour(models) {
304
347
  const painted = [], flat = [], taken = new Set();
305
348
  for (const model of models) {
306
349
  const buildingId = String(model.building.userData["buildingId"]);
350
+ if (!measuredColours[buildingId])
351
+ continue;
352
+ // The building's batches, and how many vertices of each grey each material holds.
353
+ const surfaces = [];
354
+ const greys = new Map();
355
+ const count = (material, grey, vertices) => {
356
+ const held = greys.get(material) ?? new Map();
357
+ held.set(grey, (held.get(grey) ?? 0) + vertices);
358
+ greys.set(material, held);
359
+ };
307
360
  model.building.traverse((object) => {
308
361
  const mesh = object;
309
362
  if (!mesh.isMesh)
310
363
  return;
311
364
  const batch = batchOf(buildingId, mesh.name);
312
- if (!batch)
365
+ if (!batch || Array.isArray(mesh.material) || !mesh.material.isMeshToonMaterial)
313
366
  return;
314
367
  const [base, tones = {}] = batch;
315
- if (Array.isArray(mesh.material) || !mesh.material.isMeshToonMaterial)
316
- return;
317
368
  const material = mesh.material;
318
- const colourOf = (grey) => tint(grey, tones[toneOf(grey)] ?? base);
369
+ const materialOf = (grey) => tones[toneOf(grey)] ?? base;
319
370
  const attribute = mesh.geometry.getAttribute("color");
320
371
  if (material.vertexColors) {
321
372
  if (!(attribute?.array instanceof Float32Array) || taken.has(attribute))
322
373
  return;
323
374
  taken.add(attribute);
375
+ const vertices = new Map();
376
+ for (let vertex = 0; vertex < attribute.count; vertex += 1)
377
+ vertices.set(attribute.array[vertex * 3], (vertices.get(attribute.array[vertex * 3]) ?? 0) + 1);
378
+ vertices.forEach((n, grey) => count(materialOf(grey), grey, n));
379
+ surfaces.push({ material, colours: { attribute, array: attribute.array }, materialOf });
380
+ }
381
+ else if (!taken.has(material)) {
382
+ taken.add(material);
383
+ count(materialOf(material.color.r), material.color.r, mesh.geometry.getAttribute("position").count);
384
+ surfaces.push({ material, colours: null, materialOf });
385
+ }
386
+ });
387
+ const swatches = swatchesOf(buildingId, greys);
388
+ for (const { material, colours, materialOf } of surfaces) {
389
+ const colourOf = (grey) => {
390
+ const swatch = swatches.get(materialOf(grey));
391
+ return swatch ? paint(grey, swatch) : [grey, grey, grey];
392
+ };
393
+ if (colours) {
324
394
  const cache = new Map();
325
- const paint = (grey) => {
395
+ const paintGrey = (grey) => {
326
396
  let colour = cache.get(grey);
327
397
  if (!colour)
328
398
  cache.set(grey, colour = Float32Array.from(colourOf(grey)));
329
399
  return colour;
330
400
  };
331
- painted.push({ attribute, array: attribute.array, paint, greys: null });
401
+ painted.push({ ...colours, paint: paintGrey, greys: null });
332
402
  }
333
- else if (!taken.has(material)) {
334
- taken.add(material);
403
+ else {
335
404
  flat.push({ material, grey: material.color.clone(), colour: new THREE.Color(...colourOf(material.color.r)) });
336
405
  }
337
- });
406
+ }
338
407
  }
339
408
  let enabled = false;
340
409
  return {
@@ -1,48 +1,62 @@
1
- // The colour trial's named materials (https://github.com/a2f0/skyline/issues/115): the one file
2
- // in the repository allowed colours, which tests/grayscale.test.ts exempts by name. Each colour
3
- // gives a material's hue and how strongly it shows; the lightness every surface keeps is its
4
- // model's own grey, so colour mode adds hue to the skyline without changing its values. A
5
- // material darker than its colour here carries proportionally less of it. These are hand-picked
6
- // from the materials and colours each building's reference audit records, not measured from
7
- // photographs; a material with no hue stays grey.
8
- export const colourPalette = {
9
- neutral: 0x808080,
10
- // Vision glass: the default cool tint, the teal of 340 on the Park and the Heritage, Swissôtel's
11
- // reflective blue-green, and the bronze tint of Illinois Center and its neighbours.
12
- glass: 0x525e66,
13
- "green glass": 0x3f6e6c,
14
- "blue-green glass": 0x4a8290,
15
- "bronze glass": 0x5a4b3e,
16
- // Lit offices, and the dimmer ones further in.
17
- "lit window": 0xf4d39a,
18
- "dim window": 0xb89b72,
19
- lamp: 0xfff0d2,
20
- // Stone.
21
- limestone: 0xd8caa8,
22
- marble: 0xe4dfd4,
23
- "white granite": 0xdcd7cd,
24
- granite: 0xbcb2a8,
25
- "dark granite": 0x62544f,
26
- slate: 0x4c5260,
27
- // Clay.
28
- "white terracotta": 0xe9dec4,
29
- "pink terracotta": 0xd8a898,
30
- "green tile": 0x5e8c66,
31
- brick: 0x9c4c38,
32
- "common brick": 0x8c6c5a,
33
- "orange brick": 0xa45a3a,
34
- "brown brick": 0x7c5242,
35
- "buff brick": 0xc9ab80,
36
- "glazed brick": 0xe4dfcf,
37
- // Concrete.
38
- concrete: 0xcfc9bb,
39
- precast: 0xdac8a2,
40
- // Metal and paint.
41
- copper: 0x6fa48f,
42
- maroon: 0x74282f,
43
- "blue enamel": 0x3e6194,
44
- bronze: 0x6c5034,
45
- "dark metal": 0x30333a,
46
- aluminium: 0xbfc4c9,
47
- stainless: 0xb4bcc6,
1
+ // By day no office lights show: lit and dim windows are the glass, in its measured colour.
2
+ const glass = (colour) => ({ glass: colour, "lit window": colour, "dim window": colour });
3
+ export const measuredColours = {
4
+ // docs/railway-exchange-reference.md, 2026-10-09 — Daytime colours from Chicago.jpg.
5
+ "building-railway-exchange": {
6
+ "white terracotta": 0xc8c1b3,
7
+ ...glass(0x3d4243),
8
+ copper: null,
9
+ "common brick": null,
10
+ neutral: null,
11
+ },
12
+ // docs/crain-reference.md, 2026-10-09 — Daytime colours from Chicago.jpg.
13
+ "building-crain-communications": {
14
+ aluminium: 0xfbfcfa,
15
+ ...glass(0x1e2425),
16
+ "crown glass": 0x79828c,
17
+ lamp: "aluminium",
18
+ "dark metal": null,
19
+ neutral: null,
20
+ },
21
+ // docs/one-prudential-reference.md, 2026-10-09 — Daytime colours from Chicago.jpg.
22
+ "building-one-prudential-plaza": {
23
+ limestone: 0xc2bcb4,
24
+ aluminium: "limestone",
25
+ ...glass(0x625e59),
26
+ "sign board": 0xc4c0b9,
27
+ "sign letters": 0x6684a0,
28
+ "dark metal": null,
29
+ stainless: null,
30
+ neutral: null,
31
+ },
32
+ // docs/two-prudential-reference.md, 2026-10-09 — Daytime colours from Chicago.jpg.
33
+ "building-two-prudential-plaza": {
34
+ granite: 0xb1b3b0,
35
+ ...glass(0x424c55),
36
+ "crown glass": 0x5e7686,
37
+ "crown lights": 0xb6bbbb,
38
+ stainless: null,
39
+ aluminium: null,
40
+ },
41
+ // docs/aon-reference.md, 2026-10-09 — Daytime colours from Chicago.jpg.
42
+ layer3: {
43
+ "white granite": 0xdfdfdd,
44
+ ...glass(0x424240),
45
+ "cap lights": "white granite",
46
+ aluminium: null,
47
+ stainless: null,
48
+ neutral: null,
49
+ },
50
+ // docs/blue-cross-reference.md, 2026-10-09 — Daytime colours from Chicago.jpg and a close-up.
51
+ "building-blue-cross-blue-shield": {
52
+ ...glass(0x7d8992),
53
+ screen: 0x334a60,
54
+ "band lights": 0xb0bdcd,
55
+ "dark metal": null,
56
+ neutral: null,
57
+ },
48
58
  };
59
+ // The sunny day the colour toggle shows: the sky's blue at the top of the view and its haze just
60
+ // above the horizon, each the median of a clear box of the same panorama (scripts/sample-colours.ts,
61
+ // the day study's sky), and the sun's warm white, chosen.
62
+ export const daylightColours = { sky: 0x9ebbdb, horizon: 0xc4d1e1, sun: 0xfff4e6 };
@@ -53,7 +53,7 @@
53
53
  <button type="button" id="streets" aria-pressed="true">streets</button>
54
54
  <button type="button" id="footprints" aria-pressed="false">footprints</button>
55
55
  <button type="button" id="wireframe" aria-pressed="false">wireframe</button>
56
- <button type="button" id="colour" aria-pressed="false" title="Show each building's materials in colour, a trial">colour</button>
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
59
  <div class="celebration-controls" id="celebration-controls" role="group" aria-label="Celebratory window lighting">
@@ -2,7 +2,7 @@ import { createBuildingStudy, frameBox } from "./study-viewer.js";
2
2
  import { createGeographicSkyline } from "./skyline-scene.js";
3
3
  import { createGeographicRoads } from "./models/skyline-geography.js";
4
4
  import { celebrations } from "./models/celebrations.js";
5
- import { createSkylineColour } from "./models/colour-materials.js";
5
+ import { createDaylight, createSkylineColour, daylightGround } from "./models/colour-materials.js";
6
6
  import { loadMarkup, loadStylesheet } from "./markup.js";
7
7
  // The skyline viewer's full-screen 3D mode: every mapped building, seen through the
8
8
  // drawing's own camera and framed as the viewer frames the drawing, so each tower stands
@@ -17,10 +17,12 @@ export function startSkyline3d({ root, frame, assets, signal, onShowOriginal, on
17
17
  // footprints control.
18
18
  ground.group.children.forEach((child) => { child.visible = false; });
19
19
  const { platform } = settings;
20
+ // The roads' grey at night, which the colour trial's day lightens and turning it off restores.
21
+ const nightRoads = 0x2c2c2c;
20
22
  const roads = createGeographicRoads({
21
23
  x: [platform.x - platform.width / 2, platform.x + platform.width / 2],
22
24
  z: [platform.z - platform.depth / 2, platform.z + platform.depth / 2],
23
- }, 0x2c2c2c);
25
+ }, nightRoads);
24
26
  const { azimuth } = drawingView;
25
27
  // The study renders its first frame as it starts; the scene says so once its controls,
26
28
  // menus and detail are wired too, at the end.
@@ -107,13 +109,16 @@ export function startSkyline3d({ root, frame, assets, signal, onShowOriginal, on
107
109
  viewer.requestRender();
108
110
  }, listening);
109
111
  }
110
- // The colour trial's toggle, pressed while the buildings show their materials' colours over
111
- // the greys, which it restores exactly when pressed again.
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.
112
114
  const colourButton = root.querySelector("#colour");
115
+ const daylight = createDaylight(), roadway = roads.material;
116
+ signal.addEventListener("abort", () => daylight.sky.dispose(), { once: true });
113
117
  colourButton.addEventListener("click", () => {
114
118
  colour.set(!colour.enabled);
119
+ roadway.color.setHex(colour.enabled ? daylightGround.roads : nightRoads);
120
+ viewer.setDaylight(colour.enabled ? daylight : null);
115
121
  colourButton.setAttribute("aria-pressed", String(colour.enabled));
116
- viewer.requestRender();
117
122
  }, listening);
118
123
  // The star in the middle of the bar opens and closes the toolbar. Each group unfolds from
119
124
  // the star: clipped open from its `--fold` inset while it fades in, one beside the star
@@ -105,8 +105,11 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
105
105
  controls.target.fromArray(target);
106
106
  controls.rotateSpeed = 0.65;
107
107
  controls.zoomSpeed = 0.7;
108
- scene.add(new THREE.AmbientLight(0xffffff, 0.7));
109
- const keyLight = new THREE.DirectionalLight(0xffffff, 2.4);
108
+ // The night's lights, which setDaylight(null) restores.
109
+ const night = { sun: [0xffffff, 2.4], ambient: [0xffffff, 0.7], fill: [0xffffff, 0.55] };
110
+ const ambientLight = new THREE.AmbientLight(...night.ambient);
111
+ scene.add(ambientLight);
112
+ const keyLight = new THREE.DirectionalLight(...night.sun);
110
113
  keyLight.position.fromArray(lightPosition);
111
114
  keyLight.target.position.set(0, 80, 0);
112
115
  keyLight.castShadow = true;
@@ -114,7 +117,7 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
114
117
  Object.assign(keyLight.shadow.camera, shadowCamera);
115
118
  keyLight.shadow.normalBias = 0.2;
116
119
  scene.add(keyLight, keyLight.target);
117
- const fillLight = new THREE.DirectionalLight(0xffffff, 0.55);
120
+ const fillLight = new THREE.DirectionalLight(...night.fill);
118
121
  fillLight.position.set(100, 140, -140);
119
122
  scene.add(fillLight);
120
123
  let modelOwners = new Map(models.map((model) => [model.building, model]));
@@ -123,6 +126,7 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
123
126
  const base = new THREE.Mesh(new THREE.BoxGeometry(platform.width, 2, platform.depth), new THREE.MeshToonMaterial({ color: platform.color ?? 0x3a3a3a }));
124
127
  base.position.set(platform.x || 0, -1.1, platform.z || 0);
125
128
  base.receiveShadow = true;
129
+ const nightGround = base.material.color.getHex();
126
130
  scene.add(base);
127
131
  const baseEdges = new THREE.LineSegments(new THREE.EdgesGeometry(base.geometry), new THREE.LineBasicMaterial({ color: 0x555555 }));
128
132
  baseEdges.position.copy(base.position);
@@ -662,5 +666,15 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
662
666
  window.__buildingStudy = api;
663
667
  Object.defineProperty(canvas, "__buildingStudy", { value: api, configurable: true });
664
668
  onReady();
665
- return { setLayout, setView, requestRender, buildingAt };
669
+ // Day or night: the transparent canvas shows the page's night behind it until a sky covers it.
670
+ function setDaylight(day) {
671
+ scene.background = day?.sky ?? null;
672
+ base.material.color.setHex(day?.ground ?? nightGround);
673
+ for (const [light, [colour, intensity]] of [[keyLight, day?.sun ?? night.sun], [ambientLight, day?.ambient ?? night.ambient], [fillLight, day?.fill ?? night.fill]]) {
674
+ light.color.setHex(colour);
675
+ light.intensity = intensity;
676
+ }
677
+ requestRender();
678
+ }
679
+ return { setLayout, setView, requestRender, buildingAt, setDaylight };
666
680
  }