@a2f0/skyline 0.2.31 → 0.2.33

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
@@ -382,9 +382,6 @@ Then, in the package's settings on npmjs.com, add a GitHub Actions trusted publi
382
382
  repository `a2f0/skyline`, workflow `npm-publish.yml`, and environment `npm`, and re-run
383
383
  the workflow; it reports the version as already published.
384
384
 
385
- The Sharp override in the tarball is repository development tooling policy; npm
386
- and Bun consumers use their own root overrides, so it does not pin their dependencies.
387
-
388
385
  The package is ESM only, with no CommonJS entrypoint. Building requires this repository's
389
386
  Bun/TypeScript tools; consuming the published package requires neither Bun nor build
390
387
  scripts. Metadata uses `UNLICENSED` and preserves the third-party notices in
@@ -4,9 +4,10 @@ import { aonLevels, buildAonTower } from "./aon-tower.js";
4
4
  // notched corners, the rooftop enclosure part 284775635, and the published heights. The
5
5
  // tower is the shared generator in aon-tower.ts, the same one the original layout's copy
6
6
  // uses. Each mapped face carries the source plan's fifteen window bays, distributed
7
- // over its chord; the mapped notches are deeper, so this spacing is approximate. The antenna stands
8
- // at the enclosure's centre, reaching the published 362.5 m tip; the map gives no antenna
9
- // geometry. See docs/aon-reference.md. Units are meters; +x is east, +z is south.
7
+ // uniformly between inset end piers; the mapped notches are deeper, so absolute spacing
8
+ // remains approximate. The antenna stands at the enclosure's centre, reaching the
9
+ // published 362.5 m tip; the map gives no antenna geometry. See docs/aon-reference.md.
10
+ // Units are meters; +x is east, +z is south.
10
11
  export const aonGeographicLevels = Object.freeze({
11
12
  shaftTop: aonLevels.roof, // OSM shaft part top
12
13
  enclosureTop: aonLevels.enclosureTop, // OSM part 284775635
@@ -27,7 +28,7 @@ export function createAonGeographicBuilding(record, projectPlan, offset) {
27
28
  cz += (z + z2) * cross;
28
29
  });
29
30
  const mast = [cx / (3 * twice), cz / (3 * twice)];
30
- const model = buildAonTower({ name: record.name, id: record.id, outline: polygonOf(projectPlan(record.footprint.coordinates)), enclosure, mast, base: 0 });
31
+ const model = buildAonTower({ name: record.name, id: record.id, outline: polygonOf(projectPlan(record.footprint.coordinates)), enclosure, mast, base: 0, uniformPierSpacing: true });
31
32
  model.building.position.set(offset[0], 0, offset[1]);
32
33
  model.building.userData["geography"] = record;
33
34
  model.building.userData["geographicDetail"] = { levels: h, floors: h.floors, source: "docs/aon-reference.md" };
@@ -6,6 +6,7 @@ export interface AonForm {
6
6
  enclosure: Vec2[];
7
7
  mast: Vec2;
8
8
  base: number;
9
+ uniformPierSpacing?: boolean;
9
10
  }
10
11
  export declare const aonLevels: Readonly<{
11
12
  roof: 340;
@@ -15,7 +15,7 @@ export const aonLevels = Object.freeze({
15
15
  head: 3.1,
16
16
  enclosureTop: 346.3,
17
17
  tip: 362.5,
18
- bay: 3.048, // nominal clean-plan module; mapped spacing follows each face's chord
18
+ bay: 3.048, // nominal clean-plan module
19
19
  baysPerFace: 15, // tenant portal's Suite 1300 plan: sixteen piers, fifteen openings
20
20
  faceLength: 20,
21
21
  });
@@ -60,13 +60,15 @@ export function buildAonTower(form) {
60
60
  paintedSlab(kit, shell, outline, 0, false, roofing);
61
61
  paintedSlab(kit, shell, outline, y(h.roof), true, roofing);
62
62
  // The source plan fixes fifteen openings per face, independent of an OSM trace's
63
- // corner depth. Distribute them over each face's chord; mapped spacing remains an
64
- // approximation (docs/aon-reference.md, FID-AON-001). End piers stay on their face.
63
+ // corner depth. In geographic mode distribute pier centres between the inset
64
+ // ends, so the terminal openings match the others. Absolute mapped spacing
65
+ // remains approximate (docs/aon-reference.md, FID-AON-002/003).
66
+ const pierHalf = 0.65, pierPoint = 0.7;
65
67
  const faceBays = (run) => {
66
- const count = h.baysPerFace, bay = run.length / count;
67
- return Array.from({ length: count + 1 }, (_, i) => i * bay);
68
+ const count = h.baysPerFace, inset = form.uniformPierSpacing ? pierHalf : 0;
69
+ const bay = (run.length - 2 * inset) / count;
70
+ return Array.from({ length: count + 1 }, (_, i) => inset + i * bay);
68
71
  };
69
- const pierHalf = 0.65, pierPoint = 0.7;
70
72
  const floors = [];
71
73
  for (let level = h.lobbyTop; level + h.pitch <= h.capBottom + 1e-6; level += h.pitch)
72
74
  floors.push(level);
@@ -242,7 +242,7 @@ export const colourMaterials = {
242
242
  // White concrete.
243
243
  "building-river-plaza": {
244
244
  shell: ["concrete"],
245
- "concrete and windows": ["glass", { ...windows(0x8a8a8a, 0x505050), 0x9a9a9a: "concrete" }],
245
+ "concrete and windows": ["glass", { ...windows(0x8a8a8a, 0x505050), 0x9a9a9a: "concrete", 0x424242: "aluminium" }],
246
246
  "rooftop box": ["concrete"],
247
247
  },
248
248
  // Dark orange brick and bronze glass.
@@ -230,6 +230,7 @@ export const measuredColours = {
230
230
  },
231
231
  "building-river-plaza": {
232
232
  concrete: 0xe5dfd3,
233
+ aluminium: null, // photographed frames; no separately calibrated sunny sample
233
234
  glass: null,
234
235
  "lit window": null,
235
236
  "dim window": null,
@@ -5,12 +5,19 @@ export declare const riverPlazaLevels: Readonly<{
5
5
  floors: 56;
6
6
  roof: 156;
7
7
  box: 159.7;
8
+ /** @deprecated Historical generic spacing, retained for package consumers; the model now uses counted groups. */
8
9
  bay: 1.6;
10
+ pier: 0.45;
11
+ sill: 0.35;
12
+ window: 2.1;
13
+ transom: 0.6;
14
+ frame: 0.06;
9
15
  }>;
10
16
  export declare const riverPlazaPalette: Readonly<{
11
17
  glass: number[];
12
18
  lit: 9079434;
13
19
  dim: 5263440;
14
20
  concrete: 10132122;
21
+ frame: 4342338;
15
22
  }>;
16
23
  export declare function createRiverPlazaGeographicBuilding(record: GeoBuilding, projectPlan: (coordinates: [number, number][]) => Plan, offset: [number, number]): BuildingModel;
@@ -17,17 +17,28 @@ export const riverPlazaLevels = Object.freeze({
17
17
  floors: 56,
18
18
  roof: 156, // the slab's roof, OpenStreetMap's
19
19
  box: 159.7, // the rooftop box's top, the published height and tip, the Skyscraper Center's 524 ft
20
- bay: 1.6, // the frame's bays, an estimate from photographs
20
+ /** @deprecated Historical generic spacing, retained for package consumers; the model now uses counted groups. */
21
+ bay: 1.6,
22
+ pier: 0.45, // schematic concrete width; group counts come from dated photographs
23
+ sill: 0.35, // inferred floor datum; photographed opening is about 75% of the pitch
24
+ window: 2.1,
25
+ transom: 0.6, // lower light about 30% of the opening; section width is schematic
26
+ frame: 0.06,
21
27
  });
28
+ // Starting at the south-east end, follow the mapped outline counter-clockwise.
29
+ // The long elevations have 3 / recess / 4 / recess / 3 groups; the ends have
30
+ // 2 / chamfer / 1 / chamfer / 2. Each outer corner is glazed as one group.
31
+ // See the 2007/2008/2012 counts in the reference audit; west-end symmetry is inferred.
32
+ const groupsPerChain = [2, 1, 1, 1, 2, 1, 3, 1, 4, 1, 3, 1, 2, 1, 1, 1, 2, 1, 3, 1, 4, 1, 3, 1];
22
33
  const h = riverPlazaLevels;
23
34
  const pitch = h.roof / h.floors;
24
35
  // The skins' colours: the window tones, and the concrete, which no window shares.
25
- export const riverPlazaPalette = Object.freeze({ glass: [0x2c2c2c, 0x303030, 0x343434, 0x282828], lit: 0x8a8a8a, dim: 0x505050, concrete: 0x9a9a9a });
36
+ export const riverPlazaPalette = Object.freeze({ glass: [0x2c2c2c, 0x303030, 0x343434, 0x282828], lit: 0x8a8a8a, dim: 0x505050, concrete: 0x9a9a9a, frame: 0x424242 });
26
37
  const palette = riverPlazaPalette;
27
38
  const color = (hex) => new THREE.Color(hex);
28
39
  const glassTones = palette.glass.map(color);
29
40
  const litGlass = color(palette.lit), dimGlass = color(palette.dim), concrete = color(palette.concrete);
30
- const core = color(0x6a6a6a), roofing = color(0x8a8a8a);
41
+ const core = color(0x6a6a6a), roofing = color(0x8a8a8a), frame = color(palette.frame);
31
42
  function paneColor(row, bay, wall) {
32
43
  const hash = (Math.imul(row + 19, 0x9e3779b1) ^ Math.imul(bay + 37, 0x85ebca77) ^ Math.imul(wall + 29, 0xc2b2ae3d)) >>> 0;
33
44
  const value = hash % 101;
@@ -55,8 +66,9 @@ export function createRiverPlazaGeographicBuilding(record, projectPlan, offset)
55
66
  // The box on the roof, on its mapped part.
56
67
  kit.prism(box, planOf(top), [h.roof, h.box]);
57
68
  // The slab's skin, one box to a run of a chain, held 2 cm clear of the chain's ends or 9 cm
58
- // where the outline turns in: on each floor, a window 1.1 m wide and 1.5 m tall in each
59
- // 1.6 m bay of the frame, from the chain's middle out. A run the podium stands against, its
69
+ // where the outline turns in: taller windows with a narrow lower transom follow
70
+ // dated photographs. Broad structural groups continue into the glazed
71
+ // recesses and corners. A run the podium stands against, its
60
72
  // two ends on the podium's outline, starts over the podium's roof; a chain whose runs differ
61
73
  // is skinned in parts, which meet at their joint.
62
74
  const plan = planOf(tower);
@@ -66,11 +78,17 @@ export function createRiverPlazaGeographicBuilding(record, projectPlan, offset)
66
78
  return Math.hypot(p[0] - a[0] - t * (b[0] - a[0]), p[1] - a[1] - t * (b[1] - a[1])) < 0.05;
67
79
  });
68
80
  const covered = (run) => onEdge(run.at(0)) && onEdge(run.at(run.length));
69
- chainsOf(plan).forEach((chain, index) => {
81
+ const chains = chainsOf(plan);
82
+ if (chains.length !== groupsPerChain.length)
83
+ throw new Error("River Plaza mapped facade chains changed; recheck photographed groups");
84
+ chains.forEach((chain, index) => {
70
85
  const first = chain.runs[0], last = chain.runs.at(-1);
71
86
  const s0 = turnsAt(plan, first)[0] > 0 ? 0.09 : 0.02, s1 = chain.length - (turnsAt(plan, last)[1] > 0 ? 0.09 : 0.02);
72
- const count = Math.floor(chain.length / 2 / h.bay);
73
- const centres = Array.from({ length: 2 * count }, (_, k) => chain.length / 2 + (k - count + 0.5) * h.bay);
87
+ const count = groupsPerChain[index];
88
+ const module = chain.length / count, halfWindow = (module - h.pier) / 2;
89
+ if (halfWindow <= 0)
90
+ throw new Error("River Plaza facade group is narrower than its concrete pier");
91
+ const centres = Array.from({ length: count }, (_, k) => (k + 0.5) * module);
74
92
  // The chain's parts, each a span of runs the podium covers alike.
75
93
  const parts = [];
76
94
  chain.runs.forEach((run, k) => { const part = parts.at(-1); if (part && covered(plan[run]) === covered(plan[chain.runs[part[0]]]))
@@ -82,18 +100,35 @@ export function createRiverPlazaGeographicBuilding(record, projectPlan, offset)
82
100
  const from = k0 === 0 ? s0 : chain.starts[k0], to = k1 === chain.runs.length - 1 ? s1 : chain.starts[k1] + plan[chain.runs[k1]].length;
83
101
  const heights = [base];
84
102
  for (let n = Math.ceil(base / pitch); n < h.floors; n += 1) {
85
- const sill = n * pitch + 0.75;
103
+ const sill = n * pitch + h.sill;
86
104
  if (sill > base + 0.2)
87
- heights.push(sill, sill + 1.5);
105
+ heights.push(sill, sill + h.window);
88
106
  }
89
107
  heights.push(h.roof);
90
- const cuts = centres.flatMap((c) => [c - 0.55, c + 0.55]).filter((s) => s > from + 0.01 && s < to - 0.01);
108
+ const cuts = centres.flatMap((c) => [c - halfWindow, c + halfWindow]).filter((s) => s > from + 0.01 && s < to - 0.01);
91
109
  const columns = [from, ...cuts, to];
92
110
  chainSkin(kit, wall, plan, chain, k0, k1, from, to, columns, heights, 0.02, 0.07, (bay, r) => {
93
111
  const middle = (columns[bay - 1] + columns[bay]) / 2;
94
- const glazed = r % 2 === 1 && centres.some((c) => Math.abs(middle - c) < 0.55);
112
+ const glazed = r % 2 === 1 && centres.some((c) => Math.abs(middle - c) < halfWindow);
95
113
  return glazed ? paneColor(r, bay, index) : concrete;
96
114
  }, concrete);
115
+ // A closed metal bar sits 5 mm clear of each full-height pane. Keeping
116
+ // the glass quad whole gives its upper and lower lights one night seed.
117
+ for (const centre of centres) {
118
+ const left = Math.max(from, centre - halfWindow), right = Math.min(to, centre + halfWindow);
119
+ if (right - left < 0.01)
120
+ continue;
121
+ const firstRun = chain.runs.findIndex((run, k) => k >= k0 && k <= k1 && chain.starts[k] + plan[run].length > left + 1e-6);
122
+ if (firstRun < 0)
123
+ throw new Error("River Plaza transom falls outside its facade section");
124
+ let lastRun = firstRun;
125
+ while (lastRun < k1 && chain.starts[lastRun + 1] < right - 1e-6)
126
+ lastRun += 1;
127
+ for (let row = 1; row + 1 < heights.length; row += 2) {
128
+ const lower = heights[row] + h.transom;
129
+ chainSkin(kit, wall, plan, chain, firstRun, lastRun, left, right, [left, right], [lower, lower + h.frame], 0.075, 0.09, () => frame, frame);
130
+ }
131
+ }
97
132
  }
98
133
  });
99
134
  const model = kit.finish({ height: h.box, outlines: [shell, box], opacity: 0.16 });
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@a2f0/skyline",
3
- "version": "0.2.31",
3
+ "version": "0.2.33",
4
4
  "description": "Interactive Chicago skyline viewer and reusable Three.js building models",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/a2f0/skyline.git"
9
9
  },
10
- "publishConfig": { "access": "public" },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
11
13
  "type": "module",
12
14
  "sideEffects": ["./site/**/*.js"],
13
15
  "files": ["lib/", "site/", "NOTICE.md", "docs/package.md", "docs/skyline-geography.md"],
@@ -37,15 +39,20 @@
37
39
  "./assets/*": "./site/*",
38
40
  "./package.json": "./package.json"
39
41
  },
40
- "engines": { "node": ">=22" },
41
- "peerDependencies": { "three": "~0.186.0" },
42
- "peerDependenciesMeta": { "three": { "optional": true } },
43
- "dependencies": { "@types/three": "0.186.0" },
44
- "overrides": {
45
- "miniflare": {
46
- "sharp": "0.35.5"
42
+ "engines": {
43
+ "node": ">=22"
44
+ },
45
+ "peerDependencies": {
46
+ "three": "~0.186.0"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "three": {
50
+ "optional": true
47
51
  }
48
52
  },
53
+ "dependencies": {
54
+ "@types/three": "0.186.0"
55
+ },
49
56
  "scripts": {
50
57
  "agent-tool": "agent-tool",
51
58
  "agent:run": "agent-tool run",
@@ -66,13 +73,13 @@
66
73
  "verify:deploy": "bun scripts/verify-deploy.ts"
67
74
  },
68
75
  "devDependencies": {
69
- "@a2f0/agent-tool": "0.1.11",
76
+ "@a2f0/agent-tool": "0.1.15",
70
77
  "@tsconfig/strictest": "^2.0.8",
71
- "@types/bun": "^1.4.2",
78
+ "@types/bun": "^1.4.3",
72
79
  "esbuild": "0.28.2",
73
80
  "three": "0.186.1",
74
- "playwright": "1.63.0",
81
+ "playwright": "1.64.1",
75
82
  "typescript": "^7.0.2",
76
- "wrangler": "4.148.0"
83
+ "wrangler": "4.151.1"
77
84
  }
78
85
  }
@@ -4,9 +4,10 @@ import { aonLevels, buildAonTower } from "./aon-tower.js";
4
4
  // notched corners, the rooftop enclosure part 284775635, and the published heights. The
5
5
  // tower is the shared generator in aon-tower.ts, the same one the original layout's copy
6
6
  // uses. Each mapped face carries the source plan's fifteen window bays, distributed
7
- // over its chord; the mapped notches are deeper, so this spacing is approximate. The antenna stands
8
- // at the enclosure's centre, reaching the published 362.5 m tip; the map gives no antenna
9
- // geometry. See docs/aon-reference.md. Units are meters; +x is east, +z is south.
7
+ // uniformly between inset end piers; the mapped notches are deeper, so absolute spacing
8
+ // remains approximate. The antenna stands at the enclosure's centre, reaching the
9
+ // published 362.5 m tip; the map gives no antenna geometry. See docs/aon-reference.md.
10
+ // Units are meters; +x is east, +z is south.
10
11
  export const aonGeographicLevels = Object.freeze({
11
12
  shaftTop: aonLevels.roof, // OSM shaft part top
12
13
  enclosureTop: aonLevels.enclosureTop, // OSM part 284775635
@@ -27,7 +28,7 @@ export function createAonGeographicBuilding(record, projectPlan, offset) {
27
28
  cz += (z + z2) * cross;
28
29
  });
29
30
  const mast = [cx / (3 * twice), cz / (3 * twice)];
30
- const model = buildAonTower({ name: record.name, id: record.id, outline: polygonOf(projectPlan(record.footprint.coordinates)), enclosure, mast, base: 0 });
31
+ const model = buildAonTower({ name: record.name, id: record.id, outline: polygonOf(projectPlan(record.footprint.coordinates)), enclosure, mast, base: 0, uniformPierSpacing: true });
31
32
  model.building.position.set(offset[0], 0, offset[1]);
32
33
  model.building.userData["geography"] = record;
33
34
  model.building.userData["geographicDetail"] = { levels: h, floors: h.floors, source: "docs/aon-reference.md" };
@@ -15,7 +15,7 @@ export const aonLevels = Object.freeze({
15
15
  head: 3.1,
16
16
  enclosureTop: 346.3,
17
17
  tip: 362.5,
18
- bay: 3.048, // nominal clean-plan module; mapped spacing follows each face's chord
18
+ bay: 3.048, // nominal clean-plan module
19
19
  baysPerFace: 15, // tenant portal's Suite 1300 plan: sixteen piers, fifteen openings
20
20
  faceLength: 20,
21
21
  });
@@ -60,13 +60,15 @@ export function buildAonTower(form) {
60
60
  paintedSlab(kit, shell, outline, 0, false, roofing);
61
61
  paintedSlab(kit, shell, outline, y(h.roof), true, roofing);
62
62
  // The source plan fixes fifteen openings per face, independent of an OSM trace's
63
- // corner depth. Distribute them over each face's chord; mapped spacing remains an
64
- // approximation (docs/aon-reference.md, FID-AON-001). End piers stay on their face.
63
+ // corner depth. In geographic mode distribute pier centres between the inset
64
+ // ends, so the terminal openings match the others. Absolute mapped spacing
65
+ // remains approximate (docs/aon-reference.md, FID-AON-002/003).
66
+ const pierHalf = 0.65, pierPoint = 0.7;
65
67
  const faceBays = (run) => {
66
- const count = h.baysPerFace, bay = run.length / count;
67
- return Array.from({ length: count + 1 }, (_, i) => i * bay);
68
+ const count = h.baysPerFace, inset = form.uniformPierSpacing ? pierHalf : 0;
69
+ const bay = (run.length - 2 * inset) / count;
70
+ return Array.from({ length: count + 1 }, (_, i) => inset + i * bay);
68
71
  };
69
- const pierHalf = 0.65, pierPoint = 0.7;
70
72
  const floors = [];
71
73
  for (let level = h.lobbyTop; level + h.pitch <= h.capBottom + 1e-6; level += h.pitch)
72
74
  floors.push(level);
@@ -242,7 +242,7 @@ export const colourMaterials = {
242
242
  // White concrete.
243
243
  "building-river-plaza": {
244
244
  shell: ["concrete"],
245
- "concrete and windows": ["glass", { ...windows(0x8a8a8a, 0x505050), 0x9a9a9a: "concrete" }],
245
+ "concrete and windows": ["glass", { ...windows(0x8a8a8a, 0x505050), 0x9a9a9a: "concrete", 0x424242: "aluminium" }],
246
246
  "rooftop box": ["concrete"],
247
247
  },
248
248
  // Dark orange brick and bronze glass.
@@ -230,6 +230,7 @@ export const measuredColours = {
230
230
  },
231
231
  "building-river-plaza": {
232
232
  concrete: 0xe5dfd3,
233
+ aluminium: null, // photographed frames; no separately calibrated sunny sample
233
234
  glass: null,
234
235
  "lit window": null,
235
236
  "dim window": null,
@@ -17,17 +17,28 @@ export const riverPlazaLevels = Object.freeze({
17
17
  floors: 56,
18
18
  roof: 156, // the slab's roof, OpenStreetMap's
19
19
  box: 159.7, // the rooftop box's top, the published height and tip, the Skyscraper Center's 524 ft
20
- bay: 1.6, // the frame's bays, an estimate from photographs
20
+ /** @deprecated Historical generic spacing, retained for package consumers; the model now uses counted groups. */
21
+ bay: 1.6,
22
+ pier: 0.45, // schematic concrete width; group counts come from dated photographs
23
+ sill: 0.35, // inferred floor datum; photographed opening is about 75% of the pitch
24
+ window: 2.1,
25
+ transom: 0.6, // lower light about 30% of the opening; section width is schematic
26
+ frame: 0.06,
21
27
  });
28
+ // Starting at the south-east end, follow the mapped outline counter-clockwise.
29
+ // The long elevations have 3 / recess / 4 / recess / 3 groups; the ends have
30
+ // 2 / chamfer / 1 / chamfer / 2. Each outer corner is glazed as one group.
31
+ // See the 2007/2008/2012 counts in the reference audit; west-end symmetry is inferred.
32
+ const groupsPerChain = [2, 1, 1, 1, 2, 1, 3, 1, 4, 1, 3, 1, 2, 1, 1, 1, 2, 1, 3, 1, 4, 1, 3, 1];
22
33
  const h = riverPlazaLevels;
23
34
  const pitch = h.roof / h.floors;
24
35
  // The skins' colours: the window tones, and the concrete, which no window shares.
25
- export const riverPlazaPalette = Object.freeze({ glass: [0x2c2c2c, 0x303030, 0x343434, 0x282828], lit: 0x8a8a8a, dim: 0x505050, concrete: 0x9a9a9a });
36
+ export const riverPlazaPalette = Object.freeze({ glass: [0x2c2c2c, 0x303030, 0x343434, 0x282828], lit: 0x8a8a8a, dim: 0x505050, concrete: 0x9a9a9a, frame: 0x424242 });
26
37
  const palette = riverPlazaPalette;
27
38
  const color = (hex) => new THREE.Color(hex);
28
39
  const glassTones = palette.glass.map(color);
29
40
  const litGlass = color(palette.lit), dimGlass = color(palette.dim), concrete = color(palette.concrete);
30
- const core = color(0x6a6a6a), roofing = color(0x8a8a8a);
41
+ const core = color(0x6a6a6a), roofing = color(0x8a8a8a), frame = color(palette.frame);
31
42
  function paneColor(row, bay, wall) {
32
43
  const hash = (Math.imul(row + 19, 0x9e3779b1) ^ Math.imul(bay + 37, 0x85ebca77) ^ Math.imul(wall + 29, 0xc2b2ae3d)) >>> 0;
33
44
  const value = hash % 101;
@@ -55,8 +66,9 @@ export function createRiverPlazaGeographicBuilding(record, projectPlan, offset)
55
66
  // The box on the roof, on its mapped part.
56
67
  kit.prism(box, planOf(top), [h.roof, h.box]);
57
68
  // The slab's skin, one box to a run of a chain, held 2 cm clear of the chain's ends or 9 cm
58
- // where the outline turns in: on each floor, a window 1.1 m wide and 1.5 m tall in each
59
- // 1.6 m bay of the frame, from the chain's middle out. A run the podium stands against, its
69
+ // where the outline turns in: taller windows with a narrow lower transom follow
70
+ // dated photographs. Broad structural groups continue into the glazed
71
+ // recesses and corners. A run the podium stands against, its
60
72
  // two ends on the podium's outline, starts over the podium's roof; a chain whose runs differ
61
73
  // is skinned in parts, which meet at their joint.
62
74
  const plan = planOf(tower);
@@ -66,11 +78,17 @@ export function createRiverPlazaGeographicBuilding(record, projectPlan, offset)
66
78
  return Math.hypot(p[0] - a[0] - t * (b[0] - a[0]), p[1] - a[1] - t * (b[1] - a[1])) < 0.05;
67
79
  });
68
80
  const covered = (run) => onEdge(run.at(0)) && onEdge(run.at(run.length));
69
- chainsOf(plan).forEach((chain, index) => {
81
+ const chains = chainsOf(plan);
82
+ if (chains.length !== groupsPerChain.length)
83
+ throw new Error("River Plaza mapped facade chains changed; recheck photographed groups");
84
+ chains.forEach((chain, index) => {
70
85
  const first = chain.runs[0], last = chain.runs.at(-1);
71
86
  const s0 = turnsAt(plan, first)[0] > 0 ? 0.09 : 0.02, s1 = chain.length - (turnsAt(plan, last)[1] > 0 ? 0.09 : 0.02);
72
- const count = Math.floor(chain.length / 2 / h.bay);
73
- const centres = Array.from({ length: 2 * count }, (_, k) => chain.length / 2 + (k - count + 0.5) * h.bay);
87
+ const count = groupsPerChain[index];
88
+ const module = chain.length / count, halfWindow = (module - h.pier) / 2;
89
+ if (halfWindow <= 0)
90
+ throw new Error("River Plaza facade group is narrower than its concrete pier");
91
+ const centres = Array.from({ length: count }, (_, k) => (k + 0.5) * module);
74
92
  // The chain's parts, each a span of runs the podium covers alike.
75
93
  const parts = [];
76
94
  chain.runs.forEach((run, k) => { const part = parts.at(-1); if (part && covered(plan[run]) === covered(plan[chain.runs[part[0]]]))
@@ -82,18 +100,35 @@ export function createRiverPlazaGeographicBuilding(record, projectPlan, offset)
82
100
  const from = k0 === 0 ? s0 : chain.starts[k0], to = k1 === chain.runs.length - 1 ? s1 : chain.starts[k1] + plan[chain.runs[k1]].length;
83
101
  const heights = [base];
84
102
  for (let n = Math.ceil(base / pitch); n < h.floors; n += 1) {
85
- const sill = n * pitch + 0.75;
103
+ const sill = n * pitch + h.sill;
86
104
  if (sill > base + 0.2)
87
- heights.push(sill, sill + 1.5);
105
+ heights.push(sill, sill + h.window);
88
106
  }
89
107
  heights.push(h.roof);
90
- const cuts = centres.flatMap((c) => [c - 0.55, c + 0.55]).filter((s) => s > from + 0.01 && s < to - 0.01);
108
+ const cuts = centres.flatMap((c) => [c - halfWindow, c + halfWindow]).filter((s) => s > from + 0.01 && s < to - 0.01);
91
109
  const columns = [from, ...cuts, to];
92
110
  chainSkin(kit, wall, plan, chain, k0, k1, from, to, columns, heights, 0.02, 0.07, (bay, r) => {
93
111
  const middle = (columns[bay - 1] + columns[bay]) / 2;
94
- const glazed = r % 2 === 1 && centres.some((c) => Math.abs(middle - c) < 0.55);
112
+ const glazed = r % 2 === 1 && centres.some((c) => Math.abs(middle - c) < halfWindow);
95
113
  return glazed ? paneColor(r, bay, index) : concrete;
96
114
  }, concrete);
115
+ // A closed metal bar sits 5 mm clear of each full-height pane. Keeping
116
+ // the glass quad whole gives its upper and lower lights one night seed.
117
+ for (const centre of centres) {
118
+ const left = Math.max(from, centre - halfWindow), right = Math.min(to, centre + halfWindow);
119
+ if (right - left < 0.01)
120
+ continue;
121
+ const firstRun = chain.runs.findIndex((run, k) => k >= k0 && k <= k1 && chain.starts[k] + plan[run].length > left + 1e-6);
122
+ if (firstRun < 0)
123
+ throw new Error("River Plaza transom falls outside its facade section");
124
+ let lastRun = firstRun;
125
+ while (lastRun < k1 && chain.starts[lastRun + 1] < right - 1e-6)
126
+ lastRun += 1;
127
+ for (let row = 1; row + 1 < heights.length; row += 2) {
128
+ const lower = heights[row] + h.transom;
129
+ chainSkin(kit, wall, plan, chain, firstRun, lastRun, left, right, [left, right], [lower, lower + h.frame], 0.075, 0.09, () => frame, frame);
130
+ }
131
+ }
97
132
  }
98
133
  });
99
134
  const model = kit.finish({ height: h.box, outlines: [shell, box], opacity: 0.16 });