@a2f0/skyline 0.2.2 → 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.
package/docs/package.md CHANGED
@@ -314,6 +314,10 @@ the selection and `litWindows` counts illuminated office panes. Each instance
314
314
  has its own state. Use the model from `createGeographicSkyline()` or
315
315
  `createGeographicBuilding()` for these displays.
316
316
 
317
+ `models/colour-materials` and `models/colour-palette` are an opt-in colour trial for the
318
+ viewer's colour toggle, published only because every model module is; they are not a stable
319
+ API and may change or disappear in any release.
320
+
317
321
  `@a2f0/skyline/models/building-kit` exposes the same plan runs and mesh builder
318
322
  used by this repository, for adding buildings. Coordinate and model assumptions
319
323
  are in [the geography notes](skyline-geography.md). Asset paths are also available
@@ -0,0 +1,39 @@
1
+ import type { BuildingModel } from "./building-kit.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];
5
+ export type Tones = Readonly<Record<number, MaterialName>>;
6
+ export type Batch = readonly [material: MaterialName, tones?: Tones];
7
+ export type BuildingMaterials = Readonly<Record<string, Batch>>;
8
+ export declare const colourMaterials: Readonly<Record<string, BuildingMaterials>>;
9
+ export declare function batchOf(buildingId: string, meshName: string): Batch | undefined;
10
+ export declare const toneOf: (grey: 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;
28
+ export interface SkylineColour {
29
+ /** Whether the buildings show their materials' colours. */
30
+ readonly enabled: boolean;
31
+ /** Shows the colours, or restores the greys exactly. */
32
+ set(enabled: boolean): void;
33
+ /**
34
+ * Recolours vertices another writer has since rewritten in grey, such as window
35
+ * illumination resetting a facade for a celebration.
36
+ */
37
+ refresh(): void;
38
+ }
39
+ export declare function createSkylineColour(models: readonly BuildingModel[]): SkylineColour;
@@ -0,0 +1,454 @@
1
+ import * as THREE from "../vendor/three-r186.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
+ ];
20
+ // A batch's lit and dim office windows.
21
+ const windows = (lit, dim) => ({ [lit]: "lit window", [dim]: "dim window" });
22
+ export const colourMaterials = {
23
+ "building-heritage-at-millennium-park": {
24
+ "tower and lower wing": ["green glass"],
25
+ "mapped ground footprint": ["concrete"],
26
+ "limestone frame": ["limestone"],
27
+ "bronze mullions and slab edges": ["bronze"],
28
+ "roof terraces": ["neutral"],
29
+ "mechanical penthouses": ["concrete"],
30
+ glazing: ["green glass", { 0x7a7a7a: "lit window" }],
31
+ // The four preserved Wabash facades.
32
+ "Wabash facade": ["buff brick"],
33
+ },
34
+ "building-kemper": {
35
+ "marble shell": ["marble"],
36
+ "mullions and bands": ["marble"],
37
+ "crown glazing": ["glass"],
38
+ "crown fins": ["aluminium"],
39
+ "roof cap": ["marble"],
40
+ "lit glazing": ["lit window"],
41
+ "dim glazing": ["dim window"],
42
+ glazing: ["glass"],
43
+ },
44
+ // Silver-reflective glass in white aluminium.
45
+ "building-crain-communications": {
46
+ "aluminum spandrels": ["aluminium"],
47
+ "ribbon glazing": ["glass", { ...windows(0x8a8a8a, 0x565656), 0x585858: "aluminium" }],
48
+ "sloped glazing": ["crown glass"],
49
+ "glazing grid": ["crown glass"],
50
+ "diamond outline lights": ["lamp"],
51
+ coping: ["aluminium"],
52
+ "slot floor": ["neutral"],
53
+ "mechanical louvers": ["aluminium", { 0x232323: "glass", 0x303030: "dark metal" }],
54
+ "louver blades": ["aluminium", { 0x232323: "glass" }],
55
+ "crown shadow strips": ["neutral", { 0x232323: "glass", 0x585858: "aluminium" }],
56
+ },
57
+ // Bronze-tinted glass and bronze spandrels, on a granite plaza.
58
+ "building-330-north-wabash": {
59
+ shell: ["bronze", { 0x3a3a3a: "neutral", 0x464646: "dark granite" }],
60
+ "glass, spandrels and louvers": ["bronze glass", { ...windows(0x7a7a7a, 0x4a4a4a), 0x262626: "bronze", 0x353535: "bronze" }],
61
+ "bronze mullions and columns": ["bronze"],
62
+ },
63
+ "building-michigan-plaza-south-tower": {
64
+ "mapped tower shell": ["glass"],
65
+ "mullions and bands": ["aluminium"],
66
+ "lit glazing": ["lit window"],
67
+ "dim glazing": ["dim window"],
68
+ glazing: ["glass"],
69
+ },
70
+ "building-trump-tower-only": {
71
+ "tower shell": ["glass"],
72
+ "glass and spandrels": ["glass", windows(0x7c7c7c, 0x505050)],
73
+ "stainless mullions": ["stainless"],
74
+ crown: ["glass", { 0x3c3c3c: "stainless" }],
75
+ "crown mullions": ["stainless"],
76
+ spire: ["stainless"],
77
+ },
78
+ // Limestone piers and aluminium spandrels, under the sign's board and letters.
79
+ "building-one-prudential-plaza": {
80
+ shell: ["limestone", { 0x242424: "dark metal", 0x4c4c4c: "neutral", 0x8e8e8e: "aluminium" }],
81
+ "windows and spandrels": ["glass", { ...windows(0x7c7c7c, 0x4e4e4e), 0x8a8a8a: "lit window", 0x242424: "dark metal", 0x8e8e8e: "aluminium" }],
82
+ "limestone piers": ["limestone"],
83
+ "limestone courses": ["limestone"],
84
+ "penthouse and sign": ["limestone", { 0x2e2e2e: "sign board", 0x383838: "dark metal", 0x4c4c4c: "neutral", 0xb0b0b0: "sign letters", 0xc4c4c4: "sign letters" }],
85
+ "screen louvers": ["aluminium"],
86
+ "antenna mast": ["stainless"],
87
+ },
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.
90
+ "building-two-prudential-plaza": {
91
+ "limestone, glass and crown": ["granite", {
92
+ 0x353535: "glass", 0x3a3a3a: "glass", 0x404040: "glass", 0x454545: "glass", 0x4a4a4a: "glass",
93
+ 0x3f3f3f: "crown glass", 0x565656: "crown glass", 0xcdcdcd: "crown lights",
94
+ ...windows(0x8e8e8e, 0x5e5e5e),
95
+ }],
96
+ piers: ["granite"],
97
+ copings: ["granite"],
98
+ "strip mullions": ["aluminium"],
99
+ "crown ribs": ["crown lights"],
100
+ spire: ["stainless"],
101
+ "spire inset panels": ["glass"],
102
+ lobby: ["glass"],
103
+ },
104
+ // Mount Airy white granite.
105
+ layer3: {
106
+ "tube shell": ["white granite", { 0x202020: "neutral", 0x4c4c4c: "neutral" }],
107
+ "window ribbons": ["glass", windows(0x7c7c7c, 0x4e4e4e)],
108
+ "granite piers": ["white granite"],
109
+ "granite cap": ["cap lights"],
110
+ "rooftop enclosure": ["neutral", { 0x9c9c9c: "aluminium" }],
111
+ "antenna mast": ["stainless"],
112
+ },
113
+ "building-blue-cross-blue-shield": {
114
+ shell: ["glass", { 0x3a3a3a: "neutral" }],
115
+ "glass, spandrels and bands": ["glass", { ...windows(0x8a8a8a, 0x5c5c5c), 0x2a2a2a: "neutral", 0x3e3e3e: "screen" }],
116
+ mullions: ["dark metal"],
117
+ "band columns and emblems": ["band lights"],
118
+ },
119
+ // Blue-green glass, and the south face's white concrete frame.
120
+ "building-340-on-the-park": {
121
+ shell: ["neutral"],
122
+ "curtain wall": ["green glass", { ...windows(0x7a7a7a, 0x4a4a4a), 0x242424: "neutral", 0x2a2a2a: "concrete", 0xb4b4b4: "concrete" }],
123
+ "concrete frame": ["concrete"],
124
+ "railings and guards": ["aluminium"],
125
+ },
126
+ // A concrete frame over bronze ribbon windows.
127
+ "building-the-buckingham": {
128
+ shell: ["neutral"],
129
+ "frame and glass": ["bronze glass", { ...windows(0x6e6e6e, 0x464646), 0x4c4c4c: "concrete" }],
130
+ piers: ["concrete"],
131
+ "corner balconies": ["concrete"],
132
+ },
133
+ // A grey precast slab.
134
+ "building-michigan-plaza-front-tall": {
135
+ shell: ["neutral"],
136
+ "walls and windows": ["glass", { ...windows(0x727272, 0x484848), 0x666666: "concrete" }],
137
+ },
138
+ // Buff limestone over a granite base.
139
+ "building-willoughby-tower": {
140
+ shell: ["neutral"],
141
+ "stone and windows": ["glass", { ...windows(0x6e6e6e, 0x444444), 0x4a4a4a: "dark granite", 0x585858: "limestone" }],
142
+ pinnacles: ["limestone"],
143
+ },
144
+ // Tan brick and terracotta.
145
+ "building-six-north-michigan": {
146
+ shell: ["neutral"],
147
+ "brick and windows": ["glass", { ...windows(0x8a8a8a, 0x5e5e5e), 0x6e6e6e: "buff brick", 0x9c9c9c: "white terracotta" }],
148
+ cornices: ["white terracotta"],
149
+ },
150
+ // A cream terracotta front, and common brick on its south wall.
151
+ "building-six-north-far-east": {
152
+ shell: ["neutral"],
153
+ "terracotta and windows": ["glass", {
154
+ ...windows(0x6e6e6e, 0x3a3a3a),
155
+ 0x363636: "white terracotta", 0x404040: "common brick", 0x444444: "white terracotta", 0x505050: "white terracotta", 0x585858: "white terracotta",
156
+ }],
157
+ parapet: ["white terracotta"],
158
+ },
159
+ // Brown masonry.
160
+ "building-michigan-plaza-front-middle": {
161
+ shell: ["neutral"],
162
+ "masonry and windows": ["glass", { ...windows(0x9a9a9a, 0x525252), 0x7a7a7a: "brown brick", 0x888888: "limestone" }],
163
+ },
164
+ // Buff limestone under a slate roof.
165
+ "building-university-club": {
166
+ shell: ["limestone", { 0x2e2e2e: "neutral", 0x343434: "slate", 0x4a4a4a: "neutral" }],
167
+ "stone and windows": ["glass", { ...windows(0x8c8c8c, 0x444444), 0xa4a4a4: "limestone", 0xb4b4b4: "limestone" }],
168
+ "merlons and pinnacles": ["limestone"],
169
+ },
170
+ // Pink terracotta over granite, under a green tile roof.
171
+ "building-monroe": {
172
+ shell: ["pink terracotta", { 0x2e2e2e: "neutral", 0x303030: "green tile", 0x484848: "neutral" }],
173
+ "terracotta and windows": ["glass", { ...windows(0x929292, 0x484848), 0x9a9a9a: "granite", 0xb2b2b2: "pink terracotta", 0xc2c2c2: "pink terracotta" }],
174
+ "attic windows": ["glass"],
175
+ },
176
+ "building-maclean-center": {
177
+ shell: ["neutral"],
178
+ "stone and windows": ["glass", { ...windows(0x6e6e6e, 0x3c3c3c), 0x242424: "neutral", 0x7e7e7e: "limestone", 0x8e8e8e: "limestone" }],
179
+ cornice: ["limestone"],
180
+ },
181
+ "building-lakeview": {
182
+ shell: ["neutral"],
183
+ "stone and windows": ["glass", { ...windows(0x6e6e6e, 0x3c3c3c), 0x767676: "limestone", 0x888888: "limestone" }],
184
+ },
185
+ // Terracotta over granite columns, round a light court of white brick.
186
+ "building-peoples-gas": {
187
+ shell: ["neutral", { 0x646464: "common brick", 0x9c9c9c: "glazed brick" }],
188
+ "terracotta and windows": ["glass", {
189
+ ...windows(0x6e6e6e, 0x3c3c3c),
190
+ 0x242424: "neutral", 0x5a5a5a: "dark granite", 0x7c7c7c: "white terracotta", 0x8e8e8e: "white terracotta", 0x9c9c9c: "glazed brick",
191
+ }],
192
+ cornice: ["white terracotta"],
193
+ },
194
+ // Natural aluminium mullions and blue porcelain-enamelled spandrels.
195
+ "building-200-south-michigan": {
196
+ shell: ["neutral"],
197
+ "curtain wall": ["glass", {
198
+ ...windows(0x7a7a7a, 0x4a4a4a),
199
+ 0x2d2d2d: "blue enamel", 0x525252: "aluminium", 0x585858: "aluminium", 0x5e5e5e: "aluminium", 0x6a6a6a: "aluminium",
200
+ }],
201
+ "rooftop block": ["neutral"],
202
+ penthouse: ["neutral"],
203
+ },
204
+ // White terracotta under a green copper roof.
205
+ "building-railway-exchange": {
206
+ shell: ["neutral", { 0x9c9c9c: "copper" }],
207
+ "terracotta and windows": ["glass", { ...windows(0x6e6e6e, 0x3c3c3c), 0x626262: "common brick", 0x808080: "white terracotta", 0x909090: "white terracotta" }],
208
+ cornice: ["white terracotta"],
209
+ },
210
+ // Sullivan's cream terracotta front, and common brick above its neighbours.
211
+ "building-michigan-west-right": {
212
+ shell: ["neutral"],
213
+ "terracotta and windows": ["glass", { ...windows(0x6e6e6e, 0x3a3a3a), 0x404040: "common brick", 0x484848: "white terracotta", 0x5e5e5e: "white terracotta" }],
214
+ cartouches: ["white terracotta"],
215
+ },
216
+ // Red brick fronts with stone bands.
217
+ "building-michigan-west-front": {
218
+ shell: ["neutral"],
219
+ "brick and windows": ["glass", { ...windows(0x6e6e6e, 0x3a3a3a), 0x464646: "brick", 0x525252: "limestone" }],
220
+ },
221
+ "building-30-south-michigan": {
222
+ shell: ["neutral"],
223
+ "brick and windows": ["glass", { ...windows(0x6e6e6e, 0x3a3a3a), 0x464646: "brick", 0x525252: "limestone" }],
224
+ },
225
+ // Limestone, and red and grey brick.
226
+ "building-chicago-athletic-association": {
227
+ shell: ["neutral"],
228
+ "stone, brick and windows": ["glass", { ...windows(0x6e6e6e, 0x3a3a3a), 0x5a5a5a: "brick", 0x6d6d6d: "limestone", 0x7a7a7a: "limestone" }],
229
+ cornice: ["limestone"],
230
+ },
231
+ // Mies's black curtain wall and bronze-tinted glass.
232
+ "building-office-west-of-aon": {
233
+ shell: ["neutral"],
234
+ "curtain wall": ["bronze glass", { ...windows(0x7a7a7a, 0x4a4a4a), 0x2b2b2b: "dark metal", 0x303030: "dark metal", 0x333333: "dark metal" }],
235
+ },
236
+ // White concrete.
237
+ "building-river-plaza": {
238
+ shell: ["concrete"],
239
+ "concrete and windows": ["glass", { ...windows(0x8a8a8a, 0x505050), 0x9a9a9a: "concrete" }],
240
+ "rooftop box": ["concrete"],
241
+ },
242
+ // Dark orange brick and bronze glass.
243
+ "building-hyatt-regency-west-tower": {
244
+ shell: ["neutral"],
245
+ "brick and windows": ["bronze glass", { ...windows(0x6d6d6d, 0x4a4a4a), 0x2c2c2c: "dark metal", 0x3b3b3b: "orange brick" }],
246
+ },
247
+ // Cream precast, and the drums' maroon fins.
248
+ "building-buckingham-east": {
249
+ shell: ["precast", { 0x686868: "neutral" }],
250
+ "precast and windows": ["glass", { ...windows(0x8a8a8a, 0x505050), 0x848484: "precast" }],
251
+ drums: ["precast", { 0x5a5a5a: "maroon", 0x686868: "neutral" }],
252
+ },
253
+ // Dark brown aluminium and bronze glass.
254
+ "building-three-illinois-center": {
255
+ shell: ["neutral"],
256
+ "curtain wall": ["bronze glass", { ...windows(0x707070, 0x464646), 0x282828: "bronze", 0x323232: "bronze" }],
257
+ },
258
+ // Reflective blue-green glass in its frame.
259
+ "building-swissotel": {
260
+ shell: ["neutral"],
261
+ "curtain wall": ["blue-green glass", windows(0x878787, 0x515151)],
262
+ },
263
+ };
264
+ // A batch's materials from its mesh's name, if the trial knows the building and the batch.
265
+ export function batchOf(buildingId, meshName) {
266
+ const materials = colourMaterials[buildingId];
267
+ const batch = meshName.split(" · ").at(-1);
268
+ return materials?.[batch] ?? materials?.[batch.replace(/ \d+$/, "")];
269
+ }
270
+ // A grey's tone: the hex the model wrote it as, from its linear channel.
271
+ export const toneOf = (grey) => new THREE.Color(grey, grey, grey).getHex();
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];
290
+ }
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;
309
+ }
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);
329
+ }
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
+ };
341
+ }
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.
346
+ export function createSkylineColour(models) {
347
+ const painted = [], flat = [], taken = new Set();
348
+ for (const model of models) {
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
+ };
360
+ model.building.traverse((object) => {
361
+ const mesh = object;
362
+ if (!mesh.isMesh)
363
+ return;
364
+ const batch = batchOf(buildingId, mesh.name);
365
+ if (!batch || Array.isArray(mesh.material) || !mesh.material.isMeshToonMaterial)
366
+ return;
367
+ const [base, tones = {}] = batch;
368
+ const material = mesh.material;
369
+ const materialOf = (grey) => tones[toneOf(grey)] ?? base;
370
+ const attribute = mesh.geometry.getAttribute("color");
371
+ if (material.vertexColors) {
372
+ if (!(attribute?.array instanceof Float32Array) || taken.has(attribute))
373
+ return;
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) {
394
+ const cache = new Map();
395
+ const paintGrey = (grey) => {
396
+ let colour = cache.get(grey);
397
+ if (!colour)
398
+ cache.set(grey, colour = Float32Array.from(colourOf(grey)));
399
+ return colour;
400
+ };
401
+ painted.push({ ...colours, paint: paintGrey, greys: null });
402
+ }
403
+ else {
404
+ flat.push({ material, grey: material.color.clone(), colour: new THREE.Color(...colourOf(material.color.r)) });
405
+ }
406
+ }
407
+ }
408
+ let enabled = false;
409
+ return {
410
+ get enabled() { return enabled; },
411
+ set(next) {
412
+ if (next === enabled)
413
+ return;
414
+ enabled = next;
415
+ for (const batch of painted) {
416
+ const { array } = batch;
417
+ if (next) {
418
+ const greys = batch.greys = new Float32Array(array.length / 3);
419
+ for (let vertex = 0; vertex < greys.length; vertex += 1) {
420
+ const grey = array[vertex * 3];
421
+ greys[vertex] = grey;
422
+ array.set(batch.paint(grey), vertex * 3);
423
+ }
424
+ }
425
+ else {
426
+ batch.greys.forEach((grey, vertex) => array.fill(grey, vertex * 3, vertex * 3 + 3));
427
+ batch.greys = null;
428
+ }
429
+ batch.attribute.needsUpdate = true;
430
+ }
431
+ for (const { material, grey, colour } of flat)
432
+ material.color.copy(next ? colour : grey);
433
+ },
434
+ refresh() {
435
+ if (!enabled)
436
+ return;
437
+ for (const batch of painted) {
438
+ const { array } = batch, greys = batch.greys;
439
+ let changed = false;
440
+ for (let vertex = 0; vertex < greys.length; vertex += 1) {
441
+ const colour = batch.paint(greys[vertex]), at = vertex * 3;
442
+ if (array[at] === colour[0] && array[at + 1] === colour[1] && array[at + 2] === colour[2])
443
+ continue;
444
+ const grey = array[at];
445
+ greys[vertex] = grey;
446
+ array.set(batch.paint(grey), at);
447
+ changed = true;
448
+ }
449
+ if (changed)
450
+ batch.attribute.needsUpdate = true;
451
+ }
452
+ },
453
+ };
454
+ }
@@ -0,0 +1,8 @@
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;
8
+ };
@@ -0,0 +1,62 @@
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
+ },
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.2",
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": {
@@ -0,0 +1,454 @@
1
+ import * as THREE from "../vendor/three-r186.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
+ ];
20
+ // A batch's lit and dim office windows.
21
+ const windows = (lit, dim) => ({ [lit]: "lit window", [dim]: "dim window" });
22
+ export const colourMaterials = {
23
+ "building-heritage-at-millennium-park": {
24
+ "tower and lower wing": ["green glass"],
25
+ "mapped ground footprint": ["concrete"],
26
+ "limestone frame": ["limestone"],
27
+ "bronze mullions and slab edges": ["bronze"],
28
+ "roof terraces": ["neutral"],
29
+ "mechanical penthouses": ["concrete"],
30
+ glazing: ["green glass", { 0x7a7a7a: "lit window" }],
31
+ // The four preserved Wabash facades.
32
+ "Wabash facade": ["buff brick"],
33
+ },
34
+ "building-kemper": {
35
+ "marble shell": ["marble"],
36
+ "mullions and bands": ["marble"],
37
+ "crown glazing": ["glass"],
38
+ "crown fins": ["aluminium"],
39
+ "roof cap": ["marble"],
40
+ "lit glazing": ["lit window"],
41
+ "dim glazing": ["dim window"],
42
+ glazing: ["glass"],
43
+ },
44
+ // Silver-reflective glass in white aluminium.
45
+ "building-crain-communications": {
46
+ "aluminum spandrels": ["aluminium"],
47
+ "ribbon glazing": ["glass", { ...windows(0x8a8a8a, 0x565656), 0x585858: "aluminium" }],
48
+ "sloped glazing": ["crown glass"],
49
+ "glazing grid": ["crown glass"],
50
+ "diamond outline lights": ["lamp"],
51
+ coping: ["aluminium"],
52
+ "slot floor": ["neutral"],
53
+ "mechanical louvers": ["aluminium", { 0x232323: "glass", 0x303030: "dark metal" }],
54
+ "louver blades": ["aluminium", { 0x232323: "glass" }],
55
+ "crown shadow strips": ["neutral", { 0x232323: "glass", 0x585858: "aluminium" }],
56
+ },
57
+ // Bronze-tinted glass and bronze spandrels, on a granite plaza.
58
+ "building-330-north-wabash": {
59
+ shell: ["bronze", { 0x3a3a3a: "neutral", 0x464646: "dark granite" }],
60
+ "glass, spandrels and louvers": ["bronze glass", { ...windows(0x7a7a7a, 0x4a4a4a), 0x262626: "bronze", 0x353535: "bronze" }],
61
+ "bronze mullions and columns": ["bronze"],
62
+ },
63
+ "building-michigan-plaza-south-tower": {
64
+ "mapped tower shell": ["glass"],
65
+ "mullions and bands": ["aluminium"],
66
+ "lit glazing": ["lit window"],
67
+ "dim glazing": ["dim window"],
68
+ glazing: ["glass"],
69
+ },
70
+ "building-trump-tower-only": {
71
+ "tower shell": ["glass"],
72
+ "glass and spandrels": ["glass", windows(0x7c7c7c, 0x505050)],
73
+ "stainless mullions": ["stainless"],
74
+ crown: ["glass", { 0x3c3c3c: "stainless" }],
75
+ "crown mullions": ["stainless"],
76
+ spire: ["stainless"],
77
+ },
78
+ // Limestone piers and aluminium spandrels, under the sign's board and letters.
79
+ "building-one-prudential-plaza": {
80
+ shell: ["limestone", { 0x242424: "dark metal", 0x4c4c4c: "neutral", 0x8e8e8e: "aluminium" }],
81
+ "windows and spandrels": ["glass", { ...windows(0x7c7c7c, 0x4e4e4e), 0x8a8a8a: "lit window", 0x242424: "dark metal", 0x8e8e8e: "aluminium" }],
82
+ "limestone piers": ["limestone"],
83
+ "limestone courses": ["limestone"],
84
+ "penthouse and sign": ["limestone", { 0x2e2e2e: "sign board", 0x383838: "dark metal", 0x4c4c4c: "neutral", 0xb0b0b0: "sign letters", 0xc4c4c4: "sign letters" }],
85
+ "screen louvers": ["aluminium"],
86
+ "antenna mast": ["stainless"],
87
+ },
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.
90
+ "building-two-prudential-plaza": {
91
+ "limestone, glass and crown": ["granite", {
92
+ 0x353535: "glass", 0x3a3a3a: "glass", 0x404040: "glass", 0x454545: "glass", 0x4a4a4a: "glass",
93
+ 0x3f3f3f: "crown glass", 0x565656: "crown glass", 0xcdcdcd: "crown lights",
94
+ ...windows(0x8e8e8e, 0x5e5e5e),
95
+ }],
96
+ piers: ["granite"],
97
+ copings: ["granite"],
98
+ "strip mullions": ["aluminium"],
99
+ "crown ribs": ["crown lights"],
100
+ spire: ["stainless"],
101
+ "spire inset panels": ["glass"],
102
+ lobby: ["glass"],
103
+ },
104
+ // Mount Airy white granite.
105
+ layer3: {
106
+ "tube shell": ["white granite", { 0x202020: "neutral", 0x4c4c4c: "neutral" }],
107
+ "window ribbons": ["glass", windows(0x7c7c7c, 0x4e4e4e)],
108
+ "granite piers": ["white granite"],
109
+ "granite cap": ["cap lights"],
110
+ "rooftop enclosure": ["neutral", { 0x9c9c9c: "aluminium" }],
111
+ "antenna mast": ["stainless"],
112
+ },
113
+ "building-blue-cross-blue-shield": {
114
+ shell: ["glass", { 0x3a3a3a: "neutral" }],
115
+ "glass, spandrels and bands": ["glass", { ...windows(0x8a8a8a, 0x5c5c5c), 0x2a2a2a: "neutral", 0x3e3e3e: "screen" }],
116
+ mullions: ["dark metal"],
117
+ "band columns and emblems": ["band lights"],
118
+ },
119
+ // Blue-green glass, and the south face's white concrete frame.
120
+ "building-340-on-the-park": {
121
+ shell: ["neutral"],
122
+ "curtain wall": ["green glass", { ...windows(0x7a7a7a, 0x4a4a4a), 0x242424: "neutral", 0x2a2a2a: "concrete", 0xb4b4b4: "concrete" }],
123
+ "concrete frame": ["concrete"],
124
+ "railings and guards": ["aluminium"],
125
+ },
126
+ // A concrete frame over bronze ribbon windows.
127
+ "building-the-buckingham": {
128
+ shell: ["neutral"],
129
+ "frame and glass": ["bronze glass", { ...windows(0x6e6e6e, 0x464646), 0x4c4c4c: "concrete" }],
130
+ piers: ["concrete"],
131
+ "corner balconies": ["concrete"],
132
+ },
133
+ // A grey precast slab.
134
+ "building-michigan-plaza-front-tall": {
135
+ shell: ["neutral"],
136
+ "walls and windows": ["glass", { ...windows(0x727272, 0x484848), 0x666666: "concrete" }],
137
+ },
138
+ // Buff limestone over a granite base.
139
+ "building-willoughby-tower": {
140
+ shell: ["neutral"],
141
+ "stone and windows": ["glass", { ...windows(0x6e6e6e, 0x444444), 0x4a4a4a: "dark granite", 0x585858: "limestone" }],
142
+ pinnacles: ["limestone"],
143
+ },
144
+ // Tan brick and terracotta.
145
+ "building-six-north-michigan": {
146
+ shell: ["neutral"],
147
+ "brick and windows": ["glass", { ...windows(0x8a8a8a, 0x5e5e5e), 0x6e6e6e: "buff brick", 0x9c9c9c: "white terracotta" }],
148
+ cornices: ["white terracotta"],
149
+ },
150
+ // A cream terracotta front, and common brick on its south wall.
151
+ "building-six-north-far-east": {
152
+ shell: ["neutral"],
153
+ "terracotta and windows": ["glass", {
154
+ ...windows(0x6e6e6e, 0x3a3a3a),
155
+ 0x363636: "white terracotta", 0x404040: "common brick", 0x444444: "white terracotta", 0x505050: "white terracotta", 0x585858: "white terracotta",
156
+ }],
157
+ parapet: ["white terracotta"],
158
+ },
159
+ // Brown masonry.
160
+ "building-michigan-plaza-front-middle": {
161
+ shell: ["neutral"],
162
+ "masonry and windows": ["glass", { ...windows(0x9a9a9a, 0x525252), 0x7a7a7a: "brown brick", 0x888888: "limestone" }],
163
+ },
164
+ // Buff limestone under a slate roof.
165
+ "building-university-club": {
166
+ shell: ["limestone", { 0x2e2e2e: "neutral", 0x343434: "slate", 0x4a4a4a: "neutral" }],
167
+ "stone and windows": ["glass", { ...windows(0x8c8c8c, 0x444444), 0xa4a4a4: "limestone", 0xb4b4b4: "limestone" }],
168
+ "merlons and pinnacles": ["limestone"],
169
+ },
170
+ // Pink terracotta over granite, under a green tile roof.
171
+ "building-monroe": {
172
+ shell: ["pink terracotta", { 0x2e2e2e: "neutral", 0x303030: "green tile", 0x484848: "neutral" }],
173
+ "terracotta and windows": ["glass", { ...windows(0x929292, 0x484848), 0x9a9a9a: "granite", 0xb2b2b2: "pink terracotta", 0xc2c2c2: "pink terracotta" }],
174
+ "attic windows": ["glass"],
175
+ },
176
+ "building-maclean-center": {
177
+ shell: ["neutral"],
178
+ "stone and windows": ["glass", { ...windows(0x6e6e6e, 0x3c3c3c), 0x242424: "neutral", 0x7e7e7e: "limestone", 0x8e8e8e: "limestone" }],
179
+ cornice: ["limestone"],
180
+ },
181
+ "building-lakeview": {
182
+ shell: ["neutral"],
183
+ "stone and windows": ["glass", { ...windows(0x6e6e6e, 0x3c3c3c), 0x767676: "limestone", 0x888888: "limestone" }],
184
+ },
185
+ // Terracotta over granite columns, round a light court of white brick.
186
+ "building-peoples-gas": {
187
+ shell: ["neutral", { 0x646464: "common brick", 0x9c9c9c: "glazed brick" }],
188
+ "terracotta and windows": ["glass", {
189
+ ...windows(0x6e6e6e, 0x3c3c3c),
190
+ 0x242424: "neutral", 0x5a5a5a: "dark granite", 0x7c7c7c: "white terracotta", 0x8e8e8e: "white terracotta", 0x9c9c9c: "glazed brick",
191
+ }],
192
+ cornice: ["white terracotta"],
193
+ },
194
+ // Natural aluminium mullions and blue porcelain-enamelled spandrels.
195
+ "building-200-south-michigan": {
196
+ shell: ["neutral"],
197
+ "curtain wall": ["glass", {
198
+ ...windows(0x7a7a7a, 0x4a4a4a),
199
+ 0x2d2d2d: "blue enamel", 0x525252: "aluminium", 0x585858: "aluminium", 0x5e5e5e: "aluminium", 0x6a6a6a: "aluminium",
200
+ }],
201
+ "rooftop block": ["neutral"],
202
+ penthouse: ["neutral"],
203
+ },
204
+ // White terracotta under a green copper roof.
205
+ "building-railway-exchange": {
206
+ shell: ["neutral", { 0x9c9c9c: "copper" }],
207
+ "terracotta and windows": ["glass", { ...windows(0x6e6e6e, 0x3c3c3c), 0x626262: "common brick", 0x808080: "white terracotta", 0x909090: "white terracotta" }],
208
+ cornice: ["white terracotta"],
209
+ },
210
+ // Sullivan's cream terracotta front, and common brick above its neighbours.
211
+ "building-michigan-west-right": {
212
+ shell: ["neutral"],
213
+ "terracotta and windows": ["glass", { ...windows(0x6e6e6e, 0x3a3a3a), 0x404040: "common brick", 0x484848: "white terracotta", 0x5e5e5e: "white terracotta" }],
214
+ cartouches: ["white terracotta"],
215
+ },
216
+ // Red brick fronts with stone bands.
217
+ "building-michigan-west-front": {
218
+ shell: ["neutral"],
219
+ "brick and windows": ["glass", { ...windows(0x6e6e6e, 0x3a3a3a), 0x464646: "brick", 0x525252: "limestone" }],
220
+ },
221
+ "building-30-south-michigan": {
222
+ shell: ["neutral"],
223
+ "brick and windows": ["glass", { ...windows(0x6e6e6e, 0x3a3a3a), 0x464646: "brick", 0x525252: "limestone" }],
224
+ },
225
+ // Limestone, and red and grey brick.
226
+ "building-chicago-athletic-association": {
227
+ shell: ["neutral"],
228
+ "stone, brick and windows": ["glass", { ...windows(0x6e6e6e, 0x3a3a3a), 0x5a5a5a: "brick", 0x6d6d6d: "limestone", 0x7a7a7a: "limestone" }],
229
+ cornice: ["limestone"],
230
+ },
231
+ // Mies's black curtain wall and bronze-tinted glass.
232
+ "building-office-west-of-aon": {
233
+ shell: ["neutral"],
234
+ "curtain wall": ["bronze glass", { ...windows(0x7a7a7a, 0x4a4a4a), 0x2b2b2b: "dark metal", 0x303030: "dark metal", 0x333333: "dark metal" }],
235
+ },
236
+ // White concrete.
237
+ "building-river-plaza": {
238
+ shell: ["concrete"],
239
+ "concrete and windows": ["glass", { ...windows(0x8a8a8a, 0x505050), 0x9a9a9a: "concrete" }],
240
+ "rooftop box": ["concrete"],
241
+ },
242
+ // Dark orange brick and bronze glass.
243
+ "building-hyatt-regency-west-tower": {
244
+ shell: ["neutral"],
245
+ "brick and windows": ["bronze glass", { ...windows(0x6d6d6d, 0x4a4a4a), 0x2c2c2c: "dark metal", 0x3b3b3b: "orange brick" }],
246
+ },
247
+ // Cream precast, and the drums' maroon fins.
248
+ "building-buckingham-east": {
249
+ shell: ["precast", { 0x686868: "neutral" }],
250
+ "precast and windows": ["glass", { ...windows(0x8a8a8a, 0x505050), 0x848484: "precast" }],
251
+ drums: ["precast", { 0x5a5a5a: "maroon", 0x686868: "neutral" }],
252
+ },
253
+ // Dark brown aluminium and bronze glass.
254
+ "building-three-illinois-center": {
255
+ shell: ["neutral"],
256
+ "curtain wall": ["bronze glass", { ...windows(0x707070, 0x464646), 0x282828: "bronze", 0x323232: "bronze" }],
257
+ },
258
+ // Reflective blue-green glass in its frame.
259
+ "building-swissotel": {
260
+ shell: ["neutral"],
261
+ "curtain wall": ["blue-green glass", windows(0x878787, 0x515151)],
262
+ },
263
+ };
264
+ // A batch's materials from its mesh's name, if the trial knows the building and the batch.
265
+ export function batchOf(buildingId, meshName) {
266
+ const materials = colourMaterials[buildingId];
267
+ const batch = meshName.split(" · ").at(-1);
268
+ return materials?.[batch] ?? materials?.[batch.replace(/ \d+$/, "")];
269
+ }
270
+ // A grey's tone: the hex the model wrote it as, from its linear channel.
271
+ export const toneOf = (grey) => new THREE.Color(grey, grey, grey).getHex();
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];
290
+ }
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;
309
+ }
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);
329
+ }
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
+ };
341
+ }
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.
346
+ export function createSkylineColour(models) {
347
+ const painted = [], flat = [], taken = new Set();
348
+ for (const model of models) {
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
+ };
360
+ model.building.traverse((object) => {
361
+ const mesh = object;
362
+ if (!mesh.isMesh)
363
+ return;
364
+ const batch = batchOf(buildingId, mesh.name);
365
+ if (!batch || Array.isArray(mesh.material) || !mesh.material.isMeshToonMaterial)
366
+ return;
367
+ const [base, tones = {}] = batch;
368
+ const material = mesh.material;
369
+ const materialOf = (grey) => tones[toneOf(grey)] ?? base;
370
+ const attribute = mesh.geometry.getAttribute("color");
371
+ if (material.vertexColors) {
372
+ if (!(attribute?.array instanceof Float32Array) || taken.has(attribute))
373
+ return;
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) {
394
+ const cache = new Map();
395
+ const paintGrey = (grey) => {
396
+ let colour = cache.get(grey);
397
+ if (!colour)
398
+ cache.set(grey, colour = Float32Array.from(colourOf(grey)));
399
+ return colour;
400
+ };
401
+ painted.push({ ...colours, paint: paintGrey, greys: null });
402
+ }
403
+ else {
404
+ flat.push({ material, grey: material.color.clone(), colour: new THREE.Color(...colourOf(material.color.r)) });
405
+ }
406
+ }
407
+ }
408
+ let enabled = false;
409
+ return {
410
+ get enabled() { return enabled; },
411
+ set(next) {
412
+ if (next === enabled)
413
+ return;
414
+ enabled = next;
415
+ for (const batch of painted) {
416
+ const { array } = batch;
417
+ if (next) {
418
+ const greys = batch.greys = new Float32Array(array.length / 3);
419
+ for (let vertex = 0; vertex < greys.length; vertex += 1) {
420
+ const grey = array[vertex * 3];
421
+ greys[vertex] = grey;
422
+ array.set(batch.paint(grey), vertex * 3);
423
+ }
424
+ }
425
+ else {
426
+ batch.greys.forEach((grey, vertex) => array.fill(grey, vertex * 3, vertex * 3 + 3));
427
+ batch.greys = null;
428
+ }
429
+ batch.attribute.needsUpdate = true;
430
+ }
431
+ for (const { material, grey, colour } of flat)
432
+ material.color.copy(next ? colour : grey);
433
+ },
434
+ refresh() {
435
+ if (!enabled)
436
+ return;
437
+ for (const batch of painted) {
438
+ const { array } = batch, greys = batch.greys;
439
+ let changed = false;
440
+ for (let vertex = 0; vertex < greys.length; vertex += 1) {
441
+ const colour = batch.paint(greys[vertex]), at = vertex * 3;
442
+ if (array[at] === colour[0] && array[at + 1] === colour[1] && array[at + 2] === colour[2])
443
+ continue;
444
+ const grey = array[at];
445
+ greys[vertex] = grey;
446
+ array.set(batch.paint(grey), at);
447
+ changed = true;
448
+ }
449
+ if (changed)
450
+ batch.attribute.needsUpdate = true;
451
+ }
452
+ },
453
+ };
454
+ }
@@ -0,0 +1,62 @@
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
+ },
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,6 +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="Colour trial: a sunny day, each measured building in its photographed colours; the rest stay grey">colour</button>
56
57
  <button type="button" id="turntable" aria-pressed="false" aria-describedby="motion-status">turntable</button>
57
58
  <button type="button" id="show-original" title="Compare with the original skyline SVG" hidden>show original</button>
58
59
  <div class="celebration-controls" id="celebration-controls" role="group" aria-label="Celebratory window lighting">
@@ -2,6 +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 { createDaylight, createSkylineColour, daylightGround } from "./models/colour-materials.js";
5
6
  import { loadMarkup, loadStylesheet } from "./markup.js";
6
7
  // The skyline viewer's full-screen 3D mode: every mapped building, seen through the
7
8
  // drawing's own camera and framed as the viewer frames the drawing, so each tower stands
@@ -16,10 +17,12 @@ export function startSkyline3d({ root, frame, assets, signal, onShowOriginal, on
16
17
  // footprints control.
17
18
  ground.group.children.forEach((child) => { child.visible = false; });
18
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;
19
22
  const roads = createGeographicRoads({
20
23
  x: [platform.x - platform.width / 2, platform.x + platform.width / 2],
21
24
  z: [platform.z - platform.depth / 2, platform.z + platform.depth / 2],
22
- }, 0x2c2c2c);
25
+ }, nightRoads);
23
26
  const { azimuth } = drawingView;
24
27
  // The study renders its first frame as it starts; the scene says so once its controls,
25
28
  // menus and detail are wired too, at the end.
@@ -55,6 +58,8 @@ export function startSkyline3d({ root, frame, assets, signal, onShowOriginal, on
55
58
  enablePan: true,
56
59
  minimumCameraHeight: 1,
57
60
  });
61
+ // The colour trial's layer (models/colour-materials.ts), which the colour toggle turns on.
62
+ const colour = createSkylineColour(models);
58
63
  // A toolbar badge applies one celebration across the skyline, using each
59
64
  // building's own supported wording. Context menus change only their building.
60
65
  const illuminated = models.filter((model) => model.illumination);
@@ -63,6 +68,7 @@ export function startSkyline3d({ root, frame, assets, signal, onShowOriginal, on
63
68
  const celebrationButtons = [...root.querySelectorAll("[data-celebration]")];
64
69
  function setCelebration(targets, next) {
65
70
  targets.forEach((model) => model.illumination.set(model.illumination.presets.some(({ id }) => id === next) ? next : null));
71
+ colour.refresh();
66
72
  for (const button of celebrationButtons) {
67
73
  const eligible = illuminated.filter((model) => model.illumination.presets.some(({ id }) => id === button.dataset["celebration"]));
68
74
  const active = eligible.filter((model) => model.illumination.active === button.dataset["celebration"]).length;
@@ -103,6 +109,17 @@ export function startSkyline3d({ root, frame, assets, signal, onShowOriginal, on
103
109
  viewer.requestRender();
104
110
  }, listening);
105
111
  }
112
+ // The colour trial's toggle, pressed while the scene shows a sunny day, the measured buildings in
113
+ // their colours over the greys; pressed again, it restores the night and every grey exactly.
114
+ 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);
119
+ roadway.color.setHex(colour.enabled ? daylightGround.roads : nightRoads);
120
+ viewer.setDaylight(colour.enabled ? daylight : null);
121
+ colourButton.setAttribute("aria-pressed", String(colour.enabled));
122
+ }, listening);
106
123
  // The star in the middle of the bar opens and closes the toolbar. Each group unfolds from
107
124
  // the star: clipped open from its `--fold` inset while it fades in, one beside the star
108
125
  // 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
@@ -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
  }