@a2f0/skyline 0.2.18 → 0.2.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,6 +38,8 @@ const messageTopFloors = zones.map(([, , count], zone) => h.floors.slice(0, zone
38
38
  + Math.floor((count + 5) / 2) - 1).reverse().slice(0, 3);
39
39
  export const blueCrossFloors = [...zones.flatMap(([from, to, count]) => Array.from({ length: count }, (_, i) => from + (to - from) * i / count)), h.occupied];
40
40
  const spandrelBelow = 0.3, spandrelAbove = 0.9;
41
+ const infillFront = 0.52, emblemClearance = 0.1;
42
+ const transom = { height: 0.18, back: 0.17, front: 0.22, cornerClearance: 0.25 };
41
43
  const color = (hex) => new THREE.Color(hex);
42
44
  const glassTones = [0x414141, 0x464646, 0x3c3c3c, 0x4a4a4a].map(color);
43
45
  const litGlass = color(0x8a8a8a), dimGlass = color(0x5c5c5c);
@@ -75,12 +77,30 @@ const rows = [];
75
77
  push(h.roof, h.top, "screen", -1);
76
78
  }
77
79
  const rowsBetween = (lo, hi) => rows.filter((row) => row.lo >= lo - 1e-6 && row.hi <= hi + 1e-6);
80
+ // The end wings carry their curtain wall through the lower and upper bands.
81
+ // Only the middle level is an open setback. Subdivide the old band backing into
82
+ // normal glazing/spandrel courses on the wings and exposed short block returns.
83
+ // FID-BCBS-005: 2012 oblique photographs, docs/blue-cross-reference.md.
84
+ const glazedBandRows = (cuts) => cuts.flatMap((row) => {
85
+ if (row.kind !== "band")
86
+ return [row];
87
+ const band = h.bands.find(([, hi]) => hi === row.hi);
88
+ const count = band === h.bands[1] ? 3 : 2;
89
+ return Array.from({ length: count }, (_, i) => {
90
+ const lo = row.lo + (row.hi - row.lo) * i / count;
91
+ const hi = row.lo + (row.hi - row.lo) * (i + 1) / count;
92
+ return [{ lo, hi: lo + 0.8, kind: "spandrel", floor: -1 }, { lo: lo + 0.8, hi, kind: "glass", floor: -1 }];
93
+ }).flat();
94
+ });
78
95
  export function createBlueCrossGeographicBuilding(record, projectPlan, offset) {
79
96
  const kit = createBuilder(record.name, record.id);
80
97
  const shell = kit.batch("Blue Cross · shell", kit.material(0xffffff, { vertexColors: true }));
81
98
  const wall = kit.batch("Blue Cross · glass, spandrels and bands", kit.material(0xffffff, { vertexColors: true }));
82
99
  const mullions = kit.batch("Blue Cross · mullions", kit.material(0x2c2c2c));
83
100
  const bars = kit.batch("Blue Cross · band columns and emblems", kit.material(0xb4b4b4));
101
+ const infill = kit.batch("Blue Cross · band infill", kit.material(0xffffff, { vertexColors: true }));
102
+ const transoms = kit.batch("Blue Cross · transoms", kit.material(0xb4b4b4));
103
+ const aluminium = color(0xb4b4b4);
84
104
  const windows = [];
85
105
  let windowColumns = 0;
86
106
  const outline = (way) => orient(polygonOf(projectPlan(record.parts.find((part) => part.way === way).coordinates)));
@@ -130,10 +150,13 @@ export function createBlueCrossGeographicBuilding(record, projectPlan, offset) {
130
150
  // every row, and mullions one module apart on the office floors. Each unbroken stretch of
131
151
  // kept runs along a chain is one box, held 2 cm clear of its ends.
132
152
  const skin = (corners, lo, hi, seed, keep, lift = 0) => {
133
- const plan = planOf(corners), cuts = rowsBetween(lo, hi);
134
- const heights = [cuts[0].lo + lift, ...cuts.map((row) => row.hi)];
153
+ const plan = planOf(corners);
135
154
  const top = Math.min(hi, h.roof) - 0.05;
136
155
  chainsOf(plan).forEach((chain, index) => {
156
+ const shortFace = Math.abs(plan[chain.runs[0]].normal(0)[0]) > 0.9;
157
+ const sideGlazing = seed !== 0 || shortFace;
158
+ const cuts = sideGlazing ? glazedBandRows(rowsBetween(lo, hi)) : rowsBetween(lo, hi);
159
+ const heights = [cuts[0].lo + lift, ...cuts.map((row) => row.hi)];
137
160
  const count = Math.max(1, Math.round(chain.length / h.module));
138
161
  const stations = Array.from({ length: count + 1 }, (_, i) => chain.length * i / count);
139
162
  const messageFace = seed === 0 && plan[chain.runs[0]].normal(0)[1] > 0.9 && chain.length > 50;
@@ -160,6 +183,21 @@ export function createBlueCrossGeographicBuilding(record, projectPlan, offset) {
160
183
  return screen;
161
184
  return spandrel;
162
185
  }, spandrel);
186
+ if (sideGlazing) {
187
+ // Close each rail as a thin box following the mapped chain. Keep rails
188
+ // off the skin's top/bottom planes and ahead of its vertical mullions.
189
+ // Leave corner clearance for rails on adjoining/reentrant faces.
190
+ const railStart = s0 + transom.cornerClearance, railEnd = s1 - transom.cornerClearance;
191
+ const railK0 = chain.starts.findLastIndex((s) => s <= railStart);
192
+ const railK1 = chain.starts.findLastIndex((s) => s < railEnd);
193
+ for (const row of cuts.filter(({ kind }) => kind === "glass")) {
194
+ for (const [bottom, top] of [[row.lo - transom.height, row.lo], [row.hi, row.hi + transom.height]]) {
195
+ if (railEnd <= railStart || bottom <= heights[0] || top >= hi)
196
+ continue;
197
+ chainSkin(kit, transoms, plan, chain, railK0, railK1, railStart, railEnd, [], [bottom, top], transom.back, transom.front, () => aluminium, aluminium);
198
+ }
199
+ }
200
+ }
163
201
  // Mullions on each office run, whole on one run and held clear of its ends. Their
164
202
  // backs stand a centimetre off the wall: on the block's side walls the middle band's
165
203
  // mullions start just inside the end bays, whose closing walls lie in that plane and
@@ -197,20 +235,22 @@ export function createBlueCrossGeographicBuilding(record, projectPlan, offset) {
197
235
  skin(bay, h.lobbyTop, middle[0], i + 1, (run) => !lies(run, block));
198
236
  skin(bay, middle[1], h.wings, i + 1, (run) => !lies(run, block));
199
237
  });
200
- // An emblem: a plate standing 7 to 30 cm off the screen, cut to an outline given across and
238
+ // An emblem: a plate standing ahead of the screen infill, cut to an outline given across and
201
239
  // up from its centre. Its faces fan from the centre, which sees the whole outline, and its
202
240
  // sides follow the outline's edges.
203
241
  const emblem = (centre, t, n, y, shape) => {
242
+ // The corrected screen panels stand ahead of the old backing plane.
204
243
  const point = ([u, v], depth) => [centre[0] + t[0] * u + n[0] * depth, y + v, centre[1] + t[1] * u + n[1] * depth];
244
+ const backDepth = infillFront + emblemClearance, frontDepth = backDepth + 0.23;
205
245
  const out = [n[0], 0, n[1]], back = [-n[0], 0, -n[1]];
206
246
  shape.forEach((p, i) => {
207
247
  const q = shape[(i + 1) % shape.length];
208
- kit.triangle(bars, [point([0, 0], 0.3), point(p, 0.3), point(q, 0.3)], [out, out, out]);
209
- kit.triangle(bars, [point([0, 0], 0.07), point(q, 0.07), point(p, 0.07)], [back, back, back]);
248
+ kit.triangle(bars, [point([0, 0], frontDepth), point(p, frontDepth), point(q, frontDepth)], [out, out, out]);
249
+ kit.triangle(bars, [point([0, 0], backDepth), point(q, backDepth), point(p, backDepth)], [back, back, back]);
210
250
  // The edge's side faces away from the centre.
211
251
  const sign = Math.sign((q[1] - p[1]) * (p[0] + q[0]) - (q[0] - p[0]) * (p[1] + q[1])), length = Math.hypot(q[0] - p[0], q[1] - p[1]);
212
252
  const [across, up] = [sign * (q[1] - p[1]) / length, -sign * (q[0] - p[0]) / length];
213
- kit.quad(bars, [point(p, 0.07), point(q, 0.07), point(q, 0.3), point(p, 0.3)], [[t[0] * across, up, t[1] * across]]);
253
+ kit.quad(bars, [point(p, backDepth), point(q, backDepth), point(q, frontDepth), point(p, frontDepth)], [[t[0] * across, up, t[1] * across]]);
214
254
  });
215
255
  };
216
256
  // The company's emblems, 4.2 m tall: a cross, its arms a third of its span, and a shield,
@@ -218,8 +258,8 @@ export function createBlueCrossGeographicBuilding(record, projectPlan, offset) {
218
258
  const cross = [[0.7, 2.1], [0.7, 0.7], [2.1, 0.7], [2.1, -0.7], [0.7, -0.7], [0.7, -2.1],
219
259
  [-0.7, -2.1], [-0.7, -0.7], [-2.1, -0.7], [-2.1, 0.7], [-0.7, 0.7], [-0.7, 2.1]];
220
260
  const shield = [[-1.8, 2.1], [1.8, 2.1], [1.8, 0.3], [1.6, -0.7], [1.0, -1.5], [0, -2.1], [-1.0, -1.5], [-1.6, -0.7], [-1.8, 0.3]];
221
- // The block's columns, exposed in each band and in the screen on its long faces: eight
222
- // across each, one to a structural bay. The emblems stand in the screen's first two
261
+ // The block's columns, exposed in each band and in the screen on its long faces:
262
+ // eight south, six north. The emblems stand in the screen's first two south
223
263
  // bays at the south face's west end, the cross west of the shield.
224
264
  const faces = chainsOf(planOf(block)).filter(({ length }) => length > 50);
225
265
  faces.forEach((chain) => {
@@ -227,14 +267,60 @@ export function createBlueCrossGeographicBuilding(record, projectPlan, offset) {
227
267
  const a = run.at(0), b = last.at(last.length), n = run.normal(0), length = Math.hypot(b[0] - a[0], b[1] - a[1]);
228
268
  const t = [(b[0] - a[0]) / length, (b[1] - a[1]) / length];
229
269
  const at = (s) => [a[0] + t[0] * s, a[1] + t[1] * s];
230
- const pitch = length / 8;
231
- for (let i = 0; i < 8; i += 1) {
270
+ const slotCount = n[1] > 0.9 ? 8 : 6, pitch = length / slotCount;
271
+ for (let i = 0; i < slotCount; i += 1) {
232
272
  for (const [lo, hi] of [...h.bands, [h.roof + 0.4, h.top - 0.3]])
233
273
  kit.box(bars, at((i + 0.5) * pitch), n, 0.45, 0.07, 0.4, lo + 0.1, hi - 0.1);
234
274
  }
235
- // The south face runs west to east; the emblems stand in its first two bays.
236
- if (n[1] > 0.9)
237
- [cross, shield].forEach((shape, i) => emblem(at((i + 1) * pitch), t, n, h.roof + 6.7, shape));
275
+ if (Math.abs(n[1]) > 0.9) {
276
+ // FID-BCBS-002/005: the long elevations have narrow column slots,
277
+ // with curtain-wall panels between them, not an uninterrupted dark band.
278
+ // The northeast photograph resolves six on the north crown. The five
279
+ // internal north atrium bays alone do not establish facade spacing.
280
+ const panel = (target, s0, s1, levels, columns, back, front, paint, sides) => {
281
+ const k0 = chain.starts.findLastIndex((s) => s <= s0);
282
+ const k1 = chain.starts.findLastIndex((s) => s < s1);
283
+ chainSkin(kit, target, plan, chain, k0, k1, s0, s1, columns, levels, back, front, paint, sides);
284
+ };
285
+ // Two, three and two glazed rows in the mechanical bands; nine shallow
286
+ // courses in the crown. The 1.9 m slots and thin framing are photo ratios,
287
+ // not surveyed dimensions (docs/blue-cross-reference.md).
288
+ const bands = [
289
+ ...h.bands.map(([lo, hi], i) => [lo, hi, i === 1 ? 3 : 2, false]),
290
+ [h.roof, h.top, 9, true],
291
+ ];
292
+ for (const [lo, hi, count, crown] of bands) {
293
+ const levels = [lo + 0.02], paints = [];
294
+ const append = (height, paint) => { levels.push(height); paints.push(paint); };
295
+ append(lo + 0.18, aluminium);
296
+ for (let row = 1; row < count; row += 1) {
297
+ const y = lo + (hi - lo) * row / count;
298
+ append(y - (crown ? 0.08 : 0.55), crown ? screen : glassTones[1]);
299
+ if (!crown) {
300
+ append(y - 0.4, aluminium);
301
+ append(y + 0.4, spandrel);
302
+ }
303
+ append(y + (crown ? 0.08 : 0.55), aluminium);
304
+ }
305
+ append(hi - 0.18, crown ? screen : glassTones[1]);
306
+ append(hi - 0.02, aluminium);
307
+ for (let bay = 0; bay <= slotCount; bay += 1) {
308
+ const s0 = bay === 0 ? 0.02 : (bay - 0.5) * pitch + 0.95;
309
+ const s1 = bay === slotCount ? chain.length - 0.02 : (bay + 0.5) * pitch - 0.95;
310
+ panel(infill, s0, s1, levels, [s0, s0 + 0.12, s1 - 0.12, s1], 0.2, infillFront, (cell, row) => cell === 1 || cell === 3 ? aluminium : paints[row], aluminium);
311
+ }
312
+ }
313
+ // Pale rails frame the office ribbons. They sit just ahead of the vertical
314
+ // mullions and never cross the exposed column slots or celebration panes.
315
+ for (const row of rows.filter(({ kind }) => kind === "glass")) {
316
+ for (const [lo, hi] of [[row.lo - transom.height, row.lo], [row.hi, row.hi + transom.height]]) {
317
+ panel(transoms, 0.02, chain.length - 0.02, [lo, hi], [], transom.back, transom.front, () => aluminium, aluminium);
318
+ }
319
+ }
320
+ // The south face runs west to east; the emblems stand in its first two bays.
321
+ if (n[1] > 0.9)
322
+ [cross, shield].forEach((shape, i) => emblem(at((i + 1) * pitch), t, n, h.roof + 6.7, shape));
323
+ }
238
324
  });
239
325
  const model = kit.finish({ height: h.top, outlines: [shell, mullions], opacity: 0.16 });
240
326
  const glass = model.building.getObjectByName(wall.name);
@@ -113,10 +113,12 @@ export const colourMaterials = {
113
113
  "antenna mast": ["stainless"],
114
114
  },
115
115
  "building-blue-cross-blue-shield": {
116
- shell: ["glass", { 0x3a3a3a: "neutral" }],
117
- "glass, spandrels and bands": ["glass", { ...windows(0x8a8a8a, 0x5c5c5c), 0x2a2a2a: "neutral", 0x3e3e3e: "screen" }],
116
+ shell: ["glass", { 0x3a3a3a: "neutral", 0x383838: "opaque spandrel" }],
117
+ "glass, spandrels and bands": ["glass", { ...windows(0x8a8a8a, 0x5c5c5c), 0x383838: "opaque spandrel", 0x2a2a2a: "neutral", 0x3e3e3e: "screen" }],
118
118
  mullions: ["dark metal"],
119
119
  "band columns and emblems": ["band lights"],
120
+ "band infill": ["glass", { 0x383838: "opaque spandrel", 0x3e3e3e: "screen", 0xb4b4b4: "aluminium" }],
121
+ transoms: ["aluminium"],
120
122
  },
121
123
  // Blue-green glass, and the south face's white concrete frame.
122
124
  "building-340-on-the-park": {
@@ -271,7 +273,7 @@ export function batchOf(buildingId, meshName) {
271
273
  }
272
274
  // A grey's tone: the hex the model wrote it as, from its linear channel.
273
275
  export const toneOf = (grey) => new THREE.Color(grey, grey, grey).getHex();
274
- // Historical comparisons below predate sky reflections and FID-KEM-002/003.
276
+ // Historical comparisons below predate sky reflections, FID-KEM-002/003 and FID-BCBS-003.
275
277
  // The measured colours scale to the scene by this factor in linear light, so the skyline view's
276
278
  // faces show about the photograph's brightness under the day's lights. At 1.4 the skyline view's
277
279
  // 1600 x 900 render, its pixels chosen by each photograph sample's rule, reaches 0.97 (Two
@@ -62,8 +62,14 @@ export const measuredColours = {
62
62
  // docs/blue-cross-reference.md, 2026-10-09 — Daytime colours from Chicago.jpg and a close-up.
63
63
  "building-blue-cross-blue-shield": {
64
64
  ...glass(0x7d8992),
65
+ // FID-BCBS-003: the panorama mixes panes and opaque spandrels. Retain its
66
+ // measured hue provisionally for both, keeping their grey contrast and
67
+ // separate reflection masks through the shared glass swatch.
68
+ "opaque spandrel": "glass",
65
69
  screen: 0x334a60,
66
70
  "band lights": 0xb0bdcd,
71
+ // Inferred same pale metal as the columns; independent calibration is open.
72
+ aluminium: "band lights",
67
73
  "dark metal": null,
68
74
  neutral: null,
69
75
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2f0/skyline",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
4
4
  "description": "Interactive Chicago skyline viewer and reusable Three.js building models",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -38,6 +38,8 @@ const messageTopFloors = zones.map(([, , count], zone) => h.floors.slice(0, zone
38
38
  + Math.floor((count + 5) / 2) - 1).reverse().slice(0, 3);
39
39
  export const blueCrossFloors = [...zones.flatMap(([from, to, count]) => Array.from({ length: count }, (_, i) => from + (to - from) * i / count)), h.occupied];
40
40
  const spandrelBelow = 0.3, spandrelAbove = 0.9;
41
+ const infillFront = 0.52, emblemClearance = 0.1;
42
+ const transom = { height: 0.18, back: 0.17, front: 0.22, cornerClearance: 0.25 };
41
43
  const color = (hex) => new THREE.Color(hex);
42
44
  const glassTones = [0x414141, 0x464646, 0x3c3c3c, 0x4a4a4a].map(color);
43
45
  const litGlass = color(0x8a8a8a), dimGlass = color(0x5c5c5c);
@@ -75,12 +77,30 @@ const rows = [];
75
77
  push(h.roof, h.top, "screen", -1);
76
78
  }
77
79
  const rowsBetween = (lo, hi) => rows.filter((row) => row.lo >= lo - 1e-6 && row.hi <= hi + 1e-6);
80
+ // The end wings carry their curtain wall through the lower and upper bands.
81
+ // Only the middle level is an open setback. Subdivide the old band backing into
82
+ // normal glazing/spandrel courses on the wings and exposed short block returns.
83
+ // FID-BCBS-005: 2012 oblique photographs, docs/blue-cross-reference.md.
84
+ const glazedBandRows = (cuts) => cuts.flatMap((row) => {
85
+ if (row.kind !== "band")
86
+ return [row];
87
+ const band = h.bands.find(([, hi]) => hi === row.hi);
88
+ const count = band === h.bands[1] ? 3 : 2;
89
+ return Array.from({ length: count }, (_, i) => {
90
+ const lo = row.lo + (row.hi - row.lo) * i / count;
91
+ const hi = row.lo + (row.hi - row.lo) * (i + 1) / count;
92
+ return [{ lo, hi: lo + 0.8, kind: "spandrel", floor: -1 }, { lo: lo + 0.8, hi, kind: "glass", floor: -1 }];
93
+ }).flat();
94
+ });
78
95
  export function createBlueCrossGeographicBuilding(record, projectPlan, offset) {
79
96
  const kit = createBuilder(record.name, record.id);
80
97
  const shell = kit.batch("Blue Cross · shell", kit.material(0xffffff, { vertexColors: true }));
81
98
  const wall = kit.batch("Blue Cross · glass, spandrels and bands", kit.material(0xffffff, { vertexColors: true }));
82
99
  const mullions = kit.batch("Blue Cross · mullions", kit.material(0x2c2c2c));
83
100
  const bars = kit.batch("Blue Cross · band columns and emblems", kit.material(0xb4b4b4));
101
+ const infill = kit.batch("Blue Cross · band infill", kit.material(0xffffff, { vertexColors: true }));
102
+ const transoms = kit.batch("Blue Cross · transoms", kit.material(0xb4b4b4));
103
+ const aluminium = color(0xb4b4b4);
84
104
  const windows = [];
85
105
  let windowColumns = 0;
86
106
  const outline = (way) => orient(polygonOf(projectPlan(record.parts.find((part) => part.way === way).coordinates)));
@@ -130,10 +150,13 @@ export function createBlueCrossGeographicBuilding(record, projectPlan, offset) {
130
150
  // every row, and mullions one module apart on the office floors. Each unbroken stretch of
131
151
  // kept runs along a chain is one box, held 2 cm clear of its ends.
132
152
  const skin = (corners, lo, hi, seed, keep, lift = 0) => {
133
- const plan = planOf(corners), cuts = rowsBetween(lo, hi);
134
- const heights = [cuts[0].lo + lift, ...cuts.map((row) => row.hi)];
153
+ const plan = planOf(corners);
135
154
  const top = Math.min(hi, h.roof) - 0.05;
136
155
  chainsOf(plan).forEach((chain, index) => {
156
+ const shortFace = Math.abs(plan[chain.runs[0]].normal(0)[0]) > 0.9;
157
+ const sideGlazing = seed !== 0 || shortFace;
158
+ const cuts = sideGlazing ? glazedBandRows(rowsBetween(lo, hi)) : rowsBetween(lo, hi);
159
+ const heights = [cuts[0].lo + lift, ...cuts.map((row) => row.hi)];
137
160
  const count = Math.max(1, Math.round(chain.length / h.module));
138
161
  const stations = Array.from({ length: count + 1 }, (_, i) => chain.length * i / count);
139
162
  const messageFace = seed === 0 && plan[chain.runs[0]].normal(0)[1] > 0.9 && chain.length > 50;
@@ -160,6 +183,21 @@ export function createBlueCrossGeographicBuilding(record, projectPlan, offset) {
160
183
  return screen;
161
184
  return spandrel;
162
185
  }, spandrel);
186
+ if (sideGlazing) {
187
+ // Close each rail as a thin box following the mapped chain. Keep rails
188
+ // off the skin's top/bottom planes and ahead of its vertical mullions.
189
+ // Leave corner clearance for rails on adjoining/reentrant faces.
190
+ const railStart = s0 + transom.cornerClearance, railEnd = s1 - transom.cornerClearance;
191
+ const railK0 = chain.starts.findLastIndex((s) => s <= railStart);
192
+ const railK1 = chain.starts.findLastIndex((s) => s < railEnd);
193
+ for (const row of cuts.filter(({ kind }) => kind === "glass")) {
194
+ for (const [bottom, top] of [[row.lo - transom.height, row.lo], [row.hi, row.hi + transom.height]]) {
195
+ if (railEnd <= railStart || bottom <= heights[0] || top >= hi)
196
+ continue;
197
+ chainSkin(kit, transoms, plan, chain, railK0, railK1, railStart, railEnd, [], [bottom, top], transom.back, transom.front, () => aluminium, aluminium);
198
+ }
199
+ }
200
+ }
163
201
  // Mullions on each office run, whole on one run and held clear of its ends. Their
164
202
  // backs stand a centimetre off the wall: on the block's side walls the middle band's
165
203
  // mullions start just inside the end bays, whose closing walls lie in that plane and
@@ -197,20 +235,22 @@ export function createBlueCrossGeographicBuilding(record, projectPlan, offset) {
197
235
  skin(bay, h.lobbyTop, middle[0], i + 1, (run) => !lies(run, block));
198
236
  skin(bay, middle[1], h.wings, i + 1, (run) => !lies(run, block));
199
237
  });
200
- // An emblem: a plate standing 7 to 30 cm off the screen, cut to an outline given across and
238
+ // An emblem: a plate standing ahead of the screen infill, cut to an outline given across and
201
239
  // up from its centre. Its faces fan from the centre, which sees the whole outline, and its
202
240
  // sides follow the outline's edges.
203
241
  const emblem = (centre, t, n, y, shape) => {
242
+ // The corrected screen panels stand ahead of the old backing plane.
204
243
  const point = ([u, v], depth) => [centre[0] + t[0] * u + n[0] * depth, y + v, centre[1] + t[1] * u + n[1] * depth];
244
+ const backDepth = infillFront + emblemClearance, frontDepth = backDepth + 0.23;
205
245
  const out = [n[0], 0, n[1]], back = [-n[0], 0, -n[1]];
206
246
  shape.forEach((p, i) => {
207
247
  const q = shape[(i + 1) % shape.length];
208
- kit.triangle(bars, [point([0, 0], 0.3), point(p, 0.3), point(q, 0.3)], [out, out, out]);
209
- kit.triangle(bars, [point([0, 0], 0.07), point(q, 0.07), point(p, 0.07)], [back, back, back]);
248
+ kit.triangle(bars, [point([0, 0], frontDepth), point(p, frontDepth), point(q, frontDepth)], [out, out, out]);
249
+ kit.triangle(bars, [point([0, 0], backDepth), point(q, backDepth), point(p, backDepth)], [back, back, back]);
210
250
  // The edge's side faces away from the centre.
211
251
  const sign = Math.sign((q[1] - p[1]) * (p[0] + q[0]) - (q[0] - p[0]) * (p[1] + q[1])), length = Math.hypot(q[0] - p[0], q[1] - p[1]);
212
252
  const [across, up] = [sign * (q[1] - p[1]) / length, -sign * (q[0] - p[0]) / length];
213
- kit.quad(bars, [point(p, 0.07), point(q, 0.07), point(q, 0.3), point(p, 0.3)], [[t[0] * across, up, t[1] * across]]);
253
+ kit.quad(bars, [point(p, backDepth), point(q, backDepth), point(q, frontDepth), point(p, frontDepth)], [[t[0] * across, up, t[1] * across]]);
214
254
  });
215
255
  };
216
256
  // The company's emblems, 4.2 m tall: a cross, its arms a third of its span, and a shield,
@@ -218,8 +258,8 @@ export function createBlueCrossGeographicBuilding(record, projectPlan, offset) {
218
258
  const cross = [[0.7, 2.1], [0.7, 0.7], [2.1, 0.7], [2.1, -0.7], [0.7, -0.7], [0.7, -2.1],
219
259
  [-0.7, -2.1], [-0.7, -0.7], [-2.1, -0.7], [-2.1, 0.7], [-0.7, 0.7], [-0.7, 2.1]];
220
260
  const shield = [[-1.8, 2.1], [1.8, 2.1], [1.8, 0.3], [1.6, -0.7], [1.0, -1.5], [0, -2.1], [-1.0, -1.5], [-1.6, -0.7], [-1.8, 0.3]];
221
- // The block's columns, exposed in each band and in the screen on its long faces: eight
222
- // across each, one to a structural bay. The emblems stand in the screen's first two
261
+ // The block's columns, exposed in each band and in the screen on its long faces:
262
+ // eight south, six north. The emblems stand in the screen's first two south
223
263
  // bays at the south face's west end, the cross west of the shield.
224
264
  const faces = chainsOf(planOf(block)).filter(({ length }) => length > 50);
225
265
  faces.forEach((chain) => {
@@ -227,14 +267,60 @@ export function createBlueCrossGeographicBuilding(record, projectPlan, offset) {
227
267
  const a = run.at(0), b = last.at(last.length), n = run.normal(0), length = Math.hypot(b[0] - a[0], b[1] - a[1]);
228
268
  const t = [(b[0] - a[0]) / length, (b[1] - a[1]) / length];
229
269
  const at = (s) => [a[0] + t[0] * s, a[1] + t[1] * s];
230
- const pitch = length / 8;
231
- for (let i = 0; i < 8; i += 1) {
270
+ const slotCount = n[1] > 0.9 ? 8 : 6, pitch = length / slotCount;
271
+ for (let i = 0; i < slotCount; i += 1) {
232
272
  for (const [lo, hi] of [...h.bands, [h.roof + 0.4, h.top - 0.3]])
233
273
  kit.box(bars, at((i + 0.5) * pitch), n, 0.45, 0.07, 0.4, lo + 0.1, hi - 0.1);
234
274
  }
235
- // The south face runs west to east; the emblems stand in its first two bays.
236
- if (n[1] > 0.9)
237
- [cross, shield].forEach((shape, i) => emblem(at((i + 1) * pitch), t, n, h.roof + 6.7, shape));
275
+ if (Math.abs(n[1]) > 0.9) {
276
+ // FID-BCBS-002/005: the long elevations have narrow column slots,
277
+ // with curtain-wall panels between them, not an uninterrupted dark band.
278
+ // The northeast photograph resolves six on the north crown. The five
279
+ // internal north atrium bays alone do not establish facade spacing.
280
+ const panel = (target, s0, s1, levels, columns, back, front, paint, sides) => {
281
+ const k0 = chain.starts.findLastIndex((s) => s <= s0);
282
+ const k1 = chain.starts.findLastIndex((s) => s < s1);
283
+ chainSkin(kit, target, plan, chain, k0, k1, s0, s1, columns, levels, back, front, paint, sides);
284
+ };
285
+ // Two, three and two glazed rows in the mechanical bands; nine shallow
286
+ // courses in the crown. The 1.9 m slots and thin framing are photo ratios,
287
+ // not surveyed dimensions (docs/blue-cross-reference.md).
288
+ const bands = [
289
+ ...h.bands.map(([lo, hi], i) => [lo, hi, i === 1 ? 3 : 2, false]),
290
+ [h.roof, h.top, 9, true],
291
+ ];
292
+ for (const [lo, hi, count, crown] of bands) {
293
+ const levels = [lo + 0.02], paints = [];
294
+ const append = (height, paint) => { levels.push(height); paints.push(paint); };
295
+ append(lo + 0.18, aluminium);
296
+ for (let row = 1; row < count; row += 1) {
297
+ const y = lo + (hi - lo) * row / count;
298
+ append(y - (crown ? 0.08 : 0.55), crown ? screen : glassTones[1]);
299
+ if (!crown) {
300
+ append(y - 0.4, aluminium);
301
+ append(y + 0.4, spandrel);
302
+ }
303
+ append(y + (crown ? 0.08 : 0.55), aluminium);
304
+ }
305
+ append(hi - 0.18, crown ? screen : glassTones[1]);
306
+ append(hi - 0.02, aluminium);
307
+ for (let bay = 0; bay <= slotCount; bay += 1) {
308
+ const s0 = bay === 0 ? 0.02 : (bay - 0.5) * pitch + 0.95;
309
+ const s1 = bay === slotCount ? chain.length - 0.02 : (bay + 0.5) * pitch - 0.95;
310
+ panel(infill, s0, s1, levels, [s0, s0 + 0.12, s1 - 0.12, s1], 0.2, infillFront, (cell, row) => cell === 1 || cell === 3 ? aluminium : paints[row], aluminium);
311
+ }
312
+ }
313
+ // Pale rails frame the office ribbons. They sit just ahead of the vertical
314
+ // mullions and never cross the exposed column slots or celebration panes.
315
+ for (const row of rows.filter(({ kind }) => kind === "glass")) {
316
+ for (const [lo, hi] of [[row.lo - transom.height, row.lo], [row.hi, row.hi + transom.height]]) {
317
+ panel(transoms, 0.02, chain.length - 0.02, [lo, hi], [], transom.back, transom.front, () => aluminium, aluminium);
318
+ }
319
+ }
320
+ // The south face runs west to east; the emblems stand in its first two bays.
321
+ if (n[1] > 0.9)
322
+ [cross, shield].forEach((shape, i) => emblem(at((i + 1) * pitch), t, n, h.roof + 6.7, shape));
323
+ }
238
324
  });
239
325
  const model = kit.finish({ height: h.top, outlines: [shell, mullions], opacity: 0.16 });
240
326
  const glass = model.building.getObjectByName(wall.name);
@@ -113,10 +113,12 @@ export const colourMaterials = {
113
113
  "antenna mast": ["stainless"],
114
114
  },
115
115
  "building-blue-cross-blue-shield": {
116
- shell: ["glass", { 0x3a3a3a: "neutral" }],
117
- "glass, spandrels and bands": ["glass", { ...windows(0x8a8a8a, 0x5c5c5c), 0x2a2a2a: "neutral", 0x3e3e3e: "screen" }],
116
+ shell: ["glass", { 0x3a3a3a: "neutral", 0x383838: "opaque spandrel" }],
117
+ "glass, spandrels and bands": ["glass", { ...windows(0x8a8a8a, 0x5c5c5c), 0x383838: "opaque spandrel", 0x2a2a2a: "neutral", 0x3e3e3e: "screen" }],
118
118
  mullions: ["dark metal"],
119
119
  "band columns and emblems": ["band lights"],
120
+ "band infill": ["glass", { 0x383838: "opaque spandrel", 0x3e3e3e: "screen", 0xb4b4b4: "aluminium" }],
121
+ transoms: ["aluminium"],
120
122
  },
121
123
  // Blue-green glass, and the south face's white concrete frame.
122
124
  "building-340-on-the-park": {
@@ -271,7 +273,7 @@ export function batchOf(buildingId, meshName) {
271
273
  }
272
274
  // A grey's tone: the hex the model wrote it as, from its linear channel.
273
275
  export const toneOf = (grey) => new THREE.Color(grey, grey, grey).getHex();
274
- // Historical comparisons below predate sky reflections and FID-KEM-002/003.
276
+ // Historical comparisons below predate sky reflections, FID-KEM-002/003 and FID-BCBS-003.
275
277
  // The measured colours scale to the scene by this factor in linear light, so the skyline view's
276
278
  // faces show about the photograph's brightness under the day's lights. At 1.4 the skyline view's
277
279
  // 1600 x 900 render, its pixels chosen by each photograph sample's rule, reaches 0.97 (Two
@@ -62,8 +62,14 @@ export const measuredColours = {
62
62
  // docs/blue-cross-reference.md, 2026-10-09 — Daytime colours from Chicago.jpg and a close-up.
63
63
  "building-blue-cross-blue-shield": {
64
64
  ...glass(0x7d8992),
65
+ // FID-BCBS-003: the panorama mixes panes and opaque spandrels. Retain its
66
+ // measured hue provisionally for both, keeping their grey contrast and
67
+ // separate reflection masks through the shared glass swatch.
68
+ "opaque spandrel": "glass",
65
69
  screen: 0x334a60,
66
70
  "band lights": 0xb0bdcd,
71
+ // Inferred same pale metal as the columns; independent calibration is open.
72
+ aluminium: "band lights",
67
73
  "dark metal": null,
68
74
  neutral: null,
69
75
  },
@@ -16,7 +16,7 @@
16
16
  <span class="visually-hidden">Preparing the skyline…</span>
17
17
  <!-- skyline-loading:start -->
18
18
  <svg class="skyline-trace" viewBox="0 0 1000 390.01" aria-hidden="true" focusable="false">
19
- <defs><path id="skyline-loading-outline" pathLength="1" d="M0 390.01 L24 390.01 L24 326.71 L36.78 320.28 L76.29 320.28 L78.85 321.63 L79.08 320.54 L87.68 320.54 L87.92 316.21 L133.24 316.74 L133.47 317.75 L133.94 317.75 L134.17 316.74 L164.38 316.74 L164.85 317.75 L165.08 321.6 L181.12 321.6 L181.35 329.03 L189.48 329.03 L190.41 330.18 L207.85 330.18 L208.08 328.36 L208.54 328.36 L209.01 331.14 L212.26 338.16 L212.49 334.77 L213.89 334.77 L214.12 344.27 L216.21 344.27 L216.45 348.52 L234.57 348.52 L234.81 318.86 L238.53 318.86 L238.76 308.7 L242.48 308.7 L242.71 280.86 L244.57 280.86 L244.8 273.62 L245.03 272.91 L245.73 272.91 L245.96 272.02 L249.68 272.02 L249.91 273.17 L251.54 273.17 L251.77 272.02 L253.87 272.02 L254.1 273.17 L255.26 272.91 L255.49 272.02 L257.58 272.02 L257.82 272.91 L258.75 272.91 L258.98 280.86 L260.84 280.86 L261.07 318.86 L261.3 316.21 L263.63 316.21 L263.86 318.86 L265.02 318.86 L265.25 316.21 L268.04 316.21 L268.28 333.8 L272.69 333.8 L272.92 321.07 L274.55 321.07 L274.78 314 L288.96 314 L289.19 317.54 L300.58 317.54 L300.81 310.9 L311.51 310.9 L311.74 229.87 L323.13 229.87 L323.36 222.27 L330.8 222.27 L331.03 219.97 L349.39 220.16 L349.62 229.87 L352.41 229.87 L352.64 249.49 L382.16 249.49 L382.39 383.11 L386.81 383.11 L387.04 262.54 L406.57 233.23 L407.26 233.23 L410.05 252.75 L410.52 252.78 L410.75 233.23 L411.21 233.23 L439.11 263.31 L439.34 313.74 L444.22 313.74 L444.45 202.79 L513.71 202.79 L513.95 228.1 L528.36 228.1 L528.59 76.89 L548.34 76.8 L548.58 67.61 L561.59 67.52 L562.29 16 L563.68 16 L564.61 67.52 L571.82 67.61 L572.05 83.08 L572.98 83.08 L573.21 152.98 L579.49 143.92 L580.88 121.96 L582.04 143.91 L595.53 163.38 L598.55 173.04 L598.78 176.62 L600.41 176.62 L600.64 180.12 L602.03 180.12 L602.27 183.62 L603.66 183.62 L603.89 187.12 L605.52 187.12 L605.75 336.98 L606.22 379.83 L608.77 379.83 L609.01 89.53 L622.72 89.53 L622.95 83.96 L641.78 83.96 L642.01 69.64 L642.71 69.64 L642.94 83.96 L661.77 83.96 L662 89.53 L675.25 89.53 L675.48 202.65 L675.94 189.66 L748.46 189.66 L748.69 202.65 L755.43 202.65 L755.66 208.92 L800.75 208.92 L801.22 295.71 L806.33 295.71 L806.56 284.84 L818.42 284.84 L818.65 282.28 L837.48 282.28 L837.71 284.84 L848.17 284.84 L848.4 291.4 L848.87 291.4 L849.1 295.71 L854.91 295.71 L855.14 266.9 L894.19 266.9 L894.42 390.01 L937.42 390.01 L937.65 306.26 L939.51 306.26 L939.74 290.76 L953.69 290.76 L953.92 306.26 L958.57 306.26 L958.8 295.48 L974.14 295.48 L974.37 306.26 L976 306.26 L976 390.01 L1000 390.01" /></defs>
19
+ <defs><path id="skyline-loading-outline" pathLength="1" d="M0 390.01 L24 390.01 L24 326.71 L36.78 320.28 L76.29 320.28 L78.85 321.63 L79.08 320.54 L87.68 320.54 L87.92 316.21 L133.24 316.74 L133.47 317.75 L133.94 317.75 L134.17 316.74 L164.38 316.74 L164.85 317.75 L165.08 321.6 L181.12 321.6 L181.35 329.03 L189.48 329.03 L190.41 330.18 L207.85 330.18 L208.08 328.36 L208.54 328.36 L209.01 331.14 L212.26 338.16 L212.49 334.77 L213.89 334.77 L214.12 344.27 L216.21 344.27 L216.45 348.52 L234.57 348.52 L234.81 318.86 L238.53 318.86 L238.76 308.7 L242.48 308.7 L242.71 280.86 L244.57 280.86 L244.8 273.62 L245.03 272.91 L245.73 272.91 L245.96 272.02 L249.68 272.02 L249.91 273.17 L251.54 273.17 L251.77 272.02 L253.87 272.02 L254.1 273.17 L255.26 272.91 L255.49 272.02 L257.58 272.02 L257.82 272.91 L258.75 272.91 L258.98 280.86 L260.84 280.86 L261.07 318.86 L261.3 316.21 L263.63 316.21 L263.86 318.86 L265.02 318.86 L265.25 316.21 L268.04 316.21 L268.28 333.8 L272.69 333.8 L272.92 321.07 L274.55 321.07 L274.78 314 L288.96 314 L289.19 317.54 L300.58 317.54 L300.81 310.9 L311.51 310.9 L311.74 229.87 L323.13 229.87 L323.36 222.27 L330.8 222.27 L331.03 219.97 L349.39 220.16 L349.62 229.87 L352.41 229.87 L352.64 249.49 L382.16 249.49 L382.39 383.11 L386.81 383.11 L387.04 262.54 L406.57 233.23 L407.26 233.23 L410.05 252.75 L410.52 252.78 L410.75 233.23 L411.21 233.23 L439.11 263.31 L439.34 313.74 L444.22 313.74 L444.45 202.79 L513.71 202.79 L513.95 228.1 L528.36 228.1 L528.59 76.89 L548.34 76.8 L548.58 67.61 L561.59 67.52 L562.29 16 L563.68 16 L564.61 67.52 L571.82 67.61 L572.05 83.08 L572.98 83.08 L573.21 152.98 L579.49 143.92 L580.88 121.96 L582.04 143.91 L595.53 163.38 L598.55 173.04 L598.78 176.62 L600.41 176.62 L600.64 180.12 L602.03 180.12 L602.27 183.62 L603.66 183.62 L603.89 187.12 L605.52 187.12 L605.75 336.98 L606.22 379.83 L608.77 379.83 L609.01 89.53 L622.72 89.53 L622.95 83.96 L641.78 83.96 L642.01 69.64 L642.71 69.64 L642.94 83.96 L661.77 83.96 L662 89.53 L675.25 89.53 L675.48 202.65 L675.71 189.68 L748.46 189.66 L748.69 202.65 L755.43 202.65 L755.66 208.92 L800.75 208.92 L801.22 295.71 L806.33 295.71 L806.56 284.84 L818.42 284.84 L818.65 282.28 L837.48 282.28 L837.71 284.84 L848.17 284.84 L848.4 291.4 L848.87 291.4 L849.1 295.71 L854.91 295.71 L855.14 266.9 L894.19 266.9 L894.42 390.01 L937.42 390.01 L937.65 306.26 L939.51 306.26 L939.74 290.76 L953.69 290.76 L953.92 306.26 L958.57 306.26 L958.8 295.48 L974.14 295.48 L974.37 306.26 L976 306.26 L976 390.01 L1000 390.01" /></defs>
20
20
  <use class="skyline-trace-rail" href="#skyline-loading-outline" />
21
21
  <use class="skyline-trace-line" href="#skyline-loading-outline" />
22
22
  <use class="skyline-trace-tip" href="#skyline-loading-outline" />