@a2f0/skyline 0.2.19 → 0.2.22

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.
@@ -3,7 +3,7 @@
3
3
  const entry = document.currentScript.dataset["study"];
4
4
  function showStudyError(message) {
5
5
  document.querySelector("#loading").textContent = message;
6
- document.querySelectorAll("button").forEach((button) => { button.disabled = true; });
6
+ document.querySelectorAll("button, input").forEach((control) => { control.disabled = true; });
7
7
  }
8
8
  if (location.protocol === "file:") {
9
9
  const page = location.pathname.split("/").pop();
@@ -109,6 +109,7 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
109
109
  const night = { sun: [0xffffff, 2.4], ambient: [0xffffff, 0.7], fill: [0xffffff, 0.55] };
110
110
  const ambientLight = new THREE.AmbientLight(...night.ambient);
111
111
  scene.add(ambientLight);
112
+ let lightBounds;
112
113
  const keyLight = new THREE.DirectionalLight(...night.sun);
113
114
  keyLight.position.fromArray(lightPosition);
114
115
  keyLight.target.position.set(0, 80, 0);
@@ -489,6 +490,7 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
489
490
  const next = { ...original, ...layouts?.[name] };
490
491
  layout = name;
491
492
  ({ models, fit, target, platform, lightPosition, shadowCamera, clippingMargin } = next);
493
+ lightBounds = undefined;
492
494
  extras = next.extras || [];
493
495
  models.forEach((model) => { scene.add(model.building); model.setWireframe(wireframe); });
494
496
  extras.forEach((object) => scene.add(object));
@@ -501,6 +503,7 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
501
503
  base.position.set(platform.x || 0, -1.1, platform.z || 0);
502
504
  baseEdges.position.copy(base.position);
503
505
  keyLight.position.fromArray(lightPosition);
506
+ keyLight.target.position.set(0, 80, 0);
504
507
  Object.assign(keyLight.shadow.camera, shadowCamera);
505
508
  keyLight.shadow.camera.updateProjectionMatrix();
506
509
  const nextView = previousView && previousView !== defaultView ? previousView : next.defaultView || defaultView;
@@ -586,6 +589,8 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
586
589
  get groundShadows() { return base.receiveShadow; },
587
590
  get activeView() { return activeView; },
588
591
  get selectedBuilding() { return selectedModel?.building.userData["buildingId"] || null; },
592
+ get sunDirection() { return keyLight.position.clone().sub(keyLight.target.position).normalize().toArray(); },
593
+ get sunIntensity() { return keyLight.intensity; },
589
594
  get shadowBounds() {
590
595
  // Actual rendered geometry in the shadow camera's clip space, for coverage checks.
591
596
  const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
@@ -667,7 +672,29 @@ export function createBuildingStudy({ models, defaultView = "quarter", views = {
667
672
  Object.defineProperty(canvas, "__buildingStudy", { value: api, configurable: true });
668
673
  onReady();
669
674
  // Day or night: the transparent canvas shows the page's night behind it until a sky covers it.
675
+ // A sphere enclosing every caster and the platform fits the shadow camera at any
676
+ // solar azimuth/elevation. Cache it: dragging time never traverses model vertices.
670
677
  function setDaylight(day) {
678
+ if (day?.direction) {
679
+ if (!lightBounds) {
680
+ const min = new THREE.Vector3(Infinity, Infinity, Infinity), max = new THREE.Vector3(-Infinity, -Infinity, -Infinity);
681
+ for (const bounds of [api.platformBounds, ...api.modelBounds]) {
682
+ min.min(new THREE.Vector3().fromArray(bounds.min));
683
+ max.max(new THREE.Vector3().fromArray(bounds.max));
684
+ }
685
+ lightBounds = { center: min.clone().add(max).multiplyScalar(0.5), radius: max.distanceTo(min) / 2 };
686
+ }
687
+ const radius = lightBounds.radius * 1.02;
688
+ keyLight.target.position.copy(lightBounds.center);
689
+ keyLight.position.fromArray(day.direction).multiplyScalar(2 * radius + 1).add(lightBounds.center);
690
+ Object.assign(keyLight.shadow.camera, { left: -radius, right: radius, top: radius, bottom: -radius, near: 1, far: 4 * radius + 2 });
691
+ }
692
+ else {
693
+ keyLight.position.fromArray(lightPosition);
694
+ keyLight.target.position.set(0, 80, 0);
695
+ Object.assign(keyLight.shadow.camera, shadowCamera);
696
+ }
697
+ keyLight.shadow.camera.updateProjectionMatrix();
671
698
  scene.background = day?.sky ?? null;
672
699
  base.material.color.setHex(day?.ground ?? nightGround);
673
700
  for (const [light, [colour, intensity]] of [[keyLight, day?.sun ?? night.sun], [ambientLight, day?.ambient ?? night.ambient], [fillLight, day?.fill ?? night.fill]]) {
package/site/study.css CHANGED
@@ -162,6 +162,12 @@ body.skyline-3d { height: 100vh; height: 100dvh; }
162
162
  .control-bar .button-group { gap: 4px; }
163
163
  .control-bar .camera-views { grid-area: views; justify-self: start; --fold: inset(0 0 0 100%); }
164
164
  .control-bar .model-display { grid-area: display; justify-self: end; justify-content: end; --fold: inset(0 100% 0 0); }
165
+ .time-controls { display: grid; grid-template-columns: auto minmax(60px, 1fr) auto auto; align-items: center; gap: 8px; flex-basis: 100%; width: 100%; font-size: 10px; }
166
+ .time-controls label { color: #bbb; }
167
+ .time-controls #time-date { display: block; margin-top: 2px; color: #888; font-size: 9px; }
168
+ .time-controls input { width: 100%; min-width: 0; height: 28px; margin: 0; accent-color: #bbb; cursor: pointer; }
169
+ .time-controls output { min-width: 88px; text-align: right; color: #ddd; font-variant-numeric: tabular-nums; }
170
+ .time-controls button:disabled { opacity: 0.45; cursor: default; }
165
171
  .celebration-controls { display: flex; flex-wrap: wrap; align-items: center; justify-content: end; gap: 4px; flex-basis: 100%; }
166
172
  .celebration-caption { margin-right: 4px; color: #888; font-size: 10px; }
167
173
  .control-bar .celebration-controls button { display: grid; place-items: center; width: 36px; height: 36px; padding: 4px; }
@@ -179,8 +185,8 @@ body.skyline-3d { height: 100vh; height: 100dvh; }
179
185
  .control-bar .menu-toggle:hover { color: #fff; background: #ffffff14; }
180
186
  .menu-toggle svg { width: 20px; height: 20px; fill: currentColor; transition: rotate 0.35s cubic-bezier(0.2, 0.7, 0.2, 1); }
181
187
  .menu-toggle[aria-expanded="true"] svg { rotate: 30deg; }
182
- /* At desktop widths the badges share the display row, so opening the controls
183
- keeps the canvas height unchanged. Smaller screens retain 36px touch targets. */
188
+ /* Compact badges leave room for the time slider. Smaller screens retain 36px
189
+ touch targets. */
184
190
  @container skyline (min-width: 1440px) {
185
191
  .celebration-controls { flex-basis: auto; flex-wrap: nowrap; }
186
192
  .celebration-caption { display: none; }
@@ -203,6 +209,12 @@ body.skyline-3d { height: 100vh; height: 100dvh; }
203
209
  .skyline-trace-tip { display: none; animation: none; }
204
210
  }
205
211
  @container skyline (max-width: 600px) {
212
+ .time-controls { grid-template-columns: minmax(0, 1fr) auto; gap: 2px 8px; }
213
+ .time-controls label { grid-column: 1; grid-row: 1; }
214
+ .time-controls #time-date { display: inline; margin-left: 8px; }
215
+ .time-controls output { grid-column: 2; grid-row: 1; }
216
+ .time-controls input { grid-column: 1; grid-row: 2; height: 32px; }
217
+ .time-controls button { grid-column: 2; grid-row: 2; }
206
218
  .skyline-trace { stroke-width: 8.4; }
207
219
  .skyline-trace-tip { stroke-width: 12; }
208
220
  .skyline-3d .hint { font-size: 9px; }
@@ -0,0 +1,56 @@
1
+ import * as THREE from "./vendor/three-r186.js";
2
+ import { daylightGround, daylightIntensities } from "./models/colour-materials.js";
3
+ import { nightColours, timeOfDayColours } from "./models/colour-palette.js";
4
+ import { smooth, windowActivity } from "./models/night-windows.js";
5
+ import { chicagoHour, solarPosition } from "./solar-time.js";
6
+ // One tiny texture per viewer, updated in place while dragging and disposed at teardown.
7
+ // Transparent night sky reveals the viewer's existing stars behind opaque buildings.
8
+ export function createTimeLighting(latitude, longitude, transparentNight) {
9
+ const rows = 256, data = new Uint8Array(rows * 4);
10
+ const sky = new THREE.DataTexture(data, 1, rows);
11
+ sky.colorSpace = "srgb";
12
+ const a = new THREE.Color(), b = new THREE.Color(), pixel = new THREE.Color();
13
+ function blend(from, to, amount, coloured) {
14
+ a.setHex(from).lerp(b.setHex(to), amount);
15
+ if (!coloured) {
16
+ const grey = a.r * 0.2126 + a.g * 0.7152 + a.b * 0.0722;
17
+ a.setRGB(grey, grey, grey);
18
+ }
19
+ return a.getHex();
20
+ }
21
+ return {
22
+ sky,
23
+ update(date, coloured) {
24
+ const { elevation, direction } = solarPosition(date, latitude, longitude);
25
+ const upper = timeOfDayColours.findIndex((stop) => stop.elevation > elevation);
26
+ const last = timeOfDayColours.length - 1;
27
+ const low = timeOfDayColours[upper < 0 ? last : Math.max(0, upper - 1)];
28
+ const high = timeOfDayColours[upper < 0 ? last : upper];
29
+ const amount = high === low ? 0 : smooth(low.elevation, high.elevation, elevation);
30
+ const top = blend(low.sky, high.sky, amount, coloured);
31
+ const horizon = blend(low.horizon, high.horizon, amount, coloured);
32
+ const sun = blend(low.sun, high.sun, amount, coloured);
33
+ const day = smooth(-6, 18, elevation);
34
+ const opacity = transparentNight ? smooth(-18, -2, elevation) : 1;
35
+ const topColour = new THREE.Color(top), bottomColour = new THREE.Color(horizon);
36
+ for (let row = 0; row < rows; row += 1) {
37
+ const hex = pixel.copy(bottomColour).lerp(topColour, row / (rows - 1)).getHex();
38
+ // The canvas is composited with premultiplied alpha. Background textures write
39
+ // directly to it, so premultiply the final sRGB channels here as well.
40
+ data.set([Math.round(((hex >> 16) & 255) * opacity), Math.round(((hex >> 8) & 255) * opacity), Math.round((hex & 255) * opacity), Math.round(255 * opacity)], row * 4);
41
+ }
42
+ sky.needsUpdate = true;
43
+ const ground = blend(0x161616, daylightGround.blocks, day, false);
44
+ return {
45
+ sky, direction, ground,
46
+ roads: blend(0x2c2c2c, daylightGround.roads, day, false),
47
+ sun: [sun, daylightIntensities.sun * smooth(-0.833, 8, elevation)],
48
+ ambient: [blend(nightColours.ambient, top, day, coloured), 0.16 + day * (daylightIntensities.ambient - 0.16)],
49
+ fill: [blend(nightColours.fill, horizon, day, coloured), 0.08 + day * (daylightIntensities.fill - 0.08)],
50
+ reflections: { sky: top, horizon, ground: blend(0x080808, 0x444444, day, false) },
51
+ night: { darkness: 1 - smooth(-6, 3, elevation), activity: windowActivity(chicagoHour(date)), outlineStrength: 0.05 + 0.95 * day },
52
+ };
53
+ },
54
+ dispose() { sky.dispose(); },
55
+ };
56
+ }